[
  {
    "path": ".cargo/config.toml",
    "content": "[target.x86_64-apple-darwin]\nrustflags = [\n    \"-C\", \"link-arg=-framework\",\n    \"-C\", \"link-arg=AppKit\",\n    \"-C\", \"link-arg=-framework\",\n    \"-C\", \"link-arg=ApplicationServices\",\n    \"-C\", \"link-arg=-framework\",\n    \"-C\", \"link-arg=Foundation\",\n]\n\n[target.aarch64-apple-darwin]\nrustflags = [\n    \"-C\", \"link-arg=-framework\",\n    \"-C\", \"link-arg=AppKit\",\n    \"-C\", \"link-arg=-framework\",\n    \"-C\", \"link-arg=ApplicationServices\",\n    \"-C\", \"link-arg=-framework\",\n    \"-C\", \"link-arg=Foundation\",\n]\n\n[target.x86_64-pc-windows-msvc]\nrustflags = [\"-C\", \"link-arg=/DEBUG:FASTLINK\"]\n\n[target.aarch64-pc-windows-msvc]\nrustflags = [\"-C\", \"link-arg=/DEBUG:FASTLINK\"]\n"
  },
  {
    "path": ".dockerignore",
    "content": "# Node modules\nnode_modules/\n**/node_modules\n.pnpm-store/\n.pnpm/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Build artifacts\n**/target/\ndist/\nbuild/\n*.tgz\n*.tar.gz\nremote-frontend/dist/\n\n# IDE and editor files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS generated files\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\n\n# Git\n.git/\n.gitignore\n\n# Repo content not needed for Docker builds\n.github/\ndocs/\ndev_assets/\ndev_assets_seed/\n\n# Docker\nDockerfile*\n.dockerignore\n\n# Environment files\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# Logs\nlogs/\n*.log\n\n# Runtime data\npids/\n*.pid\n*.seed\n*.pid.lock\n\n# Coverage directory used by tools like istanbul\ncoverage/\n\n# Temporary folders\ntmp/\ntemp/\n"
  },
  {
    "path": ".github/actions/cargo-checks-common-setup/action.yml",
    "content": "name: 'Cargo checks common setup'\ndescription: 'Common setup for cargo checks in CI'\n\ninputs:\n  toolchain:\n    description: 'Rust toolchain version'\n    required: true\n  components:\n    description: 'Comma-separated rustup components'\n    required: false\n    default: ''\n  cache-key:\n    description: 'Shared key for rust-cache'\n    required: true\n  setup-node:\n    description: 'Whether to install Node.js and pnpm'\n    required: false\n    default: 'false'\n  setup-sqlx-cli:\n    description: 'Whether to install sqlx-cli'\n    required: false\n    default: 'false'\n\nruns:\n  using: 'composite'\n  steps:\n    - name: Setup Node\n      if: ${{ inputs.setup-node == 'true' }}\n      uses: ./.github/actions/setup-node\n\n    - name: Setup sccache\n      uses: BloopAI/sccache-action@main\n\n    - name: Install Rust toolchain\n      if: ${{ inputs.components == '' }}\n      uses: dtolnay/rust-toolchain@stable\n      with:\n        toolchain: ${{ inputs.toolchain }}\n\n    - name: Install Rust toolchain with components\n      if: ${{ inputs.components != '' }}\n      uses: dtolnay/rust-toolchain@stable\n      with:\n        toolchain: ${{ inputs.toolchain }}\n        components: ${{ inputs.components }}\n\n    - name: Cache Rust dependencies\n      uses: Swatinem/rust-cache@v2\n      env:\n        RUST_CACHE_DEBUG: true\n      with:\n        workspaces: \".\"\n        cache-provider: \"github\"\n        cache-on-failure: true\n        shared-key: ${{ inputs.cache-key }}\n        cache-all-crates: true\n\n    - name: Install sqlx-cli\n      if: ${{ inputs.setup-sqlx-cli == 'true' }}\n      uses: taiki-e/cache-cargo-install-action@34ce5120836e5f9f1508d8713d7fdea0e8facd6f # v3.0.1\n      with:\n        tool: sqlx-cli\n        no-default-features: true\n        features: sqlite,postgres\n"
  },
  {
    "path": ".github/actions/setup-jsign/action.yml",
    "content": "name: 'Setup Jsign'\ndescription: 'Downloads and caches Jsign for cross-platform Windows code signing'\n\ninputs:\n  version:\n    description: 'Jsign version'\n    required: false\n    default: '7.4'\n  sha256:\n    description: 'Expected SHA256 of the jar'\n    required: false\n    default: '2abf2ade9ea322acc2d60c24794eadc465ff9380938fca4c932d09e0b25f1c28'\n\noutputs:\n  jar-path:\n    description: 'Path to the Jsign jar'\n    value: ${{ steps.path.outputs.jar }}\n\nruns:\n  using: 'composite'\n  steps:\n    - name: Set jar path\n      id: path\n      shell: bash\n      run: echo \"jar=${{ runner.temp }}/jsign-${{ inputs.version }}.jar\" >> $GITHUB_OUTPUT\n\n    - name: Cache Jsign\n      id: cache\n      uses: actions/cache@v5\n      with:\n        path: ${{ steps.path.outputs.jar }}\n        key: jsign-${{ inputs.version }}-${{ inputs.sha256 }}\n\n    - name: Download Jsign\n      if: steps.cache.outputs.cache-hit != 'true'\n      shell: bash\n      run: |\n        curl -sL \"https://github.com/ebourg/jsign/releases/download/${{ inputs.version }}/jsign-${{ inputs.version }}.jar\" \\\n          -o \"${{ steps.path.outputs.jar }}\"\n\n    - name: Verify checksum\n      shell: bash\n      run: echo \"${{ inputs.sha256 }}  ${{ steps.path.outputs.jar }}\" | sha256sum -c -\n"
  },
  {
    "path": ".github/actions/setup-node/action.yml",
    "content": "name: 'Setup Node.js and pnpm'\ndescription: 'Sets up Node.js and pnpm with caching'\n\nruns:\n  using: 'composite'\n  steps:\n    - name: Setup Node.js\n      uses: actions/setup-node@v4\n      with:\n        node-version: ${{ env.NODE_VERSION }}\n        registry-url: 'https://registry.npmjs.org'\n\n    - name: Setup pnpm\n      uses: pnpm/action-setup@v4\n      with:\n        version: ${{ env.PNPM_VERSION }}\n\n    - name: Get pnpm store directory\n      shell: bash\n      run: |\n        echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n\n    - name: Setup pnpm cache\n      uses: actions/cache@v4\n      with:\n        path: ${{ env.STORE_PATH }}\n        key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n        restore-keys: |\n          ${{ runner.os }}-${{ runner.arch }}-pnpm-store-\n"
  },
  {
    "path": ".github/workflows/pre-release.yml",
    "content": "name: Create GitHub Pre-Release\n\non:\n  workflow_dispatch:\n    inputs:\n      version_type:\n        description: \"Version bump type\"\n        required: true\n        default: \"patch\"\n        type: choice\n        options:\n          - patch\n          - minor\n          - major\n          - prerelease\n\nconcurrency:\n  group: release-${{ github.ref_name }} # allow concurrent prerelease from different branches\n  cancel-in-progress: true\n\npermissions:\n  contents: write\n  packages: write\n  pull-requests: write\n\nenv:\n  NODE_VERSION: 22\n  PNPM_VERSION: 10.13.1\n  RUST_TOOLCHAIN: nightly-2025-12-04\n  CARGO_XWIN_VERSION: 0.20.2\n\njobs:\n  bump-version:\n    runs-on: blacksmith-4vcpu-ubuntu-2404\n    outputs:\n      new_tag: ${{ steps.version.outputs.new_tag }}\n      new_version: ${{ steps.version.outputs.new_version }}\n      branch_suffix: ${{ steps.branch.outputs.suffix }}\n    steps:\n      - name: Cache cargo-edit\n        uses: actions/cache@v5\n        id: cache-cargo-edit\n        with:\n          path: ~/.cargo/bin/cargo-set-version\n          key: cargo-edit-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}\n\n      - name: Install cargo-edit\n        if: steps.cache-cargo-edit.outputs.cache-hit != 'true'\n        run: cargo install cargo-edit\n\n      - uses: actions/checkout@v6\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          ssh-key: ${{ secrets.DEPLOY_KEY }}\n\n      - name: setup node\n        uses: ./.github/actions/setup-node\n\n      - name: Setup SSH Agent for private dependencies\n        id: ssh-setup\n        if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}\n        uses: webfactory/ssh-agent@v0.9.0\n        with:\n          ssh-private-key: ${{ secrets.VK_PRIVATE_DEPLOY_KEY }}\n\n      - name: Generate branch suffix\n        id: branch\n        run: |\n          branch_name=\"${{ github.ref_name }}\"\n          # Get last 6 characters of branch name, remove all special chars (including dashes)\n          suffix=$(echo \"$branch_name\" | tail -c 7 | sed 's/[^a-zA-Z0-9]//g' | tr '[:upper:]' '[:lower:]')\n          echo \"Branch: $branch_name\"\n          echo \"Suffix: $suffix\"\n          echo \"suffix=$suffix\" >> $GITHUB_OUTPUT\n\n      - name: Determine and update versions\n        id: version\n        run: |\n          # Get the latest version from npm registry\n          latest_npm_version=$(npm view vibe-kanban version 2>/dev/null || echo \"0.0.0\")\n          echo \"Latest npm version: $latest_npm_version\"\n\n          # Get current repo version\n          current_repo_version=$(node -p \"require('./package.json').version\")\n          echo \"Current repo version: $current_repo_version\"\n\n          # Use the higher of the two versions as the base (prevents downgrade errors with cargo set-version)\n          base_version=$(node -e \"\n            const npm = '$latest_npm_version'.split('.').map(Number);\n            const repo = '$current_repo_version'.split('.').map(Number);\n            for (let i = 0; i < 3; i++) {\n              if ((npm[i] || 0) > (repo[i] || 0)) { console.log('$latest_npm_version'); process.exit(); }\n              if ((npm[i] || 0) < (repo[i] || 0)) { console.log('$current_repo_version'); process.exit(); }\n            }\n            console.log('$current_repo_version');\n          \")\n          echo \"Base version for bump: $base_version\"\n\n          timestamp=$(date +%Y%m%d%H%M%S)\n\n          # Update root package.json based on base version\n          if [[ \"${{ github.event.inputs.version_type }}\" == \"prerelease\" ]]; then\n            # For prerelease, use current package.json version and add branch suffix\n            npm version prerelease --preid=\"${{ steps.branch.outputs.suffix }}\" --no-git-tag-version\n\n            new_version=$(node -p \"require('./package.json').version\")\n            new_tag=\"v${new_version}.${timestamp}\"\n          else\n            # For regular releases, use base version and bump it\n            npm version $base_version --no-git-tag-version --allow-same-version\n            npm version ${{ github.event.inputs.version_type }} --no-git-tag-version\n\n            new_version=$(node -p \"require('./package.json').version\")\n            new_tag=\"v${new_version}-${timestamp}\"\n          fi\n\n          # Update npx-cli package.json to match\n          (\n            cd npx-cli\n            npm version $new_version --no-git-tag-version --allow-same-version\n          )\n\n          # Update web app package.json to match\n          (\n            cd packages/local-web\n            npm version $new_version --no-git-tag-version --allow-same-version\n          )\n\n          cargo set-version --workspace \"$new_version\"\n\n          node -e \"\n            const fs = require('fs');\n            const path = 'crates/tauri-app/tauri.conf.json';\n            const conf = JSON.parse(fs.readFileSync(path, 'utf8'));\n            conf.version = '$new_version';\n            fs.writeFileSync(path, JSON.stringify(conf, null, 2) + '\\n');\n          \"\n\n          echo \"New version: $new_version\"\n          echo \"new_version=$new_version\" >> $GITHUB_OUTPUT\n          echo \"new_tag=$new_tag\" >> $GITHUB_OUTPUT\n\n      - name: Update remote crate lockfile\n        if: ${{ steps.ssh-setup.outcome == 'success' }}\n        run: cargo metadata --format-version 1 --manifest-path crates/remote/Cargo.toml > /dev/null\n\n      - name: Update relay-tunnel crate lockfile\n        run: cargo metadata --format-version 1 --manifest-path crates/relay-tunnel/Cargo.toml > /dev/null\n\n      - name: Stop SSH agent\n        if: ${{ steps.ssh-setup.outcome == 'success' }}\n        run: ssh-agent -k\n\n      - name: Commit changes and create tag\n        run: |\n          git config --local user.email \"action@github.com\"\n          git config --local user.name \"GitHub Action\"\n          git add package.json pnpm-lock.yaml npx-cli/package.json npx-cli/package-lock.json packages/local-web/package.json crates/tauri-app/tauri.conf.json Cargo.lock\n          git add $(find . -name Cargo.toml)\n          [ -f crates/remote/Cargo.lock ] && git add crates/remote/Cargo.lock || true\n          [ -f crates/relay-tunnel/Cargo.lock ] && git add crates/relay-tunnel/Cargo.lock || true\n          git commit -m \"chore: bump version to ${{ steps.version.outputs.new_version }}\"\n          git tag -a ${{ steps.version.outputs.new_tag }} -m \"Release ${{ steps.version.outputs.new_tag }}\"\n          git push\n          git push --tags\n\n  build-frontend:\n    needs: bump-version\n    runs-on: blacksmith-16vcpu-ubuntu-2404\n    env:\n      VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY: ${{ secrets.PUBLIC_REACT_VIRTUOSO_LICENSE_KEY }}\n      VITE_VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Setup Node\n        uses: ./.github/actions/setup-node\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Lint frontend\n        run: cd packages/local-web && npm run lint\n\n      - name: Type check frontend\n        run: cd packages/local-web && npx tsc --noEmit\n\n      - name: Build frontend\n        run: cd packages/local-web && npm run build\n        env:\n          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n          VITE_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}\n          VITE_POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }}\n          VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}\n\n      - name: Create Sentry release\n        uses: getsentry/action-release@v3\n        env:\n          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}\n          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}\n        with:\n          release: ${{ needs.bump-version.outputs.new_version }}\n          environment: production\n          sourcemaps: \"./packages/local-web/dist\"\n          ignore_missing: true\n\n      - name: Upload frontend artifact\n        uses: actions/upload-artifact@v6\n        with:\n          name: frontend-dist\n          path: packages/local-web/dist/\n          retention-days: 1\n\n  build-backend:\n    needs: [bump-version, build-frontend]\n    runs-on: ${{ matrix.os }}\n    strategy:\n      # Platform matrix - keep target/name in sync with package-npx-cli job\n      matrix:\n        include:\n          - target: x86_64-unknown-linux-musl\n            os: blacksmith-16vcpu-ubuntu-2404\n            name: linux-x64\n          - target: aarch64-unknown-linux-musl\n            os: blacksmith-16vcpu-ubuntu-2404-arm\n            name: linux-arm64\n          - target: x86_64-pc-windows-msvc\n            os: blacksmith-16vcpu-ubuntu-2404\n            name: windows-x64\n          - target: x86_64-apple-darwin\n            os: macos-15-xlarge\n            name: macos-x64\n          - target: aarch64-apple-darwin\n            os: macos-15-xlarge\n            name: macos-arm64\n          - target: aarch64-pc-windows-msvc\n            os: blacksmith-16vcpu-ubuntu-2404\n            name: windows-arm64\n    env:\n      CARGO_INCREMENTAL: \"0\"\n      SCCACHE_GHA_ENABLED: \"true\"\n      RUSTC_WRAPPER: \"sccache\"\n      SCCACHE_CACHE_SIZE: \"10G\"\n      CARGO_HOME: ${{ github.workspace }}/.cargo\n      RUSTUP_HOME: ${{ github.workspace }}/.rustup\n      XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Setup sccache\n        uses: BloopAI/sccache-action@main\n\n      - name: Cache Rust toolchain\n        uses: actions/cache@v5\n        with:\n          path: .rustup/toolchains\n          key: rust-toolchain-${{ runner.os }}-${{ matrix.target }}-${{ env.RUST_TOOLCHAIN }}\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          targets: ${{ matrix.target }}\n          components: rustfmt, clippy\n\n      - name: Install dependencies (Linux)\n        if: runner.os == 'Linux'\n        run: |\n          sudo apt-get update\n          DEBIAN_FRONTEND=noninteractive sudo apt-get install -y clang libclang-dev lld llvm nasm cmake ninja-build\n\n          if [[ \"${{ matrix.target }}\" == *\"windows\"* ]]; then\n            DEBIAN_FRONTEND=noninteractive sudo apt-get install -y clang-19 clang-tools-19 llvm-19 lld-19\n            echo \"/usr/lib/llvm-19/bin\" >> $GITHUB_PATH\n          fi\n\n      - name: Cache Cargo registry\n        uses: actions/cache@v5\n        with:\n          path: |\n            .cargo/registry/cache\n            .cargo/registry/index\n            .cargo/git/db\n            .cargo/bin\n            .cargo/.crates.toml\n            .cargo/.crates2.json\n          key: cargo-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}\n          restore-keys: |\n            cargo-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-\n\n      - name: Install Zig\n        if: runner.os == 'Linux' && !contains(matrix.target, 'windows')\n        uses: BloopAI/setup-zig@main\n        with:\n          version: 0.15.2\n\n      - name: Install cargo zigbuild\n        if: runner.os == 'Linux' && !contains(matrix.target, 'windows')\n        run: cargo install --locked cargo-zigbuild --version 0.20.1\n\n      - name: Install cargo xwin\n        if: runner.os == 'Linux' && contains(matrix.target, 'windows')\n        run: cargo install --locked cargo-xwin --version ${{ env.CARGO_XWIN_VERSION }}\n\n      - name: Cache xwin downloads\n        if: runner.os == 'Linux' && contains(matrix.target, 'windows')\n        uses: actions/cache@v5\n        with:\n          path: ${{ github.workspace }}/.xwin-cache\n          key: xwin-${{ runner.os }}-${{ matrix.target }}-cargo-xwin-${{ env.CARGO_XWIN_VERSION }}\n\n      - name: Cache target\n        uses: actions/cache@v5\n        with:\n          path: target\n          key: target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}-${{ github.sha }}\n          restore-keys: |\n            target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}-\n            target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-\n\n      - name: Setup cargo-sweep\n        shell: bash\n        run: |\n          cargo install --locked cargo-sweep --version 0.8.0\n\n      - name: Download frontend artifact\n        uses: actions/download-artifact@v7\n        with:\n          name: frontend-dist\n          path: packages/local-web/dist/\n\n      - name: Build backend (Linux)\n        if: runner.os == 'Linux' && !contains(matrix.target, 'windows')\n        run: |\n          cargo zigbuild --release --target ${{ matrix.target }} -p server -p mcp -p review --bin server --bin vibe-kanban-mcp --bin review\n        env:\n          POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}\n          POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }}\n          VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }}\n          VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }}\n          SENTRY_DSN: ${{ secrets.SENTRY_DSN }}\n\n      - name: Build backend (macOS)\n        if: runner.os == 'macOS'\n        run: |\n          if [[ \"${{ matrix.target }}\" == \"x86_64-apple-darwin\" ]]; then\n            export MACOSX_DEPLOYMENT_TARGET=10.12\n          elif [[ \"${{ matrix.target }}\" == \"aarch64-apple-darwin\" ]]; then\n            export MACOSX_DEPLOYMENT_TARGET=11.0\n          fi\n          cargo build --release --target ${{ matrix.target }} -p server -p mcp -p review --bin server --bin vibe-kanban-mcp --bin review\n        env:\n          POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}\n          POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }}\n          VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }}\n          VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }}\n          SENTRY_DSN: ${{ secrets.SENTRY_DSN }}\n\n      - name: Build backend (Windows)\n        if: runner.os == 'Linux' && contains(matrix.target, 'windows')\n        run: |\n          if [[ \"${{ matrix.target }}\" == \"aarch64-pc-windows-msvc\" ]]; then\n            # ring requires clang on arm64 windows. See https://github.com/briansmith/ring/issues/2117\n            chmod +x scripts/ring-cc-wrapper.sh scripts/clang\n            export PATH=\"${{ github.workspace }}/scripts:$PATH\"\n            export RING_CC=/usr/lib/llvm-19/bin/clang\n            export DEFAULT_CC=clang-cl\n            export CC_aarch64_pc_windows_msvc=\"${{ github.workspace }}/scripts/ring-cc-wrapper.sh\"\n          fi\n          cargo xwin build --cross-compiler clang-cl --release --target ${{ matrix.target }} -p server -p mcp -p review --bin server --bin vibe-kanban-mcp --bin review\n        env:\n          POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}\n          POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }}\n          VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }}\n          VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }}\n          SENTRY_DSN: ${{ secrets.SENTRY_DSN }}\n          # Avoid aws-lc-sys CMake failures when Rust's `release` profile includes debug info.\n          # Without this, cmake-rs selects RelWithDebInfo and CMake fails when ASM is enabled.\n          CARGO_PROFILE_RELEASE_DEBUG: 0\n\n      - name: Setup Sentry CLI\n        uses: matbour/setup-sentry-cli@v2\n        with:\n          token: ${{ secrets.SENTRY_AUTH_TOKEN }}\n          organization: ${{ secrets.SENTRY_ORG }}\n          project: ${{ secrets.SENTRY_PROJECT }}\n          version: 2.21.2\n\n      - name: Upload source maps to Sentry\n        run: sentry-cli debug-files upload --include-sources target/${{ matrix.target }}/release\n\n      - name: Prepare binaries (non-macOS)\n        if: runner.os != 'macOS'\n        shell: bash\n        run: |\n          mkdir -p dist\n          if [[ \"${{ matrix.name }}\" == *\"windows\"* ]]; then\n            cp target/${{ matrix.target }}/release/server.exe dist/vibe-kanban-${{ matrix.name }}.exe\n            cp target/${{ matrix.target }}/release/vibe-kanban-mcp.exe dist/vibe-kanban-mcp-${{ matrix.name }}.exe\n            cp target/${{ matrix.target }}/release/review.exe dist/vibe-kanban-review-${{ matrix.name }}.exe\n          else\n            cp target/${{ matrix.target }}/release/server dist/vibe-kanban-${{ matrix.name }}\n            cp target/${{ matrix.target }}/release/vibe-kanban-mcp dist/vibe-kanban-mcp-${{ matrix.name }}\n            cp target/${{ matrix.target }}/release/review dist/vibe-kanban-review-${{ matrix.name }}\n          fi\n\n      # Code signing for macOS only\n      - name: Prepare Apple certificate (macOS)\n        if: runner.os == 'macOS'\n        run: |\n          echo \"${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}\" | base64 --decode > certificate.p12\n\n      - name: Write API Key to file\n        if: runner.os == 'macOS'\n        env:\n          API_KEY: ${{ secrets.APP_STORE_API_KEY }}\n        run: echo $API_KEY > app_store_key.json\n\n      - name: Sign main binary (macOS)\n        if: runner.os == 'macOS'\n        uses: BloopAI/apple-code-sign-action@v1\n        with:\n          input_path: target/${{ matrix.target }}/release/server\n          output_path: vibe-kanban\n          p12_file: certificate.p12\n          p12_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          sign: true\n          sign_args: \"--code-signature-flags=runtime\"\n\n      - name: Package main binary (macOS)\n        if: runner.os == 'macOS'\n        run: zip vibe-kanban.zip vibe-kanban\n\n      - name: Sign MCP binary (macOS)\n        if: runner.os == 'macOS'\n        uses: BloopAI/apple-code-sign-action@v1\n        with:\n          input_path: target/${{ matrix.target }}/release/vibe-kanban-mcp\n          output_path: vibe-kanban-mcp\n          p12_file: certificate.p12\n          p12_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          sign: true\n          sign_args: \"--code-signature-flags=runtime\"\n\n      - name: Package MCP binary (macOS)\n        if: runner.os == 'macOS'\n        run: zip vibe-kanban-mcp.zip vibe-kanban-mcp\n\n      - name: Sign Review binary (macOS)\n        if: runner.os == 'macOS'\n        uses: BloopAI/apple-code-sign-action@v1\n        with:\n          input_path: target/${{ matrix.target }}/release/review\n          output_path: vibe-kanban-review\n          p12_file: certificate.p12\n          p12_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          sign: true\n          sign_args: \"--code-signature-flags=runtime\"\n\n      - name: Package Review binary (macOS)\n        if: runner.os == 'macOS'\n        run: zip vibe-kanban-review.zip vibe-kanban-review\n\n      - name: Notarize signed binaries (macOS)\n        if: runner.os == 'macOS'\n        uses: BloopAI/apple-code-sign-action@main\n        continue-on-error: true\n        with:\n          input_path: |\n            vibe-kanban.zip\n            vibe-kanban-mcp.zip\n            vibe-kanban-review.zip\n          sign: false\n          notarize: true\n          app_store_connect_api_key_json_file: app_store_key.json\n\n      - name: Prepare signed binaries (macOS)\n        if: runner.os == 'macOS'\n        run: |\n          mkdir -p dist\n          cp vibe-kanban.zip dist/vibe-kanban-${{ matrix.name }}.zip\n          cp vibe-kanban-mcp.zip dist/vibe-kanban-mcp-${{ matrix.name }}.zip\n          cp vibe-kanban-review.zip dist/vibe-kanban-review-${{ matrix.name }}.zip\n\n      - name: Clean up certificates (macOS)\n        if: runner.os == 'macOS'\n        run: |\n          rm -f certificate.p12\n          rm -rf private_keys/\n\n      - name: Upload binary artifact\n        uses: actions/upload-artifact@v6\n        with:\n          name: backend-binary-${{ matrix.name }}\n          path: dist/\n          retention-days: 1\n\n      - name: Sweep Cargo target cache\n        shell: bash\n        run: |\n          cargo sweep --maxsize 10GB\n          cargo sweep --time 30\n\n\n  package-npx-cli:\n    needs: [bump-version, build-frontend, build-backend]\n    runs-on: blacksmith-4vcpu-ubuntu-2404\n    strategy:\n      # NOTE: This matrix must be kept in sync with build-backend job above\n      # GitHub Actions doesn't support YAML anchors, so duplication is unavoidable\n      matrix:\n        include:\n          - target: x86_64-unknown-linux-musl\n            name: linux-x64\n            binary: vibe-kanban\n            mcp_binary: vibe-kanban-mcp\n            review_binary: vibe-kanban-review\n          - target: x86_64-pc-windows-msvc\n            name: windows-x64\n            binary: vibe-kanban.exe\n            mcp_binary: vibe-kanban-mcp.exe\n            review_binary: vibe-kanban-review.exe\n          - target: x86_64-apple-darwin\n            name: macos-x64\n            binary: vibe-kanban\n            mcp_binary: vibe-kanban-mcp\n            review_binary: vibe-kanban-review\n          - target: aarch64-apple-darwin\n            name: macos-arm64\n            binary: vibe-kanban\n            mcp_binary: vibe-kanban-mcp\n            review_binary: vibe-kanban-review\n          - target: aarch64-pc-windows-msvc\n            name: windows-arm64\n            binary: vibe-kanban.exe\n            mcp_binary: vibe-kanban-mcp.exe\n            review_binary: vibe-kanban-review.exe\n          - target: aarch64-unknown-linux-musl\n            name: linux-arm64\n            binary: vibe-kanban\n            mcp_binary: vibe-kanban-mcp\n            review_binary: vibe-kanban-review\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Download frontend artifact\n        uses: actions/download-artifact@v7\n        with:\n          name: frontend-dist\n          path: packages/local-web/dist/\n\n      - name: Download backend binary artifact\n        uses: actions/download-artifact@v7\n        with:\n          name: backend-binary-${{ matrix.name }}\n          path: dist/\n\n      - name: List downloaded artifacts\n        run: |\n          echo \"Downloaded backend binaries:\"\n          find dist/\n\n      - name: Create platform package\n        if: matrix.name != 'macos-arm64' && matrix.name != 'macos-x64'\n        run: |\n          mkdir -p npx-cli/dist/${{ matrix.name }}\n          mkdir vibe-kanban-${{ matrix.name }}\n          mkdir vibe-kanban-mcp-${{ matrix.name }}\n          mkdir vibe-kanban-review-${{ matrix.name }}\n\n          cp dist/vibe-kanban-${{ matrix.name }}* vibe-kanban-${{ matrix.name }}/${{ matrix.binary }}\n          cp dist/vibe-kanban-mcp-${{ matrix.name }}* vibe-kanban-mcp-${{ matrix.name }}/${{ matrix.mcp_binary }}\n          cp dist/vibe-kanban-review-${{ matrix.name }}* vibe-kanban-review-${{ matrix.name }}/${{ matrix.review_binary }}\n\n          zip -j npx-cli/dist/${{ matrix.name }}/vibe-kanban.zip vibe-kanban-${{ matrix.name }}/${{ matrix.binary }}\n          zip -j npx-cli/dist/${{ matrix.name }}/vibe-kanban-mcp.zip vibe-kanban-mcp-${{ matrix.name }}/${{ matrix.mcp_binary }}\n          zip -j npx-cli/dist/${{ matrix.name }}/vibe-kanban-review.zip vibe-kanban-review-${{ matrix.name }}/${{ matrix.review_binary }}\n\n      - name: Create platform package (macOS)\n        if: matrix.name == 'macos-arm64' || matrix.name == 'macos-x64'\n        run: |\n          mkdir -p npx-cli/dist/${{ matrix.name }}\n          mkdir vibe-kanban-${{ matrix.name }}\n          cp dist/vibe-kanban-${{ matrix.name }}* npx-cli/dist/${{ matrix.name }}/vibe-kanban.zip\n          cp dist/vibe-kanban-mcp-${{ matrix.name }}* npx-cli/dist/${{ matrix.name }}/vibe-kanban-mcp.zip\n          cp dist/vibe-kanban-review-${{ matrix.name }}* npx-cli/dist/${{ matrix.name }}/vibe-kanban-review.zip\n\n      - name: Upload platform package artifact\n        uses: actions/upload-artifact@v6\n        with:\n          name: npx-platform-${{ matrix.name }}\n          path: npx-cli/dist/\n          retention-days: 1\n\n  upload-to-r2:\n    needs: [bump-version, package-npx-cli]\n    runs-on: blacksmith-4vcpu-ubuntu-2404\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Download all platform packages\n        uses: actions/download-artifact@v7\n        with:\n          pattern: npx-platform-*\n          path: binaries/\n          merge-multiple: true\n\n      - name: List downloaded binaries\n        run: |\n          echo \"Downloaded binaries:\"\n          find binaries/\n\n      - name: Configure AWS CLI for R2\n        run: |\n          aws configure set aws_access_key_id ${{ secrets.R2_BINARIES_ACCESS_KEY_ID }}\n          aws configure set aws_secret_access_key ${{ secrets.R2_BINARIES_SECRET_ACCESS_KEY }}\n          aws configure set default.region auto\n\n      - name: Generate manifest and upload to R2\n        run: |\n          TAG=\"${{ needs.bump-version.outputs.new_tag }}\"\n          ENDPOINT=\"${{ secrets.R2_BINARIES_ENDPOINT }}\"\n          BUCKET=\"${{ secrets.R2_BINARIES_BUCKET }}\"\n\n          # Generate version manifest with checksums\n          node -e \"\n            const fs = require('fs');\n            const crypto = require('crypto');\n            const manifest = { version: '$TAG', platforms: {} };\n            const platforms = ['linux-x64', 'linux-arm64', 'windows-x64', 'windows-arm64', 'macos-x64', 'macos-arm64'];\n            const binaries = ['vibe-kanban', 'vibe-kanban-mcp', 'vibe-kanban-review'];\n\n            for (const platform of platforms) {\n              manifest.platforms[platform] = {};\n              for (const binary of binaries) {\n                const zipPath = \\`binaries/\\${platform}/\\${binary}.zip\\`;\n                if (fs.existsSync(zipPath)) {\n                  const data = fs.readFileSync(zipPath);\n                  manifest.platforms[platform][binary] = {\n                    sha256: crypto.createHash('sha256').update(data).digest('hex'),\n                    size: data.length\n                  };\n                }\n              }\n            }\n            fs.writeFileSync('version-manifest.json', JSON.stringify(manifest, null, 2));\n            console.log('Generated manifest:');\n            console.log(JSON.stringify(manifest, null, 2));\n          \"\n\n          # Upload binaries (use full tag for path, allows multiple pre-releases to coexist)\n          for platform in linux-x64 linux-arm64 windows-x64 windows-arm64 macos-x64 macos-arm64; do\n            for binary in vibe-kanban vibe-kanban-mcp vibe-kanban-review; do\n              if [ -f \"binaries/$platform/$binary.zip\" ]; then\n                echo \"Uploading binaries/$platform/$binary.zip...\"\n                aws s3 cp \"binaries/$platform/$binary.zip\" \\\n                  \"s3://$BUCKET/binaries/$TAG/$platform/$binary.zip\" \\\n                  --endpoint-url \"$ENDPOINT\"\n              fi\n            done\n          done\n\n          # Upload version manifest\n          echo \"Uploading version manifest...\"\n          aws s3 cp version-manifest.json \\\n            \"s3://$BUCKET/binaries/$TAG/manifest.json\" \\\n            --endpoint-url \"$ENDPOINT\" --content-type \"application/json\"\n\n          # Update global manifest\n          VERSION=\"${{ needs.bump-version.outputs.new_version }}\"\n          echo \"Updating global manifest...\"\n          echo \"{\\\"latest\\\": \\\"$VERSION\\\"}\" | aws s3 cp - \\\n            \"s3://$BUCKET/binaries/manifest.json\" \\\n            --endpoint-url \"$ENDPOINT\" --content-type \"application/json\"\n\n      - name: Verify upload\n        run: |\n          TAG=\"${{ needs.bump-version.outputs.new_tag }}\"\n          ENDPOINT=\"${{ secrets.R2_BINARIES_ENDPOINT }}\"\n          BUCKET=\"${{ secrets.R2_BINARIES_BUCKET }}\"\n\n          echo \"Listing uploaded files...\"\n          aws s3 ls \"s3://$BUCKET/binaries/$TAG/\" \\\n            --endpoint-url \"$ENDPOINT\" \\\n            --recursive\n\n  build-tauri:\n    needs: [bump-version, build-frontend]\n    runs-on: ${{ matrix.os }}\n    defaults:\n      run:\n        shell: bash\n    strategy:\n      matrix:\n        include:\n          - target: aarch64-apple-darwin\n            os: macos-15-xlarge\n            platform: darwin-aarch64\n          - target: x86_64-apple-darwin\n            os: macos-15-xlarge\n            platform: darwin-x86_64\n          - target: x86_64-unknown-linux-gnu\n            os: blacksmith-16vcpu-ubuntu-2404\n            platform: linux-x86_64\n          - target: aarch64-unknown-linux-gnu\n            os: blacksmith-16vcpu-ubuntu-2404-arm\n            platform: linux-aarch64\n          - target: x86_64-pc-windows-msvc\n            os: blacksmith-16vcpu-ubuntu-2404\n            platform: windows-x86_64\n          - target: aarch64-pc-windows-msvc\n            os: blacksmith-16vcpu-ubuntu-2404\n            platform: windows-aarch64\n    env:\n      CARGO_INCREMENTAL: \"0\"\n      SCCACHE_GHA_ENABLED: \"true\"\n      RUSTC_WRAPPER: \"sccache\"\n      SCCACHE_CACHE_SIZE: \"10G\"\n      CARGO_HOME: ${{ github.workspace }}/.cargo\n      RUSTUP_HOME: ${{ github.workspace }}/.rustup\n      XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Setup sccache\n        uses: BloopAI/sccache-action@main\n\n      - name: Cache Rust toolchain\n        uses: actions/cache@v5\n        with:\n          path: .rustup/toolchains\n          key: rust-toolchain-${{ runner.os }}-${{ matrix.target }}-${{ env.RUST_TOOLCHAIN }}\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          targets: ${{ matrix.target }}\n\n      - name: Cache Cargo registry\n        uses: actions/cache@v5\n        with:\n          path: |\n            .cargo/registry/cache\n            .cargo/registry/index\n            .cargo/git/db\n            .cargo/bin\n            .cargo/.crates.toml\n            .cargo/.crates2.json\n          key: cargo-tauri-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}\n          restore-keys: |\n            cargo-tauri-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-\n\n      - name: Cache target\n        uses: actions/cache@v5\n        with:\n          path: target\n          key: tauri-target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}-${{ github.sha }}\n          restore-keys: |\n            tauri-target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}-\n            tauri-target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-\n\n      - name: Setup cargo-sweep\n        run: cargo install --locked cargo-sweep --version 0.8.0\n\n      - name: Setup Node\n        uses: ./.github/actions/setup-node\n\n      - name: Install Linux dependencies\n        if: runner.os == 'Linux' && contains(matrix.target, 'linux')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential \\\n            libssl-dev libayatana-appindicator3-dev librsvg2-dev \\\n            libxdo-dev file pkg-config xdg-utils\n\n      - name: Install Windows cross-compilation dependencies\n        if: runner.os == 'Linux' && contains(matrix.target, 'windows')\n        run: |\n          sudo apt-get update\n          DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \\\n            clang libclang-dev lld llvm nasm cmake ninja-build nsis \\\n            clang-19 clang-tools-19 llvm-19 lld-19 \\\n            meson valac bison gobject-introspection libgirepository1.0-dev \\\n            libglib2.0-dev libgsf-1-dev libgcab-dev libmsi-dev\n          echo \"/usr/lib/llvm-19/bin\" >> $GITHUB_PATH\n\n      - name: Build and install wixl\n        if: runner.os == 'Linux' && contains(matrix.target, 'windows')\n        env:\n          RUSTC_WRAPPER: \"\"\n        run: |\n          cd /tmp\n          curl -sL https://download.gnome.org/sources/msitools/0.103/msitools-0.103.tar.xz | tar xJ\n          cd msitools-0.103\n          meson setup builddir --prefix=/usr\n          meson compile -C builddir wixl\n          sudo install -m755 builddir/tools/wixl/wixl /usr/local/bin/wixl\n          sudo install -m644 builddir/libmsi/libmsi-1.0.so.0.0.0 /usr/local/lib/\n          sudo ln -sf libmsi-1.0.so.0.0.0 /usr/local/lib/libmsi-1.0.so.0\n          sudo ln -sf libmsi-1.0.so.0 /usr/local/lib/libmsi-1.0.so\n          sudo ldconfig\n          sudo mkdir -p /usr/share/wixl-0.103/ext\n          sudo cp -r data/ext/ui /usr/share/wixl-0.103/ext/\n          cd / && rm -rf /tmp/msitools-0.103\n          wixl --version\n\n      - name: Install cargo-xwin\n        if: runner.os == 'Linux' && contains(matrix.target, 'windows')\n        run: cargo install --locked cargo-xwin --version ${{ env.CARGO_XWIN_VERSION }}\n\n      - name: Cache xwin downloads\n        if: runner.os == 'Linux' && contains(matrix.target, 'windows')\n        uses: actions/cache@v5\n        with:\n          path: ${{ github.workspace }}/.xwin-cache\n          key: xwin-${{ runner.os }}-${{ matrix.target }}-cargo-xwin-${{ env.CARGO_XWIN_VERSION }}\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Install Tauri CLI\n        uses: taiki-e/cache-cargo-install-action@34ce5120836e5f9f1508d8713d7fdea0e8facd6f # v3.0.1\n        with:\n          tool: tauri-cli@2\n\n      - name: Download frontend artifact\n        uses: actions/download-artifact@v7\n        with:\n          name: frontend-dist\n          path: packages/local-web/dist/\n\n      - name: Patch tauri.conf.json for CI\n        run: |\n          node -e \"\n            const fs = require('fs');\n            const conf = JSON.parse(fs.readFileSync('crates/tauri-app/tauri.conf.json', 'utf8'));\n            // Inject the real updater endpoint (replaces __TAURI_UPDATE_ENDPOINT__ placeholder)\n            const endpoint = '${{ secrets.R2_BINARIES_PUBLIC_URL }}/binaries/tauri-update/latest.json';\n            conf.plugins.updater.endpoints = conf.plugins.updater.endpoints.map(e =>\n              e === '__TAURI_UPDATE_ENDPOINT__' ? endpoint : e\n            );\n            fs.writeFileSync('crates/tauri-app/tauri.conf.json', JSON.stringify(conf, null, 2) + '\\n');\n          \"\n\n      - name: Set up Apple notarization key\n        if: runner.os == 'macOS'\n        env:\n          API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}\n          API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}\n          API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }}\n        run: |\n          # Write the .p8 key file where Tauri/notarytool can find it\n          mkdir -p ~/.private_keys\n          KEY_PATH=\"$HOME/.private_keys/AuthKey_${API_KEY_ID}.p8\"\n          printf '%s' \"$API_PRIVATE_KEY\" > \"$KEY_PATH\"\n\n          echo \"APPLE_API_KEY=$API_KEY_ID\" >> $GITHUB_ENV\n          echo \"APPLE_API_ISSUER=$API_ISSUER\" >> $GITHUB_ENV\n          echo \"APPLE_API_KEY_PATH=$KEY_PATH\" >> $GITHUB_ENV\n\n      - name: Build Tauri app\n        run: |\n          if [[ \"${{ matrix.target }}\" == \"aarch64-pc-windows-msvc\" ]]; then\n            # ring requires clang on arm64 windows cross-compile\n            chmod +x scripts/ring-cc-wrapper.sh scripts/clang\n            export PATH=\"${{ github.workspace }}/scripts:$PATH\"\n            export RING_CC=/usr/lib/llvm-19/bin/clang\n            export DEFAULT_CC=clang-cl\n            export CC_aarch64_pc_windows_msvc=\"${{ github.workspace }}/scripts/ring-cc-wrapper.sh\"\n            export CARGO_PROFILE_RELEASE_DEBUG=0\n          fi\n\n          if [[ \"${{ matrix.target }}\" == *\"windows\"* ]]; then\n            cargo tauri build --runner cargo-xwin --target ${{ matrix.target }} --ci\n          else\n            cargo tauri build --target ${{ matrix.target }} --ci\n          fi\n        env:\n          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}\n          APPLE_API_KEY: ${{ env.APPLE_API_KEY }}\n          APPLE_API_ISSUER: ${{ env.APPLE_API_ISSUER }}\n          APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }}\n          POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}\n          POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }}\n          VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }}\n          VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }}\n          SENTRY_DSN: ${{ secrets.SENTRY_DSN }}\n\n      - name: Build MSI with wixl\n        if: contains(matrix.target, 'windows')\n        run: |\n          node scripts/build-tauri-msi.js \\\n            --target ${{ matrix.target }} \\\n            --version ${{ needs.bump-version.outputs.new_version }}\n\n      - name: Setup Jsign\n        if: contains(matrix.target, 'windows')\n        id: jsign\n        uses: ./.github/actions/setup-jsign\n\n      - name: Install JRE for Jsign\n        if: contains(matrix.target, 'windows')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y default-jre-headless\n\n      - name: Azure CLI login\n        if: contains(matrix.target, 'windows') && env.AZURE_ENDPOINT != ''\n        uses: azure/login@v2\n        with:\n          creds: '{\"clientId\":\"${{ secrets.AZURE_CLIENT_ID }}\",\"clientSecret\":\"${{ secrets.AZURE_CLIENT_SECRET }}\",\"tenantId\":\"${{ secrets.AZURE_TENANT_ID }}\"}'\n          allow-no-subscriptions: true\n        env:\n          AZURE_ENDPOINT: ${{ secrets.AZURE_ENDPOINT }}\n\n      - name: Sign Windows artifacts with Azure Trusted Signing\n        if: contains(matrix.target, 'windows') && env.AZURE_ENDPOINT != ''\n        env:\n          AZURE_ENDPOINT: ${{ secrets.AZURE_ENDPOINT }}\n          AZURE_CODE_SIGNING_NAME: ${{ secrets.AZURE_CODE_SIGNING_NAME }}\n          AZURE_CERT_PROFILE_NAME: ${{ secrets.AZURE_CERT_PROFILE_NAME }}\n        run: |\n          TOKEN=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken -o tsv)\n\n          # Extract hostname from endpoint URL\n          KEYSTORE=$(echo \"$AZURE_ENDPOINT\" | sed 's|https\\?://||;s|/.*||')\n\n          # Sign all exe and msi files in the bundle directory\n          find target/${{ matrix.target }}/release/bundle -type f \\( -name \"*.exe\" -o -name \"*.msi\" \\) | while read file; do\n            echo \"Signing: $file\"\n            java -jar \"${{ steps.jsign.outputs.jar-path }}\" \\\n              --storetype TRUSTEDSIGNING \\\n              --keystore \"$KEYSTORE\" \\\n              --storepass \"$TOKEN\" \\\n              --alias \"${AZURE_CODE_SIGNING_NAME}/${AZURE_CERT_PROFILE_NAME}\" \\\n              --tsaurl http://timestamp.acs.microsoft.com \\\n              --tsmode RFC3161 \\\n              \"$file\"\n          done\n\n      - name: Collect updater artifacts and installers\n        run: |\n          mkdir -p tauri-artifacts/${{ matrix.platform }}\n          # Collect updater artifacts (.sig files and their corresponding bundles)\n          find target/${{ matrix.target }}/release/bundle -name \"*.sig\" | while read sig; do\n            artifact=\"${sig%.sig}\"\n            if [ -f \"$artifact\" ]; then\n              cp \"$artifact\" \"tauri-artifacts/${{ matrix.platform }}/\"\n              cp \"$sig\" \"tauri-artifacts/${{ matrix.platform }}/\"\n              echo \"Collected updater artifact: $(basename $artifact)\"\n            fi\n          done\n          # Collect installer files for GitHub Release (DMG, AppImage, deb, msi, NSIS exe)\n          find target/${{ matrix.target }}/release/bundle \\\n            \\( -name \"*.dmg\" -o -name \"*.AppImage\" -o -name \"*.deb\" -o -name \"*.msi\" -o -name \"*-setup.exe\" \\) | while read f; do\n            cp \"$f\" \"tauri-artifacts/${{ matrix.platform }}/\"\n            echo \"Collected installer: $(basename $f)\"\n          done\n          echo \"All artifacts for ${{ matrix.platform }}:\"\n          ls -la tauri-artifacts/${{ matrix.platform }}/\n\n      - name: Clean up signing keys\n        if: always() && runner.os == 'macOS'\n        run: rm -rf ~/.private_keys\n\n      - name: Upload Tauri artifacts\n        uses: actions/upload-artifact@v6\n        with:\n          name: tauri-artifacts-${{ matrix.platform }}\n          path: tauri-artifacts/\n          retention-days: 1\n\n      - name: Sweep Cargo target cache\n        if: always()\n        run: |\n          cargo sweep --maxsize 10GB\n          cargo sweep --time 30\n\n  upload-tauri-update:\n    needs: [bump-version, build-tauri]\n    runs-on: blacksmith-4vcpu-ubuntu-2404\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Setup Node\n        uses: ./.github/actions/setup-node\n\n      - name: Download all Tauri artifacts\n        uses: actions/download-artifact@v7\n        with:\n          pattern: tauri-artifacts-*\n          path: tauri-artifacts/\n          merge-multiple: true\n\n      - name: List Tauri artifacts\n        run: find tauri-artifacts/\n\n      - name: Generate updater manifest\n        run: |\n          node scripts/generate-tauri-update-json.js \\\n            --version \"${{ needs.bump-version.outputs.new_version }}\" \\\n            --notes \"Release ${{ needs.bump-version.outputs.new_tag }}\" \\\n            --artifacts-dir ./tauri-artifacts \\\n            --download-base \"${{ secrets.R2_BINARIES_PUBLIC_URL }}/binaries/${{ needs.bump-version.outputs.new_tag }}/tauri\" \\\n            --output latest.json\n          echo \"Generated latest.json:\"\n          cat latest.json\n\n      - name: Generate desktop manifest for NPX CLI\n        run: |\n          node scripts/generate-desktop-manifest.js \\\n            --version \"${{ needs.bump-version.outputs.new_version }}\" \\\n            --artifacts-dir ./tauri-artifacts \\\n            --output desktop-manifest.json\n          echo \"Generated desktop-manifest.json:\"\n          cat desktop-manifest.json\n\n      - name: Configure AWS CLI for R2\n        run: |\n          aws configure set aws_access_key_id ${{ secrets.R2_BINARIES_ACCESS_KEY_ID }}\n          aws configure set aws_secret_access_key ${{ secrets.R2_BINARIES_SECRET_ACCESS_KEY }}\n          aws configure set default.region auto\n\n      - name: Upload Tauri artifacts and update manifest to R2\n        run: |\n          TAG=\"${{ needs.bump-version.outputs.new_tag }}\"\n          ENDPOINT=\"${{ secrets.R2_BINARIES_ENDPOINT }}\"\n          BUCKET=\"${{ secrets.R2_BINARIES_BUCKET }}\"\n\n          # Upload individual platform artifacts\n          for platform in darwin-aarch64 darwin-x86_64 linux-x86_64 linux-aarch64 windows-x86_64 windows-aarch64; do\n            if [ -d \"tauri-artifacts/$platform\" ]; then\n              for file in tauri-artifacts/$platform/*; do\n                [ -f \"$file\" ] || continue\n                # Skip .sig files from artifact upload (signatures are in latest.json)\n                [[ \"$file\" == *.sig ]] && continue\n                filename=$(basename \"$file\")\n                echo \"Uploading $filename for $platform...\"\n                aws s3 cp \"$file\" \\\n                  \"s3://$BUCKET/binaries/$TAG/tauri/$platform/$filename\" \\\n                  --endpoint-url \"$ENDPOINT\"\n              done\n            fi\n          done\n\n          # Upload latest.json alongside the tag artifacts (NOT to the fixed\n          # update endpoint). The fixed endpoint is only updated when a\n          # pre-release is promoted to a full release (see publish.yml).\n          echo \"Uploading updater manifest for tag...\"\n          aws s3 cp latest.json \\\n            \"s3://$BUCKET/binaries/$TAG/tauri/latest.json\" \\\n            --endpoint-url \"$ENDPOINT\" --content-type \"application/json\"\n\n          echo \"Update manifest uploaded to: binaries/$TAG/tauri/latest.json\"\n\n          # Upload desktop manifest for NPX CLI auto-install\n          echo \"Uploading desktop manifest...\"\n          aws s3 cp desktop-manifest.json \\\n            \"s3://$BUCKET/binaries/$TAG/tauri/desktop-manifest.json\" \\\n            --endpoint-url \"$ENDPOINT\" --content-type \"application/json\"\n\n          echo \"Desktop manifest uploaded to: binaries/$TAG/tauri/desktop-manifest.json\"\n\n  create-prerelease:\n    needs: [bump-version, build-frontend, upload-to-r2, upload-tauri-update]\n    runs-on: blacksmith-4vcpu-ubuntu-2404\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Download frontend artifact\n        uses: actions/download-artifact@v7\n        with:\n          name: frontend-dist\n          path: packages/local-web/dist/\n\n      - name: Download Tauri artifacts for release\n        uses: actions/download-artifact@v7\n        with:\n          pattern: tauri-artifacts-*\n          path: tauri-release/\n          merge-multiple: true\n\n      - name: Collect Tauri installers for release\n        run: |\n          mkdir -p tauri-installers\n          find tauri-release \\( -name \"*.dmg\" -o -name \"*.AppImage\" -o -name \"*.deb\" -o -name \"*.msi\" -o -name \"*-setup.exe\" \\) \\\n            -exec cp {} tauri-installers/ \\;\n          echo \"Tauri installers for release:\"\n          ls -la tauri-installers/ 2>/dev/null || echo \"No installers found\"\n\n      - name: List downloaded artifacts\n        run: |\n          echo \"Web dist:\"\n          find packages/local-web/dist\n\n      - name: Zip frontend\n        run: |\n          mkdir vibe-kanban-${{ needs.bump-version.outputs.new_tag }}\n          mv packages/local-web/dist vibe-kanban-${{ needs.bump-version.outputs.new_tag }}\n          zip -r vibe-kanban-${{ needs.bump-version.outputs.new_tag }}.zip vibe-kanban-${{ needs.bump-version.outputs.new_tag }}\n\n      - name: Setup Node for npm pack\n        uses: ./.github/actions/setup-node\n\n      - name: Install npx-cli dependencies\n        run: |\n          cd npx-cli\n          npm ci\n\n      - name: Build npx-cli TypeScript, inject secrets, and Pack\n        run: |\n          cd npx-cli\n          npm run build\n          # Replace placeholders in the bundled output\n          sed -i \"s|__R2_PUBLIC_URL__|${{ secrets.R2_BINARIES_PUBLIC_URL }}|g\" bin/cli.js\n          sed -i \"s|__BINARY_TAG__|${{ needs.bump-version.outputs.new_tag }}|g\" bin/cli.js\n          npm pack\n\n      - name: Create GitHub Pre-Release\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: ${{ needs.bump-version.outputs.new_tag }}\n          name: Pre-release ${{ needs.bump-version.outputs.new_tag }}\n          prerelease: true\n          generate_release_notes: true\n          files: |\n            vibe-kanban-${{ needs.bump-version.outputs.new_tag }}.zip\n            npx-cli/vibe-kanban-*.tgz\n            tauri-installers/*\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish to npm\n\non:\n  release:\n    types: [released]\n  workflow_dispatch:\n    inputs:\n      tag_name:\n        description: \"Release tag (e.g., v1.2.3)\"\n        required: true\n      release_id:\n        description: \"GitHub release ID\"\n        required: true\n\nconcurrency:\n  group: publish\n  cancel-in-progress: true\n\npermissions:\n  contents: write\n  packages: write\n  id-token: write  # Required for OIDC trusted publishing\n\nenv:\n  NODE_VERSION: 22\n  PNPM_VERSION: 10.13.1\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    # Only run for main app releases (not remote-v* tags) that were converted from pre-release\n    if: github.event.release.prerelease == false && !startsWith(github.event.release.tag_name, 'remote-')\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.release.tag_name || inputs.tag_name }}\n\n      - name: Setup Node\n        uses: ./.github/actions/setup-node\n\n      - name: Upgrade npm for OIDC support\n        run: npm install -g npm@latest\n\n      - name: Download release assets\n        uses: actions/github-script@v8\n        env:\n          RELEASE_ID: ${{ inputs.release_id }}\n        with:\n          script: |\n            const fs = require('fs');\n            const path = require('path');\n\n            const releaseId = context.payload.release?.id || process.env.RELEASE_ID;\n            console.log(\"releaseId:\", releaseId);\n\n            if (!releaseId) {\n              core.setFailed('No release ID found.');\n              return;\n            }\n\n            const release = await github.rest.repos.getRelease({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              release_id: releaseId\n            });\n            \n            // Find the .tgz file\n            const tgzAsset = release.data.assets.find(asset => asset.name.endsWith('.tgz'));\n            \n            if (!tgzAsset) {\n              core.setFailed('No .tgz file found in release assets');\n              return;\n            }\n            \n            // Download the asset\n            const response = await github.rest.repos.getReleaseAsset({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              asset_id: tgzAsset.id,\n              headers: {\n                Accept: 'application/octet-stream'\n              }\n            });\n            \n            // Save to npx-cli directory\n            const filePath = path.join('npx-cli', tgzAsset.name);\n            fs.writeFileSync(filePath, Buffer.from(response.data));\n            \n            console.log(`Downloaded ${tgzAsset.name} to ${filePath}`);\n            \n            // Set output for next step\n            core.setOutput('package-file', filePath);\n            core.setOutput('package-name', tgzAsset.name);\n\n      - name: Verify package integrity\n        id: verify\n        run: |\n          cd npx-cli\n          \n          # List files to confirm download\n          ls -la *.tgz\n          \n          # Verify the package can be read\n          npm pack --dry-run || echo \"Note: This is expected to show differences since we're using the pre-built package\"\n          \n          # Extract package name from the downloaded file\n          PACKAGE_FILE=$(ls *.tgz | head -n1)\n          echo \"package-file=$PACKAGE_FILE\" >> $GITHUB_OUTPUT\n\n      - name: Publish to npm\n        run: |\n          cd npx-cli\n\n          # Publish the exact same package that was tested\n          PACKAGE_FILE=\"${{ steps.verify.outputs.package-file }}\"\n\n          echo \"Publishing $PACKAGE_FILE to npm...\"\n          npm publish \"$PACKAGE_FILE\" --provenance --access public\n\n          echo \"✅ Successfully published to npm!\"\n\n      - name: Update release description\n        uses: actions/github-script@v8\n        env:\n          RELEASE_ID: ${{ inputs.release_id }}\n        with:\n          script: |\n            const releaseId = context.payload.release?.id || process.env.RELEASE_ID;;\n\n            // Fetch the release to get the current body\n            const release = await github.rest.repos.getRelease({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              release_id: releaseId\n            });\n\n            const currentBody = release.data.body || '';\n            await github.rest.repos.updateRelease({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              release_id: releaseId,\n              body: currentBody + '\\n\\n✅ **Published to npm registry**'\n            });\n\n  # Promote the tag-specific Tauri update manifest to the fixed endpoint\n  # so existing desktop app users receive the update notification.\n  promote-tauri-update:\n    runs-on: ubuntu-latest\n    if: github.event.release.prerelease == false && !startsWith(github.event.release.tag_name, 'remote-')\n    steps:\n      - name: Configure AWS CLI for R2\n        run: |\n          aws configure set aws_access_key_id ${{ secrets.R2_BINARIES_ACCESS_KEY_ID }}\n          aws configure set aws_secret_access_key ${{ secrets.R2_BINARIES_SECRET_ACCESS_KEY }}\n          aws configure set default.region auto\n\n      - name: Copy update manifest to live endpoint\n        run: |\n          TAG=\"${{ github.event.release.tag_name || inputs.tag_name }}\"\n          ENDPOINT=\"${{ secrets.R2_BINARIES_ENDPOINT }}\"\n          BUCKET=\"${{ secrets.R2_BINARIES_BUCKET }}\"\n\n          echo \"Promoting update manifest for $TAG to live endpoint...\"\n          aws s3 cp \\\n            \"s3://$BUCKET/binaries/$TAG/tauri/latest.json\" \\\n            \"s3://$BUCKET/binaries/tauri-update/latest.json\" \\\n            --endpoint-url \"$ENDPOINT\" --content-type \"application/json\"\n\n          echo \"Update manifest promoted: binaries/tauri-update/latest.json\""
  },
  {
    "path": ".github/workflows/relay-deploy-dev.yml",
    "content": "name: Relay Deploy Dev\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - crates/relay-tunnel/**\n  workflow_dispatch:\n\njobs:\n  run-relay-deploy:\n    name: Deploy Relay Dev\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - name: Dispatch dev relay deployment workflow\n        uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1\n        with:\n          token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }}\n          repository: BloopAI/vibe-kanban-remote-deployment\n          event-type: vibe-kanban-relay-deploy-dev\n          client-payload: |\n            {\n              \"ref\": \"${{ github.ref_name }}\",\n              \"sha\": \"${{ github.sha }}\"\n            }\n"
  },
  {
    "path": ".github/workflows/relay-deploy-prod.yml",
    "content": "name: Deploy Relay Prod\n\non:\n  workflow_dispatch:\n    inputs:\n      version_type:\n        description: \"Version bump type\"\n        required: true\n        default: \"patch\"\n        type: choice\n        options:\n          - patch\n          - minor\n          - major\n\nconcurrency:\n  group: relay-deploy-${{ github.ref_name }}\n  cancel-in-progress: true\n\npermissions:\n  contents: write\n\nenv:\n  RUST_TOOLCHAIN: nightly-2025-12-04\n\njobs:\n  release-and-deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Cache cargo-edit\n        uses: actions/cache@v5\n        id: cache-cargo-edit\n        with:\n          path: ~/.cargo/bin/cargo-set-version\n          key: cargo-edit-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}\n\n      - name: Install cargo-edit\n        if: steps.cache-cargo-edit.outputs.cache-hit != 'true'\n        run: cargo install cargo-edit\n\n      - uses: actions/checkout@v6\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          ssh-key: ${{ secrets.DEPLOY_KEY }}\n\n      - name: Determine and update version\n        id: version\n        run: |\n          # Get current version from crates/relay-tunnel/Cargo.toml\n          current_version=$(grep '^version = ' crates/relay-tunnel/Cargo.toml | head -1 | sed 's/version = \"\\(.*\\)\"/\\1/')\n          echo \"Current relay-tunnel version: $current_version\"\n\n          # Parse version components\n          IFS='.' read -r major minor patch <<< \"$current_version\"\n\n          # Bump based on type\n          case \"${{ github.event.inputs.version_type }}\" in\n            major)\n              major=$((major + 1))\n              minor=0\n              patch=0\n              ;;\n            minor)\n              minor=$((minor + 1))\n              patch=0\n              ;;\n            patch)\n              patch=$((patch + 1))\n              ;;\n          esac\n\n          new_version=\"${major}.${minor}.${patch}\"\n          new_tag=\"relay-v${new_version}\"\n\n          # Update version in crates/relay-tunnel/Cargo.toml\n          cd crates/relay-tunnel\n          cargo set-version \"$new_version\"\n          cargo update relay-tunnel\n          cd ../..\n\n          echo \"New version: $new_version\"\n          echo \"New tag: $new_tag\"\n          echo \"new_version=$new_version\" >> $GITHUB_OUTPUT\n          echo \"new_tag=$new_tag\" >> $GITHUB_OUTPUT\n          echo \"new_ref=$new_tag\" >> $GITHUB_OUTPUT\n\n      - name: Commit changes and create tag\n        run: |\n          git config --local user.email \"action@github.com\"\n          git config --local user.name \"GitHub Action\"\n          git add crates/relay-tunnel/Cargo.toml crates/relay-tunnel/Cargo.lock\n          git commit -m \"chore: bump relay-tunnel version to ${{ steps.version.outputs.new_version }}\"\n          git tag -a ${{ steps.version.outputs.new_tag }} -m \"Relay release ${{ steps.version.outputs.new_tag }}\"\n          git push\n          git push --tags\n\n      - name: Dispatch relay deployment workflow\n        uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1\n        with:\n          token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }}\n          repository: BloopAI/vibe-kanban-remote-deployment\n          event-type: vibe-kanban-relay-deploy-prod\n          client-payload: |\n            {\n              \"ref\": \"${{ steps.version.outputs.new_ref }}\",\n              \"sha\": \"${{ github.sha }}\",\n              \"version\": \"${{ steps.version.outputs.new_version }}\"\n            }\n"
  },
  {
    "path": ".github/workflows/relay-release.yml",
    "content": "name: Create Relay Release\n\non:\n  repository_dispatch:\n    types: [relay-deploy-success]\n\njobs:\n  create-release:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: ${{ github.event.client_payload.tag }}\n          name: ${{ github.event.client_payload.tag }}\n          generate_release_notes: true\n          body: |\n            ## Relay Tunnel Service Release\n\n            Deployed to ${{ github.event.client_payload.environment }}\n"
  },
  {
    "path": ".github/workflows/remote-deploy-dev.yml",
    "content": "name: Remote Deploy Dev\n\non:\n  push:\n    branches:\n      - main \n    paths:\n      - crates/remote/**\n      - packages/remote-web/**\n  workflow_dispatch:\n\njobs:\n  run-remote-deploy:\n    name: Deploy Remote Dev\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - name: Dispatch dev remote deployment workflow\n        uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1\n        with:\n          token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }}\n          repository: BloopAI/vibe-kanban-remote-deployment\n          event-type: vibe-kanban-remote-deploy-dev\n          client-payload: |\n            {\n              \"ref\": \"${{ github.ref_name }}\",\n              \"sha\": \"${{ github.sha }}\"\n            }\n"
  },
  {
    "path": ".github/workflows/remote-deploy-prod.yml",
    "content": "name: Deploy Remote Prod\n\non:\n  workflow_dispatch:\n    inputs:\n      version_type:\n        description: \"Version bump type\"\n        required: true\n        default: \"patch\"\n        type: choice\n        options:\n          - patch\n          - minor\n          - major\n\nconcurrency:\n  group: remote-deploy-${{ github.ref_name }}\n  cancel-in-progress: true\n\npermissions:\n  contents: write\n\nenv:\n  RUST_TOOLCHAIN: nightly-2025-12-04\n\njobs:\n  release-and-deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Cache cargo-edit\n        uses: actions/cache@v5\n        id: cache-cargo-edit\n        with:\n          path: ~/.cargo/bin/cargo-set-version\n          key: cargo-edit-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}\n\n      - name: Install cargo-edit\n        if: steps.cache-cargo-edit.outputs.cache-hit != 'true'\n        run: cargo install cargo-edit\n\n      - uses: actions/checkout@v6\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          ssh-key: ${{ secrets.DEPLOY_KEY }}\n\n      - name: Setup SSH Agent for private dependencies\n        uses: webfactory/ssh-agent@v0.9.0\n        with:\n          ssh-private-key: ${{ secrets.VK_PRIVATE_DEPLOY_KEY }}\n\n      - name: Determine and update version\n        id: version\n        run: |\n          # Get current version from crates/remote/Cargo.toml\n          current_version=$(grep '^version = ' crates/remote/Cargo.toml | head -1 | sed 's/version = \"\\(.*\\)\"/\\1/')\n          echo \"Current remote version: $current_version\"\n\n          # Parse version components\n          IFS='.' read -r major minor patch <<< \"$current_version\"\n\n          # Bump based on type\n          case \"${{ github.event.inputs.version_type }}\" in\n            major)\n              major=$((major + 1))\n              minor=0\n              patch=0\n              ;;\n            minor)\n              minor=$((minor + 1))\n              patch=0\n              ;;\n            patch)\n              patch=$((patch + 1))\n              ;;\n          esac\n\n          new_version=\"${major}.${minor}.${patch}\"\n          new_tag=\"remote-v${new_version}\"\n\n          # Update version in crates/remote/Cargo.toml\n          cd crates/remote\n          cargo set-version \"$new_version\"\n          cargo update remote\n          cd ../..\n\n          echo \"New version: $new_version\"\n          echo \"New tag: $new_tag\"\n          echo \"new_version=$new_version\" >> $GITHUB_OUTPUT\n          echo \"new_tag=$new_tag\" >> $GITHUB_OUTPUT\n          echo \"new_ref=$new_tag\" >> $GITHUB_OUTPUT\n\n      - name: Stop SSH agent\n        run: ssh-agent -k\n\n      - name: Commit changes and create tag\n        run: |\n          git config --local user.email \"action@github.com\"\n          git config --local user.name \"GitHub Action\"\n          git add crates/remote/Cargo.toml crates/remote/Cargo.lock\n          git commit -m \"chore: bump remote version to ${{ steps.version.outputs.new_version }}\"\n          git tag -a ${{ steps.version.outputs.new_tag }} -m \"Remote release ${{ steps.version.outputs.new_tag }}\"\n          git push\n          git push --tags\n\n      - name: Dispatch remote deployment workflow\n        uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1\n        with:\n          token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }}\n          repository: BloopAI/vibe-kanban-remote-deployment\n          event-type: vibe-kanban-remote-deploy-prod\n          client-payload: |\n            {\n              \"ref\": \"${{ steps.version.outputs.new_ref }}\",\n              \"sha\": \"${{ github.sha }}\",\n              \"version\": \"${{ steps.version.outputs.new_version }}\"\n            }\n"
  },
  {
    "path": ".github/workflows/remote-release.yml",
    "content": "name: Create Remote Release\n\non:\n  repository_dispatch:\n    types: [remote-deploy-success]\n\njobs:\n  create-release:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: ${{ github.event.client_payload.tag }}\n          name: ${{ github.event.client_payload.tag }}\n          generate_release_notes: true\n          body: |\n            ## Remote Service Release\n\n            ✅ Deployed to ${{ github.event.client_payload.environment }}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  pull_request:\n    branches:\n      - main\n    paths-ignore:\n      - .github/workflows/**\n      - '!.github/workflows/test.yml'\n  push:\n    branches:\n      - main\n  workflow_dispatch:\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}\n\nenv:\n  CARGO_TERM_COLOR: always\n  NODE_VERSION: 22\n  PNPM_VERSION: 10.13.1\n  RUST_TOOLCHAIN: nightly-2025-12-04\n  RUNNER_LABEL: &runner_label blacksmith-16vcpu-ubuntu-2404\n\njobs:\n  changes:\n    runs-on: *runner_label\n    if: github.event_name == 'pull_request'\n    permissions:\n      contents: read\n      pull-requests: read\n    outputs:\n      frontend: ${{ steps.filter.outputs.frontend }}\n      backend: ${{ steps.filter.outputs.backend }}\n      backend-remote: ${{ steps.filter.outputs['backend-remote'] }}\n      tauri: ${{ steps.filter.outputs.tauri }}\n    steps:\n      - uses: actions/checkout@v6\n      - uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0\n        id: filter\n        with:\n          filters: |\n            frontend:\n              - 'packages/local-web/**'\n              - 'packages/web-core/**'\n              - 'packages/remote-web/**'\n              - 'packages/ui/**'\n              - 'shared/**'\n              - 'scripts/check-i18n.sh'\n              - 'scripts/check-unused-i18n-keys.mjs'\n              - 'scripts/check-legacy-frontend-paths.sh'\n              - 'scripts/legacy-frontend-paths-allowlist.txt'\n              - 'pnpm-lock.yaml'\n              - 'pnpm-workspace.yaml'\n              - 'package.json'\n              - '.npmrc'\n              - '.github/workflows/test.yml'\n              - '.github/actions/**'\n            backend:\n              - 'crates/api-types/**'\n              - 'crates/db/**'\n              - 'crates/deployment/**'\n              - 'crates/executors/**'\n              - 'crates/git/**'\n              - 'crates/local-deployment/**'\n              - 'crates/mcp/**'\n              - 'crates/review/**'\n              - 'crates/server/**'\n              - 'crates/services/**'\n              - 'crates/utils/**'\n              - 'Cargo.toml'\n              - 'Cargo.lock'\n              - 'shared/**'\n              - 'scripts/prepare-db.js'\n              - 'pnpm-lock.yaml'\n              - 'package.json'\n              - 'rustfmt.toml'\n              - 'rust-toolchain.toml'\n              - '.cargo/**'\n              - '.github/workflows/test.yml'\n              - '.github/actions/**'\n            backend-remote:\n              - 'crates/remote/**'\n              - 'crates/relay-tunnel/**'\n              - 'crates/api-types/**'\n              - 'crates/utils/**'\n              - 'Cargo.toml'\n              - 'Cargo.lock'\n              - 'shared/**'\n              - 'rustfmt.toml'\n              - 'rust-toolchain.toml'\n              - '.cargo/**'\n              - 'pnpm-lock.yaml'\n              - 'package.json'\n              - '.github/workflows/test.yml'\n              - '.github/actions/**'\n            tauri:\n              - 'crates/tauri-app/**'\n              - 'crates/api-types/**'\n              - 'crates/db/**'\n              - 'crates/deployment/**'\n              - 'crates/executors/**'\n              - 'crates/git/**'\n              - 'crates/local-deployment/**'\n              - 'crates/mcp/**'\n              - 'crates/review/**'\n              - 'crates/server/**'\n              - 'crates/services/**'\n              - 'crates/utils/**'\n              - 'Cargo.toml'\n              - 'Cargo.lock'\n              - 'rustfmt.toml'\n              - 'rust-toolchain.toml'\n              - '.cargo/**'\n              - 'package.json'\n              - 'pnpm-lock.yaml'\n              - 'pnpm-workspace.yaml'\n              - '.npmrc'\n              - '.github/workflows/test.yml'\n              - '.github/actions/**'\n\n  frontend-checks:\n    needs: changes\n    if: always() && (needs.changes.outputs.frontend == 'true' || needs.changes.result == 'skipped')\n    runs-on: *runner_label\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Node\n        uses: ./.github/actions/setup-node\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Run frontend checks\n        env:\n          NODE_OPTIONS: --max-old-space-size=8192\n        run: |\n          npx concurrently \\\n            --kill-others-on-fail \\\n            --names \"local:lint,local:fmt,local:build,remote:fmt,remote:build,ui:check,ui:lint,ui:fmt,core:check,core:fmt,i18n,i18n:unused,legacy\" \\\n            --timings \\\n            \"cd packages/local-web && npm run lint\" \\\n            \"cd packages/local-web && npm run format:check\" \\\n            \"cd packages/local-web && npm run build\" \\\n            \"cd packages/remote-web && npm run format:check\" \\\n            \"cd packages/remote-web && npm run build\" \\\n            \"cd packages/ui && npm run check\" \\\n            \"cd packages/ui && npm run lint\" \\\n            \"cd packages/ui && npm run format:check\" \\\n            \"cd packages/web-core && npm run check\" \\\n            \"cd packages/web-core && npm run format:check\" \\\n            \"GITHUB_BASE_REF=${{ github.base_ref || 'main' }} ./scripts/check-i18n.sh\" \\\n            \"node scripts/check-unused-i18n-keys.mjs\" \\\n            \"./scripts/check-legacy-frontend-paths.sh\"\n\n  backend-schema-checks:\n    needs: changes\n    if: always() && (needs.changes.outputs.backend == 'true' || needs.changes.result == 'skipped')\n    runs-on: *runner_label\n    env:\n      SCCACHE_GHA_ENABLED: \"true\"\n      RUSTC_WRAPPER: \"sccache\"\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Rust\n        uses: ./.github/actions/cargo-checks-common-setup\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          cache-key: backend-schema-checks-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }}\n          setup-node: 'true'\n          setup-sqlx-cli: 'true'\n\n      - name: Check generated types\n        run: npm run generate-types:check\n\n      - name: Sqlx checks\n        run: npm run prepare-db:check\n\n  backend-remote-checks:\n    needs: changes\n    if: >-\n      always()\n      && (needs.changes.outputs['backend-remote'] == 'true' || needs.changes.result == 'skipped')\n      && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)\n    runs-on: *runner_label\n    env:\n      SCCACHE_GHA_ENABLED: \"true\"\n      RUSTC_WRAPPER: \"sccache\"\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Rust\n        uses: ./.github/actions/cargo-checks-common-setup\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          components: rustfmt, clippy\n          cache-key: backend-remote-checks-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }}\n          setup-node: 'true'\n          setup-sqlx-cli: 'true'\n\n      - name: Setup SSH Agent for private dependencies\n        uses: webfactory/ssh-agent@v0.9.0\n        with:\n          ssh-private-key: ${{ secrets.VK_PRIVATE_DEPLOY_KEY }}\n\n      - name: Check remote Cargo.lock is consistent\n        run: |\n          cargo metadata --locked --format-version 1 --manifest-path crates/remote/Cargo.toml > /dev/null\n          cargo metadata --locked --format-version 1 --manifest-path crates/relay-tunnel/Cargo.toml > /dev/null\n\n      - name: Check formatting\n        run: |\n          cargo fmt --all --manifest-path crates/remote/Cargo.toml -- --check\n          cargo fmt --all --manifest-path crates/relay-tunnel/Cargo.toml -- --check\n\n      - name: Run Clippy\n        run: |\n          cargo clippy --all-targets --manifest-path crates/remote/Cargo.toml -- -D warnings\n          cargo clippy --all-targets --manifest-path crates/relay-tunnel/Cargo.toml -- -D warnings\n\n      - name: Check generated types\n        run: npm run remote:generate-types:check\n\n      - name: Sqlx checks\n        run: npm run remote:prepare-db:check\n\n  backend-clippy:\n    needs: changes\n    if: always() && (needs.changes.outputs.backend == 'true' || needs.changes.result == 'skipped')\n    runs-on: *runner_label\n    env:\n      SCCACHE_GHA_ENABLED: \"true\"\n      RUSTC_WRAPPER: \"sccache\"\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Rust\n        uses: ./.github/actions/cargo-checks-common-setup\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          components: rustfmt, clippy\n          cache-key: backend-clippy-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }}\n\n      - name: Run Clippy and fmt checks\n        run: |\n          set -x\n          cargo fmt --all -- --check & pid_fmt=$!\n          cargo clippy --workspace --all-targets --exclude vibe-kanban-tauri -- -D warnings\n          wait $pid_fmt\n\n  backend-test:\n    needs: changes\n    if: always() && (needs.changes.outputs.backend == 'true' || needs.changes.result == 'skipped')\n    runs-on: *runner_label\n    env:\n      SCCACHE_GHA_ENABLED: \"true\"\n      RUSTC_WRAPPER: \"sccache\"\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Rust\n        uses: ./.github/actions/cargo-checks-common-setup\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          cache-key: backend-test-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }}\n\n      - name: Install cargo-nextest\n        uses: taiki-e/install-action@nextest\n\n      - name: Run Cargo tests\n        run: cargo nextest run --workspace --exclude vibe-kanban-tauri\n\n  tauri-checks:\n    needs: changes\n    if: always() && (needs.changes.outputs.tauri == 'true' || needs.changes.result == 'skipped')\n    runs-on: *runner_label\n    env:\n      SCCACHE_GHA_ENABLED: \"true\"\n      RUSTC_WRAPPER: \"sccache\"\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Rust\n        uses: ./.github/actions/cargo-checks-common-setup\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          components: rustfmt, clippy\n          cache-key: tauri-checks-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }}\n\n      - name: Install Linux dependencies\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential \\\n            libssl-dev libayatana-appindicator3-dev librsvg2-dev \\\n            libxdo-dev file pkg-config xdg-utils\n\n      - name: Run Tauri fmt, clippy, and compile checks\n        run: |\n          cargo fmt --all --manifest-path crates/tauri-app/Cargo.toml -- --check\n          cargo clippy --all-targets --manifest-path crates/tauri-app/Cargo.toml -- -D warnings\n          cargo check -p vibe-kanban-tauri\n"
  },
  {
    "path": ".gitignore",
    "content": "# Rust\ntarget/\n**/*.rs.bk\n\n# Node.js\nnode_modules/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# Build outputs\n/dist\n/build\nbindings/\n\n# Environment variables\n.env\n.env.remote\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n.env.production\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Coverage directory used by tools like istanbul\ncoverage/\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# ESLint cache\n.eslintcache\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Storybook build outputs\n.out\n.storybook-out\n\n.env\npackages/**/dist\n\nbuild-npm-package-codesign.sh\n\nnpx-cli/bin/\nnpx-cli/dist\nnpx-cli/vibe-kanban-*\nvibe-kanban-*.tgz\n\n# Development ports file\n.dev-ports.json\n\ndev_assets\n/packages/web/.env.sentry-build-plugin\n.ssh\n\nvibe-kanban-cloud/\n\n.jj\n.sisyphus/\n\n# Vite temp files\n*.timestamp-*.mjs\n\n# Mobile testing (per-developer, do not commit)\n*.ts.net.crt\n*.ts.net.key\nCaddyfile\nCaddyfile.dev\n"
  },
  {
    "path": ".npmrc",
    "content": "engine-strict=true\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Repository Guidelines\n\n## Project Structure & Module Organization\n- `crates/`: Rust workspace crates — `server` (API + bins), `db` (SQLx models/migrations), `executors`, `services`, `utils`, `git` (Git operations), `api-types` (shared API types for local + remote), `review` (PR review tool), `deployment`, `local-deployment`, `remote`.\n- `packages/local-web/`: Local React + TypeScript app entrypoint (Vite, Tailwind). Shell source in `packages/local-web/src`.\n- `packages/remote-web/`: Remote deployment frontend entrypoint.\n- `packages/web-core/`: Shared React + TypeScript frontend library used by local + remote web (`packages/web-core/src`).\n- `shared/`: Generated TypeScript types (`shared/types.ts`, `shared/remote-types.ts`) and agent tool schemas (`shared/schemas/`). Do not edit generated files directly.\n- `assets/`, `dev_assets_seed/`, `dev_assets/`: Packaged and local dev assets.\n- `npx-cli/`: Files published to the npm CLI package.\n- `scripts/`: Dev helpers (ports, DB preparation).\n- `docs/`: Documentation files.\n\n### Crate-specific guides\n- [`crates/remote/AGENTS.md`](crates/remote/AGENTS.md) — Remote server architecture, ElectricSQL integration, mutation patterns, environment variables.\n- [`docs/AGENTS.md`](docs/AGENTS.md) — Mintlify documentation writing guidelines and component reference.\n- [`packages/local-web/AGENTS.md`](packages/local-web/AGENTS.md) — Web app design system styling guidelines.\n\n## Managing Shared Types Between Rust and TypeScript\n\nts-rs allows you to derive TypeScript types from Rust structs/enums. By annotating your Rust types with #[derive(TS)] and related macros, ts-rs will generate .ts declaration files for those types.\nWhen making changes to the types, you can regenerate them using `pnpm run generate-types`\nDo not manually edit shared/types.ts, instead edit crates/server/src/bin/generate_types.rs\n\nFor remote/cloud types, regenerate using `pnpm run remote:generate-types`\nDo not manually edit shared/remote-types.ts, instead edit crates/remote/src/bin/remote-generate-types.rs (see crates/remote/AGENTS.md for details).\n\n## Build, Test, and Development Commands\n- Install: `pnpm i`\n- Run dev (web app + backend with ports auto-assigned): `pnpm run dev`\n- Backend (watch): `pnpm run backend:dev:watch`\n- Web app (dev): `pnpm run local-web:dev`\n- Type checks: `pnpm run check` (frontend + all backend Rust workspaces) and `pnpm run backend:check` (all backend Rust workspaces, including `crates/remote`)\n- Rust tests: `cargo test --workspace`\n- Generate TS types from Rust: `pnpm run generate-types` (or `generate-types:check` in CI)\n- Prepare SQLx (offline): `pnpm run prepare-db`\n- Prepare SQLx (remote package, postgres): `pnpm run remote:prepare-db`\n- Local NPX build: `pnpm run build:npx` then `pnpm pack` in `npx-cli/`\n- Format code: `pnpm run format` (runs `cargo fmt` for all backend Rust workspaces + web-core/web Prettier)\n- Lint: `pnpm run lint` (runs web/ui ESLint + `cargo clippy` for all backend Rust workspaces)\n\n## Before Completing a Task\n- Run `pnpm run format` to format all Rust workspaces and web code.\n\n## Coding Style & Naming Conventions\n- Rust: `rustfmt` enforced (`rustfmt.toml`); group imports by crate; snake_case modules, PascalCase types.\n- TypeScript/React: ESLint + Prettier (2 spaces, single quotes, 80 cols). PascalCase components, camelCase vars/functions, kebab-case file names where practical.\n- Keep functions small, add `Debug`/`Serialize`/`Deserialize` where useful.\n\n## Testing Guidelines\n- Rust: prefer unit tests alongside code (`#[cfg(test)]`), run `cargo test --workspace`. Add tests for new logic and edge cases.\n- Web app: ensure `pnpm run check` and `pnpm run lint` pass. If adding runtime logic, include lightweight tests (e.g., Vitest) in the same directory.\n\n## Security & Config Tips\n- Use `.env` for local overrides; never commit secrets. Key envs: `FRONTEND_PORT`, `BACKEND_PORT`, `HOST` \n- Dev ports and assets are managed by `scripts/setup-dev-environment.js`.\n\n"
  },
  {
    "path": "CODE-OF-CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, caste, color, religion, or sexual\nidentity and orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the overall\n  community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or advances of\n  any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email address,\n  without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nmaintainers@bloop.ai through e-mail, with an appropriate subject line.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n### Attribution\n\nThis Code of Conduct is adapted from the [Next.js project][nextjs-coc]\n\nThe original text is from the [Contributor Covenant][homepage],\nversion 2.1, available at\n[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].\n\nFor answers to common questions about this code of conduct, see the FAQ at\n[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at\n[https://www.contributor-covenant.org/translations][translations].\n\n[nextjs-coc]: https://raw.githubusercontent.com/vercel/next.js/canary/CODE_OF_CONDUCT.md\n[homepage]: https://www.contributor-covenant.org\n[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html\n[FAQ]: https://www.contributor-covenant.org/faq\n[translations]: https://www.contributor-covenant.org/translations\n"
  },
  {
    "path": "CONTRIBUTORS.md",
    "content": "# Contributing & Change Control\n\n## Change Control Policy\n\nAll changes to production code are governed by formal change control procedures. These procedures ensure that modifications are reviewed, approved, and deployed in a controlled manner.\n\n## Code Review Requirements\n\nA maintainer must review pull requests before they are merged into any production branch. No code changes shall be merged without explicit approval from a qualified reviewer.\n\n## Pull Request Process\n\n1. Create a feature or fix branch from the base branch.\n2. Make changes and open a pull request.\n3. Obtain the required review and approval from a maintainer.\n4. All required CI checks must pass before merging.\n5. Merge only after approval has been granted and CI checks have passed.\n\n## Separation of Duties\n\nDevelopment, testing, and deployment of changes shall not be performed by a single individual without approval and oversight. All significant changes require independent review to ensure correctness, security, and alignment with project standards.\n\n## Coding Practices\n\nContributors are expected to follow the project's coding standards throughout the development cycle. These standards cover code quality, style consistency, and security.\n\n### Style & Formatting\n\n- **Rust**: Code must be formatted with `rustfmt` (config in `rustfmt.toml`). Use `snake_case` for modules and functions, `PascalCase` for types. Group imports by crate.\n- **TypeScript/React**: Code must pass ESLint and Prettier (2 spaces, single quotes, 80-column width). Use `PascalCase` for components, `camelCase` for variables and functions, and `kebab-case` for file names.\n- Run `pnpm run format` before submitting a pull request.\n- Run `pnpm run lint` to verify there are no linting errors.\n\n### Code Quality\n\n- Keep functions small and focused on a single responsibility.\n- Write clear, self-documenting code. Add comments only where the logic is not self-evident.\n- Do not introduce unnecessary abstractions or over-engineer solutions.\n- Do not manually edit generated files (e.g., `shared/types.ts`). Modify the source and regenerate.\n\n### Testing\n\n- **Rust**: Add unit tests alongside code using `#[cfg(test)]`. Run `cargo test --workspace` to verify.\n- **TypeScript**: Ensure `pnpm run check` and `pnpm run lint` pass. Include lightweight tests (e.g., Vitest) for new runtime logic.\n- All CI checks must pass before a pull request can be merged.\n\n### Security\n\n- Never commit secrets, credentials, or API keys. Use `.env` for local configuration.\n- Be mindful of common vulnerabilities (injection, XSS, insecure deserialization) when writing code that handles user input or external data.\n- Report security issues privately to the maintainers rather than opening a public issue.\n\n### Commit Messages\n\n- Use clear, descriptive commit messages that explain the _why_ behind a change.\n- Prefix with a conventional type where appropriate (e.g., `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`).\n- Keep the subject line under 72 characters. Use the body for additional context if needed.\n\n## Scope\n\nThese procedures apply to all production branches in this repository.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nresolver = \"3\"\nmembers = [\n    \"crates/api-types\",\n    \"crates/server\",\n    \"crates/trusted-key-auth\",\n    \"crates/mcp\",\n    \"crates/db\",\n    \"crates/executors\",\n    \"crates/services\",\n    \"crates/worktree-manager\",\n    \"crates/workspace-manager\",\n    \"crates/relay-control\",\n    \"crates/server-info\",\n    \"crates/utils\",\n    \"crates/git\",\n    \"crates/git-host\",\n    \"crates/local-deployment\",\n    \"crates/deployment\",\n    \"crates/review\",\n    \"crates/tauri-app\",\n]\nexclude = [\"crates/remote\", \"crates/relay-tunnel\"]\n\n[workspace.dependencies]\ntokio = { version = \"1.0\", features = [\"full\"] }\naxum = { version = \"0.8.4\", features = [\"macros\", \"multipart\", \"ws\"] }\ntower-http = { version = \"0.5\", features = [\"cors\", \"request-id\", \"trace\", \"fs\", \"validate-request\", \"compression-gzip\", \"compression-br\"] }\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = { version = \"1.0\", features = [\"preserve_order\"] }\nanyhow = \"1.0\"\ngit2 = { version = \"0.20.3\", default-features = false }\nreqwest = { version = \"0.13\", default-features = false, features = [\"json\", \"query\", \"stream\", \"rustls\"] }\nrustls = { version = \"0.23\", default-features = false, features = [\"aws_lc_rs\", \"std\", \"tls12\"] }\nthiserror = \"2.0.12\"\ntracing = \"0.1.43\"\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"fmt\", \"json\"] }\nts-rs = { git = \"https://github.com/xazukx/ts-rs.git\", branch = \"use-ts-enum\", features = [\"uuid-impl\", \"chrono-impl\", \"no-serde-warnings\", \"serde-json-impl\"] }\nschemars = { version = \"1.0.4\", features = [\"derive\", \"chrono04\", \"uuid1\", \"preserve_order\"] }\nserde_with = \"3\"\nasync-trait = \"0.1\"\naws-lc-sys = \"=0.37.0\"\naws-lc-rs = \"=1.16.0\"\n\n[profile.release]\ndebug = 1\nsplit-debuginfo = \"packed\"\nstrip = true\n\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1.6\n\nFROM node:24-alpine AS fe-builder\n\nARG POSTHOG_API_KEY=\"\"\nARG POSTHOG_API_ENDPOINT=\"\"\n\nWORKDIR /app\n\nENV PNPM_HOME=/pnpm\nENV PATH=${PNPM_HOME}:${PATH}\nENV VITE_PUBLIC_POSTHOG_KEY=${POSTHOG_API_KEY}\nENV VITE_PUBLIC_POSTHOG_HOST=${POSTHOG_API_ENDPOINT}\nENV NODE_OPTIONS=--max-old-space-size=4096\n\nRUN corepack enable\nRUN pnpm config set store-dir /pnpm/store\n\nCOPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./\nCOPY packages/local-web/package.json packages/local-web/package.json\nCOPY packages/ui/package.json packages/ui/package.json\nCOPY packages/web-core/package.json packages/web-core/package.json\n\nRUN --mount=type=cache,id=pnpm,target=/pnpm/store \\\n    pnpm install --frozen-lockfile\n\nCOPY packages/local-web/ packages/local-web/\nCOPY packages/public/ packages/public/\nCOPY packages/ui/ packages/ui/\nCOPY packages/web-core/ packages/web-core/\nCOPY shared/ shared/\n\nRUN pnpm -C packages/local-web build\n\nFROM rust:1.93-slim-bookworm AS builder\n\nARG POSTHOG_API_KEY=\"\"\nARG POSTHOG_API_ENDPOINT=\"\"\nARG SENTRY_DSN=\"\"\nARG VK_SHARED_API_BASE=\"\"\n\nENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse\nENV CARGO_NET_GIT_FETCH_WITH_CLI=true\nENV CARGO_TARGET_DIR=/app/target\nENV POSTHOG_API_KEY=${POSTHOG_API_KEY}\nENV POSTHOG_API_ENDPOINT=${POSTHOG_API_ENDPOINT}\nENV SENTRY_DSN=${SENTRY_DSN}\nENV VK_SHARED_API_BASE=${VK_SHARED_API_BASE}\n\nWORKDIR /app\n\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends \\\n    build-essential \\\n    ca-certificates \\\n    git \\\n    libclang-dev \\\n    libssl-dev \\\n    pkg-config \\\n  && rm -rf /var/lib/apt/lists/*\n\nCOPY rust-toolchain.toml ./\nRUN cargo --version >/dev/null\n\nCOPY Cargo.toml Cargo.lock ./\nCOPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml\nCOPY crates/db/Cargo.toml crates/db/Cargo.toml\nCOPY crates/deployment/Cargo.toml crates/deployment/Cargo.toml\nCOPY crates/executors/Cargo.toml crates/executors/Cargo.toml\nCOPY crates/git/Cargo.toml crates/git/Cargo.toml\nCOPY crates/git-host/Cargo.toml crates/git-host/Cargo.toml\nCOPY crates/local-deployment/Cargo.toml crates/local-deployment/Cargo.toml\nCOPY crates/mcp/Cargo.toml crates/mcp/Cargo.toml\nCOPY crates/relay-control/Cargo.toml crates/relay-control/Cargo.toml\nCOPY crates/relay-tunnel/Cargo.toml crates/relay-tunnel/Cargo.toml\nCOPY crates/review/Cargo.toml crates/review/Cargo.toml\nCOPY crates/server/Cargo.toml crates/server/Cargo.toml\nCOPY crates/server-info/Cargo.toml crates/server-info/Cargo.toml\nCOPY crates/services/Cargo.toml crates/services/Cargo.toml\nCOPY crates/tauri-app/Cargo.toml crates/tauri-app/Cargo.toml\nCOPY crates/trusted-key-auth/Cargo.toml crates/trusted-key-auth/Cargo.toml\nCOPY crates/utils/Cargo.toml crates/utils/Cargo.toml\nCOPY crates/workspace-manager/Cargo.toml crates/workspace-manager/Cargo.toml\nCOPY crates/worktree-manager/Cargo.toml crates/worktree-manager/Cargo.toml\n\nCOPY crates/api-types/ crates/api-types/\nCOPY crates/db/ crates/db/\nCOPY crates/deployment/ crates/deployment/\nCOPY crates/executors/ crates/executors/\nCOPY crates/git/ crates/git/\nCOPY crates/git-host/ crates/git-host/\nCOPY crates/local-deployment/ crates/local-deployment/\nCOPY crates/mcp/ crates/mcp/\nCOPY crates/relay-control/ crates/relay-control/\nCOPY crates/relay-tunnel/ crates/relay-tunnel/\nCOPY crates/review/ crates/review/\nCOPY crates/server/ crates/server/\nCOPY crates/server-info/ crates/server-info/\nCOPY crates/services/ crates/services/\nCOPY crates/trusted-key-auth/ crates/trusted-key-auth/\nCOPY crates/utils/ crates/utils/\nCOPY crates/workspace-manager/ crates/workspace-manager/\nCOPY crates/worktree-manager/ crates/worktree-manager/\nCOPY assets/ assets/\nCOPY --from=fe-builder /app/packages/local-web/dist packages/local-web/dist\n\nRUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \\\n    --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \\\n    --mount=type=cache,id=workspace-target,target=/app/target \\\n    cargo build --locked --release --bin server \\\n && cp /app/target/release/server /usr/local/bin/server\n\nFROM debian:bookworm-slim AS runtime\n\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends \\\n    ca-certificates \\\n    git \\\n    openssh-client \\\n    tini \\\n    wget \\\n  && rm -rf /var/lib/apt/lists/* \\\n  && useradd --system --create-home --uid 10001 appuser\n\nWORKDIR /repos\n\nCOPY --from=builder /usr/local/bin/server /usr/local/bin/server\n\nRUN mkdir -p /repos \\\n  && chown -R appuser:appuser /repos\n\nUSER appuser\n\nENV HOST=0.0.0.0\nENV PORT=3000\n\nEXPOSE 3000\n\nHEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\\n  CMD [\"/bin/sh\", \"-c\", \"wget --spider -q http://127.0.0.1:${PORT:-3000}/health\"]\n\nENTRYPOINT [\"/usr/bin/tini\", \"--\", \"/usr/local/bin/server\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://vibekanban.com\">\n    <picture>\n      <source srcset=\"packages/public/vibe-kanban-logo-dark.svg\" media=\"(prefers-color-scheme: dark)\">\n      <source srcset=\"packages/public/vibe-kanban-logo.svg\" media=\"(prefers-color-scheme: light)\">\n      <img src=\"packages/public/vibe-kanban-logo.svg\" alt=\"Vibe Kanban Logo\">\n    </picture>\n  </a>\n</p>\n\n<p align=\"center\">Get 10X more out of Claude Code, Gemini CLI, Codex, Amp and other coding agents...</p>\n<p align=\"center\">\n  <a href=\"https://www.npmjs.com/package/vibe-kanban\"><img alt=\"npm\" src=\"https://img.shields.io/npm/v/vibe-kanban?style=flat-square\" /></a>\n  <a href=\"https://github.com/BloopAI/vibe-kanban/blob/main/.github/workflows/publish.yml\"><img alt=\"Build status\" src=\"https://img.shields.io/github/actions/workflow/status/BloopAI/vibe-kanban/.github%2Fworkflows%2Fpublish.yml\" /></a>\n  <a href=\"https://deepwiki.com/BloopAI/vibe-kanban\"><img src=\"https://deepwiki.com/badge.svg\" alt=\"Ask DeepWiki\"></a>\n</p>\n\n<h1 align=\"center\">\n  <a href=\"https://jobs.polymer.co/vibe-kanban?source=github\"><strong>We're hiring!</strong></a>\n</h1>\n\n![](packages/public/vibe-kanban-screenshot-overview.png)\n\n## Overview\n\nIn a world where software engineers spend most of their time planning and reviewing coding agents, the most impactful way to ship more is to get faster at planning and review.\n\nVibe Kanban is built for this. Use kanban issues to plan work, either privately or with your team. When you're ready to begin, create workspaces where coding agents can execute.\n\n- **Plan with kanban issues** — create, prioritise, and assign issues on a kanban board\n- **Run coding agents in workspaces** — each workspace gives an agent a branch, a terminal, and a dev server\n- **Review diffs and leave inline comments** — send feedback directly to the agent without leaving the UI\n- **Preview your app** — built-in browser with devtools, inspect mode, and device emulation\n- **Switch between 10+ coding agents** — Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, and Qwen Code\n- **Create pull requests and merge** — open PRs with AI-generated descriptions, review on GitHub, and merge\n\n![](packages/public/vibe-kanban-screenshot-workspace.png)\n\nOne command. Describe the work, review the diff, ship it.\n\n```bash\nnpx vibe-kanban\n```\n\n\n## Installation\n\nMake sure you have authenticated with your favourite coding agent. A full list of supported coding agents can be found in the [docs](https://vibekanban.com/docs/supported-coding-agents). Then in your terminal run:\n\n```bash\nnpx vibe-kanban\n```\n\n## Documentation\n\nHead to the [website](https://vibekanban.com/docs) for the latest documentation and user guides.\n\n## Self-Hosting\n\nWant to host your own Vibe Kanban Cloud instance? See our [self-hosting guide](https://vibekanban.com/docs/self-hosting/deploy-docker).\n\n## Support\n\nWe use [GitHub Discussions](https://github.com/BloopAI/vibe-kanban/discussions) for feature requests. Please open a discussion to create a feature request. For bugs please open an issue on this repo.\n\n## Contributing\n\nWe would prefer that ideas and changes are first raised with the core team via [GitHub Discussions](https://github.com/BloopAI/vibe-kanban/discussions) or [Discord](https://discord.gg/AC4nwVtJM3), where we can discuss implementation details and alignment with the existing roadmap. Please do not open PRs without first discussing your proposal with the team.\n\n## Development\n\n### Prerequisites\n\n- [Rust](https://rustup.rs/) (latest stable)\n- [Node.js](https://nodejs.org/) (>=20)\n- [pnpm](https://pnpm.io/) (>=8)\n\nAdditional development tools:\n```bash\ncargo install cargo-watch\ncargo install sqlx-cli\n```\n\nInstall dependencies:\n```bash\npnpm i\n```\n\n### Running the dev server\n\n```bash\npnpm run dev\n```\n\nThis will start the backend and web app. A blank DB will be copied from the `dev_assets_seed` folder.\n\n### Building the web app\n\nTo build just the web app:\n\n```bash\ncd packages/local-web\npnpm run build\n```\n\n### Build from source (macOS)\n\n1. Run `./local-build.sh`\n2. Test with `cd npx-cli && node bin/cli.js`\n\n### Environment Variables\n\nThe following environment variables can be configured at build time or runtime:\n\n| Variable | Type | Default | Description |\n|----------|------|---------|-------------|\n| `POSTHOG_API_KEY` | Build-time | Empty | PostHog analytics API key (disables analytics if empty) |\n| `POSTHOG_API_ENDPOINT` | Build-time | Empty | PostHog analytics endpoint (disables analytics if empty) |\n| `PORT` | Runtime | Auto-assign | **Production**: Server port. **Dev**: Frontend port (backend uses PORT+1) |\n| `BACKEND_PORT` | Runtime | `0` (auto-assign) | Backend server port (dev mode only, overrides PORT+1) |\n| `FRONTEND_PORT` | Runtime | `3000` | Frontend dev server port (dev mode only, overrides PORT) |\n| `HOST` | Runtime | `127.0.0.1` | Backend server host |\n| `MCP_HOST` | Runtime | Value of `HOST` | MCP server connection host (use `127.0.0.1` when `HOST=0.0.0.0` on Windows) |\n| `MCP_PORT` | Runtime | Value of `BACKEND_PORT` | MCP server connection port |\n| `DISABLE_WORKTREE_CLEANUP` | Runtime | Not set | Disable all git worktree cleanup including orphan and expired workspace cleanup (for debugging) |\n| `VK_ALLOWED_ORIGINS` | Runtime | Not set | Comma-separated list of origins that are allowed to make backend API requests (e.g., `https://my-vibekanban-frontend.com`) |\n| `VK_SHARED_API_BASE` | Runtime | Not set | Base URL for the remote/cloud API used by the local desktop app |\n| `VK_SHARED_RELAY_API_BASE` | Runtime | Not set | Base URL for the relay API used by tunnel-mode connections |\n| `VK_TUNNEL` | Runtime | Not set | Enable relay tunnel mode when set (requires relay API base URL) |\n\n**Build-time variables** must be set when running `pnpm run build`. **Runtime variables** are read when the application starts.\n\n#### Self-Hosting with a Reverse Proxy or Custom Domain\n\nWhen running Vibe Kanban behind a reverse proxy (e.g., nginx, Caddy, Traefik) or on a custom domain, you must set the `VK_ALLOWED_ORIGINS` environment variable. Without this, the browser's Origin header won't match the backend's expected host, and API requests will be rejected with a 403 Forbidden error.\n\nSet it to the full origin URL(s) where your frontend is accessible:\n\n```bash\n# Single origin\nVK_ALLOWED_ORIGINS=https://vk.example.com\n\n# Multiple origins (comma-separated)\nVK_ALLOWED_ORIGINS=https://vk.example.com,https://vk-staging.example.com\n```\n\n### Remote Deployment\n\nWhen running Vibe Kanban on a remote server (e.g., via systemctl, Docker, or cloud hosting), you can configure your editor to open projects via SSH:\n\n1. **Access via tunnel**: Use Cloudflare Tunnel, ngrok, or similar to expose the web UI\n2. **Configure remote SSH** in Settings → Editor Integration:\n   - Set **Remote SSH Host** to your server hostname or IP\n   - Set **Remote SSH User** to your SSH username (optional)\n3. **Prerequisites**:\n   - SSH access from your local machine to the remote server\n   - SSH keys configured (passwordless authentication)\n   - VSCode Remote-SSH extension\n\nWhen configured, the \"Open in VSCode\" buttons will generate URLs like `vscode://vscode-remote/ssh-remote+user@host/path` that open your local editor and connect to the remote server.\n\nSee the [documentation](https://vibekanban.com/docs/settings/general) for detailed setup instructions.\n"
  },
  {
    "path": "assets/scripts/toast-notification.ps1",
    "content": "param(\n    [Parameter(Mandatory=$true)]\n    [string]$Title,\n    \n    [Parameter(Mandatory=$true)]\n    [string]$Message,\n    \n    [Parameter(Mandatory=$false)]\n    [string]$AppName = \"Vibe Kanban\"\n)\n\n[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)\n$RawXml = [xml] $Template.GetXml()\n($RawXml.toast.visual.binding.text|where {$_.id -eq \"1\"}).AppendChild($RawXml.CreateTextNode($Title)) | Out-Null\n($RawXml.toast.visual.binding.text|where {$_.id -eq \"2\"}).AppendChild($RawXml.CreateTextNode($Message)) | Out-Null\n$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument\n$SerializedXml.LoadXml($RawXml.OuterXml)\n$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)\n$Toast.Tag = $AppName\n$Toast.Group = $AppName\n$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppName)\n$Notifier.Show($Toast)"
  },
  {
    "path": "crates/api-types/Cargo.toml",
    "content": "[package]\nname = \"api-types\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\nchrono = { version = \"0.4\", features = [\"serde\"] }\nschemars = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nsqlx = { version = \"0.8.6\", default-features = false, features = [\"postgres\", \"uuid\", \"chrono\", \"derive\"] }\nts-rs = { workspace = true }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\n"
  },
  {
    "path": "crates/api-types/src/attachment.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n/// An attachment links a blob to an issue or comment.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct Attachment {\n    pub id: Uuid,\n    pub blob_id: Uuid,\n    pub issue_id: Option<Uuid>,\n    pub comment_id: Option<Uuid>,\n    pub created_at: DateTime<Utc>,\n    pub expires_at: Option<DateTime<Utc>>,\n}\n\n/// An attachment with its associated blob data (for API responses).\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct AttachmentWithBlob {\n    pub id: Uuid,\n    pub blob_id: Uuid,\n    pub issue_id: Option<Uuid>,\n    pub comment_id: Option<Uuid>,\n    pub created_at: DateTime<Utc>,\n    pub expires_at: Option<DateTime<Utc>>,\n    // Blob fields\n    pub blob_path: String,\n    pub thumbnail_blob_path: Option<String>,\n    pub original_name: String,\n    pub mime_type: Option<String>,\n    pub size_bytes: i64,\n    pub hash: String,\n    pub width: Option<i32>,\n    pub height: Option<i32>,\n}\n\n/// An attachment with blob data and a presigned file URL.\n#[derive(Debug, Serialize, Deserialize)]\npub struct AttachmentWithUrl {\n    #[serde(flatten)]\n    pub attachment: AttachmentWithBlob,\n    pub file_url: Option<String>,\n}\n\n/// Response from listing attachments.\n#[derive(Debug, Serialize, Deserialize)]\npub struct ListAttachmentsResponse {\n    pub attachments: Vec<AttachmentWithUrl>,\n}\n\n/// Response containing a presigned URL for an attachment file or thumbnail.\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct AttachmentUrlResponse {\n    pub url: String,\n}\n"
  },
  {
    "path": "crates/api-types/src/auth.rs",
    "content": "use chrono::{DateTime, Duration, Utc};\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, sqlx::FromRow, Serialize)]\npub struct AuthSession {\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub created_at: DateTime<Utc>,\n    pub last_used_at: Option<DateTime<Utc>>,\n    pub revoked_at: Option<DateTime<Utc>>,\n    pub refresh_token_id: Option<Uuid>,\n    pub refresh_token_issued_at: Option<DateTime<Utc>>,\n}\n\nimpl AuthSession {\n    pub fn last_activity_at(&self) -> DateTime<Utc> {\n        self.last_used_at.unwrap_or(self.created_at)\n    }\n\n    pub fn inactivity_duration(&self, now: DateTime<Utc>) -> Duration {\n        now.signed_duration_since(self.last_activity_at())\n    }\n}\n"
  },
  {
    "path": "crates/api-types/src/blob.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct Blob {\n    pub id: Uuid,\n    pub project_id: Uuid,\n    pub blob_path: String,\n    pub thumbnail_blob_path: Option<String>,\n    pub original_name: String,\n    pub mime_type: Option<String>,\n    pub size_bytes: i64,\n    pub hash: String,\n    pub width: Option<i32>,\n    pub height: Option<i32>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n"
  },
  {
    "path": "crates/api-types/src/issue.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse sqlx::Type;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)]\n#[sqlx(type_name = \"issue_priority\", rename_all = \"snake_case\")]\n#[serde(rename_all = \"snake_case\")]\npub enum IssuePriority {\n    Urgent,\n    High,\n    Medium,\n    Low,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, sqlx::FromRow)]\npub struct Issue {\n    pub id: Uuid,\n    pub project_id: Uuid,\n    pub issue_number: i32,\n    pub simple_id: String,\n    pub status_id: Uuid,\n    pub title: String,\n    pub description: Option<String>,\n    pub priority: Option<IssuePriority>,\n    pub start_date: Option<DateTime<Utc>>,\n    pub target_date: Option<DateTime<Utc>>,\n    pub completed_at: Option<DateTime<Utc>>,\n    pub sort_order: f64,\n    pub parent_issue_id: Option<Uuid>,\n    pub parent_issue_sort_order: Option<f64>,\n    pub extension_metadata: Value,\n    pub creator_user_id: Option<Uuid>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\npub enum IssueSortField {\n    SortOrder,\n    Priority,\n    CreatedAt,\n    UpdatedAt,\n    Title,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\npub enum SortDirection {\n    Asc,\n    Desc,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateIssueRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub project_id: Uuid,\n    pub status_id: Uuid,\n    pub title: String,\n    pub description: Option<String>,\n    pub priority: Option<IssuePriority>,\n    pub start_date: Option<DateTime<Utc>>,\n    pub target_date: Option<DateTime<Utc>>,\n    pub completed_at: Option<DateTime<Utc>>,\n    pub sort_order: f64,\n    pub parent_issue_id: Option<Uuid>,\n    pub parent_issue_sort_order: Option<f64>,\n    pub extension_metadata: Value,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct UpdateIssueRequest {\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub status_id: Option<Uuid>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub title: Option<String>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub description: Option<Option<String>>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub priority: Option<Option<IssuePriority>>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub start_date: Option<Option<DateTime<Utc>>>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub target_date: Option<Option<DateTime<Utc>>>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub completed_at: Option<Option<DateTime<Utc>>>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub sort_order: Option<f64>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub parent_issue_id: Option<Option<Uuid>>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub parent_issue_sort_order: Option<Option<f64>>,\n    #[serde(\n        default,\n        deserialize_with = \"some_if_present\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub extension_metadata: Option<Value>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListIssuesQuery {\n    pub project_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct SearchIssuesRequest {\n    pub project_id: Uuid,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub status_id: Option<Uuid>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub status_ids: Option<Vec<Uuid>>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub priority: Option<IssuePriority>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub parent_issue_id: Option<Uuid>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub search: Option<String>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub simple_id: Option<String>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub assignee_user_id: Option<Uuid>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tag_id: Option<Uuid>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tag_ids: Option<Vec<Uuid>>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub sort_field: Option<IssueSortField>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub sort_direction: Option<SortDirection>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub limit: Option<i32>,\n    #[ts(optional)]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub offset: Option<i32>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListIssuesResponse {\n    pub issues: Vec<Issue>,\n    pub total_count: usize,\n    pub limit: usize,\n    pub offset: usize,\n}\n"
  },
  {
    "path": "crates/api-types/src/issue_assignee.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct IssueAssignee {\n    pub id: Uuid,\n    pub issue_id: Uuid,\n    pub user_id: Uuid,\n    pub assigned_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateIssueAssigneeRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub issue_id: Uuid,\n    pub user_id: Uuid,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateIssueAssigneeRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub user_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListIssueAssigneesQuery {\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListIssueAssigneesResponse {\n    pub issue_assignees: Vec<IssueAssignee>,\n}\n"
  },
  {
    "path": "crates/api-types/src/issue_comment.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct IssueComment {\n    pub id: Uuid,\n    pub issue_id: Uuid,\n    pub author_id: Option<Uuid>,\n    pub parent_id: Option<Uuid>,\n    pub message: String,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct CreateIssueCommentRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub issue_id: Uuid,\n    pub message: String,\n    pub parent_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateIssueCommentRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub message: Option<String>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub parent_id: Option<Option<Uuid>>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListIssueCommentsQuery {\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, TS)]\npub struct ListIssueCommentsResponse {\n    pub issue_comments: Vec<IssueComment>,\n}\n"
  },
  {
    "path": "crates/api-types/src/issue_comment_reaction.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct IssueCommentReaction {\n    pub id: Uuid,\n    pub comment_id: Uuid,\n    pub user_id: Uuid,\n    pub emoji: String,\n    pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct CreateIssueCommentReactionRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub comment_id: Uuid,\n    pub emoji: String,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateIssueCommentReactionRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub emoji: Option<String>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListIssueCommentReactionsQuery {\n    pub comment_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, TS)]\npub struct ListIssueCommentReactionsResponse {\n    pub issue_comment_reactions: Vec<IssueCommentReaction>,\n}\n"
  },
  {
    "path": "crates/api-types/src/issue_follower.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct IssueFollower {\n    pub id: Uuid,\n    pub issue_id: Uuid,\n    pub user_id: Uuid,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct CreateIssueFollowerRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub issue_id: Uuid,\n    pub user_id: Uuid,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateIssueFollowerRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub user_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListIssueFollowersQuery {\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, TS)]\npub struct ListIssueFollowersResponse {\n    pub issue_followers: Vec<IssueFollower>,\n}\n"
  },
  {
    "path": "crates/api-types/src/issue_relationship.rs",
    "content": "use chrono::{DateTime, Utc};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse sqlx::Type;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS, JsonSchema)]\n#[sqlx(type_name = \"issue_relationship_type\", rename_all = \"snake_case\")]\n#[serde(rename_all = \"snake_case\")]\npub enum IssueRelationshipType {\n    Blocking,\n    Related,\n    HasDuplicate,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct IssueRelationship {\n    pub id: Uuid,\n    pub issue_id: Uuid,\n    pub related_issue_id: Uuid,\n    pub relationship_type: IssueRelationshipType,\n    pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateIssueRelationshipRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub issue_id: Uuid,\n    pub related_issue_id: Uuid,\n    pub relationship_type: IssueRelationshipType,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateIssueRelationshipRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub related_issue_id: Option<Uuid>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub relationship_type: Option<IssueRelationshipType>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListIssueRelationshipsQuery {\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListIssueRelationshipsResponse {\n    pub issue_relationships: Vec<IssueRelationship>,\n}\n"
  },
  {
    "path": "crates/api-types/src/issue_tag.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct IssueTag {\n    pub id: Uuid,\n    pub issue_id: Uuid,\n    pub tag_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateIssueTagRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub issue_id: Uuid,\n    pub tag_id: Uuid,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateIssueTagRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub tag_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListIssueTagsQuery {\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListIssueTagsResponse {\n    pub issue_tags: Vec<IssueTag>,\n}\n"
  },
  {
    "path": "crates/api-types/src/lib.rs",
    "content": "//! API types shared between local and remote backends.\n//!\n//! This crate contains:\n//! - Row types (e.g., `Issue`, `Project`) - the API representation of database entities\n//! - Request types (e.g., `CreateIssueRequest`, `UpdateIssueRequest`) - API input types\n//! - Shared enums (e.g., `IssuePriority`, `PullRequestStatus`)\n\nuse serde::{Deserialize, Deserializer};\n\npub mod attachment;\npub mod auth;\npub mod blob;\npub mod issue;\npub mod issue_assignee;\npub mod issue_comment;\npub mod issue_comment_reaction;\npub mod issue_follower;\npub mod issue_relationship;\npub mod issue_tag;\npub mod migration;\npub mod notification;\npub mod oauth;\npub mod organization_member;\npub mod organizations;\npub mod project;\npub mod project_status;\npub mod pull_request;\npub mod pull_requests_local;\npub mod relay;\npub mod response;\npub mod tag;\npub mod user;\npub mod workspace;\npub mod workspaces;\n\npub use attachment::*;\npub use auth::*;\npub use blob::*;\npub use issue::*;\npub use issue_assignee::*;\npub use issue_comment::*;\npub use issue_comment_reaction::*;\npub use issue_follower::*;\npub use issue_relationship::*;\npub use issue_tag::*;\npub use migration::*;\npub use notification::*;\npub use oauth::*;\npub use organization_member::*;\npub use organizations::*;\npub use project::*;\npub use project_status::*;\npub use pull_request::*;\npub use pull_requests_local::*;\npub use relay::*;\npub use response::*;\npub use tag::*;\npub use user::*;\npub use workspace::*;\npub use workspaces::*;\n\npub fn some_if_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>\nwhere\n    D: Deserializer<'de>,\n    T: Deserialize<'de>,\n{\n    T::deserialize(deserializer).map(Some)\n}\n"
  },
  {
    "path": "crates/api-types/src/migration.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MigrateProjectRequest {\n    pub organization_id: Uuid,\n    pub name: String,\n    pub color: String,\n    pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MigrateIssueRequest {\n    pub project_id: Uuid,\n    pub status_name: String,\n    pub title: String,\n    pub description: Option<String>,\n    pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MigratePullRequestRequest {\n    pub url: String,\n    pub number: i32,\n    pub status: String,\n    pub merged_at: Option<DateTime<Utc>>,\n    pub merge_commit_sha: Option<String>,\n    pub target_branch_name: String,\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MigrateWorkspaceRequest {\n    pub project_id: Uuid,\n    pub issue_id: Option<Uuid>,\n    pub local_workspace_id: Uuid,\n    pub archived: bool,\n    pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct BulkMigrateRequest<T> {\n    pub items: Vec<T>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct BulkMigrateResponse {\n    pub ids: Vec<Uuid>,\n}\n"
  },
  {
    "path": "crates/api-types/src/notification.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::Type;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::{IssuePriority, some_if_present};\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[sqlx(type_name = \"notification_type\", rename_all = \"snake_case\")]\npub enum NotificationType {\n    IssueCommentAdded,\n    IssueStatusChanged,\n    IssueAssigneeChanged,\n    IssuePriorityChanged,\n    IssueUnassigned,\n    IssueCommentReaction,\n    IssueDeleted,\n    IssueTitleChanged,\n    IssueDescriptionChanged,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationGroupKind {\n    Single,\n    IssueChanges,\n    StatusChanges,\n    Comments,\n    Reactions,\n    IssueDeleted,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct Notification {\n    pub id: Uuid,\n    pub organization_id: Uuid,\n    pub user_id: Uuid,\n    pub notification_type: NotificationType,\n    pub payload: NotificationPayload,\n    pub issue_id: Option<Uuid>,\n    pub comment_id: Option<Uuid>,\n    pub seen: bool,\n    pub dismissed_at: Option<DateTime<Utc>>,\n    pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\npub struct NotificationPayload {\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub deeplink_path: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub issue_id: Option<Uuid>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub issue_simple_id: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub issue_title: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub actor_user_id: Option<Uuid>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub comment_preview: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub old_status_id: Option<Uuid>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub new_status_id: Option<Uuid>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub old_status_name: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub new_status_name: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub new_title: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub old_priority: Option<IssuePriority>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub new_priority: Option<IssuePriority>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub assignee_user_id: Option<Uuid>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub emoji: Option<String>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateNotificationRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub seen: Option<bool>,\n}\n"
  },
  {
    "path": "crates/api-types/src/oauth.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct HandoffInitRequest {\n    pub provider: String,\n    pub return_to: String,\n    pub app_challenge: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct HandoffInitResponse {\n    pub handoff_id: Uuid,\n    pub authorize_url: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct HandoffRedeemRequest {\n    pub handoff_id: Uuid,\n    pub app_code: String,\n    pub app_verifier: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct HandoffRedeemResponse {\n    pub access_token: String,\n    pub refresh_token: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct TokenRefreshRequest {\n    pub refresh_token: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct TokenRefreshResponse {\n    pub access_token: String,\n    pub refresh_token: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct ProviderProfile {\n    pub provider: String,\n    pub username: Option<String>,\n    pub display_name: Option<String>,\n    pub email: Option<String>,\n    pub avatar_url: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct ProfileResponse {\n    pub user_id: Uuid,\n    pub username: Option<String>,\n    pub email: String,\n    pub providers: Vec<ProviderProfile>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\n#[serde(tag = \"status\", rename_all = \"lowercase\")]\npub enum LoginStatus {\n    LoggedOut,\n    LoggedIn { profile: ProfileResponse },\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, TS)]\npub struct StatusResponse {\n    pub logged_in: bool,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub profile: Option<ProfileResponse>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub degraded: Option<bool>,\n}\n"
  },
  {
    "path": "crates/api-types/src/organization_member.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::Type;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[sqlx(type_name = \"member_role\", rename_all = \"lowercase\")]\n#[ts(use_ts_enum)]\n#[ts(rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum MemberRole {\n    Admin,\n    Member,\n}\n\n/// Organization member as stored in the database / streamed via Electric.\n/// This is the full row type with organization_id for shapes.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct OrganizationMember {\n    pub organization_id: Uuid,\n    pub user_id: Uuid,\n    pub role: MemberRole,\n    pub joined_at: DateTime<Utc>,\n    pub last_seen_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/api-types/src/organizations.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::Type;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::MemberRole;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[sqlx(type_name = \"invitation_status\", rename_all = \"lowercase\")]\n#[ts(use_ts_enum)]\n#[ts(rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum InvitationStatus {\n    Pending,\n    Accepted,\n    Declined,\n    Expired,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)]\npub struct Organization {\n    pub id: Uuid,\n    pub name: String,\n    pub slug: String,\n    pub is_personal: bool,\n    pub issue_prefix: String,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)]\npub struct OrganizationWithRole {\n    pub id: Uuid,\n    pub name: String,\n    pub slug: String,\n    pub is_personal: bool,\n    pub issue_prefix: String,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n    pub user_role: MemberRole,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListOrganizationsResponse {\n    pub organizations: Vec<OrganizationWithRole>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct GetOrganizationResponse {\n    pub organization: Organization,\n    pub user_role: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateOrganizationRequest {\n    pub name: String,\n    pub slug: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateOrganizationResponse {\n    pub organization: OrganizationWithRole,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct UpdateOrganizationRequest {\n    pub name: String,\n}\n\n// Invitation types\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct Invitation {\n    pub id: Uuid,\n    pub organization_id: Uuid,\n    pub invited_by_user_id: Option<Uuid>,\n    pub email: String,\n    pub role: MemberRole,\n    pub status: InvitationStatus,\n    pub token: String,\n    pub created_at: DateTime<Utc>,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateInvitationRequest {\n    pub email: String,\n    pub role: MemberRole,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateInvitationResponse {\n    pub invitation: Invitation,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListInvitationsResponse {\n    pub invitations: Vec<Invitation>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct GetInvitationResponse {\n    pub id: Uuid,\n    pub organization_slug: String,\n    pub role: MemberRole,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct AcceptInvitationResponse {\n    pub organization_id: String,\n    pub organization_slug: String,\n    pub role: MemberRole,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct RevokeInvitationRequest {\n    pub invitation_id: Uuid,\n}\n\n// Member types\n\n/// Organization member info for API responses (without organization_id).\n/// See also `OrganizationMember` in organization_member.rs for the full DB row type.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct OrganizationMemberInfo {\n    pub user_id: Uuid,\n    pub role: MemberRole,\n    pub joined_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct OrganizationMemberWithProfile {\n    pub user_id: Uuid,\n    pub role: MemberRole,\n    pub joined_at: DateTime<Utc>,\n    pub first_name: Option<String>,\n    pub last_name: Option<String>,\n    pub username: Option<String>,\n    pub email: Option<String>,\n    pub avatar_url: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListMembersResponse {\n    pub members: Vec<OrganizationMemberWithProfile>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct UpdateMemberRoleRequest {\n    pub role: MemberRole,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct UpdateMemberRoleResponse {\n    pub user_id: Uuid,\n    pub role: MemberRole,\n}\n"
  },
  {
    "path": "crates/api-types/src/project.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct Project {\n    pub id: Uuid,\n    pub organization_id: Uuid,\n    pub name: String,\n    pub color: String,\n    pub sort_order: i32,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct CreateProjectRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub organization_id: Uuid,\n    pub name: String,\n    pub color: String,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateProjectRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub name: Option<String>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub color: Option<String>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub sort_order: Option<i32>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListProjectsQuery {\n    pub organization_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListProjectsResponse {\n    pub projects: Vec<Project>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct BulkUpdateProjectItem {\n    pub id: Uuid,\n    #[serde(flatten)]\n    pub changes: UpdateProjectRequest,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct BulkUpdateProjectsRequest {\n    pub updates: Vec<BulkUpdateProjectItem>,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct BulkUpdateProjectsResponse {\n    pub data: Vec<Project>,\n    pub txid: i64,\n}\n"
  },
  {
    "path": "crates/api-types/src/project_status.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ProjectStatus {\n    pub id: Uuid,\n    pub project_id: Uuid,\n    pub name: String,\n    pub color: String,\n    pub sort_order: i32,\n    pub hidden: bool,\n    pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct CreateProjectStatusRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub project_id: Uuid,\n    pub name: String,\n    pub color: String,\n    pub sort_order: i32,\n    pub hidden: bool,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateProjectStatusRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub name: Option<String>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub color: Option<String>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub sort_order: Option<i32>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub hidden: Option<bool>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListProjectStatusesQuery {\n    pub project_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListProjectStatusesResponse {\n    pub project_statuses: Vec<ProjectStatus>,\n}\n"
  },
  {
    "path": "crates/api-types/src/pull_request.rs",
    "content": "use chrono::{DateTime, Utc};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse sqlx::Type;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS, JsonSchema)]\n#[sqlx(type_name = \"pull_request_status\", rename_all = \"lowercase\")]\n#[serde(rename_all = \"lowercase\")]\npub enum PullRequestStatus {\n    Open,\n    Merged,\n    Closed,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct PullRequest {\n    pub id: Uuid,\n    pub url: String,\n    pub number: i32,\n    pub status: PullRequestStatus,\n    pub merged_at: Option<DateTime<Utc>>,\n    pub merge_commit_sha: Option<String>,\n    pub target_branch_name: String,\n    pub issue_id: Uuid,\n    pub workspace_id: Option<Uuid>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListPullRequestsQuery {\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListPullRequestsResponse {\n    pub pull_requests: Vec<PullRequest>,\n}\n"
  },
  {
    "path": "crates/api-types/src/pull_requests_local.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse crate::PullRequestStatus;\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct UpsertPullRequestRequest {\n    pub url: String,\n    pub number: i32,\n    pub status: PullRequestStatus,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub merged_at: Option<DateTime<Utc>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub merge_commit_sha: Option<String>,\n    pub target_branch_name: String,\n    pub local_workspace_id: Uuid,\n}\n"
  },
  {
    "path": "crates/api-types/src/relay.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)]\npub struct RelayHost {\n    pub id: Uuid,\n    pub owner_user_id: Uuid,\n    pub name: String,\n    pub status: String,\n    pub last_seen_at: Option<DateTime<Utc>>,\n    pub agent_version: Option<String>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n    pub access_role: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListRelayHostsResponse {\n    pub hosts: Vec<RelayHost>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct RelaySessionAuthCodeResponse {\n    pub session_id: Uuid,\n    pub code: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)]\npub struct RelaySession {\n    pub id: Uuid,\n    pub host_id: Uuid,\n    pub request_user_id: Uuid,\n    pub state: String,\n    pub created_at: DateTime<Utc>,\n    pub expires_at: DateTime<Utc>,\n    pub claimed_at: Option<DateTime<Utc>>,\n    pub ended_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/api-types/src/response.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\n/// Response wrapper for mutation endpoints (create/update).\n/// Includes the Postgres transaction ID for Electric sync.\n#[derive(Debug, Serialize, Deserialize)]\npub struct MutationResponse<T> {\n    pub data: T,\n    pub txid: i64,\n}\n\n/// Response wrapper for delete endpoints.\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct DeleteResponse {\n    pub txid: i64,\n}\n"
  },
  {
    "path": "crates/api-types/src/tag.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::some_if_present;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct Tag {\n    pub id: Uuid,\n    pub project_id: Uuid,\n    pub name: String,\n    pub color: String,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct CreateTagRequest {\n    /// Optional client-generated ID. If not provided, server generates one.\n    /// Using client-generated IDs enables stable optimistic updates.\n    #[ts(optional)]\n    pub id: Option<Uuid>,\n    pub project_id: Uuid,\n    pub name: String,\n    pub color: String,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateTagRequest {\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub name: Option<String>,\n    #[serde(default, deserialize_with = \"some_if_present\")]\n    pub color: Option<String>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ListTagsQuery {\n    pub project_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ListTagsResponse {\n    pub tags: Vec<Tag>,\n}\n"
  },
  {
    "path": "crates/api-types/src/user.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)]\npub struct User {\n    pub id: Uuid,\n    pub email: String,\n    pub first_name: Option<String>,\n    pub last_name: Option<String>,\n    pub username: Option<String>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct UserData {\n    pub user_id: Uuid,\n    pub first_name: Option<String>,\n    pub last_name: Option<String>,\n    pub username: Option<String>,\n}\n"
  },
  {
    "path": "crates/api-types/src/workspace.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n/// Workspace metadata pushed from local clients\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)]\npub struct Workspace {\n    pub id: Uuid,\n    pub project_id: Uuid,\n    pub owner_user_id: Uuid,\n    pub issue_id: Option<Uuid>,\n    pub local_workspace_id: Option<Uuid>,\n    pub name: Option<String>,\n    pub archived: bool,\n    pub files_changed: Option<i32>,\n    pub lines_added: Option<i32>,\n    pub lines_removed: Option<i32>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n"
  },
  {
    "path": "crates/api-types/src/workspaces.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct DeleteWorkspaceRequest {\n    pub local_workspace_id: Uuid,\n}\n\n#[derive(Debug, Serialize)]\npub struct CreateWorkspaceRequest {\n    pub project_id: Uuid,\n    pub local_workspace_id: Uuid,\n    pub issue_id: Uuid,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub name: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub archived: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub files_changed: Option<i32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub lines_added: Option<i32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub lines_removed: Option<i32>,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct UpdateWorkspaceRequest {\n    pub local_workspace_id: Uuid,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub name: Option<Option<String>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub archived: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub files_changed: Option<Option<i32>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub lines_added: Option<Option<i32>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub lines_removed: Option<Option<i32>>,\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-039c2290b6cf7cdc905c8ddc44293f067fe7e8f246da737e4baad3f494ac8b8f.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO execution_processes (\\n                    id, session_id, run_reason, executor_action,\\n                    status, exit_code, started_at, completed_at, created_at, updated_at\\n                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 10\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"039c2290b6cf7cdc905c8ddc44293f067fe7e8f246da737e4baad3f494ac8b8f\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-04c207be2c3c2c07ff42c695542504c358d67c1f40ca2b1e75a396a90c173a53.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT eprs.after_head_commit\\n               FROM execution_process_repo_states eprs\\n               JOIN execution_processes ep ON ep.id = eprs.execution_process_id\\n              WHERE ep.session_id = $1\\n                AND eprs.repo_id = $2\\n                AND ep.created_at < (SELECT created_at FROM execution_processes WHERE id = $3)\\n              ORDER BY ep.created_at DESC\\n              LIMIT 1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"after_head_commit\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"04c207be2c3c2c07ff42c695542504c358d67c1f40ca2b1e75a396a90c173a53\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-04e5a05c7cad438d39c4c8590410889ab1eefa7376d474a10c119d3f4d9143c7.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                execution_process_id as \\\"execution_process_id!: Uuid\\\",\\n                agent_session_id,\\n                agent_message_id,\\n                prompt,\\n                summary,\\n                seen as \\\"seen!: bool\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM coding_agent_turns\\n               WHERE agent_session_id = ?\\n               ORDER BY updated_at DESC\\n               LIMIT 1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"execution_process_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"agent_session_id\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"agent_message_id\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"prompt\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"summary\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"seen!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"04e5a05c7cad438d39c4c8590410889ab1eefa7376d474a10c119d3f4d9143c7\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-04f17449e3e12785affab91e4eab308103491e34c022199b7b060e04fa8aed0f.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      file_path as \\\"file_path!\\\",\\n                      original_name as \\\"original_name!\\\",\\n                      mime_type,\\n                      size_bytes as \\\"size_bytes!\\\",\\n                      hash as \\\"hash!\\\",\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM attachments\\n               WHERE hash = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"file_path!\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"original_name!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"mime_type\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"size_bytes!\",\n        \"ordinal\": 4,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"hash!\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"04f17449e3e12785affab91e4eab308103491e34c022199b7b060e04fa8aed0f\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-0a805c219c9028b2677bd94ccabd47916e60d26c1cede27e467f0ae91f6639ab.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE migration_state\\n            SET status = 'migrated',\\n                remote_id = $3,\\n                error_message = NULL,\\n                updated_at = datetime('now', 'subsec')\\n            WHERE entity_type = $1 AND local_id = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"0a805c219c9028b2677bd94ccabd47916e60d26c1cede27e467f0ae91f6639ab\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-0ab07fb562e61148f3f07f33f766ea12c73d467df4522240008370f681c8409a.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspace_repos\\n               SET target_branch = $1, updated_at = datetime('now')\\n               WHERE target_branch = $2\\n                 AND workspace_id IN (\\n                     SELECT w.id FROM workspaces w\\n                     JOIN tasks t ON w.task_id = t.id\\n                     WHERE t.parent_workspace_id = $3\\n                 )\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"0ab07fb562e61148f3f07f33f766ea12c73d467df4522240008370f681c8409a\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-0ac0d0f3826330836e3fd1bf57c42777eb489ac41a650f9361e6b563fc69bf35.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE migration_state\\n            SET status = 'pending',\\n                error_message = NULL,\\n                updated_at = datetime('now', 'subsec')\\n            WHERE status = 'failed'\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"0ac0d0f3826330836e3fd1bf57c42777eb489ac41a650f9361e6b563fc69bf35\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-0c7b20643f119afd3e233105b0fa2920e8e940bdad86cdc95d01e485a20d6ed4.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                    ep.id as \\\"id!: Uuid\\\",\\n                    ep.session_id as \\\"session_id!: Uuid\\\",\\n                    ep.run_reason as \\\"run_reason!: ExecutionProcessRunReason\\\",\\n                    ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\",\\n                    ep.status as \\\"status!: ExecutionProcessStatus\\\",\\n                    ep.exit_code,\\n                    ep.dropped as \\\"dropped!: bool\\\",\\n                    ep.started_at as \\\"started_at!: DateTime<Utc>\\\",\\n                    ep.completed_at as \\\"completed_at?: DateTime<Utc>\\\",\\n                    ep.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                    ep.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM execution_processes ep\\n               JOIN sessions s ON ep.session_id = s.id\\n               WHERE s.workspace_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE\\n               ORDER BY ep.created_at DESC LIMIT 1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"run_reason!: ExecutionProcessRunReason\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"exit_code\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dropped!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"started_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"0c7b20643f119afd3e233105b0fa2920e8e940bdad86cdc95d01e485a20d6ed4\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-0f90844fc62261ed140e02515ae464b940743113814507313c9fdc176000d1bf.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE migration_state\\n            SET status = 'skipped',\\n                error_message = $3,\\n                updated_at = datetime('now', 'subsec')\\n            WHERE entity_type = $1 AND local_id = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"0f90844fc62261ed140e02515ae464b940743113814507313c9fdc176000d1bf\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-1085d1f8107c7e16fc2058ef610918760d8d420f0fca97adecd76d698f6f3a51.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                workspace_id as \\\"workspace_id!: Uuid\\\",\\n                repo_id as \\\"repo_id!: Uuid\\\",\\n                merge_type as \\\"merge_type!: MergeType\\\",\\n                merge_commit,\\n                pr_number,\\n                pr_url,\\n                pr_status as \\\"pr_status?: MergeStatus\\\",\\n                pr_merged_at as \\\"pr_merged_at?: DateTime<Utc>\\\",\\n                pr_merge_commit_sha,\\n                target_branch_name as \\\"target_branch_name!: String\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\"\\n            FROM merges\\n            WHERE workspace_id = $1 AND repo_id = $2\\n            ORDER BY created_at DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"merge_type!: MergeType\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_number\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pr_url\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_status?: MergeStatus\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merged_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merge_commit_sha\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch_name!: String\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"1085d1f8107c7e16fc2058ef610918760d8d420f0fca97adecd76d698f6f3a51\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-11793c98a4bee67fce9972ed6b10a18226e0455a0e8d113d04c4d5148b72aec7.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\", tag_name, content as \\\"content!\\\", created_at as \\\"created_at!: DateTime<Utc>\\\", updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM tags\\n               WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"tag_name\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"content!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"11793c98a4bee67fce9972ed6b10a18226e0455a0e8d113d04c4d5148b72aec7\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-12a5c0a8b95d8cb87f1c869ff35692a2cee52bc418b06d00a31a4c139e12d18a.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            SELECT \\n                ep.id as \\\"execution_id!: Uuid\\\", \\n                ep.session_id as \\\"session_id!: Uuid\\\"\\n            FROM execution_processes ep\\n            WHERE EXISTS (\\n                SELECT 1 FROM execution_process_logs epl WHERE epl.execution_id = ep.id\\n            )\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"execution_id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false\n    ]\n  },\n  \"hash\": \"12a5c0a8b95d8cb87f1c869ff35692a2cee52bc418b06d00a31a4c139e12d18a\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-13826fc6fdd367255cb921640e5972f30905ac7a81ad477cf8bbcfc24f06f39b.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n        SELECT\\n            ep.id as \\\"id!: Uuid\\\",\\n            ep.session_id as \\\"session_id!: Uuid\\\",\\n            ep.run_reason as \\\"run_reason!: ExecutionProcessRunReason\\\",\\n            ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\",\\n            ep.status as \\\"status!: ExecutionProcessStatus\\\",\\n            ep.exit_code,\\n            ep.dropped as \\\"dropped!: bool\\\",\\n            ep.started_at as \\\"started_at!: DateTime<Utc>\\\",\\n            ep.completed_at as \\\"completed_at?: DateTime<Utc>\\\",\\n            ep.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n            ep.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n        FROM execution_processes ep\\n        JOIN sessions s ON ep.session_id = s.id\\n        WHERE s.workspace_id = ?\\n          AND ep.status = 'running'\\n          AND ep.run_reason = 'devserver'\\n        ORDER BY ep.created_at DESC\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"run_reason!: ExecutionProcessRunReason\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"exit_code\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dropped!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"started_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"13826fc6fdd367255cb921640e5972f30905ac7a81ad477cf8bbcfc24f06f39b\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-167c1d13ee37ebe62cd2316feaf6b5354eb26d0a8fc16efb22827a5cde59a60e.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT COUNT(1) as \\\"count!: i64\\\"\\n               FROM merges\\n               WHERE workspace_id = $1\\n                 AND merge_type = 'pr'\\n                 AND pr_status = 'open'\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"count!: i64\",\n        \"ordinal\": 0,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"167c1d13ee37ebe62cd2316feaf6b5354eb26d0a8fc16efb22827a5cde59a60e\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT COUNT(*) as \\\"count!: i64\\\"\\n               FROM execution_processes ep\\n               WHERE ep.session_id = $1\\n                 AND ep.status = 'running'\\n                 AND ep.run_reason = 'codingagent'\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"count!: i64\",\n        \"ordinal\": 0,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"DELETE FROM workspaces WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-218f1d14c72148ea88d75e816e1ba111c8f4678a7e428b15462e6dfc74c25b03.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE tags\\n               SET tag_name = $2, content = $3, updated_at = datetime('now', 'subsec')\\n               WHERE id = $1\\n               RETURNING id as \\\"id!: Uuid\\\", tag_name, content as \\\"content!\\\", created_at as \\\"created_at!: DateTime<Utc>\\\", updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"tag_name\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"content!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"218f1d14c72148ea88d75e816e1ba111c8f4678a7e428b15462e6dfc74c25b03\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-2547a5d06fd3b17360bff34a04b7d3d929c13ef0d86395a9201834d8fc955295.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                cat.agent_session_id as \\\"session_id!\\\",\\n                cat.agent_message_id as \\\"message_id\\\"\\n               FROM execution_processes ep\\n               JOIN coding_agent_turns cat ON ep.id = cat.execution_process_id\\n               WHERE ep.session_id = $1\\n                 AND ep.run_reason = 'codingagent'\\n                 AND ep.dropped = FALSE\\n                 AND cat.agent_session_id IS NOT NULL\\n               ORDER BY ep.created_at DESC\\n               LIMIT 1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"session_id!\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"message_id\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      true\n    ]\n  },\n  \"hash\": \"2547a5d06fd3b17360bff34a04b7d3d929c13ef0d86395a9201834d8fc955295\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-256f9e937384933464e6d4d00ee977bbb2915ef80930c8b5c0b0525367a5264d.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE repos SET name = $1, display_name = $2, updated_at = datetime('now', 'subsec') WHERE id = $3\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"256f9e937384933464e6d4d00ee977bbb2915ef80930c8b5c0b0525367a5264d\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-29cc3aa8d0ad5deda94494402500a4125e29381d63f18ef083cc4da95e2c5db5.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT w.id as \\\"workspace_id!: Uuid\\\"\\n               FROM workspaces w\\n               WHERE w.container_ref = ?\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"29cc3aa8d0ad5deda94494402500a4125e29381d63f18ef083cc4da95e2c5db5\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-2a57b702e52b3cc9bdbc361267985958b11d4493b01a9ab8daedf5d951422897.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspaces SET updated_at = datetime('now', 'subsec') WHERE id = ?\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"2a57b702e52b3cc9bdbc361267985958b11d4493b01a9ab8daedf5d951422897\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-2b253f92ac5daa4864e7335fde1b82625f504fd73d19b21992497219a9c3170a.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO repos (id, path, name, display_name)\\n               VALUES ($1, $2, $3, $4)\\n               ON CONFLICT(path) DO UPDATE SET updated_at = updated_at\\n               RETURNING id as \\\"id!: Uuid\\\",\\n                         path,\\n                         name,\\n                         display_name,\\n                         setup_script,\\n                         cleanup_script,\\n                         archive_script,\\n                         copy_files,\\n                         parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                         dev_server_script,\\n                         default_target_branch,\\n                         default_working_dir,\\n                         created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                         updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 4\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"2b253f92ac5daa4864e7335fde1b82625f504fd73d19b21992497219a9c3170a\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-2c0172d5b2c5bff0914727a57983d5c336f5b2dfa73ca6c2efa4ea23bb526e05.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      name,\\n                      default_agent_working_dir,\\n                      remote_project_id as \\\"remote_project_id: Uuid\\\",\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM projects\\n               ORDER BY created_at DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_agent_working_dir\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_project_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"2c0172d5b2c5bff0914727a57983d5c336f5b2dfa73ca6c2efa4ea23bb526e05\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-2c71bf4dd5683e0dedf2341e52880ff2c0765659d3cf53d62faa54adc91071dd.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT w.name AS \\\"name: String\\\"\\n               FROM workspaces w\\n               JOIN workspace_repos wr ON wr.workspace_id = w.id\\n               WHERE wr.repo_id = $1\\n                 AND w.archived = FALSE\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"name: String\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"2c71bf4dd5683e0dedf2341e52880ff2c0765659d3cf53d62faa54adc91071dd\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-2cb5a269045f23da9f4ee0ee679ccb7fffc39d4b37b1b58357b11a7abfdba125.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE sessions SET executor = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"2cb5a269045f23da9f4ee0ee679ccb7fffc39d4b37b1b58357b11a7abfdba125\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-31f4e685fc0b1103ff662b3866b3bb422cc7fc8e0661ebfed30ffd16ea7ed8c0.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      workspace_id as \\\"workspace_id!: Uuid\\\",\\n                      repo_id as \\\"repo_id!: Uuid\\\",\\n                      target_branch,\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM workspace_repos\\n               WHERE workspace_id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"target_branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"31f4e685fc0b1103ff662b3866b3bb422cc7fc8e0661ebfed30ffd16ea7ed8c0\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-3266b6a544952177f84e2e7c31be9dba212c92d91b997de7f6aa811e08ed6c72.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                    ep.id as \\\"id!: Uuid\\\",\\n                    ep.session_id as \\\"session_id!: Uuid\\\",\\n                    ep.run_reason as \\\"run_reason!: ExecutionProcessRunReason\\\",\\n                    ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\",\\n                    ep.status as \\\"status!: ExecutionProcessStatus\\\",\\n                    ep.exit_code,\\n                    ep.dropped as \\\"dropped!: bool\\\",\\n                    ep.started_at as \\\"started_at!: DateTime<Utc>\\\",\\n                    ep.completed_at as \\\"completed_at?: DateTime<Utc>\\\",\\n                    ep.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                    ep.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM execution_processes ep\\n               WHERE ep.session_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE\\n               ORDER BY ep.created_at DESC LIMIT 1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"run_reason!: ExecutionProcessRunReason\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"exit_code\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dropped!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"started_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"3266b6a544952177f84e2e7c31be9dba212c92d91b997de7f6aa811e08ed6c72\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-33f23656ba343bd75a88b0fadf2a4ba01eda330f9b549e625e27701e3b0b5a31.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                entity_type as \\\"entity_type!: EntityType\\\",\\n                local_id as \\\"local_id!: Uuid\\\",\\n                remote_id as \\\"remote_id: Uuid\\\",\\n                status as \\\"status!: MigrationStatus\\\",\\n                error_message,\\n                attempt_count as \\\"attempt_count!\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM migration_state\\n            WHERE entity_type = $1 AND status = 'pending'\\n            ORDER BY created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"entity_type!: EntityType\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"local_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: MigrationStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"error_message\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"attempt_count!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"33f23656ba343bd75a88b0fadf2a4ba01eda330f9b549e625e27701e3b0b5a31\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-3634e2bab8fef106721bb64a791edd81d3d49eb34fbabd34e4feadfb5f229a6e.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      container_ref as \\\"container_ref!\\\"\\n               FROM workspaces\\n               WHERE container_ref IS NOT NULL\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref!\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      true\n    ]\n  },\n  \"hash\": \"3634e2bab8fef106721bb64a791edd81d3d49eb34fbabd34e4feadfb5f229a6e\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-3a9148e9e914d644d4d82a1c2dc8bd0e093d4f4c638afa7fd8f5211892fb6d84.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"DELETE FROM repos WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"3a9148e9e914d644d4d82a1c2dc8bd0e093d4f4c638afa7fd8f5211892fb6d84\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-3ace1ee8dba0669400d69891912b86823e41ca643092d990b12c1a6160112427.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                m.id as \\\"id!: Uuid\\\",\\n                m.workspace_id as \\\"workspace_id!: Uuid\\\",\\n                m.repo_id as \\\"repo_id!: Uuid\\\",\\n                m.merge_type as \\\"merge_type!: MergeType\\\",\\n                m.merge_commit,\\n                m.pr_number,\\n                m.pr_url,\\n                m.pr_status as \\\"pr_status?: MergeStatus\\\",\\n                m.pr_merged_at as \\\"pr_merged_at?: DateTime<Utc>\\\",\\n                m.pr_merge_commit_sha,\\n                m.target_branch_name as \\\"target_branch_name!: String\\\",\\n                m.created_at as \\\"created_at!: DateTime<Utc>\\\"\\n            FROM merges m\\n            INNER JOIN (\\n                SELECT workspace_id, MAX(created_at) as max_created_at\\n                FROM merges\\n                WHERE merge_type = 'pr'\\n                GROUP BY workspace_id\\n            ) latest ON m.workspace_id = latest.workspace_id\\n                AND m.created_at = latest.max_created_at\\n            INNER JOIN workspaces w ON m.workspace_id = w.id\\n            WHERE m.merge_type = 'pr' AND w.archived = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"merge_type!: MergeType\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_number\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pr_url\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_status?: MergeStatus\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merged_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merge_commit_sha\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch_name!: String\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"3ace1ee8dba0669400d69891912b86823e41ca643092d990b12c1a6160112427\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-3d34580933bc02a168f4a7c483460a6ad13ef72b508532f5a6cd5e53aff04a69.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            SELECT\\n                workspace_id as \\\"workspace_id!: Uuid\\\",\\n                execution_process_id as \\\"execution_process_id!: Uuid\\\",\\n                session_id as \\\"session_id!: Uuid\\\",\\n                status as \\\"status!: ExecutionProcessStatus\\\",\\n                completed_at as \\\"completed_at?: DateTime<Utc>\\\"\\n            FROM (\\n                SELECT\\n                    s.workspace_id,\\n                    ep.id as execution_process_id,\\n                    ep.session_id,\\n                    ep.status,\\n                    ep.completed_at,\\n                    ROW_NUMBER() OVER (\\n                        PARTITION BY s.workspace_id\\n                        ORDER BY ep.created_at DESC\\n                    ) as rn\\n                FROM execution_processes ep\\n                JOIN sessions s ON ep.session_id = s.id\\n                JOIN workspaces w ON s.workspace_id = w.id\\n                WHERE w.archived = $1\\n                  AND ep.run_reason IN ('codingagent', 'setupscript', 'cleanupscript')\\n                  AND ep.dropped = FALSE\\n            )\\n            WHERE rn = 1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"execution_process_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false,\n      true,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"3d34580933bc02a168f4a7c483460a6ad13ef72b508532f5a6cd5e53aff04a69\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-3d85256618729c1c0bf2758ffab9cdb4ec2af0751e3a37db4009c02f95f6f556.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO merges (\\n                id, workspace_id, repo_id, merge_type, pr_number, pr_url, pr_status, created_at, target_branch_name\\n            ) VALUES ($1, $2, $3, 'pr', $4, $5, 'open', $6, $7)\\n            RETURNING\\n                id as \\\"id!: Uuid\\\",\\n                workspace_id as \\\"workspace_id!: Uuid\\\",\\n                repo_id as \\\"repo_id!: Uuid\\\",\\n                merge_type as \\\"merge_type!: MergeType\\\",\\n                merge_commit,\\n                pr_number,\\n                pr_url,\\n                pr_status as \\\"pr_status?: MergeStatus\\\",\\n                pr_merged_at as \\\"pr_merged_at?: DateTime<Utc>\\\",\\n                pr_merge_commit_sha,\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                target_branch_name as \\\"target_branch_name!: String\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"merge_type!: MergeType\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_number\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pr_url\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_status?: MergeStatus\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merged_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merge_commit_sha\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch_name!: String\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 7\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"3d85256618729c1c0bf2758ffab9cdb4ec2af0751e3a37db4009c02f95f6f556\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-3f4b2179dcd8857fc18f1e5f6e6cf10f152eebbb141b2d3604dd4191e0c2f367.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                    ep.id as \\\"id!: Uuid\\\",\\n                    ep.session_id as \\\"session_id!: Uuid\\\",\\n                    ep.run_reason as \\\"run_reason!: ExecutionProcessRunReason\\\",\\n                    ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\",\\n                    ep.status as \\\"status!: ExecutionProcessStatus\\\",\\n                    ep.exit_code,\\n                    ep.dropped as \\\"dropped!: bool\\\",\\n                    ep.started_at as \\\"started_at!: DateTime<Utc>\\\",\\n                    ep.completed_at as \\\"completed_at?: DateTime<Utc>\\\",\\n                    ep.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                    ep.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM execution_processes ep WHERE ep.id = ?\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"run_reason!: ExecutionProcessRunReason\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"exit_code\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dropped!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"started_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"3f4b2179dcd8857fc18f1e5f6e6cf10f152eebbb141b2d3604dd4191e0c2f367\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-420a0c490f1e4d549fb194265e971d8c03b86fe75b595091f6425de11d120a6b.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE execution_processes\\n               SET dropped = TRUE\\n             WHERE session_id = $1\\n               AND created_at >= (SELECT created_at FROM execution_processes WHERE id = $2)\\n               AND dropped = FALSE\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"420a0c490f1e4d549fb194265e971d8c03b86fe75b595091f6425de11d120a6b\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-4506eeeb85b49f00143a9d23636c976159e1c72c9cfce005599c5eb52dc15095.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT s.id AS \\\"id!: Uuid\\\",\\n                      s.workspace_id AS \\\"workspace_id!: Uuid\\\",\\n                      s.name,\\n                      s.executor,\\n                      s.agent_working_dir,\\n                      s.created_at AS \\\"created_at!: DateTime<Utc>\\\",\\n                      s.updated_at AS \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM sessions s\\n               LEFT JOIN (\\n                   SELECT ep.session_id, MAX(ep.created_at) as last_used\\n                   FROM execution_processes ep\\n                   WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE\\n                   GROUP BY ep.session_id\\n               ) latest_ep ON s.id = latest_ep.session_id\\n               WHERE s.workspace_id = $1\\n               ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"agent_working_dir\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4506eeeb85b49f00143a9d23636c976159e1c72c9cfce005599c5eb52dc15095\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-48c556a5317e6ea77595a8fdc410d30df50c8405adf38b371fdf0a1bde8c0083.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE execution_process_repo_states\\n               SET before_head_commit = $1, updated_at = $2\\n             WHERE execution_process_id = $3\\n               AND repo_id = $4\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 4\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"48c556a5317e6ea77595a8fdc410d30df50c8405adf38b371fdf0a1bde8c0083\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-4ac35216ead7e5be9cc2de504a06b6e375e23ca2ed14493ec991f53e458a6a34.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"DELETE FROM attachments WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"4ac35216ead7e5be9cc2de504a06b6e375e23ca2ed14493ec991f53e458a6a34\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-4b59b958807be54c7c9949d96ced96e4ab1498f1056e7d0d7956aff46352d90f.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                workspace_id as \\\"workspace_id!: Uuid\\\",\\n                repo_id as \\\"repo_id!: Uuid\\\",\\n                merge_type as \\\"merge_type!: MergeType\\\",\\n                merge_commit,\\n                pr_number,\\n                pr_url,\\n                pr_status as \\\"pr_status?: MergeStatus\\\",\\n                pr_merged_at as \\\"pr_merged_at?: DateTime<Utc>\\\",\\n                pr_merge_commit_sha,\\n                target_branch_name as \\\"target_branch_name!: String\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\"\\n            FROM merges\\n            WHERE workspace_id = $1\\n            ORDER BY created_at DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"merge_type!: MergeType\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_number\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pr_url\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_status?: MergeStatus\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merged_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merge_commit_sha\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch_name!: String\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4b59b958807be54c7c9949d96ced96e4ab1498f1056e7d0d7956aff46352d90f\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-4b952fb779fbcf70bd402b6bcc0eec07b75879333614b8ef98e5b8073ad66ca6.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO attachments (id, file_path, original_name, mime_type, size_bytes, hash)\\n               VALUES ($1, $2, $3, $4, $5, $6)\\n               RETURNING id as \\\"id!: Uuid\\\", \\n                         file_path as \\\"file_path!\\\", \\n                         original_name as \\\"original_name!\\\", \\n                         mime_type,\\n                         size_bytes as \\\"size_bytes!\\\",\\n                         hash as \\\"hash!\\\",\\n                         created_at as \\\"created_at!: DateTime<Utc>\\\", \\n                         updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"file_path!\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"original_name!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"mime_type\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"size_bytes!\",\n        \"ordinal\": 4,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"hash!\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 6\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4b952fb779fbcf70bd402b6bcc0eec07b75879333614b8ef98e5b8073ad66ca6\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-4c9b1b539ec383ace94ef29c58967bbf08112ebdc61276e9710663a083318211.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\", project_id as \\\"project_id!: Uuid\\\", title, description, status as \\\"status!: TaskStatus\\\", parent_workspace_id as \\\"parent_workspace_id: Uuid\\\", created_at as \\\"created_at!: DateTime<Utc>\\\", updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM tasks\\n               ORDER BY created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"project_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"title\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"description\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: TaskStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parent_workspace_id: Uuid\",\n        \"ordinal\": 5,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4c9b1b539ec383ace94ef29c58967bbf08112ebdc61276e9710663a083318211\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-4d84b308a2cc7677da65111d080bf02e5e35c052048360d3dbea656bbbcd3edb.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE execution_process_repo_states\\n               SET merge_commit = $1, updated_at = $2\\n             WHERE execution_process_id = $3\\n               AND repo_id = $4\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 4\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"4d84b308a2cc7677da65111d080bf02e5e35c052048360d3dbea656bbbcd3edb\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-5154289c5a9ddbc061d42de2baf129e03a75061b8b305110921688f01d112de1.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                w.id AS \\\"id!: Uuid\\\",\\n                w.task_id AS \\\"task_id: Uuid\\\",\\n                w.container_ref,\\n                w.branch,\\n                w.setup_completed_at AS \\\"setup_completed_at: DateTime<Utc>\\\",\\n                w.created_at AS \\\"created_at!: DateTime<Utc>\\\",\\n                w.updated_at AS \\\"updated_at!: DateTime<Utc>\\\",\\n                w.archived AS \\\"archived!: bool\\\",\\n                w.pinned AS \\\"pinned!: bool\\\",\\n                w.name,\\n                w.worktree_deleted AS \\\"worktree_deleted!: bool\\\",\\n\\n                CASE WHEN EXISTS (\\n                    SELECT 1\\n                    FROM sessions s\\n                    JOIN execution_processes ep ON ep.session_id = s.id\\n                    WHERE s.workspace_id = w.id\\n                      AND ep.status = 'running'\\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\\n                    LIMIT 1\\n                ) THEN 1 ELSE 0 END AS \\\"is_running!: i64\\\",\\n\\n                CASE WHEN (\\n                    SELECT ep.status\\n                    FROM sessions s\\n                    JOIN execution_processes ep ON ep.session_id = s.id\\n                    WHERE s.workspace_id = w.id\\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\\n                    ORDER BY ep.created_at DESC\\n                    LIMIT 1\\n                ) IN ('failed','killed') THEN 1 ELSE 0 END AS \\\"is_errored!: i64\\\"\\n\\n            FROM workspaces w\\n            ORDER BY w.updated_at DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"task_id: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_completed_at: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archived!: bool\",\n        \"ordinal\": 7,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pinned!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"worktree_deleted!: bool\",\n        \"ordinal\": 10,\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"name\": \"is_running!: i64\",\n        \"ordinal\": 11,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"is_errored!: i64\",\n        \"ordinal\": 12,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"5154289c5a9ddbc061d42de2baf129e03a75061b8b305110921688f01d112de1\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-517fb82570b9624e166696af53963ec499966562b23b5833fc4ca4cf43bcaccc.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                entity_type as \\\"entity_type!: EntityType\\\",\\n                local_id as \\\"local_id!: Uuid\\\",\\n                remote_id as \\\"remote_id: Uuid\\\",\\n                status as \\\"status!: MigrationStatus\\\",\\n                error_message,\\n                attempt_count as \\\"attempt_count!\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM migration_state\\n            WHERE entity_type = $1\\n            ORDER BY created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"entity_type!: EntityType\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"local_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: MigrationStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"error_message\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"attempt_count!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"517fb82570b9624e166696af53963ec499966562b23b5833fc4ca4cf43bcaccc\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-557963b950205b10db273762da5fd24c9db96c1f366a796c319e4adc888d7414.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspace_repos SET target_branch = $1, updated_at = datetime('now') WHERE workspace_id = $2 AND repo_id = $3\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"557963b950205b10db273762da5fd24c9db96c1f366a796c319e4adc888d7414\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-570f62a32921c4a9a7e4e1006e9b31c4c58e69ab8681d76dfe9b184ff1e0bc65.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                workspace_id as \\\"workspace_id!: Uuid\\\",\\n                repo_id as \\\"repo_id!: Uuid\\\",\\n                merge_type as \\\"merge_type!: MergeType\\\",\\n                merge_commit,\\n                pr_number,\\n                pr_url,\\n                pr_status as \\\"pr_status?: MergeStatus\\\",\\n                pr_merged_at as \\\"pr_merged_at?: DateTime<Utc>\\\",\\n                pr_merge_commit_sha,\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                target_branch_name as \\\"target_branch_name!: String\\\"\\n               FROM merges\\n               WHERE merge_type = 'pr'\\n               ORDER BY created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"merge_type!: MergeType\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_number\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pr_url\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_status?: MergeStatus\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merged_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merge_commit_sha\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch_name!: String\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"570f62a32921c4a9a7e4e1006e9b31c4c58e69ab8681d76dfe9b184ff1e0bc65\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-5785a10c3d51ff9b001aa455f6296a4dcba61cec700a4b72031c5c643b273938.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE merges\\n            SET pr_status = $1,\\n                pr_merge_commit_sha = $2,\\n                pr_merged_at = $3\\n            WHERE id = $4\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 4\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5785a10c3d51ff9b001aa455f6296a4dcba61cec700a4b72031c5c643b273938\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspaces SET\\n                archived = COALESCE($1, archived),\\n                pinned = COALESCE($2, pinned),\\n                name = CASE WHEN $3 THEN $4 ELSE name END,\\n                updated_at = datetime('now', 'subsec')\\n            WHERE id = $5\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 5\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-586d5ab95899967c8a8f74996c4a598a73661823bc2bcb240cebb7cd0533abb6.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT i.id as \\\"id!: Uuid\\\",\\n                      i.file_path as \\\"file_path!\\\",\\n                      i.original_name as \\\"original_name!\\\",\\n                      i.mime_type,\\n                      i.size_bytes as \\\"size_bytes!\\\",\\n                      i.hash as \\\"hash!\\\",\\n                      i.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      i.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM attachments i\\n               JOIN workspace_attachments wa ON i.id = wa.attachment_id\\n               WHERE wa.workspace_id = $1\\n               ORDER BY wa.created_at\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"file_path!\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"original_name!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"mime_type\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"size_bytes!\",\n        \"ordinal\": 4,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"hash!\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"586d5ab95899967c8a8f74996c4a598a73661823bc2bcb240cebb7cd0533abb6\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-5884e7baa4a061166cb2911f717d3fd92852d62975a910dd9cb05e7908fdf8b6.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      path,\\n                      name,\\n                      display_name,\\n                      setup_script,\\n                      cleanup_script,\\n                      archive_script,\\n                      copy_files,\\n                      parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                      dev_server_script,\\n                      default_target_branch,\\n                      default_working_dir,\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM repos\\n               WHERE name = '__NEEDS_BACKFILL__'\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"5884e7baa4a061166cb2911f717d3fd92852d62975a910dd9cb05e7908fdf8b6\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-592656b17a5f78d117365909e47afba7d3df545ac1078c307c6b968e75c8e2ba.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                w.id AS \\\"id!: Uuid\\\",\\n                w.task_id AS \\\"task_id: Uuid\\\",\\n                w.container_ref,\\n                w.branch,\\n                w.setup_completed_at AS \\\"setup_completed_at: DateTime<Utc>\\\",\\n                w.created_at AS \\\"created_at!: DateTime<Utc>\\\",\\n                w.updated_at AS \\\"updated_at!: DateTime<Utc>\\\",\\n                w.archived AS \\\"archived!: bool\\\",\\n                w.pinned AS \\\"pinned!: bool\\\",\\n                w.name,\\n                w.worktree_deleted AS \\\"worktree_deleted!: bool\\\",\\n\\n                CASE WHEN EXISTS (\\n                    SELECT 1\\n                    FROM sessions s\\n                    JOIN execution_processes ep ON ep.session_id = s.id\\n                    WHERE s.workspace_id = w.id\\n                      AND ep.status = 'running'\\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\\n                    LIMIT 1\\n                ) THEN 1 ELSE 0 END AS \\\"is_running!: i64\\\",\\n\\n                CASE WHEN (\\n                    SELECT ep.status\\n                    FROM sessions s\\n                    JOIN execution_processes ep ON ep.session_id = s.id\\n                    WHERE s.workspace_id = w.id\\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\\n                    ORDER BY ep.created_at DESC\\n                    LIMIT 1\\n                ) IN ('failed','killed') THEN 1 ELSE 0 END AS \\\"is_errored!: i64\\\"\\n\\n            FROM workspaces w\\n            WHERE w.id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"task_id: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_completed_at: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archived!: bool\",\n        \"ordinal\": 7,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pinned!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"worktree_deleted!: bool\",\n        \"ordinal\": 10,\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"name\": \"is_running!: i64\",\n        \"ordinal\": 11,\n        \"type_info\": \"Null\"\n      },\n      {\n        \"name\": \"is_errored!: i64\",\n        \"ordinal\": 12,\n        \"type_info\": \"Null\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"592656b17a5f78d117365909e47afba7d3df545ac1078c307c6b968e75c8e2ba\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            INSERT INTO scratch (id, scratch_type, payload)\\n            VALUES ($1, $2, $3)\\n            RETURNING\\n                id              as \\\"id!: Uuid\\\",\\n                scratch_type,\\n                payload,\\n                created_at      as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at      as \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"scratch_type\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"payload\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-5d9739705372b113b1bb45d441ebdf2846dc4cd83b8547128c733cd282b5b4f2.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE execution_processes\\n               SET status = $1, exit_code = $2, completed_at = $3\\n               WHERE id = $4\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 4\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5d9739705372b113b1bb45d441ebdf2846dc4cd83b8547128c733cd282b5b4f2\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-5ff9809face43fe1f071dfda62b6a30f4a32a9aaace29caf89b95c224482201b.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT r.id as \\\"id!: Uuid\\\", r.path, r.name, r.copy_files\\n               FROM repos r\\n               JOIN workspace_repos wr ON r.id = wr.repo_id\\n               WHERE wr.workspace_id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"5ff9809face43fe1f071dfda62b6a30f4a32a9aaace29caf89b95c224482201b\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-606016bf31cf0abd87d7eb95ea99d7c8c014523cb02e482d608299ceefaeffc5.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT EXISTS(\\n                SELECT 1 FROM coding_agent_turns cat\\n                JOIN execution_processes ep ON cat.execution_process_id = ep.id\\n                JOIN sessions s ON ep.session_id = s.id\\n                WHERE s.workspace_id = $1 AND cat.seen = 0\\n            ) as \\\"has_unseen!: bool\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"has_unseen!: bool\",\n        \"ordinal\": 0,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"606016bf31cf0abd87d7eb95ea99d7c8c014523cb02e482d608299ceefaeffc5\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-61c6546164ba21a659d32d4e345926b0ee1a611fe4e46bb8db51a4e41f781af9.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE coding_agent_turns\\n               SET summary = $1, updated_at = $2\\n               WHERE execution_process_id = $3\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"61c6546164ba21a659d32d4e345926b0ee1a611fe4e46bb8db51a4e41f781af9\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id AS \\\"id!: Uuid\\\",\\n                          task_id AS \\\"task_id: Uuid\\\",\\n                          container_ref,\\n                          branch,\\n                          setup_completed_at AS \\\"setup_completed_at: DateTime<Utc>\\\",\\n                          created_at AS \\\"created_at!: DateTime<Utc>\\\",\\n                          updated_at AS \\\"updated_at!: DateTime<Utc>\\\",\\n                          archived AS \\\"archived!: bool\\\",\\n                          pinned AS \\\"pinned!: bool\\\",\\n                          name,\\n                          worktree_deleted AS \\\"worktree_deleted!: bool\\\"\\n                   FROM workspaces\\n                   ORDER BY created_at DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"task_id: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_completed_at: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archived!: bool\",\n        \"ordinal\": 7,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pinned!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"worktree_deleted!: bool\",\n        \"ordinal\": 10,\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-686810c5271e1d44042b6ea2c6cc434eb2e3f5d3540c97d703c34dd4e978c690.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                entity_type as \\\"entity_type!: EntityType\\\",\\n                local_id as \\\"local_id!: Uuid\\\",\\n                remote_id as \\\"remote_id: Uuid\\\",\\n                status as \\\"status!: MigrationStatus\\\",\\n                error_message,\\n                attempt_count as \\\"attempt_count!\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM migration_state\\n            WHERE entity_type = $1 AND local_id = $2\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"entity_type!: EntityType\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"local_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: MigrationStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"error_message\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"attempt_count!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"686810c5271e1d44042b6ea2c6cc434eb2e3f5d3540c97d703c34dd4e978c690\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-6ae5eb2719382d4d081ee17dbd5de654c156b06e2af4ddfb917d36002146be5b.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO workspace_attachments (id, workspace_id, attachment_id)\\n                   SELECT $1, $2, $3\\n                   WHERE NOT EXISTS (\\n                       SELECT 1 FROM workspace_attachments WHERE workspace_id = $2 AND attachment_id = $3\\n                   )\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"6ae5eb2719382d4d081ee17dbd5de654c156b06e2af4ddfb917d36002146be5b\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-70b21c5c0a2ba5c21c9c1132f14a68c02a8a2cd555caea74e57a0aeb206770d3.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT COUNT(*) as \\\"count!: i64\\\" FROM workspaces\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"count!: i64\",\n        \"ordinal\": 0,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"70b21c5c0a2ba5c21c9c1132f14a68c02a8a2cd555caea74e57a0aeb206770d3\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-7364150098bec681451c43762117a1f5c5b4e27f5f65186c3cc16092b3491c37.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO migration_state (id, entity_type, local_id)\\n            VALUES ($1, $2, $3)\\n            ON CONFLICT (entity_type, local_id) DO UPDATE SET\\n                updated_at = datetime('now', 'subsec')\\n            RETURNING\\n                id as \\\"id!: Uuid\\\",\\n                entity_type as \\\"entity_type!: EntityType\\\",\\n                local_id as \\\"local_id!: Uuid\\\",\\n                remote_id as \\\"remote_id: Uuid\\\",\\n                status as \\\"status!: MigrationStatus\\\",\\n                error_message,\\n                attempt_count as \\\"attempt_count!\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"entity_type!: EntityType\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"local_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: MigrationStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"error_message\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"attempt_count!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7364150098bec681451c43762117a1f5c5b4e27f5f65186c3cc16092b3491c37\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-73aee4cb95294087554eafaf3126556df244f4b6639d5a188f0badb6739c1a70.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\", tag_name, content as \\\"content!\\\", created_at as \\\"created_at!: DateTime<Utc>\\\", updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM tags\\n               ORDER BY tag_name ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"tag_name\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"content!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"73aee4cb95294087554eafaf3126556df244f4b6639d5a188f0badb6739c1a70\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name)\\n               VALUES ($1, $2, $3, $4, $5, $6)\\n               RETURNING id as \\\"id!: Uuid\\\", task_id as \\\"task_id: Uuid\\\", container_ref, branch, setup_completed_at as \\\"setup_completed_at: DateTime<Utc>\\\", created_at as \\\"created_at!: DateTime<Utc>\\\", updated_at as \\\"updated_at!: DateTime<Utc>\\\", archived as \\\"archived!: bool\\\", pinned as \\\"pinned!: bool\\\", name, worktree_deleted as \\\"worktree_deleted!: bool\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"task_id: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_completed_at: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archived!: bool\",\n        \"ordinal\": 7,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pinned!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"worktree_deleted!: bool\",\n        \"ordinal\": 10,\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 6\n    },\n    \"nullable\": [\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT  id                AS \\\"id!: Uuid\\\",\\n                       task_id           AS \\\"task_id: Uuid\\\",\\n                       container_ref,\\n                       branch,\\n                       setup_completed_at AS \\\"setup_completed_at: DateTime<Utc>\\\",\\n                       created_at        AS \\\"created_at!: DateTime<Utc>\\\",\\n                       updated_at        AS \\\"updated_at!: DateTime<Utc>\\\",\\n                       archived          AS \\\"archived!: bool\\\",\\n                       pinned            AS \\\"pinned!: bool\\\",\\n                       name,\\n                       worktree_deleted  AS \\\"worktree_deleted!: bool\\\"\\n               FROM    workspaces\\n               WHERE   id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"task_id: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_completed_at: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archived!: bool\",\n        \"ordinal\": 7,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pinned!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"worktree_deleted!: bool\",\n        \"ordinal\": 10,\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-7807cb09da72c5a1e35bf4f4da1bea1743a578588e72444ede98f5f969af08c1.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                entity_type as \\\"entity_type!: EntityType\\\",\\n                local_id as \\\"local_id!: Uuid\\\",\\n                remote_id as \\\"remote_id: Uuid\\\",\\n                status as \\\"status!: MigrationStatus\\\",\\n                error_message,\\n                attempt_count as \\\"attempt_count!\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM migration_state\\n            ORDER BY created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"entity_type!: EntityType\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"local_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: MigrationStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"error_message\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"attempt_count!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7807cb09da72c5a1e35bf4f4da1bea1743a578588e72444ede98f5f969af08c1\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-784e59a5259f046a74bbfd3cc5a78500797ccf3e67928e5f1520623c5c04ac9f.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO workspace_repos (id, workspace_id, repo_id, target_branch)\\n                   VALUES ($1, $2, $3, $4)\\n                   RETURNING id as \\\"id!: Uuid\\\",\\n                             workspace_id as \\\"workspace_id!: Uuid\\\",\\n                             repo_id as \\\"repo_id!: Uuid\\\",\\n                             target_branch,\\n                             created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                             updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"target_branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 4\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"784e59a5259f046a74bbfd3cc5a78500797ccf3e67928e5f1520623c5c04ac9f\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-79e1e11b83c786c6d5a985ab045b6bd122d5efa920225dadc9fedb6592c6e0a3.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE execution_process_repo_states\\n               SET after_head_commit = $1, updated_at = $2\\n             WHERE execution_process_id = $3\\n               AND repo_id = $4\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 4\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"79e1e11b83c786c6d5a985ab045b6bd122d5efa920225dadc9fedb6592c6e0a3\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-79f6b9f999c33900ae87475d72651b274cc94ab3b1f36e9c5517bc5572ea9947.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE repos\\n               SET display_name = $1,\\n                   setup_script = $2,\\n                   cleanup_script = $3,\\n                   archive_script = $4,\\n                   copy_files = $5,\\n                   parallel_setup_script = $6,\\n                   dev_server_script = $7,\\n                   default_target_branch = $8,\\n                   default_working_dir = $9,\\n                   updated_at = datetime('now', 'subsec')\\n               WHERE id = $10\\n               RETURNING id as \\\"id!: Uuid\\\",\\n                         path,\\n                         name,\\n                         display_name,\\n                         setup_script,\\n                         cleanup_script,\\n                         archive_script,\\n                         copy_files,\\n                         parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                         dev_server_script,\\n                         default_target_branch,\\n                         default_working_dir,\\n                         created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                         updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 10\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"79f6b9f999c33900ae87475d72651b274cc94ab3b1f36e9c5517bc5572ea9947\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-7d12bf106e68365fc1aa239b8b39065430f30ad658d0bf9801c81e3ced2127da.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                    ep.id as \\\"id!: Uuid\\\",\\n                    ep.session_id as \\\"session_id!: Uuid\\\",\\n                    ep.run_reason as \\\"run_reason!: ExecutionProcessRunReason\\\",\\n                    ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\",\\n                    ep.status as \\\"status!: ExecutionProcessStatus\\\",\\n                    ep.exit_code,\\n                    ep.dropped as \\\"dropped!: bool\\\",\\n                    ep.started_at as \\\"started_at!: DateTime<Utc>\\\",\\n                    ep.completed_at as \\\"completed_at?: DateTime<Utc>\\\",\\n                    ep.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                    ep.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM execution_processes ep WHERE ep.rowid = ?\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"run_reason!: ExecutionProcessRunReason\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"exit_code\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dropped!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"started_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7d12bf106e68365fc1aa239b8b39065430f30ad658d0bf9801c81e3ced2127da\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-80669005bff96b45015f095ccf28598df604540e2aaf3828fcb8db7d55538dc7.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO tags (id, tag_name, content)\\n               VALUES ($1, $2, $3)\\n               RETURNING id as \\\"id!: Uuid\\\", tag_name, content as \\\"content!\\\", created_at as \\\"created_at!: DateTime<Utc>\\\", updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"tag_name\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"content!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"80669005bff96b45015f095ccf28598df604540e2aaf3828fcb8db7d55538dc7\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-80e6cfb4e27fcaa79b7dbd37ac16aac255f46a646c75aa65111b2f58ec03f892.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                    ep.id as \\\"id!: Uuid\\\",\\n                    ep.session_id as \\\"session_id!: Uuid\\\",\\n                    ep.run_reason as \\\"run_reason!: ExecutionProcessRunReason\\\",\\n                    ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\",\\n                    ep.status as \\\"status!: ExecutionProcessStatus\\\",\\n                    ep.exit_code,\\n                    ep.dropped as \\\"dropped!: bool\\\",\\n                    ep.started_at as \\\"started_at!: DateTime<Utc>\\\",\\n                    ep.completed_at as \\\"completed_at?: DateTime<Utc>\\\",\\n                    ep.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                    ep.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM execution_processes ep WHERE ep.status = 'running' ORDER BY ep.created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"run_reason!: ExecutionProcessRunReason\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"exit_code\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dropped!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"started_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"80e6cfb4e27fcaa79b7dbd37ac16aac255f46a646c75aa65111b2f58ec03f892\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-82b92577d15b92908cecca7bff6ff21478c90a16eb6123165f0bd16d2d60bca4.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT DISTINCT s.workspace_id as \\\"workspace_id!: Uuid\\\"\\n               FROM coding_agent_turns cat\\n               JOIN execution_processes ep ON cat.execution_process_id = ep.id\\n               JOIN sessions s ON ep.session_id = s.id\\n               JOIN workspaces w ON s.workspace_id = w.id\\n               WHERE cat.seen = 0 AND w.archived = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"82b92577d15b92908cecca7bff6ff21478c90a16eb6123165f0bd16d2d60bca4\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-82f7bba858a26732ad9d4122c3a0bca4209ae37c59dcd7353a68e2dec434c48a.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT r.id as \\\"id!: Uuid\\\",\\n                      r.path,\\n                      r.name,\\n                      r.display_name,\\n                      r.setup_script,\\n                      r.cleanup_script,\\n                      r.archive_script,\\n                      r.copy_files,\\n                      r.parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                      r.dev_server_script,\\n                      r.default_target_branch,\\n                      r.default_working_dir,\\n                      r.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      r.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM repos r\\n               JOIN workspace_repos wr ON r.id = wr.repo_id\\n               WHERE wr.workspace_id = $1\\n               ORDER BY r.display_name ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"82f7bba858a26732ad9d4122c3a0bca4209ae37c59dcd7353a68e2dec434c48a\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-84ee994f0aad005cf62ca318eb20ae29d218a72cdd1fadf2a5ae399b0719ca19.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                entity_type as \\\"entity_type!: EntityType\\\",\\n                local_id as \\\"local_id!: Uuid\\\",\\n                remote_id as \\\"remote_id: Uuid\\\",\\n                status as \\\"status!: MigrationStatus\\\",\\n                error_message,\\n                attempt_count as \\\"attempt_count!\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM migration_state\\n            WHERE status = $1\\n            ORDER BY created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"entity_type!: EntityType\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"local_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: MigrationStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"error_message\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"attempt_count!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"84ee994f0aad005cf62ca318eb20ae29d218a72cdd1fadf2a5ae399b0719ca19\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-891bad4b14f75be20c70a5cec02fb3b4fb3cbb84ce322bf5da3791d75b1deae7.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      path,\\n                      name,\\n                      display_name,\\n                      setup_script,\\n                      cleanup_script,\\n                      archive_script,\\n                      copy_files,\\n                      parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                      dev_server_script,\\n                      default_target_branch,\\n                      default_working_dir,\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM repos\\n               ORDER BY display_name ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"891bad4b14f75be20c70a5cec02fb3b4fb3cbb84ce322bf5da3791d75b1deae7\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"DELETE FROM scratch WHERE id = $1 AND scratch_type = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-9139f8d02c4ff94db0f2e8de7a6d5a53092479499815531962b7c84f5e0b2129.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT logs\\n               FROM execution_process_logs \\n               WHERE execution_id = $1\\n               ORDER BY inserted_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"logs\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"9139f8d02c4ff94db0f2e8de7a6d5a53092479499815531962b7c84f5e0b2129\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            SELECT\\n                w.id as \\\"id!: Uuid\\\",\\n                w.task_id as \\\"task_id: Uuid\\\",\\n                w.container_ref,\\n                w.branch as \\\"branch!\\\",\\n                w.setup_completed_at as \\\"setup_completed_at: DateTime<Utc>\\\",\\n                w.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                w.updated_at as \\\"updated_at!: DateTime<Utc>\\\",\\n                w.archived as \\\"archived!: bool\\\",\\n                w.pinned as \\\"pinned!: bool\\\",\\n                w.name,\\n                w.worktree_deleted as \\\"worktree_deleted!: bool\\\"\\n            FROM workspaces w\\n            LEFT JOIN sessions s ON w.id = s.workspace_id\\n            LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL\\n            WHERE w.container_ref IS NOT NULL\\n                AND w.worktree_deleted = FALSE\\n                AND w.id NOT IN (\\n                    SELECT DISTINCT s2.workspace_id\\n                    FROM sessions s2\\n                    JOIN execution_processes ep2 ON s2.id = ep2.session_id\\n                    WHERE ep2.completed_at IS NULL\\n                )\\n            GROUP BY w.id, w.container_ref, w.updated_at\\n            HAVING datetime('now', 'localtime',\\n                CASE\\n                    WHEN w.archived = 1\\n                    THEN '-1 hours'\\n                    ELSE '-72 hours'\\n                END\\n            ) > datetime(\\n                MAX(\\n                    max(\\n                        datetime(w.updated_at),\\n                        datetime(ep.completed_at)\\n                    )\\n                )\\n            )\\n            ORDER BY MAX(\\n                CASE\\n                    WHEN ep.completed_at IS NOT NULL THEN ep.completed_at\\n                    ELSE w.updated_at\\n                END\\n            ) ASC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"task_id: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"branch!\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_completed_at: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archived!: bool\",\n        \"ordinal\": 7,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pinned!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"worktree_deleted!: bool\",\n        \"ordinal\": 10,\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-93efe07b91d232fc0c371be8ee618ba6ccfd6930454fc11845d5dfc2ba0bad62.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT s.id AS \\\"id!: Uuid\\\",\\n                      s.workspace_id AS \\\"workspace_id!: Uuid\\\",\\n                      s.name,\\n                      s.executor,\\n                      s.agent_working_dir,\\n                      s.created_at AS \\\"created_at!: DateTime<Utc>\\\",\\n                      s.updated_at AS \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM sessions s\\n               LEFT JOIN (\\n                   SELECT ep.session_id, MAX(ep.created_at) as last_used\\n                   FROM execution_processes ep\\n                   WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE\\n                   GROUP BY ep.session_id\\n               ) latest_ep ON s.id = latest_ep.session_id\\n               WHERE s.workspace_id = $1\\n               ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC\\n               LIMIT 1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"agent_working_dir\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"93efe07b91d232fc0c371be8ee618ba6ccfd6930454fc11845d5dfc2ba0bad62\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-973e43902b05d671f69b24a0aeeb07bc0cbcd22d75b20c83c49a122f92c6b231.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      path,\\n                      name,\\n                      display_name,\\n                      setup_script,\\n                      cleanup_script,\\n                      archive_script,\\n                      copy_files,\\n                      parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                      dev_server_script,\\n                      default_target_branch,\\n                      default_working_dir,\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM repos\\n               WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"973e43902b05d671f69b24a0aeeb07bc0cbcd22d75b20c83c49a122f92c6b231\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-9747ebaebd562d65f0c333b0f5efc74fa63ab9fcb35a43f75f57da3fcb9a2588.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO execution_process_logs (execution_id, logs, byte_size, inserted_at)\\n               VALUES ($1, $2, $3, datetime('now', 'subsec'))\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9747ebaebd562d65f0c333b0f5efc74fa63ab9fcb35a43f75f57da3fcb9a2588\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-9821ee63362e96cf3fd936e2d54a641fb30f239a8137dc6c1b3a670b2c6138c1.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      workspace_id as \\\"workspace_id!: Uuid\\\",\\n                      repo_id as \\\"repo_id!: Uuid\\\",\\n                      target_branch,\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM workspace_repos\\n               WHERE workspace_id = $1 AND repo_id = $2\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"target_branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"9821ee63362e96cf3fd936e2d54a641fb30f239a8137dc6c1b3a670b2c6138c1\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-99399425f53b140a8de232a4de3c6c056bc422f2fbdb8ead6aab3f6945906e51.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                ep.id                         as \\\"id!: Uuid\\\",\\n                ep.session_id                 as \\\"session_id!: Uuid\\\",\\n                s.workspace_id                as \\\"workspace_id!: Uuid\\\",\\n                eprs.repo_id                  as \\\"repo_id!: Uuid\\\",\\n                eprs.after_head_commit        as after_head_commit,\\n                prev.after_head_commit        as prev_after_head_commit,\\n                wr.target_branch              as \\\"target_branch!\\\",\\n                r.path                        as repo_path\\n            FROM execution_processes ep\\n            JOIN sessions s ON s.id = ep.session_id\\n            JOIN execution_process_repo_states eprs ON eprs.execution_process_id = ep.id\\n            JOIN repos r ON r.id = eprs.repo_id\\n            JOIN workspaces w ON w.id = s.workspace_id\\n            JOIN workspace_repos wr ON wr.workspace_id = w.id AND wr.repo_id = eprs.repo_id\\n            LEFT JOIN execution_process_repo_states prev\\n              ON prev.execution_process_id = (\\n                   SELECT id FROM execution_processes\\n                     WHERE session_id = ep.session_id\\n                       AND created_at < ep.created_at\\n                     ORDER BY created_at DESC\\n                     LIMIT 1\\n               )\\n              AND prev.repo_id = eprs.repo_id\\n            WHERE eprs.before_head_commit IS NULL\\n              AND eprs.after_head_commit IS NOT NULL\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"after_head_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"prev_after_head_commit\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"repo_path\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"99399425f53b140a8de232a4de3c6c056bc422f2fbdb8ead6aab3f6945906e51\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE projects\\n               SET remote_project_id = $2\\n               WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-9f783d1b275548a59429235991e5299b7aaf071effebbd62f006404b3ce83dc8.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT r.id as \\\"id!: Uuid\\\",\\n                      r.path,\\n                      r.name,\\n                      r.display_name,\\n                      r.setup_script,\\n                      r.cleanup_script,\\n                      r.archive_script,\\n                      r.copy_files,\\n                      r.parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                      r.dev_server_script,\\n                      r.default_target_branch,\\n                      r.default_working_dir,\\n                      r.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      r.updated_at as \\\"updated_at!: DateTime<Utc>\\\",\\n                      wr.target_branch\\n               FROM repos r\\n               JOIN workspace_repos wr ON r.id = wr.repo_id\\n               WHERE wr.workspace_id = $1\\n               ORDER BY r.display_name ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch\",\n        \"ordinal\": 14,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"9f783d1b275548a59429235991e5299b7aaf071effebbd62f006404b3ce83dc8\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-9f8ab7d7c2660321412117bfb55e3b2b9ccd7b9ed2679fb8ccca0a36996e6e21.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO execution_process_repo_states (\\n                        id,\\n                        execution_process_id,\\n                        repo_id,\\n                        before_head_commit,\\n                        after_head_commit,\\n                        merge_commit,\\n                        created_at,\\n                        updated_at\\n                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 8\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9f8ab7d7c2660321412117bfb55e3b2b9ccd7b9ed2679fb8ccca0a36996e6e21\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-a1574f21db387b0e4a2c3f5723de6df4ee42d98145d16e9d135345dd60128429.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT \\n                execution_id as \\\"execution_id!: Uuid\\\",\\n                logs,\\n                byte_size,\\n                inserted_at as \\\"inserted_at!: DateTime<Utc>\\\"\\n               FROM execution_process_logs \\n               WHERE execution_id = $1\\n               ORDER BY inserted_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"execution_id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"logs\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"byte_size\",\n        \"ordinal\": 2,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"inserted_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a1574f21db387b0e4a2c3f5723de6df4ee42d98145d16e9d135345dd60128429\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            INSERT INTO scratch (id, scratch_type, payload)\\n            VALUES ($1, $2, $3)\\n            ON CONFLICT(id, scratch_type) DO UPDATE SET\\n                payload = excluded.payload,\\n                updated_at = datetime('now', 'subsec')\\n            RETURNING\\n                id              as \\\"id!: Uuid\\\",\\n                scratch_type,\\n                payload,\\n                created_at      as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at      as \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"scratch_type\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"payload\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-a4a50fcfb903e6d0a315676f4f760e5bb7718e10ea550aedf990c9da84834416.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"DELETE FROM migration_state\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"a4a50fcfb903e6d0a315676f4f760e5bb7718e10ea550aedf990c9da84834416\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-a915c22f5ed0bb86c3a242ca38cbc1bfca40ebfe9096058c27e94479b67c7c02.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE migration_state\\n            SET status = 'failed',\\n                error_message = $3,\\n                attempt_count = attempt_count + 1,\\n                updated_at = datetime('now', 'subsec')\\n            WHERE entity_type = $1 AND local_id = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"a915c22f5ed0bb86c3a242ca38cbc1bfca40ebfe9096058c27e94479b67c7c02\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            SELECT\\n                id              as \\\"id!: Uuid\\\",\\n                scratch_type,\\n                payload,\\n                created_at      as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at      as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM scratch\\n            ORDER BY created_at DESC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"scratch_type\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"payload\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-aa598f6943fbf773ca00deb113f3955bdf689d1c22df63849bc5ce36c7c76382.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspaces SET container_ref = $1, updated_at = $2 WHERE id = $3\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"aa598f6943fbf773ca00deb113f3955bdf689d1c22df63849bc5ce36c7c76382\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-ab2693e557142354564a2882f3c321b350828419c440885c0f88840079b1c94e.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE coding_agent_turns\\n               SET seen = 1, updated_at = $1\\n               WHERE execution_process_id IN (\\n                   SELECT ep.id FROM execution_processes ep\\n                   JOIN sessions s ON ep.session_id = s.id\\n                   WHERE s.workspace_id = $2\\n               ) AND seen = 0\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"ab2693e557142354564a2882f3c321b350828419c440885c0f88840079b1c94e\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-abbda92ba42bea0f7d17d0945d51b011bf50e7b36ee50ed74988e053f6fb0eec.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE sessions SET\\n                name = CASE WHEN $1 THEN $2 ELSE name END,\\n                updated_at = datetime('now', 'subsec')\\n            WHERE id = $3\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"abbda92ba42bea0f7d17d0945d51b011bf50e7b36ee50ed74988e053f6fb0eec\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-abff188fc81caf44081e2053cb7841d1dc6c1a8965f4b862caa2f9cebcae0176.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      file_path as \\\"file_path!\\\",\\n                      original_name as \\\"original_name!\\\",\\n                      mime_type,\\n                      size_bytes as \\\"size_bytes!\\\",\\n                      hash as \\\"hash!\\\",\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM attachments\\n               WHERE file_path = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"file_path!\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"original_name!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"mime_type\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"size_bytes!\",\n        \"ordinal\": 4,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"hash!\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"abff188fc81caf44081e2053cb7841d1dc6c1a8965f4b862caa2f9cebcae0176\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-b1edd509d00577007680243589ce59570182b98a1e9059d9702d97e9eaa9cbf5.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT COUNT(*) as \\\"count!: i64\\\"\\n               FROM execution_processes ep\\n               JOIN sessions s ON ep.session_id = s.id\\n               WHERE s.workspace_id = $1\\n                 AND ep.status = 'running'\\n                 AND ep.run_reason != 'devserver'\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"count!: i64\",\n        \"ordinal\": 0,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"b1edd509d00577007680243589ce59570182b98a1e9059d9702d97e9eaa9cbf5\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-b4476bedb8e5c0f7bc21654dc62fb00fbd8a41e24efaba55be8278031d71cc59.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO merges (\\n                id, workspace_id, repo_id, merge_type, merge_commit, created_at, target_branch_name\\n            ) VALUES ($1, $2, $3, 'direct', $4, $5, $6)\\n            RETURNING\\n                id as \\\"id!: Uuid\\\",\\n                workspace_id as \\\"workspace_id!: Uuid\\\",\\n                repo_id as \\\"repo_id!: Uuid\\\",\\n                merge_type as \\\"merge_type!: MergeType\\\",\\n                merge_commit,\\n                pr_number,\\n                pr_url,\\n                pr_status as \\\"pr_status?: MergeStatus\\\",\\n                pr_merged_at as \\\"pr_merged_at?: DateTime<Utc>\\\",\\n                pr_merge_commit_sha,\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                target_branch_name as \\\"target_branch_name!: String\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"merge_type!: MergeType\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_number\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pr_url\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_status?: MergeStatus\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merged_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merge_commit_sha\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch_name!: String\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 6\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"b4476bedb8e5c0f7bc21654dc62fb00fbd8a41e24efaba55be8278031d71cc59\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-b4ff8dabb0d5c99319fad3f2f7e620523c96b89beaf1edd97f79d9972b93c8fe.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE coding_agent_turns\\n               SET agent_session_id = $1, updated_at = $2\\n               WHERE execution_process_id = $3\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"b4ff8dabb0d5c99319fad3f2f7e620523c96b89beaf1edd97f79d9972b93c8fe\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-c097fa44c48e55f0e74f56577c0c1c4b3b92b2875d12c0bd1a70a1dcc4eda58e.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT EXISTS(SELECT 1 FROM workspaces WHERE container_ref = ?) as \\\"exists!: bool\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"exists!: bool\",\n        \"ordinal\": 0,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"c097fa44c48e55f0e74f56577c0c1c4b3b92b2875d12c0bd1a70a1dcc4eda58e\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-c24119a35ed2099b886a0b1a9a41adf01d1a1f86792abf3d3a410c6cbab2ec0f.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT r.id as \\\"id!: Uuid\\\",\\n                      r.path,\\n                      r.name,\\n                      r.display_name,\\n                      r.setup_script,\\n                      r.cleanup_script,\\n                      r.archive_script,\\n                      r.copy_files,\\n                      r.parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                      r.dev_server_script,\\n                      r.default_target_branch,\\n                      r.default_working_dir,\\n                      r.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      r.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM repos r\\n               LEFT JOIN (\\n                   SELECT repo_id, MAX(updated_at) AS last_used_at\\n                   FROM workspace_repos\\n                   GROUP BY repo_id\\n               ) wr ON wr.repo_id = r.id\\n               ORDER BY wr.last_used_at DESC, r.display_name ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"c24119a35ed2099b886a0b1a9a41adf01d1a1f86792abf3d3a410c6cbab2ec0f\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-c27f2fd6b3696cb5a8ec54226608440786a6cec601783f797be3a8c515080d62.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"DELETE FROM repos\\n               WHERE id NOT IN (SELECT repo_id FROM project_repos)\\n                 AND id NOT IN (SELECT repo_id FROM workspace_repos)\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c27f2fd6b3696cb5a8ec54226608440786a6cec601783f797be3a8c515080d62\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            SELECT\\n                id              as \\\"id!: Uuid\\\",\\n                scratch_type,\\n                payload,\\n                created_at      as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at      as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM scratch\\n            WHERE id = $1 AND scratch_type = $2\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"scratch_type\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"payload\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-c5a45e39543468b57c2e3662735c640210c3948113dcbd1be8339f2c27506b76.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspaces SET branch = $1, updated_at = datetime('now') WHERE id = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c5a45e39543468b57c2e3662735c640210c3948113dcbd1be8339f2c27506b76\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-c793ee8493c54ea295a62a51650d00894fdad2f2cadc5665ae1e16a605626cb2.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT remote_id as \\\"remote_id: Uuid\\\"\\n            FROM migration_state\\n            WHERE entity_type = $1 AND local_id = $2 AND status = 'migrated'\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"c793ee8493c54ea295a62a51650d00894fdad2f2cadc5665ae1e16a605626cb2\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-cac90f2884c7c0eed4d2ab621016a5bc62dfbcb65539eb4a52e3306f96c0698a.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO sessions (id, workspace_id, name, executor, agent_working_dir)\\n               VALUES ($1, $2, $3, $4, $5)\\n               RETURNING id AS \\\"id!: Uuid\\\",\\n                         workspace_id AS \\\"workspace_id!: Uuid\\\",\\n                         name,\\n                         executor,\\n                         agent_working_dir,\\n                         created_at AS \\\"created_at!: DateTime<Utc>\\\",\\n                         updated_at AS \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"agent_working_dir\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 5\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"cac90f2884c7c0eed4d2ab621016a5bc62dfbcb65539eb4a52e3306f96c0698a\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-cd6d7ca74442a100d9caf170ac43118795226f50b8392069b47abd4f7564c135.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) as \\\"pending!: i64\\\",\\n                COALESCE(SUM(CASE WHEN status = 'migrated' THEN 1 ELSE 0 END), 0) as \\\"migrated!: i64\\\",\\n                COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) as \\\"failed!: i64\\\",\\n                COALESCE(SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END), 0) as \\\"skipped!: i64\\\",\\n                COUNT(*) as \\\"total!: i64\\\"\\n            FROM migration_state\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"pending!: i64\",\n        \"ordinal\": 0,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"migrated!: i64\",\n        \"ordinal\": 1,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"failed!: i64\",\n        \"ordinal\": 2,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"skipped!: i64\",\n        \"ordinal\": 3,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"total!: i64\",\n        \"ordinal\": 4,\n        \"type_info\": \"Integer\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"cd6d7ca74442a100d9caf170ac43118795226f50b8392069b47abd4f7564c135\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-d41acd2bd3c805f9787c0d468a25ce62bfa8b268131c19b83fd76acb59a8c9ea.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspaces SET worktree_deleted = FALSE, updated_at = datetime('now') WHERE id = ?\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"d41acd2bd3c805f9787c0d468a25ce62bfa8b268131c19b83fd76acb59a8c9ea\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-d7a11078522c029b71a75f4a45abc941536d3ce08d8ee0fcbde3eacf6360b7d5.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                      ep.id              as \\\"id!: Uuid\\\",\\n                      ep.session_id      as \\\"session_id!: Uuid\\\",\\n                      ep.run_reason      as \\\"run_reason!: ExecutionProcessRunReason\\\",\\n                      ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\",\\n                      ep.status          as \\\"status!: ExecutionProcessStatus\\\",\\n                      ep.exit_code,\\n                      ep.dropped as \\\"dropped!: bool\\\",\\n                      ep.started_at      as \\\"started_at!: DateTime<Utc>\\\",\\n                      ep.completed_at    as \\\"completed_at?: DateTime<Utc>\\\",\\n                      ep.created_at      as \\\"created_at!: DateTime<Utc>\\\",\\n                      ep.updated_at      as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM execution_processes ep\\n               WHERE ep.session_id = ?\\n                 AND (? OR ep.dropped = FALSE)\\n               ORDER BY ep.created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"session_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"run_reason!: ExecutionProcessRunReason\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: ExecutionProcessStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"exit_code\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dropped!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"started_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 2\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"d7a11078522c029b71a75f4a45abc941536d3ce08d8ee0fcbde3eacf6360b7d5\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-d9f7205a1a749c23c928ef2861783446bd99a7b7939929dee1f8a409bb99ab04.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            SELECT DISTINCT s.workspace_id as \\\"workspace_id!: Uuid\\\"\\n            FROM execution_processes ep\\n            JOIN sessions s ON ep.session_id = s.id\\n            JOIN workspaces w ON s.workspace_id = w.id\\n            WHERE w.archived = $1\\n              AND ep.status = 'running'\\n              AND ep.run_reason = 'devserver'\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"d9f7205a1a749c23c928ef2861783446bd99a7b7939929dee1f8a409bb99ab04\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-db1b29e1ea843ee4024c914820978a558f0ac4cc65da76645ccff4748240e565.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO coding_agent_turns (\\n                id, execution_process_id, agent_session_id, agent_message_id, prompt, summary, seen,\\n                created_at, updated_at\\n               )\\n               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\\n               RETURNING\\n                id as \\\"id!: Uuid\\\",\\n                execution_process_id as \\\"execution_process_id!: Uuid\\\",\\n                agent_session_id,\\n                agent_message_id,\\n                prompt,\\n                summary,\\n                seen as \\\"seen!: bool\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"execution_process_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"agent_session_id\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"agent_message_id\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"prompt\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"summary\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"seen!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 9\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"db1b29e1ea843ee4024c914820978a558f0ac4cc65da76645ccff4748240e565\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-db39f7ab7391c1289299e7f8aa7e1f642874eed0179e91a9558f9df534db797c.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id AS \\\"id!: Uuid\\\",\\n                      workspace_id AS \\\"workspace_id!: Uuid\\\",\\n                      name,\\n                      executor,\\n                      agent_working_dir,\\n                      created_at AS \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at AS \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM sessions\\n               WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"executor\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"agent_working_dir\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"db39f7ab7391c1289299e7f8aa7e1f642874eed0179e91a9558f9df534db797c\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-dc5d0ad507cbd962235c9e85c3e43f34c7c38eb2e08ab7899073010a6e77b37d.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\",\\n                      file_path as \\\"file_path!\\\",\\n                      original_name as \\\"original_name!\\\",\\n                      mime_type,\\n                      size_bytes as \\\"size_bytes!\\\",\\n                      hash as \\\"hash!\\\",\\n                      created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM attachments\\n               WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"file_path!\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"original_name!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"mime_type\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"size_bytes!\",\n        \"ordinal\": 4,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"hash!\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"dc5d0ad507cbd962235c9e85c3e43f34c7c38eb2e08ab7899073010a6e77b37d\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-dc88d70bb25b6437580480c346ed29fb90115e3b83fa36d8966b62f02990b9c7.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE coding_agent_turns\\n               SET agent_message_id = $1, updated_at = $2\\n               WHERE execution_process_id = $3\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"dc88d70bb25b6437580480c346ed29fb90115e3b83fa36d8966b62f02990b9c7\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"DELETE FROM tags WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-df2f35912a8055dff6cb24c83ea67fc49b432f457961fa584c6a13389bfdcea5.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT i.id as \\\"id!: Uuid\\\",\\n                      i.file_path as \\\"file_path!\\\",\\n                      i.original_name as \\\"original_name!\\\",\\n                      i.mime_type,\\n                      i.size_bytes as \\\"size_bytes!\\\",\\n                      i.hash as \\\"hash!\\\",\\n                      i.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      i.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM attachments i\\n               LEFT JOIN workspace_attachments wa ON i.id = wa.attachment_id\\n               WHERE wa.workspace_id IS NULL\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"file_path!\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"original_name!\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"mime_type\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"size_bytes!\",\n        \"ordinal\": 4,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"hash!\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"df2f35912a8055dff6cb24c83ea67fc49b432f457961fa584c6a13389bfdcea5\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-df66eae37a24c07c2ae0a521c802e3828ac153e6c087edcf2ba4dbe621dc79d3.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT id as \\\"id!: Uuid\\\", project_id as \\\"project_id!: Uuid\\\", title, description, status as \\\"status!: TaskStatus\\\", parent_workspace_id as \\\"parent_workspace_id: Uuid\\\", created_at as \\\"created_at!: DateTime<Utc>\\\", updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM tasks\\n               WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"project_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"title\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"description\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: TaskStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parent_workspace_id: Uuid\",\n        \"ordinal\": 5,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"df66eae37a24c07c2ae0a521c802e3828ac153e6c087edcf2ba4dbe621dc79d3\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"\\n            SELECT\\n                id              as \\\"id!: Uuid\\\",\\n                scratch_type,\\n                payload,\\n                created_at      as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at      as \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM scratch\\n            WHERE rowid = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"scratch_type\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"payload\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-ee06dfd8dc7fc2ffc239db9635a3a5cac2e603992392a632bff7d450c6bca061.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"INSERT INTO migration_state (id, entity_type, local_id)\\n            VALUES ($1, $2, $3)\\n            RETURNING\\n                id as \\\"id!: Uuid\\\",\\n                entity_type as \\\"entity_type!: EntityType\\\",\\n                local_id as \\\"local_id!: Uuid\\\",\\n                remote_id as \\\"remote_id: Uuid\\\",\\n                status as \\\"status!: MigrationStatus\\\",\\n                error_message,\\n                attempt_count as \\\"attempt_count!\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"entity_type!: EntityType\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"local_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"remote_id: Uuid\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"status!: MigrationStatus\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"error_message\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"attempt_count!\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 3\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"ee06dfd8dc7fc2ffc239db9635a3a5cac2e603992392a632bff7d450c6bca061\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-efce74898a8e81dafc3933231e8ac3c07be392e1c073e62c621138c00d0ed30d.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"UPDATE workspaces SET worktree_deleted = TRUE, updated_at = datetime('now') WHERE id = ?\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"efce74898a8e81dafc3933231e8ac3c07be392e1c073e62c621138c00d0ed30d\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT  id                AS \\\"id!: Uuid\\\",\\n                       task_id           AS \\\"task_id: Uuid\\\",\\n                       container_ref,\\n                       branch,\\n                       setup_completed_at AS \\\"setup_completed_at: DateTime<Utc>\\\",\\n                       created_at        AS \\\"created_at!: DateTime<Utc>\\\",\\n                       updated_at        AS \\\"updated_at!: DateTime<Utc>\\\",\\n                       archived          AS \\\"archived!: bool\\\",\\n                       pinned            AS \\\"pinned!: bool\\\",\\n                       name,\\n                       worktree_deleted  AS \\\"worktree_deleted!: bool\\\"\\n               FROM    workspaces\\n               WHERE   rowid = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"task_id: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"container_ref\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"branch\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_completed_at: DateTime<Utc>\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archived!: bool\",\n        \"ordinal\": 7,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pinned!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"worktree_deleted!: bool\",\n        \"ordinal\": 10,\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-f584dbe0f2f2a4f1e7dcf5b8f675eb2a6d954bb3f148ac0fece10652f05fb49b.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT ep.executor_action as \\\"executor_action!: sqlx::types::Json<ExecutorActionField>\\\"\\n               FROM sessions s\\n               JOIN execution_processes ep ON ep.session_id = s.id\\n               WHERE s.workspace_id = $1\\n               ORDER BY s.created_at ASC, ep.created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n        \"ordinal\": 0,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"f584dbe0f2f2a4f1e7dcf5b8f675eb2a6d954bb3f148ac0fece10652f05fb49b\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-f9e8640c28fae8aebf3d8b0d3984804fdb3f197c8cc2d5750fd267c82e3e68a1.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT DISTINCT r.id as \\\"id!: Uuid\\\",\\n                      r.path,\\n                      r.name,\\n                      r.display_name,\\n                      r.setup_script,\\n                      r.cleanup_script,\\n                      r.archive_script,\\n                      r.copy_files,\\n                      r.parallel_setup_script as \\\"parallel_setup_script!: bool\\\",\\n                      r.dev_server_script,\\n                      r.default_target_branch,\\n                      r.default_working_dir,\\n                      r.created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                      r.updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM repos r\\n               JOIN workspace_repos wr ON r.id = wr.repo_id\\n               JOIN workspaces w ON wr.workspace_id = w.id\\n               WHERE w.task_id = $1\\n               ORDER BY r.display_name ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"path\",\n        \"ordinal\": 1,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"name\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"display_name\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"setup_script\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"cleanup_script\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"archive_script\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"copy_files\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"parallel_setup_script!: bool\",\n        \"ordinal\": 8,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"dev_server_script\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_target_branch\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"default_working_dir\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 12,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 13,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f9e8640c28fae8aebf3d8b0d3984804fdb3f197c8cc2d5750fd267c82e3e68a1\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-faae305f6ac9dc7d04d21c76531cde3912647430195267ffa5b99bb9a7df1feb.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                workspace_id as \\\"workspace_id!: Uuid\\\",\\n                repo_id as \\\"repo_id!: Uuid\\\",\\n                merge_type as \\\"merge_type!: MergeType\\\",\\n                merge_commit,\\n                pr_number,\\n                pr_url,\\n                pr_status as \\\"pr_status?: MergeStatus\\\",\\n                pr_merged_at as \\\"pr_merged_at?: DateTime<Utc>\\\",\\n                pr_merge_commit_sha,\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                target_branch_name as \\\"target_branch_name!: String\\\"\\n               FROM merges\\n               WHERE merge_type = 'pr' AND pr_status = 'open'\\n               ORDER BY created_at DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"workspace_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"merge_type!: MergeType\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_number\",\n        \"ordinal\": 5,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"pr_url\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_status?: MergeStatus\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merged_at?: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"pr_merge_commit_sha\",\n        \"ordinal\": 9,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 10,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"target_branch_name!: String\",\n        \"ordinal\": 11,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 0\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"faae305f6ac9dc7d04d21c76531cde3912647430195267ffa5b99bb9a7df1feb\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-fb1ab168509b38eccf3064e2a90690a3fdef67a98fee7e5943689e61818d34f0.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                    id               as \\\"id!: Uuid\\\",\\n                    execution_process_id as \\\"execution_process_id!: Uuid\\\",\\n                    repo_id as \\\"repo_id!: Uuid\\\",\\n                    before_head_commit,\\n                    after_head_commit,\\n                    merge_commit,\\n                    created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                    updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM execution_process_repo_states\\n               WHERE execution_process_id = $1\\n               ORDER BY created_at ASC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"execution_process_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"repo_id!: Uuid\",\n        \"ordinal\": 2,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"before_head_commit\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"after_head_commit\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"merge_commit\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 6,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"fb1ab168509b38eccf3064e2a90690a3fdef67a98fee7e5943689e61818d34f0\"\n}\n"
  },
  {
    "path": "crates/db/.sqlx/query-fc90f4dd7a408d6129aff95538de22c3a1ca018bc7837e3dc1c5aa0007844887.json",
    "content": "{\n  \"db_name\": \"SQLite\",\n  \"query\": \"SELECT\\n                id as \\\"id!: Uuid\\\",\\n                execution_process_id as \\\"execution_process_id!: Uuid\\\",\\n                agent_session_id,\\n                agent_message_id,\\n                prompt,\\n                summary,\\n                seen as \\\"seen!: bool\\\",\\n                created_at as \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at as \\\"updated_at!: DateTime<Utc>\\\"\\n               FROM coding_agent_turns\\n               WHERE execution_process_id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"name\": \"id!: Uuid\",\n        \"ordinal\": 0,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"execution_process_id!: Uuid\",\n        \"ordinal\": 1,\n        \"type_info\": \"Blob\"\n      },\n      {\n        \"name\": \"agent_session_id\",\n        \"ordinal\": 2,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"agent_message_id\",\n        \"ordinal\": 3,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"prompt\",\n        \"ordinal\": 4,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"summary\",\n        \"ordinal\": 5,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"seen!: bool\",\n        \"ordinal\": 6,\n        \"type_info\": \"Integer\"\n      },\n      {\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"ordinal\": 7,\n        \"type_info\": \"Text\"\n      },\n      {\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"ordinal\": 8,\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Right\": 1\n    },\n    \"nullable\": [\n      true,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"fc90f4dd7a408d6129aff95538de22c3a1ca018bc7837e3dc1c5aa0007844887\"\n}\n"
  },
  {
    "path": "crates/db/Cargo.toml",
    "content": "[package]\nname = \"db\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\nutils = { path = \"../utils\" }\nexecutors = { path = \"../executors\" }\nthiserror = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nanyhow = { workspace = true }\ntracing = { workspace = true }\nsqlx = { version = \"0.8.6\", features = [\"runtime-tokio\", \"tls-rustls-aws-lc-rs\", \"sqlite\", \"sqlite-preupdate-hook\", \"chrono\", \"uuid\"] }\nchrono = { version = \"0.4\", features = [\"serde\"] }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nts-rs = { workspace = true }\nserde_with = { workspace = true }\nstrum = \"0.27.2\"\nstrum_macros = \"0.27.2\"\nfutures = \"0.3.32\"\n"
  },
  {
    "path": "crates/db/migrations/20250617183714_init.sql",
    "content": "PRAGMA foreign_keys = ON;\n\nCREATE TABLE projects (\n    id            BLOB PRIMARY KEY,\n    name          TEXT NOT NULL,\n    git_repo_path TEXT NOT NULL DEFAULT '' UNIQUE,\n    setup_script  TEXT DEFAULT '',\n    created_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))\n);\n\nCREATE TABLE tasks (\n    id          BLOB PRIMARY KEY,\n    project_id  BLOB NOT NULL,\n    title       TEXT NOT NULL,\n    description TEXT,\n    status      TEXT NOT NULL DEFAULT 'todo'\n                   CHECK (status IN ('todo','inprogress','done','cancelled','inreview')),\n    created_at  TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at  TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE\n);\n\nCREATE TABLE task_attempts (\n    id            BLOB PRIMARY KEY,\n    task_id       BLOB NOT NULL,\n    worktree_path TEXT NOT NULL,\n    merge_commit  TEXT,\n    executor      TEXT,\n    stdout        TEXT,\n    stderr        TEXT,\n    created_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE\n);\n\nCREATE TABLE task_attempt_activities (\n    id              BLOB PRIMARY KEY,\n    task_attempt_id BLOB NOT NULL,\n    status          TEXT NOT NULL DEFAULT 'init'\n                       CHECK (status IN ('init','setuprunning','setupcomplete','setupfailed','executorrunning','executorcomplete','executorfailed','paused')),    note            TEXT,\n    created_at      TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE\n);\n"
  },
  {
    "path": "crates/db/migrations/20250620212427_execution_processes.sql",
    "content": "PRAGMA foreign_keys = ON;\n\nCREATE TABLE execution_processes (\n    id                BLOB PRIMARY KEY,\n    task_attempt_id   BLOB NOT NULL,\n    process_type      TEXT NOT NULL DEFAULT 'setupscript'\n                         CHECK (process_type IN ('setupscript','codingagent','devserver')),\n    status            TEXT NOT NULL DEFAULT 'running'\n                         CHECK (status IN ('running','completed','failed','killed')),\n    command           TEXT NOT NULL,\n    args              TEXT,  -- JSON array of arguments\n    working_directory TEXT NOT NULL,\n    stdout            TEXT,\n    stderr            TEXT,\n    exit_code         INTEGER,\n    started_at        TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    completed_at      TEXT,\n    created_at        TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at        TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_execution_processes_task_attempt_id ON execution_processes(task_attempt_id);\nCREATE INDEX idx_execution_processes_status ON execution_processes(status);\nCREATE INDEX idx_execution_processes_type ON execution_processes(process_type);\n"
  },
  {
    "path": "crates/db/migrations/20250620214100_remove_stdout_stderr_from_task_attempts.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Remove stdout and stderr columns from task_attempts table\n-- These are now tracked in the execution_processes table for better granularity\n\n-- SQLite doesn't support DROP COLUMN directly, so we need to recreate the table\n-- First, create a new table without stdout and stderr\nCREATE TABLE task_attempts_new (\n    id            BLOB PRIMARY KEY,\n    task_id       BLOB NOT NULL,\n    worktree_path TEXT NOT NULL,\n    merge_commit  TEXT,\n    executor      TEXT,\n    created_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE\n);\n\n-- Copy data from old table to new table (excluding stdout and stderr)\nINSERT INTO task_attempts_new (id, task_id, worktree_path, merge_commit, executor, created_at, updated_at)\nSELECT id, task_id, worktree_path, merge_commit, executor, created_at, updated_at\nFROM task_attempts;\n\n-- Drop the old table\nDROP TABLE task_attempts;\n\n-- Rename the new table to the original name\nALTER TABLE task_attempts_new RENAME TO task_attempts;\n"
  },
  {
    "path": "crates/db/migrations/20250621120000_relate_activities_to_execution_processes.sql",
    "content": "-- Migration to relate task_attempt_activities to execution_processes instead of task_attempts\n-- This migration will:\n-- 1. Drop and recreate the task_attempt_activities table with execution_process_id\n-- 2. Clear existing data as it cannot be migrated meaningfully\n\n-- Drop the existing table (this will wipe existing activity data)\nDROP TABLE IF EXISTS task_attempt_activities;\n\n-- Create the new table structure with execution_process_id foreign key\nCREATE TABLE task_attempt_activities (\n    id TEXT PRIMARY KEY,\n    execution_process_id TEXT NOT NULL REFERENCES execution_processes(id) ON DELETE CASCADE,\n    status TEXT NOT NULL,\n    note TEXT,\n    created_at DATETIME NOT NULL DEFAULT (datetime('now')),\n    FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE\n);\n\n-- Create index for efficient lookups by execution_process_id\nCREATE INDEX idx_task_attempt_activities_execution_process_id ON task_attempt_activities(execution_process_id);\n\n-- Create index for efficient lookups by created_at for ordering\nCREATE INDEX idx_task_attempt_activities_created_at ON task_attempt_activities(created_at);\n"
  },
  {
    "path": "crates/db/migrations/20250623120000_executor_sessions.sql",
    "content": "PRAGMA foreign_keys = ON;\n\nCREATE TABLE executor_sessions (\n    id                    BLOB PRIMARY KEY,\n    task_attempt_id       BLOB NOT NULL,\n    execution_process_id  BLOB NOT NULL,\n    session_id            TEXT,  -- External session ID from Claude/Amp\n    prompt                TEXT,  -- The prompt sent to the executor\n    created_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE,\n    FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_executor_sessions_task_attempt_id ON executor_sessions(task_attempt_id);\nCREATE INDEX idx_executor_sessions_execution_process_id ON executor_sessions(execution_process_id);\nCREATE INDEX idx_executor_sessions_session_id ON executor_sessions(session_id);\n"
  },
  {
    "path": "crates/db/migrations/20250623130000_add_executor_type_to_execution_processes.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Add executor_type column to execution_processes table\nALTER TABLE execution_processes ADD COLUMN executor_type TEXT;\n"
  },
  {
    "path": "crates/db/migrations/20250625000000_add_dev_script_to_projects.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Add dev_script column to projects table\nALTER TABLE projects ADD COLUMN dev_script TEXT DEFAULT '';\n"
  },
  {
    "path": "crates/db/migrations/20250701000000_add_branch_to_task_attempts.sql",
    "content": "-- Add branch column to task_attempts table\nALTER TABLE task_attempts ADD COLUMN branch TEXT NOT NULL DEFAULT '';\n"
  },
  {
    "path": "crates/db/migrations/20250701000001_add_pr_tracking_to_task_attempts.sql",
    "content": "-- Add PR tracking fields to task_attempts table\nALTER TABLE task_attempts ADD COLUMN pr_url TEXT;\nALTER TABLE task_attempts ADD COLUMN pr_number INTEGER;\nALTER TABLE task_attempts ADD COLUMN pr_status TEXT; -- open, closed, merged\nALTER TABLE task_attempts ADD COLUMN pr_merged_at DATETIME;\n"
  },
  {
    "path": "crates/db/migrations/20250701120000_add_assistant_message_to_executor_sessions.sql",
    "content": "-- Add summary column to executor_sessions table\nALTER TABLE executor_sessions ADD COLUMN summary TEXT;\n"
  },
  {
    "path": "crates/db/migrations/20250708000000_add_base_branch_to_task_attempts.sql",
    "content": "-- Add base_branch column to task_attempts table with default value\nALTER TABLE task_attempts ADD COLUMN base_branch TEXT NOT NULL DEFAULT 'main';\n"
  },
  {
    "path": "crates/db/migrations/20250709000000_add_worktree_deleted_flag.sql",
    "content": "-- Add worktree_deleted flag to track when worktrees are cleaned up\nALTER TABLE task_attempts ADD COLUMN worktree_deleted BOOLEAN NOT NULL DEFAULT FALSE;"
  },
  {
    "path": "crates/db/migrations/20250710000000_add_setup_completion.sql",
    "content": "-- Add setup completion tracking to task_attempts table\n-- This enables automatic setup script execution for recreated worktrees\nALTER TABLE task_attempts ADD COLUMN setup_completed_at DATETIME;"
  },
  {
    "path": "crates/db/migrations/20250715154859_add_task_templates.sql",
    "content": "-- Add task templates tables\nCREATE TABLE task_templates (\n    id            BLOB PRIMARY KEY,\n    project_id    BLOB,  -- NULL for global templates\n    title         TEXT NOT NULL,\n    description   TEXT,\n    template_name TEXT NOT NULL,  -- Display name for the template\n    created_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE\n);\n\n-- Add index for faster queries\nCREATE INDEX idx_task_templates_project_id ON task_templates(project_id);\n\n-- Add unique constraints to prevent duplicate template names within same scope\n-- For project-specific templates: unique within each project\nCREATE UNIQUE INDEX idx_task_templates_unique_name_project \nON task_templates(project_id, template_name) \nWHERE project_id IS NOT NULL;\n\n-- For global templates: unique across all global templates\nCREATE UNIQUE INDEX idx_task_templates_unique_name_global \nON task_templates(template_name) \nWHERE project_id IS NULL;"
  },
  {
    "path": "crates/db/migrations/20250716143725_add_default_templates.sql",
    "content": "-- Add default global templates\n\n-- 1. Bug Analysis template\nINSERT INTO task_templates (\n    id,\n    project_id,\n    title,\n    description,\n    template_name,\n    created_at,\n    updated_at\n) VALUES (\n    randomblob(16),\n    NULL, -- Global template\n    'Analyze codebase for potential bugs and issues',\n    'Perform a comprehensive analysis of the project codebase to identify potential bugs, code smells, and areas of improvement.\n\n## Analysis Checklist:\n\n### 1. Static Code Analysis\n- [ ] Run linting tools to identify syntax and style issues\n- [ ] Check for unused variables, imports, and dead code\n- [ ] Identify potential type errors or mismatches\n- [ ] Look for deprecated API usage\n\n### 2. Common Bug Patterns\n- [ ] Check for null/undefined reference errors\n- [ ] Identify potential race conditions\n- [ ] Look for improper error handling\n- [ ] Check for resource leaks (memory, file handles, connections)\n- [ ] Identify potential security vulnerabilities (XSS, SQL injection, etc.)\n\n### 3. Code Quality Issues\n- [ ] Identify overly complex functions (high cyclomatic complexity)\n- [ ] Look for code duplication\n- [ ] Check for missing or inadequate input validation\n- [ ] Identify hardcoded values that should be configurable\n\n### 4. Testing Gaps\n- [ ] Identify untested code paths\n- [ ] Check for missing edge case tests\n- [ ] Look for inadequate error scenario testing\n\n### 5. Performance Concerns\n- [ ] Identify potential performance bottlenecks\n- [ ] Check for inefficient algorithms or data structures\n- [ ] Look for unnecessary database queries or API calls\n\n## Deliverables:\n1. Prioritized list of identified issues\n2. Recommendations for fixes\n3. Estimated effort for addressing each issue',\n    'Bug Analysis',\n    datetime('now', 'subsec'),\n    datetime('now', 'subsec')\n);\n\n-- 2. Unit Test template\nINSERT INTO task_templates (\n    id,\n    project_id,\n    title,\n    description,\n    template_name,\n    created_at,\n    updated_at\n) VALUES (\n    randomblob(16),\n    NULL, -- Global template\n    'Add unit tests for [component/function]',\n    'Write unit tests to improve code coverage and ensure reliability.\n\n## Unit Testing Checklist\n\n### 1. Identify What to Test\n- [ ] Run coverage report to find untested functions\n- [ ] List the specific functions/methods to test\n- [ ] Note current coverage percentage\n\n### 2. Write Tests\n- [ ] Test the happy path (expected behavior)\n- [ ] Test edge cases (empty inputs, boundaries)\n- [ ] Test error cases (invalid inputs, exceptions)\n- [ ] Mock external dependencies\n- [ ] Use descriptive test names\n\n### 3. Test Quality\n- [ ] Each test focuses on one behavior\n- [ ] Tests can run independently\n- [ ] No hardcoded values that might change\n- [ ] Clear assertions that verify the behavior\n\n## Examples to Cover:\n- Normal inputs → Expected outputs\n- Empty/null inputs → Proper handling\n- Invalid inputs → Error cases\n- Boundary values → Edge case behavior\n\n## Goal\nAchieve at least 80% coverage for the target component\n\n## Deliverables\n1. New test file(s) with comprehensive unit tests\n2. Updated coverage report\n3. All tests passing',\n    'Add Unit Tests',\n    datetime('now', 'subsec'),\n    datetime('now', 'subsec')\n);\n\n-- 3. Code Refactoring template\nINSERT INTO task_templates (\n    id,\n    project_id,\n    title,\n    description,\n    template_name,\n    created_at,\n    updated_at\n) VALUES (\n    randomblob(16),\n    NULL, -- Global template\n    'Refactor [component/module] for better maintainability',\n    'Improve code structure and maintainability without changing functionality.\n\n## Refactoring Checklist\n\n### 1. Identify Refactoring Targets\n- [ ] Run code analysis tools (linters, complexity analyzers)\n- [ ] Identify code smells (long methods, duplicate code, large classes)\n- [ ] Check for outdated patterns or deprecated approaches\n- [ ] Review areas with frequent bugs or changes\n\n### 2. Plan the Refactoring\n- [ ] Define clear goals (what to improve and why)\n- [ ] Ensure tests exist for current functionality\n- [ ] Create a backup branch\n- [ ] Break down into small, safe steps\n\n### 3. Common Refactoring Actions\n- [ ] Extract methods from long functions\n- [ ] Remove duplicate code (DRY principle)\n- [ ] Rename variables/functions for clarity\n- [ ] Simplify complex conditionals\n- [ ] Extract constants from magic numbers/strings\n- [ ] Group related functionality into modules\n- [ ] Remove dead code\n\n### 4. Maintain Functionality\n- [ ] Run tests after each change\n- [ ] Keep changes small and incremental\n- [ ] Commit frequently with clear messages\n- [ ] Verify no behavior has changed\n\n### 5. Code Quality Improvements\n- [ ] Apply consistent formatting\n- [ ] Update to modern syntax/features\n- [ ] Improve error handling\n- [ ] Add type annotations (if applicable)\n\n## Success Criteria\n- All tests still pass\n- Code is more readable and maintainable\n- No new bugs introduced\n- Performance not degraded\n\n## Deliverables\n1. Refactored code with improved structure\n2. All tests passing\n3. Brief summary of changes made',\n    'Code Refactoring',\n    datetime('now', 'subsec'),\n    datetime('now', 'subsec')\n);"
  },
  {
    "path": "crates/db/migrations/20250716161432_update_executor_names_to_kebab_case.sql",
    "content": "-- Migration to update executor type names from snake_case/camelCase to kebab-case\n-- This handles the change from charmopencode -> charm-opencode and setup_script -> setup-script\n\n-- Update task_attempts.executor column\nUPDATE task_attempts \nSET executor = 'charm-opencode' \nWHERE executor = 'charmopencode';\n\nUPDATE task_attempts \nSET executor = 'setup-script' \nWHERE executor = 'setup_script';\n\n-- Update execution_processes.executor_type column\nUPDATE execution_processes \nSET executor_type = 'charm-opencode' \nWHERE executor_type = 'charmopencode';\n\nUPDATE execution_processes \nSET executor_type = 'setup-script' \nWHERE executor_type = 'setup_script';"
  },
  {
    "path": "crates/db/migrations/20250716170000_add_parent_task_to_tasks.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Add parent_task_attempt column to tasks table\nALTER TABLE tasks ADD COLUMN parent_task_attempt BLOB REFERENCES task_attempts(id);\n\n-- Create index for parent_task_attempt lookups\nCREATE INDEX idx_tasks_parent_task_attempt ON tasks(parent_task_attempt);"
  },
  {
    "path": "crates/db/migrations/20250717000000_drop_task_attempt_activities.sql",
    "content": "-- Migration to drop task_attempt_activities table\n-- This removes the task attempt activity tracking functionality\n\n-- Drop indexes first\nDROP INDEX IF EXISTS idx_task_attempt_activities_execution_process_id;\nDROP INDEX IF EXISTS idx_task_attempt_activities_created_at;\n\n-- Drop the table\nDROP TABLE IF EXISTS task_attempt_activities;\n"
  },
  {
    "path": "crates/db/migrations/20250719000000_add_cleanup_script_to_projects.sql",
    "content": "-- Add cleanup_script column to projects table\nALTER TABLE projects ADD COLUMN cleanup_script TEXT;\n"
  },
  {
    "path": "crates/db/migrations/20250720000000_add_cleanupscript_to_process_type_constraint.sql",
    "content": "-- 1. Add the replacement column with the wider CHECK\nALTER TABLE execution_processes\n  ADD COLUMN process_type_new TEXT NOT NULL DEFAULT 'setupscript'\n    CHECK (process_type_new IN ('setupscript',\n                                'cleanupscript',   -- new value 🎉\n                                'codingagent',\n                                'devserver'));\n\n-- 2. Copy existing values across\nUPDATE execution_processes\n  SET process_type_new = process_type;\n\n-- 3. Drop any indexes that mention the old column\nDROP INDEX IF EXISTS idx_execution_processes_type;\n\n-- 4. Remove the old column (requires 3.35+)\nALTER TABLE execution_processes DROP COLUMN process_type;\n\n-- 5. Rename the new column back to the canonical name\nALTER TABLE execution_processes\n  RENAME COLUMN process_type_new TO process_type;\n\n-- 6. Re-create the index\nCREATE INDEX idx_execution_processes_type\n        ON execution_processes(process_type);"
  },
  {
    "path": "crates/db/migrations/20250726182144_update_worktree_path_to_container_ref.sql",
    "content": "-- Add migration script here\n\nALTER TABLE task_attempts ADD COLUMN container_ref TEXT;  -- nullable\nUPDATE task_attempts SET container_ref = worktree_path;\n\n-- If you might have triggers or indexes on worktree_path, drop them before this step.\n\nALTER TABLE task_attempts DROP COLUMN worktree_path;"
  },
  {
    "path": "crates/db/migrations/20250726210910_make_branch_optional.sql",
    "content": "-- Add migration script here\n\n-- 1) Create replacement column (nullable TEXT)\nALTER TABLE task_attempts ADD COLUMN branch_new TEXT;  -- nullable\n\n-- 2) Copy existing values\nUPDATE task_attempts SET branch_new = branch;\n\n-- If you have indexes/triggers/constraints that reference \"branch\",\n-- drop them before the next two steps and recreate them afterwards.\n\n-- 3) Remove the old non-nullable column\nALTER TABLE task_attempts DROP COLUMN branch;\n\n-- 4) Keep the original column name\nALTER TABLE task_attempts RENAME COLUMN branch_new TO branch;\n"
  },
  {
    "path": "crates/db/migrations/20250727124142_remove_command_from_execution_process.sql",
    "content": "-- Add migration script here\n\nALTER TABLE execution_processes DROP COLUMN command;\nALTER TABLE execution_processes DROP COLUMN args;"
  },
  {
    "path": "crates/db/migrations/20250727150349_remove_working_directory.sql",
    "content": "-- Add migration script here\n\nALTER TABLE execution_processes DROP COLUMN working_directory;"
  },
  {
    "path": "crates/db/migrations/20250729162941_create_execution_process_logs.sql",
    "content": "PRAGMA foreign_keys = ON;\n\nCREATE TABLE execution_process_logs (\n    execution_id      BLOB PRIMARY KEY,\n    logs              TEXT NOT NULL,      -- JSONL format (one LogMsg per line)\n    byte_size         INTEGER NOT NULL,\n    inserted_at       TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (execution_id) REFERENCES execution_processes(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_execution_process_logs_inserted_at ON execution_process_logs(inserted_at);\n"
  },
  {
    "path": "crates/db/migrations/20250729165913_remove_stdout_and_stderr_from_execution_processes.sql",
    "content": "-- Add migration script here\n\nALTER TABLE execution_processes DROP COLUMN stdout;\nALTER TABLE execution_processes DROP COLUMN stderr;"
  },
  {
    "path": "crates/db/migrations/20250730000000_add_executor_action_to_execution_processes.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Clear existing execution_processes records since we can't meaningfully migrate them\n-- (old records lack the actual script content and prompts needed for ExecutorActions)\nDELETE FROM execution_processes;\n\n-- Add executor_action column to execution_processes table for storing full ExecutorActions JSON\nALTER TABLE execution_processes ADD COLUMN executor_action TEXT NOT NULL DEFAULT '';\n"
  },
  {
    "path": "crates/db/migrations/20250730000001_rename_process_type_to_run_reason.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Rename process_type column to run_reason for better semantic clarity\nALTER TABLE execution_processes RENAME COLUMN process_type TO run_reason;\n"
  },
  {
    "path": "crates/db/migrations/20250730124500_add_execution_process_task_attempt_index.sql",
    "content": "ALTER TABLE execution_processes\nADD COLUMN executor_action_type TEXT\n  GENERATED ALWAYS AS (json_extract(executor_action, '$.type')) VIRTUAL;\n\nCREATE INDEX idx_execution_processes_task_attempt_type_created\nON execution_processes (task_attempt_id, executor_action_type, created_at DESC);"
  },
  {
    "path": "crates/db/migrations/20250805112332_add_executor_action_type_to_task_attempts.sql",
    "content": "-- Remove unused executor_type column from execution_processes\nALTER TABLE execution_processes DROP COLUMN executor_type;\n\nALTER TABLE task_attempts RENAME COLUMN executor TO base_coding_agent;\n\n"
  },
  {
    "path": "crates/db/migrations/20250805122100_fix_executor_action_type_virtual_column.sql",
    "content": "-- Drop the existing virtual column and index\nDROP INDEX IF EXISTS idx_execution_processes_task_attempt_type_created;\nALTER TABLE execution_processes DROP COLUMN executor_action_type;\n\n-- Recreate the virtual column with the correct JSON path\nALTER TABLE execution_processes\nADD COLUMN executor_action_type TEXT\n  GENERATED ALWAYS AS (json_extract(executor_action, '$.typ.type')) VIRTUAL;\n\n-- Recreate the index\nCREATE INDEX idx_execution_processes_task_attempt_type_created\nON execution_processes (task_attempt_id, executor_action_type, created_at DESC);\n"
  },
  {
    "path": "crates/db/migrations/20250811000000_add_copy_files_to_projects.sql",
    "content": "-- Add copy_files column to projects table\n-- This field stores comma-separated file paths to copy from the original project directory to the worktree\nALTER TABLE projects ADD COLUMN copy_files TEXT;"
  },
  {
    "path": "crates/db/migrations/20250813000001_rename_base_coding_agent_to_profile.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Rename base_coding_agent column to profile_label for better semantic clarity\nALTER TABLE task_attempts RENAME COLUMN base_coding_agent TO profile;\n-- best effort attempt to not break older task attempts by mapping to profiles\nUPDATE task_attempts\nSET profile = CASE profile\n    WHEN 'CLAUDE_CODE' THEN 'claude-code'\n    WHEN 'CODEX' THEN 'codex'\n    WHEN 'GEMINI' THEN 'gemini'\n    WHEN 'AMP' THEN 'amp'\n    WHEN 'OPENCODE' THEN 'opencode'\nEND\nWHERE profile IS NOT NULL\n  AND profile IN ('CLAUDE_CODE', 'CODEX', 'GEMINI', 'AMP', 'OPENCODE');\n"
  },
  {
    "path": "crates/db/migrations/20250815100344_migrate_old_executor_actions.sql",
    "content": "-- JSON format changed, means you can access logs from old execution_processes\n\nUPDATE execution_processes\nSET executor_action = json_set(\n  json_remove(executor_action, '$.typ.profile'),\n  '$.typ.profile_variant_label',\n  json_object(\n    'profile', json_extract(executor_action, '$.typ.profile'),\n    'variant', json('null')\n  )\n)\nWHERE json_type(executor_action, '$.typ') IS NOT NULL\n  AND json_type(executor_action, '$.typ.profile') = 'text';"
  },
  {
    "path": "crates/db/migrations/20250818150000_refactor_images_to_junction_tables.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- Refactor images table to use junction tables for many-to-many relationships\n-- This allows images to be associated with multiple tasks and execution processes\n-- No data migration needed as there are no existing users of the image system\n\nCREATE TABLE images (\n    id                    BLOB PRIMARY KEY,\n    file_path             TEXT NOT NULL,  -- relative path within cache/images/\n    original_name         TEXT NOT NULL,\n    mime_type             TEXT,\n    size_bytes            INTEGER,\n    hash                  TEXT NOT NULL UNIQUE,  -- SHA256 for deduplication\n    created_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))\n);\n\n-- Create junction table for task-image associations\nCREATE TABLE task_images (\n    id                    BLOB PRIMARY KEY,\n    task_id               BLOB NOT NULL,\n    image_id              BLOB NOT NULL,\n    created_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,\n    FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,\n    UNIQUE(task_id, image_id)  -- Prevent duplicate associations\n);\n\n\n-- Create indexes for efficient querying\nCREATE INDEX idx_images_hash ON images(hash);\nCREATE INDEX idx_task_images_task_id ON task_images(task_id);\nCREATE INDEX idx_task_images_image_id ON task_images(image_id);\n"
  },
  {
    "path": "crates/db/migrations/20250819000000_move_merge_commit_to_merges_table.sql",
    "content": "-- Create enhanced merges table with type-specific columns\nCREATE TABLE merges (\n    id              BLOB PRIMARY KEY,\n    task_attempt_id BLOB NOT NULL,\n    merge_type      TEXT NOT NULL CHECK (merge_type IN ('direct', 'pr')),\n    \n    -- Direct merge fields (NULL for PR merges)\n    merge_commit    TEXT,\n    \n    -- PR merge fields (NULL for direct merges)\n    pr_number       INTEGER,\n    pr_url          TEXT,\n    pr_status       TEXT CHECK (pr_status IN ('open', 'merged', 'closed')),\n    pr_merged_at    TEXT,\n    pr_merge_commit_sha TEXT,\n    \n    created_at      TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    target_branch_name TEXT NOT NULL,\n\n    -- Data integrity constraints\n    CHECK (\n        (merge_type = 'direct' AND merge_commit IS NOT NULL \n         AND pr_number IS NULL AND pr_url IS NULL) \n        OR \n        (merge_type = 'pr' AND pr_number IS NOT NULL AND pr_url IS NOT NULL \n         AND pr_status IS NOT NULL AND merge_commit IS NULL)\n    ),\n    \n    FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE\n);\n\n-- Create general index for all task_attempt_id queries\nCREATE INDEX idx_merges_task_attempt_id ON merges(task_attempt_id);\n\n-- Create index for finding open PRs quickly\nCREATE INDEX idx_merges_open_pr ON merges(task_attempt_id, pr_status) \nWHERE merge_type = 'pr' AND pr_status = 'open';\n\n-- Migrate existing merge_commit data to new table as direct merges\nINSERT INTO merges (id, task_attempt_id, merge_type, merge_commit, created_at, target_branch_name)\nSELECT \n    randomblob(16),\n    id,\n    'direct',\n    merge_commit,\n    updated_at,\n    base_branch\nFROM task_attempts\nWHERE merge_commit IS NOT NULL;\n\n-- Migrate existing PR data from task_attempts to merges\nINSERT INTO merges (id, task_attempt_id, merge_type, pr_number, pr_url, pr_status, pr_merged_at, pr_merge_commit_sha, created_at, target_branch_name)\nSELECT \n    randomblob(16),\n    id,\n    'pr',\n    pr_number,\n    pr_url,\n    CASE \n        WHEN pr_status = 'merged' THEN 'merged'\n        WHEN pr_status = 'closed' THEN 'closed'\n        ELSE 'open'\n    END,\n    pr_merged_at,\n    NULL, -- We don't have merge_commit for PRs in task_attempts\n    COALESCE(pr_merged_at, updated_at),\n    base_branch\nFROM task_attempts\nWHERE pr_number IS NOT NULL;\n\n-- Drop merge_commit column from task_attempts\nALTER TABLE task_attempts DROP COLUMN merge_commit;\n\n-- Drop PR columns from task_attempts\nALTER TABLE task_attempts DROP COLUMN pr_url;\nALTER TABLE task_attempts DROP COLUMN pr_number;\nALTER TABLE task_attempts DROP COLUMN pr_status;\nALTER TABLE task_attempts DROP COLUMN pr_merged_at;"
  },
  {
    "path": "crates/db/migrations/20250902120000_add_masked_by_restore_to_execution_processes.sql",
    "content": "-- Add a boolean flag to mark processes as dropped (excluded from timeline/logs)\nALTER TABLE execution_processes\n    ADD COLUMN dropped BOOLEAN NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "crates/db/migrations/20250902184501_rename-profile-to-executor.sql",
    "content": "-- Add migration script here\n\nALTER TABLE task_attempts RENAME COLUMN profile TO executor;\n"
  },
  {
    "path": "crates/db/migrations/20250903091032_executors_to_screaming_snake.sql",
    "content": "-- Converts pascal/camel to SCREAMING_SNAKE\nUPDATE task_attempts\nSET executor = (\n  WITH RECURSIVE\n    x(s, i, out) AS (\n      SELECT executor, 1, ''\n      UNION ALL\n      SELECT s, i+1,\n             out ||\n             CASE\n               WHEN i = 1 THEN substr(s,1,1)\n               WHEN (substr(s,i,1) BETWEEN 'A' AND 'Z') AND (\n                      (substr(s,i-1,1) BETWEEN 'a' AND 'z') OR\n                      (substr(s,i-1,1) BETWEEN '0' AND '9') OR\n                      ((substr(s,i-1,1) BETWEEN 'A' AND 'Z')\n                        AND i < length(s) AND substr(s,i+1,1) BETWEEN 'a' AND 'z')\n                    )\n                    THEN '_' || substr(s,i,1)\n               ELSE substr(s,i,1)\n             END\n      FROM x\n      WHERE i <= length(s)\n    )\n  SELECT UPPER(out) FROM x WHERE i = length(s) + 1\n);\n"
  },
  {
    "path": "crates/db/migrations/20250905090000_add_after_head_commit_to_execution_processes.sql",
    "content": "-- Add after_head_commit column to store commit OID after a process ends\nALTER TABLE execution_processes\n    ADD COLUMN after_head_commit TEXT;\n\n"
  },
  {
    "path": "crates/db/migrations/20250906120000_add_follow_up_drafts.sql",
    "content": "-- Follow-up drafts per task attempt\n-- Stores a single draft prompt that can be queued for the next available run\n\nCREATE TABLE IF NOT EXISTS follow_up_drafts (\n    id               TEXT PRIMARY KEY,\n    task_attempt_id  TEXT NOT NULL UNIQUE,\n    prompt           TEXT NOT NULL DEFAULT '',\n    queued           INTEGER NOT NULL DEFAULT 0,\n    sending          INTEGER NOT NULL DEFAULT 0,\n    version          INTEGER NOT NULL DEFAULT 0,\n    variant          TEXT NULL,\n    image_ids        TEXT NULL, -- JSON array of UUID strings\n    created_at       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY(task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE\n);\n\nCREATE INDEX IF NOT EXISTS idx_follow_up_drafts_task_attempt_id\n    ON follow_up_drafts(task_attempt_id);\n\n-- Trigger to keep updated_at current\nCREATE TRIGGER IF NOT EXISTS trg_follow_up_drafts_updated_at\nAFTER UPDATE ON follow_up_drafts\nFOR EACH ROW\nBEGIN\n    UPDATE follow_up_drafts SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;\nEND;\n"
  },
  {
    "path": "crates/db/migrations/20250910120000_add_before_head_commit_to_execution_processes.sql",
    "content": "-- Add before_head_commit column to store commit OID before a process starts\nALTER TABLE execution_processes\n    ADD COLUMN before_head_commit TEXT;\n\n-- Backfill before_head_commit for legacy rows using the previous process's after_head_commit\nUPDATE execution_processes AS ep\nSET before_head_commit = (\n  SELECT prev.after_head_commit\n  FROM execution_processes prev\n  WHERE prev.task_attempt_id = ep.task_attempt_id\n    AND prev.created_at = (\n      SELECT max(created_at) FROM execution_processes\n      WHERE task_attempt_id = ep.task_attempt_id AND created_at < ep.created_at\n    )\n)\nWHERE ep.before_head_commit IS NULL\n  AND ep.after_head_commit IS NOT NULL;\n"
  },
  {
    "path": "crates/db/migrations/20250917123000_optimize_selects_and_cleanup_indexes.sql",
    "content": "PRAGMA foreign_keys = ON;\n\n-- 1) task_attempts: filter by task_id and sort by created_at DESC\nCREATE INDEX IF NOT EXISTS idx_task_attempts_task_id_created_at\nON task_attempts (task_id, created_at DESC);\n\n-- Global listing ordered by created_at DESC\nCREATE INDEX IF NOT EXISTS idx_task_attempts_created_at\nON task_attempts (created_at DESC);\n\n-- 2) execution_processes: filter by task_attempt_id and sort by created_at ASC\nCREATE INDEX IF NOT EXISTS idx_execution_processes_task_attempt_created_at\nON execution_processes (task_attempt_id, created_at ASC);\n\n-- Drop redundant single-column index superseded by the composite above\nDROP INDEX IF EXISTS idx_execution_processes_task_attempt_id;\n\n-- 3) tasks: list by project ordered by created_at DESC\nCREATE INDEX IF NOT EXISTS idx_tasks_project_created_at\nON tasks (project_id, created_at DESC);\n\n"
  },
  {
    "path": "crates/db/migrations/20250921222241_unify_drafts_tables.sql",
    "content": "-- Unify follow_up_drafts and retry_drafts into a single drafts table\n-- This migration consolidates the duplicate code between the two draft types\n\n-- Create the unified drafts table\nCREATE TABLE IF NOT EXISTS drafts (\n    id                TEXT PRIMARY KEY,\n    task_attempt_id   TEXT NOT NULL,\n    draft_type        TEXT NOT NULL CHECK(draft_type IN ('follow_up', 'retry')),\n    retry_process_id  TEXT NULL, -- Only used for retry drafts\n    prompt            TEXT NOT NULL DEFAULT '',\n    queued            INTEGER NOT NULL DEFAULT 0,\n    sending           INTEGER NOT NULL DEFAULT 0,\n    version           INTEGER NOT NULL DEFAULT 0,\n    variant           TEXT NULL,\n    image_ids         TEXT NULL, -- JSON array of UUID strings\n    created_at        DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at        DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY(task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE,\n    FOREIGN KEY(retry_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE,\n    -- Unique constraint: only one draft per task_attempt_id and draft_type\n    UNIQUE(task_attempt_id, draft_type)\n);\n\n-- Create indexes\nCREATE INDEX IF NOT EXISTS idx_drafts_task_attempt_id\n    ON drafts(task_attempt_id);\n\nCREATE INDEX IF NOT EXISTS idx_drafts_draft_type\n    ON drafts(draft_type);\n\nCREATE INDEX IF NOT EXISTS idx_drafts_queued_sending\n    ON drafts(queued, sending) WHERE queued = 1;\n\n-- Migrate existing follow_up_drafts\nINSERT INTO drafts (\n    id, task_attempt_id, draft_type, retry_process_id, prompt,\n    queued, sending, version, variant, image_ids, created_at, updated_at\n)\nSELECT\n    id, task_attempt_id, 'follow_up', NULL, prompt,\n    queued, sending, version, variant, image_ids, created_at, updated_at\nFROM follow_up_drafts;\n\n-- Drop old tables\nDROP TABLE IF EXISTS follow_up_drafts;\n\n-- Create trigger to keep updated_at current\nCREATE TRIGGER IF NOT EXISTS trg_drafts_updated_at\nAFTER UPDATE ON drafts\nFOR EACH ROW\nBEGIN\n    UPDATE drafts SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;\nEND;"
  },
  {
    "path": "crates/db/migrations/20250923000000_make_branch_non_null.sql",
    "content": "-- Make branch column NOT NULL by recreating it\n-- First update any NULL values to 'main'\n-- Note: NULL values should not exist in practice, this is just a safety measure\nUPDATE task_attempts SET branch = 'main' WHERE branch IS NULL;\n\n-- 1) Create replacement column (NOT NULL TEXT)\nALTER TABLE task_attempts ADD COLUMN branch_new TEXT NOT NULL DEFAULT 'main';\n\n-- 2) Copy existing values\nUPDATE task_attempts SET branch_new = branch;\n\n-- 3) Remove the old nullable column\nALTER TABLE task_attempts DROP COLUMN branch;\n\n-- 4) Keep the original column name\nALTER TABLE task_attempts RENAME COLUMN branch_new TO branch;\n\n-- Rename base_branch to target_branch now that we only need one column\nALTER TABLE task_attempts RENAME COLUMN base_branch TO target_branch;"
  },
  {
    "path": "crates/db/migrations/20251020120000_convert_templates_to_tags.sql",
    "content": "-- Convert task_templates to tags\n-- Migrate ALL templates with snake_case conversion\n\nCREATE TABLE tags (\n    id            BLOB PRIMARY KEY,\n    tag_name      TEXT NOT NULL CHECK(INSTR(tag_name, ' ') = 0),\n    content       TEXT NOT NULL CHECK(content != ''),\n    created_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))\n);\n\n-- Only migrate templates that have non-empty descriptions\n-- Templates with empty/null descriptions are skipped\nINSERT INTO tags (id, tag_name, content, created_at, updated_at)\nSELECT\n    id,\n    LOWER(REPLACE(template_name, ' ', '_')) as tag_name,\n    description,\n    created_at,\n    updated_at\nFROM task_templates\nWHERE description IS NOT NULL AND description != '';\n\nDROP INDEX idx_task_templates_project_id;\nDROP INDEX idx_task_templates_unique_name_project;\nDROP INDEX idx_task_templates_unique_name_global;\nDROP TABLE task_templates;\n"
  },
  {
    "path": "crates/db/migrations/20251101090000_drop_execution_process_logs_pk.sql",
    "content": "-- Migration steps following the official SQLite \"12-step generalized ALTER TABLE\" procedure:\n-- https://www.sqlite.org/lang_altertable.html#otheralter\n--\nPRAGMA foreign_keys = OFF;\n\n-- This is a sqlx workaround to enable BEGIN TRANSACTION in this migration, until `-- no-transaction` lands in sqlx-sqlite.\n-- https://github.com/launchbadge/sqlx/issues/2085#issuecomment-1499859906\nCOMMIT TRANSACTION;\n\nBEGIN TRANSACTION;\n\n-- Create replacement table without the PRIMARY KEY constraint on execution_id.\nCREATE TABLE execution_process_logs_new (\n    execution_id      BLOB NOT NULL,\n    logs              TEXT NOT NULL,      -- JSONL format (one LogMsg per line)\n    byte_size         INTEGER NOT NULL,\n    inserted_at       TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (execution_id) REFERENCES execution_processes(id) ON DELETE CASCADE\n);\n\n-- Copy existing data into the replacement table.\nINSERT INTO execution_process_logs_new (\n    execution_id,\n    logs,\n    byte_size,\n    inserted_at\n)\nSELECT\n    execution_id,\n    logs,\n    byte_size,\n    inserted_at\nFROM execution_process_logs;\n\n-- Drop the original table.\nDROP TABLE execution_process_logs;\n\n-- Rename the new table into place.\nALTER TABLE execution_process_logs_new RENAME TO execution_process_logs;\n\n-- Rebuild indexes to preserve performance characteristics.\nCREATE INDEX IF NOT EXISTS idx_execution_process_logs_execution_id_inserted_at\n    ON execution_process_logs (execution_id, inserted_at);\n\n-- Verify foreign key constraints before committing the transaction.\nPRAGMA foreign_key_check;\n\nCOMMIT;\n\nPRAGMA foreign_keys = ON;\n\n-- sqlx workaround due to lack of `-- no-transaction` in sqlx-sqlite.\nBEGIN TRANSACTION;\n"
  },
  {
    "path": "crates/db/migrations/20251114000000_create_shared_tasks.sql",
    "content": "PRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS shared_tasks (\n    id                  BLOB PRIMARY KEY,\n    remote_project_id   BLOB NOT NULL,\n    title               TEXT NOT NULL,\n    description         TEXT,\n    status              TEXT NOT NULL DEFAULT 'todo'\n                        CHECK (status IN ('todo','inprogress','done','cancelled','inreview')),\n    assignee_user_id    BLOB,\n    assignee_first_name TEXT,\n    assignee_last_name  TEXT,\n    assignee_username   TEXT,\n    version             INTEGER NOT NULL DEFAULT 1,\n    last_event_seq      INTEGER,\n    created_at          TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at          TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_shared_tasks_remote_project\n    ON shared_tasks (remote_project_id);\n\nCREATE INDEX IF NOT EXISTS idx_shared_tasks_status\n    ON shared_tasks (status);\n\nCREATE TABLE IF NOT EXISTS shared_activity_cursors (\n    remote_project_id BLOB PRIMARY KEY,\n    last_seq          INTEGER NOT NULL CHECK (last_seq >= 0),\n    updated_at        TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))\n);\n\nALTER TABLE tasks\n    ADD COLUMN shared_task_id BLOB REFERENCES shared_tasks(id) ON DELETE SET NULL;\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_shared_task_unique\n    ON tasks(shared_task_id)\n    WHERE shared_task_id IS NOT NULL;\n\nALTER TABLE projects\n    ADD COLUMN remote_project_id BLOB;\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_projects_remote_project_id\n    ON projects(remote_project_id)\n    WHERE remote_project_id IS NOT NULL;\n"
  },
  {
    "path": "crates/db/migrations/20251120000001_refactor_to_scratch.sql",
    "content": "CREATE TABLE scratch (\n    id           BLOB NOT NULL,\n    scratch_type TEXT NOT NULL,\n    payload      TEXT NOT NULL,\n    created_at   TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at   TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    PRIMARY KEY (id, scratch_type)\n);\n\nCREATE INDEX idx_scratch_created_at ON scratch(created_at);\n"
  },
  {
    "path": "crates/db/migrations/20251129155145_drop_drafts_table.sql",
    "content": "-- Drop the drafts table (follow-up and retry drafts are no longer used)\nDROP TABLE IF EXISTS drafts;\n"
  },
  {
    "path": "crates/db/migrations/20251202000000_migrate_to_electric.sql",
    "content": "DROP TABLE IF EXISTS shared_activity_cursors;\n\n-- Drop the index on the old column if it exists\nDROP INDEX IF EXISTS idx_tasks_shared_task_unique;\n\n-- Add new column to hold the data\nALTER TABLE tasks ADD COLUMN shared_task_id_new BLOB;\n\n-- Migrate data\nUPDATE tasks SET shared_task_id_new = shared_task_id;\n\n-- Drop the old column (removing the foreign key constraint)\nALTER TABLE tasks DROP COLUMN shared_task_id;\n\n-- Rename the new column to the old name\nALTER TABLE tasks RENAME COLUMN shared_task_id_new TO shared_task_id;\n\n-- Recreate the index\nCREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_shared_task_unique\n    ON tasks(shared_task_id)\n    WHERE shared_task_id IS NOT NULL;\n\nDROP TABLE IF EXISTS shared_tasks;"
  },
  {
    "path": "crates/db/migrations/20251206000000_add_parallel_setup_script_to_projects.sql",
    "content": "-- Add parallel_setup_script column to projects table\n-- When true, setup script runs in parallel with coding agent instead of sequentially\nALTER TABLE projects ADD COLUMN parallel_setup_script INTEGER NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "crates/db/migrations/20251209000000_add_project_repositories.sql",
    "content": "-- Step 1: Create global repos registry\nCREATE TABLE repos (\n    id           BLOB PRIMARY KEY,\n    path         TEXT NOT NULL UNIQUE,\n    name         TEXT NOT NULL,\n    display_name TEXT NOT NULL,\n    created_at   TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at   TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))\n);\n\n-- Step 2: Create project_repos junction with per-repo script fields\nCREATE TABLE project_repos (\n    id                      BLOB PRIMARY KEY,\n    project_id              BLOB NOT NULL,\n    repo_id                 BLOB NOT NULL,\n    setup_script            TEXT,\n    cleanup_script          TEXT,\n    copy_files              TEXT,\n    parallel_setup_script   INTEGER NOT NULL DEFAULT 0,\n    FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,\n    FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE,\n    UNIQUE (project_id, repo_id)\n);\nCREATE INDEX idx_project_repos_project_id ON project_repos(project_id);\nCREATE INDEX idx_project_repos_repo_id ON project_repos(repo_id);\n\n-- Step 3: Create attempt_repos\nCREATE TABLE attempt_repos (\n    id            BLOB PRIMARY KEY,\n    attempt_id    BLOB NOT NULL,\n    repo_id       BLOB NOT NULL,\n    target_branch TEXT NOT NULL,\n    created_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at    TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE,\n    FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE,\n    UNIQUE (attempt_id, repo_id)\n);\nCREATE INDEX idx_attempt_repos_attempt_id ON attempt_repos(attempt_id);\nCREATE INDEX idx_attempt_repos_repo_id ON attempt_repos(repo_id);\n\n-- Step 4: Execution process repo states\nCREATE TABLE execution_process_repo_states (\n    id                   BLOB PRIMARY KEY,\n    execution_process_id BLOB NOT NULL,\n    repo_id              BLOB NOT NULL,\n    before_head_commit   TEXT,\n    after_head_commit    TEXT,\n    merge_commit         TEXT,\n    created_at           TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at           TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE,\n    FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE,\n    UNIQUE (execution_process_id, repo_id)\n);\nCREATE INDEX idx_eprs_process_id ON execution_process_repo_states(execution_process_id);\nCREATE INDEX idx_eprs_repo_id ON execution_process_repo_states(repo_id);\n\n-- Step 5: Add repo_id to merges table for multi-repo support\nALTER TABLE merges ADD COLUMN repo_id BLOB REFERENCES repos(id);\nCREATE INDEX idx_merges_repo_id ON merges(repo_id);\n\n-- Step 6: Migrate existing projects to repos\n-- Name/display_name use a sentinel that gets fixed by Rust backfill at startup.\n-- This avoids fragile SQL string manipulation and handles Windows paths correctly.\nINSERT INTO repos (id, path, name, display_name)\nSELECT\n    randomblob(16),\n    git_repo_path,\n    '__NEEDS_BACKFILL__',\n    '__NEEDS_BACKFILL__'\nFROM projects\nWHERE git_repo_path IS NOT NULL AND git_repo_path != '';\n\nINSERT INTO project_repos (id, project_id, repo_id, setup_script, cleanup_script, copy_files, parallel_setup_script)\nSELECT\n    randomblob(16),\n    p.id,\n    r.id,\n    p.setup_script,\n    p.cleanup_script,\n    p.copy_files,\n    p.parallel_setup_script\nFROM projects p\nJOIN repos r ON r.path = p.git_repo_path\nWHERE p.git_repo_path IS NOT NULL AND p.git_repo_path != '';\n\n-- Step 7: Migrate task_attempt.target_branch\nINSERT INTO attempt_repos (id, attempt_id, repo_id, target_branch, created_at, updated_at)\nSELECT\n    randomblob(16),\n    ta.id,\n    r.id,\n    ta.target_branch,\n    ta.created_at,\n    ta.updated_at\nFROM task_attempts ta\nJOIN tasks t ON t.id = ta.task_id\nJOIN project_repos pr ON pr.project_id = t.project_id\nJOIN repos r ON r.id = pr.repo_id;\n\n-- Step 8: Backfill merges.repo_id from attempt_repos\nUPDATE merges\nSET repo_id = (\n    SELECT ar.repo_id\n    FROM attempt_repos ar\n    WHERE ar.attempt_id = merges.task_attempt_id\n    LIMIT 1\n);\n\n-- Step 9: Make merges.repo_id NOT NULL\nDROP INDEX idx_merges_repo_id;\nALTER TABLE merges ADD COLUMN repo_id_new BLOB NOT NULL DEFAULT X'00';\nUPDATE merges SET repo_id_new = repo_id;\nALTER TABLE merges DROP COLUMN repo_id;\nALTER TABLE merges RENAME COLUMN repo_id_new TO repo_id;\nCREATE INDEX idx_merges_repo_id ON merges(repo_id);\n\n-- Step 10: Backfill per-repo state\nINSERT INTO execution_process_repo_states (\n    id, execution_process_id, repo_id, before_head_commit, after_head_commit\n)\nSELECT\n    randomblob(16),\n    ep.id,\n    r.id,\n    ep.before_head_commit,\n    ep.after_head_commit\nFROM execution_processes ep\nJOIN task_attempts ta ON ta.id = ep.task_attempt_id\nJOIN tasks t ON t.id = ta.task_id\nJOIN project_repos pr ON pr.project_id = t.project_id\nJOIN repos r ON r.id = pr.repo_id;\n\n-- Step 11: Cleanup old columns (Modern SQLite Syntax)\n-- Note: Old worktrees are migrated on-demand via WorkspaceManager::migrate_legacy_worktree\n-- using `git worktree move` to preserve existing work\nALTER TABLE execution_processes DROP COLUMN before_head_commit;\nALTER TABLE execution_processes DROP COLUMN after_head_commit;\n\nALTER TABLE task_attempts DROP COLUMN target_branch;\n\n-- Step 12: Recreate projects table to remove `git_repo_path` (which has a UNIQUE constraint)\n\nCOMMIT;\n\nPRAGMA foreign_keys = OFF;\n\n-- This is a sqlx workaround to enable BEGIN TRANSACTION in this migration\n-- This commits Steps 1-8 immediately.\n\nBEGIN TRANSACTION;\n\n-- Create replacement table (keeps dev_script, moves other scripts to project_repos)\nCREATE TABLE projects_new (\n    id                BLOB PRIMARY KEY,\n    name              TEXT NOT NULL,\n    dev_script        TEXT,\n    remote_project_id BLOB,\n    created_at        TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at        TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))\n);\n\nINSERT INTO projects_new (id, name, dev_script, remote_project_id, created_at, updated_at)\nSELECT id, name, dev_script, remote_project_id, created_at, updated_at\nFROM projects;\n\n-- Drop the original table\nDROP TABLE projects;\n\n-- Rename the new table into place\nALTER TABLE projects_new RENAME TO projects;\n\n-- Rebuild indexes to preserve performance/constraints\nCREATE UNIQUE INDEX IF NOT EXISTS idx_projects_remote_project_id\n    ON projects(remote_project_id)\n    WHERE remote_project_id IS NOT NULL;\n\n-- Verify foreign key constraints before committing the transaction\nPRAGMA foreign_key_check;\n\nCOMMIT;\n\nPRAGMA foreign_keys = ON;\n\n-- sqlx workaround due to lack of `-- no-transaction` in sqlx-sqlite.\n-- Starts a new empty transaction for sqlx to close successfully.\nBEGIN TRANSACTION;\n"
  },
  {
    "path": "crates/db/migrations/20251215145026_drop_worktree_deleted.sql",
    "content": "ALTER TABLE task_attempts DROP COLUMN worktree_deleted;\n"
  },
  {
    "path": "crates/db/migrations/20251216000000_add_dev_script_working_dir_to_projects.sql",
    "content": "ALTER TABLE projects ADD COLUMN dev_script_working_dir TEXT DEFAULT '';\n"
  },
  {
    "path": "crates/db/migrations/20251216142123_refactor_task_attempts_to_workspaces_sessions.sql",
    "content": "-- Refactor task_attempts into workspaces and sessions\n-- - Rename task_attempts -> workspaces (keeps workspace-related fields)\n-- - Create sessions table (executor moves here)\n-- - Update execution_processes.task_attempt_id -> session_id\n-- - Rename executor_sessions -> coding_agent_turns (drop redundant task_attempt_id)\n-- - Rename merges.task_attempt_id -> workspace_id\n-- - Rename tasks.parent_task_attempt -> parent_workspace_id\n\n-- 1. Rename task_attempts to workspaces (FK refs auto-update in schema)\nALTER TABLE task_attempts RENAME TO workspaces;\n\n-- 2. Create sessions table\nCREATE TABLE sessions (\n    id              BLOB PRIMARY KEY,\n    workspace_id    BLOB NOT NULL,\n    executor        TEXT,\n    created_at      TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at      TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_sessions_workspace_id ON sessions(workspace_id);\n\n-- 3. Migrate data: create one session per workspace\nINSERT INTO sessions (id, workspace_id, executor, created_at, updated_at)\nSELECT randomblob(16), id, executor, created_at, updated_at FROM workspaces;\n\n-- 4. Drop executor column from workspaces\nALTER TABLE workspaces DROP COLUMN executor;\n\n-- 5. Rename merges.task_attempt_id to workspace_id\nDROP INDEX idx_merges_task_attempt_id;\nDROP INDEX idx_merges_open_pr;\nALTER TABLE merges RENAME COLUMN task_attempt_id TO workspace_id;\nCREATE INDEX idx_merges_workspace_id ON merges(workspace_id);\nCREATE INDEX idx_merges_open_pr ON merges(workspace_id, pr_status)\nWHERE merge_type = 'pr' AND pr_status = 'open';\n\n-- 6. Rename tasks.parent_task_attempt to parent_workspace_id\nDROP INDEX IF EXISTS idx_tasks_parent_task_attempt;\nALTER TABLE tasks RENAME COLUMN parent_task_attempt TO parent_workspace_id;\nCREATE INDEX idx_tasks_parent_workspace_id ON tasks(parent_workspace_id);\n\n-- Steps 7-8 need FK disabled to avoid cascade deletes during DROP TABLE\n-- sqlx workaround: end auto-transaction to allow PRAGMA to take effect\n-- https://github.com/launchbadge/sqlx/issues/2085#issuecomment-1499859906\nCOMMIT;\n\nPRAGMA foreign_keys = OFF;\n\nBEGIN TRANSACTION;\n\n-- 7. Update execution_processes to reference session_id instead of task_attempt_id\n-- (needs rebuild because FK target changes from workspaces to sessions)\nDROP INDEX IF EXISTS idx_execution_processes_task_attempt_created_at;\nDROP INDEX IF EXISTS idx_execution_processes_task_attempt_type_created;\n\nCREATE TABLE execution_processes_new (\n    id              BLOB PRIMARY KEY,\n    session_id      BLOB NOT NULL,\n    run_reason      TEXT NOT NULL DEFAULT 'setupscript'\n                       CHECK (run_reason IN ('setupscript','codingagent','devserver','cleanupscript')),\n    executor_action TEXT NOT NULL DEFAULT '{}',\n    status          TEXT NOT NULL DEFAULT 'running'\n                       CHECK (status IN ('running','completed','failed','killed')),\n    exit_code       INTEGER,\n    dropped         INTEGER NOT NULL DEFAULT 0,\n    started_at      TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    completed_at    TEXT,\n    created_at      TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at      TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE\n);\n\n-- Join through sessions to get the correct session_id for each execution_process\nINSERT INTO execution_processes_new (id, session_id, run_reason, executor_action, status, exit_code, dropped, started_at, completed_at, created_at, updated_at)\nSELECT ep.id, s.id, ep.run_reason, ep.executor_action, ep.status, ep.exit_code, ep.dropped, ep.started_at, ep.completed_at, ep.created_at, ep.updated_at\nFROM execution_processes ep\nJOIN sessions s ON ep.task_attempt_id = s.workspace_id;\n\nDROP TABLE execution_processes;\nALTER TABLE execution_processes_new RENAME TO execution_processes;\n\n-- Recreate execution_processes indexes\nCREATE INDEX idx_execution_processes_session_id ON execution_processes(session_id);\nCREATE INDEX idx_execution_processes_status ON execution_processes(status);\nCREATE INDEX idx_execution_processes_run_reason ON execution_processes(run_reason);\n\n-- Composite indexes for Task::find_by_project_id_with_attempt_status query optimization\nCREATE INDEX idx_execution_processes_session_status_run_reason\nON execution_processes (session_id, status, run_reason);\n\nCREATE INDEX idx_execution_processes_session_run_reason_created\nON execution_processes (session_id, run_reason, created_at DESC);\n\n-- 8. Rename executor_sessions to coding_agent_turns and drop task_attempt_id\n-- (needs rebuild to drop the redundant task_attempt_id column)\n-- Also rename session_id to agent_session_id for clarity\nCREATE TABLE coding_agent_turns (\n    id                    BLOB PRIMARY KEY,\n    execution_process_id  BLOB NOT NULL,\n    agent_session_id      TEXT,\n    prompt                TEXT,\n    summary               TEXT,\n    created_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE\n);\n\nINSERT INTO coding_agent_turns (id, execution_process_id, agent_session_id, prompt, summary, created_at, updated_at)\nSELECT id, execution_process_id, session_id, prompt, summary, created_at, updated_at\nFROM executor_sessions;\n\nDROP TABLE executor_sessions;\n\n-- Recreate coding_agent_turns indexes\nCREATE INDEX idx_coding_agent_turns_execution_process_id ON coding_agent_turns(execution_process_id);\nCREATE INDEX idx_coding_agent_turns_agent_session_id ON coding_agent_turns(agent_session_id);\n\n-- 9. Rename attempt_repos to workspace_repos and attempt_id to workspace_id\nALTER TABLE attempt_repos RENAME TO workspace_repos;\nALTER TABLE workspace_repos RENAME COLUMN attempt_id TO workspace_id;\nDROP INDEX idx_attempt_repos_attempt_id;\nDROP INDEX idx_attempt_repos_repo_id;\nCREATE INDEX idx_workspace_repos_workspace_id ON workspace_repos(workspace_id);\nCREATE INDEX idx_workspace_repos_repo_id ON workspace_repos(repo_id);\n\n-- Verify foreign key constraints before committing\nPRAGMA foreign_key_check;\n\nCOMMIT;\n\nPRAGMA foreign_keys = ON;\n\n-- sqlx workaround: start empty transaction for sqlx to close gracefully\nBEGIN TRANSACTION;\n"
  },
  {
    "path": "crates/db/migrations/20251219000000_add_agent_working_dir_to_projects.sql",
    "content": "-- Add column with empty default first (named default_ because it's the default for new workspaces)\nALTER TABLE projects ADD COLUMN default_agent_working_dir TEXT DEFAULT '';\n\n-- Copy existing dev_script_working_dir values to default_agent_working_dir\n-- ONLY for single-repo projects (multi-repo projects should default to None/empty)\nUPDATE projects SET default_agent_working_dir = dev_script_working_dir\nWHERE dev_script_working_dir IS NOT NULL\n  AND dev_script_working_dir != ''\n  AND (SELECT COUNT(*) FROM project_repos WHERE project_repos.project_id = projects.id) = 1;\n\n-- Add agent_working_dir to workspaces (snapshot of project's default at workspace creation)\nALTER TABLE workspaces ADD COLUMN agent_working_dir TEXT DEFAULT '';\n"
  },
  {
    "path": "crates/db/migrations/20251219164205_add_missing_indexes_for_slow_queries.sql",
    "content": "CREATE INDEX IF NOT EXISTS idx_sessions_workspace_id_created_at\nON sessions (workspace_id, created_at DESC);\n\nCREATE INDEX IF NOT EXISTS idx_projects_created_at\nON projects (created_at DESC);\n\nCREATE INDEX IF NOT EXISTS idx_workspaces_container_ref\nON workspaces (container_ref)\nWHERE container_ref IS NOT NULL;\n\nCREATE INDEX IF NOT EXISTS idx_eprs_process_repo\nON execution_process_repo_states (execution_process_id, repo_id);\n\nPRAGMA optimize;\n"
  },
  {
    "path": "crates/db/migrations/20251220134608_fix_session_executor_format.sql",
    "content": "-- Fix session executor values that were incorrectly stored with variant suffix\n-- Values like \"CLAUDE_CODE:ROUTER\" should be \"CLAUDE_CODE\"\n-- This was introduced in the refactor from task_attempts to sessions (commit 6a129d0fa)\nUPDATE sessions\nSET executor = substr(executor, 1, instr(executor, ':') - 1),\n    updated_at = datetime('now', 'subsec')\nWHERE executor LIKE '%:%';\n"
  },
  {
    "path": "crates/db/migrations/20251221000000_add_workspace_flags.sql",
    "content": "-- Add workspace flags for archived, pinned, and name\nALTER TABLE workspaces ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;\nALTER TABLE workspaces ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;\nALTER TABLE workspaces ADD COLUMN name TEXT;\n\n-- Archive workspaces for completed/cancelled tasks\nUPDATE workspaces\nSET archived = 1\nWHERE task_id IN (\n    SELECT id FROM tasks WHERE status IN ('done', 'cancelled')\n);\n"
  },
  {
    "path": "crates/db/migrations/20260107000000_move_scripts_to_repos.sql",
    "content": "-- Add script columns to repos\nALTER TABLE repos ADD COLUMN setup_script TEXT;\nALTER TABLE repos ADD COLUMN cleanup_script TEXT;\nALTER TABLE repos ADD COLUMN copy_files TEXT;\nALTER TABLE repos ADD COLUMN parallel_setup_script INTEGER NOT NULL DEFAULT 0;\nALTER TABLE repos ADD COLUMN dev_server_script TEXT;\n\n-- Migrate from first project_repo (by rowid) for each repo\nUPDATE repos\nSET\n    setup_script = (SELECT pr.setup_script FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1),\n    cleanup_script = (SELECT pr.cleanup_script FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1),\n    copy_files = (SELECT pr.copy_files FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1),\n    parallel_setup_script = COALESCE((SELECT pr.parallel_setup_script FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1), 0);\n\n-- Migrate dev_script directly from projects to repos (via first project_repo)\nUPDATE repos\nSET dev_server_script = (\n    SELECT p.dev_script\n    FROM projects p\n    JOIN project_repos pr ON pr.project_id = p.id\n    WHERE pr.repo_id = repos.id\n      AND p.dev_script IS NOT NULL\n      AND p.dev_script != ''\n    ORDER BY pr.rowid ASC\n    LIMIT 1\n);\n\n-- Remove script columns from project_repos\nALTER TABLE project_repos DROP COLUMN setup_script;\nALTER TABLE project_repos DROP COLUMN cleanup_script;\nALTER TABLE project_repos DROP COLUMN copy_files;\nALTER TABLE project_repos DROP COLUMN parallel_setup_script;\n\n-- Remove dev_script columns from projects\nALTER TABLE projects DROP COLUMN dev_script;\nALTER TABLE projects DROP COLUMN dev_script_working_dir;\n"
  },
  {
    "path": "crates/db/migrations/20260107115155_add_seen_to_coding_agent_turns.sql",
    "content": "-- Add 'seen' column to coding_agent_turns table\n-- New turns default to unseen (0), marked as seen (1) when user views the workspace\nALTER TABLE coding_agent_turns ADD COLUMN seen INTEGER NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "crates/db/migrations/20260112160045_add_composite_indexes_for_performance.sql",
    "content": "-- Add composite index for workspace_repos lookup queries\n-- This optimizes queries like: WHERE workspace_id = $1 AND repo_id = $2\n-- which were taking up to 5 seconds without this index\nCREATE INDEX IF NOT EXISTS idx_workspace_repos_lookup \nON workspace_repos (workspace_id, repo_id);\n\n-- Add composite index for merges status filtering\n-- This optimizes queries like: WHERE merge_type = 'pr' AND pr_status = 'open'\n-- which were taking 2+ seconds without proper indexing\nCREATE INDEX IF NOT EXISTS idx_merges_type_status \nON merges (merge_type, pr_status);\n\n-- Optimize database after adding indexes\nPRAGMA optimize;\n"
  },
  {
    "path": "crates/db/migrations/20260113144821_remove_shared_tasks.sql",
    "content": "-- Remove shared task functionality\n\n-- Drop index first\nDROP INDEX IF EXISTS idx_tasks_shared_task_unique;\n\n-- Remove shared_task_id column from tasks table\nALTER TABLE tasks DROP COLUMN shared_task_id;\n\n-- Drop the shared_tasks related tables\nDROP TABLE IF EXISTS shared_activity_cursors;\nDROP TABLE IF EXISTS shared_tasks;\n"
  },
  {
    "path": "crates/db/migrations/20260122000000_add_default_target_branch_to_repos.sql",
    "content": "-- Add default_target_branch column to repos table\nALTER TABLE repos ADD COLUMN default_target_branch TEXT;\n"
  },
  {
    "path": "crates/db/migrations/20260123125956_add_agent_message_id.sql",
    "content": "-- Add agent_message_id column to coding_agent_turns\n-- This stores the last message ID from the agent for use with --resume-session-at\nALTER TABLE coding_agent_turns ADD COLUMN agent_message_id TEXT;\n"
  },
  {
    "path": "crates/db/migrations/20260126000000_add_agent_working_dir_to_repos.sql",
    "content": "-- Add default_working_dir to repos for monorepo support\n-- Allows users to specify the subdirectory where the coding agent should run\nALTER TABLE repos ADD COLUMN default_working_dir TEXT;\n"
  },
  {
    "path": "crates/db/migrations/20260128000000_add_migration_state.sql",
    "content": "-- Migration state tracking table for local→remote data migration\nCREATE TABLE IF NOT EXISTS migration_state (\n    id TEXT PRIMARY KEY,\n    entity_type TEXT NOT NULL,  -- 'project', 'task', 'pr_merge'\n    local_id TEXT NOT NULL,\n    remote_id TEXT,\n    status TEXT NOT NULL DEFAULT 'pending',  -- 'pending', 'migrated', 'failed', 'skipped'\n    error_message TEXT,\n    attempt_count INTEGER NOT NULL DEFAULT 0,\n    created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    UNIQUE(entity_type, local_id)\n);\n\nCREATE INDEX idx_migration_state_status ON migration_state(status);\nCREATE INDEX idx_migration_state_entity_type ON migration_state(entity_type);\nCREATE INDEX idx_migration_state_entity_lookup ON migration_state(entity_type, local_id);\n"
  },
  {
    "path": "crates/db/migrations/20260203000000_add_archive_script_to_repos.sql",
    "content": "-- Add archive_script column to repos table\n-- This script runs when a workspace is being archived\nALTER TABLE repos ADD COLUMN archive_script TEXT;\n\n-- Add 'archivescript' to the run_reason CHECK constraint\n-- Note: The column was renamed from process_type to run_reason in migration 20250730000001\n\n-- 1. Add the replacement column with the wider CHECK\nALTER TABLE execution_processes\n  ADD COLUMN run_reason_new TEXT NOT NULL DEFAULT 'setupscript'\n    CHECK (run_reason_new IN ('setupscript',\n                               'cleanupscript',\n                               'archivescript',\n                               'codingagent',\n                               'devserver'));\n\n-- 2. Copy existing values across\nUPDATE execution_processes\n  SET run_reason_new = run_reason;\n\n-- 3. Drop any indexes that reference run_reason\nDROP INDEX IF EXISTS idx_execution_processes_run_reason;\nDROP INDEX IF EXISTS idx_execution_processes_session_status_run_reason;\nDROP INDEX IF EXISTS idx_execution_processes_session_run_reason_created;\n\n-- 4. Remove the old column (requires 3.35+)\nALTER TABLE execution_processes DROP COLUMN run_reason;\n\n-- 5. Rename the new column back to the canonical name\nALTER TABLE execution_processes\n  RENAME COLUMN run_reason_new TO run_reason;\n\n-- 6. Re-create all indexes\nCREATE INDEX idx_execution_processes_run_reason\n        ON execution_processes(run_reason);\n\nCREATE INDEX idx_execution_processes_session_status_run_reason\n        ON execution_processes (session_id, status, run_reason);\n\nCREATE INDEX idx_execution_processes_session_run_reason_created\n        ON execution_processes (session_id, run_reason, created_at DESC);\n"
  },
  {
    "path": "crates/db/migrations/20260217120312_remove_task_fk_from_workspaces.sql",
    "content": "-- Remove FK constraint from workspaces.task_id → tasks(id).\n-- task_id column is preserved, just no longer FK-enforced.\n-- This breaks the ON DELETE CASCADE so deleting a task no longer deletes workspaces.\n\n-- sqlx workaround: end auto-transaction to allow PRAGMA to take effect\nCOMMIT;\n\nPRAGMA foreign_keys = OFF;\n\nBEGIN TRANSACTION;\n\nCREATE TABLE workspaces_new (\n    id                 BLOB PRIMARY KEY,\n    task_id            BLOB,\n    container_ref      TEXT,\n    branch             TEXT NOT NULL,\n    agent_working_dir  TEXT,\n    setup_completed_at TEXT,\n    created_at         TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    updated_at         TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    archived           INTEGER NOT NULL DEFAULT 0,\n    pinned             INTEGER NOT NULL DEFAULT 0,\n    name               TEXT\n);\n\nINSERT INTO workspaces_new (id, task_id, container_ref, branch, agent_working_dir,\n    setup_completed_at, created_at, updated_at, archived, pinned, name)\nSELECT id, task_id, container_ref, branch, agent_working_dir,\n    setup_completed_at, created_at, updated_at, archived, pinned, name\nFROM workspaces;\n\nDROP TABLE workspaces;\nALTER TABLE workspaces_new RENAME TO workspaces;\n\n-- Recreate indexes (from 20250917 + 20251219 migrations)\nCREATE INDEX idx_workspaces_task_id_created_at\n    ON workspaces (task_id, created_at DESC);\nCREATE INDEX idx_workspaces_created_at\n    ON workspaces (created_at DESC);\nCREATE INDEX idx_workspaces_container_ref\n    ON workspaces (container_ref) WHERE container_ref IS NOT NULL;\n\n-- Verify foreign key constraints before committing\nPRAGMA foreign_key_check;\n\nCOMMIT;\n\nPRAGMA foreign_keys = ON;\n\n-- Create junction table for workspace-image associations (mirrors task_images)\nCREATE TABLE workspace_images (\n    id                    BLOB PRIMARY KEY,\n    workspace_id          BLOB NOT NULL,\n    image_id              BLOB NOT NULL,\n    created_at            TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),\n    FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,\n    FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,\n    UNIQUE(workspace_id, image_id)\n);\n\nCREATE INDEX idx_workspace_images_workspace_id ON workspace_images(workspace_id);\nCREATE INDEX idx_workspace_images_image_id ON workspace_images(image_id);\n\n-- Migrate existing task_images → workspace_images via workspaces.task_id\nINSERT INTO workspace_images (id, workspace_id, image_id, created_at)\nSELECT randomblob(16), w.id, ti.image_id, ti.created_at\nFROM task_images ti\nJOIN workspaces w ON w.task_id = ti.task_id;\n\n-- sqlx workaround: start empty transaction for sqlx to close gracefully\nBEGIN TRANSACTION;\n"
  },
  {
    "path": "crates/db/migrations/20260220000000_optimize_query_planner_after_latest_process_query_update.sql",
    "content": "-- Refresh SQLite planner statistics after recent query changes.\nPRAGMA optimize;\n"
  },
  {
    "path": "crates/db/migrations/20260302113031_add_worktree_deleted_to_workspaces.sql",
    "content": "-- Add worktree_deleted flag to track when worktrees are cleaned up\nALTER TABLE workspaces ADD COLUMN worktree_deleted BOOLEAN NOT NULL DEFAULT FALSE;\n"
  },
  {
    "path": "crates/db/migrations/20260304153000_move_agent_working_dir_to_sessions.sql",
    "content": "-- Move agent_working_dir ownership from workspaces to sessions.\n-- Session working dir is backend-computed at session creation time.\n\nALTER TABLE sessions ADD COLUMN agent_working_dir TEXT;\n\n-- Backfill existing sessions from workspace snapshot\nUPDATE sessions\nSET agent_working_dir = (\n    SELECT w.agent_working_dir\n    FROM workspaces w\n    WHERE w.id = sessions.workspace_id\n);\n\nALTER TABLE workspaces DROP COLUMN agent_working_dir;\n"
  },
  {
    "path": "crates/db/migrations/20260314000000_add_name_to_sessions.sql",
    "content": "-- Add name column to sessions table\nALTER TABLE sessions ADD COLUMN name TEXT;\n"
  },
  {
    "path": "crates/db/migrations/20260317120000_cleanup_attachment_schema.sql",
    "content": "COMMIT;\n\nPRAGMA foreign_keys = OFF;\n\nBEGIN TRANSACTION;\n\nALTER TABLE images RENAME TO attachments;\nALTER TABLE task_images RENAME TO task_attachments;\nALTER TABLE task_attachments RENAME COLUMN image_id TO attachment_id;\nALTER TABLE workspace_images RENAME TO workspace_attachments;\nALTER TABLE workspace_attachments RENAME COLUMN image_id TO attachment_id;\n\nDROP INDEX IF EXISTS idx_images_hash;\nDROP INDEX IF EXISTS idx_task_images_task_id;\nDROP INDEX IF EXISTS idx_task_images_image_id;\nDROP INDEX IF EXISTS idx_workspace_images_workspace_id;\nDROP INDEX IF EXISTS idx_workspace_images_image_id;\n\nCREATE INDEX idx_attachments_hash ON attachments(hash);\nCREATE INDEX idx_task_attachments_task_id ON task_attachments(task_id);\nCREATE INDEX idx_task_attachments_attachment_id ON task_attachments(attachment_id);\nCREATE INDEX idx_workspace_attachments_workspace_id\n    ON workspace_attachments(workspace_id);\nCREATE INDEX idx_workspace_attachments_attachment_id\n    ON workspace_attachments(attachment_id);\n\n\nPRAGMA foreign_key_check;\n\nCOMMIT;\n\nPRAGMA foreign_keys = ON;\n\nBEGIN TRANSACTION;\n"
  },
  {
    "path": "crates/db/src/lib.rs",
    "content": "use std::{str::FromStr, sync::Arc};\n\nuse sqlx::{\n    ConnectOptions, Error, Pool, Sqlite, SqlitePool,\n    migrate::MigrateError,\n    sqlite::{SqliteConnectOptions, SqliteConnection, SqliteJournalMode, SqlitePoolOptions},\n};\nuse utils::assets::asset_dir;\n\npub mod models;\n\nasync fn run_migrations(pool: &Pool<Sqlite>) -> Result<(), Error> {\n    use std::collections::HashSet;\n\n    let migrator = sqlx::migrate!(\"./migrations\");\n    let mut processed_versions: HashSet<i64> = HashSet::new();\n\n    loop {\n        match migrator.run(pool).await {\n            Ok(()) => return Ok(()),\n            Err(MigrateError::VersionMismatch(version)) => {\n                if cfg!(debug_assertions) {\n                    // return the error in debug mode to catch migration issues early\n                    return Err(sqlx::Error::Migrate(Box::new(\n                        MigrateError::VersionMismatch(version),\n                    )));\n                }\n\n                if !cfg!(windows) {\n                    // On non-Windows platforms, we do not attempt to auto-fix checksum mismatches\n                    return Err(sqlx::Error::Migrate(Box::new(\n                        MigrateError::VersionMismatch(version),\n                    )));\n                }\n\n                // Guard against infinite loop\n                if !processed_versions.insert(version) {\n                    return Err(sqlx::Error::Migrate(Box::new(\n                        MigrateError::VersionMismatch(version),\n                    )));\n                }\n\n                // On Windows, there can be checksum mismatches due to line ending differences\n                // or other platform-specific issues. Update the stored checksum and retry.\n                tracing::warn!(\n                    \"Migration version {} has checksum mismatch, updating stored checksum (likely platform-specific difference)\",\n                    version\n                );\n\n                // Find the migration with the mismatched version and get its current checksum\n                if let Some(migration) = migrator.iter().find(|m| m.version == version) {\n                    // Update the checksum in _sqlx_migrations to match the current file\n                    sqlx::query(\"UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?\")\n                        .bind(&*migration.checksum)\n                        .bind(version)\n                        .execute(pool)\n                        .await?;\n                } else {\n                    // Migration not found in current set, can't fix\n                    return Err(sqlx::Error::Migrate(Box::new(\n                        MigrateError::VersionMismatch(version),\n                    )));\n                }\n            }\n            Err(e) => return Err(e.into()),\n        }\n    }\n}\n\n#[derive(Clone)]\npub struct DBService {\n    pub pool: Pool<Sqlite>,\n}\n\nimpl DBService {\n    pub async fn new() -> Result<DBService, Error> {\n        let database_url = format!(\n            \"sqlite://{}\",\n            asset_dir().join(\"db.v2.sqlite\").to_string_lossy()\n        );\n        let options = SqliteConnectOptions::from_str(&database_url)?\n            .create_if_missing(true)\n            .journal_mode(SqliteJournalMode::Delete);\n        let pool = SqlitePool::connect_with(options).await?;\n        run_migrations(&pool).await?;\n        Ok(DBService { pool })\n    }\n\n    pub async fn new_migration_pool() -> Result<Pool<Sqlite>, Error> {\n        let database_url = format!(\n            \"sqlite://{}\",\n            asset_dir().join(\"db.v2.sqlite\").to_string_lossy()\n        );\n        let options = SqliteConnectOptions::from_str(&database_url)?\n            .create_if_missing(true)\n            .journal_mode(SqliteJournalMode::Delete)\n            .disable_statement_logging();\n        SqlitePoolOptions::new()\n            .max_connections(64)\n            .connect_with(options)\n            .await\n    }\n\n    pub async fn new_with_after_connect<F>(after_connect: F) -> Result<DBService, Error>\n    where\n        F: for<'a> Fn(\n                &'a mut SqliteConnection,\n            ) -> std::pin::Pin<\n                Box<dyn std::future::Future<Output = Result<(), Error>> + Send + 'a>,\n            > + Send\n            + Sync\n            + 'static,\n    {\n        let pool = Self::create_pool(Some(Arc::new(after_connect))).await?;\n        Ok(DBService { pool })\n    }\n\n    async fn create_pool<F>(after_connect: Option<Arc<F>>) -> Result<Pool<Sqlite>, Error>\n    where\n        F: for<'a> Fn(\n                &'a mut SqliteConnection,\n            ) -> std::pin::Pin<\n                Box<dyn std::future::Future<Output = Result<(), Error>> + Send + 'a>,\n            > + Send\n            + Sync\n            + 'static,\n    {\n        let database_url = format!(\n            \"sqlite://{}\",\n            asset_dir().join(\"db.v2.sqlite\").to_string_lossy()\n        );\n        let options = SqliteConnectOptions::from_str(&database_url)?\n            .create_if_missing(true)\n            .journal_mode(SqliteJournalMode::Delete);\n\n        let pool = if let Some(hook) = after_connect {\n            SqlitePoolOptions::new()\n                .after_connect(move |conn, _meta| {\n                    let hook = hook.clone();\n                    Box::pin(async move {\n                        hook(conn).await?;\n                        Ok(())\n                    })\n                })\n                .connect_with(options)\n                .await?\n        } else {\n            SqlitePool::connect_with(options).await?\n        };\n\n        run_migrations(&pool).await?;\n        Ok(pool)\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/coding_agent_turn.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct CodingAgentTurn {\n    pub id: Uuid,\n    pub execution_process_id: Uuid,\n    pub agent_session_id: Option<String>,\n    pub agent_message_id: Option<String>,\n    pub prompt: Option<String>,  // The prompt sent to the executor\n    pub summary: Option<String>, // Final assistant message/summary\n    pub seen: bool,              // Whether user has viewed this turn\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateCodingAgentTurn {\n    pub execution_process_id: Uuid,\n    pub prompt: Option<String>,\n}\n\n/// Session info from a coding agent turn, used for follow-up requests\n#[derive(Debug)]\npub struct CodingAgentResumeInfo {\n    pub session_id: String,\n    pub message_id: Option<String>,\n}\n\nimpl CodingAgentTurn {\n    /// Find session info from the latest coding agent turn for a session.\n    /// Only returns turns that have an agent_session_id set.\n    pub async fn find_latest_session_info(\n        pool: &SqlitePool,\n        session_id: Uuid,\n    ) -> Result<Option<CodingAgentResumeInfo>, sqlx::Error> {\n        sqlx::query_as!(\n            CodingAgentResumeInfo,\n            r#\"SELECT\n                cat.agent_session_id as \"session_id!\",\n                cat.agent_message_id as \"message_id\"\n               FROM execution_processes ep\n               JOIN coding_agent_turns cat ON ep.id = cat.execution_process_id\n               WHERE ep.session_id = $1\n                 AND ep.run_reason = 'codingagent'\n                 AND ep.dropped = FALSE\n                 AND cat.agent_session_id IS NOT NULL\n               ORDER BY ep.created_at DESC\n               LIMIT 1\"#,\n            session_id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Find coding agent turn by execution process ID\n    pub async fn find_by_execution_process_id(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            CodingAgentTurn,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                execution_process_id as \"execution_process_id!: Uuid\",\n                agent_session_id,\n                agent_message_id,\n                prompt,\n                summary,\n                seen as \"seen!: bool\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM coding_agent_turns\n               WHERE execution_process_id = $1\"#,\n            execution_process_id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn find_by_agent_session_id(\n        pool: &SqlitePool,\n        agent_session_id: &str,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            CodingAgentTurn,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                execution_process_id as \"execution_process_id!: Uuid\",\n                agent_session_id,\n                agent_message_id,\n                prompt,\n                summary,\n                seen as \"seen!: bool\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM coding_agent_turns\n               WHERE agent_session_id = ?\n               ORDER BY updated_at DESC\n               LIMIT 1\"#,\n            agent_session_id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Create a new coding agent turn\n    pub async fn create(\n        pool: &SqlitePool,\n        data: &CreateCodingAgentTurn,\n        id: Uuid,\n    ) -> Result<Self, sqlx::Error> {\n        let now = Utc::now();\n\n        tracing::debug!(\n            \"Creating coding agent turn: id={}, execution_process_id={}, agent_session_id=None (will be set later)\",\n            id,\n            data.execution_process_id\n        );\n\n        sqlx::query_as!(\n            CodingAgentTurn,\n            r#\"INSERT INTO coding_agent_turns (\n                id, execution_process_id, agent_session_id, agent_message_id, prompt, summary, seen,\n                created_at, updated_at\n               )\n               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n               RETURNING\n                id as \"id!: Uuid\",\n                execution_process_id as \"execution_process_id!: Uuid\",\n                agent_session_id,\n                agent_message_id,\n                prompt,\n                summary,\n                seen as \"seen!: bool\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            data.execution_process_id,\n            None::<String>, // agent_session_id initially None until parsed from output\n            None::<String>, // agent_message_id initially None until parsed from output\n            data.prompt,\n            None::<String>, // summary initially None\n            false,          // seen - defaults to unseen\n            now,            // created_at\n            now             // updated_at\n        )\n        .fetch_one(pool)\n        .await\n    }\n\n    /// Update coding agent turn with agent session ID\n    pub async fn update_agent_session_id(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n        agent_session_id: &str,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            r#\"UPDATE coding_agent_turns\n               SET agent_session_id = $1, updated_at = $2\n               WHERE execution_process_id = $3\"#,\n            agent_session_id,\n            now,\n            execution_process_id\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n\n    /// Update coding agent turn with agent message ID (for --resume-session-at)\n    pub async fn update_agent_message_id(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n        agent_message_id: &str,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            r#\"UPDATE coding_agent_turns\n               SET agent_message_id = $1, updated_at = $2\n               WHERE execution_process_id = $3\"#,\n            agent_message_id,\n            now,\n            execution_process_id\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n\n    /// Update coding agent turn summary\n    pub async fn update_summary(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n        summary: &str,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            r#\"UPDATE coding_agent_turns\n               SET summary = $1, updated_at = $2\n               WHERE execution_process_id = $3\"#,\n            summary,\n            now,\n            execution_process_id\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n\n    /// Mark a coding agent turn as unseen by execution process ID.\n    pub async fn mark_unseen_by_execution_process_id(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query(\n            r#\"UPDATE coding_agent_turns\n               SET seen = 0, updated_at = ?\n               WHERE execution_process_id = ?\n                 AND seen = 1\"#,\n        )\n        .bind(now)\n        .bind(execution_process_id.to_string())\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n\n    /// Mark all coding agent turns for a workspace as seen\n    pub async fn mark_seen_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            r#\"UPDATE coding_agent_turns\n               SET seen = 1, updated_at = $1\n               WHERE execution_process_id IN (\n                   SELECT ep.id FROM execution_processes ep\n                   JOIN sessions s ON ep.session_id = s.id\n                   WHERE s.workspace_id = $2\n               ) AND seen = 0\"#,\n            now,\n            workspace_id\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n\n    /// Check if a workspace has any unseen coding agent turns\n    pub async fn has_unseen_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<bool, sqlx::Error> {\n        let result = sqlx::query_scalar!(\n            r#\"SELECT EXISTS(\n                SELECT 1 FROM coding_agent_turns cat\n                JOIN execution_processes ep ON cat.execution_process_id = ep.id\n                JOIN sessions s ON ep.session_id = s.id\n                WHERE s.workspace_id = $1 AND cat.seen = 0\n            ) as \"has_unseen!: bool\"\"#,\n            workspace_id\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(result)\n    }\n\n    /// Find all workspaces that have unseen coding agent turns, filtered by archived status\n    pub async fn find_workspaces_with_unseen(\n        pool: &SqlitePool,\n        archived: bool,\n    ) -> Result<std::collections::HashSet<Uuid>, sqlx::Error> {\n        let result: Vec<Uuid> = sqlx::query_scalar!(\n            r#\"SELECT DISTINCT s.workspace_id as \"workspace_id!: Uuid\"\n               FROM coding_agent_turns cat\n               JOIN execution_processes ep ON cat.execution_process_id = ep.id\n               JOIN sessions s ON ep.session_id = s.id\n               JOIN workspaces w ON s.workspace_id = w.id\n               WHERE cat.seen = 0 AND w.archived = $1\"#,\n            archived\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(result.into_iter().collect())\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/execution_process.rs",
    "content": "use std::collections::{HashMap, HashSet};\n\nuse chrono::{DateTime, Utc};\nuse executors::{\n    actions::{ExecutorAction, ExecutorActionType},\n    profile::ExecutorProfileId,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse sqlx::{FromRow, SqlitePool, Type};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse super::{\n    execution_process_repo_state::{CreateExecutionProcessRepoState, ExecutionProcessRepoState},\n    repo::Repo,\n    session::Session,\n    workspace::Workspace,\n    workspace_repo::WorkspaceRepo,\n};\n\n#[derive(Debug, Error)]\npub enum ExecutionProcessError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(\"Execution process not found\")]\n    ExecutionProcessNotFound,\n    #[error(\"Failed to create execution process: {0}\")]\n    CreateFailed(String),\n    #[error(\"Failed to update execution process: {0}\")]\n    UpdateFailed(String),\n    #[error(\"Invalid executor action format\")]\n    InvalidExecutorAction,\n    #[error(\"Validation error: {0}\")]\n    ValidationError(String),\n}\n\n#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)]\n#[sqlx(type_name = \"execution_process_status\", rename_all = \"lowercase\")]\n#[serde(rename_all = \"lowercase\")]\n#[ts(use_ts_enum)]\npub enum ExecutionProcessStatus {\n    Running,\n    Completed,\n    Failed,\n    Killed,\n}\n\n#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)]\n#[sqlx(type_name = \"execution_process_run_reason\", rename_all = \"lowercase\")]\n#[serde(rename_all = \"lowercase\")]\npub enum ExecutionProcessRunReason {\n    SetupScript,\n    CleanupScript,\n    ArchiveScript,\n    CodingAgent,\n    DevServer,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct ExecutionProcess {\n    pub id: Uuid,\n    pub session_id: Uuid,\n    pub run_reason: ExecutionProcessRunReason,\n    #[ts(type = \"ExecutorAction\")]\n    pub executor_action: sqlx::types::Json<ExecutorActionField>,\n    pub status: ExecutionProcessStatus,\n    pub exit_code: Option<i64>,\n    /// dropped: true if this process is excluded from the current\n    /// history view (due to restore/trimming). Hidden from logs/timeline;\n    /// still listed in the Processes tab.\n    pub dropped: bool,\n    pub started_at: DateTime<Utc>,\n    pub completed_at: Option<DateTime<Utc>>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateExecutionProcess {\n    pub session_id: Uuid,\n    pub executor_action: ExecutorAction,\n    pub run_reason: ExecutionProcessRunReason,\n}\n\n#[derive(Debug, Deserialize, TS)]\n#[allow(dead_code)]\npub struct UpdateExecutionProcess {\n    pub status: Option<ExecutionProcessStatus>,\n    pub exit_code: Option<i64>,\n    pub completed_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug)]\npub struct ExecutionContext {\n    pub execution_process: ExecutionProcess,\n    pub session: Session,\n    pub workspace: Workspace,\n    pub repos: Vec<Repo>,\n}\n\n/// Summary info about the latest execution process for a workspace\n#[derive(Debug, Clone, FromRow)]\npub struct LatestProcessInfo {\n    pub workspace_id: Uuid,\n    pub execution_process_id: Uuid,\n    pub session_id: Uuid,\n    pub status: ExecutionProcessStatus,\n    pub completed_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(untagged)]\npub enum ExecutorActionField {\n    ExecutorAction(ExecutorAction),\n    Other(Value),\n}\n\n#[derive(Debug, Clone)]\npub struct MissingBeforeContext {\n    pub id: Uuid,\n    pub session_id: Uuid,\n    pub workspace_id: Uuid,\n    pub repo_id: Uuid,\n    pub prev_after_head_commit: Option<String>,\n    pub target_branch: String,\n    pub repo_path: Option<String>,\n}\n\nimpl ExecutionProcess {\n    /// Find execution process by ID\n    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcess,\n            r#\"SELECT\n                    ep.id as \"id!: Uuid\",\n                    ep.session_id as \"session_id!: Uuid\",\n                    ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n                    ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n                    ep.status as \"status!: ExecutionProcessStatus\",\n                    ep.exit_code,\n                    ep.dropped as \"dropped!: bool\",\n                    ep.started_at as \"started_at!: DateTime<Utc>\",\n                    ep.completed_at as \"completed_at?: DateTime<Utc>\",\n                    ep.created_at as \"created_at!: DateTime<Utc>\",\n                    ep.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM execution_processes ep WHERE ep.id = ?\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Context for backfilling before_head_commit for legacy rows\n    /// List processes that have after_head_commit set but missing before_head_commit, with join context\n    pub async fn list_missing_before_context(\n        pool: &SqlitePool,\n    ) -> Result<Vec<MissingBeforeContext>, sqlx::Error> {\n        let rows = sqlx::query!(\n            r#\"SELECT\n                ep.id                         as \"id!: Uuid\",\n                ep.session_id                 as \"session_id!: Uuid\",\n                s.workspace_id                as \"workspace_id!: Uuid\",\n                eprs.repo_id                  as \"repo_id!: Uuid\",\n                eprs.after_head_commit        as after_head_commit,\n                prev.after_head_commit        as prev_after_head_commit,\n                wr.target_branch              as \"target_branch!\",\n                r.path                        as repo_path\n            FROM execution_processes ep\n            JOIN sessions s ON s.id = ep.session_id\n            JOIN execution_process_repo_states eprs ON eprs.execution_process_id = ep.id\n            JOIN repos r ON r.id = eprs.repo_id\n            JOIN workspaces w ON w.id = s.workspace_id\n            JOIN workspace_repos wr ON wr.workspace_id = w.id AND wr.repo_id = eprs.repo_id\n            LEFT JOIN execution_process_repo_states prev\n              ON prev.execution_process_id = (\n                   SELECT id FROM execution_processes\n                     WHERE session_id = ep.session_id\n                       AND created_at < ep.created_at\n                     ORDER BY created_at DESC\n                     LIMIT 1\n               )\n              AND prev.repo_id = eprs.repo_id\n            WHERE eprs.before_head_commit IS NULL\n              AND eprs.after_head_commit IS NOT NULL\"#\n        )\n        .fetch_all(pool)\n        .await?;\n\n        let result = rows\n            .into_iter()\n            .map(|r| MissingBeforeContext {\n                id: r.id,\n                session_id: r.session_id,\n                workspace_id: r.workspace_id,\n                repo_id: r.repo_id,\n                prev_after_head_commit: r.prev_after_head_commit,\n                target_branch: r.target_branch,\n                repo_path: Some(r.repo_path),\n            })\n            .collect();\n        Ok(result)\n    }\n\n    /// Find execution process by rowid\n    pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcess,\n            r#\"SELECT\n                    ep.id as \"id!: Uuid\",\n                    ep.session_id as \"session_id!: Uuid\",\n                    ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n                    ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n                    ep.status as \"status!: ExecutionProcessStatus\",\n                    ep.exit_code,\n                    ep.dropped as \"dropped!: bool\",\n                    ep.started_at as \"started_at!: DateTime<Utc>\",\n                    ep.completed_at as \"completed_at?: DateTime<Utc>\",\n                    ep.created_at as \"created_at!: DateTime<Utc>\",\n                    ep.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM execution_processes ep WHERE ep.rowid = ?\"#,\n            rowid\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Find all execution processes for a session (optionally include soft-deleted)\n    pub async fn find_by_session_id(\n        pool: &SqlitePool,\n        session_id: Uuid,\n        show_soft_deleted: bool,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcess,\n            r#\"SELECT\n                      ep.id              as \"id!: Uuid\",\n                      ep.session_id      as \"session_id!: Uuid\",\n                      ep.run_reason      as \"run_reason!: ExecutionProcessRunReason\",\n                      ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n                      ep.status          as \"status!: ExecutionProcessStatus\",\n                      ep.exit_code,\n                      ep.dropped as \"dropped!: bool\",\n                      ep.started_at      as \"started_at!: DateTime<Utc>\",\n                      ep.completed_at    as \"completed_at?: DateTime<Utc>\",\n                      ep.created_at      as \"created_at!: DateTime<Utc>\",\n                      ep.updated_at      as \"updated_at!: DateTime<Utc>\"\n               FROM execution_processes ep\n               WHERE ep.session_id = ?\n                 AND (? OR ep.dropped = FALSE)\n               ORDER BY ep.created_at ASC\"#,\n            session_id,\n            show_soft_deleted\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    /// Find running execution processes\n    pub async fn find_running(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcess,\n            r#\"SELECT\n                    ep.id as \"id!: Uuid\",\n                    ep.session_id as \"session_id!: Uuid\",\n                    ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n                    ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n                    ep.status as \"status!: ExecutionProcessStatus\",\n                    ep.exit_code,\n                    ep.dropped as \"dropped!: bool\",\n                    ep.started_at as \"started_at!: DateTime<Utc>\",\n                    ep.completed_at as \"completed_at?: DateTime<Utc>\",\n                    ep.created_at as \"created_at!: DateTime<Utc>\",\n                    ep.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM execution_processes ep WHERE ep.status = 'running' ORDER BY ep.created_at ASC\"#,\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    /// Check if there's a running coding agent process for a session\n    pub async fn has_running_coding_agent_for_session(\n        pool: &SqlitePool,\n        session_id: Uuid,\n    ) -> Result<bool, sqlx::Error> {\n        let count: i64 = sqlx::query_scalar!(\n            r#\"SELECT COUNT(*) as \"count!: i64\"\n               FROM execution_processes ep\n               WHERE ep.session_id = $1\n                 AND ep.status = 'running'\n                 AND ep.run_reason = 'codingagent'\"#,\n            session_id\n        )\n        .fetch_one(pool)\n        .await?;\n        Ok(count > 0)\n    }\n\n    /// Check if there are running processes (excluding dev servers) for a workspace (across all sessions)\n    pub async fn has_running_non_dev_server_processes_for_workspace(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<bool, sqlx::Error> {\n        let count: i64 = sqlx::query_scalar!(\n            r#\"SELECT COUNT(*) as \"count!: i64\"\n               FROM execution_processes ep\n               JOIN sessions s ON ep.session_id = s.id\n               WHERE s.workspace_id = $1\n                 AND ep.status = 'running'\n                 AND ep.run_reason != 'devserver'\"#,\n            workspace_id\n        )\n        .fetch_one(pool)\n        .await?;\n        Ok(count > 0)\n    }\n\n    /// Find running dev servers for a specific workspace (across all sessions)\n    pub async fn find_running_dev_servers_by_workspace(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcess,\n            r#\"\n        SELECT\n            ep.id as \"id!: Uuid\",\n            ep.session_id as \"session_id!: Uuid\",\n            ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n            ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n            ep.status as \"status!: ExecutionProcessStatus\",\n            ep.exit_code,\n            ep.dropped as \"dropped!: bool\",\n            ep.started_at as \"started_at!: DateTime<Utc>\",\n            ep.completed_at as \"completed_at?: DateTime<Utc>\",\n            ep.created_at as \"created_at!: DateTime<Utc>\",\n            ep.updated_at as \"updated_at!: DateTime<Utc>\"\n        FROM execution_processes ep\n        JOIN sessions s ON ep.session_id = s.id\n        WHERE s.workspace_id = ?\n          AND ep.status = 'running'\n          AND ep.run_reason = 'devserver'\n        ORDER BY ep.created_at DESC\n        \"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    /// Find latest execution process by session and run reason\n    pub async fn find_latest_by_session_and_run_reason(\n        pool: &SqlitePool,\n        session_id: Uuid,\n        run_reason: &ExecutionProcessRunReason,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcess,\n            r#\"SELECT\n                    ep.id as \"id!: Uuid\",\n                    ep.session_id as \"session_id!: Uuid\",\n                    ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n                    ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n                    ep.status as \"status!: ExecutionProcessStatus\",\n                    ep.exit_code,\n                    ep.dropped as \"dropped!: bool\",\n                    ep.started_at as \"started_at!: DateTime<Utc>\",\n                    ep.completed_at as \"completed_at?: DateTime<Utc>\",\n                    ep.created_at as \"created_at!: DateTime<Utc>\",\n                    ep.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM execution_processes ep\n               WHERE ep.session_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE\n               ORDER BY ep.created_at DESC LIMIT 1\"#,\n            session_id,\n            run_reason\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Find latest execution process by workspace and run reason (across all sessions)\n    pub async fn find_latest_by_workspace_and_run_reason(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        run_reason: &ExecutionProcessRunReason,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcess,\n            r#\"SELECT\n                    ep.id as \"id!: Uuid\",\n                    ep.session_id as \"session_id!: Uuid\",\n                    ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n                    ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n                    ep.status as \"status!: ExecutionProcessStatus\",\n                    ep.exit_code,\n                    ep.dropped as \"dropped!: bool\",\n                    ep.started_at as \"started_at!: DateTime<Utc>\",\n                    ep.completed_at as \"completed_at?: DateTime<Utc>\",\n                    ep.created_at as \"created_at!: DateTime<Utc>\",\n                    ep.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM execution_processes ep\n               JOIN sessions s ON ep.session_id = s.id\n               WHERE s.workspace_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE\n               ORDER BY ep.created_at DESC LIMIT 1\"#,\n            workspace_id,\n            run_reason\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Create a new execution process\n    ///\n    /// Note: We intentionally avoid using a transaction here. SQLite update\n    /// hooks fire during transactions (before commit), and the hook spawns an\n    /// async task that queries `find_by_rowid` on a different connection.\n    /// If we used a transaction, that query would not see the uncommitted row,\n    /// causing the WebSocket event to be lost.\n    pub async fn create(\n        pool: &SqlitePool,\n        data: &CreateExecutionProcess,\n        process_id: Uuid,\n        repo_states: &[CreateExecutionProcessRepoState],\n    ) -> Result<Self, sqlx::Error> {\n        let now = Utc::now();\n        let executor_action_json = sqlx::types::Json(&data.executor_action);\n\n        sqlx::query!(\n            r#\"INSERT INTO execution_processes (\n                    id, session_id, run_reason, executor_action,\n                    status, exit_code, started_at, completed_at, created_at, updated_at\n                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"#,\n            process_id,\n            data.session_id,\n            data.run_reason,\n            executor_action_json,\n            ExecutionProcessStatus::Running,\n            None::<i64>,\n            now,\n            None::<DateTime<Utc>>,\n            now,\n            now\n        )\n        .execute(pool)\n        .await?;\n\n        ExecutionProcessRepoState::create_many(pool, process_id, repo_states).await?;\n\n        Self::find_by_id(pool, process_id)\n            .await?\n            .ok_or(sqlx::Error::RowNotFound)\n    }\n\n    pub async fn was_stopped(pool: &SqlitePool, id: Uuid) -> bool {\n        if let Ok(exp_process) = Self::find_by_id(pool, id).await\n            && exp_process.is_some_and(|ep| {\n                ep.status == ExecutionProcessStatus::Killed\n                    || ep.status == ExecutionProcessStatus::Completed\n            })\n        {\n            return true;\n        }\n        false\n    }\n\n    /// Update execution process status and completion info\n    pub async fn update_completion(\n        pool: &SqlitePool,\n        id: Uuid,\n        status: ExecutionProcessStatus,\n        exit_code: Option<i64>,\n    ) -> Result<(), sqlx::Error> {\n        let completed_at = if matches!(status, ExecutionProcessStatus::Running) {\n            None\n        } else {\n            Some(Utc::now())\n        };\n\n        sqlx::query!(\n            r#\"UPDATE execution_processes\n               SET status = $1, exit_code = $2, completed_at = $3\n               WHERE id = $4\"#,\n            status,\n            exit_code,\n            completed_at,\n            id\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub fn executor_action(&self) -> Result<&ExecutorAction, anyhow::Error> {\n        match &self.executor_action.0 {\n            ExecutorActionField::ExecutorAction(action) => Ok(action),\n            ExecutorActionField::Other(_) => Err(anyhow::anyhow!(\n                \"Executor action is not a valid ExecutorAction JSON object\"\n            )),\n        }\n    }\n\n    /// Soft-drop processes at and after the specified boundary (inclusive)\n    pub async fn drop_at_and_after(\n        pool: &SqlitePool,\n        session_id: Uuid,\n        boundary_process_id: Uuid,\n    ) -> Result<i64, sqlx::Error> {\n        let result = sqlx::query!(\n            r#\"UPDATE execution_processes\n               SET dropped = TRUE\n             WHERE session_id = $1\n               AND created_at >= (SELECT created_at FROM execution_processes WHERE id = $2)\n               AND dropped = FALSE\"#,\n            session_id,\n            boundary_process_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(result.rows_affected() as i64)\n    }\n\n    /// Find the previous process's after_head_commit before the given boundary process\n    /// for a specific repository\n    pub async fn find_prev_after_head_commit(\n        pool: &SqlitePool,\n        session_id: Uuid,\n        boundary_process_id: Uuid,\n        repo_id: Uuid,\n    ) -> Result<Option<String>, sqlx::Error> {\n        let result = sqlx::query_scalar!(\n            r#\"SELECT eprs.after_head_commit\n               FROM execution_process_repo_states eprs\n               JOIN execution_processes ep ON ep.id = eprs.execution_process_id\n              WHERE ep.session_id = $1\n                AND eprs.repo_id = $2\n                AND ep.created_at < (SELECT created_at FROM execution_processes WHERE id = $3)\n              ORDER BY ep.created_at DESC\n              LIMIT 1\"#,\n            session_id,\n            repo_id,\n            boundary_process_id\n        )\n        .fetch_optional(pool)\n        .await?;\n        Ok(result.flatten())\n    }\n\n    /// Get the parent Session for this execution process\n    pub async fn parent_session(&self, pool: &SqlitePool) -> Result<Option<Session>, sqlx::Error> {\n        Session::find_by_id(pool, self.session_id).await\n    }\n\n    /// Get both the parent Workspace and Session for this execution process\n    pub async fn parent_workspace_and_session(\n        &self,\n        pool: &SqlitePool,\n    ) -> Result<Option<(Workspace, Session)>, sqlx::Error> {\n        let session = match Session::find_by_id(pool, self.session_id).await? {\n            Some(s) => s,\n            None => return Ok(None),\n        };\n        let workspace = match Workspace::find_by_id(pool, session.workspace_id).await? {\n            Some(w) => w,\n            None => return Ok(None),\n        };\n        Ok(Some((workspace, session)))\n    }\n\n    /// Load execution context with related session, workspace, task, project, and repos\n    pub async fn load_context(\n        pool: &SqlitePool,\n        exec_id: Uuid,\n    ) -> Result<ExecutionContext, sqlx::Error> {\n        let execution_process = Self::find_by_id(pool, exec_id)\n            .await?\n            .ok_or(sqlx::Error::RowNotFound)?;\n\n        let session = Session::find_by_id(pool, execution_process.session_id)\n            .await?\n            .ok_or(sqlx::Error::RowNotFound)?;\n\n        let workspace = Workspace::find_by_id(pool, session.workspace_id)\n            .await?\n            .ok_or(sqlx::Error::RowNotFound)?;\n\n        let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n\n        Ok(ExecutionContext {\n            execution_process,\n            session,\n            workspace,\n            repos,\n        })\n    }\n\n    /// Fetch the latest CodingAgent executor profile for a session.\n    /// Returns None if no CodingAgent execution process exists for this session.\n    pub async fn latest_executor_profile_for_session(\n        pool: &SqlitePool,\n        session_id: Uuid,\n    ) -> Result<Option<ExecutorProfileId>, ExecutionProcessError> {\n        // Find the latest CodingAgent execution process for this session\n        let latest_execution_process = sqlx::query_as!(\n            ExecutionProcess,\n            r#\"SELECT\n                    ep.id as \"id!: Uuid\",\n                    ep.session_id as \"session_id!: Uuid\",\n                    ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n                    ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\",\n                    ep.status as \"status!: ExecutionProcessStatus\",\n                    ep.exit_code,\n                    ep.dropped as \"dropped!: bool\",\n                    ep.started_at as \"started_at!: DateTime<Utc>\",\n                    ep.completed_at as \"completed_at?: DateTime<Utc>\",\n                    ep.created_at as \"created_at!: DateTime<Utc>\",\n                    ep.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM execution_processes ep\n               WHERE ep.session_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE\n               ORDER BY ep.created_at DESC LIMIT 1\"#,\n            session_id,\n            ExecutionProcessRunReason::CodingAgent\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        let Some(latest_execution_process) = latest_execution_process else {\n            return Ok(None);\n        };\n\n        let action = latest_execution_process\n            .executor_action()\n            .map_err(|e| ExecutionProcessError::ValidationError(e.to_string()))?;\n\n        match &action.typ {\n            ExecutorActionType::CodingAgentInitialRequest(request) => {\n                Ok(Some(request.executor_config.profile_id()))\n            }\n            ExecutorActionType::CodingAgentFollowUpRequest(request) => {\n                Ok(Some(request.executor_config.profile_id()))\n            }\n            ExecutorActionType::ReviewRequest(request) => {\n                Ok(Some(request.executor_config.profile_id()))\n            }\n            _ => Err(ExecutionProcessError::ValidationError(\n                \"Couldn't find profile from initial request\".to_string(),\n            )),\n        }\n    }\n\n    /// Fetch latest execution process info for all workspaces with the given archived status.\n    /// Returns a map of workspace_id -> LatestProcessInfo for the most recent\n    /// non-dropped execution process (excluding dev servers).\n    pub async fn find_latest_for_workspaces(\n        pool: &SqlitePool,\n        archived: bool,\n    ) -> Result<HashMap<Uuid, LatestProcessInfo>, sqlx::Error> {\n        let rows: Vec<LatestProcessInfo> = sqlx::query_as!(\n            LatestProcessInfo,\n            r#\"\n            SELECT\n                workspace_id as \"workspace_id!: Uuid\",\n                execution_process_id as \"execution_process_id!: Uuid\",\n                session_id as \"session_id!: Uuid\",\n                status as \"status!: ExecutionProcessStatus\",\n                completed_at as \"completed_at?: DateTime<Utc>\"\n            FROM (\n                SELECT\n                    s.workspace_id,\n                    ep.id as execution_process_id,\n                    ep.session_id,\n                    ep.status,\n                    ep.completed_at,\n                    ROW_NUMBER() OVER (\n                        PARTITION BY s.workspace_id\n                        ORDER BY ep.created_at DESC\n                    ) as rn\n                FROM execution_processes ep\n                JOIN sessions s ON ep.session_id = s.id\n                JOIN workspaces w ON s.workspace_id = w.id\n                WHERE w.archived = $1\n                  AND ep.run_reason IN ('codingagent', 'setupscript', 'cleanupscript')\n                  AND ep.dropped = FALSE\n            )\n            WHERE rn = 1\n            \"#,\n            archived\n        )\n        .fetch_all(pool)\n        .await?;\n\n        let result = rows\n            .into_iter()\n            .map(|info| (info.workspace_id, info))\n            .collect();\n\n        Ok(result)\n    }\n\n    /// Find all workspaces with running dev servers, filtered by archived status.\n    /// Returns a set of workspace IDs that have at least one running dev server.\n    pub async fn find_workspaces_with_running_dev_servers(\n        pool: &SqlitePool,\n        archived: bool,\n    ) -> Result<HashSet<Uuid>, sqlx::Error> {\n        let rows: Vec<Uuid> = sqlx::query_scalar!(\n            r#\"\n            SELECT DISTINCT s.workspace_id as \"workspace_id!: Uuid\"\n            FROM execution_processes ep\n            JOIN sessions s ON ep.session_id = s.id\n            JOIN workspaces w ON s.workspace_id = w.id\n            WHERE w.archived = $1\n              AND ep.status = 'running'\n              AND ep.run_reason = 'devserver'\n            \"#,\n            archived\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows.into_iter().collect())\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/execution_process_logs.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse ts_rs::TS;\nuse utils::log_msg::LogMsg;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct ExecutionProcessLogs {\n    pub execution_id: Uuid,\n    pub logs: String, // JSONL format\n    pub byte_size: i64,\n    pub inserted_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct ExecutionProcessLogMigrationInfo {\n    pub execution_id: Uuid,\n    pub session_id: Uuid,\n}\n\nimpl ExecutionProcessLogs {\n    /// Check if there are any log records\n    pub async fn has_any(pool: &SqlitePool) -> Result<bool, sqlx::Error> {\n        let result: Option<i64> =\n            match sqlx::query_scalar(\"SELECT 1 FROM execution_process_logs LIMIT 1\")\n                .fetch_optional(pool)\n                .await\n            {\n                Ok(r) => r,\n                Err(sqlx::Error::Database(e)) if e.message().contains(\"no such table\") => {\n                    return Ok(false);\n                }\n                Err(e) => return Err(e),\n            };\n        Ok(result.is_some())\n    }\n\n    /// Count the total number of distinct execution processes that have logs\n    pub async fn count_distinct_processes(pool: &SqlitePool) -> Result<i64, sqlx::Error> {\n        let count: i64 = sqlx::query_scalar(\n            r#\"\n            SELECT COUNT(id)\n            FROM execution_processes ep\n            WHERE EXISTS (\n                SELECT 1 FROM execution_process_logs epl WHERE epl.execution_id = ep.id\n            )\n            \"#,\n        )\n        .fetch_one(pool)\n        .await?;\n        Ok(count)\n    }\n\n    /// Get a stream of distinct execution processes that have logs\n    pub fn stream_distinct_processes<'a>(\n        pool: &'a SqlitePool,\n    ) -> futures::stream::BoxStream<'a, Result<ExecutionProcessLogMigrationInfo, sqlx::Error>> {\n        sqlx::query_as!(\n            ExecutionProcessLogMigrationInfo,\n            r#\"\n            SELECT \n                ep.id as \"execution_id!: Uuid\", \n                ep.session_id as \"session_id!: Uuid\"\n            FROM execution_processes ep\n            WHERE EXISTS (\n                SELECT 1 FROM execution_process_logs epl WHERE epl.execution_id = ep.id\n            )\n            \"#\n        )\n        .fetch(pool)\n    }\n\n    /// Delete all records in execution_process_logs\n    pub async fn delete_all(pool: &SqlitePool) -> Result<(), sqlx::Error> {\n        sqlx::query(\"DELETE FROM execution_process_logs\")\n            .execute(pool)\n            .await?;\n        Ok(())\n    }\n\n    /// Find logs by execution process ID\n    pub async fn find_by_execution_id(\n        pool: &SqlitePool,\n        execution_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcessLogs,\n            r#\"SELECT \n                execution_id as \"execution_id!: Uuid\",\n                logs,\n                byte_size,\n                inserted_at as \"inserted_at!: DateTime<Utc>\"\n               FROM execution_process_logs \n               WHERE execution_id = $1\n               ORDER BY inserted_at ASC\"#,\n            execution_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    /// Find logs by execution process ID as a stream of strings\n    pub fn stream_log_lines_by_execution_id<'a>(\n        pool: &'a SqlitePool,\n        execution_id: &'a Uuid,\n    ) -> futures::stream::BoxStream<'a, Result<String, sqlx::Error>> {\n        sqlx::query_scalar!(\n            r#\"SELECT logs\n               FROM execution_process_logs \n               WHERE execution_id = $1\n               ORDER BY inserted_at ASC\"#,\n            *execution_id\n        )\n        .fetch(pool)\n    }\n\n    /// Parse JSONL logs back into Vec<LogMsg>\n    pub fn parse_logs(records: &[Self]) -> Result<Vec<LogMsg>, serde_json::Error> {\n        let mut messages = Vec::new();\n        for line in records.iter().flat_map(|record| record.logs.lines()) {\n            if !line.trim().is_empty() {\n                let msg: LogMsg = serde_json::from_str(line)?;\n                messages.push(msg);\n            }\n        }\n        Ok(messages)\n    }\n\n    /// Append a JSONL line to the logs for an execution process\n    pub async fn append_log_line(\n        pool: &SqlitePool,\n        execution_id: Uuid,\n        jsonl_line: &str,\n    ) -> Result<(), sqlx::Error> {\n        let byte_size = jsonl_line.len() as i64;\n        sqlx::query!(\n            r#\"INSERT INTO execution_process_logs (execution_id, logs, byte_size, inserted_at)\n               VALUES ($1, $2, $3, datetime('now', 'subsec'))\"#,\n            execution_id,\n            jsonl_line,\n            byte_size\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/execution_process_repo_state.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct ExecutionProcessRepoState {\n    pub id: Uuid,\n    pub execution_process_id: Uuid,\n    pub repo_id: Uuid,\n    pub before_head_commit: Option<String>,\n    pub after_head_commit: Option<String>,\n    pub merge_commit: Option<String>,\n    #[ts(type = \"Date\")]\n    pub created_at: DateTime<Utc>,\n    #[ts(type = \"Date\")]\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone)]\npub struct CreateExecutionProcessRepoState {\n    pub repo_id: Uuid,\n    pub before_head_commit: Option<String>,\n    pub after_head_commit: Option<String>,\n    pub merge_commit: Option<String>,\n}\n\nimpl ExecutionProcessRepoState {\n    pub async fn create_many(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n        entries: &[CreateExecutionProcessRepoState],\n    ) -> Result<(), sqlx::Error> {\n        if entries.is_empty() {\n            return Ok(());\n        }\n\n        let now = Utc::now();\n\n        for entry in entries {\n            let id = Uuid::new_v4();\n            sqlx::query!(\n                r#\"INSERT INTO execution_process_repo_states (\n                        id,\n                        execution_process_id,\n                        repo_id,\n                        before_head_commit,\n                        after_head_commit,\n                        merge_commit,\n                        created_at,\n                        updated_at\n                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\"#,\n                id,\n                execution_process_id,\n                entry.repo_id,\n                entry.before_head_commit,\n                entry.after_head_commit,\n                entry.merge_commit,\n                now,\n                now\n            )\n            .execute(pool)\n            .await?;\n        }\n\n        Ok(())\n    }\n\n    pub async fn update_before_head_commit(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n        repo_id: Uuid,\n        before_head_commit: &str,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            r#\"UPDATE execution_process_repo_states\n               SET before_head_commit = $1, updated_at = $2\n             WHERE execution_process_id = $3\n               AND repo_id = $4\"#,\n            before_head_commit,\n            now,\n            execution_process_id,\n            repo_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn update_after_head_commit(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n        repo_id: Uuid,\n        after_head_commit: &str,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            r#\"UPDATE execution_process_repo_states\n               SET after_head_commit = $1, updated_at = $2\n             WHERE execution_process_id = $3\n               AND repo_id = $4\"#,\n            after_head_commit,\n            now,\n            execution_process_id,\n            repo_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn set_merge_commit(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n        repo_id: Uuid,\n        merge_commit: &str,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            r#\"UPDATE execution_process_repo_states\n               SET merge_commit = $1, updated_at = $2\n             WHERE execution_process_id = $3\n               AND repo_id = $4\"#,\n            merge_commit,\n            now,\n            execution_process_id,\n            repo_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn find_by_execution_process_id(\n        pool: &SqlitePool,\n        execution_process_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            ExecutionProcessRepoState,\n            r#\"SELECT\n                    id               as \"id!: Uuid\",\n                    execution_process_id as \"execution_process_id!: Uuid\",\n                    repo_id as \"repo_id!: Uuid\",\n                    before_head_commit,\n                    after_head_commit,\n                    merge_commit,\n                    created_at as \"created_at!: DateTime<Utc>\",\n                    updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM execution_process_repo_states\n               WHERE execution_process_id = $1\n               ORDER BY created_at ASC\"#,\n            execution_process_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/file.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct File {\n    pub id: Uuid,\n    pub file_path: String, // relative path within cache/attachments/\n    pub original_name: String,\n    pub mime_type: Option<String>,\n    pub size_bytes: i64,\n    pub hash: String, // SHA256 hash for deduplication\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateFile {\n    pub file_path: String,\n    pub original_name: String,\n    pub mime_type: Option<String>,\n    pub size_bytes: i64,\n    pub hash: String,\n}\n\nimpl File {\n    pub async fn create(pool: &SqlitePool, data: &CreateFile) -> Result<Self, sqlx::Error> {\n        let id = Uuid::new_v4();\n        sqlx::query_as!(\n            File,\n            r#\"INSERT INTO attachments (id, file_path, original_name, mime_type, size_bytes, hash)\n               VALUES ($1, $2, $3, $4, $5, $6)\n               RETURNING id as \"id!: Uuid\", \n                         file_path as \"file_path!\", \n                         original_name as \"original_name!\", \n                         mime_type,\n                         size_bytes as \"size_bytes!\",\n                         hash as \"hash!\",\n                         created_at as \"created_at!: DateTime<Utc>\", \n                         updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            data.file_path,\n            data.original_name,\n            data.mime_type,\n            data.size_bytes,\n            data.hash,\n        )\n        .fetch_one(pool)\n        .await\n    }\n\n    pub async fn find_by_hash(pool: &SqlitePool, hash: &str) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            File,\n            r#\"SELECT id as \"id!: Uuid\",\n                      file_path as \"file_path!\",\n                      original_name as \"original_name!\",\n                      mime_type,\n                      size_bytes as \"size_bytes!\",\n                      hash as \"hash!\",\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM attachments\n               WHERE hash = $1\"#,\n            hash\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            File,\n            r#\"SELECT id as \"id!: Uuid\",\n                      file_path as \"file_path!\",\n                      original_name as \"original_name!\",\n                      mime_type,\n                      size_bytes as \"size_bytes!\",\n                      hash as \"hash!\",\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM attachments\n               WHERE id = $1\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn find_by_file_path(\n        pool: &SqlitePool,\n        file_path: &str,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            File,\n            r#\"SELECT id as \"id!: Uuid\",\n                      file_path as \"file_path!\",\n                      original_name as \"original_name!\",\n                      mime_type,\n                      size_bytes as \"size_bytes!\",\n                      hash as \"hash!\",\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM attachments\n               WHERE file_path = $1\"#,\n            file_path\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn find_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            File,\n            r#\"SELECT i.id as \"id!: Uuid\",\n                      i.file_path as \"file_path!\",\n                      i.original_name as \"original_name!\",\n                      i.mime_type,\n                      i.size_bytes as \"size_bytes!\",\n                      i.hash as \"hash!\",\n                      i.created_at as \"created_at!: DateTime<Utc>\",\n                      i.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM attachments i\n               JOIN workspace_attachments wa ON i.id = wa.attachment_id\n               WHERE wa.workspace_id = $1\n               ORDER BY wa.created_at\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<(), sqlx::Error> {\n        sqlx::query!(r#\"DELETE FROM attachments WHERE id = $1\"#, id)\n            .execute(pool)\n            .await?;\n        Ok(())\n    }\n\n    pub async fn find_orphaned_files(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            File,\n            r#\"SELECT i.id as \"id!: Uuid\",\n                      i.file_path as \"file_path!\",\n                      i.original_name as \"original_name!\",\n                      i.mime_type,\n                      i.size_bytes as \"size_bytes!\",\n                      i.hash as \"hash!\",\n                      i.created_at as \"created_at!: DateTime<Utc>\",\n                      i.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM attachments i\n               LEFT JOIN workspace_attachments wa ON i.id = wa.attachment_id\n               WHERE wa.workspace_id IS NULL\"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct WorkspaceAttachment {\n    pub id: Uuid,\n    pub workspace_id: Uuid,\n    pub attachment_id: Uuid,\n    pub created_at: DateTime<Utc>,\n}\n\nimpl WorkspaceAttachment {\n    /// Associate multiple attachments with a workspace, skipping duplicates.\n    pub async fn associate_many_dedup(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        attachment_ids: &[Uuid],\n    ) -> Result<(), sqlx::Error> {\n        for &attachment_id in attachment_ids {\n            let id = Uuid::new_v4();\n            sqlx::query!(\n                r#\"INSERT INTO workspace_attachments (id, workspace_id, attachment_id)\n                   SELECT $1, $2, $3\n                   WHERE NOT EXISTS (\n                       SELECT 1 FROM workspace_attachments WHERE workspace_id = $2 AND attachment_id = $3\n                   )\"#,\n                id,\n                workspace_id,\n                attachment_id\n            )\n            .execute(pool)\n            .await?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/merge.rs",
    "content": "use std::collections::HashMap;\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool, Type};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Type)]\n#[sqlx(type_name = \"merge_status\", rename_all = \"snake_case\")]\n#[serde(rename_all = \"snake_case\")]\npub enum MergeStatus {\n    Open,\n    Merged,\n    Closed,\n    Unknown,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum Merge {\n    Direct(DirectMerge),\n    Pr(PrMerge),\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct DirectMerge {\n    pub id: Uuid,\n    pub workspace_id: Uuid,\n    pub repo_id: Uuid,\n    pub merge_commit: String,\n    pub target_branch_name: String,\n    pub created_at: DateTime<Utc>,\n}\n\n/// PR merge - represents a pull request merge\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct PrMerge {\n    pub id: Uuid,\n    pub workspace_id: Uuid,\n    pub repo_id: Uuid,\n    pub created_at: DateTime<Utc>,\n    pub target_branch_name: String,\n    pub pr_info: PullRequestInfo,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct PullRequestInfo {\n    pub number: i64,\n    pub url: String,\n    pub status: MergeStatus,\n    pub merged_at: Option<chrono::DateTime<chrono::Utc>>,\n    pub merge_commit_sha: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[sqlx(type_name = \"TEXT\", rename_all = \"snake_case\")]\npub enum MergeType {\n    Direct,\n    Pr,\n}\n\n#[derive(FromRow)]\nstruct MergeRow {\n    id: Uuid,\n    workspace_id: Uuid,\n    repo_id: Uuid,\n    merge_type: MergeType,\n    merge_commit: Option<String>,\n    target_branch_name: String,\n    pr_number: Option<i64>,\n    pr_url: Option<String>,\n    pr_status: Option<MergeStatus>,\n    pr_merged_at: Option<DateTime<Utc>>,\n    pr_merge_commit_sha: Option<String>,\n    created_at: DateTime<Utc>,\n}\n\nimpl Merge {\n    pub fn merge_commit(&self) -> Option<String> {\n        match self {\n            Merge::Direct(direct) => Some(direct.merge_commit.clone()),\n            Merge::Pr(pr) => pr.pr_info.merge_commit_sha.clone(),\n        }\n    }\n\n    /// Create a direct merge record\n    pub async fn create_direct(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        repo_id: Uuid,\n        target_branch_name: &str,\n        merge_commit: &str,\n    ) -> Result<DirectMerge, sqlx::Error> {\n        let id = Uuid::new_v4();\n        let now = Utc::now();\n\n        sqlx::query_as!(\n            MergeRow,\n            r#\"INSERT INTO merges (\n                id, workspace_id, repo_id, merge_type, merge_commit, created_at, target_branch_name\n            ) VALUES ($1, $2, $3, 'direct', $4, $5, $6)\n            RETURNING\n                id as \"id!: Uuid\",\n                workspace_id as \"workspace_id!: Uuid\",\n                repo_id as \"repo_id!: Uuid\",\n                merge_type as \"merge_type!: MergeType\",\n                merge_commit,\n                pr_number,\n                pr_url,\n                pr_status as \"pr_status?: MergeStatus\",\n                pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n                pr_merge_commit_sha,\n                created_at as \"created_at!: DateTime<Utc>\",\n                target_branch_name as \"target_branch_name!: String\"\n            \"#,\n            id,\n            workspace_id,\n            repo_id,\n            merge_commit,\n            now,\n            target_branch_name\n        )\n        .fetch_one(pool)\n        .await\n        .map(Into::into)\n    }\n    /// Create a new PR record (when PR is opened)\n    pub async fn create_pr(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        repo_id: Uuid,\n        target_branch_name: &str,\n        pr_number: i64,\n        pr_url: &str,\n    ) -> Result<PrMerge, sqlx::Error> {\n        let id = Uuid::new_v4();\n        let now = Utc::now();\n\n        sqlx::query_as!(\n            MergeRow,\n            r#\"INSERT INTO merges (\n                id, workspace_id, repo_id, merge_type, pr_number, pr_url, pr_status, created_at, target_branch_name\n            ) VALUES ($1, $2, $3, 'pr', $4, $5, 'open', $6, $7)\n            RETURNING\n                id as \"id!: Uuid\",\n                workspace_id as \"workspace_id!: Uuid\",\n                repo_id as \"repo_id!: Uuid\",\n                merge_type as \"merge_type!: MergeType\",\n                merge_commit,\n                pr_number,\n                pr_url,\n                pr_status as \"pr_status?: MergeStatus\",\n                pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n                pr_merge_commit_sha,\n                created_at as \"created_at!: DateTime<Utc>\",\n                target_branch_name as \"target_branch_name!: String\"\n            \"#,\n            id,\n            workspace_id,\n            repo_id,\n            pr_number,\n            pr_url,\n            now,\n            target_branch_name\n        )\n        .fetch_one(pool)\n        .await\n        .map(Into::into)\n    }\n\n    pub async fn find_all_pr(pool: &SqlitePool) -> Result<Vec<PrMerge>, sqlx::Error> {\n        let rows = sqlx::query_as!(\n            MergeRow,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                workspace_id as \"workspace_id!: Uuid\",\n                repo_id as \"repo_id!: Uuid\",\n                merge_type as \"merge_type!: MergeType\",\n                merge_commit,\n                pr_number,\n                pr_url,\n                pr_status as \"pr_status?: MergeStatus\",\n                pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n                pr_merge_commit_sha,\n                created_at as \"created_at!: DateTime<Utc>\",\n                target_branch_name as \"target_branch_name!: String\"\n               FROM merges\n               WHERE merge_type = 'pr'\n               ORDER BY created_at ASC\"#,\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows.into_iter().map(Into::into).collect())\n    }\n\n    pub async fn get_open_prs(pool: &SqlitePool) -> Result<Vec<PrMerge>, sqlx::Error> {\n        let rows = sqlx::query_as!(\n            MergeRow,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                workspace_id as \"workspace_id!: Uuid\",\n                repo_id as \"repo_id!: Uuid\",\n                merge_type as \"merge_type!: MergeType\",\n                merge_commit,\n                pr_number,\n                pr_url,\n                pr_status as \"pr_status?: MergeStatus\",\n                pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n                pr_merge_commit_sha,\n                created_at as \"created_at!: DateTime<Utc>\",\n                target_branch_name as \"target_branch_name!: String\"\n               FROM merges\n               WHERE merge_type = 'pr' AND pr_status = 'open'\n               ORDER BY created_at DESC\"#,\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows.into_iter().map(Into::into).collect())\n    }\n\n    pub async fn count_open_prs_for_workspace(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<i64, sqlx::Error> {\n        let count = sqlx::query_scalar!(\n            r#\"SELECT COUNT(1) as \"count!: i64\"\n               FROM merges\n               WHERE workspace_id = $1\n                 AND merge_type = 'pr'\n                 AND pr_status = 'open'\"#,\n            workspace_id\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(count)\n    }\n\n    /// Update PR status for a workspace\n    pub async fn update_status(\n        pool: &SqlitePool,\n        merge_id: Uuid,\n        pr_status: MergeStatus,\n        merge_commit_sha: Option<String>,\n    ) -> Result<(), sqlx::Error> {\n        let merged_at = if matches!(pr_status, MergeStatus::Merged) {\n            Some(Utc::now())\n        } else {\n            None\n        };\n\n        sqlx::query!(\n            r#\"UPDATE merges\n            SET pr_status = $1,\n                pr_merge_commit_sha = $2,\n                pr_merged_at = $3\n            WHERE id = $4\"#,\n            pr_status,\n            merge_commit_sha,\n            merged_at,\n            merge_id\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n    /// Find all merges for a workspace (returns both direct and PR merges)\n    pub async fn find_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        // Get raw data from database\n        let rows = sqlx::query_as!(\n            MergeRow,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                workspace_id as \"workspace_id!: Uuid\",\n                repo_id as \"repo_id!: Uuid\",\n                merge_type as \"merge_type!: MergeType\",\n                merge_commit,\n                pr_number,\n                pr_url,\n                pr_status as \"pr_status?: MergeStatus\",\n                pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n                pr_merge_commit_sha,\n                target_branch_name as \"target_branch_name!: String\",\n                created_at as \"created_at!: DateTime<Utc>\"\n            FROM merges\n            WHERE workspace_id = $1\n            ORDER BY created_at DESC\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        // Convert to appropriate types based on merge_type\n        Ok(rows.into_iter().map(Into::into).collect())\n    }\n\n    /// Find all merges for a workspace and specific repo\n    pub async fn find_by_workspace_and_repo_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        repo_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        let rows = sqlx::query_as!(\n            MergeRow,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                workspace_id as \"workspace_id!: Uuid\",\n                repo_id as \"repo_id!: Uuid\",\n                merge_type as \"merge_type!: MergeType\",\n                merge_commit,\n                pr_number,\n                pr_url,\n                pr_status as \"pr_status?: MergeStatus\",\n                pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n                pr_merge_commit_sha,\n                target_branch_name as \"target_branch_name!: String\",\n                created_at as \"created_at!: DateTime<Utc>\"\n            FROM merges\n            WHERE workspace_id = $1 AND repo_id = $2\n            ORDER BY created_at DESC\"#,\n            workspace_id,\n            repo_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows.into_iter().map(Into::into).collect())\n    }\n\n    /// Get the latest PR for each workspace (for workspace summaries)\n    /// Returns a map of workspace_id -> PrMerge for workspaces that have PRs\n    pub async fn get_latest_pr_status_for_workspaces(\n        pool: &SqlitePool,\n        archived: bool,\n    ) -> Result<HashMap<Uuid, PrMerge>, sqlx::Error> {\n        // Get the latest PR for each workspace by using a subquery to find the max created_at\n        // Only consider PR merges (not direct merges)\n        let rows = sqlx::query_as!(\n            MergeRow,\n            r#\"SELECT\n                m.id as \"id!: Uuid\",\n                m.workspace_id as \"workspace_id!: Uuid\",\n                m.repo_id as \"repo_id!: Uuid\",\n                m.merge_type as \"merge_type!: MergeType\",\n                m.merge_commit,\n                m.pr_number,\n                m.pr_url,\n                m.pr_status as \"pr_status?: MergeStatus\",\n                m.pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n                m.pr_merge_commit_sha,\n                m.target_branch_name as \"target_branch_name!: String\",\n                m.created_at as \"created_at!: DateTime<Utc>\"\n            FROM merges m\n            INNER JOIN (\n                SELECT workspace_id, MAX(created_at) as max_created_at\n                FROM merges\n                WHERE merge_type = 'pr'\n                GROUP BY workspace_id\n            ) latest ON m.workspace_id = latest.workspace_id\n                AND m.created_at = latest.max_created_at\n            INNER JOIN workspaces w ON m.workspace_id = w.id\n            WHERE m.merge_type = 'pr' AND w.archived = $1\"#,\n            archived\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows\n            .into_iter()\n            .map(|row| {\n                let workspace_id = row.workspace_id;\n                (workspace_id, PrMerge::from(row))\n            })\n            .collect())\n    }\n}\n\n// Conversion implementations\nimpl From<MergeRow> for DirectMerge {\n    fn from(row: MergeRow) -> Self {\n        DirectMerge {\n            id: row.id,\n            workspace_id: row.workspace_id,\n            repo_id: row.repo_id,\n            merge_commit: row\n                .merge_commit\n                .expect(\"direct merge must have merge_commit\"),\n            target_branch_name: row.target_branch_name,\n            created_at: row.created_at,\n        }\n    }\n}\n\nimpl From<MergeRow> for PrMerge {\n    fn from(row: MergeRow) -> Self {\n        PrMerge {\n            id: row.id,\n            workspace_id: row.workspace_id,\n            repo_id: row.repo_id,\n            target_branch_name: row.target_branch_name,\n            pr_info: PullRequestInfo {\n                number: row.pr_number.expect(\"pr merge must have pr_number\"),\n                url: row.pr_url.expect(\"pr merge must have pr_url\"),\n                status: row.pr_status.expect(\"pr merge must have status\"),\n                merged_at: row.pr_merged_at,\n                merge_commit_sha: row.pr_merge_commit_sha,\n            },\n            created_at: row.created_at,\n        }\n    }\n}\n\nimpl From<MergeRow> for Merge {\n    fn from(row: MergeRow) -> Self {\n        match row.merge_type {\n            MergeType::Direct => Merge::Direct(DirectMerge::from(row)),\n            MergeType::Pr => Merge::Pr(PrMerge::from(row)),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/migration_state.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{Executor, FromRow, Sqlite, SqlitePool, Type};\nuse strum_macros::{Display, EnumString};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum MigrationStateError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\n#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, EnumString, Display)]\n#[sqlx(type_name = \"text\", rename_all = \"lowercase\")]\n#[serde(rename_all = \"lowercase\")]\n#[strum(serialize_all = \"lowercase\")]\npub enum EntityType {\n    Project,\n    Task,\n    PrMerge,\n    Workspace,\n}\n\n#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, EnumString, Display, Default)]\n#[sqlx(type_name = \"text\", rename_all = \"lowercase\")]\n#[serde(rename_all = \"lowercase\")]\n#[strum(serialize_all = \"lowercase\")]\npub enum MigrationStatus {\n    #[default]\n    Pending,\n    Migrated,\n    Failed,\n    Skipped,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct MigrationState {\n    pub id: Uuid,\n    pub entity_type: EntityType,\n    pub local_id: Uuid,\n    pub remote_id: Option<Uuid>,\n    pub status: MigrationStatus,\n    pub error_message: Option<String>,\n    pub attempt_count: i64,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CreateMigrationState {\n    pub entity_type: EntityType,\n    pub local_id: Uuid,\n}\n\nimpl MigrationState {\n    pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, MigrationStateError> {\n        let records = sqlx::query_as!(\n            MigrationState,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                entity_type as \"entity_type!: EntityType\",\n                local_id as \"local_id!: Uuid\",\n                remote_id as \"remote_id: Uuid\",\n                status as \"status!: MigrationStatus\",\n                error_message,\n                attempt_count as \"attempt_count!\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\n            FROM migration_state\n            ORDER BY created_at ASC\"#\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn find_by_entity_type(\n        pool: &SqlitePool,\n        entity_type: EntityType,\n    ) -> Result<Vec<Self>, MigrationStateError> {\n        let entity_type_str = entity_type.to_string();\n        let records = sqlx::query_as!(\n            MigrationState,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                entity_type as \"entity_type!: EntityType\",\n                local_id as \"local_id!: Uuid\",\n                remote_id as \"remote_id: Uuid\",\n                status as \"status!: MigrationStatus\",\n                error_message,\n                attempt_count as \"attempt_count!\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\n            FROM migration_state\n            WHERE entity_type = $1\n            ORDER BY created_at ASC\"#,\n            entity_type_str\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn find_by_status(\n        pool: &SqlitePool,\n        status: MigrationStatus,\n    ) -> Result<Vec<Self>, MigrationStateError> {\n        let status_str = status.to_string();\n        let records = sqlx::query_as!(\n            MigrationState,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                entity_type as \"entity_type!: EntityType\",\n                local_id as \"local_id!: Uuid\",\n                remote_id as \"remote_id: Uuid\",\n                status as \"status!: MigrationStatus\",\n                error_message,\n                attempt_count as \"attempt_count!\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\n            FROM migration_state\n            WHERE status = $1\n            ORDER BY created_at ASC\"#,\n            status_str\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn find_pending_by_type(\n        pool: &SqlitePool,\n        entity_type: EntityType,\n    ) -> Result<Vec<Self>, MigrationStateError> {\n        let entity_type_str = entity_type.to_string();\n        let records = sqlx::query_as!(\n            MigrationState,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                entity_type as \"entity_type!: EntityType\",\n                local_id as \"local_id!: Uuid\",\n                remote_id as \"remote_id: Uuid\",\n                status as \"status!: MigrationStatus\",\n                error_message,\n                attempt_count as \"attempt_count!\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\n            FROM migration_state\n            WHERE entity_type = $1 AND status = 'pending'\n            ORDER BY created_at ASC\"#,\n            entity_type_str\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn find_by_entity(\n        pool: &SqlitePool,\n        entity_type: EntityType,\n        local_id: Uuid,\n    ) -> Result<Option<Self>, MigrationStateError> {\n        let entity_type_str = entity_type.to_string();\n        let record = sqlx::query_as!(\n            MigrationState,\n            r#\"SELECT\n                id as \"id!: Uuid\",\n                entity_type as \"entity_type!: EntityType\",\n                local_id as \"local_id!: Uuid\",\n                remote_id as \"remote_id: Uuid\",\n                status as \"status!: MigrationStatus\",\n                error_message,\n                attempt_count as \"attempt_count!\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\n            FROM migration_state\n            WHERE entity_type = $1 AND local_id = $2\"#,\n            entity_type_str,\n            local_id\n        )\n        .fetch_optional(pool)\n        .await?;\n        Ok(record)\n    }\n\n    pub async fn get_remote_id(\n        pool: &SqlitePool,\n        entity_type: EntityType,\n        local_id: Uuid,\n    ) -> Result<Option<Uuid>, MigrationStateError> {\n        let entity_type_str = entity_type.to_string();\n        let record = sqlx::query_scalar!(\n            r#\"SELECT remote_id as \"remote_id: Uuid\"\n            FROM migration_state\n            WHERE entity_type = $1 AND local_id = $2 AND status = 'migrated'\"#,\n            entity_type_str,\n            local_id\n        )\n        .fetch_optional(pool)\n        .await?;\n        Ok(record.flatten())\n    }\n\n    pub async fn create<'e, E>(\n        executor: E,\n        data: &CreateMigrationState,\n    ) -> Result<Self, MigrationStateError>\n    where\n        E: Executor<'e, Database = Sqlite>,\n    {\n        let id = Uuid::new_v4();\n        let entity_type_str = data.entity_type.to_string();\n        let record = sqlx::query_as!(\n            MigrationState,\n            r#\"INSERT INTO migration_state (id, entity_type, local_id)\n            VALUES ($1, $2, $3)\n            RETURNING\n                id as \"id!: Uuid\",\n                entity_type as \"entity_type!: EntityType\",\n                local_id as \"local_id!: Uuid\",\n                remote_id as \"remote_id: Uuid\",\n                status as \"status!: MigrationStatus\",\n                error_message,\n                attempt_count as \"attempt_count!\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            entity_type_str,\n            data.local_id\n        )\n        .fetch_one(executor)\n        .await?;\n        Ok(record)\n    }\n\n    pub async fn upsert<'e, E>(\n        executor: E,\n        data: &CreateMigrationState,\n    ) -> Result<Self, MigrationStateError>\n    where\n        E: Executor<'e, Database = Sqlite>,\n    {\n        let id = Uuid::new_v4();\n        let entity_type_str = data.entity_type.to_string();\n        let record = sqlx::query_as!(\n            MigrationState,\n            r#\"INSERT INTO migration_state (id, entity_type, local_id)\n            VALUES ($1, $2, $3)\n            ON CONFLICT (entity_type, local_id) DO UPDATE SET\n                updated_at = datetime('now', 'subsec')\n            RETURNING\n                id as \"id!: Uuid\",\n                entity_type as \"entity_type!: EntityType\",\n                local_id as \"local_id!: Uuid\",\n                remote_id as \"remote_id: Uuid\",\n                status as \"status!: MigrationStatus\",\n                error_message,\n                attempt_count as \"attempt_count!\",\n                created_at as \"created_at!: DateTime<Utc>\",\n                updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            entity_type_str,\n            data.local_id\n        )\n        .fetch_one(executor)\n        .await?;\n        Ok(record)\n    }\n\n    pub async fn mark_migrated<'e, E>(\n        executor: E,\n        entity_type: EntityType,\n        local_id: Uuid,\n        remote_id: Uuid,\n    ) -> Result<(), MigrationStateError>\n    where\n        E: Executor<'e, Database = Sqlite>,\n    {\n        let entity_type_str = entity_type.to_string();\n        sqlx::query!(\n            r#\"UPDATE migration_state\n            SET status = 'migrated',\n                remote_id = $3,\n                error_message = NULL,\n                updated_at = datetime('now', 'subsec')\n            WHERE entity_type = $1 AND local_id = $2\"#,\n            entity_type_str,\n            local_id,\n            remote_id\n        )\n        .execute(executor)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn mark_failed<'e, E>(\n        executor: E,\n        entity_type: EntityType,\n        local_id: Uuid,\n        error_message: &str,\n    ) -> Result<(), MigrationStateError>\n    where\n        E: Executor<'e, Database = Sqlite>,\n    {\n        let entity_type_str = entity_type.to_string();\n        sqlx::query!(\n            r#\"UPDATE migration_state\n            SET status = 'failed',\n                error_message = $3,\n                attempt_count = attempt_count + 1,\n                updated_at = datetime('now', 'subsec')\n            WHERE entity_type = $1 AND local_id = $2\"#,\n            entity_type_str,\n            local_id,\n            error_message\n        )\n        .execute(executor)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn mark_skipped<'e, E>(\n        executor: E,\n        entity_type: EntityType,\n        local_id: Uuid,\n        reason: &str,\n    ) -> Result<(), MigrationStateError>\n    where\n        E: Executor<'e, Database = Sqlite>,\n    {\n        let entity_type_str = entity_type.to_string();\n        sqlx::query!(\n            r#\"UPDATE migration_state\n            SET status = 'skipped',\n                error_message = $3,\n                updated_at = datetime('now', 'subsec')\n            WHERE entity_type = $1 AND local_id = $2\"#,\n            entity_type_str,\n            local_id,\n            reason\n        )\n        .execute(executor)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn reset_failed(pool: &SqlitePool) -> Result<u64, MigrationStateError> {\n        let result = sqlx::query!(\n            r#\"UPDATE migration_state\n            SET status = 'pending',\n                error_message = NULL,\n                updated_at = datetime('now', 'subsec')\n            WHERE status = 'failed'\"#\n        )\n        .execute(pool)\n        .await?;\n        Ok(result.rows_affected())\n    }\n\n    pub async fn get_stats(pool: &SqlitePool) -> Result<MigrationStats, MigrationStateError> {\n        let stats = sqlx::query_as!(\n            MigrationStats,\n            r#\"SELECT\n                COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) as \"pending!: i64\",\n                COALESCE(SUM(CASE WHEN status = 'migrated' THEN 1 ELSE 0 END), 0) as \"migrated!: i64\",\n                COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) as \"failed!: i64\",\n                COALESCE(SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END), 0) as \"skipped!: i64\",\n                COUNT(*) as \"total!: i64\"\n            FROM migration_state\"#\n        )\n        .fetch_one(pool)\n        .await?;\n        Ok(stats)\n    }\n\n    pub async fn clear_all(pool: &SqlitePool) -> Result<u64, MigrationStateError> {\n        let result = sqlx::query!(\"DELETE FROM migration_state\")\n            .execute(pool)\n            .await?;\n        Ok(result.rows_affected())\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]\npub struct MigrationStats {\n    pub pending: i64,\n    pub migrated: i64,\n    pub failed: i64,\n    pub skipped: i64,\n    pub total: i64,\n}\n"
  },
  {
    "path": "crates/db/src/models/mod.rs",
    "content": "pub mod coding_agent_turn;\npub mod execution_process;\npub mod execution_process_logs;\npub mod execution_process_repo_state;\npub mod file;\npub mod merge;\npub mod migration_state;\npub mod project;\npub mod repo;\npub mod requests;\npub mod scratch;\npub mod session;\npub mod tag;\npub mod task;\npub mod workspace;\npub mod workspace_repo;\n"
  },
  {
    "path": "crates/db/src/models/project.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct Project {\n    pub id: Uuid,\n    pub name: String,\n    pub default_agent_working_dir: Option<String>,\n    pub remote_project_id: Option<Uuid>,\n    #[ts(type = \"Date\")]\n    pub created_at: DateTime<Utc>,\n    #[ts(type = \"Date\")]\n    pub updated_at: DateTime<Utc>,\n}\n\nimpl Project {\n    pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Project,\n            r#\"SELECT id as \"id!: Uuid\",\n                      name,\n                      default_agent_working_dir,\n                      remote_project_id as \"remote_project_id: Uuid\",\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM projects\n               ORDER BY created_at DESC\"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn set_remote_project_id(\n        pool: &SqlitePool,\n        id: Uuid,\n        remote_project_id: Option<Uuid>,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            r#\"UPDATE projects\n               SET remote_project_id = $2\n               WHERE id = $1\"#,\n            id,\n            remote_project_id\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/repo.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_with::rust::double_option;\nuse sqlx::{Executor, FromRow, Sqlite, SqlitePool};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Serialize, TS)]\npub struct SearchResult {\n    pub path: String,\n    pub is_file: bool,\n    pub match_type: SearchMatchType,\n    /// Ranking score based on git history (higher = more recently/frequently edited)\n    #[serde(default)]\n    pub score: i64,\n}\n\n#[derive(Debug, Clone, Serialize, TS)]\npub enum SearchMatchType {\n    FileName,\n    DirectoryName,\n    FullPath,\n}\n\n#[derive(Debug, Error)]\npub enum RepoError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(\"Repository not found\")]\n    NotFound,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct Repo {\n    pub id: Uuid,\n    pub path: PathBuf,\n    pub name: String,\n    pub display_name: String,\n    pub setup_script: Option<String>,\n    pub cleanup_script: Option<String>,\n    pub archive_script: Option<String>,\n    pub copy_files: Option<String>,\n    pub parallel_setup_script: bool,\n    pub dev_server_script: Option<String>,\n    pub default_target_branch: Option<String>,\n    pub default_working_dir: Option<String>,\n    #[ts(type = \"Date\")]\n    pub created_at: DateTime<Utc>,\n    #[ts(type = \"Date\")]\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct UpdateRepo {\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub display_name: Option<Option<String>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub setup_script: Option<Option<String>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub cleanup_script: Option<Option<String>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub archive_script: Option<Option<String>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub copy_files: Option<Option<String>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"boolean | null\")]\n    pub parallel_setup_script: Option<Option<bool>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub dev_server_script: Option<Option<String>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub default_target_branch: Option<Option<String>>,\n\n    #[serde(\n        default,\n        skip_serializing_if = \"Option::is_none\",\n        with = \"double_option\"\n    )]\n    #[ts(optional, type = \"string | null\")]\n    pub default_working_dir: Option<Option<String>>,\n}\n\nimpl Repo {\n    /// Get repos that still have the migration sentinel as their name.\n    /// Used by the startup backfill to fix repo names.\n    pub async fn list_needing_name_fix(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Repo,\n            r#\"SELECT id as \"id!: Uuid\",\n                      path,\n                      name,\n                      display_name,\n                      setup_script,\n                      cleanup_script,\n                      archive_script,\n                      copy_files,\n                      parallel_setup_script as \"parallel_setup_script!: bool\",\n                      dev_server_script,\n                      default_target_branch,\n                      default_working_dir,\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM repos\n               WHERE name = '__NEEDS_BACKFILL__'\"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn update_name(\n        pool: &SqlitePool,\n        id: Uuid,\n        name: &str,\n        display_name: &str,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            \"UPDATE repos SET name = $1, display_name = $2, updated_at = datetime('now', 'subsec') WHERE id = $3\",\n            name,\n            display_name,\n            id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Repo,\n            r#\"SELECT id as \"id!: Uuid\",\n                      path,\n                      name,\n                      display_name,\n                      setup_script,\n                      cleanup_script,\n                      archive_script,\n                      copy_files,\n                      parallel_setup_script as \"parallel_setup_script!: bool\",\n                      dev_server_script,\n                      default_target_branch,\n                      default_working_dir,\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM repos\n               WHERE id = $1\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn find_by_ids(pool: &SqlitePool, ids: &[Uuid]) -> Result<Vec<Self>, sqlx::Error> {\n        if ids.is_empty() {\n            return Ok(Vec::new());\n        }\n\n        // Fetch each repo individually since SQLite doesn't support array parameters\n        let mut repos = Vec::with_capacity(ids.len());\n        for id in ids {\n            if let Some(repo) = Self::find_by_id(pool, *id).await? {\n                repos.push(repo);\n            }\n        }\n        Ok(repos)\n    }\n\n    pub async fn find_or_create<'e, E>(\n        executor: E,\n        path: &Path,\n        display_name: &str,\n    ) -> Result<Self, sqlx::Error>\n    where\n        E: Executor<'e, Database = Sqlite>,\n    {\n        let path_str = path.to_string_lossy().to_string();\n        let id = Uuid::new_v4();\n        let repo_name = path\n            .file_name()\n            .map(|name| name.to_string_lossy().to_string())\n            .unwrap_or_else(|| id.to_string());\n\n        // Use INSERT OR IGNORE + SELECT to handle race conditions atomically\n        sqlx::query_as!(\n            Repo,\n            r#\"INSERT INTO repos (id, path, name, display_name)\n               VALUES ($1, $2, $3, $4)\n               ON CONFLICT(path) DO UPDATE SET updated_at = updated_at\n               RETURNING id as \"id!: Uuid\",\n                         path,\n                         name,\n                         display_name,\n                         setup_script,\n                         cleanup_script,\n                         archive_script,\n                         copy_files,\n                         parallel_setup_script as \"parallel_setup_script!: bool\",\n                         dev_server_script,\n                         default_target_branch,\n                         default_working_dir,\n                         created_at as \"created_at!: DateTime<Utc>\",\n                         updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            path_str,\n            repo_name,\n            display_name,\n        )\n        .fetch_one(executor)\n        .await\n    }\n\n    pub async fn delete_orphaned(pool: &SqlitePool) -> Result<u64, sqlx::Error> {\n        let result = sqlx::query!(\n            r#\"DELETE FROM repos\n               WHERE id NOT IN (SELECT repo_id FROM project_repos)\n                 AND id NOT IN (SELECT repo_id FROM workspace_repos)\"#\n        )\n        .execute(pool)\n        .await?;\n        Ok(result.rows_affected())\n    }\n\n    pub async fn list_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Repo,\n            r#\"SELECT id as \"id!: Uuid\",\n                      path,\n                      name,\n                      display_name,\n                      setup_script,\n                      cleanup_script,\n                      archive_script,\n                      copy_files,\n                      parallel_setup_script as \"parallel_setup_script!: bool\",\n                      dev_server_script,\n                      default_target_branch,\n                      default_working_dir,\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM repos\n               ORDER BY display_name ASC\"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn list_by_recent_workspace_usage(\n        pool: &SqlitePool,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Repo,\n            r#\"SELECT r.id as \"id!: Uuid\",\n                      r.path,\n                      r.name,\n                      r.display_name,\n                      r.setup_script,\n                      r.cleanup_script,\n                      r.archive_script,\n                      r.copy_files,\n                      r.parallel_setup_script as \"parallel_setup_script!: bool\",\n                      r.dev_server_script,\n                      r.default_target_branch,\n                      r.default_working_dir,\n                      r.created_at as \"created_at!: DateTime<Utc>\",\n                      r.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM repos r\n               LEFT JOIN (\n                   SELECT repo_id, MAX(updated_at) AS last_used_at\n                   FROM workspace_repos\n                   GROUP BY repo_id\n               ) wr ON wr.repo_id = r.id\n               ORDER BY wr.last_used_at DESC, r.display_name ASC\"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    /// Returns the names of active (non-archived) workspaces that reference this repo.\n    pub async fn active_workspace_names(\n        pool: &SqlitePool,\n        repo_id: Uuid,\n    ) -> Result<Vec<String>, sqlx::Error> {\n        let rows = sqlx::query_scalar!(\n            r#\"SELECT w.name AS \"name: String\"\n               FROM workspaces w\n               JOIN workspace_repos wr ON wr.workspace_id = w.id\n               WHERE wr.repo_id = $1\n                 AND w.archived = FALSE\"#,\n            repo_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows\n            .into_iter()\n            .map(|name| name.unwrap_or_else(|| \"Unnamed workspace\".to_string()))\n            .collect())\n    }\n\n    /// Delete a repo by ID. Relies on ON DELETE CASCADE for workspace_repos / project_repos.\n    pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<u64, sqlx::Error> {\n        let result = sqlx::query!(\"DELETE FROM repos WHERE id = $1\", id)\n            .execute(pool)\n            .await?;\n        Ok(result.rows_affected())\n    }\n\n    pub async fn update(\n        pool: &SqlitePool,\n        id: Uuid,\n        payload: &UpdateRepo,\n    ) -> Result<Self, RepoError> {\n        let existing = Self::find_by_id(pool, id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n        // None = don't update (use existing)\n        // Some(None) = set to NULL\n        // Some(Some(v)) = set to v\n        let display_name = match &payload.display_name {\n            None => existing.display_name,\n            Some(v) => v.clone().unwrap_or_default(),\n        };\n        let setup_script = match &payload.setup_script {\n            None => existing.setup_script,\n            Some(v) => v.clone(),\n        };\n        let cleanup_script = match &payload.cleanup_script {\n            None => existing.cleanup_script,\n            Some(v) => v.clone(),\n        };\n        let archive_script = match &payload.archive_script {\n            None => existing.archive_script,\n            Some(v) => v.clone(),\n        };\n        let copy_files = match &payload.copy_files {\n            None => existing.copy_files,\n            Some(v) => v.clone(),\n        };\n        let parallel_setup_script = match &payload.parallel_setup_script {\n            None => existing.parallel_setup_script,\n            Some(v) => v.unwrap_or(false),\n        };\n        let dev_server_script = match &payload.dev_server_script {\n            None => existing.dev_server_script,\n            Some(v) => v.clone(),\n        };\n        let default_target_branch = match &payload.default_target_branch {\n            None => existing.default_target_branch,\n            Some(v) => v.clone(),\n        };\n        let default_working_dir = match &payload.default_working_dir {\n            None => existing.default_working_dir,\n            Some(v) => v.clone(),\n        };\n\n        sqlx::query_as!(\n            Repo,\n            r#\"UPDATE repos\n               SET display_name = $1,\n                   setup_script = $2,\n                   cleanup_script = $3,\n                   archive_script = $4,\n                   copy_files = $5,\n                   parallel_setup_script = $6,\n                   dev_server_script = $7,\n                   default_target_branch = $8,\n                   default_working_dir = $9,\n                   updated_at = datetime('now', 'subsec')\n               WHERE id = $10\n               RETURNING id as \"id!: Uuid\",\n                         path,\n                         name,\n                         display_name,\n                         setup_script,\n                         cleanup_script,\n                         archive_script,\n                         copy_files,\n                         parallel_setup_script as \"parallel_setup_script!: bool\",\n                         dev_server_script,\n                         default_target_branch,\n                         default_working_dir,\n                         created_at as \"created_at!: DateTime<Utc>\",\n                         updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            display_name,\n            setup_script,\n            cleanup_script,\n            archive_script,\n            copy_files,\n            parallel_setup_script,\n            dev_server_script,\n            default_target_branch,\n            default_working_dir,\n            id\n        )\n        .fetch_one(pool)\n        .await\n        .map_err(RepoError::from)\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/requests.rs",
    "content": "use executors::profile::ExecutorConfig;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse super::{execution_process::ExecutionProcess, workspace::Workspace};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct ContainerQuery {\n    #[serde(rename = \"ref\")]\n    pub container_ref: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct WorkspaceRepoInput {\n    pub repo_id: Uuid,\n    pub target_branch: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CreateWorkspaceApiRequest {\n    pub name: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct LinkedIssueInfo {\n    pub remote_project_id: Uuid,\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CreateAndStartWorkspaceRequest {\n    pub name: Option<String>,\n    pub repos: Vec<WorkspaceRepoInput>,\n    pub linked_issue: Option<LinkedIssueInfo>,\n    pub executor_config: ExecutorConfig,\n    pub prompt: String,\n    pub attachment_ids: Option<Vec<Uuid>>,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CreateAndStartWorkspaceResponse {\n    pub workspace: Workspace,\n    pub execution_process: ExecutionProcess,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct UpdateWorkspace {\n    pub archived: Option<bool>,\n    pub pinned: Option<bool>,\n    pub name: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct UpdateSession {\n    pub name: Option<String>,\n}\n"
  },
  {
    "path": "crates/db/src/models/scratch.rs",
    "content": "use chrono::{DateTime, Utc};\nuse executors::profile::ExecutorConfig;\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse strum_macros::{Display, EnumDiscriminants, EnumString};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum ScratchError {\n    #[error(transparent)]\n    Serde(#[from] serde_json::Error),\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(\"Scratch type mismatch: expected '{expected}' but got '{actual}'\")]\n    TypeMismatch { expected: String, actual: String },\n}\n\n/// Data for a draft follow-up scratch\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct DraftFollowUpData {\n    pub message: String,\n    #[serde(alias = \"executor_profile_id\", alias = \"config\")]\n    pub executor_config: ExecutorConfig,\n}\n\n/// Data for preview settings scratch (URL override and screen size)\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct PreviewSettingsData {\n    pub url: String,\n    #[serde(default)]\n    pub screen_size: Option<String>,\n    #[serde(default)]\n    pub responsive_width: Option<i32>,\n    #[serde(default)]\n    pub responsive_height: Option<i32>,\n}\n\n/// Data for workspace notes scratch\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct WorkspaceNotesData {\n    pub content: String,\n}\n\n/// Workspace-specific panel state\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct WorkspacePanelStateData {\n    pub right_main_panel_mode: Option<String>,\n    pub is_left_main_panel_visible: bool,\n}\n\n/// Workspace sidebar PR filter state\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum WorkspacePrFilterData {\n    #[default]\n    All,\n    HasPr,\n    NoPr,\n}\n\n/// Workspace sidebar sort field\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum WorkspaceSortByData {\n    #[default]\n    UpdatedAt,\n    CreatedAt,\n}\n\n/// Workspace sidebar sort order\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum WorkspaceSortOrderData {\n    Asc,\n    #[default]\n    Desc,\n}\n\n/// Workspace sidebar filter state\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\npub struct WorkspaceFilterStateData {\n    #[serde(default)]\n    pub project_ids: Vec<String>,\n    #[serde(default)]\n    pub pr_filter: WorkspacePrFilterData,\n}\n\n/// Workspace sidebar sort state\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\npub struct WorkspaceSortStateData {\n    #[serde(default)]\n    pub sort_by: WorkspaceSortByData,\n    #[serde(default)]\n    pub sort_order: WorkspaceSortOrderData,\n}\n\n/// Data for UI preferences scratch (global preferences stored per-user or per-device)\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct UiPreferencesData {\n    /// Preferred repo actions per repo\n    #[serde(default)]\n    pub repo_actions: std::collections::HashMap<String, String>,\n    /// Expanded/collapsed state for UI sections\n    #[serde(default)]\n    pub expanded: std::collections::HashMap<String, bool>,\n    /// Context bar position\n    #[serde(default)]\n    pub context_bar_position: Option<String>,\n    /// Pane sizes\n    #[serde(default)]\n    pub pane_sizes: std::collections::HashMap<String, serde_json::Value>,\n    /// Collapsed paths per workspace in file tree\n    #[serde(default)]\n    pub collapsed_paths: std::collections::HashMap<String, Vec<String>>,\n    /// Preferred file-search repo\n    #[serde(default)]\n    pub file_search_repo_id: Option<String>,\n    /// Global left sidebar visibility\n    #[serde(default)]\n    pub is_left_sidebar_visible: Option<bool>,\n    /// Global right sidebar visibility\n    #[serde(default)]\n    pub is_right_sidebar_visible: Option<bool>,\n    /// Global terminal visibility\n    #[serde(default)]\n    pub is_terminal_visible: Option<bool>,\n    /// Workspace-specific panel states\n    #[serde(default)]\n    pub workspace_panel_states: std::collections::HashMap<String, WorkspacePanelStateData>,\n    /// Workspace sidebar filter preferences\n    #[serde(default)]\n    pub workspace_filters: WorkspaceFilterStateData,\n    /// Workspace sidebar sort preferences\n    #[serde(default)]\n    pub workspace_sort: WorkspaceSortStateData,\n    /// Last selected organization ID\n    #[serde(default)]\n    pub selected_org_id: Option<String>,\n    /// Last selected project ID\n    #[serde(default)]\n    pub selected_project_id: Option<String>,\n    /// Default setting for creating a draft workspace from new issues\n    #[serde(default)]\n    pub create_draft_workspace_by_default: Option<bool>,\n}\n\n/// Linked issue data for draft workspace scratch\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct DraftWorkspaceLinkedIssue {\n    pub issue_id: String,\n    pub simple_id: String,\n    pub title: String,\n    pub remote_project_id: String,\n}\n\n/// Uploaded attachment stored in a draft workspace\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct DraftWorkspaceAttachment {\n    pub id: Uuid,\n    pub file_path: String,\n    pub original_name: String,\n    #[serde(default)]\n    pub mime_type: Option<String>,\n    pub size_bytes: i64,\n}\n\n/// Data for a draft workspace scratch (new workspace creation)\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct DraftWorkspaceData {\n    pub message: String,\n    #[serde(default)]\n    pub repos: Vec<DraftWorkspaceRepo>,\n    #[serde(default, alias = \"selected_profile\", alias = \"config\")]\n    pub executor_config: Option<ExecutorConfig>,\n    #[serde(default)]\n    pub linked_issue: Option<DraftWorkspaceLinkedIssue>,\n    #[serde(default)]\n    pub attachments: Vec<DraftWorkspaceAttachment>,\n}\n\n/// Repository entry in a draft workspace\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct DraftWorkspaceRepo {\n    pub repo_id: Uuid,\n    pub target_branch: String,\n}\n\n/// Data for project repo defaults scratch (default repos/branches per project)\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ProjectRepoDefaultsData {\n    pub repos: Vec<DraftWorkspaceRepo>,\n}\n\n/// Data for a draft issue scratch (issue creation on kanban board)\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct DraftIssueData {\n    #[serde(default)]\n    pub title: String,\n    #[serde(default)]\n    pub description: Option<String>,\n    pub status_id: String,\n    /// Stored as the string value of IssuePriority (e.g. \"urgent\", \"high\", \"medium\", \"low\")\n    #[serde(default)]\n    pub priority: Option<String>,\n    #[serde(default)]\n    pub assignee_ids: Vec<String>,\n    #[serde(default)]\n    pub tag_ids: Vec<String>,\n    #[serde(default)]\n    pub create_draft_workspace: bool,\n    /// The project this draft belongs to\n    pub project_id: String,\n    /// Parent issue ID if creating a sub-issue\n    #[serde(default)]\n    pub parent_issue_id: Option<String>,\n}\n\n/// The payload of a scratch, tagged by type. The type is part of the composite primary key.\n/// Data is stored as markdown string.\n#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumDiscriminants)]\n#[serde(tag = \"type\", content = \"data\", rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[strum_discriminants(name(ScratchType))]\n#[strum_discriminants(derive(Display, EnumString, Serialize, Deserialize, TS))]\n#[strum_discriminants(ts(use_ts_enum))]\n#[strum_discriminants(serde(rename_all = \"SCREAMING_SNAKE_CASE\"))]\n#[strum_discriminants(strum(serialize_all = \"SCREAMING_SNAKE_CASE\"))]\npub enum ScratchPayload {\n    DraftTask(String),\n    DraftFollowUp(DraftFollowUpData),\n    DraftWorkspace(DraftWorkspaceData),\n    DraftIssue(DraftIssueData),\n    PreviewSettings(PreviewSettingsData),\n    WorkspaceNotes(WorkspaceNotesData),\n    UiPreferences(UiPreferencesData),\n    ProjectRepoDefaults(ProjectRepoDefaultsData),\n}\n\nimpl ScratchPayload {\n    /// Returns the scratch type for this payload\n    pub fn scratch_type(&self) -> ScratchType {\n        ScratchType::from(self)\n    }\n\n    /// Validates that the payload type matches the expected type\n    pub fn validate_type(&self, expected: ScratchType) -> Result<(), ScratchError> {\n        let actual = self.scratch_type();\n        if actual != expected {\n            return Err(ScratchError::TypeMismatch {\n                expected: expected.to_string(),\n                actual: actual.to_string(),\n            });\n        }\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, FromRow)]\nstruct ScratchRow {\n    pub id: Uuid,\n    pub scratch_type: String,\n    pub payload: String,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct Scratch {\n    pub id: Uuid,\n    pub payload: ScratchPayload,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\nimpl Scratch {\n    /// Returns the scratch type derived from the payload\n    pub fn scratch_type(&self) -> ScratchType {\n        self.payload.scratch_type()\n    }\n}\n\nimpl TryFrom<ScratchRow> for Scratch {\n    type Error = ScratchError;\n    fn try_from(r: ScratchRow) -> Result<Self, ScratchError> {\n        let payload: ScratchPayload = serde_json::from_str(&r.payload)?;\n        payload.validate_type(r.scratch_type.parse().map_err(|_| {\n            ScratchError::TypeMismatch {\n                expected: r.scratch_type.clone(),\n                actual: payload.scratch_type().to_string(),\n            }\n        })?)?;\n        Ok(Scratch {\n            id: r.id,\n            payload,\n            created_at: r.created_at,\n            updated_at: r.updated_at,\n        })\n    }\n}\n\n/// Request body for creating a scratch (id comes from URL path, type from payload)\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CreateScratch {\n    pub payload: ScratchPayload,\n}\n\n/// Request body for updating a scratch\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct UpdateScratch {\n    pub payload: ScratchPayload,\n}\n\nimpl Scratch {\n    pub async fn create(\n        pool: &SqlitePool,\n        id: Uuid,\n        data: &CreateScratch,\n    ) -> Result<Self, ScratchError> {\n        let scratch_type_str = data.payload.scratch_type().to_string();\n        let payload_str = serde_json::to_string(&data.payload)?;\n\n        let row = sqlx::query_as!(\n            ScratchRow,\n            r#\"\n            INSERT INTO scratch (id, scratch_type, payload)\n            VALUES ($1, $2, $3)\n            RETURNING\n                id              as \"id!: Uuid\",\n                scratch_type,\n                payload,\n                created_at      as \"created_at!: DateTime<Utc>\",\n                updated_at      as \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            scratch_type_str,\n            payload_str,\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Scratch::try_from(row)\n    }\n\n    pub async fn find_by_id(\n        pool: &SqlitePool,\n        id: Uuid,\n        scratch_type: &ScratchType,\n    ) -> Result<Option<Self>, ScratchError> {\n        let scratch_type_str = scratch_type.to_string();\n        let row = sqlx::query_as!(\n            ScratchRow,\n            r#\"\n            SELECT\n                id              as \"id!: Uuid\",\n                scratch_type,\n                payload,\n                created_at      as \"created_at!: DateTime<Utc>\",\n                updated_at      as \"updated_at!: DateTime<Utc>\"\n            FROM scratch\n            WHERE id = $1 AND scratch_type = $2\n            \"#,\n            id,\n            scratch_type_str,\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        let scratch = row.map(Scratch::try_from).transpose()?;\n        Ok(scratch)\n    }\n\n    pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, ScratchError> {\n        let rows = sqlx::query_as!(\n            ScratchRow,\n            r#\"\n            SELECT\n                id              as \"id!: Uuid\",\n                scratch_type,\n                payload,\n                created_at      as \"created_at!: DateTime<Utc>\",\n                updated_at      as \"updated_at!: DateTime<Utc>\"\n            FROM scratch\n            ORDER BY created_at DESC\n            \"#\n        )\n        .fetch_all(pool)\n        .await?;\n\n        let scratches = rows\n            .into_iter()\n            .filter_map(|row| Scratch::try_from(row).ok())\n            .collect();\n\n        Ok(scratches)\n    }\n\n    /// Upsert a scratch record - creates if not exists, updates if exists.\n    pub async fn update(\n        pool: &SqlitePool,\n        id: Uuid,\n        scratch_type: &ScratchType,\n        data: &UpdateScratch,\n    ) -> Result<Self, ScratchError> {\n        let payload_str = serde_json::to_string(&data.payload)?;\n        let scratch_type_str = scratch_type.to_string();\n\n        // Upsert: insert if not exists, update if exists\n        let row = sqlx::query_as!(\n            ScratchRow,\n            r#\"\n            INSERT INTO scratch (id, scratch_type, payload)\n            VALUES ($1, $2, $3)\n            ON CONFLICT(id, scratch_type) DO UPDATE SET\n                payload = excluded.payload,\n                updated_at = datetime('now', 'subsec')\n            RETURNING\n                id              as \"id!: Uuid\",\n                scratch_type,\n                payload,\n                created_at      as \"created_at!: DateTime<Utc>\",\n                updated_at      as \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            scratch_type_str,\n            payload_str,\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Scratch::try_from(row)\n    }\n\n    pub async fn delete(\n        pool: &SqlitePool,\n        id: Uuid,\n        scratch_type: &ScratchType,\n    ) -> Result<u64, sqlx::Error> {\n        let scratch_type_str = scratch_type.to_string();\n        let result = sqlx::query!(\n            \"DELETE FROM scratch WHERE id = $1 AND scratch_type = $2\",\n            id,\n            scratch_type_str\n        )\n        .execute(pool)\n        .await?;\n        Ok(result.rows_affected())\n    }\n\n    pub async fn find_by_rowid(\n        pool: &SqlitePool,\n        rowid: i64,\n    ) -> Result<Option<Self>, ScratchError> {\n        let row = sqlx::query_as!(\n            ScratchRow,\n            r#\"\n            SELECT\n                id              as \"id!: Uuid\",\n                scratch_type,\n                payload,\n                created_at      as \"created_at!: DateTime<Utc>\",\n                updated_at      as \"updated_at!: DateTime<Utc>\"\n            FROM scratch\n            WHERE rowid = $1\n            \"#,\n            rowid\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        let scratch = row.map(Scratch::try_from).transpose()?;\n        Ok(scratch)\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/session.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse super::workspace_repo::WorkspaceRepo;\n\n#[derive(Debug, Error)]\npub enum SessionError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(\"Session not found\")]\n    NotFound,\n    #[error(\"Workspace not found\")]\n    WorkspaceNotFound,\n    #[error(\"Executor mismatch: session uses {expected} but request specified {actual}\")]\n    ExecutorMismatch { expected: String, actual: String },\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct Session {\n    pub id: Uuid,\n    pub workspace_id: Uuid,\n    pub name: Option<String>,\n    pub executor: Option<String>,\n    pub agent_working_dir: Option<String>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateSession {\n    pub executor: Option<String>,\n    pub name: Option<String>,\n}\n\nimpl Session {\n    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Session,\n            r#\"SELECT id AS \"id!: Uuid\",\n                      workspace_id AS \"workspace_id!: Uuid\",\n                      name,\n                      executor,\n                      agent_working_dir,\n                      created_at AS \"created_at!: DateTime<Utc>\",\n                      updated_at AS \"updated_at!: DateTime<Utc>\"\n               FROM sessions\n               WHERE id = $1\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Find all sessions for a workspace, ordered by most recently used.\n    /// \"Most recently used\" is defined as the most recent non-dev server execution process.\n    /// Sessions with no executions fall back to created_at for ordering.\n    pub async fn find_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Session,\n            r#\"SELECT s.id AS \"id!: Uuid\",\n                      s.workspace_id AS \"workspace_id!: Uuid\",\n                      s.name,\n                      s.executor,\n                      s.agent_working_dir,\n                      s.created_at AS \"created_at!: DateTime<Utc>\",\n                      s.updated_at AS \"updated_at!: DateTime<Utc>\"\n               FROM sessions s\n               LEFT JOIN (\n                   SELECT ep.session_id, MAX(ep.created_at) as last_used\n                   FROM execution_processes ep\n                   WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE\n                   GROUP BY ep.session_id\n               ) latest_ep ON s.id = latest_ep.session_id\n               WHERE s.workspace_id = $1\n               ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    /// Find the most recently used session for a workspace.\n    /// \"Most recently used\" is defined as the most recent non-dev server execution process.\n    /// Sessions with no executions fall back to created_at for ordering.\n    pub async fn find_latest_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Session,\n            r#\"SELECT s.id AS \"id!: Uuid\",\n                      s.workspace_id AS \"workspace_id!: Uuid\",\n                      s.name,\n                      s.executor,\n                      s.agent_working_dir,\n                      s.created_at AS \"created_at!: DateTime<Utc>\",\n                      s.updated_at AS \"updated_at!: DateTime<Utc>\"\n               FROM sessions s\n               LEFT JOIN (\n                   SELECT ep.session_id, MAX(ep.created_at) as last_used\n                   FROM execution_processes ep\n                   WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE\n                   GROUP BY ep.session_id\n               ) latest_ep ON s.id = latest_ep.session_id\n               WHERE s.workspace_id = $1\n               ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC\n               LIMIT 1\"#,\n            workspace_id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    /// Find the first-created session for a workspace.\n    /// This is a temporary policy for orchestrator MCP session discovery.\n    pub async fn find_first_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as::<_, Session>(\n            r#\"SELECT id,\n                      workspace_id,\n                      name,\n                      executor,\n                      agent_working_dir,\n                      created_at,\n                      updated_at\n               FROM sessions\n               WHERE workspace_id = ?\n               ORDER BY created_at ASC, id ASC\n               LIMIT 1\"#,\n        )\n        .bind(workspace_id)\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn create(\n        pool: &SqlitePool,\n        data: &CreateSession,\n        id: Uuid,\n        workspace_id: Uuid,\n    ) -> Result<Self, SessionError> {\n        let agent_working_dir = Self::resolve_agent_working_dir(pool, workspace_id).await?;\n        let name = data.name.as_deref().filter(|s| !s.is_empty());\n\n        Ok(sqlx::query_as!(\n            Session,\n            r#\"INSERT INTO sessions (id, workspace_id, name, executor, agent_working_dir)\n               VALUES ($1, $2, $3, $4, $5)\n               RETURNING id AS \"id!: Uuid\",\n                         workspace_id AS \"workspace_id!: Uuid\",\n                         name,\n                         executor,\n                         agent_working_dir,\n                         created_at AS \"created_at!: DateTime<Utc>\",\n                         updated_at AS \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            workspace_id,\n            name,\n            data.executor,\n            agent_working_dir\n        )\n        .fetch_one(pool)\n        .await?)\n    }\n\n    async fn resolve_agent_working_dir(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Option<String>, sqlx::Error> {\n        let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace_id).await?;\n        if repos.len() != 1 {\n            return Ok(None);\n        }\n\n        let repo = &repos[0];\n        let path = match repo.default_working_dir.as_deref() {\n            Some(subdir) if !subdir.is_empty() => std::path::PathBuf::from(&repo.name).join(subdir),\n            _ => std::path::PathBuf::from(&repo.name),\n        };\n\n        Ok(Some(path.to_string_lossy().to_string()))\n    }\n\n    pub async fn update(\n        pool: &SqlitePool,\n        id: Uuid,\n        name: Option<&str>,\n    ) -> Result<(), sqlx::Error> {\n        let name_value = name.filter(|s| !s.is_empty());\n        let name_provided = name.is_some();\n\n        sqlx::query!(\n            r#\"UPDATE sessions SET\n                name = CASE WHEN $1 THEN $2 ELSE name END,\n                updated_at = datetime('now', 'subsec')\n            WHERE id = $3\"#,\n            name_provided,\n            name_value,\n            id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn update_executor(\n        pool: &SqlitePool,\n        id: Uuid,\n        executor: &str,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            r#\"UPDATE sessions SET executor = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2\"#,\n            executor,\n            id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/tag.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct Tag {\n    pub id: Uuid,\n    pub tag_name: String,\n    pub content: String,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateTag {\n    pub tag_name: String,\n    pub content: String,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct UpdateTag {\n    pub tag_name: Option<String>,\n    pub content: Option<String>,\n}\n\nimpl Tag {\n    pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Tag,\n            r#\"SELECT id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM tags\n               ORDER BY tag_name ASC\"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Tag,\n            r#\"SELECT id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM tags\n               WHERE id = $1\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn create(pool: &SqlitePool, data: &CreateTag) -> Result<Self, sqlx::Error> {\n        let id = Uuid::new_v4();\n        sqlx::query_as!(\n            Tag,\n            r#\"INSERT INTO tags (id, tag_name, content)\n               VALUES ($1, $2, $3)\n               RETURNING id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            data.tag_name,\n            data.content\n        )\n        .fetch_one(pool)\n        .await\n    }\n\n    pub async fn update(\n        pool: &SqlitePool,\n        id: Uuid,\n        data: &UpdateTag,\n    ) -> Result<Self, sqlx::Error> {\n        let existing = Self::find_by_id(pool, id)\n            .await?\n            .ok_or(sqlx::Error::RowNotFound)?;\n\n        let tag_name = data.tag_name.as_ref().unwrap_or(&existing.tag_name);\n        let content = data.content.as_ref().unwrap_or(&existing.content);\n\n        sqlx::query_as!(\n            Tag,\n            r#\"UPDATE tags\n               SET tag_name = $2, content = $3, updated_at = datetime('now', 'subsec')\n               WHERE id = $1\n               RETURNING id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n            id,\n            tag_name,\n            content\n        )\n        .fetch_one(pool)\n        .await\n    }\n\n    pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<u64, sqlx::Error> {\n        let result = sqlx::query!(\"DELETE FROM tags WHERE id = $1\", id)\n            .execute(pool)\n            .await?;\n        Ok(result.rows_affected())\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/task.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool, Type};\nuse strum_macros::{Display, EnumString};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(\n    Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS, EnumString, Display, Default,\n)]\n#[sqlx(type_name = \"task_status\", rename_all = \"lowercase\")]\n#[serde(rename_all = \"lowercase\")]\n#[strum(serialize_all = \"lowercase\")]\npub enum TaskStatus {\n    #[default]\n    Todo,\n    InProgress,\n    InReview,\n    Done,\n    Cancelled,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct Task {\n    pub id: Uuid,\n    pub project_id: Uuid, // Foreign key to Project\n    pub title: String,\n    pub description: Option<String>,\n    pub status: TaskStatus,\n    pub parent_workspace_id: Option<Uuid>, // Foreign key to parent Workspace\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\nimpl Task {\n    pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Task,\n            r#\"SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM tasks\n               ORDER BY created_at ASC\"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Task,\n            r#\"SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM tasks\n               WHERE id = $1\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/workspace.rs",
    "content": "use chrono::{DateTime, Utc};\nuse executors::actions::{ExecutorAction, ExecutorActionType};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n/// Maximum length for auto-generated workspace names (derived from first user prompt)\nconst WORKSPACE_NAME_MAX_LEN: usize = 60;\n\nuse super::{\n    execution_process::ExecutorActionField,\n    session::Session,\n    workspace_repo::{RepoWithTargetBranch, WorkspaceRepo},\n};\n\n#[derive(Debug, Error)]\npub enum WorkspaceError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(\"Workspace not found\")]\n    WorkspaceNotFound,\n    #[error(\"Validation error: {0}\")]\n    ValidationError(String),\n    #[error(\"Branch not found: {0}\")]\n    BranchNotFound(String),\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct ContainerInfo {\n    pub workspace_id: Uuid,\n}\n\n#[derive(Debug)]\nstruct WorkspaceContainerRefRow {\n    id: Uuid,\n    container_ref: String,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct Workspace {\n    pub id: Uuid,\n    pub task_id: Option<Uuid>,\n    pub container_ref: Option<String>,\n    pub branch: String,\n    pub setup_completed_at: Option<DateTime<Utc>>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n    pub archived: bool,\n    pub pinned: bool,\n    pub name: Option<String>,\n    pub worktree_deleted: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct WorkspaceWithStatus {\n    #[serde(flatten)]\n    #[ts(flatten)]\n    pub workspace: Workspace,\n    pub is_running: bool,\n    pub is_errored: bool,\n}\n\nimpl std::ops::Deref for WorkspaceWithStatus {\n    type Target = Workspace;\n    fn deref(&self) -> &Self::Target {\n        &self.workspace\n    }\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateFollowUpAttempt {\n    pub prompt: String,\n}\n\n/// Context data for resume operations (simplified)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AttemptResumeContext {\n    pub execution_history: String,\n    pub cumulative_diffs: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WorkspaceContext {\n    pub workspace: Workspace,\n    pub workspace_repos: Vec<RepoWithTargetBranch>,\n    pub orchestrator_session_id: Option<Uuid>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateWorkspace {\n    pub branch: String,\n    pub name: Option<String>,\n}\n\nimpl Workspace {\n    /// Fetch all workspaces. Newest first.\n    pub async fn fetch_all(pool: &SqlitePool) -> Result<Vec<Self>, WorkspaceError> {\n        let workspaces = sqlx::query_as!(\n            Workspace,\n            r#\"SELECT id AS \"id!: Uuid\",\n                          task_id AS \"task_id: Uuid\",\n                          container_ref,\n                          branch,\n                          setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n                          created_at AS \"created_at!: DateTime<Utc>\",\n                          updated_at AS \"updated_at!: DateTime<Utc>\",\n                          archived AS \"archived!: bool\",\n                          pinned AS \"pinned!: bool\",\n                          name,\n                          worktree_deleted AS \"worktree_deleted!: bool\"\n                   FROM workspaces\n                   ORDER BY created_at DESC\"#\n        )\n        .fetch_all(pool)\n        .await\n        .map_err(WorkspaceError::Database)?;\n\n        Ok(workspaces)\n    }\n\n    /// Load full workspace context by workspace ID.\n    pub async fn load_context(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<WorkspaceContext, WorkspaceError> {\n        let workspace = Workspace::find_by_id(pool, workspace_id)\n            .await?\n            .ok_or(WorkspaceError::WorkspaceNotFound)?;\n\n        let workspace_repos =\n            WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace_id).await?;\n        let orchestrator_session_id = Session::find_first_by_workspace_id(pool, workspace_id)\n            .await?\n            .map(|session| session.id);\n\n        Ok(WorkspaceContext {\n            workspace,\n            workspace_repos,\n            orchestrator_session_id,\n        })\n    }\n\n    /// Update container reference\n    pub async fn update_container_ref(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        container_ref: &str,\n    ) -> Result<(), sqlx::Error> {\n        let now = Utc::now();\n        sqlx::query!(\n            \"UPDATE workspaces SET container_ref = $1, updated_at = $2 WHERE id = $3\",\n            container_ref,\n            now,\n            workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn mark_worktree_deleted(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            \"UPDATE workspaces SET worktree_deleted = TRUE, updated_at = datetime('now') WHERE id = ?\",\n            workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn clear_worktree_deleted(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            \"UPDATE workspaces SET worktree_deleted = FALSE, updated_at = datetime('now') WHERE id = ?\",\n            workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    /// Update the workspace's updated_at timestamp to prevent cleanup.\n    /// Call this when the workspace is accessed (e.g., opened in editor).\n    pub async fn touch(pool: &SqlitePool, workspace_id: Uuid) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            \"UPDATE workspaces SET updated_at = datetime('now', 'subsec') WHERE id = ?\",\n            workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Workspace,\n            r#\"SELECT  id                AS \"id!: Uuid\",\n                       task_id           AS \"task_id: Uuid\",\n                       container_ref,\n                       branch,\n                       setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n                       created_at        AS \"created_at!: DateTime<Utc>\",\n                       updated_at        AS \"updated_at!: DateTime<Utc>\",\n                       archived          AS \"archived!: bool\",\n                       pinned            AS \"pinned!: bool\",\n                       name,\n                       worktree_deleted  AS \"worktree_deleted!: bool\"\n               FROM    workspaces\n               WHERE   id = $1\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            Workspace,\n            r#\"SELECT  id                AS \"id!: Uuid\",\n                       task_id           AS \"task_id: Uuid\",\n                       container_ref,\n                       branch,\n                       setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n                       created_at        AS \"created_at!: DateTime<Utc>\",\n                       updated_at        AS \"updated_at!: DateTime<Utc>\",\n                       archived          AS \"archived!: bool\",\n                       pinned            AS \"pinned!: bool\",\n                       name,\n                       worktree_deleted  AS \"worktree_deleted!: bool\"\n               FROM    workspaces\n               WHERE   rowid = $1\"#,\n            rowid\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn container_ref_exists(\n        pool: &SqlitePool,\n        container_ref: &str,\n    ) -> Result<bool, sqlx::Error> {\n        let result = sqlx::query!(\n            r#\"SELECT EXISTS(SELECT 1 FROM workspaces WHERE container_ref = ?) as \"exists!: bool\"\"#,\n            container_ref\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(result.exists)\n    }\n\n    /// Find workspaces that are expired and eligible for cleanup.\n    /// Uses accelerated cleanup (1 hour) for archived workspaces.\n    /// Uses standard cleanup (72 hours) for non-archived workspaces.\n    pub async fn find_expired_for_cleanup(\n        pool: &SqlitePool,\n    ) -> Result<Vec<Workspace>, sqlx::Error> {\n        sqlx::query_as!(\n            Workspace,\n            r#\"\n            SELECT\n                w.id as \"id!: Uuid\",\n                w.task_id as \"task_id: Uuid\",\n                w.container_ref,\n                w.branch as \"branch!\",\n                w.setup_completed_at as \"setup_completed_at: DateTime<Utc>\",\n                w.created_at as \"created_at!: DateTime<Utc>\",\n                w.updated_at as \"updated_at!: DateTime<Utc>\",\n                w.archived as \"archived!: bool\",\n                w.pinned as \"pinned!: bool\",\n                w.name,\n                w.worktree_deleted as \"worktree_deleted!: bool\"\n            FROM workspaces w\n            LEFT JOIN sessions s ON w.id = s.workspace_id\n            LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL\n            WHERE w.container_ref IS NOT NULL\n                AND w.worktree_deleted = FALSE\n                AND w.id NOT IN (\n                    SELECT DISTINCT s2.workspace_id\n                    FROM sessions s2\n                    JOIN execution_processes ep2 ON s2.id = ep2.session_id\n                    WHERE ep2.completed_at IS NULL\n                )\n            GROUP BY w.id, w.container_ref, w.updated_at\n            HAVING datetime('now', 'localtime',\n                CASE\n                    WHEN w.archived = 1\n                    THEN '-1 hours'\n                    ELSE '-72 hours'\n                END\n            ) > datetime(\n                MAX(\n                    max(\n                        datetime(w.updated_at),\n                        datetime(ep.completed_at)\n                    )\n                )\n            )\n            ORDER BY MAX(\n                CASE\n                    WHEN ep.completed_at IS NOT NULL THEN ep.completed_at\n                    ELSE w.updated_at\n                END\n            ) ASC\n            \"#\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn create(\n        pool: &SqlitePool,\n        data: &CreateWorkspace,\n        id: Uuid,\n    ) -> Result<Self, WorkspaceError> {\n        Ok(sqlx::query_as!(\n            Workspace,\n            r#\"INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name)\n               VALUES ($1, $2, $3, $4, $5, $6)\n               RETURNING id as \"id!: Uuid\", task_id as \"task_id: Uuid\", container_ref, branch, setup_completed_at as \"setup_completed_at: DateTime<Utc>\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\", archived as \"archived!: bool\", pinned as \"pinned!: bool\", name, worktree_deleted as \"worktree_deleted!: bool\"\"#,\n            id,\n            Option::<Uuid>::None,\n            Option::<String>::None,\n            data.branch,\n            Option::<DateTime<Utc>>::None,\n            data.name\n        )\n        .fetch_one(pool)\n        .await?)\n    }\n\n    pub async fn update_branch_name(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        new_branch_name: &str,\n    ) -> Result<(), WorkspaceError> {\n        sqlx::query!(\n            \"UPDATE workspaces SET branch = $1, updated_at = datetime('now') WHERE id = $2\",\n            new_branch_name,\n            workspace_id,\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub async fn resolve_container_ref(\n        pool: &SqlitePool,\n        container_ref: &str,\n    ) -> Result<ContainerInfo, sqlx::Error> {\n        let result = sqlx::query!(\n            r#\"SELECT w.id as \"workspace_id!: Uuid\"\n               FROM workspaces w\n               WHERE w.container_ref = ?\"#,\n            container_ref\n        )\n        .fetch_optional(pool)\n        .await?\n        .ok_or(sqlx::Error::RowNotFound)?;\n\n        Ok(ContainerInfo {\n            workspace_id: result.workspace_id,\n        })\n    }\n\n    /// Find workspace by path using container-ref path containment.\n    /// Used by clients that may open a repo subfolder rather than the workspace root.\n    pub async fn resolve_container_ref_by_prefix(\n        pool: &SqlitePool,\n        path: &str,\n    ) -> Result<ContainerInfo, sqlx::Error> {\n        let workspaces = sqlx::query_as!(\n            WorkspaceContainerRefRow,\n            r#\"SELECT id as \"id!: Uuid\",\n                      container_ref as \"container_ref!\"\n               FROM workspaces\n               WHERE container_ref IS NOT NULL\"#,\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Self::best_matching_container_ref(\n            path,\n            workspaces\n                .iter()\n                .map(|ws| (ws.id, ws.container_ref.as_str())),\n        )\n        .map(|workspace_id| ContainerInfo { workspace_id })\n        .ok_or(sqlx::Error::RowNotFound)\n    }\n\n    fn best_matching_container_ref<'a>(\n        path: &str,\n        candidates: impl Iterator<Item = (Uuid, &'a str)>,\n    ) -> Option<Uuid> {\n        let path = std::path::Path::new(path);\n\n        candidates\n            .filter(|(_, container_ref)| {\n                let container_ref = std::path::Path::new(container_ref);\n                path.starts_with(container_ref) || container_ref.starts_with(path)\n            })\n            .max_by_key(|(_, container_ref)| {\n                std::path::Path::new(container_ref).components().count()\n            })\n            .map(|(workspace_id, _)| workspace_id)\n    }\n\n    pub async fn set_archived(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        archived: bool,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            \"UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2\",\n            archived,\n            workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    /// Update workspace fields. Only non-None values will be updated.\n    /// For `name`, pass `Some(\"\")` to clear the name, `Some(\"foo\")` to set it, or `None` to leave unchanged.\n    pub async fn update(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        archived: Option<bool>,\n        pinned: Option<bool>,\n        name: Option<&str>,\n    ) -> Result<(), sqlx::Error> {\n        // Convert empty string to None for name field (to store as NULL)\n        let name_value = name.filter(|s| !s.is_empty());\n        let name_provided = name.is_some();\n\n        sqlx::query!(\n            r#\"UPDATE workspaces SET\n                archived = COALESCE($1, archived),\n                pinned = COALESCE($2, pinned),\n                name = CASE WHEN $3 THEN $4 ELSE name END,\n                updated_at = datetime('now', 'subsec')\n            WHERE id = $5\"#,\n            archived,\n            pinned,\n            name_provided,\n            name_value,\n            workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn get_first_user_message(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Option<String>, sqlx::Error> {\n        let actions = sqlx::query_scalar!(\n            r#\"SELECT ep.executor_action as \"executor_action!: sqlx::types::Json<ExecutorActionField>\"\n               FROM sessions s\n               JOIN execution_processes ep ON ep.session_id = s.id\n               WHERE s.workspace_id = $1\n               ORDER BY s.created_at ASC, ep.created_at ASC\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        for action in actions {\n            if let ExecutorActionField::ExecutorAction(action) = action.0\n                && let Some(prompt) = Self::extract_first_prompt_from_executor_action(&action)\n            {\n                return Ok(Some(prompt));\n            }\n        }\n\n        Ok(None)\n    }\n\n    fn extract_first_prompt_from_executor_action(action: &ExecutorAction) -> Option<String> {\n        let mut current = Some(action);\n        while let Some(action) = current {\n            match action.typ() {\n                ExecutorActionType::CodingAgentInitialRequest(request) => {\n                    return Some(request.prompt.clone());\n                }\n                ExecutorActionType::CodingAgentFollowUpRequest(request) => {\n                    return Some(request.prompt.clone());\n                }\n                ExecutorActionType::ReviewRequest(request) => {\n                    return Some(request.prompt.clone());\n                }\n                ExecutorActionType::ScriptRequest(_) => {\n                    current = action.next_action();\n                }\n            }\n        }\n        None\n    }\n\n    pub fn truncate_to_name(prompt: &str, max_len: usize) -> String {\n        let trimmed = prompt.trim();\n        if trimmed.chars().count() <= max_len {\n            trimmed.to_string()\n        } else {\n            let truncated: String = trimmed.chars().take(max_len).collect();\n            if let Some(last_space) = truncated.rfind(' ') {\n                format!(\"{}...\", &truncated[..last_space])\n            } else {\n                format!(\"{}...\", truncated)\n            }\n        }\n    }\n\n    pub async fn find_all_with_status(\n        pool: &SqlitePool,\n        archived: Option<bool>,\n        limit: Option<i64>,\n    ) -> Result<Vec<WorkspaceWithStatus>, sqlx::Error> {\n        // Fetch all workspaces with status (uses cached SQLx query)\n        let records = sqlx::query!(\n            r#\"SELECT\n                w.id AS \"id!: Uuid\",\n                w.task_id AS \"task_id: Uuid\",\n                w.container_ref,\n                w.branch,\n                w.setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n                w.created_at AS \"created_at!: DateTime<Utc>\",\n                w.updated_at AS \"updated_at!: DateTime<Utc>\",\n                w.archived AS \"archived!: bool\",\n                w.pinned AS \"pinned!: bool\",\n                w.name,\n                w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n                CASE WHEN EXISTS (\n                    SELECT 1\n                    FROM sessions s\n                    JOIN execution_processes ep ON ep.session_id = s.id\n                    WHERE s.workspace_id = w.id\n                      AND ep.status = 'running'\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n                    LIMIT 1\n                ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n                CASE WHEN (\n                    SELECT ep.status\n                    FROM sessions s\n                    JOIN execution_processes ep ON ep.session_id = s.id\n                    WHERE s.workspace_id = w.id\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n                    ORDER BY ep.created_at DESC\n                    LIMIT 1\n                ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n            FROM workspaces w\n            ORDER BY w.updated_at DESC\"#\n        )\n        .fetch_all(pool)\n        .await?;\n\n        let mut workspaces: Vec<WorkspaceWithStatus> = records\n            .into_iter()\n            .map(|rec| WorkspaceWithStatus {\n                workspace: Workspace {\n                    id: rec.id,\n                    task_id: rec.task_id,\n                    container_ref: rec.container_ref,\n                    branch: rec.branch,\n                    setup_completed_at: rec.setup_completed_at,\n                    created_at: rec.created_at,\n                    updated_at: rec.updated_at,\n                    archived: rec.archived,\n                    pinned: rec.pinned,\n                    name: rec.name,\n                    worktree_deleted: rec.worktree_deleted,\n                },\n                is_running: rec.is_running != 0,\n                is_errored: rec.is_errored != 0,\n            })\n            // Apply archived filter if provided\n            .filter(|ws| archived.is_none_or(|a| ws.workspace.archived == a))\n            .collect();\n\n        // Apply limit if provided (already sorted by updated_at DESC from query)\n        if let Some(lim) = limit {\n            workspaces.truncate(lim as usize);\n        }\n\n        for ws in &mut workspaces {\n            if ws.workspace.name.is_none()\n                && let Some(prompt) = Self::get_first_user_message(pool, ws.workspace.id).await?\n            {\n                let name = Self::truncate_to_name(&prompt, WORKSPACE_NAME_MAX_LEN);\n                Self::update(pool, ws.workspace.id, None, None, Some(&name)).await?;\n                ws.workspace.name = Some(name);\n            }\n        }\n\n        Ok(workspaces)\n    }\n\n    /// Delete a workspace by ID\n    pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<u64, sqlx::Error> {\n        let result = sqlx::query!(\"DELETE FROM workspaces WHERE id = $1\", id)\n            .execute(pool)\n            .await?;\n        Ok(result.rows_affected())\n    }\n\n    /// Count total workspaces across all projects\n    pub async fn count_all(pool: &SqlitePool) -> Result<i64, WorkspaceError> {\n        sqlx::query_scalar!(r#\"SELECT COUNT(*) as \"count!: i64\" FROM workspaces\"#)\n            .fetch_one(pool)\n            .await\n            .map_err(WorkspaceError::Database)\n    }\n\n    pub async fn find_by_id_with_status(\n        pool: &SqlitePool,\n        id: Uuid,\n    ) -> Result<Option<WorkspaceWithStatus>, sqlx::Error> {\n        let rec = sqlx::query!(\n            r#\"SELECT\n                w.id AS \"id!: Uuid\",\n                w.task_id AS \"task_id: Uuid\",\n                w.container_ref,\n                w.branch,\n                w.setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n                w.created_at AS \"created_at!: DateTime<Utc>\",\n                w.updated_at AS \"updated_at!: DateTime<Utc>\",\n                w.archived AS \"archived!: bool\",\n                w.pinned AS \"pinned!: bool\",\n                w.name,\n                w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n                CASE WHEN EXISTS (\n                    SELECT 1\n                    FROM sessions s\n                    JOIN execution_processes ep ON ep.session_id = s.id\n                    WHERE s.workspace_id = w.id\n                      AND ep.status = 'running'\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n                    LIMIT 1\n                ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n                CASE WHEN (\n                    SELECT ep.status\n                    FROM sessions s\n                    JOIN execution_processes ep ON ep.session_id = s.id\n                    WHERE s.workspace_id = w.id\n                      AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n                    ORDER BY ep.created_at DESC\n                    LIMIT 1\n                ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n            FROM workspaces w\n            WHERE w.id = $1\"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        let Some(rec) = rec else {\n            return Ok(None);\n        };\n\n        let mut ws = WorkspaceWithStatus {\n            workspace: Workspace {\n                id: rec.id,\n                task_id: rec.task_id,\n                container_ref: rec.container_ref,\n                branch: rec.branch,\n                setup_completed_at: rec.setup_completed_at,\n                created_at: rec.created_at,\n                updated_at: rec.updated_at,\n                archived: rec.archived,\n                pinned: rec.pinned,\n                name: rec.name,\n                worktree_deleted: rec.worktree_deleted,\n            },\n            is_running: rec.is_running != 0,\n            is_errored: rec.is_errored != 0,\n        };\n\n        if ws.workspace.name.is_none()\n            && let Some(prompt) = Self::get_first_user_message(pool, ws.workspace.id).await?\n        {\n            let name = Self::truncate_to_name(&prompt, WORKSPACE_NAME_MAX_LEN);\n            Self::update(pool, ws.workspace.id, None, None, Some(&name)).await?;\n            ws.workspace.name = Some(name);\n        }\n\n        Ok(Some(ws))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use uuid::Uuid;\n\n    use super::Workspace;\n\n    #[test]\n    fn best_matching_container_ref_prefers_deepest_match() {\n        let broad_id = Uuid::new_v4();\n        let exact_id = Uuid::new_v4();\n        let selected = Workspace::best_matching_container_ref(\n            \"/tmp/ws/repo/packages/app\",\n            [(broad_id, \"/tmp\"), (exact_id, \"/tmp/ws\")].into_iter(),\n        );\n\n        assert_eq!(selected, Some(exact_id));\n    }\n\n    #[test]\n    fn best_matching_container_ref_supports_parent_request_path() {\n        let workspace_id = Uuid::new_v4();\n        let selected = Workspace::best_matching_container_ref(\n            \"/tmp/ws/repo\",\n            [(workspace_id, \"/tmp/ws/repo/packages/app\")].into_iter(),\n        );\n\n        assert_eq!(selected, Some(workspace_id));\n    }\n\n    #[test]\n    fn best_matching_container_ref_ignores_unrelated_paths() {\n        let workspace_id = Uuid::new_v4();\n        let selected = Workspace::best_matching_container_ref(\n            \"/tmp/other/path\",\n            [(workspace_id, \"/tmp/ws\")].into_iter(),\n        );\n\n        assert_eq!(selected, None);\n    }\n}\n"
  },
  {
    "path": "crates/db/src/models/workspace_repo.rs",
    "content": "use std::path::PathBuf;\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::{FromRow, SqlitePool};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse super::repo::Repo;\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]\npub struct WorkspaceRepo {\n    pub id: Uuid,\n    pub workspace_id: Uuid,\n    pub repo_id: Uuid,\n    pub target_branch: String,\n    #[ts(type = \"Date\")]\n    pub created_at: DateTime<Utc>,\n    #[ts(type = \"Date\")]\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Deserialize, TS)]\npub struct CreateWorkspaceRepo {\n    pub repo_id: Uuid,\n    pub target_branch: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct RepoWithTargetBranch {\n    #[serde(flatten)]\n    pub repo: Repo,\n    pub target_branch: String,\n}\n\n/// Repo info with copy_files configuration.\n#[derive(Debug, Clone)]\npub struct RepoWithCopyFiles {\n    pub id: Uuid,\n    pub path: PathBuf,\n    pub name: String,\n    pub copy_files: Option<String>,\n}\n\nimpl WorkspaceRepo {\n    pub async fn create_many(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        repos: &[CreateWorkspaceRepo],\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        if repos.is_empty() {\n            return Ok(Vec::new());\n        }\n\n        // Build bulk insert query with VALUES for each repo\n        // SQLite doesn't have great support for bulk inserts with RETURNING,\n        // so we'll use a transaction to batch the inserts efficiently\n        let mut tx = pool.begin().await?;\n        let mut results = Vec::with_capacity(repos.len());\n\n        for repo in repos {\n            let id = Uuid::new_v4();\n            let workspace_repo = sqlx::query_as!(\n                WorkspaceRepo,\n                r#\"INSERT INTO workspace_repos (id, workspace_id, repo_id, target_branch)\n                   VALUES ($1, $2, $3, $4)\n                   RETURNING id as \"id!: Uuid\",\n                             workspace_id as \"workspace_id!: Uuid\",\n                             repo_id as \"repo_id!: Uuid\",\n                             target_branch,\n                             created_at as \"created_at!: DateTime<Utc>\",\n                             updated_at as \"updated_at!: DateTime<Utc>\"\"#,\n                id,\n                workspace_id,\n                repo.repo_id,\n                repo.target_branch\n            )\n            .fetch_one(&mut *tx)\n            .await?;\n            results.push(workspace_repo);\n        }\n\n        tx.commit().await?;\n        Ok(results)\n    }\n\n    pub async fn find_by_workspace_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            WorkspaceRepo,\n            r#\"SELECT id as \"id!: Uuid\",\n                      workspace_id as \"workspace_id!: Uuid\",\n                      repo_id as \"repo_id!: Uuid\",\n                      target_branch,\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM workspace_repos\n               WHERE workspace_id = $1\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn find_repos_for_workspace(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<Repo>, sqlx::Error> {\n        sqlx::query_as!(\n            Repo,\n            r#\"SELECT r.id as \"id!: Uuid\",\n                      r.path,\n                      r.name,\n                      r.display_name,\n                      r.setup_script,\n                      r.cleanup_script,\n                      r.archive_script,\n                      r.copy_files,\n                      r.parallel_setup_script as \"parallel_setup_script!: bool\",\n                      r.dev_server_script,\n                      r.default_target_branch,\n                      r.default_working_dir,\n                      r.created_at as \"created_at!: DateTime<Utc>\",\n                      r.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM repos r\n               JOIN workspace_repos wr ON r.id = wr.repo_id\n               WHERE wr.workspace_id = $1\n               ORDER BY r.display_name ASC\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn find_repos_with_target_branch_for_workspace(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<RepoWithTargetBranch>, sqlx::Error> {\n        let rows = sqlx::query!(\n            r#\"SELECT r.id as \"id!: Uuid\",\n                      r.path,\n                      r.name,\n                      r.display_name,\n                      r.setup_script,\n                      r.cleanup_script,\n                      r.archive_script,\n                      r.copy_files,\n                      r.parallel_setup_script as \"parallel_setup_script!: bool\",\n                      r.dev_server_script,\n                      r.default_target_branch,\n                      r.default_working_dir,\n                      r.created_at as \"created_at!: DateTime<Utc>\",\n                      r.updated_at as \"updated_at!: DateTime<Utc>\",\n                      wr.target_branch\n               FROM repos r\n               JOIN workspace_repos wr ON r.id = wr.repo_id\n               WHERE wr.workspace_id = $1\n               ORDER BY r.display_name ASC\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows\n            .into_iter()\n            .map(|row| RepoWithTargetBranch {\n                repo: Repo {\n                    id: row.id,\n                    path: PathBuf::from(row.path),\n                    name: row.name,\n                    display_name: row.display_name,\n                    setup_script: row.setup_script,\n                    cleanup_script: row.cleanup_script,\n                    archive_script: row.archive_script,\n                    copy_files: row.copy_files,\n                    parallel_setup_script: row.parallel_setup_script,\n                    dev_server_script: row.dev_server_script,\n                    default_target_branch: row.default_target_branch,\n                    default_working_dir: row.default_working_dir,\n                    created_at: row.created_at,\n                    updated_at: row.updated_at,\n                },\n                target_branch: row.target_branch,\n            })\n            .collect())\n    }\n\n    pub async fn find_by_workspace_and_repo_id(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        repo_id: Uuid,\n    ) -> Result<Option<Self>, sqlx::Error> {\n        sqlx::query_as!(\n            WorkspaceRepo,\n            r#\"SELECT id as \"id!: Uuid\",\n                      workspace_id as \"workspace_id!: Uuid\",\n                      repo_id as \"repo_id!: Uuid\",\n                      target_branch,\n                      created_at as \"created_at!: DateTime<Utc>\",\n                      updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM workspace_repos\n               WHERE workspace_id = $1 AND repo_id = $2\"#,\n            workspace_id,\n            repo_id\n        )\n        .fetch_optional(pool)\n        .await\n    }\n\n    pub async fn update_target_branch(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n        repo_id: Uuid,\n        new_target_branch: &str,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            \"UPDATE workspace_repos SET target_branch = $1, updated_at = datetime('now') WHERE workspace_id = $2 AND repo_id = $3\",\n            new_target_branch,\n            workspace_id,\n            repo_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn update_target_branch_for_children_of_workspace(\n        pool: &SqlitePool,\n        parent_workspace_id: Uuid,\n        old_branch: &str,\n        new_branch: &str,\n    ) -> Result<u64, sqlx::Error> {\n        let result = sqlx::query!(\n            r#\"UPDATE workspace_repos\n               SET target_branch = $1, updated_at = datetime('now')\n               WHERE target_branch = $2\n                 AND workspace_id IN (\n                     SELECT w.id FROM workspaces w\n                     JOIN tasks t ON w.task_id = t.id\n                     WHERE t.parent_workspace_id = $3\n                 )\"#,\n            new_branch,\n            old_branch,\n            parent_workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(result.rows_affected())\n    }\n\n    pub async fn find_unique_repos_for_task(\n        pool: &SqlitePool,\n        task_id: Uuid,\n    ) -> Result<Vec<Repo>, sqlx::Error> {\n        sqlx::query_as!(\n            Repo,\n            r#\"SELECT DISTINCT r.id as \"id!: Uuid\",\n                      r.path,\n                      r.name,\n                      r.display_name,\n                      r.setup_script,\n                      r.cleanup_script,\n                      r.archive_script,\n                      r.copy_files,\n                      r.parallel_setup_script as \"parallel_setup_script!: bool\",\n                      r.dev_server_script,\n                      r.default_target_branch,\n                      r.default_working_dir,\n                      r.created_at as \"created_at!: DateTime<Utc>\",\n                      r.updated_at as \"updated_at!: DateTime<Utc>\"\n               FROM repos r\n               JOIN workspace_repos wr ON r.id = wr.repo_id\n               JOIN workspaces w ON wr.workspace_id = w.id\n               WHERE w.task_id = $1\n               ORDER BY r.display_name ASC\"#,\n            task_id\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    /// Find repos for a workspace with their copy_files configuration.\n    pub async fn find_repos_with_copy_files(\n        pool: &SqlitePool,\n        workspace_id: Uuid,\n    ) -> Result<Vec<RepoWithCopyFiles>, sqlx::Error> {\n        let rows = sqlx::query!(\n            r#\"SELECT r.id as \"id!: Uuid\", r.path, r.name, r.copy_files\n               FROM repos r\n               JOIN workspace_repos wr ON r.id = wr.repo_id\n               WHERE wr.workspace_id = $1\"#,\n            workspace_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(rows\n            .into_iter()\n            .map(|row| RepoWithCopyFiles {\n                id: row.id,\n                path: PathBuf::from(row.path),\n                name: row.name,\n                copy_files: row.copy_files,\n            })\n            .collect())\n    }\n}\n"
  },
  {
    "path": "crates/deployment/Cargo.toml",
    "content": "[package]\nname = \"deployment\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\ndb = { path = \"../db\" }\nutils = { path = \"../utils\" }\ngit = { path = \"../git\" }\nservices = { path = \"../services\" }\nworktree-manager = { path = \"../worktree-manager\" }\nexecutors = { path = \"../executors\" }\nasync-trait = { workspace = true } \nthiserror = { workspace = true } \nanyhow = { workspace = true }\nrelay-control = { path = \"../relay-control\" }\ntrusted-key-auth = { path = \"../trusted-key-auth\" }\nserver-info = { path = \"../server-info\" }\ntokio = { workspace = true }\nsqlx = \"0.8.6\"\nserde_json = { workspace = true }\ngit2 = { workspace = true }\nfutures = \"0.3.31\"\naxum = { workspace = true }\n"
  },
  {
    "path": "crates/deployment/src/lib.rs",
    "content": "use std::sync::Arc;\n\nuse anyhow::Error as AnyhowError;\nuse async_trait::async_trait;\nuse axum::response::sse::Event;\nuse db::{DBService, models::workspace::WorkspaceError};\nuse executors::executors::ExecutorError;\nuse futures::{StreamExt, TryStreamExt};\nuse git::{GitService, GitServiceError};\nuse git2::Error as Git2Error;\nuse relay_control::{RelayControl, signing::RelaySigningService};\nuse serde_json::Value;\nuse server_info::ServerInfo;\nuse services::services::{\n    analytics::AnalyticsService,\n    approvals::Approvals,\n    auth::AuthContext,\n    config::{Config, ConfigError},\n    container::{ContainerError, ContainerService},\n    events::{EventError, EventService},\n    file::{FileError, FileService},\n    file_search::FileSearchCache,\n    filesystem::{FilesystemError, FilesystemService},\n    filesystem_watcher::FilesystemWatcherError,\n    queued_message::QueuedMessageService,\n    remote_client::RemoteClient,\n    repo::RepoService,\n};\nuse sqlx::Error as SqlxError;\nuse thiserror::Error;\nuse tokio::sync::RwLock;\nuse trusted_key_auth::runtime::TrustedKeyAuthRuntime;\nuse utils::sentry as sentry_utils;\nuse worktree_manager::WorktreeError;\n\n#[derive(Debug, Clone, Copy, Error)]\n#[error(\"Remote client not configured\")]\npub struct RemoteClientNotConfigured;\n\n#[derive(Debug, Error)]\npub enum DeploymentError {\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(transparent)]\n    Sqlx(#[from] SqlxError),\n    #[error(transparent)]\n    Git2(#[from] Git2Error),\n    #[error(transparent)]\n    GitServiceError(#[from] GitServiceError),\n    #[error(transparent)]\n    FilesystemWatcherError(#[from] FilesystemWatcherError),\n    #[error(transparent)]\n    Workspace(#[from] WorkspaceError),\n    #[error(transparent)]\n    Container(#[from] ContainerError),\n    #[error(transparent)]\n    Executor(#[from] ExecutorError),\n    #[error(transparent)]\n    File(#[from] FileError),\n    #[error(transparent)]\n    Filesystem(#[from] FilesystemError),\n    #[error(transparent)]\n    Worktree(#[from] WorktreeError),\n    #[error(transparent)]\n    Event(#[from] EventError),\n    #[error(transparent)]\n    Config(#[from] ConfigError),\n    #[error(\"Remote client not configured\")]\n    RemoteClientNotConfigured,\n    #[error(transparent)]\n    Other(#[from] AnyhowError),\n}\n\n#[async_trait]\npub trait Deployment: Clone + Send + Sync + 'static {\n    async fn new() -> Result<Self, DeploymentError>;\n\n    fn user_id(&self) -> &str;\n\n    fn config(&self) -> &Arc<RwLock<Config>>;\n\n    fn db(&self) -> &DBService;\n\n    fn analytics(&self) -> &Option<AnalyticsService>;\n\n    fn container(&self) -> &impl ContainerService;\n\n    fn git(&self) -> &GitService;\n\n    fn repo(&self) -> &RepoService;\n\n    fn file(&self) -> &FileService;\n\n    fn filesystem(&self) -> &FilesystemService;\n\n    fn events(&self) -> &EventService;\n\n    fn file_search_cache(&self) -> &Arc<FileSearchCache>;\n\n    fn approvals(&self) -> &Approvals;\n\n    fn queued_message_service(&self) -> &QueuedMessageService;\n\n    fn auth_context(&self) -> &AuthContext;\n\n    fn relay_control(&self) -> &Arc<RelayControl>;\n\n    fn relay_signing(&self) -> &RelaySigningService;\n\n    fn server_info(&self) -> &Arc<ServerInfo>;\n\n    fn trusted_key_auth(&self) -> &TrustedKeyAuthRuntime;\n\n    fn remote_client(&self) -> Result<RemoteClient, RemoteClientNotConfigured> {\n        Err(RemoteClientNotConfigured)\n    }\n\n    fn shared_api_base(&self) -> Option<String> {\n        None\n    }\n\n    async fn update_sentry_scope(&self) -> Result<(), DeploymentError> {\n        let user_id = self.user_id();\n        let config = self.config().read().await;\n        let username = config.github.username.as_deref();\n        let email = config.github.primary_email.as_deref();\n        sentry_utils::configure_user_scope(user_id, username, email);\n\n        Ok(())\n    }\n\n    async fn track_if_analytics_allowed(&self, event_name: &str, properties: Value) {\n        let analytics_enabled = self.config().read().await.analytics_enabled;\n        // Track events unless user has explicitly opted out\n        if analytics_enabled && let Some(analytics) = self.analytics() {\n            analytics.track_event(self.user_id(), event_name, Some(properties.clone()));\n        }\n    }\n\n    async fn stream_events(\n        &self,\n    ) -> futures::stream::BoxStream<'static, Result<Event, std::io::Error>> {\n        self.events()\n            .msg_store()\n            .history_plus_stream()\n            .map_ok(|m| m.to_sse_event())\n            .boxed()\n    }\n}\n"
  },
  {
    "path": "crates/executors/Cargo.toml",
    "content": "[package]\nname = \"executors\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\nworkspace_utils = { path = \"../utils\", package = \"utils\" }\ngit = { path = \"../git\" }\ntokio = { workspace = true }\ntokio-util = { version = \"0.7\", features = [\"io\", \"compat\", \"rt\"] }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\ntracing = { workspace = true }\ntoml = \"0.8\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nts-rs = { workspace = true }\nschemars = { workspace = true }\ndirs = \"5.0\"\nxdg = \"3.0\"\nasync-trait = { workspace = true } \ncommand-group = { version = \"5.0\", features = [\"with-tokio\"] }\nregex = \"1.11.1\"\njson-patch = \"2.0\"\nthiserror = { workspace = true }\nenum_dispatch = \"0.3.13\"\nfutures-io = \"0.3.31\"\ntokio-stream = { version = \"0.1.17\", features = [\"io-util\"] }\nfutures = \"0.3.31\"\nbon = \"3.6\"\nos_pipe = \"1.2\"\nstrip-ansi-escapes = \"0.2.1\"\nstrum = \"0.27.2\"\nstrum_macros = \"0.27.2\"\nconvert_case = \"0.6\"\nsqlx = \"0.8.6\"\nshlex = \"1.3.0\"\nagent-client-protocol = { version = \"0.8\", features = [\"unstable\"] }\ncodex-protocol = { git = \"https://github.com/openai/codex.git\", package = \"codex-protocol\", tag = \"rust-v0.114.0\" }\ncodex-app-server-protocol = { git = \"https://github.com/openai/codex.git\", package = \"codex-app-server-protocol\", tag = \"rust-v0.114.0\" }\nsha2 = \"0.10\"\nderivative = \"2.2.0\"\nreqwest = { workspace = true }\neventsource-stream = \"0.2\"\nwalkdir = \"2\"\nrand = \"0.8\"\nbase64 = \"0.22\"\njsonc-parser = { version = \"0.29\", features = [\"cst\", \"serde\"] }\nlru = \"0.12\"\nasync-stream = \"0.3\"\n\n[target.'cfg(windows)'.dependencies]\nwinsplit = \"0.1.0\"\n\n[features]\ndefault = []\nqa-mode = []\n"
  },
  {
    "path": "crates/executors/default_mcp.json",
    "content": "{\n  \"vibe_kanban\": {\n    \"command\": \"npx\",\n    \"args\": [\n      \"-y\",\n      \"vibe-kanban@latest\",\n      \"--mcp\"\n    ]\n  },\n  \"context7\": {\n    \"type\": \"http\",\n    \"url\": \"https://mcp.context7.com/mcp\",\n    \"headers\": {\n      \"CONTEXT7_API_KEY\": \"YOUR_API_KEY\"\n    }\n  },\n  \"playwright\": {\n    \"command\": \"npx\",\n    \"args\": [\n      \"@playwright/mcp@latest\"\n    ]\n  },\n  \"exa\": {\n    \"command\": \"npx\",\n    \"args\": [\n      \"-y\",\n      \"exa-mcp-server\",\n      \"tools=web_search_exa,get_code_context_exa\"\n    ],\n    \"env\": {\n      \"EXA_API_KEY\": \"YOUR_API_KEY\"\n    }\n  },\n  \"chrome_devtools\": {\n    \"command\": \"npx\",\n    \"args\": [\n      \"chrome-devtools-mcp@latest\"\n    ]\n  },\n  \"dev_manager\": {\n    \"command\": \"npx\",\n    \"args\": [\n      \"dev-manager-mcp\",\n      \"stdio\"\n    ]\n  },\n  \"meta\": {\n    \"vibe_kanban\": {\n      \"name\": \"Vibe Kanban\",\n      \"description\": \"Create, update and delete Vibe Kanban tasks\",\n      \"url\": \"https://www.vibekanban.com/docs/integrations/vibe-kanban-mcp-server\",\n      \"icon\": \"favicon-vk-light.svg\"\n    },\n    \"context7\": {\n      \"name\": \"Context7\",\n      \"description\": \"Fetch up-to-date documentation and code examples\",\n      \"url\": \"https://github.com/upstash/context7\",\n      \"icon\": \"mcp/context7logo.png\"\n    },\n    \"playwright\": {\n      \"name\": \"Playwright\",\n      \"description\": \"Browser automation with Playwright\",\n      \"url\": \"https://github.com/microsoft/playwright-mcp\",\n      \"icon\": \"mcp/playwright_logo_icon.svg\"\n    },\n    \"exa\": {\n      \"name\": \"Exa\",\n      \"description\": \"Web search and code context retrieval powered by Exa AI\",\n      \"url\": \"https://docs.exa.ai/reference/exa-mcp\",\n      \"icon\": \"mcp/exa_logo.svg\"\n    },\n    \"chrome_devtools\": {\n      \"name\": \"Chrome DevTools\",\n      \"description\": \"Browser automation, debugging and performance analysis with Chrome DevTools\",\n      \"url\": \"https://github.com/ChromeDevTools/chrome-devtools-mcp\",\n      \"icon\": \"mcp/chrome_devtools_logo.svg\"\n    },\n    \"dev_manager\": {\n      \"name\": \"Dev Manager\",\n      \"description\": \"Launch and manage multiple dev servers in parallel with automatic port allocation\",\n      \"url\": \"https://github.com/BloopAI/dev-manager-mcp\",\n      \"icon\": \"mcp/dev_manager_logo.svg\"\n    }\n  }\n}\n\n"
  },
  {
    "path": "crates/executors/default_profiles.json",
    "content": "{\n  \"executors\": {\n    \"CLAUDE_CODE\": {\n      \"DEFAULT\": {\n        \"CLAUDE_CODE\": {\n          \"dangerously_skip_permissions\": true\n        }\n      }\n    },\n    \"AMP\": {\n      \"DEFAULT\": {\n        \"AMP\": {\n          \"dangerously_allow_all\": true\n        }\n      }\n    },\n    \"GEMINI\": {\n      \"DEFAULT\": {\n        \"GEMINI\": {\n          \"yolo\": true\n        }\n      }\n    },\n    \"CODEX\": {\n      \"DEFAULT\": {\n        \"CODEX\": {\n          \"sandbox\": \"danger-full-access\"\n        }\n      }\n    },\n    \"OPENCODE\": {\n      \"DEFAULT\": {\n        \"OPENCODE\": {\n          \"auto_approve\": true\n        }\n      }\n    },\n    \"QWEN_CODE\": {\n      \"DEFAULT\": {\n        \"QWEN_CODE\": {\n          \"yolo\": true\n        }\n      }\n    },\n    \"CURSOR_AGENT\": {\n      \"DEFAULT\": {\n        \"CURSOR_AGENT\": {\n          \"force\": true,\n          \"model\": \"auto\"\n        }\n      }\n    },\n    \"COPILOT\": {\n      \"DEFAULT\": {\n        \"COPILOT\": {\n          \"allow_all_tools\": true\n        }\n      }\n    },\n    \"DROID\": {\n      \"DEFAULT\": {\n        \"DROID\": {\n          \"autonomy\": \"skip-permissions-unsafe\"\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "crates/executors/src/actions/coding_agent_follow_up.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\n#[cfg(not(feature = \"qa-mode\"))]\nuse crate::profile::ExecutorConfigs;\nuse crate::{\n    actions::Executable,\n    approvals::ExecutorApprovalService,\n    env::ExecutionEnv,\n    executors::{BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor},\n    profile::ExecutorConfig,\n};\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct CodingAgentFollowUpRequest {\n    pub prompt: String,\n    pub session_id: String,\n    #[serde(default)]\n    pub reset_to_message_id: Option<String>,\n    /// Unified executor identity + overrides\n    #[serde(alias = \"executor_profile_id\", alias = \"profile_variant_label\")]\n    pub executor_config: ExecutorConfig,\n    /// Optional relative path to execute the agent in (relative to container_ref).\n    /// If None, uses the container_ref directory directly.\n    #[serde(default)]\n    pub working_dir: Option<String>,\n}\n\nimpl CodingAgentFollowUpRequest {\n    pub fn effective_dir(&self, current_dir: &Path) -> std::path::PathBuf {\n        match &self.working_dir {\n            Some(rel_path) => current_dir.join(rel_path),\n            None => current_dir.to_path_buf(),\n        }\n    }\n\n    pub fn base_executor(&self) -> BaseCodingAgent {\n        self.executor_config.executor\n    }\n}\n\n#[async_trait]\nimpl Executable for CodingAgentFollowUpRequest {\n    #[cfg_attr(feature = \"qa-mode\", allow(unused_variables))]\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        approvals: Arc<dyn ExecutorApprovalService>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let effective_dir = self.effective_dir(current_dir);\n\n        #[cfg(feature = \"qa-mode\")]\n        {\n            tracing::info!(\"QA mode: using mock executor for follow-up instead of real agent\");\n            let executor = crate::executors::qa_mock::QaMockExecutor;\n            return executor\n                .spawn_follow_up(\n                    &effective_dir,\n                    &self.prompt,\n                    &self.session_id,\n                    self.reset_to_message_id.as_deref(),\n                    env,\n                )\n                .await;\n        }\n\n        #[cfg(not(feature = \"qa-mode\"))]\n        {\n            let profile_id = self.executor_config.profile_id();\n            let mut agent = ExecutorConfigs::get_cached()\n                .get_coding_agent(&profile_id)\n                .ok_or(ExecutorError::UnknownExecutorType(profile_id.to_string()))?;\n\n            if self.executor_config.has_overrides() {\n                agent.apply_overrides(&self.executor_config);\n            }\n            agent.use_approvals(approvals.clone());\n\n            agent\n                .spawn_follow_up(\n                    &effective_dir,\n                    &self.prompt,\n                    &self.session_id,\n                    self.reset_to_message_id.as_deref(),\n                    env,\n                )\n                .await\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/actions/coding_agent_initial.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\n#[cfg(not(feature = \"qa-mode\"))]\nuse crate::profile::ExecutorConfigs;\nuse crate::{\n    actions::Executable,\n    approvals::ExecutorApprovalService,\n    env::ExecutionEnv,\n    executors::{BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor},\n    profile::ExecutorConfig,\n};\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct CodingAgentInitialRequest {\n    pub prompt: String,\n    /// Unified executor identity + overrides\n    #[serde(alias = \"executor_profile_id\", alias = \"profile_variant_label\")]\n    pub executor_config: ExecutorConfig,\n    /// Optional relative path to execute the agent in (relative to container_ref).\n    /// If None, uses the container_ref directory directly.\n    #[serde(default)]\n    pub working_dir: Option<String>,\n}\n\nimpl CodingAgentInitialRequest {\n    pub fn base_executor(&self) -> BaseCodingAgent {\n        self.executor_config.executor\n    }\n\n    pub fn effective_dir(&self, current_dir: &Path) -> std::path::PathBuf {\n        match &self.working_dir {\n            Some(rel_path) => current_dir.join(rel_path),\n            None => current_dir.to_path_buf(),\n        }\n    }\n}\n\n#[async_trait]\nimpl Executable for CodingAgentInitialRequest {\n    #[cfg_attr(feature = \"qa-mode\", allow(unused_variables))]\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        approvals: Arc<dyn ExecutorApprovalService>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let effective_dir = self.effective_dir(current_dir);\n\n        #[cfg(feature = \"qa-mode\")]\n        {\n            tracing::info!(\"QA mode: using mock executor instead of real agent\");\n            let executor = crate::executors::qa_mock::QaMockExecutor;\n            return executor.spawn(&effective_dir, &self.prompt, env).await;\n        }\n\n        #[cfg(not(feature = \"qa-mode\"))]\n        {\n            let profile_id = self.executor_config.profile_id();\n            let mut agent = ExecutorConfigs::get_cached()\n                .get_coding_agent(&profile_id)\n                .ok_or(ExecutorError::UnknownExecutorType(profile_id.to_string()))?;\n\n            if self.executor_config.has_overrides() {\n                agent.apply_overrides(&self.executor_config);\n            }\n            agent.use_approvals(approvals.clone());\n\n            agent.spawn(&effective_dir, &self.prompt, env).await\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/actions/mod.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse enum_dispatch::enum_dispatch;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\nuse crate::{\n    actions::{\n        coding_agent_follow_up::CodingAgentFollowUpRequest,\n        coding_agent_initial::CodingAgentInitialRequest, review::ReviewRequest,\n        script::ScriptRequest,\n    },\n    approvals::ExecutorApprovalService,\n    env::ExecutionEnv,\n    executors::{BaseCodingAgent, ExecutorError, SpawnedChild},\n};\npub mod coding_agent_follow_up;\npub mod coding_agent_initial;\npub mod review;\npub mod script;\n\npub use review::RepoReviewContext;\n\n#[enum_dispatch]\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\n#[serde(tag = \"type\")]\npub enum ExecutorActionType {\n    CodingAgentInitialRequest,\n    CodingAgentFollowUpRequest,\n    ScriptRequest,\n    ReviewRequest,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ExecutorAction {\n    pub typ: ExecutorActionType,\n    pub next_action: Option<Box<ExecutorAction>>,\n}\n\nimpl ExecutorAction {\n    pub fn new(typ: ExecutorActionType, next_action: Option<Box<ExecutorAction>>) -> Self {\n        Self { typ, next_action }\n    }\n    pub fn append_action(mut self, action: ExecutorAction) -> Self {\n        if let Some(next) = self.next_action {\n            self.next_action = Some(Box::new(next.append_action(action)));\n        } else {\n            self.next_action = Some(Box::new(action));\n        }\n        self\n    }\n\n    pub fn typ(&self) -> &ExecutorActionType {\n        &self.typ\n    }\n\n    pub fn next_action(&self) -> Option<&ExecutorAction> {\n        self.next_action.as_deref()\n    }\n\n    pub fn base_executor(&self) -> Option<BaseCodingAgent> {\n        match self.typ() {\n            ExecutorActionType::CodingAgentInitialRequest(request) => Some(request.base_executor()),\n            ExecutorActionType::CodingAgentFollowUpRequest(request) => {\n                Some(request.base_executor())\n            }\n            ExecutorActionType::ReviewRequest(request) => Some(request.base_executor()),\n            ExecutorActionType::ScriptRequest(_) => None,\n        }\n    }\n}\n\n#[async_trait]\n#[enum_dispatch(ExecutorActionType)]\npub trait Executable {\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        approvals: Arc<dyn ExecutorApprovalService>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError>;\n}\n\n#[async_trait]\nimpl Executable for ExecutorAction {\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        approvals: Arc<dyn ExecutorApprovalService>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        self.typ.spawn(current_dir, approvals, env).await\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/actions/review.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::{\n    actions::Executable,\n    approvals::ExecutorApprovalService,\n    env::ExecutionEnv,\n    executors::{BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor},\n    profile::{ExecutorConfig, ExecutorConfigs},\n};\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct RepoReviewContext {\n    pub repo_id: Uuid,\n    pub repo_name: String,\n    pub base_commit: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct ReviewRequest {\n    /// Unified executor identity + overrides\n    #[serde(alias = \"executor_profile_id\", alias = \"profile_variant_label\")]\n    pub executor_config: ExecutorConfig,\n    pub context: Option<Vec<RepoReviewContext>>,\n    pub prompt: String,\n    /// Optional session ID to resume an existing session\n    #[serde(default)]\n    pub session_id: Option<String>,\n    /// Optional relative path to execute the agent in (relative to container_ref).\n    #[serde(default)]\n    pub working_dir: Option<String>,\n}\n\nimpl ReviewRequest {\n    pub fn base_executor(&self) -> BaseCodingAgent {\n        self.executor_config.executor\n    }\n\n    pub fn effective_dir(&self, current_dir: &Path) -> std::path::PathBuf {\n        match &self.working_dir {\n            Some(rel_path) => current_dir.join(rel_path),\n            None => current_dir.to_path_buf(),\n        }\n    }\n}\n\n#[async_trait]\nimpl Executable for ReviewRequest {\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        approvals: Arc<dyn ExecutorApprovalService>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let effective_dir = self.effective_dir(current_dir);\n\n        let profile_id = self.executor_config.profile_id();\n        let mut agent = ExecutorConfigs::get_cached()\n            .get_coding_agent(&profile_id)\n            .ok_or(ExecutorError::UnknownExecutorType(profile_id.to_string()))?;\n\n        if self.executor_config.has_overrides() {\n            agent.apply_overrides(&self.executor_config);\n        }\n        agent.use_approvals(approvals.clone());\n\n        agent\n            .spawn_review(\n                &effective_dir,\n                &self.prompt,\n                self.session_id.as_deref(),\n                env,\n            )\n            .await\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/actions/script.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse serde::{Deserialize, Serialize};\nuse tokio::process::Command;\nuse ts_rs::TS;\nuse workspace_utils::{command_ext::GroupSpawnNoWindowExt, shell::get_shell_command};\n\nuse crate::{\n    actions::Executable,\n    approvals::ExecutorApprovalService,\n    env::ExecutionEnv,\n    executors::{ExecutorError, SpawnedChild},\n};\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub enum ScriptRequestLanguage {\n    Bash,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub enum ScriptContext {\n    SetupScript,\n    CleanupScript,\n    ArchiveScript,\n    DevServer,\n    ToolInstallScript,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct ScriptRequest {\n    pub script: String,\n    pub language: ScriptRequestLanguage,\n    pub context: ScriptContext,\n    /// Optional relative path to execute the script in (relative to container_ref).\n    /// If None, uses the container_ref directory directly.\n    #[serde(default)]\n    pub working_dir: Option<String>,\n}\n\n#[async_trait]\nimpl Executable for ScriptRequest {\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        _approvals: Arc<dyn ExecutorApprovalService>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        // Use working_dir if specified, otherwise use current_dir\n        let effective_dir = match &self.working_dir {\n            Some(rel_path) => current_dir.join(rel_path),\n            None => current_dir.to_path_buf(),\n        };\n\n        let (shell_cmd, shell_arg) = get_shell_command();\n        let mut command = Command::new(shell_cmd);\n        command\n            .kill_on_drop(true)\n            .stdin(std::process::Stdio::null())\n            .stdout(std::process::Stdio::piped())\n            .stderr(std::process::Stdio::piped())\n            .arg(shell_arg)\n            .arg(&self.script)\n            .current_dir(&effective_dir);\n\n        // Apply environment variables\n        env.apply_to_command(&mut command);\n\n        let child = command.group_spawn_no_window()?;\n\n        Ok(child.into())\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/approvals.rs",
    "content": "use std::fmt;\n\nuse async_trait::async_trait;\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse tokio_util::sync::CancellationToken;\nuse workspace_utils::approvals::{ApprovalStatus, QuestionStatus};\n\n/// Errors emitted by executor approval services.\n#[derive(Debug, Error)]\npub enum ExecutorApprovalError {\n    #[error(\"executor approval session not registered\")]\n    SessionNotRegistered,\n    #[error(\"executor approval request failed: {0}\")]\n    RequestFailed(String),\n    #[error(\"executor approval service unavailable\")]\n    ServiceUnavailable,\n    #[error(\"executor approval request cancelled\")]\n    Cancelled,\n}\n\nimpl ExecutorApprovalError {\n    pub fn request_failed<E: fmt::Display>(err: E) -> Self {\n        Self::RequestFailed(err.to_string())\n    }\n}\n\n/// Abstraction for executor approval backends.\n#[async_trait]\npub trait ExecutorApprovalService: Send + Sync {\n    /// Creates a tool approval request. Returns the approval_id immediately.\n    async fn create_tool_approval(&self, tool_name: &str) -> Result<String, ExecutorApprovalError>;\n\n    /// Creates a question approval request. Returns the approval_id immediately.\n    async fn create_question_approval(\n        &self,\n        tool_name: &str,\n        question_count: usize,\n    ) -> Result<String, ExecutorApprovalError>;\n\n    /// Waits for a tool approval to be resolved. Blocks until approved/denied/timed out.\n    async fn wait_tool_approval(\n        &self,\n        approval_id: &str,\n        cancel: CancellationToken,\n    ) -> Result<ApprovalStatus, ExecutorApprovalError>;\n\n    /// Waits for a question to be answered. Blocks until answered/timed out.\n    async fn wait_question_answer(\n        &self,\n        approval_id: &str,\n        cancel: CancellationToken,\n    ) -> Result<QuestionStatus, ExecutorApprovalError>;\n}\n\n#[derive(Debug, Default)]\npub struct NoopExecutorApprovalService;\n\n#[async_trait]\nimpl ExecutorApprovalService for NoopExecutorApprovalService {\n    async fn create_tool_approval(\n        &self,\n        _tool_name: &str,\n    ) -> Result<String, ExecutorApprovalError> {\n        Ok(\"noop\".to_string())\n    }\n\n    async fn create_question_approval(\n        &self,\n        _tool_name: &str,\n        _question_count: usize,\n    ) -> Result<String, ExecutorApprovalError> {\n        Ok(\"noop\".to_string())\n    }\n\n    async fn wait_tool_approval(\n        &self,\n        _approval_id: &str,\n        _cancel: CancellationToken,\n    ) -> Result<ApprovalStatus, ExecutorApprovalError> {\n        Ok(ApprovalStatus::Approved)\n    }\n\n    async fn wait_question_answer(\n        &self,\n        _approval_id: &str,\n        _cancel: CancellationToken,\n    ) -> Result<QuestionStatus, ExecutorApprovalError> {\n        Err(ExecutorApprovalError::ServiceUnavailable)\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\npub struct ToolCallMetadata {\n    pub tool_call_id: String,\n}\n"
  },
  {
    "path": "crates/executors/src/command.rs",
    "content": "use std::{collections::HashMap, path::PathBuf};\n\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse workspace_utils::shell::resolve_executable_path;\n\nuse crate::executors::ExecutorError;\n\n#[derive(Debug, Error)]\npub enum CommandBuildError {\n    #[error(\"base command cannot be parsed: {0}\")]\n    InvalidBase(String),\n    #[error(\"base command is empty after parsing\")]\n    EmptyCommand,\n    #[error(\"failed to quote command: {0}\")]\n    QuoteError(#[from] shlex::QuoteError),\n    #[error(\"invalid shell parameters: {0}\")]\n    InvalidShellParams(String),\n}\n\n#[derive(Debug, Clone)]\npub struct CommandParts {\n    program: String,\n    args: Vec<String>,\n}\n\nimpl CommandParts {\n    pub fn new(program: String, args: Vec<String>) -> Self {\n        Self { program, args }\n    }\n\n    pub async fn into_resolved(self) -> Result<(PathBuf, Vec<String>), ExecutorError> {\n        let CommandParts { program, args } = self;\n        let executable = resolve_executable_path(&program)\n            .await\n            .ok_or(ExecutorError::ExecutableNotFound { program })?;\n        Ok((executable, args))\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, Default)]\npub struct CmdOverrides {\n    #[schemars(\n        title = \"Base Command Override\",\n        description = \"Override the base command with a custom command\"\n    )]\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub base_command_override: Option<String>,\n    #[schemars(\n        title = \"Additional Parameters\",\n        description = \"Additional parameters to append to the base command\"\n    )]\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub additional_params: Option<Vec<String>>,\n    #[schemars(\n        title = \"Environment Variables\",\n        description = \"Environment variables to set when running the executor\"\n    )]\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub env: Option<HashMap<String, String>>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]\npub struct CommandBuilder {\n    /// Base executable command (e.g., \"npx -y @anthropic-ai/claude-code@latest\")\n    pub base: String,\n    /// Optional parameters to append to the base command\n    pub params: Option<Vec<String>>,\n}\n\nimpl CommandBuilder {\n    pub fn new<S: Into<String>>(base: S) -> Self {\n        Self {\n            base: base.into(),\n            params: None,\n        }\n    }\n\n    pub fn params<I>(mut self, params: I) -> Self\n    where\n        I: IntoIterator,\n        I::Item: Into<String>,\n    {\n        self.params = Some(params.into_iter().map(|p| p.into()).collect());\n        self\n    }\n\n    pub fn override_base<S: Into<String>>(mut self, base: S) -> Self {\n        self.base = base.into();\n        self\n    }\n\n    fn extend_shell_params<I>(mut self, more: I) -> Result<Self, CommandBuildError>\n    where\n        I: IntoIterator,\n        I::Item: Into<String>,\n    {\n        let joined = more\n            .into_iter()\n            .map(|p| p.into())\n            .collect::<Vec<String>>()\n            .join(\" \");\n\n        if joined.trim().is_empty() {\n            return Ok(self);\n        }\n\n        let extra: Vec<String> = split_command_line(&joined)\n            .map_err(|err| CommandBuildError::InvalidShellParams(format!(\"{joined}: {err}\")))?;\n\n        match &mut self.params {\n            Some(p) => p.extend(extra),\n            None => self.params = Some(extra),\n        }\n        Ok(self)\n    }\n\n    pub fn extend_params<I>(mut self, more: I) -> Self\n    where\n        I: IntoIterator,\n        I::Item: Into<String>,\n    {\n        let extra: Vec<String> = more.into_iter().map(|p| p.into()).collect();\n        match &mut self.params {\n            Some(p) => p.extend(extra),\n            None => self.params = Some(extra),\n        }\n        self\n    }\n\n    pub fn build_initial(&self) -> Result<CommandParts, CommandBuildError> {\n        self.build(&[])\n    }\n\n    pub fn build_follow_up(\n        &self,\n        additional_args: &[String],\n    ) -> Result<CommandParts, CommandBuildError> {\n        self.build(additional_args)\n    }\n\n    fn build(&self, additional_args: &[String]) -> Result<CommandParts, CommandBuildError> {\n        let mut parts = vec![];\n        let base_parts = split_command_line(&self.base)?;\n        parts.extend(base_parts);\n        if let Some(ref params) = self.params {\n            parts.extend(params.clone());\n        }\n        parts.extend(additional_args.iter().cloned());\n\n        if parts.is_empty() {\n            return Err(CommandBuildError::EmptyCommand);\n        }\n\n        let program = parts.remove(0);\n        Ok(CommandParts::new(program, parts))\n    }\n}\n\nfn split_command_line(input: &str) -> Result<Vec<String>, CommandBuildError> {\n    #[cfg(windows)]\n    {\n        let parts = winsplit::split(input);\n        if parts.is_empty() {\n            Err(CommandBuildError::EmptyCommand)\n        } else {\n            Ok(parts)\n        }\n    }\n\n    #[cfg(not(windows))]\n    {\n        shlex::split(input).ok_or_else(|| CommandBuildError::InvalidBase(input.to_string()))\n    }\n}\n\npub fn apply_overrides(\n    builder: CommandBuilder,\n    overrides: &CmdOverrides,\n) -> Result<CommandBuilder, CommandBuildError> {\n    let builder = if let Some(ref base) = overrides.base_command_override {\n        builder.override_base(base.clone())\n    } else {\n        builder\n    };\n    if let Some(ref extra) = overrides.additional_params {\n        builder.extend_shell_params(extra.clone())\n    } else {\n        Ok(builder)\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/env.rs",
    "content": "use std::{collections::HashMap, path::PathBuf};\n\nuse git::GitService;\nuse tokio::process::Command;\n\nuse crate::command::CmdOverrides;\n\n/// Repository context for executor operations\n#[derive(Debug, Clone, Default)]\npub struct RepoContext {\n    pub workspace_root: PathBuf,\n    /// Names of repositories in the workspace (subdirectory names)\n    pub repo_names: Vec<String>,\n}\n\nimpl RepoContext {\n    pub fn new(workspace_root: PathBuf, repo_names: Vec<String>) -> Self {\n        Self {\n            workspace_root,\n            repo_names,\n        }\n    }\n\n    pub fn repo_paths(&self) -> Vec<PathBuf> {\n        self.repo_names\n            .iter()\n            .map(|name| self.workspace_root.join(name))\n            .collect()\n    }\n\n    /// Check all repos for uncommitted changes.\n    /// Returns a formatted string describing any uncommitted changes found,\n    /// or an empty string if all repos are clean.\n    pub async fn check_uncommitted_changes(&self) -> String {\n        let repo_paths = self.repo_paths();\n        if repo_paths.is_empty() {\n            return String::new();\n        }\n\n        tokio::task::spawn_blocking(move || {\n            let git = GitService::new();\n            let mut all_status = String::new();\n\n            for repo_path in &repo_paths {\n                // Skip if not a git repository\n                if !repo_path.join(\".git\").exists() {\n                    continue;\n                }\n\n                match git.get_worktree_status(repo_path) {\n                    Ok(status) if !status.entries.is_empty() => {\n                        let mut status_output = String::new();\n                        for entry in &status.entries {\n                            status_output.push(entry.staged);\n                            status_output.push(entry.unstaged);\n                            status_output.push(' ');\n                            status_output.push_str(&String::from_utf8_lossy(&entry.path));\n                            status_output.push('\\n');\n                        }\n                        all_status.push_str(&format!(\n                            \"\\n{}:\\n{}\",\n                            repo_path.display(),\n                            status_output\n                        ));\n                    }\n                    _ => {}\n                }\n            }\n\n            all_status\n        })\n        .await\n        .unwrap_or_default()\n    }\n}\n\n/// Environment variables to inject into executor processes\n#[derive(Debug, Clone)]\npub struct ExecutionEnv {\n    pub vars: HashMap<String, String>,\n    pub repo_context: RepoContext,\n    pub commit_reminder: bool,\n    pub commit_reminder_prompt: String,\n}\n\nimpl ExecutionEnv {\n    pub fn new(\n        repo_context: RepoContext,\n        commit_reminder: bool,\n        commit_reminder_prompt: String,\n    ) -> Self {\n        Self {\n            vars: HashMap::new(),\n            repo_context,\n            commit_reminder,\n            commit_reminder_prompt,\n        }\n    }\n\n    /// Insert an environment variable\n    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {\n        self.vars.insert(key.into(), value.into());\n    }\n\n    /// Merge additional vars into this env. Incoming keys overwrite existing ones.\n    pub fn merge(&mut self, other: &HashMap<String, String>) {\n        self.vars\n            .extend(other.iter().map(|(k, v)| (k.clone(), v.clone())));\n    }\n\n    /// Return a new env with overrides applied. Overrides take precedence.\n    pub fn with_overrides(mut self, overrides: &HashMap<String, String>) -> Self {\n        self.merge(overrides);\n        self\n    }\n\n    /// Return a new env with profile env from CmdOverrides merged in.\n    pub fn with_profile(self, cmd: &CmdOverrides) -> Self {\n        if let Some(ref profile_env) = cmd.env {\n            self.with_overrides(profile_env)\n        } else {\n            self\n        }\n    }\n\n    /// Apply all environment variables to a Command\n    pub fn apply_to_command(&self, command: &mut Command) {\n        for (key, value) in &self.vars {\n            command.env(key, value);\n        }\n    }\n\n    pub fn contains_key(&self, key: &str) -> bool {\n        self.vars.contains_key(key)\n    }\n\n    pub fn get(&self, key: &str) -> Option<&String> {\n        self.vars.get(key)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn profile_overrides_runtime_env() {\n        let mut base = ExecutionEnv::new(RepoContext::default(), false, String::new());\n        base.insert(\"VK_PROJECT_NAME\", \"runtime\");\n        base.insert(\"FOO\", \"runtime\");\n\n        let mut profile = HashMap::new();\n        profile.insert(\"FOO\".to_string(), \"profile\".to_string());\n        profile.insert(\"BAR\".to_string(), \"profile\".to_string());\n\n        let merged = base.with_overrides(&profile);\n\n        assert_eq!(merged.vars.get(\"VK_PROJECT_NAME\").unwrap(), \"runtime\");\n        assert_eq!(merged.vars.get(\"FOO\").unwrap(), \"profile\"); // overrides\n        assert_eq!(merged.vars.get(\"BAR\").unwrap(), \"profile\");\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executor_discovery.rs",
    "content": "use std::path::PathBuf;\n\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\nuse crate::{\n    executors::{BaseCodingAgent, SlashCommandDescription},\n    model_selector::ModelSelectorConfig,\n};\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\npub struct ExecutorDiscoveredOptions {\n    pub model_selector: ModelSelectorConfig,\n    pub slash_commands: Vec<SlashCommandDescription>,\n    pub loading_models: bool,\n    pub loading_agents: bool,\n    pub loading_slash_commands: bool,\n    pub error: Option<String>,\n}\n\nimpl ExecutorDiscoveredOptions {\n    pub fn with_loading(mut self, loading: bool) -> Self {\n        self.loading_models = loading;\n        self.loading_agents = loading;\n        self.loading_slash_commands = loading;\n        self\n    }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Hash)]\npub struct ExecutorConfigCacheKey {\n    pub path: Option<PathBuf>,\n    pub cmd_key: String,\n    pub base_executor: BaseCodingAgent,\n}\n\nimpl ExecutorConfigCacheKey {\n    pub fn new(path: Option<&PathBuf>, cmd_key: String, base_executor: BaseCodingAgent) -> Self {\n        Self {\n            path: path.cloned(),\n            cmd_key,\n            base_executor,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/acp/client.rs",
    "content": "use std::sync::Arc;\n\nuse agent_client_protocol::{self as acp};\nuse async_trait::async_trait;\nuse tokio::sync::{Mutex, mpsc};\nuse tokio_util::sync::CancellationToken;\nuse tracing::{debug, warn};\nuse workspace_utils::approvals::ApprovalStatus;\n\nuse crate::{\n    approvals::{ExecutorApprovalError, ExecutorApprovalService},\n    executors::acp::{AcpEvent, ApprovalResponse},\n};\n\n/// ACP client that handles agent-client protocol communication\n#[derive(Clone)]\npub struct AcpClient {\n    event_tx: mpsc::UnboundedSender<AcpEvent>,\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    feedback_queue: Arc<Mutex<Vec<String>>>,\n    cancel: CancellationToken,\n}\n\nimpl AcpClient {\n    /// Create a new ACP client\n    pub fn new(\n        event_tx: mpsc::UnboundedSender<AcpEvent>,\n        approvals: Option<Arc<dyn ExecutorApprovalService>>,\n        cancel: CancellationToken,\n    ) -> Self {\n        Self {\n            event_tx,\n            approvals,\n            feedback_queue: Arc::new(Mutex::new(Vec::new())),\n            cancel,\n        }\n    }\n\n    pub fn record_user_prompt_event(&self, prompt: &str) {\n        self.send_event(AcpEvent::User(prompt.to_string()));\n    }\n\n    /// Send an event to the event channel\n    fn send_event(&self, event: AcpEvent) {\n        if let Err(e) = self.event_tx.send(event) {\n            warn!(\"Failed to send ACP event: {}\", e);\n        }\n    }\n\n    /// Queue a user feedback message to be sent after a denial.\n    pub async fn enqueue_feedback(&self, message: String) {\n        let trimmed = message.trim().to_string();\n        if !trimmed.is_empty() {\n            let mut q = self.feedback_queue.lock().await;\n            q.push(trimmed);\n        }\n    }\n\n    /// Drain and return queued feedback messages.\n    pub async fn drain_feedback(&self) -> Vec<String> {\n        let mut q = self.feedback_queue.lock().await;\n        q.drain(..).collect()\n    }\n}\n\n#[async_trait(?Send)]\nimpl acp::Client for AcpClient {\n    async fn request_permission(\n        &self,\n        args: acp::RequestPermissionRequest,\n    ) -> Result<acp::RequestPermissionResponse, acp::Error> {\n        self.send_event(AcpEvent::RequestPermission(args.clone()));\n\n        if self.approvals.is_none() {\n            // Auto-approve with best available option when no approval service is configured\n            let chosen_option = args\n                .options\n                .iter()\n                .find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowAlways))\n                .or_else(|| {\n                    args.options\n                        .iter()\n                        .find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowOnce))\n                })\n                .or_else(|| args.options.first());\n\n            let outcome = if let Some(opt) = chosen_option {\n                debug!(\"Auto-approving permission with option: {}\", opt.option_id);\n                acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(\n                    opt.option_id.clone(),\n                ))\n            } else {\n                warn!(\"No permission options available, cancelling\");\n                acp::RequestPermissionOutcome::Cancelled\n            };\n\n            return Ok(acp::RequestPermissionResponse::new(outcome));\n        }\n\n        let tool_call_id = args.tool_call.tool_call_id.0.to_string();\n        let tool_name = args.tool_call.fields.title.as_deref().unwrap_or(\"tool\");\n        let approval_service = self\n            .approvals\n            .as_ref()\n            .ok_or(ExecutorApprovalError::ServiceUnavailable)\n            .map_err(|_| acp::Error::invalid_request())?;\n\n        let approval_id = match approval_service.create_tool_approval(tool_name).await {\n            Ok(id) => id,\n            Err(err) => return self.handle_approval_error(err, &tool_call_id),\n        };\n\n        self.send_event(AcpEvent::ApprovalRequested {\n            tool_call_id: tool_call_id.clone(),\n            approval_id: approval_id.clone(),\n        });\n\n        let status = match approval_service\n            .wait_tool_approval(&approval_id, self.cancel.clone())\n            .await\n        {\n            Ok(s) => s,\n            Err(err) => return self.handle_approval_error(err, &tool_call_id),\n        };\n\n        // Map our ApprovalStatus to ACP outcome\n        let outcome = match &status {\n            ApprovalStatus::Approved => {\n                let chosen = args\n                    .options\n                    .iter()\n                    .find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowOnce));\n                if let Some(opt) = chosen {\n                    acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(\n                        opt.option_id.clone(),\n                    ))\n                } else {\n                    tracing::error!(\"No suitable approval option found, cancelling\");\n                    return Err(acp::Error::invalid_request());\n                }\n            }\n            ApprovalStatus::Denied { reason } => {\n                // If user provided a reason, queue it to send after denial\n                if let Some(feedback) = reason.as_ref() {\n                    self.enqueue_feedback(feedback.clone()).await;\n                }\n                let chosen = args\n                    .options\n                    .iter()\n                    .find(|o| matches!(o.kind, acp::PermissionOptionKind::RejectOnce));\n                if let Some(opt) = chosen {\n                    acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(\n                        opt.option_id.clone(),\n                    ))\n                } else {\n                    warn!(\"No permission options for denial, cancelling\");\n                    acp::RequestPermissionOutcome::Cancelled\n                }\n            }\n            ApprovalStatus::TimedOut => {\n                warn!(\"Approval timed out\");\n                acp::RequestPermissionOutcome::Cancelled\n            }\n            ApprovalStatus::Pending => {\n                // This should not occur after waiter resolves\n                warn!(\"Approval resolved to Pending\");\n                acp::RequestPermissionOutcome::Cancelled\n            }\n        };\n\n        self.send_event(AcpEvent::ApprovalResponse(ApprovalResponse {\n            tool_call_id: tool_call_id.clone(),\n            status: status.clone(),\n        }));\n\n        Ok(acp::RequestPermissionResponse::new(outcome))\n    }\n\n    async fn session_notification(&self, args: acp::SessionNotification) -> Result<(), acp::Error> {\n        // Convert to typed events\n        let event = match args.update {\n            acp::SessionUpdate::AgentMessageChunk(chunk) => Some(AcpEvent::Message(chunk.content)),\n            acp::SessionUpdate::AgentThoughtChunk(chunk) => Some(AcpEvent::Thought(chunk.content)),\n            acp::SessionUpdate::ToolCall(tc) => Some(AcpEvent::ToolCall(tc)),\n            acp::SessionUpdate::ToolCallUpdate(update) => Some(AcpEvent::ToolUpdate(update)),\n            acp::SessionUpdate::Plan(plan) => Some(AcpEvent::Plan(plan)),\n            _ => Some(AcpEvent::Other(args)),\n        };\n\n        if let Some(event) = event {\n            self.send_event(event);\n        }\n\n        Ok(())\n    }\n\n    // File system operations - not implemented as we don't expose FS\n    async fn write_text_file(\n        &self,\n        _args: acp::WriteTextFileRequest,\n    ) -> Result<acp::WriteTextFileResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    async fn read_text_file(\n        &self,\n        _args: acp::ReadTextFileRequest,\n    ) -> Result<acp::ReadTextFileResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    // Terminal operations - not implemented\n    async fn create_terminal(\n        &self,\n        _args: acp::CreateTerminalRequest,\n    ) -> Result<acp::CreateTerminalResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    async fn terminal_output(\n        &self,\n        _args: acp::TerminalOutputRequest,\n    ) -> Result<acp::TerminalOutputResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    async fn release_terminal(\n        &self,\n        _args: acp::ReleaseTerminalRequest,\n    ) -> Result<acp::ReleaseTerminalResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    async fn wait_for_terminal_exit(\n        &self,\n        _args: acp::WaitForTerminalExitRequest,\n    ) -> Result<acp::WaitForTerminalExitResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    async fn kill_terminal_command(\n        &self,\n        _args: acp::KillTerminalCommandRequest,\n    ) -> Result<acp::KillTerminalCommandResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    // Extension methods\n    async fn ext_method(&self, _args: acp::ExtRequest) -> Result<acp::ExtResponse, acp::Error> {\n        Err(acp::Error::method_not_found())\n    }\n\n    async fn ext_notification(&self, _args: acp::ExtNotification) -> Result<(), acp::Error> {\n        Ok(())\n    }\n}\n\nimpl AcpClient {\n    fn handle_approval_error(\n        &self,\n        err: ExecutorApprovalError,\n        tool_call_id: &str,\n    ) -> Result<acp::RequestPermissionResponse, acp::Error> {\n        if let ExecutorApprovalError::Cancelled = err {\n            debug!(\"ACP approval cancelled for tool_call_id={}\", tool_call_id);\n            Ok(acp::RequestPermissionResponse::new(\n                acp::RequestPermissionOutcome::Cancelled,\n            ))\n        } else {\n            tracing::error!(\n                \"ACP approval wait failed for tool_call_id={}: {err}\",\n                tool_call_id\n            );\n            self.send_event(AcpEvent::ApprovalResponse(ApprovalResponse {\n                tool_call_id: tool_call_id.to_string(),\n                status: ApprovalStatus::TimedOut,\n            }));\n            Err(acp::Error::internal_error())\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/acp/harness.rs",
    "content": "use std::{\n    path::{Path, PathBuf},\n    process::Stdio,\n    rc::Rc,\n    sync::Arc,\n};\n\nuse agent_client_protocol as proto;\nuse agent_client_protocol::Agent as _;\nuse command_group::AsyncGroupChild;\nuse futures::StreamExt;\nuse tokio::{io::AsyncWriteExt, process::Command, sync::mpsc};\nuse tokio_util::{\n    compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt},\n    io::ReaderStream,\n    sync::CancellationToken,\n};\nuse tracing::error;\nuse workspace_utils::{\n    approvals::ApprovalStatus, command_ext::GroupSpawnNoWindowExt, stream_lines::LinesStreamExt,\n};\n\nuse super::{AcpClient, SessionManager};\nuse crate::{\n    approvals::ExecutorApprovalService,\n    command::{CmdOverrides, CommandParts},\n    env::ExecutionEnv,\n    executors::{ExecutorError, ExecutorExitResult, SpawnedChild, acp::AcpEvent},\n};\n\n/// Reusable harness for ACP-based conns (Gemini, Qwen, etc.)\npub struct AcpAgentHarness {\n    session_namespace: String,\n    model: Option<String>,\n    mode: Option<String>,\n}\n\nimpl Default for AcpAgentHarness {\n    fn default() -> Self {\n        // Keep existing behavior for Gemini\n        Self::new()\n    }\n}\n\nimpl AcpAgentHarness {\n    /// Create a harness with the default Gemini namespace\n    pub fn new() -> Self {\n        Self {\n            session_namespace: \"gemini_sessions\".to_string(),\n            model: None,\n            mode: None,\n        }\n    }\n\n    /// Create a harness with a custom session namespace (e.g. for Qwen)\n    pub fn with_session_namespace(namespace: impl Into<String>) -> Self {\n        Self {\n            session_namespace: namespace.into(),\n            model: None,\n            mode: None,\n        }\n    }\n\n    pub fn with_model(mut self, model: impl Into<String>) -> Self {\n        self.model = Some(model.into());\n        self\n    }\n\n    pub fn with_mode(mut self, mode: impl Into<String>) -> Self {\n        self.mode = Some(mode.into());\n        self\n    }\n\n    pub fn apply_overrides(&mut self, executor_config: &crate::profile::ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n\n        if let Some(agent_id) = &executor_config.agent_id {\n            self.mode = Some(agent_id.clone());\n        }\n    }\n\n    pub async fn spawn_with_command(\n        &self,\n        current_dir: &Path,\n        prompt: String,\n        command_parts: CommandParts,\n        env: &ExecutionEnv,\n        cmd_overrides: &CmdOverrides,\n        approvals: Option<std::sync::Arc<dyn ExecutorApprovalService>>,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let (program_path, args) = command_parts.into_resolved().await?;\n        let mut command = Command::new(program_path);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .env(\"NODE_NO_WARNINGS\", \"1\")\n            .args(&args);\n\n        env.clone()\n            .with_profile(cmd_overrides)\n            .apply_to_command(&mut command);\n\n        let mut child = command.group_spawn_no_window()?;\n\n        let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<ExecutorExitResult>();\n        let cancel = CancellationToken::new();\n\n        Self::bootstrap_acp_connection(\n            &mut child,\n            current_dir.to_path_buf(),\n            None,\n            prompt,\n            Some(exit_tx),\n            self.session_namespace.clone(),\n            self.model.clone(),\n            self.mode.clone(),\n            approvals,\n            cancel.clone(),\n        )\n        .await?;\n\n        Ok(SpawnedChild {\n            child,\n            exit_signal: Some(exit_rx),\n            cancel: Some(cancel),\n        })\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub async fn spawn_follow_up_with_command(\n        &self,\n        current_dir: &Path,\n        prompt: String,\n        session_id: &str,\n        command_parts: CommandParts,\n        env: &ExecutionEnv,\n        cmd_overrides: &CmdOverrides,\n        approvals: Option<std::sync::Arc<dyn ExecutorApprovalService>>,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let (program_path, args) = command_parts.into_resolved().await?;\n        let mut command = Command::new(program_path);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .env(\"NODE_NO_WARNINGS\", \"1\")\n            .args(&args);\n\n        env.clone()\n            .with_profile(cmd_overrides)\n            .apply_to_command(&mut command);\n\n        let mut child = command.group_spawn_no_window()?;\n\n        let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<ExecutorExitResult>();\n        let cancel = CancellationToken::new();\n\n        Self::bootstrap_acp_connection(\n            &mut child,\n            current_dir.to_path_buf(),\n            Some(session_id.to_string()),\n            prompt,\n            Some(exit_tx),\n            self.session_namespace.clone(),\n            self.model.clone(),\n            self.mode.clone(),\n            approvals,\n            cancel.clone(),\n        )\n        .await?;\n\n        Ok(SpawnedChild {\n            child,\n            exit_signal: Some(exit_rx),\n            cancel: Some(cancel),\n        })\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    async fn bootstrap_acp_connection(\n        child: &mut AsyncGroupChild,\n        cwd: PathBuf,\n        existing_session: Option<String>,\n        prompt: String,\n        exit_signal: Option<tokio::sync::oneshot::Sender<ExecutorExitResult>>,\n        session_namespace: String,\n        model: Option<String>,\n        mode: Option<String>,\n        approvals: Option<std::sync::Arc<dyn ExecutorApprovalService>>,\n        cancel: CancellationToken,\n    ) -> Result<(), ExecutorError> {\n        // Take child's stdio for ACP wiring\n        let orig_stdout = child.inner().stdout.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::new(\n                std::io::ErrorKind::NotFound,\n                \"Child process has no stdout\",\n            ))\n        })?;\n        let orig_stdin = child.inner().stdin.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::new(\n                std::io::ErrorKind::NotFound,\n                \"Child process has no stdin\",\n            ))\n        })?;\n\n        // Create a fresh stdout pipe for logs\n        let writer = crate::stdout_dup::create_stdout_pipe_writer(child)?;\n        let shared_writer = Arc::new(tokio::sync::Mutex::new(writer));\n        let (log_tx, mut log_rx) = mpsc::unbounded_channel::<String>();\n\n        // Spawn log -> stdout writer task\n        tokio::spawn(async move {\n            while let Some(line) = log_rx.recv().await {\n                let mut data = line.into_bytes();\n                data.push(b'\\n');\n                let mut w = shared_writer.lock().await;\n                let _ = w.write_all(&data).await;\n            }\n        });\n\n        // ACP client STDIO\n        let (mut to_acp_writer, acp_incoming_reader) = tokio::io::duplex(64 * 1024);\n        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);\n\n        // Process stdout -> ACP\n        let stdout_shutdown_rx = shutdown_rx.clone();\n        tokio::spawn(async move {\n            let mut stdout_stream = ReaderStream::new(orig_stdout);\n            while let Some(res) = stdout_stream.next().await {\n                if *stdout_shutdown_rx.borrow() {\n                    break;\n                }\n                match res {\n                    Ok(data) => {\n                        let _ = to_acp_writer.write_all(&data).await;\n                    }\n                    Err(_) => break,\n                }\n            }\n        });\n\n        // ACP crate expects futures::AsyncRead + AsyncWrite, use tokio compat to adapt tokio::io::AsyncRead + Write\n        let (acp_out_writer, acp_out_reader) = tokio::io::duplex(64 * 1024);\n        let outgoing = acp_out_writer.compat_write();\n        let incoming = acp_incoming_reader.compat();\n\n        // Process ACP -> stdin\n        let stdin_shutdown_rx = shutdown_rx.clone();\n        tokio::spawn(async move {\n            let mut child_stdin = orig_stdin;\n            let mut lines = ReaderStream::new(acp_out_reader)\n                .map(|res| res.map(|bytes| String::from_utf8_lossy(&bytes).into_owned()))\n                .lines();\n            while let Some(result) = lines.next().await {\n                if *stdin_shutdown_rx.borrow() {\n                    break;\n                }\n                match result {\n                    Ok(line) => {\n                        // Use \\r\\n on Windows for compatibility with buggy ACP implementations\n                        const LINE_ENDING: &str = if cfg!(windows) { \"\\r\\n\" } else { \"\\n\" };\n                        let line = line + LINE_ENDING;\n                        if let Err(err) = child_stdin.write_all(line.as_bytes()).await {\n                            tracing::debug!(\"Failed to write to child stdin {err}\");\n                            break;\n                        }\n                        let _ = child_stdin.flush().await;\n                    }\n                    Err(err) => {\n                        tracing::debug!(\"ACP stdin line error {err}\");\n                        break;\n                    }\n                }\n            }\n        });\n\n        let mut exit_signal_tx = exit_signal;\n\n        // Run ACP client in a LocalSet\n        tokio::task::spawn_blocking(move || {\n            let rt = tokio::runtime::Builder::new_current_thread()\n                .enable_all()\n                .build()\n                .expect(\"build runtime\");\n\n            rt.block_on(async move {\n                let local = tokio::task::LocalSet::new();\n                local\n                    .run_until(async move {\n                        // Create event and raw channels\n                        // Typed events available for future use; raw lines forwarded and persisted\n                        let (event_tx, mut event_rx) =\n                            mpsc::unbounded_channel::<crate::executors::acp::AcpEvent>();\n\n                        // Create session manager\n                        let session_manager = match SessionManager::new(session_namespace) {\n                            Ok(sm) => sm,\n                            Err(e) => {\n                                error!(\"Failed to create session manager: {}\", e);\n                                return;\n                            }\n                        };\n                        let session_manager = std::sync::Arc::new(session_manager);\n\n                        // Create ACP client with approvals support\n                        let client =\n                            AcpClient::new(event_tx.clone(), approvals.clone(), cancel.clone());\n                        let client_feedback_handle = client.clone();\n\n                        client.record_user_prompt_event(&prompt);\n\n                        // Set up connection\n                        let (conn, io_fut) =\n                            proto::ClientSideConnection::new(client, outgoing, incoming, |fut| {\n                                tokio::task::spawn_local(fut);\n                            });\n                        let conn = Rc::new(conn);\n\n                        // Drive I/O\n                        let io_handle = tokio::task::spawn_local(async move {\n                            let _ = io_fut.await;\n                        });\n\n                        // Initialize\n                        let _ = conn\n                            .initialize(proto::InitializeRequest::new(proto::ProtocolVersion::V1))\n                            .await;\n\n                        // Handle session creation/forking\n                        let (acp_session_id, display_session_id, prompt_to_send) =\n                            if let Some(existing) = existing_session {\n                                // Fork existing session\n                                let new_ui_id = uuid::Uuid::new_v4().to_string();\n                                let _ = session_manager.fork_session(&existing, &new_ui_id);\n\n                                let history = session_manager.read_session_raw(&new_ui_id).ok();\n                                let meta =\n                                    history.map(|h| serde_json::json!({ \"history_jsonl\": h }));\n\n                                let mut req = proto::NewSessionRequest::new(cwd.clone());\n                                if let Some(m) = meta\n                                    && let Some(obj) = m.as_object()\n                                {\n                                    req = req.meta(obj.clone());\n                                }\n                                match conn.new_session(req).await {\n                                    Ok(resp) => {\n                                        let resume_prompt = session_manager\n                                            .generate_resume_prompt(&new_ui_id, &prompt)\n                                            .unwrap_or_else(|_| prompt.clone());\n                                        (resp.session_id.0.to_string(), new_ui_id, resume_prompt)\n                                    }\n                                    Err(e) => {\n                                        error!(\"Failed to create session: {}\", e);\n                                        return;\n                                    }\n                                }\n                            } else {\n                                // New session\n                                match conn\n                                    .new_session(proto::NewSessionRequest::new(cwd.clone()))\n                                    .await\n                                {\n                                    Ok(resp) => {\n                                        let sid = resp.session_id.0.to_string();\n                                        (sid.clone(), sid, prompt)\n                                    }\n                                    Err(e) => {\n                                        error!(\"Failed to create session: {}\", e);\n                                        return;\n                                    }\n                                }\n                            };\n\n                        // Emit session ID\n                        let _ = log_tx\n                            .send(AcpEvent::SessionStart(display_session_id.clone()).to_string());\n\n                        if let Some(model) = model.clone() {\n                            match conn\n                                .set_session_model(proto::SetSessionModelRequest::new(\n                                    proto::SessionId::new(acp_session_id.clone()),\n                                    model,\n                                ))\n                                .await\n                            {\n                                Ok(_) => {}\n                                Err(e) => error!(\"Failed to set session mode: {}\", e),\n                            }\n                        }\n\n                        if let Some(mode) = mode.clone() {\n                            match conn\n                                .set_session_mode(proto::SetSessionModeRequest::new(\n                                    proto::SessionId::new(acp_session_id.clone()),\n                                    mode,\n                                ))\n                                .await\n                            {\n                                Ok(_) => {}\n                                Err(e) => error!(\"Failed to set session mode: {}\", e),\n                            }\n                        }\n\n                        // Start raw event forwarder and persistence\n                        let app_tx_clone = log_tx.clone();\n                        let sess_id_for_writer = display_session_id.clone();\n                        let sm_for_writer = session_manager.clone();\n                        let conn_for_cancel = conn.clone();\n                        let acp_session_id_for_cancel = acp_session_id.clone();\n                        tokio::task::spawn_local(async move {\n                            while let Some(event) = event_rx.recv().await {\n                                if let AcpEvent::ApprovalResponse(resp) = &event\n                                    && let ApprovalStatus::Denied {\n                                        reason: Some(reason),\n                                    } = &resp.status\n                                    && !reason.trim().is_empty()\n                                {\n                                    let _ = conn_for_cancel\n                                        .cancel(proto::CancelNotification::new(\n                                            proto::SessionId::new(\n                                                acp_session_id_for_cancel.clone(),\n                                            ),\n                                        ))\n                                        .await;\n                                }\n\n                                let line = event.to_string();\n                                // Forward to stdout\n                                let _ = app_tx_clone.send(line.clone());\n                                // Persist to session file\n                                let _ = sm_for_writer.append_raw_line(&sess_id_for_writer, &line);\n                            }\n                        });\n\n                        // Save prompt to session\n                        let _ = session_manager.append_raw_line(\n                            &display_session_id,\n                            &serde_json::to_string(&serde_json::json!({ \"user\": prompt_to_send }))\n                                .unwrap_or_default(),\n                        );\n\n                        // Build prompt request\n                        let initial_req = proto::PromptRequest::new(\n                            proto::SessionId::new(acp_session_id.clone()),\n                            vec![proto::ContentBlock::Text(proto::TextContent::new(\n                                prompt_to_send,\n                            ))],\n                        );\n\n                        let mut current_req = Some(initial_req);\n\n                        while let Some(req) = current_req.take() {\n                            if cancel.is_cancelled() {\n                                tracing::debug!(\"ACP executor cancelled, stopping prompt loop\");\n                                break;\n                            }\n\n                            tracing::trace!(?req, \"sending ACP prompt request\");\n                            // Send the prompt and await completion to obtain stop_reason\n                            let prompt_result = tokio::select! {\n                                _ = cancel.cancelled() => {\n                                    tracing::debug!(\"ACP executor cancelled during prompt\");\n                                    break;\n                                }\n                                result = conn.prompt(req) => result,\n                            };\n\n                            match prompt_result {\n                                Ok(resp) => {\n                                    // Emit done with stop_reason\n                                    let stop_reason = serde_json::to_string(&resp.stop_reason)\n                                        .unwrap_or_default();\n                                    let _ = log_tx.send(AcpEvent::Done(stop_reason).to_string());\n                                }\n                                Err(e) => {\n                                    tracing::debug!(\"error {} {e} {:?}\", e.code, e.data);\n                                    if e.code\n                                        == agent_client_protocol::ErrorCode::INTERNAL_ERROR.code\n                                        && e.data\n                                            .as_ref()\n                                            .is_some_and(|d| d == \"server shut down unexpectedly\")\n                                    {\n                                        tracing::debug!(\"ACP server killed\");\n                                    } else {\n                                        let _ = log_tx\n                                            .send(AcpEvent::Error(format!(\"{e}\")).to_string());\n                                    }\n                                }\n                            }\n\n                            // Flush any pending user feedback after finish\n                            let feedback = client_feedback_handle\n                                .drain_feedback()\n                                .await\n                                .join(\"\\n\")\n                                .trim()\n                                .to_string();\n                            if !feedback.is_empty() {\n                                tracing::trace!(?feedback, \"sending ACP follow-up feedback\");\n                                let session_id = proto::SessionId::new(acp_session_id.clone());\n                                let feedback_req = proto::PromptRequest::new(\n                                    session_id.clone(),\n                                    vec![proto::ContentBlock::Text(proto::TextContent::new(\n                                        feedback,\n                                    ))],\n                                );\n                                current_req = Some(feedback_req);\n                            }\n                        }\n\n                        // Notify container of completion\n                        if let Some(tx) = exit_signal_tx.take() {\n                            let _ = tx.send(ExecutorExitResult::Success);\n                        }\n\n                        // Cancel session work\n                        let _ = conn\n                            .cancel(proto::CancelNotification::new(proto::SessionId::new(\n                                acp_session_id,\n                            )))\n                            .await;\n\n                        // Cleanup\n                        drop(conn);\n                        let _ = shutdown_tx.send(true);\n                        let _ = io_handle.await;\n                        drop(log_tx);\n                    })\n                    .await;\n            });\n        });\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/acp/mod.rs",
    "content": "pub mod client;\npub mod harness;\npub mod normalize_logs;\npub mod session;\n\nuse std::{fmt::Display, str::FromStr};\n\npub use client::AcpClient;\npub use harness::AcpAgentHarness;\npub use normalize_logs::*;\nuse serde::{Deserialize, Serialize};\npub use session::SessionManager;\nuse workspace_utils::approvals::ApprovalStatus;\n\n/// Parsed event types for internal processing\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub enum AcpEvent {\n    User(String),\n    SessionStart(String),\n    Message(agent_client_protocol::ContentBlock),\n    Thought(agent_client_protocol::ContentBlock),\n    ToolCall(agent_client_protocol::ToolCall),\n    ToolUpdate(agent_client_protocol::ToolCallUpdate),\n    Plan(agent_client_protocol::Plan),\n    AvailableCommands(Vec<agent_client_protocol::AvailableCommand>),\n    CurrentMode(agent_client_protocol::SessionModeId),\n    RequestPermission(agent_client_protocol::RequestPermissionRequest),\n    ApprovalRequested {\n        tool_call_id: String,\n        approval_id: String,\n    },\n    ApprovalResponse(ApprovalResponse),\n    Error(String),\n    Done(String),\n    Other(agent_client_protocol::SessionNotification),\n}\n\nimpl Display for AcpEvent {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", serde_json::to_string(self).unwrap_or_default())\n    }\n}\n\nimpl FromStr for AcpEvent {\n    type Err = serde_json::Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        serde_json::from_str(s)\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ApprovalResponse {\n    pub tool_call_id: String,\n    pub status: ApprovalStatus,\n}\n"
  },
  {
    "path": "crates/executors/src/executors/acp/normalize_logs.rs",
    "content": "use std::{\n    collections::HashMap,\n    path::{Path, PathBuf},\n    sync::{Arc, LazyLock},\n    time::Duration,\n};\n\nuse agent_client_protocol::{self as acp, SessionNotification};\nuse futures::StreamExt;\nuse regex::Regex;\nuse serde::Deserialize;\nuse workspace_utils::{approvals::ApprovalStatus, msg_store::MsgStore};\n\npub use super::AcpAgentHarness;\nuse super::AcpEvent;\nuse crate::{\n    approvals::ToolCallMetadata,\n    logs::{\n        ActionType, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType,\n        TodoItem, ToolResult, ToolResultValueType, ToolStatus as LogToolStatus,\n        plain_text_processor::PlainTextLogProcessor,\n        stderr_processor::normalize_stderr_logs,\n        utils::{ConversationPatch, EntryIndexProvider, shell_command_parsing::CommandCategory},\n    },\n};\n\npub fn normalize_logs(\n    msg_store: Arc<MsgStore>,\n    worktree_path: &Path,\n) -> Vec<tokio::task::JoinHandle<()>> {\n    normalize_logs_with_suppressed_stderr_patterns(msg_store, worktree_path, &[])\n}\n\npub fn normalize_logs_with_suppressed_stderr_patterns(\n    msg_store: Arc<MsgStore>,\n    worktree_path: &Path,\n    suppressed_stderr_patterns: &[&str],\n) -> Vec<tokio::task::JoinHandle<()>> {\n    // stderr normalization\n    let entry_index = EntryIndexProvider::start_from(&msg_store);\n    let h1 = if suppressed_stderr_patterns.is_empty() {\n        normalize_stderr_logs(msg_store.clone(), entry_index.clone())\n    } else {\n        normalize_acp_stderr_logs(\n            msg_store.clone(),\n            entry_index.clone(),\n            suppressed_stderr_patterns\n                .iter()\n                .map(|pattern| pattern.to_string())\n                .collect(),\n        )\n    };\n\n    // stdout normalization (main loop)\n    let worktree_path = worktree_path.to_path_buf();\n    // Type aliases to simplify complex state types and appease clippy\n    let h2 = tokio::spawn(async move {\n        type ToolStates = std::collections::HashMap<String, PartialToolCallData>;\n\n        let mut stored_session_id = false;\n        let mut streaming: StreamingState = StreamingState::default();\n        let mut tool_states: ToolStates = HashMap::new();\n\n        let mut stdout_lines = msg_store.stdout_lines_stream();\n        while let Some(Ok(line)) = stdout_lines.next().await {\n            if let Some(parsed) = AcpEventParser::parse_line(&line) {\n                tracing::trace!(\"Parsed ACP line: {:?}\", parsed);\n                match parsed {\n                    AcpEvent::SessionStart(id) => {\n                        if !stored_session_id {\n                            msg_store.push_session_id(id);\n                            stored_session_id = true;\n                        }\n                    }\n                    AcpEvent::Error(msg) => {\n                        let idx = entry_index.next();\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ErrorMessage {\n                                error_type: NormalizedEntryError::Other,\n                            },\n                            content: msg,\n                            metadata: None,\n                        };\n                        msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry));\n                    }\n                    AcpEvent::Done(_) => {\n                        streaming.assistant_text = None;\n                        streaming.thinking_text = None;\n                    }\n                    AcpEvent::Message(content) => {\n                        streaming.thinking_text = None;\n                        if let agent_client_protocol::ContentBlock::Text(text) = content {\n                            let is_new = streaming.assistant_text.is_none();\n                            if is_new {\n                                if text.text == \"\\n\" {\n                                    continue;\n                                }\n                                let idx = entry_index.next();\n                                streaming.assistant_text = Some(StreamingText {\n                                    index: idx,\n                                    content: String::new(),\n                                });\n                            }\n                            if let Some(ref mut s) = streaming.assistant_text {\n                                s.content.push_str(&text.text);\n                                let entry = NormalizedEntry {\n                                    timestamp: None,\n                                    entry_type: NormalizedEntryType::AssistantMessage,\n                                    content: s.content.clone(),\n                                    metadata: None,\n                                };\n                                let patch = if is_new {\n                                    ConversationPatch::add_normalized_entry(s.index, entry)\n                                } else {\n                                    ConversationPatch::replace(s.index, entry)\n                                };\n                                msg_store.push_patch(patch);\n                            }\n                        }\n                    }\n                    AcpEvent::Thought(content) => {\n                        streaming.assistant_text = None;\n                        if let agent_client_protocol::ContentBlock::Text(text) = content {\n                            let is_new = streaming.thinking_text.is_none();\n                            if is_new {\n                                let idx = entry_index.next();\n                                streaming.thinking_text = Some(StreamingText {\n                                    index: idx,\n                                    content: String::new(),\n                                });\n                            }\n                            if let Some(ref mut s) = streaming.thinking_text {\n                                s.content.push_str(&text.text);\n                                let entry = NormalizedEntry {\n                                    timestamp: None,\n                                    entry_type: NormalizedEntryType::Thinking,\n                                    content: s.content.clone(),\n                                    metadata: None,\n                                };\n                                let patch = if is_new {\n                                    ConversationPatch::add_normalized_entry(s.index, entry)\n                                } else {\n                                    ConversationPatch::replace(s.index, entry)\n                                };\n                                msg_store.push_patch(patch);\n                            }\n                        }\n                    }\n                    AcpEvent::Plan(plan) => {\n                        streaming.assistant_text = None;\n                        streaming.thinking_text = None;\n                        let todos: Vec<TodoItem> = plan\n                            .entries\n                            .iter()\n                            .map(|e| TodoItem {\n                                content: e.content.clone(),\n                                status: serde_json::to_value(&e.status)\n                                    .ok()\n                                    .and_then(|v| v.as_str().map(|s| s.to_string()))\n                                    .unwrap_or_else(|| \"unknown\".to_string()),\n                                priority: serde_json::to_value(&e.priority)\n                                    .ok()\n                                    .and_then(|v| v.as_str().map(|s| s.to_string())),\n                            })\n                            .collect();\n\n                        let idx = entry_index.next();\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ToolUse {\n                                tool_name: \"plan\".to_string(),\n                                action_type: ActionType::TodoManagement {\n                                    todos,\n                                    operation: \"update\".to_string(),\n                                },\n                                status: LogToolStatus::Success,\n                            },\n                            content: \"Plan updated\".to_string(),\n                            metadata: None,\n                        };\n                        msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry));\n                    }\n                    AcpEvent::AvailableCommands(cmds) => {\n                        let mut body = String::from(\"Available commands:\\n\");\n                        for c in &cmds {\n                            body.push_str(&format!(\"- {}\\n\", c.name));\n                        }\n                        let idx = entry_index.next();\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: body,\n                            metadata: None,\n                        };\n                        msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry));\n                    }\n                    AcpEvent::CurrentMode(mode_id) => {\n                        let idx = entry_index.next();\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: format!(\"Current mode: {}\", mode_id.0),\n                            metadata: None,\n                        };\n                        msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry));\n                    }\n                    AcpEvent::RequestPermission(perm) => {\n                        if let Ok(tc) = agent_client_protocol::ToolCall::try_from(perm.tool_call) {\n                            handle_tool_call(\n                                &tc,\n                                &worktree_path,\n                                &mut streaming,\n                                &mut tool_states,\n                                &entry_index,\n                                &msg_store,\n                            );\n                        }\n                    }\n                    AcpEvent::ToolCall(tc) => handle_tool_call(\n                        &tc,\n                        &worktree_path,\n                        &mut streaming,\n                        &mut tool_states,\n                        &entry_index,\n                        &msg_store,\n                    ),\n                    AcpEvent::ToolUpdate(update) => {\n                        let mut update = update;\n                        if update.fields.title.is_none() {\n                            update.fields.title = tool_states\n                                .get(&update.tool_call_id.0.to_string())\n                                .map(|s| s.title.clone())\n                                .or_else(|| Some(\"\".to_string()));\n                        }\n                        tracing::trace!(\"Got tool call update: {:?}\", update);\n                        if let Ok(tc) = agent_client_protocol::ToolCall::try_from(update.clone()) {\n                            handle_tool_call(\n                                &tc,\n                                &worktree_path,\n                                &mut streaming,\n                                &mut tool_states,\n                                &entry_index,\n                                &msg_store,\n                            );\n                        } else {\n                            tracing::debug!(\"Failed to convert tool call update to ToolCall\");\n                        }\n                    }\n                    AcpEvent::ApprovalRequested {\n                        tool_call_id,\n                        approval_id,\n                    } => {\n                        if let Some(tool_data) = tool_states.get(&tool_call_id) {\n                            let action = map_to_action_type(tool_data);\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::ToolUse {\n                                    tool_name: tool_data.title.clone(),\n                                    action_type: action,\n                                    status: LogToolStatus::PendingApproval { approval_id },\n                                },\n                                content: get_tool_content(tool_data),\n                                metadata: None,\n                            };\n                            msg_store\n                                .push_patch(ConversationPatch::replace(tool_data.index, entry));\n                        }\n                    }\n                    AcpEvent::ApprovalResponse(resp) => {\n                        tracing::trace!(\"Received approval response: {:?}\", resp);\n\n                        if let Some(tool_data) = tool_states.get(&resp.tool_call_id) {\n                            let new_status = LogToolStatus::from_approval_status(&resp.status);\n                            if let Some(status) = new_status {\n                                let action = map_to_action_type(tool_data);\n                                let entry = NormalizedEntry {\n                                    timestamp: None,\n                                    entry_type: NormalizedEntryType::ToolUse {\n                                        tool_name: tool_data.title.clone(),\n                                        action_type: action,\n                                        status,\n                                    },\n                                    content: get_tool_content(tool_data),\n                                    metadata: serde_json::to_value(ToolCallMetadata {\n                                        tool_call_id: tool_data.id.0.to_string(),\n                                    })\n                                    .ok(),\n                                };\n                                msg_store\n                                    .push_patch(ConversationPatch::replace(tool_data.index, entry));\n                            }\n                        }\n\n                        if let ApprovalStatus::Denied { reason } = resp.status {\n                            let tool_name = tool_states\n                                .get(&resp.tool_call_id)\n                                .map(|t| {\n                                    extract_tool_name_from_id(t.id.0.as_ref())\n                                        .unwrap_or_else(|| t.title.clone())\n                                })\n                                .unwrap_or_default();\n                            let idx = entry_index.next();\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::UserFeedback {\n                                    denied_tool: tool_name,\n                                },\n                                content: reason\n                                    .clone()\n                                    .unwrap_or_else(|| {\n                                        \"User denied this tool use request\".to_string()\n                                    })\n                                    .trim()\n                                    .to_string(),\n                                metadata: None,\n                            };\n                            msg_store\n                                .push_patch(ConversationPatch::add_normalized_entry(idx, entry));\n                        }\n                    }\n                    AcpEvent::User(_) | AcpEvent::Other(_) => (),\n                }\n            }\n        }\n\n        fn handle_tool_call(\n            tc: &agent_client_protocol::ToolCall,\n            worktree_path: &Path,\n            streaming: &mut StreamingState,\n            tool_states: &mut ToolStates,\n            entry_index: &EntryIndexProvider,\n            msg_store: &Arc<MsgStore>,\n        ) {\n            streaming.assistant_text = None;\n            streaming.thinking_text = None;\n            let id = tc.tool_call_id.0.to_string();\n            let is_new = !tool_states.contains_key(&id);\n            let tool_data = tool_states.entry(id).or_default();\n            tool_data.extend(tc, worktree_path);\n            if is_new {\n                tool_data.index = entry_index.next();\n            }\n            let action = map_to_action_type(tool_data);\n            let entry = NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ToolUse {\n                    tool_name: tool_data.title.clone(),\n                    action_type: action,\n                    status: convert_tool_status(&tool_data.status),\n                },\n                content: get_tool_content(tool_data),\n                metadata: serde_json::to_value(ToolCallMetadata {\n                    tool_call_id: tool_data.id.0.to_string(),\n                })\n                .ok(),\n            };\n            let patch = if is_new {\n                ConversationPatch::add_normalized_entry(tool_data.index, entry)\n            } else {\n                ConversationPatch::replace(tool_data.index, entry)\n            };\n            msg_store.push_patch(patch);\n        }\n\n        fn map_to_action_type(tc: &PartialToolCallData) -> ActionType {\n            match tc.kind {\n                agent_client_protocol::ToolKind::Read => {\n                    // Special-case: read_many_files style titles parsed via helper\n                    if tc.id.0.starts_with(\"read_many_files\") {\n                        let result = collect_text_content(&tc.content).map(|text| ToolResult {\n                            r#type: ToolResultValueType::Markdown,\n                            value: serde_json::Value::String(text),\n                        });\n                        return ActionType::Tool {\n                            tool_name: \"read_many_files\".to_string(),\n                            arguments: Some(serde_json::Value::String(tc.title.clone())),\n                            result,\n                        };\n                    }\n                    ActionType::FileRead {\n                        path: tc\n                            .path\n                            .clone()\n                            .unwrap_or_default()\n                            .to_string_lossy()\n                            .to_string(),\n                    }\n                }\n                agent_client_protocol::ToolKind::Edit => {\n                    let changes = extract_file_changes(tc);\n                    ActionType::FileEdit {\n                        path: tc\n                            .path\n                            .clone()\n                            .unwrap_or_default()\n                            .to_string_lossy()\n                            .to_string(),\n                        changes,\n                    }\n                }\n                agent_client_protocol::ToolKind::Execute => {\n                    let command = AcpEventParser::parse_execute_command(tc);\n                    // Prefer structured raw_output, else fallback to aggregated text content\n                    let completed =\n                        matches!(tc.status, agent_client_protocol::ToolCallStatus::Completed);\n                    tracing::trace!(\n                        \"Mapping execute tool call, completed: {}, command: {}\",\n                        completed,\n                        command\n                    );\n                    let tc_exit_status = match tc.status {\n                        agent_client_protocol::ToolCallStatus::Completed => {\n                            Some(crate::logs::CommandExitStatus::Success { success: true })\n                        }\n                        agent_client_protocol::ToolCallStatus::Failed => {\n                            Some(crate::logs::CommandExitStatus::Success { success: false })\n                        }\n                        _ => None,\n                    };\n\n                    let result = if let Some(text) = collect_text_content(&tc.content) {\n                        Some(crate::logs::CommandRunResult {\n                            exit_status: tc_exit_status,\n                            output: Some(text),\n                        })\n                    } else {\n                        Some(crate::logs::CommandRunResult {\n                            exit_status: tc_exit_status,\n                            output: None,\n                        })\n                    };\n                    ActionType::CommandRun {\n                        command: command.clone(),\n                        result,\n                        category: CommandCategory::from_command(&command),\n                    }\n                }\n                agent_client_protocol::ToolKind::Delete => ActionType::FileEdit {\n                    path: tc\n                        .path\n                        .clone()\n                        .unwrap_or_default()\n                        .to_string_lossy()\n                        .to_string(),\n                    changes: vec![FileChange::Delete],\n                },\n                agent_client_protocol::ToolKind::Search => {\n                    let query = tc\n                        .raw_input\n                        .as_ref()\n                        .and_then(|v| serde_json::from_value::<SearchArgs>(v.clone()).ok())\n                        .map(|a| a.query)\n                        .unwrap_or_else(|| tc.title.clone());\n                    ActionType::Search { query }\n                }\n                agent_client_protocol::ToolKind::Fetch => {\n                    let mut url = tc\n                        .raw_input\n                        .as_ref()\n                        .and_then(|v| serde_json::from_value::<FetchArgs>(v.clone()).ok())\n                        .map(|a| a.url)\n                        .unwrap_or_default();\n                    if url.is_empty() {\n                        // Fallback: try to extract first URL from the title\n                        if let Some(extracted) = extract_url_from_text(&tc.title) {\n                            url = extracted;\n                        }\n                    }\n                    ActionType::WebFetch { url }\n                }\n                agent_client_protocol::ToolKind::Think => {\n                    let tool_name = extract_tool_name_from_id(tc.id.0.as_ref())\n                        .unwrap_or_else(|| tc.title.clone());\n                    // For think/save_memory, surface both title and aggregated text content as arguments\n                    let text = collect_text_content(&tc.content);\n                    let arguments = Some(match &text {\n                        Some(t) => serde_json::json!({ \"title\": tc.title, \"content\": t }),\n                        None => serde_json::json!({ \"title\": tc.title }),\n                    });\n                    let result = if let Some(output) = &tc.raw_output {\n                        Some(ToolResult {\n                            r#type: ToolResultValueType::Json,\n                            value: output.clone(),\n                        })\n                    } else {\n                        collect_text_content(&tc.content).map(|text| ToolResult {\n                            r#type: ToolResultValueType::Markdown,\n                            value: serde_json::Value::String(text),\n                        })\n                    };\n                    ActionType::Tool {\n                        tool_name,\n                        arguments,\n                        result,\n                    }\n                }\n                agent_client_protocol::ToolKind::SwitchMode => ActionType::Other {\n                    description: \"switch_mode\".to_string(),\n                },\n                agent_client_protocol::ToolKind::Other\n                | agent_client_protocol::ToolKind::Move\n                | _ => {\n                    // Derive a friendlier tool name from the id if it looks like name-<digits>\n                    let tool_name = extract_tool_name_from_id(tc.id.0.as_ref())\n                        .unwrap_or_else(|| tc.title.clone());\n\n                    // Some tools embed JSON args into the title instead of raw_input\n                    let arguments = if let Some(raw) = &tc.raw_input {\n                        Some(raw.clone())\n                    } else if tc.title.trim_start().starts_with('{') {\n                        // Title contains JSON arguments for the tool\n                        serde_json::from_str::<serde_json::Value>(&tc.title).ok()\n                    } else {\n                        None\n                    };\n                    // Extract result: prefer raw_output (structured), else text content as Markdown\n                    let result = if let Some(output) = &tc.raw_output {\n                        Some(ToolResult {\n                            r#type: ToolResultValueType::Json,\n                            value: output.clone(),\n                        })\n                    } else {\n                        collect_text_content(&tc.content).map(|text| ToolResult {\n                            r#type: ToolResultValueType::Markdown,\n                            value: serde_json::Value::String(text),\n                        })\n                    };\n                    ActionType::Tool {\n                        tool_name,\n                        arguments,\n                        result,\n                    }\n                }\n            }\n        }\n\n        fn extract_file_changes(tc: &PartialToolCallData) -> Vec<FileChange> {\n            let mut changes = Vec::new();\n            for c in &tc.content {\n                if let agent_client_protocol::ToolCallContent::Diff(diff) = c {\n                    let path = diff.path.to_string_lossy().to_string();\n                    let rel = if !path.is_empty() {\n                        path\n                    } else {\n                        tc.path\n                            .clone()\n                            .unwrap_or_default()\n                            .to_string_lossy()\n                            .to_string()\n                    };\n                    let old_text = diff.old_text.as_deref().unwrap_or(\"\");\n                    if old_text.is_empty() {\n                        changes.push(FileChange::Write {\n                            content: diff.new_text.clone(),\n                        });\n                    } else {\n                        let unified = workspace_utils::diff::create_unified_diff(\n                            &rel,\n                            old_text,\n                            &diff.new_text,\n                        );\n                        changes.push(FileChange::Edit {\n                            unified_diff: unified,\n                            has_line_numbers: false,\n                        });\n                    }\n                }\n            }\n            if changes.is_empty()\n                && let Some(raw) = &tc.raw_input\n                && let Ok(edit_input) = serde_json::from_value::<EditInput>(raw.clone())\n            {\n                if let Some(diff) = edit_input.diff {\n                    changes.push(FileChange::Edit {\n                        unified_diff: workspace_utils::diff::normalize_unified_diff(\n                            &edit_input.file_path,\n                            &diff,\n                        ),\n                        has_line_numbers: true,\n                    });\n                } else if let Some(old) = edit_input.old_string\n                    && let Some(new) = edit_input.new_string\n                {\n                    changes.push(FileChange::Edit {\n                        unified_diff: workspace_utils::diff::create_unified_diff(\n                            &edit_input.file_path,\n                            &old,\n                            &new,\n                        ),\n                        has_line_numbers: false,\n                    });\n                }\n            }\n            changes\n        }\n\n        fn get_tool_content(tc: &PartialToolCallData) -> String {\n            match tc.kind {\n                agent_client_protocol::ToolKind::Execute => {\n                    AcpEventParser::parse_execute_command(tc)\n                }\n                agent_client_protocol::ToolKind::Think => \"Saving memory\".to_string(),\n                agent_client_protocol::ToolKind::Other => {\n                    let tool_name = extract_tool_name_from_id(tc.id.0.as_ref())\n                        .unwrap_or_else(|| \"tool\".to_string());\n                    if tc.title.is_empty() {\n                        tool_name\n                    } else {\n                        format!(\"{}: {}\", tool_name, tc.title)\n                    }\n                }\n                agent_client_protocol::ToolKind::Read => {\n                    if tc.id.0.starts_with(\"read_many_files\") {\n                        \"Read files\".to_string()\n                    } else {\n                        tc.path\n                            .as_ref()\n                            .map(|p| p.display().to_string())\n                            .unwrap_or_else(|| tc.title.clone())\n                    }\n                }\n                _ => tc.title.clone(),\n            }\n        }\n\n        fn extract_tool_name_from_id(id: &str) -> Option<String> {\n            if let Some(idx) = id.rfind('-') {\n                let (head, tail) = id.split_at(idx);\n                if tail\n                    .trim_start_matches('-')\n                    .chars()\n                    .all(|c| c.is_ascii_digit())\n                {\n                    return Some(head.to_string());\n                }\n            }\n            None\n        }\n\n        fn extract_url_from_text(text: &str) -> Option<String> {\n            // Simple URL extractor\n            static URL_RE: LazyLock<Regex> =\n                LazyLock::new(|| Regex::new(r#\"https?://[^\\s\"')]+\"#).expect(\"valid regex\"));\n            URL_RE.find(text).map(|m| m.as_str().to_string())\n        }\n\n        fn collect_text_content(\n            content: &[agent_client_protocol::ToolCallContent],\n        ) -> Option<String> {\n            let mut out = String::new();\n            for c in content {\n                if let agent_client_protocol::ToolCallContent::Content(inner) = c\n                    && let agent_client_protocol::ContentBlock::Text(t) = &inner.content\n                {\n                    out.push_str(&t.text);\n                    if !out.ends_with('\\n') {\n                        out.push('\\n');\n                    }\n                }\n            }\n            if out.is_empty() { None } else { Some(out) }\n        }\n\n        fn convert_tool_status(status: &agent_client_protocol::ToolCallStatus) -> LogToolStatus {\n            match status {\n                agent_client_protocol::ToolCallStatus::Pending\n                | agent_client_protocol::ToolCallStatus::InProgress => LogToolStatus::Created,\n                agent_client_protocol::ToolCallStatus::Completed => LogToolStatus::Success,\n                agent_client_protocol::ToolCallStatus::Failed => LogToolStatus::Failed,\n                _ => {\n                    tracing::debug!(\"Unknown tool call status: {:?}\", status);\n                    LogToolStatus::Created\n                }\n            }\n        }\n    });\n\n    vec![h1, h2]\n}\n\nfn normalize_acp_stderr_logs(\n    msg_store: Arc<MsgStore>,\n    entry_index_provider: EntryIndexProvider,\n    suppressed_patterns: Vec<String>,\n) -> tokio::task::JoinHandle<()> {\n    tokio::spawn(async move {\n        let mut stderr = msg_store.stderr_chunked_stream();\n\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(Box::new(|content: String| NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::Other,\n                },\n                content: strip_ansi_escapes::strip_str(&content),\n                metadata: None,\n            }))\n            .time_gap(Duration::from_secs(2))\n            .index_provider(entry_index_provider)\n            .transform_lines(Box::new(move |lines: &mut Vec<String>| {\n                lines.retain(|line| {\n                    !suppressed_patterns\n                        .iter()\n                        .any(|pattern| line.contains(pattern))\n                });\n            }))\n            .build();\n\n        while let Some(Ok(chunk)) = stderr.next().await {\n            for patch in processor.process(chunk) {\n                msg_store.push_patch(patch);\n            }\n        }\n    })\n}\n\nstruct PartialToolCallData {\n    index: usize,\n    id: agent_client_protocol::ToolCallId,\n    kind: agent_client_protocol::ToolKind,\n    title: String,\n    status: agent_client_protocol::ToolCallStatus,\n    path: Option<PathBuf>,\n    content: Vec<agent_client_protocol::ToolCallContent>,\n    raw_input: Option<serde_json::Value>,\n    raw_output: Option<serde_json::Value>,\n}\n\nimpl PartialToolCallData {\n    fn extend(&mut self, tc: &agent_client_protocol::ToolCall, worktree_path: &Path) {\n        self.id = tc.tool_call_id.clone();\n        if tc.kind != Default::default() {\n            self.kind = tc.kind;\n        }\n        if !tc.title.is_empty() {\n            self.title = tc.title.clone();\n        }\n        if tc.status != Default::default() {\n            self.status = tc.status;\n        }\n        if !tc.locations.is_empty() {\n            self.path = tc.locations.first().map(|l| {\n                PathBuf::from(workspace_utils::path::make_path_relative(\n                    &l.path.to_string_lossy(),\n                    &worktree_path.to_string_lossy(),\n                ))\n            });\n        }\n        if !tc.content.is_empty() {\n            self.content = tc.content.clone();\n        }\n        if tc.raw_input.is_some() {\n            self.raw_input = tc.raw_input.clone();\n        }\n        if tc.raw_output.is_some() {\n            self.raw_output = tc.raw_output.clone();\n        }\n    }\n}\n\nimpl Default for PartialToolCallData {\n    fn default() -> Self {\n        Self {\n            id: agent_client_protocol::ToolCallId::new(\"\"),\n            index: 0,\n            kind: agent_client_protocol::ToolKind::default(),\n            title: String::new(),\n            status: Default::default(),\n            path: None,\n            content: Vec::new(),\n            raw_input: None,\n            raw_output: None,\n        }\n    }\n}\n\nstruct AcpEventParser;\n\nimpl AcpEventParser {\n    /// Parse a line that may contain an ACP event\n    pub fn parse_line(line: &str) -> Option<AcpEvent> {\n        let trimmed = line.trim();\n\n        if let Ok(acp_event) = serde_json::from_str::<AcpEvent>(trimmed) {\n            return Some(acp_event);\n        }\n\n        tracing::debug!(\"Failed to parse ACP raw log {trimmed}\");\n\n        None\n    }\n\n    /// Parse command from tool title (for execute tools)\n    pub fn parse_execute_command(tc: &PartialToolCallData) -> String {\n        if let Some(command) = tc.raw_input.as_ref().and_then(|value| {\n            value\n                .as_object()\n                .and_then(|o| o.get(\"command\").and_then(|v| v.as_str()))\n        }) {\n            return command.to_string();\n        }\n        let title = &tc.title;\n        if let Some(command) = title.split(\" [current working directory \").next() {\n            command.trim().to_string()\n        } else if let Some(command) = title.split(\" (\").next() {\n            command.trim().to_string()\n        } else {\n            title.trim().to_string()\n        }\n    }\n}\n\n/// Result of parsing a line\n#[derive(Debug, Clone)]\n#[allow(clippy::large_enum_variant)]\npub enum ParsedLine {\n    SessionId(String),\n    Event(AcpEvent),\n    Error(String),\n    Done,\n}\n\nimpl TryFrom<SessionNotification> for AcpEvent {\n    type Error = ();\n\n    fn try_from(notification: SessionNotification) -> Result<Self, ()> {\n        let event = match notification.update {\n            acp::SessionUpdate::AgentMessageChunk(chunk) => AcpEvent::Message(chunk.content),\n            acp::SessionUpdate::AgentThoughtChunk(chunk) => AcpEvent::Thought(chunk.content),\n            acp::SessionUpdate::ToolCall(tc) => AcpEvent::ToolCall(tc),\n            acp::SessionUpdate::ToolCallUpdate(update) => AcpEvent::ToolUpdate(update),\n            acp::SessionUpdate::Plan(plan) => AcpEvent::Plan(plan),\n            acp::SessionUpdate::AvailableCommandsUpdate(update) => {\n                AcpEvent::AvailableCommands(update.available_commands)\n            }\n            acp::SessionUpdate::CurrentModeUpdate(update) => {\n                AcpEvent::CurrentMode(update.current_mode_id)\n            }\n            _ => return Err(()),\n        };\n        Ok(event)\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\nstruct SearchArgs {\n    query: String,\n}\n\n#[derive(Debug, Clone, Deserialize)]\nstruct FetchArgs {\n    url: String,\n}\n\n#[derive(Debug, Clone, Default)]\nstruct StreamingState {\n    assistant_text: Option<StreamingText>,\n    thinking_text: Option<StreamingText>,\n}\n\n#[derive(Debug, Clone)]\nstruct StreamingText {\n    index: usize,\n    content: String,\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct EditInput {\n    file_path: String,\n    #[serde(default)]\n    diff: Option<String>,\n    #[serde(default)]\n    old_string: Option<String>,\n    #[serde(default)]\n    new_string: Option<String>,\n}\n"
  },
  {
    "path": "crates/executors/src/executors/acp/session.rs",
    "content": "use std::{\n    fs::{self, OpenOptions},\n    io::{self, Result, Write},\n    path::PathBuf,\n    str::FromStr,\n};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::executors::acp::AcpEvent;\n\n/// Manages session persistence and state for ACP interactions\npub struct SessionManager {\n    base_dir: PathBuf,\n}\n\nimpl SessionManager {\n    /// Create a new session manager with the given namespace\n    pub fn new(namespace: impl Into<String>) -> Result<Self> {\n        let namespace = namespace.into();\n        let mut vk_dir = dirs::home_dir()\n            .ok_or_else(|| io::Error::other(\"Could not determine home directory\"))?\n            .join(\".vibe-kanban\");\n\n        if cfg!(debug_assertions) {\n            vk_dir = vk_dir.join(\"dev\");\n        }\n\n        let base_dir = vk_dir.join(&namespace);\n\n        fs::create_dir_all(&base_dir)?;\n\n        Ok(Self { base_dir })\n    }\n\n    /// Get the file path for a session\n    fn session_file_path(&self, session_id: &str) -> PathBuf {\n        self.base_dir.join(format!(\"{session_id}.jsonl\"))\n    }\n\n    /// Append a raw JSON line to the session log\n    ///\n    /// We normalize ACP payloads by:\n    /// - Removing top-level `sessionId`\n    /// - Unwrapping the `update` envelope (store its object directly)\n    /// - Dropping top-level `options` (permission menu). Note: `options` is\n    ///   mutually exclusive with `update`, so when `update` is present we do not\n    ///   perform any `options` stripping.\n    pub fn append_raw_line(&self, session_id: &str, raw_json: &str) -> Result<()> {\n        let Some(normalized) = Self::normalize_session_event(raw_json) else {\n            return Ok(());\n        };\n\n        let path = self.session_file_path(session_id);\n        let mut file = OpenOptions::new().create(true).append(true).open(path)?;\n\n        writeln!(file, \"{normalized}\")?;\n        Ok(())\n    }\n\n    /// Attempt to normalize a raw ACP JSON event into a cleaner shape.\n    /// Rules:\n    /// - Remove top-level `sessionId` always.\n    /// - If `update` is present with an object that has `sessionUpdate`, emit\n    ///   a single-key object where key = camelCase(sessionUpdate) and value =\n    ///   the `update` object minus `sessionUpdate`.\n    /// - If `update` is absent, remove only top-level `options`.\n    ///\n    /// Returns None if the input is not a JSON object.\n    fn normalize_session_event(raw_json: &str) -> Option<String> {\n        let mut event = AcpEvent::from_str(raw_json).ok()?;\n\n        match event {\n            AcpEvent::SessionStart(..)\n            | AcpEvent::Error(..)\n            | AcpEvent::Done(..)\n            | AcpEvent::Other(..) => return None,\n\n            AcpEvent::User(..)\n            | AcpEvent::Message(..)\n            | AcpEvent::Thought(..)\n            | AcpEvent::ToolCall(..)\n            | AcpEvent::ToolUpdate(..)\n            | AcpEvent::Plan(..)\n            | AcpEvent::AvailableCommands(..)\n            | AcpEvent::ApprovalRequested { .. }\n            | AcpEvent::ApprovalResponse(..)\n            | AcpEvent::CurrentMode(..) => {}\n\n            AcpEvent::RequestPermission(req) => event = AcpEvent::ToolUpdate(req.tool_call),\n        }\n\n        match event {\n            AcpEvent::User(prompt) => {\n                return serde_json::to_string(&serde_json::json!({\"user\": prompt})).ok();\n            }\n            AcpEvent::Message(ref content) | AcpEvent::Thought(ref content) => {\n                if let agent_client_protocol::ContentBlock::Text(text) = content {\n                    // Special simplification for pure text messages\n                    let key = if let AcpEvent::Message(_) = event {\n                        \"assistant\"\n                    } else {\n                        \"thinking\"\n                    };\n                    return serde_json::to_string(&serde_json::json!({ key: text.text })).ok();\n                }\n            }\n            _ => {}\n        }\n\n        serde_json::to_string(&event).ok()\n    }\n\n    /// Read the raw JSONL content of a session\n    pub fn read_session_raw(&self, session_id: &str) -> Result<String> {\n        let path = self.session_file_path(session_id);\n        if !path.exists() {\n            return Ok(String::new());\n        }\n\n        fs::read_to_string(path)\n    }\n\n    /// Fork a session to create a new one with the same history\n    pub fn fork_session(&self, old_id: &str, new_id: &str) -> Result<()> {\n        let old_path = self.session_file_path(old_id);\n        let new_path = self.session_file_path(new_id);\n\n        if old_path.exists() {\n            fs::copy(&old_path, &new_path)?;\n        } else {\n            // Create empty new file if old doesn't exist\n            OpenOptions::new()\n                .create(true)\n                .write(true)\n                .truncate(true)\n                .open(&new_path)?;\n        }\n\n        Ok(())\n    }\n\n    /// Delete a session\n    pub fn delete_session(&self, session_id: &str) -> Result<()> {\n        let path = self.session_file_path(session_id);\n        if path.exists() {\n            fs::remove_file(path)?;\n        }\n        Ok(())\n    }\n\n    /// Generate a resume prompt from session history\n    pub fn generate_resume_prompt(&self, session_id: &str, current_prompt: &str) -> Result<String> {\n        let session_context = self.read_session_raw(session_id)?;\n\n        Ok(format!(\n            concat!(\n                \"RESUME CONTEXT FOR CONTINUING TASK\\n\\n\",\n                \"=== EXECUTION HISTORY ===\\n\",\n                \"The following is the conversation history from this session:\\n\",\n                \"{}\\n\\n\",\n                \"=== CURRENT REQUEST ===\\n\",\n                \"{}\\n\\n\",\n                \"=== INSTRUCTIONS ===\\n\",\n                \"You are continuing work on the above task. The execution history shows \",\n                \"the previous conversation in this session. Please continue from where \",\n                \"the previous execution left off, taking into account all the context provided above.\"\n            ),\n            session_context, current_prompt\n        ))\n    }\n}\n\n/// Session metadata stored separately from events\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SessionMetadata {\n    pub session_id: String,\n    pub created_at: chrono::DateTime<chrono::Utc>,\n    pub updated_at: chrono::DateTime<chrono::Utc>,\n    pub parent_session: Option<String>,\n    pub tags: Vec<String>,\n}\n"
  },
  {
    "path": "crates/executors/src/executors/amp.rs",
    "content": "use std::{path::Path, process::Stdio, sync::Arc};\n\nuse async_trait::async_trait;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse tokio::{io::AsyncWriteExt, process::Command};\nuse ts_rs::TS;\nuse workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore};\n\nuse crate::{\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides},\n    env::ExecutionEnv,\n    executors::{\n        AppendPrompt, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor,\n        claude::{ClaudeLogProcessor, HistoryStrategy},\n    },\n    logs::{stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider},\n    profile::ExecutorConfig,\n};\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]\npub struct Amp {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    #[schemars(\n        title = \"Dangerously Allow All\",\n        description = \"Allow all commands to be executed, even if they are not safe.\"\n    )]\n    pub dangerously_allow_all: Option<bool>,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n}\n\nimpl Amp {\n    fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        let mut builder = CommandBuilder::new(\"npx -y @sourcegraph/amp@latest\")\n            .params([\"--execute\", \"--stream-json\"]);\n        if self.dangerously_allow_all.unwrap_or(false) {\n            builder = builder.extend_params([\"--dangerously-allow-all\"]);\n        }\n        apply_overrides(builder, &self.cmd)\n    }\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for Amp {\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let command_parts = self.build_command_builder()?.build_initial()?;\n        let (executable_path, args) = command_parts.into_resolved().await?;\n\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n\n        let mut command = Command::new(executable_path);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .args(&args);\n\n        env.clone()\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut command);\n\n        let mut child = command.group_spawn_no_window()?;\n\n        // Feed the prompt in, then close the pipe so amp sees EOF\n        if let Some(mut stdin) = child.inner().stdin.take() {\n            stdin.write_all(combined_prompt.as_bytes()).await?;\n            stdin.shutdown().await?;\n        }\n\n        Ok(child.into())\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let builder = self.build_command_builder()?;\n        let continue_line = builder.build_follow_up(&[\n            \"threads\".to_string(),\n            \"continue\".to_string(),\n            session_id.to_string(),\n        ])?;\n        let (continue_program, continue_args) = continue_line.into_resolved().await?;\n\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n\n        let mut command = Command::new(continue_program);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .args(&continue_args);\n\n        env.clone()\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut command);\n\n        let mut child = command.group_spawn_no_window()?;\n\n        // Feed the prompt in, then close the pipe so amp sees EOF\n        if let Some(mut stdin) = child.inner().stdin.take() {\n            stdin.write_all(combined_prompt.as_bytes()).await?;\n            stdin.shutdown().await?;\n        }\n\n        Ok(child.into())\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        current_dir: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        let entry_index_provider = EntryIndexProvider::start_from(&msg_store);\n\n        // Process stdout logs (Amp's stream JSON output) using Claude's log processor\n        let h1 = ClaudeLogProcessor::process_logs(\n            msg_store.clone(),\n            current_dir,\n            entry_index_provider.clone(),\n            HistoryStrategy::AmpResume,\n        );\n\n        // Process stderr logs using the standard stderr processor\n        let h2 = normalize_stderr_logs(msg_store, entry_index_provider);\n\n        vec![h1, h2]\n    }\n\n    // MCP configuration methods\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        dirs::home_dir().map(|home| home.join(\".config\").join(\"amp\").join(\"settings.json\"))\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        ExecutorConfig {\n            executor: BaseCodingAgent::Amp,\n            variant: None,\n            model_id: None,\n            agent_id: None,\n            reasoning_id: None,\n            permission_policy: Some(crate::model_selector::PermissionPolicy::Auto),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/claude/client.rs",
    "content": "use std::sync::Arc;\n\nuse tokio_util::sync::CancellationToken;\nuse workspace_utils::approvals::{ApprovalStatus, QuestionStatus};\n\nuse super::types::PermissionMode;\nuse crate::{\n    approvals::{ExecutorApprovalError, ExecutorApprovalService},\n    env::RepoContext,\n    executors::{\n        ExecutorError,\n        claude::{\n            ClaudeJson,\n            types::{\n                PermissionResult, PermissionUpdate, PermissionUpdateDestination,\n                PermissionUpdateType,\n            },\n        },\n        codex::client::LogWriter,\n    },\n};\n\nconst EXIT_PLAN_MODE_NAME: &str = \"ExitPlanMode\";\nconst ASK_USER_QUESTION_NAME: &str = \"AskUserQuestion\";\npub const AUTO_APPROVE_CALLBACK_ID: &str = \"AUTO_APPROVE_CALLBACK_ID\";\npub const STOP_GIT_CHECK_CALLBACK_ID: &str = \"STOP_GIT_CHECK_CALLBACK_ID\";\n// Prefix for denial messages from the user, mirrors claude code CLI behavior\nconst TOOL_DENY_PREFIX: &str = \"The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said: \";\n\n/// Claude Agent client with control protocol support\npub struct ClaudeAgentClient {\n    log_writer: LogWriter,\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    auto_approve: bool, // true when approvals is None\n    repo_context: RepoContext,\n    commit_reminder_prompt: String,\n    cancel: CancellationToken,\n}\n\nimpl ClaudeAgentClient {\n    /// Create a new client with optional approval service\n    pub fn new(\n        log_writer: LogWriter,\n        approvals: Option<Arc<dyn ExecutorApprovalService>>,\n        repo_context: RepoContext,\n        commit_reminder_prompt: String,\n        cancel: CancellationToken,\n    ) -> Arc<Self> {\n        let auto_approve = approvals.is_none();\n        Arc::new(Self {\n            log_writer,\n            approvals,\n            auto_approve,\n            repo_context,\n            commit_reminder_prompt,\n            cancel,\n        })\n    }\n\n    async fn handle_approval(\n        &self,\n        tool_use_id: String,\n        tool_name: String,\n        tool_input: serde_json::Value,\n    ) -> Result<PermissionResult, ExecutorError> {\n        let approval_service = self\n            .approvals\n            .as_ref()\n            .ok_or(ExecutorApprovalError::ServiceUnavailable)?;\n\n        let approval_id = match approval_service.create_tool_approval(&tool_name).await {\n            Ok(id) => id,\n            Err(err) => {\n                self.handle_approval_error(&tool_name, &tool_use_id, &err)\n                    .await?;\n                return Err(err.into());\n            }\n        };\n\n        let _ = self\n            .log_writer\n            .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalRequested {\n                tool_call_id: tool_use_id.clone(),\n                tool_name: tool_name.clone(),\n                approval_id: approval_id.clone(),\n            })?)\n            .await;\n\n        let status = match approval_service\n            .wait_tool_approval(&approval_id, self.cancel.clone())\n            .await\n        {\n            Ok(s) => s,\n            Err(err) => {\n                self.handle_approval_error(&tool_name, &tool_use_id, &err)\n                    .await?;\n                return Err(err.into());\n            }\n        };\n\n        self.log_writer\n            .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalResponse {\n                tool_call_id: tool_use_id.clone(),\n                tool_name: tool_name.clone(),\n                approval_status: status.clone(),\n            })?)\n            .await?;\n\n        match status {\n            ApprovalStatus::Approved => {\n                if tool_name == EXIT_PLAN_MODE_NAME {\n                    Ok(PermissionResult::Allow {\n                        updated_input: tool_input,\n                        updated_permissions: Some(vec![PermissionUpdate {\n                            update_type: PermissionUpdateType::SetMode,\n                            mode: Some(PermissionMode::BypassPermissions),\n                            destination: Some(PermissionUpdateDestination::Session),\n                            rules: None,\n                            behavior: None,\n                            directories: None,\n                        }]),\n                    })\n                } else {\n                    Ok(PermissionResult::Allow {\n                        updated_input: tool_input,\n                        updated_permissions: None,\n                    })\n                }\n            }\n            ApprovalStatus::Denied { reason } => Ok(PermissionResult::Deny {\n                message: format!(\"{}{}\", TOOL_DENY_PREFIX, reason.unwrap_or_default()),\n                interrupt: Some(false),\n            }),\n            ApprovalStatus::TimedOut => Ok(PermissionResult::Deny {\n                message: \"Approval request timed out\".to_string(),\n                interrupt: Some(true),\n            }),\n            ApprovalStatus::Pending => Ok(PermissionResult::Deny {\n                message: \"Approval still pending (unexpected)\".to_string(),\n                interrupt: Some(false),\n            }),\n        }\n    }\n\n    async fn handle_question(\n        &self,\n        tool_use_id: String,\n        tool_name: String,\n        tool_input: serde_json::Value,\n    ) -> Result<PermissionResult, ExecutorError> {\n        let approval_service = self\n            .approvals\n            .as_ref()\n            .ok_or(ExecutorApprovalError::ServiceUnavailable)?;\n\n        let question_count = tool_input\n            .get(\"questions\")\n            .and_then(|q| q.as_array())\n            .map(|a| a.len())\n            .unwrap_or(1);\n\n        let approval_id = match approval_service\n            .create_question_approval(&tool_name, question_count)\n            .await\n        {\n            Ok(id) => id,\n            Err(err) => {\n                self.handle_question_error(&tool_use_id, &tool_name, &err)\n                    .await?;\n                return Err(err.into());\n            }\n        };\n\n        let _ = self\n            .log_writer\n            .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalRequested {\n                tool_call_id: tool_use_id.clone(),\n                tool_name: tool_name.clone(),\n                approval_id: approval_id.clone(),\n            })?)\n            .await;\n\n        let status = match approval_service\n            .wait_question_answer(&approval_id, self.cancel.clone())\n            .await\n        {\n            Ok(s) => s,\n            Err(err) => {\n                self.handle_question_error(&tool_use_id, &tool_name, &err)\n                    .await?;\n                return Err(err.into());\n            }\n        };\n\n        self.log_writer\n            .log_raw(&serde_json::to_string(&ClaudeJson::QuestionResponse {\n                tool_call_id: tool_use_id.clone(),\n                tool_name: tool_name.clone(),\n                question_status: status.clone(),\n            })?)\n            .await?;\n\n        match status {\n            QuestionStatus::Answered { answers } => {\n                let answers_map: serde_json::Map<String, serde_json::Value> = answers\n                    .iter()\n                    .map(|qa| {\n                        (\n                            qa.question.clone(),\n                            serde_json::Value::String(qa.answer.join(\", \")),\n                        )\n                    })\n                    .collect();\n                let mut updated = tool_input.clone();\n                if let Some(obj) = updated.as_object_mut() {\n                    obj.insert(\n                        \"answers\".to_string(),\n                        serde_json::Value::Object(answers_map),\n                    );\n                }\n                Ok(PermissionResult::Allow {\n                    updated_input: updated,\n                    updated_permissions: None,\n                })\n            }\n            QuestionStatus::TimedOut => Ok(PermissionResult::Deny {\n                message: \"Question request timed out\".to_string(),\n                interrupt: Some(true),\n            }),\n        }\n    }\n\n    async fn handle_approval_error(\n        &self,\n        tool_name: &str,\n        tool_use_id: &str,\n        err: &ExecutorApprovalError,\n    ) -> Result<(), ExecutorError> {\n        if !matches!(err, ExecutorApprovalError::Cancelled) {\n            tracing::error!(\n                \"Claude approval failed for tool={} call_id={}: {err}\",\n                tool_name,\n                tool_use_id\n            );\n        }\n        let _ = self\n            .log_writer\n            .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalResponse {\n                tool_call_id: tool_use_id.to_string(),\n                tool_name: tool_name.to_string(),\n                approval_status: ApprovalStatus::Denied {\n                    reason: Some(format!(\"Approval service error: {err}\")),\n                },\n            })?)\n            .await;\n        Ok(())\n    }\n\n    async fn handle_question_error(\n        &self,\n        tool_use_id: &str,\n        tool_name: &str,\n        err: &ExecutorApprovalError,\n    ) -> Result<(), ExecutorError> {\n        if !matches!(err, ExecutorApprovalError::Cancelled) {\n            tracing::error!(\"Claude question failed {err}\",);\n        }\n        let _ = self\n            .log_writer\n            .log_raw(&serde_json::to_string(&ClaudeJson::QuestionResponse {\n                tool_call_id: tool_use_id.to_string(),\n                tool_name: tool_name.to_string(),\n                question_status: QuestionStatus::TimedOut,\n            })?)\n            .await;\n        Ok(())\n    }\n\n    pub async fn on_can_use_tool(\n        &self,\n        tool_name: String,\n        input: serde_json::Value,\n        _permission_suggestions: Option<Vec<PermissionUpdate>>,\n        tool_use_id: Option<String>,\n    ) -> Result<PermissionResult, ExecutorError> {\n        if tool_name == ASK_USER_QUESTION_NAME {\n            if let Some(latest_tool_use_id) = tool_use_id {\n                return self\n                    .handle_question(latest_tool_use_id, tool_name, input)\n                    .await;\n            } else {\n                tracing::warn!(\"AskUserQuestion without tool_use_id, cannot route to approval\");\n                return Ok(PermissionResult::Deny {\n                    message:\n                        \"AskUserQuestion requires user interaction but no tool_use_id was provided\"\n                            .to_string(),\n                    interrupt: Some(false),\n                });\n            }\n        }\n        if self.auto_approve {\n            Ok(PermissionResult::Allow {\n                updated_input: input,\n                updated_permissions: None,\n            })\n        } else if let Some(latest_tool_use_id) = tool_use_id {\n            self.handle_approval(latest_tool_use_id, tool_name, input)\n                .await\n        } else {\n            // Auto approve tools with no matching tool_use_id\n            // tool_use_id is undocumented so this may not be possible\n            tracing::warn!(\n                \"No tool_use_id available for tool '{}', cannot request approval\",\n                tool_name\n            );\n            Ok(PermissionResult::Allow {\n                updated_input: input,\n                updated_permissions: None,\n            })\n        }\n    }\n\n    pub async fn on_hook_callback(\n        &self,\n        callback_id: String,\n        input: serde_json::Value,\n        _tool_use_id: Option<String>,\n    ) -> Result<serde_json::Value, ExecutorError> {\n        // Stop hook git check - uses `decision` (approve/block) and `reason` fields\n        if callback_id == STOP_GIT_CHECK_CALLBACK_ID {\n            if input\n                .get(\"stop_hook_active\")\n                .and_then(|v| v.as_bool())\n                .unwrap_or(false)\n            {\n                return Ok(serde_json::json!({\"decision\": \"approve\"}));\n            }\n            let status = self.repo_context.check_uncommitted_changes().await;\n            return Ok(if status.is_empty() {\n                serde_json::json!({\"decision\": \"approve\"})\n            } else {\n                serde_json::json!({\n                    \"decision\": \"block\",\n                    \"reason\": format!(\"{}\\n{}\", self.commit_reminder_prompt, status)\n                })\n            });\n        }\n\n        if self.auto_approve {\n            Ok(serde_json::json!({\n                \"hookSpecificOutput\": {\n                    \"hookEventName\": \"PreToolUse\",\n                    \"permissionDecision\": \"allow\",\n                    \"permissionDecisionReason\": \"Auto-approved by SDK\"\n                }\n            }))\n        } else {\n            match callback_id.as_str() {\n                AUTO_APPROVE_CALLBACK_ID => Ok(serde_json::json!({\n                    \"hookSpecificOutput\": {\n                        \"hookEventName\": \"PreToolUse\",\n                        \"permissionDecision\": \"allow\",\n                        \"permissionDecisionReason\": \"Approved by SDK\"\n                    }\n                })),\n                _ => {\n                    // Hook callbacks is only used to forward approval requests to can_use_tool.\n                    // This works because `ask` decision in hook callback triggers a can_use_tool request\n                    // https://docs.claude.com/en/api/agent-sdk/permissions#permission-flow-diagram\n                    Ok(serde_json::json!({\n                        \"hookSpecificOutput\": {\n                            \"hookEventName\": \"PreToolUse\",\n                            \"permissionDecision\": \"ask\",\n                            \"permissionDecisionReason\": \"Forwarding to canusetool service\"\n                        }\n                    }))\n                }\n            }\n        }\n    }\n\n    pub async fn log_message(&self, line: &str) -> Result<(), ExecutorError> {\n        self.log_writer.log_raw(line).await\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/claude/protocol.rs",
    "content": "use std::sync::Arc;\n\nuse tokio::{\n    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},\n    process::{ChildStdin, ChildStdout},\n    sync::Mutex,\n};\nuse tokio_util::sync::CancellationToken;\n\nuse super::types::{CLIMessage, ControlRequestType, ControlResponseMessage, ControlResponseType};\nuse crate::{\n    approvals::ExecutorApprovalError,\n    executors::{\n        ExecutorError,\n        claude::{\n            client::ClaudeAgentClient,\n            types::{Message, PermissionMode, SDKControlRequest, SDKControlRequestType},\n        },\n    },\n};\n\n/// Handles bidirectional control protocol communication\n#[derive(Clone)]\npub struct ProtocolPeer {\n    stdin: Arc<Mutex<ChildStdin>>,\n}\n\nimpl ProtocolPeer {\n    pub fn spawn(\n        stdin: ChildStdin,\n        stdout: ChildStdout,\n        client: Arc<ClaudeAgentClient>,\n        cancel: CancellationToken,\n    ) -> Self {\n        let peer = Self {\n            stdin: Arc::new(Mutex::new(stdin)),\n        };\n\n        let reader_peer = peer.clone();\n        tokio::spawn(async move {\n            if let Err(e) = reader_peer.read_loop(stdout, client, cancel).await {\n                tracing::error!(\"Protocol reader loop error: {}\", e);\n            }\n        });\n\n        peer\n    }\n\n    async fn read_loop(\n        &self,\n        stdout: ChildStdout,\n        client: Arc<ClaudeAgentClient>,\n        cancel: CancellationToken,\n    ) -> Result<(), ExecutorError> {\n        let mut reader = BufReader::new(stdout);\n        let mut buffer = String::new();\n        let mut interrupt_sent = false;\n\n        loop {\n            buffer.clear();\n            tokio::select! {\n                biased;\n                _ = cancel.cancelled(), if !interrupt_sent => {\n                    interrupt_sent = true;\n                    tracing::info!(\"Cancellation received in read_loop, sending interrupt to Claude\");\n                    if let Err(e) = self.interrupt().await {\n                        tracing::warn!(\"Failed to send interrupt to Claude: {e}\");\n                    }\n                    // Continue the loop to read Claude's response (it should send a result)\n                }\n                line_result = reader.read_line(&mut buffer) => {\n                    match line_result {\n                        Ok(0) => break, // EOF\n                        Ok(_) => {\n                            let line = buffer.trim();\n                            if line.is_empty() {\n                                continue;\n                            }\n                            client.log_message(line).await?;\n\n                            // Parse and handle control messages\n                            match serde_json::from_str::<CLIMessage>(line) {\n                                Ok(CLIMessage::ControlRequest {\n                                    request_id,\n                                    request,\n                                }) => {\n                                    self.handle_control_request(&client, request_id, request)\n                                        .await;\n                                }\n                                Ok(CLIMessage::Result(_)) => {\n                                    break;\n                                }\n                                _ => {}\n                            }\n                        }\n                        Err(e) => {\n                            tracing::error!(\"Error reading stdout: {}\", e);\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n        Ok(())\n    }\n\n    async fn handle_control_request(\n        &self,\n        client: &Arc<ClaudeAgentClient>,\n        request_id: String,\n        request: ControlRequestType,\n    ) {\n        match request {\n            ControlRequestType::CanUseTool {\n                tool_name,\n                input,\n                permission_suggestions,\n                blocked_paths: _,\n                tool_use_id,\n            } => {\n                match client\n                    .on_can_use_tool(tool_name, input, permission_suggestions, tool_use_id)\n                    .await\n                {\n                    Ok(result) => {\n                        if let Err(e) = self\n                            .send_hook_response(request_id, serde_json::to_value(result).unwrap())\n                            .await\n                        {\n                            tracing::error!(\"Failed to send permission result: {e}\");\n                        }\n                    }\n                    Err(ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled)) => {\n                    }\n                    Err(e) => {\n                        tracing::error!(\"Error in on_can_use_tool: {e}\");\n                        if let Err(e2) = self.send_error(request_id, e.to_string()).await {\n                            tracing::error!(\"Failed to send error response: {e2}\");\n                        }\n                    }\n                }\n            }\n            ControlRequestType::HookCallback {\n                callback_id,\n                input,\n                tool_use_id,\n            } => {\n                match client\n                    .on_hook_callback(callback_id, input, tool_use_id)\n                    .await\n                {\n                    Ok(hook_output) => {\n                        if let Err(e) = self.send_hook_response(request_id, hook_output).await {\n                            tracing::error!(\"Failed to send hook callback result: {e}\");\n                        }\n                    }\n                    Err(e) => {\n                        tracing::error!(\"Error in on_hook_callback: {e}\");\n                        if let Err(e2) = self.send_error(request_id, e.to_string()).await {\n                            tracing::error!(\"Failed to send error response: {e2}\");\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    pub async fn send_hook_response(\n        &self,\n        request_id: String,\n        hook_output: serde_json::Value,\n    ) -> Result<(), ExecutorError> {\n        self.send_json(&ControlResponseMessage::new(ControlResponseType::Success {\n            request_id,\n            response: Some(hook_output),\n        }))\n        .await\n    }\n\n    /// Send error response to CLI\n    async fn send_error(&self, request_id: String, error: String) -> Result<(), ExecutorError> {\n        self.send_json(&ControlResponseMessage::new(ControlResponseType::Error {\n            request_id,\n            error: Some(error),\n        }))\n        .await\n    }\n\n    async fn send_json<T: serde::Serialize>(&self, message: &T) -> Result<(), ExecutorError> {\n        let json = serde_json::to_string(message)?;\n        let mut stdin = self.stdin.lock().await;\n        stdin.write_all(json.as_bytes()).await?;\n        stdin.write_all(b\"\\n\").await?;\n        stdin.flush().await?;\n        Ok(())\n    }\n\n    pub async fn send_user_message(&self, content: String) -> Result<(), ExecutorError> {\n        let message = Message::new_user(content);\n        self.send_json(&message).await\n    }\n\n    pub async fn initialize(&self, hooks: Option<serde_json::Value>) -> Result<(), ExecutorError> {\n        self.send_json(&SDKControlRequest::new(SDKControlRequestType::Initialize {\n            hooks,\n        }))\n        .await\n    }\n    pub async fn interrupt(&self) -> Result<(), ExecutorError> {\n        self.send_json(&SDKControlRequest::new(SDKControlRequestType::Interrupt {}))\n            .await\n    }\n\n    pub async fn set_permission_mode(&self, mode: PermissionMode) -> Result<(), ExecutorError> {\n        self.send_json(&SDKControlRequest::new(\n            SDKControlRequestType::SetPermissionMode { mode },\n        ))\n        .await\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/claude/slash_commands.rs",
    "content": "use std::{\n    collections::{HashMap, HashSet},\n    path::{Path, PathBuf},\n    process::Stdio,\n    sync::OnceLock,\n    time::Duration,\n};\n\nuse convert_case::{Case, Casing};\nuse tokio::{\n    fs,\n    io::{AsyncBufReadExt, BufReader},\n    process::Command,\n};\nuse workspace_utils::command_ext::GroupSpawnNoWindowExt;\n\nuse super::{ClaudeCode, ClaudeJson, ClaudePlugin, base_command};\nuse crate::{\n    command::{CommandBuildError, CommandBuilder, apply_overrides},\n    env::{ExecutionEnv, RepoContext},\n    executors::{ExecutorError, SlashCommandDescription},\n    model_selector::AgentInfo,\n};\n\nconst SLASH_COMMANDS_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(120);\n\nimpl ClaudeCode {\n    fn extract_description(content: &str) -> Option<String> {\n        if !content.starts_with(\"---\") {\n            return None;\n        }\n\n        // Find end of frontmatter\n        let end = content[3..].find(\"---\")?;\n        let frontmatter = &content[3..3 + end];\n\n        for line in frontmatter.lines() {\n            let line = line.trim();\n            if let Some(rest) = line.strip_prefix(\"description:\") {\n                return Some(rest.trim().to_string());\n            }\n        }\n        None\n    }\n\n    fn make_key(prefix: &Option<String>, name: &str) -> String {\n        prefix\n            .as_ref()\n            .map(|p| format!(\"{}:{}\", p, name))\n            .unwrap_or_else(|| name.to_string())\n    }\n\n    async fn try_read_description(path: &Path) -> Option<String> {\n        match fs::read_to_string(path).await {\n            Ok(content) => Self::extract_description(&content).or_else(|| {\n                tracing::warn!(\"Failed to read frontmatter description from {:?}\", path);\n                None\n            }),\n            Err(e) => {\n                tracing::error!(\"Failed to read file {:?}: {}\", path, e);\n                None\n            }\n        }\n    }\n\n    async fn scan_dir(\n        dir: &Path,\n        prefix: &Option<String>,\n        get_entry: fn(&Path) -> Option<(&str, PathBuf)>,\n    ) -> HashMap<String, String> {\n        let mut result = HashMap::new();\n        if let Ok(mut entries) = fs::read_dir(dir).await {\n            while let Ok(Some(entry)) = entries.next_entry().await {\n                if let Some((name, desc_path)) = get_entry(&entry.path())\n                    && let Some(desc) = Self::try_read_description(&desc_path).await\n                {\n                    result.insert(Self::make_key(prefix, name), desc);\n                }\n            }\n        }\n        result\n    }\n\n    async fn scan_base_path(base_path: &Path, prefix: Option<String>) -> HashMap<String, String> {\n        let mut descriptions = HashMap::new();\n\n        descriptions.extend(\n            Self::scan_dir(&base_path.join(\"commands\"), &prefix, |path| {\n                path.extension()\n                    .is_some_and(|ext| ext == \"md\")\n                    .then(|| {\n                        let name = path.file_stem()?.to_str()?;\n                        Some((name, path.to_path_buf()))\n                    })\n                    .flatten()\n            })\n            .await,\n        );\n\n        descriptions.extend(\n            Self::scan_dir(&base_path.join(\"skills\"), &prefix, |path| {\n                path.is_dir()\n                    .then(|| {\n                        let name = path.file_name()?.to_str()?;\n                        let skill_md = path.join(\"SKILL.md\");\n                        skill_md.exists().then_some((name, skill_md))\n                    })\n                    .flatten()\n            })\n            .await,\n        );\n\n        descriptions\n    }\n\n    pub async fn discover_custom_command_descriptions(\n        current_dir: &Path,\n        plugins: &[ClaudePlugin],\n    ) -> HashMap<String, String> {\n        let mut descriptions = HashMap::new();\n\n        // Project specific\n        descriptions.extend(Self::scan_base_path(&current_dir.join(\".claude\"), None).await);\n\n        // Global\n        if let Some(home) = dirs::home_dir() {\n            descriptions.extend(Self::scan_base_path(&home.join(\".claude\"), None).await);\n        }\n\n        // Plugins\n        for plugin in plugins {\n            descriptions\n                .extend(Self::scan_base_path(&plugin.path, Some(plugin.name.clone())).await);\n            descriptions.extend(\n                Self::scan_base_path(&plugin.path.join(\".claude\"), Some(plugin.name.clone())).await,\n            );\n        }\n\n        descriptions\n    }\n\n    pub(super) fn hardcoded_slash_commands() -> Vec<SlashCommandDescription> {\n        static KNOWN_SLASH_COMMANDS: OnceLock<Vec<SlashCommandDescription>> = OnceLock::new();\n        KNOWN_SLASH_COMMANDS.get_or_init(|| {\n            vec![\n                SlashCommandDescription {\n                    name: \"compact\".to_string(),\n                    description: Some(\n                        \"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]\"\n                            .to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"review\".to_string(),\n                    description: Some(\"Review a pull request\".to_string()),\n                },\n                SlashCommandDescription {\n                    name: \"security-review\".to_string(),\n                    description: Some(\n                        \"Complete a security review of the pending changes on the current branch\"\n                            .to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"init\".to_string(),\n                    description: Some(\n                        \"Initialize a new CLAUDE.md file with codebase documentation\".to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"pr-comments\".to_string(),\n                    description: Some(\"Get comments from a GitHub pull request\".to_string()),\n                },\n                SlashCommandDescription {\n                    name: \"context\".to_string(),\n                    description: Some(\n                        \"Visualize current context usage as a colored grid\".to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"cost\".to_string(),\n                    description: Some(\n                        \"Show the total cost and duration of the current session\".to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"release-notes\".to_string(),\n                    description: Some(\"View release notes\".to_string()),\n                },\n            ]\n        }).clone()\n    }\n\n    async fn build_slash_commands_discovery_command_builder(\n        &self,\n    ) -> Result<CommandBuilder, CommandBuildError> {\n        let mut builder =\n            CommandBuilder::new(base_command(self.claude_code_router.unwrap_or(false)))\n                .params([\"-p\"]);\n\n        builder = builder.extend_params([\n            \"--verbose\",\n            \"--output-format=stream-json\",\n            \"--max-turns\",\n            \"1\",\n            \"--\",\n            \"/\",\n        ]);\n\n        apply_overrides(builder, &self.cmd)\n    }\n\n    async fn discover_available_command_and_plugins(\n        &self,\n        current_dir: &Path,\n    ) -> Result<(Vec<String>, Vec<ClaudePlugin>, Vec<String>), ExecutorError> {\n        let command_builder = self\n            .build_slash_commands_discovery_command_builder()\n            .await?;\n        let command_parts = command_builder.build_initial()?;\n        let (program_path, args) = command_parts.into_resolved().await?;\n\n        let mut command = Command::new(program_path);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::null())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::null())\n            .current_dir(current_dir)\n            .args(&args);\n\n        ExecutionEnv::new(RepoContext::default(), false, String::new())\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut command);\n\n        if self.disable_api_key.unwrap_or(false) {\n            command.env_remove(\"ANTHROPIC_API_KEY\");\n        }\n\n        let mut child = command.group_spawn_no_window()?;\n        let stdout = child.inner().stdout.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::other(\"Claude Code missing stdout\"))\n        })?;\n\n        let mut lines = BufReader::new(stdout).lines();\n\n        let mut discovered: Option<(Vec<String>, Vec<ClaudePlugin>, Vec<String>)> = None;\n        let discovery = async {\n            while let Some(line) = lines.next_line().await.map_err(ExecutorError::Io)? {\n                if let Ok(json) = serde_json::from_str::<ClaudeJson>(&line)\n                    && let ClaudeJson::System {\n                        subtype,\n                        slash_commands,\n                        plugins,\n                        agents,\n                        ..\n                    } = &json\n                    && matches!(subtype.as_deref(), Some(\"init\"))\n                {\n                    discovered = Some((slash_commands.clone(), plugins.clone(), agents.clone()));\n                    break;\n                }\n            }\n\n            Ok::<(), ExecutorError>(())\n        };\n\n        let res = tokio::time::timeout(SLASH_COMMANDS_DISCOVERY_TIMEOUT, discovery).await;\n        let _ = child.kill().await;\n\n        let result = match res {\n            Ok(Ok(())) => discovered.unwrap_or_else(|| (vec![], vec![], vec![])),\n            Ok(Err(e)) => return Err(e),\n            Err(_) => {\n                return Err(ExecutorError::Io(std::io::Error::other(\n                    \"Timed out discovering Claude Code slash commands\",\n                )));\n            }\n        };\n\n        Ok(result)\n    }\n\n    pub async fn discover_available_slash_commands(\n        &self,\n        current_dir: &Path,\n    ) -> Result<Vec<SlashCommandDescription>, ExecutorError> {\n        let (names, plugins, _) = self\n            .discover_available_command_and_plugins(current_dir)\n            .await?;\n\n        let descriptions = Self::discover_custom_command_descriptions(current_dir, &plugins).await;\n\n        let builtin: HashSet<String> = Self::hardcoded_slash_commands()\n            .iter()\n            .map(|c| c.name.clone())\n            .collect();\n\n        let mut seen = HashSet::new();\n        let names = names\n            .into_iter()\n            .filter(|name| !name.is_empty() && !builtin.contains(name) && seen.insert(name.clone()))\n            .collect::<Vec<_>>();\n\n        let commands: Vec<SlashCommandDescription> = names\n            .into_iter()\n            .map(|name| SlashCommandDescription {\n                name: name.to_string(),\n                description: descriptions.get(&name).cloned(),\n            })\n            .collect();\n\n        Ok(commands)\n    }\n\n    pub async fn discover_available_agents(\n        &self,\n        current_dir: &Path,\n    ) -> Result<Vec<AgentInfo>, ExecutorError> {\n        let (_, _, agents) = self\n            .discover_available_command_and_plugins(current_dir)\n            .await?;\n\n        Ok(Self::map_discovered_agents(agents))\n    }\n\n    pub async fn discover_agents_and_slash_commands_initial(\n        &self,\n        current_dir: &Path,\n    ) -> Result<\n        (\n            Vec<AgentInfo>,\n            Vec<SlashCommandDescription>,\n            Vec<ClaudePlugin>,\n        ),\n        ExecutorError,\n    > {\n        let (names, plugins, agents) = self\n            .discover_available_command_and_plugins(current_dir)\n            .await?;\n\n        let agent_options = Self::map_discovered_agents(agents);\n\n        let builtin: HashSet<String> = Self::hardcoded_slash_commands()\n            .iter()\n            .map(|c| c.name.clone())\n            .collect();\n\n        let mut seen = HashSet::new();\n        let slash_commands: Vec<SlashCommandDescription> = names\n            .into_iter()\n            .filter(|name| !name.is_empty() && !builtin.contains(name) && seen.insert(name.clone()))\n            .map(|name| SlashCommandDescription {\n                name,\n                description: None,\n            })\n            .collect();\n\n        Ok((agent_options, slash_commands, plugins))\n    }\n\n    pub async fn fill_slash_command_descriptions(\n        current_dir: &Path,\n        plugins: &[ClaudePlugin],\n        slash_commands: &[SlashCommandDescription],\n    ) -> Vec<SlashCommandDescription> {\n        let descriptions = Self::discover_custom_command_descriptions(current_dir, plugins).await;\n\n        slash_commands\n            .iter()\n            .map(|cmd| SlashCommandDescription {\n                name: cmd.name.clone(),\n                description: descriptions\n                    .get(&cmd.name)\n                    .cloned()\n                    .or(cmd.description.clone()),\n            })\n            .collect()\n    }\n\n    fn map_discovered_agents(agents: Vec<String>) -> Vec<AgentInfo> {\n        let mut seen = HashSet::new();\n\n        agents\n            .into_iter()\n            .filter(|name| name != \"statusline-setup\")\n            .filter_map(|name| {\n                let option = AgentInfo {\n                    id: name.clone(),\n                    label: Self::format_agent_label(&name),\n                    description: None,\n                    is_default: name == \"general-purpose\",\n                };\n\n                if option.id.trim().is_empty() || !seen.insert(option.id.clone()) {\n                    return None;\n                }\n                Some(option)\n            })\n            .collect()\n    }\n\n    fn format_agent_label(raw: &str) -> String {\n        let raw = raw.trim();\n\n        if let Some((prefix, suffix)) = raw.split_once(':') {\n            format!(\"{}: {}\", prefix.trim(), suffix.to_case(Case::Title))\n        } else {\n            raw.to_case(Case::Title)\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/claude/types.rs",
    "content": "//! Type definitions for Claude Code control protocol\n//!\n//! Similar to: https://github.com/ZhangHanDong/claude-code-api-rs/blob/main/claude-code-sdk-rs/src/types.rs\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n/// Top-level message types from CLI stdout\n#[derive(Debug, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum CLIMessage {\n    ControlRequest {\n        request_id: String,\n        request: ControlRequestType,\n    },\n    ControlResponse {\n        response: ControlResponseType,\n    },\n    ControlCancelRequest {\n        request_id: String,\n    },\n    Result(serde_json::Value),\n    #[serde(untagged)]\n    Other(serde_json::Value),\n}\n\n/// Control request from SDK to CLI (outgoing)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SDKControlRequest {\n    #[serde(rename = \"type\")]\n    message_type: String, // Always \"control_request\"\n    pub request_id: String,\n    pub request: SDKControlRequestType,\n}\n\nimpl SDKControlRequest {\n    pub fn new(request: SDKControlRequestType) -> Self {\n        use uuid::Uuid;\n        Self {\n            message_type: \"control_request\".to_string(),\n            request_id: Uuid::new_v4().to_string(),\n            request,\n        }\n    }\n}\n\n/// Control response from SDK to CLI (outgoing)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ControlResponseMessage {\n    #[serde(rename = \"type\")]\n    message_type: String, // Always \"control_response\"\n    pub response: ControlResponseType,\n}\n\nimpl ControlResponseMessage {\n    pub fn new(response: ControlResponseType) -> Self {\n        Self {\n            message_type: \"control_response\".to_string(),\n            response,\n        }\n    }\n}\n\n/// Types of control requests\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"subtype\", rename_all = \"snake_case\")]\npub enum ControlRequestType {\n    CanUseTool {\n        tool_name: String,\n        input: Value,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        permission_suggestions: Option<Vec<PermissionUpdate>>,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        blocked_paths: Option<String>,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        tool_use_id: Option<String>,\n    },\n    HookCallback {\n        #[serde(rename = \"callback_id\")]\n        callback_id: String,\n        input: Value,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        tool_use_id: Option<String>,\n    },\n}\n\n/// Result of permission check\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"behavior\", rename_all = \"camelCase\")]\npub enum PermissionResult {\n    Allow {\n        #[serde(rename = \"updatedInput\")]\n        updated_input: Value,\n        #[serde(skip_serializing_if = \"Option::is_none\", rename = \"updatedPermissions\")]\n        updated_permissions: Option<Vec<PermissionUpdate>>,\n    },\n    Deny {\n        message: String,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        interrupt: Option<bool>,\n    },\n}\n\n#[derive(Debug, Clone, Copy, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub enum PermissionUpdateType {\n    SetMode,\n    AddRules,\n    RemoveRules,\n    ClearRules,\n    #[serde(other)]\n    Unknown,\n}\n\n#[derive(Debug, Clone, Copy, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub enum PermissionUpdateDestination {\n    Session,\n    UserSettings,\n    ProjectSettings,\n    LocalSettings,\n    #[serde(other)]\n    Unknown,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct PermissionRuleValue {\n    pub tool_name: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub rule_content: Option<String>,\n}\n\n/// Permission update operation\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub struct PermissionUpdate {\n    #[serde(rename = \"type\")]\n    pub update_type: PermissionUpdateType,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub mode: Option<PermissionMode>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub destination: Option<PermissionUpdateDestination>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub rules: Option<Vec<PermissionRuleValue>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub behavior: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub directories: Option<Vec<String>>,\n}\n\n/// Control response from SDK to CLI\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"subtype\", rename_all = \"snake_case\")]\npub enum ControlResponseType {\n    Success {\n        request_id: String,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        response: Option<Value>,\n    },\n    Error {\n        request_id: String,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        error: Option<String>,\n    },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum Message {\n    User { message: ClaudeUserMessage },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeUserMessage {\n    role: String,\n    content: String,\n}\n\nimpl Message {\n    pub fn new_user(content: String) -> Self {\n        Self::User {\n            message: ClaudeUserMessage {\n                role: \"user\".to_string(),\n                content,\n            },\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"subtype\", rename_all = \"snake_case\")]\npub enum SDKControlRequestType {\n    SetPermissionMode {\n        mode: PermissionMode,\n    },\n    Initialize {\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        hooks: Option<Value>,\n    },\n    Interrupt {},\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub enum PermissionMode {\n    Default,\n    AcceptEdits,\n    Plan,\n    BypassPermissions,\n}\n\nimpl PermissionMode {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            Self::Default => \"default\",\n            Self::AcceptEdits => \"acceptEdits\",\n            Self::Plan => \"plan\",\n            Self::BypassPermissions => \"bypassPermissions\",\n        }\n    }\n}\n\nimpl std::fmt::Display for PermissionMode {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.as_str())\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/claude.rs",
    "content": "// SDK submodules\npub mod client;\npub mod protocol;\npub mod slash_commands;\npub mod types;\n\nuse std::{\n    collections::HashMap,\n    path::{Path, PathBuf},\n    process::Stdio,\n    sync::Arc,\n    time::Duration,\n};\n\nuse async_trait::async_trait;\nuse futures::StreamExt;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse tokio::process::Command;\nuse tokio_util::sync::CancellationToken;\nuse ts_rs::TS;\nuse workspace_utils::{\n    approvals::{ApprovalStatus, QuestionStatus},\n    command_ext::GroupSpawnNoWindowExt,\n    diff::create_unified_diff,\n    log_msg::LogMsg,\n    msg_store::MsgStore,\n    path::make_path_relative,\n};\n\nuse self::{\n    client::{AUTO_APPROVE_CALLBACK_ID, ClaudeAgentClient, STOP_GIT_CHECK_CALLBACK_ID},\n    protocol::ProtocolPeer,\n    types::{ControlRequestType, ControlResponseType, PermissionMode},\n};\nuse crate::{\n    approvals::ExecutorApprovalService,\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, CommandParts, apply_overrides},\n    env::ExecutionEnv,\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild,\n        StandardCodingAgentExecutor, codex::client::LogWriter, utils::reorder_slash_commands,\n    },\n    logs::{\n        ActionType, AnsweredQuestion, AskUserQuestionItem, AskUserQuestionOption, FileChange,\n        NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem, ToolStatus,\n        plain_text_processor::PlainTextLogProcessor,\n        utils::{\n            EntryIndexProvider,\n            patch::{self, ConversationPatch},\n            shell_command_parsing::CommandCategory,\n        },\n    },\n    model_selector::PermissionPolicy,\n    profile::ExecutorConfig,\n    stdout_dup::create_stdout_pipe_writer,\n};\n\nconst SUPPRESSED_STDERR_PATTERNS: &[&str] = &[\"[WARN] Fast mode requires the native binary\"];\n\nfn base_command(claude_code_router: bool) -> &'static str {\n    if claude_code_router {\n        \"npx -y @musistudio/claude-code-router@1.0.66 code\"\n    } else {\n        \"npx -y @anthropic-ai/claude-code@2.1.62\"\n    }\n}\n\nfn normalize_claude_stderr_logs(\n    msg_store: Arc<MsgStore>,\n    entry_index_provider: EntryIndexProvider,\n) -> tokio::task::JoinHandle<()> {\n    tokio::spawn(async move {\n        let mut stderr = msg_store.stderr_chunked_stream();\n\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(|content: String| NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::Other,\n                },\n                content: strip_ansi_escapes::strip_str(&content),\n                metadata: None,\n            })\n            .time_gap(Duration::from_secs(2))\n            .index_provider(entry_index_provider)\n            .transform_lines(Box::new(|lines: &mut Vec<String>| {\n                lines.retain(|line| {\n                    !SUPPRESSED_STDERR_PATTERNS\n                        .iter()\n                        .any(|pattern| line.contains(pattern))\n                });\n            }))\n            .build();\n\n        while let Some(Ok(chunk)) = stderr.next().await {\n            for patch in processor.process(chunk) {\n                msg_store.push_patch(patch);\n            }\n        }\n    })\n}\n\nuse derivative::Derivative;\nuse strum_macros::{AsRefStr, EnumString};\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS, JsonSchema, AsRefStr, EnumString)]\n#[serde(rename_all = \"lowercase\")]\n#[strum(serialize_all = \"lowercase\")]\npub enum ClaudeEffort {\n    Low,\n    Medium,\n    High,\n    Max,\n}\n\n#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]\n#[derivative(Debug, PartialEq)]\npub struct ClaudeCode {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub claude_code_router: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub plan: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub approvals: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub effort: Option<ClaudeEffort>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub agent: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub dangerously_skip_permissions: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub disable_api_key: Option<bool>,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n\n    #[serde(skip)]\n    #[ts(skip)]\n    #[derivative(Debug = \"ignore\", PartialEq = \"ignore\")]\n    approvals_service: Option<Arc<dyn ExecutorApprovalService>>,\n}\n\nimpl ClaudeCode {\n    async fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        // If base_command_override is provided and claude_code_router is also set, log a warning\n        if self.cmd.base_command_override.is_some() && self.claude_code_router.is_some() {\n            tracing::warn!(\n                \"base_command_override is set, this will override the claude_code_router setting\"\n            );\n        }\n\n        let mut builder =\n            CommandBuilder::new(base_command(self.claude_code_router.unwrap_or(false)))\n                .params([\"-p\"]);\n\n        let plan = self.plan.unwrap_or(false);\n        let approvals = self.approvals.unwrap_or(false);\n        if plan && approvals {\n            tracing::warn!(\"Both plan and approvals are enabled. Plan will take precedence.\");\n        }\n        if plan || approvals {\n            // Enable bypass at startup, otherwise we cannot change to it after exiting plan mode\n            builder = builder.extend_params([\"--permission-prompt-tool=stdio\"]);\n            builder = builder.extend_params([format!(\n                \"--permission-mode={}\",\n                PermissionMode::BypassPermissions\n            )]);\n        } else {\n            builder = builder.extend_params([\"--disallowedTools=AskUserQuestion\"]);\n        }\n        if self.dangerously_skip_permissions.unwrap_or(false) {\n            builder = builder.extend_params([\"--dangerously-skip-permissions\"]);\n        }\n        if let Some(model) = &self.model {\n            builder = builder.extend_params([\"--model\", model]);\n        }\n        if let Some(effort) = &self.effort {\n            builder = builder.extend_params([\"--effort\", effort.as_ref()]);\n        }\n        if let Some(agent) = &self.agent {\n            builder = builder.extend_params([\"--agent\", agent]);\n        }\n        builder = builder.extend_params([\n            \"--verbose\",\n            \"--output-format=stream-json\",\n            \"--input-format=stream-json\",\n            \"--include-partial-messages\",\n            \"--replay-user-messages\",\n        ]);\n\n        apply_overrides(builder, &self.cmd)\n    }\n\n    pub fn permission_mode(&self) -> PermissionMode {\n        if self.plan.unwrap_or(false) {\n            PermissionMode::Plan\n        } else if self.approvals.unwrap_or(false) {\n            PermissionMode::Default\n        } else {\n            PermissionMode::BypassPermissions\n        }\n    }\n\n    pub fn get_hooks(&self, commit_reminder: bool) -> Option<serde_json::Value> {\n        let mut hooks = serde_json::Map::new();\n\n        if commit_reminder {\n            hooks.insert(\n                \"Stop\".to_string(),\n                serde_json::json!([{\n                    \"hookCallbackIds\": [STOP_GIT_CHECK_CALLBACK_ID]\n                }]),\n            );\n        }\n\n        // Add PreToolUse hooks based on plan/approvals settings\n        if self.plan.unwrap_or(false) {\n            hooks.insert(\n                \"PreToolUse\".to_string(),\n                serde_json::json!([\n                    {\n                        \"matcher\": \"^(ExitPlanMode|AskUserQuestion)$\",\n                        \"hookCallbackIds\": [\"tool_approval\"],\n                    },\n                    {\n                        \"matcher\": \"^(?!(ExitPlanMode|AskUserQuestion)$).*\",\n                        \"hookCallbackIds\": [AUTO_APPROVE_CALLBACK_ID],\n                    }\n                ]),\n            );\n        } else if self.approvals.unwrap_or(false) {\n            hooks.insert(\n                \"PreToolUse\".to_string(),\n                serde_json::json!([\n                    {\n                        \"matcher\": \"^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite)$).*\",\n                        \"hookCallbackIds\": [\"tool_approval\"],\n                    }\n                ]),\n            );\n        } else {\n            hooks.insert(\n                \"PreToolUse\".to_string(),\n                serde_json::json!([\n                    {\n                        \"matcher\": \"^AskUserQuestion$\",\n                        \"hookCallbackIds\": [\"tool_approval\"],\n                    }\n                ]),\n            );\n        }\n\n        Some(serde_json::Value::Object(hooks))\n    }\n\n    fn compute_cmd_key(&self) -> String {\n        serde_json::to_string(&self.cmd).unwrap_or_default()\n    }\n}\n\nfn default_discovered_options() -> crate::executor_discovery::ExecutorDiscoveredOptions {\n    use crate::{\n        executor_discovery::ExecutorDiscoveredOptions,\n        model_selector::{ModelInfo, ModelSelectorConfig, ReasoningOption},\n    };\n\n    let effort_options =\n        ReasoningOption::from_names([\"low\", \"medium\", \"high\", \"max\"].map(String::from));\n\n    let supports_effort = |id: &str| -> bool { id.contains(\"opus\") || id.contains(\"sonnet\") };\n\n    ExecutorDiscoveredOptions {\n        model_selector: ModelSelectorConfig {\n            providers: vec![],\n            models: [\n                (\"opus\", \"Opus\"),\n                (\"opus[1m]\", \"Opus (1M context)\"),\n                (\"sonnet\", \"Sonnet\"),\n                (\"haiku\", \"Haiku\"),\n            ]\n            .into_iter()\n            .map(|(id, name)| ModelInfo {\n                id: id.to_string(),\n                name: name.to_string(),\n                provider_id: None,\n                reasoning_options: if supports_effort(id) {\n                    effort_options.clone()\n                } else {\n                    vec![]\n                },\n            })\n            .collect(),\n            default_model: Some(\"opus\".to_string()),\n            agents: vec![],\n            permissions: vec![\n                PermissionPolicy::Auto,\n                PermissionPolicy::Supervised,\n                PermissionPolicy::Plan,\n            ],\n        },\n        slash_commands: ClaudeCode::hardcoded_slash_commands(),\n        loading_models: false,\n        loading_agents: false,\n        loading_slash_commands: false,\n        error: None,\n    }\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for ClaudeCode {\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n        if let Some(agent) = &executor_config.agent_id {\n            self.agent = Some(agent.clone());\n        }\n        if let Some(reasoning_id) = &executor_config.reasoning_id {\n            self.effort = reasoning_id.parse().ok();\n        }\n        if let Some(permission_policy) = executor_config.permission_policy.clone() {\n            match permission_policy {\n                PermissionPolicy::Plan => {\n                    self.plan = Some(true);\n                    self.approvals = Some(false);\n                }\n                PermissionPolicy::Supervised => {\n                    self.plan = Some(false);\n                    self.approvals = Some(true);\n                }\n                PermissionPolicy::Auto => {\n                    self.plan = Some(false);\n                    self.approvals = Some(false);\n                }\n            }\n        }\n    }\n\n    fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {\n        self.approvals_service = Some(approvals);\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let command_builder = self.build_command_builder().await?;\n        let command_parts = command_builder.build_initial()?;\n        self.spawn_internal(current_dir, prompt, command_parts, env)\n            .await\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let command_builder = self.build_command_builder().await?;\n\n        let mut args = vec![\"--resume\".to_string(), session_id.to_string()];\n\n        // --resume-session-at truncates Claude's conversation history to the specified\n        // message and continues from there.\n        if let Some(uuid) = reset_to_message_id {\n            args.push(\"--resume-session-at\".to_string());\n            args.push(uuid.to_string());\n        }\n\n        let command_parts = command_builder.build_follow_up(&args)?;\n        self.spawn_internal(current_dir, prompt, command_parts, env)\n            .await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        current_dir: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        let entry_index_provider = EntryIndexProvider::start_from(&msg_store);\n\n        // Process stdout logs (Claude's JSON output)\n        let h1 = ClaudeLogProcessor::process_logs(\n            msg_store.clone(),\n            current_dir,\n            entry_index_provider.clone(),\n            HistoryStrategy::Default,\n        );\n\n        // Process stderr logs\n        let h2 = normalize_claude_stderr_logs(msg_store, entry_index_provider);\n\n        vec![h1, h2]\n    }\n\n    async fn discover_options(\n        &self,\n        workdir: Option<&Path>,\n        repo_path: Option<&Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        use crate::{\n            executor_discovery::ExecutorConfigCacheKey, executors::utils::executor_options_cache,\n        };\n\n        let cache = executor_options_cache();\n        let cmd_key = self.compute_cmd_key();\n        let base_executor = BaseCodingAgent::ClaudeCode;\n\n        let (target_path, initial_options) = if let Some(wd) = workdir {\n            let wd_buf = wd.to_path_buf();\n            let target_key =\n                ExecutorConfigCacheKey::new(Some(&wd_buf), cmd_key.clone(), base_executor);\n            if let Some(cached) = cache.get(&target_key) {\n                return Ok(Box::pin(futures::stream::once(async move {\n                    patch::executor_discovered_options(cached.as_ref().clone().with_loading(false))\n                })));\n            }\n            let provisional = repo_path\n                .and_then(|rp| {\n                    let rp_buf = rp.to_path_buf();\n                    let repo_key =\n                        ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor);\n                    cache.get(&repo_key)\n                })\n                .or_else(|| {\n                    let global_key =\n                        ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor);\n                    cache.get(&global_key)\n                });\n            (\n                Some(wd.to_path_buf()),\n                provisional\n                    .map(|p| {\n                        let mut opts = p.as_ref().clone();\n                        opts.loading_models = false;\n                        opts.loading_agents = true;\n                        opts.loading_slash_commands = true;\n                        opts\n                    })\n                    .unwrap_or_else(|| {\n                        let mut opts = default_discovered_options();\n                        opts.loading_models = false;\n                        opts.loading_agents = true;\n                        opts.loading_slash_commands = true;\n                        opts\n                    }),\n            )\n        } else if let Some(rp) = repo_path {\n            let rp_buf = rp.to_path_buf();\n            let target_key =\n                ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor);\n            if let Some(cached) = cache.get(&target_key) {\n                return Ok(Box::pin(futures::stream::once(async move {\n                    patch::executor_discovered_options(cached.as_ref().clone().with_loading(false))\n                })));\n            }\n            let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor);\n            let provisional = cache.get(&global_key);\n            (\n                Some(rp.to_path_buf()),\n                provisional\n                    .map(|p| {\n                        let mut opts = p.as_ref().clone();\n                        opts.loading_models = false;\n                        opts.loading_agents = true;\n                        opts.loading_slash_commands = true;\n                        opts\n                    })\n                    .unwrap_or_else(|| {\n                        let mut opts = default_discovered_options();\n                        opts.loading_models = false;\n                        opts.loading_agents = true;\n                        opts.loading_slash_commands = true;\n                        opts\n                    }),\n            )\n        } else {\n            let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor);\n            if let Some(cached) = cache.get(&global_key) {\n                return Ok(Box::pin(futures::stream::once(async move {\n                    patch::executor_discovered_options(cached.as_ref().clone().with_loading(false))\n                })));\n            }\n            let mut opts = default_discovered_options();\n            opts.loading_models = false;\n            opts.loading_agents = true;\n            opts.loading_slash_commands = true;\n            (None, opts)\n        };\n\n        let initial_patch = patch::executor_discovered_options(initial_options);\n\n        let this = self.clone();\n        let cmd_key_for_discovery = cmd_key.clone();\n\n        let discovery_stream = async_stream::stream! {\n            let discovery_path = target_path.as_deref().unwrap_or(Path::new(\".\")).to_path_buf();\n            let mut final_options = default_discovered_options();\n\n            match this.discover_agents_and_slash_commands_initial(&discovery_path).await {\n                Ok((mut agent_options, slash_commands_initial, plugins)) => {\n                    let default_agents = [\n                        \"Bash\",\n                        \"general-purpose\",\n                        \"statusline-setup\",\n                        \"Explore\",\n                        \"Plan\",\n                    ];\n                    agent_options.retain(|a| !default_agents.contains(&a.id.as_str()));\n                    final_options.model_selector.agents = agent_options.clone();\n                    yield patch::update_agents(agent_options);\n                    yield patch::agents_loaded();\n\n                    let defaults = Self::hardcoded_slash_commands();\n                    let slash_commands = reorder_slash_commands(\n                        [slash_commands_initial, defaults].concat()\n                    );\n                    final_options.slash_commands = slash_commands.clone();\n                    yield patch::update_slash_commands(slash_commands);\n\n                    let slash_commands_with_descriptions = Self::fill_slash_command_descriptions(\n                        &discovery_path,\n                        &plugins,\n                        &final_options.slash_commands,\n                    ).await;\n                    final_options.slash_commands = slash_commands_with_descriptions;\n                    yield patch::update_slash_commands(final_options.slash_commands.clone());\n                    yield patch::slash_commands_loaded();\n\n                    let cache = executor_options_cache();\n                    if let Some(path) = &target_path {\n                        let target_cache_key = ExecutorConfigCacheKey::new(\n                            Some(path),\n                            cmd_key_for_discovery.clone(),\n                            BaseCodingAgent::ClaudeCode,\n                        );\n                        cache.put(target_cache_key, final_options.clone());\n                    }\n                    let global_cache_key = ExecutorConfigCacheKey::new(\n                        None,\n                        cmd_key_for_discovery,\n                        BaseCodingAgent::ClaudeCode,\n                    );\n                    cache.put(global_cache_key, final_options);\n                }\n                Err(e) => {\n                    tracing::warn!(\"Failed to discover Claude Code options: {}\", e);\n                    yield patch::discovery_error(e.to_string());\n                }\n            }\n        };\n\n        Ok(Box::pin(\n            futures::stream::once(async move { initial_patch }).chain(discovery_stream),\n        ))\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        use crate::model_selector::*;\n\n        let permission_policy = if self.plan.unwrap_or(false) {\n            PermissionPolicy::Plan\n        } else if self.dangerously_skip_permissions.unwrap_or(false) {\n            PermissionPolicy::Auto\n        } else if self.approvals.unwrap_or(false) {\n            PermissionPolicy::Supervised\n        } else {\n            PermissionPolicy::Auto\n        };\n\n        ExecutorConfig {\n            executor: BaseCodingAgent::ClaudeCode,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: None,\n            reasoning_id: self.effort.as_ref().map(|e| e.as_ref().to_owned()),\n            permission_policy: Some(permission_policy),\n        }\n    }\n\n    // MCP configuration methods\n\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        dirs::home_dir().map(|home| home.join(\".claude.json\"))\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        let auth_file_path = dirs::home_dir().map(|home| home.join(\".claude.json\"));\n\n        if let Some(path) = auth_file_path\n            && let Some(timestamp) = std::fs::metadata(&path)\n                .ok()\n                .and_then(|m| m.modified().ok())\n                .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())\n                .map(|d| d.as_secs() as i64)\n        {\n            return AvailabilityInfo::LoginDetected {\n                last_auth_timestamp: timestamp,\n            };\n        }\n        AvailabilityInfo::NotFound\n    }\n}\n\nimpl ClaudeCode {\n    async fn spawn_internal(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        command_parts: CommandParts,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let (program_path, args) = command_parts.into_resolved().await?;\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n\n        let mut command = Command::new(program_path);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .args(&args);\n\n        env.clone()\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut command);\n\n        // Remove ANTHROPIC_API_KEY if disable_api_key is enabled\n        if self.disable_api_key.unwrap_or(false) {\n            command.env_remove(\"ANTHROPIC_API_KEY\");\n            tracing::info!(\"ANTHROPIC_API_KEY removed from environment\");\n        }\n\n        let mut child = command.group_spawn_no_window()?;\n        let child_stdout = child.inner().stdout.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::other(\"Claude Code missing stdout\"))\n        })?;\n        let child_stdin =\n            child.inner().stdin.take().ok_or_else(|| {\n                ExecutorError::Io(std::io::Error::other(\"Claude Code missing stdin\"))\n            })?;\n\n        let new_stdout = create_stdout_pipe_writer(&mut child)?;\n        let permission_mode = self.permission_mode();\n        let hooks = self.get_hooks(env.commit_reminder);\n\n        // Create cancellation token for graceful shutdown\n        let cancel = CancellationToken::new();\n\n        // Spawn task to handle the SDK client with control protocol\n        let prompt_clone = combined_prompt.clone();\n        let approvals_clone = self.approvals_service.clone();\n        let repo_context = env.repo_context.clone();\n        let commit_reminder_prompt = env.commit_reminder_prompt.clone();\n        let cancel_for_task = cancel.clone();\n        tokio::spawn(async move {\n            let log_writer = LogWriter::new(new_stdout);\n            let client = ClaudeAgentClient::new(\n                log_writer.clone(),\n                approvals_clone,\n                repo_context,\n                commit_reminder_prompt,\n                cancel_for_task.clone(),\n            );\n            let protocol_peer =\n                ProtocolPeer::spawn(child_stdin, child_stdout, client.clone(), cancel_for_task);\n\n            // Initialize control protocol\n            if let Err(e) = protocol_peer.initialize(hooks).await {\n                tracing::error!(\"Failed to initialize control protocol: {e}\");\n                let _ = log_writer\n                    .log_raw(&format!(\"Error: Failed to initialize - {e}\"))\n                    .await;\n                return;\n            }\n\n            if let Err(e) = protocol_peer.set_permission_mode(permission_mode).await {\n                tracing::warn!(\"Failed to set permission mode to {permission_mode}: {e}\");\n            }\n\n            // Send user message\n            if let Err(e) = protocol_peer.send_user_message(prompt_clone).await {\n                tracing::error!(\"Failed to send prompt: {e}\");\n                let _ = log_writer\n                    .log_raw(&format!(\"Error: Failed to send prompt - {e}\"))\n                    .await;\n            }\n        });\n\n        Ok(SpawnedChild {\n            child,\n            exit_signal: None,\n            cancel: Some(cancel),\n        })\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum HistoryStrategy {\n    // Claude-code format\n    Default,\n    // Amp threads format which includes logs from previous executions\n    AmpResume,\n}\n\n/// Default context window for models (used until we get actual value from result)\nconst DEFAULT_CLAUDE_CONTEXT_WINDOW: u32 = 200_000;\n\n/// Handles log processing and interpretation for Claude executor\npub struct ClaudeLogProcessor {\n    model_name: Option<String>,\n    // Map tool_use_id -> structured info for follow-up ToolResult replacement\n    tool_map: HashMap<String, ClaudeToolCallInfo>,\n    // Strategy controlling how to handle history and user messages\n    strategy: HistoryStrategy,\n    streaming_messages: HashMap<String, StreamingMessageState>,\n    streaming_message_id: Option<String>,\n    last_assistant_message: Option<String>,\n    // Main model name (excluding subagents). Only used internally for context window tracking.\n    main_model_name: Option<String>,\n    main_model_context_window: u32,\n    context_tokens_used: u32,\n}\n\nimpl ClaudeLogProcessor {\n    #[cfg(test)]\n    fn new() -> Self {\n        Self::new_with_strategy(HistoryStrategy::Default)\n    }\n\n    fn new_with_strategy(strategy: HistoryStrategy) -> Self {\n        Self {\n            model_name: None,\n            main_model_name: None,\n            tool_map: HashMap::new(),\n            strategy,\n            streaming_messages: HashMap::new(),\n            streaming_message_id: None,\n            last_assistant_message: None,\n            main_model_context_window: DEFAULT_CLAUDE_CONTEXT_WINDOW,\n            context_tokens_used: 0,\n        }\n    }\n\n    /// Process raw logs and convert them to normalized entries with patches\n    pub fn process_logs(\n        msg_store: Arc<MsgStore>,\n        current_dir: &Path,\n        entry_index_provider: EntryIndexProvider,\n        strategy: HistoryStrategy,\n    ) -> tokio::task::JoinHandle<()> {\n        let current_dir_clone = current_dir.to_owned();\n        tokio::spawn(async move {\n            let mut stream = msg_store.history_plus_stream();\n            let mut buffer = String::new();\n            let worktree_path = current_dir_clone.to_string_lossy().to_string();\n            let mut session_id_extracted = false;\n            let mut processor = Self::new_with_strategy(strategy);\n            // Track pending assistant UUID - only committed when we see a Result message\n            let mut pending_assistant_uuid: Option<String> = None;\n\n            while let Some(Ok(msg)) = stream.next().await {\n                let chunk = match msg {\n                    LogMsg::Stdout(x) => x,\n                    LogMsg::JsonPatch(_)\n                    | LogMsg::SessionId(_)\n                    | LogMsg::MessageId(_)\n                    | LogMsg::Stderr(_)\n                    | LogMsg::Ready => continue,\n                    LogMsg::Finished => break,\n                };\n\n                buffer.push_str(&chunk);\n\n                // Process complete JSON lines\n                for line in buffer\n                    .split_inclusive('\\n')\n                    .filter(|l| l.ends_with('\\n'))\n                    .map(str::to_owned)\n                    .collect::<Vec<_>>()\n                {\n                    let trimmed = line.trim();\n                    if trimmed.is_empty() {\n                        continue;\n                    }\n\n                    // Filter out claude-code-router service messages\n                    if trimmed.starts_with(\"Service not running, starting service\")\n                        || trimmed\n                            .contains(\"claude code router service has been successfully stopped\")\n                    {\n                        continue;\n                    }\n\n                    match serde_json::from_str::<ClaudeJson>(trimmed) {\n                        Ok(claude_json) => {\n                            if !session_id_extracted\n                                && let Some(session_id) = Self::extract_session_id(&claude_json)\n                            {\n                                msg_store.push_session_id(session_id);\n                                session_id_extracted = true;\n                            }\n\n                            // Track message UUIDs for --resume-session-at:\n                            // - User messages: always valid, push immediately and clear pending\n                            // - Assistant messages: may have incomplete tool calls, store as pending\n                            // - Result messages: confirms assistant turn is complete, commit pending\n                            match &claude_json {\n                                ClaudeJson::User { uuid, .. } => {\n                                    pending_assistant_uuid = None;\n                                    if let Some(uuid) = uuid {\n                                        msg_store.push_message_id(uuid.clone());\n                                    }\n                                }\n                                ClaudeJson::Assistant { uuid, .. } => {\n                                    pending_assistant_uuid = uuid.clone();\n                                }\n                                ClaudeJson::Result { .. } => {\n                                    if let Some(uuid) = pending_assistant_uuid.take() {\n                                        msg_store.push_message_id(uuid);\n                                    }\n                                }\n                                _ => {}\n                            }\n\n                            let patches = processor.normalize_entries(\n                                &claude_json,\n                                &worktree_path,\n                                &entry_index_provider,\n                            );\n                            for patch in patches {\n                                msg_store.push_patch(patch);\n                            }\n                        }\n                        Err(_) => {\n                            // Handle non-JSON output as raw system message\n                            if !trimmed.is_empty() {\n                                let entry = NormalizedEntry {\n                                    timestamp: None,\n                                    entry_type: NormalizedEntryType::SystemMessage,\n                                    content: trimmed.to_string(),\n                                    metadata: None,\n                                };\n\n                                let patch_id = entry_index_provider.next();\n                                let patch =\n                                    ConversationPatch::add_normalized_entry(patch_id, entry);\n                                msg_store.push_patch(patch);\n                            }\n                        }\n                    }\n                }\n\n                // Keep the partial line in the buffer\n                buffer = buffer.rsplit('\\n').next().unwrap_or(\"\").to_owned();\n            }\n\n            // Handle any remaining content in buffer\n            if !buffer.trim().is_empty() {\n                let entry = NormalizedEntry {\n                    timestamp: None,\n                    entry_type: NormalizedEntryType::SystemMessage,\n                    content: buffer.trim().to_string(),\n                    metadata: None,\n                };\n\n                let patch_id = entry_index_provider.next();\n                let patch = ConversationPatch::add_normalized_entry(patch_id, entry);\n                msg_store.push_patch(patch);\n            }\n        })\n    }\n\n    /// Extract session ID from Claude JSON\n    fn extract_session_id(claude_json: &ClaudeJson) -> Option<String> {\n        match claude_json {\n            ClaudeJson::System { .. } => None, // session might not have been initialized yet\n            ClaudeJson::Assistant { session_id, .. } => session_id.clone(),\n            ClaudeJson::User { session_id, .. } => session_id.clone(),\n            ClaudeJson::ToolUse { session_id, .. } => session_id.clone(),\n            ClaudeJson::ToolResult { session_id, .. } => session_id.clone(),\n            ClaudeJson::Result { session_id, .. } => session_id.clone(),\n            ClaudeJson::StreamEvent { .. } => None, // session might not have been initialized yet\n            ClaudeJson::ApprovalRequested { .. } => None,\n            ClaudeJson::ApprovalResponse { .. } => None,\n            ClaudeJson::QuestionResponse { .. } => None,\n            ClaudeJson::ControlRequest { .. } => None,\n            ClaudeJson::ControlResponse { .. } => None,\n            ClaudeJson::ControlCancelRequest { .. } => None,\n            ClaudeJson::RateLimitEvent { session_id, .. } => session_id.clone(),\n            ClaudeJson::Unknown { .. } => None,\n        }\n    }\n\n    /// Generate warning entry if API key source is ANTHROPIC_API_KEY\n    fn warn_if_unmanaged_key(src: &Option<String>) -> Option<NormalizedEntry> {\n        match src.as_deref() {\n            Some(\"ANTHROPIC_API_KEY\") => {\n                tracing::warn!(\n                    \"ANTHROPIC_API_KEY env variable detected, your Anthropic subscription is not being used\"\n                );\n                Some(NormalizedEntry {\n                    timestamp: None,\n                    entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other,\n                    },\n                    content: \"Claude Code + ANTHROPIC_API_KEY detected. Usage will be billed via Anthropic pay-as-you-go instead of your Claude subscription. If this is unintended, please select the `disable_api_key` checkbox in the conding-agent-configurations settings page.\".to_string(),\n                    metadata: None,\n                })\n            }\n            _ => None,\n        }\n    }\n\n    /// Normalize Claude tool_result content to either Markdown string or parsed JSON.\n    /// - If content is a string that parses as JSON, return Json with parsed value.\n    /// - If content is a string (non-JSON), return Markdown with the raw string.\n    /// - If content is an array of { text: string }, join texts as Markdown.\n    /// - Otherwise return Json with the original value.\n    fn normalize_claude_tool_result_value(\n        content: &serde_json::Value,\n    ) -> (crate::logs::ToolResultValueType, serde_json::Value) {\n        if let Some(s) = content.as_str() {\n            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {\n                return (crate::logs::ToolResultValueType::Json, parsed);\n            }\n            return (\n                crate::logs::ToolResultValueType::Markdown,\n                serde_json::Value::String(s.to_string()),\n            );\n        }\n\n        if let Ok(items) = serde_json::from_value::<Vec<ClaudeToolResultTextItem>>(content.clone())\n            && !items.is_empty()\n        {\n            let joined = items\n                .into_iter()\n                .map(|i| i.text)\n                .collect::<Vec<_>>()\n                .join(\"\\n\\n\");\n            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&joined) {\n                return (crate::logs::ToolResultValueType::Json, parsed);\n            }\n            return (\n                crate::logs::ToolResultValueType::Markdown,\n                serde_json::Value::String(joined),\n            );\n        }\n\n        (crate::logs::ToolResultValueType::Json, content.clone())\n    }\n\n    fn build_tool_use_entry(\n        tool_data: &ClaudeToolData,\n        worktree_path: &str,\n        status: ToolStatus,\n    ) -> (NormalizedEntry, String, String) {\n        let tool_name = tool_data.get_name().to_string();\n        let action_type = Self::extract_action_type(tool_data, worktree_path);\n        let content = Self::generate_concise_content(tool_data, &action_type, worktree_path);\n        let entry = Self::tool_use_entry(tool_name.clone(), action_type, status, content.clone());\n        (entry, tool_name, content)\n    }\n\n    fn tool_use_entry(\n        tool_name: String,\n        action_type: ActionType,\n        status: ToolStatus,\n        content: String,\n    ) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name,\n                action_type,\n                status,\n            },\n            content,\n            metadata: None,\n        }\n    }\n\n    fn replace_tool_entry_status(\n        &mut self,\n        tool_call_id: &str,\n        status: ToolStatus,\n        worktree_path: &str,\n        patches: &mut Vec<json_patch::Patch>,\n    ) {\n        if let Some(info) = self.tool_map.get(tool_call_id).cloned() {\n            let action_type = Self::extract_action_type(&info.tool_data, worktree_path);\n            let entry = Self::tool_use_entry(\n                info.tool_name.clone(),\n                action_type,\n                status,\n                info.content.clone(),\n            );\n            patches.push(ConversationPatch::replace(info.entry_index, entry));\n        }\n    }\n\n    /// Convert Claude content item to normalized entry\n    fn content_item_to_normalized_entry(\n        content_item: &ClaudeContentItem,\n        role: &str,\n        worktree_path: &str,\n        last_assistant_message: &mut Option<String>,\n    ) -> Option<NormalizedEntry> {\n        match content_item {\n            ClaudeContentItem::Text { text } => {\n                let entry_type = match role {\n                    \"assistant\" => NormalizedEntryType::AssistantMessage,\n                    _ => return None,\n                };\n                *last_assistant_message = Some(text.clone());\n                Some(NormalizedEntry {\n                    timestamp: None,\n                    entry_type,\n                    content: text.clone(),\n                    metadata: Some(\n                        serde_json::to_value(content_item).unwrap_or(serde_json::Value::Null),\n                    ),\n                })\n            }\n            ClaudeContentItem::Thinking { thinking } => Some(NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::Thinking,\n                content: thinking.clone(),\n                metadata: Some(\n                    serde_json::to_value(content_item).unwrap_or(serde_json::Value::Null),\n                ),\n            }),\n            ClaudeContentItem::ToolUse { tool_data, id: _ } => {\n                let (entry, _, _) =\n                    Self::build_tool_use_entry(tool_data, worktree_path, ToolStatus::Created);\n                Some(entry)\n            }\n            ClaudeContentItem::ToolResult { .. } => {\n                // TODO: Add proper ToolResult support to NormalizedEntry when the type system supports it\n                None\n            }\n        }\n    }\n\n    /// Extract action type from structured tool data\n    fn extract_action_type(tool_data: &ClaudeToolData, worktree_path: &str) -> ActionType {\n        match tool_data {\n            ClaudeToolData::Read { file_path } => ActionType::FileRead {\n                path: make_path_relative(file_path, worktree_path),\n            },\n            ClaudeToolData::Edit {\n                file_path,\n                old_string,\n                new_string,\n            } => {\n                let changes = if old_string.is_some() || new_string.is_some() {\n                    vec![FileChange::Edit {\n                        unified_diff: create_unified_diff(\n                            file_path,\n                            &old_string.clone().unwrap_or_default(),\n                            &new_string.clone().unwrap_or_default(),\n                        ),\n                        has_line_numbers: false,\n                    }]\n                } else {\n                    vec![]\n                };\n                ActionType::FileEdit {\n                    path: make_path_relative(file_path, worktree_path),\n                    changes,\n                }\n            }\n            ClaudeToolData::MultiEdit { file_path, edits } => {\n                let changes: Vec<FileChange> = edits\n                    .iter()\n                    .filter(|edit| edit.old_string.is_some() || edit.new_string.is_some())\n                    .map(|edit| FileChange::Edit {\n                        unified_diff: create_unified_diff(\n                            file_path,\n                            &edit.old_string.clone().unwrap_or_default(),\n                            &edit.new_string.clone().unwrap_or_default(),\n                        ),\n                        has_line_numbers: false,\n                    })\n                    .collect();\n                ActionType::FileEdit {\n                    path: make_path_relative(file_path, worktree_path),\n                    changes,\n                }\n            }\n            ClaudeToolData::Write { file_path, content } => {\n                let diffs = vec![FileChange::Write {\n                    content: content.clone(),\n                }];\n                ActionType::FileEdit {\n                    path: make_path_relative(file_path, worktree_path),\n                    changes: diffs,\n                }\n            }\n            ClaudeToolData::Bash { command, .. } => ActionType::CommandRun {\n                command: command.clone(),\n                result: None,\n                category: CommandCategory::from_command(command),\n            },\n            ClaudeToolData::Grep { pattern, .. } => ActionType::Search {\n                query: pattern.clone(),\n            },\n            ClaudeToolData::WebFetch { url, .. } => ActionType::WebFetch { url: url.clone() },\n            ClaudeToolData::WebSearch { query, .. } => ActionType::WebFetch { url: query.clone() },\n            ClaudeToolData::Task {\n                description,\n                prompt,\n                subagent_type,\n            } => {\n                let task_description = if let Some(desc) = description {\n                    desc.clone()\n                } else {\n                    prompt.clone().unwrap_or_default()\n                };\n                ActionType::TaskCreate {\n                    description: task_description,\n                    subagent_type: subagent_type.clone(),\n                    result: None,\n                }\n            }\n            ClaudeToolData::ExitPlanMode { plan } => {\n                ActionType::PlanPresentation { plan: plan.clone() }\n            }\n            ClaudeToolData::NotebookEdit { .. } => ActionType::Tool {\n                tool_name: \"NotebookEdit\".to_string(),\n                arguments: Some(serde_json::to_value(tool_data).unwrap_or(serde_json::Value::Null)),\n                result: None,\n            },\n            ClaudeToolData::TodoWrite { todos } => ActionType::TodoManagement {\n                todos: todos\n                    .iter()\n                    .map(|t| TodoItem {\n                        content: t.content.clone(),\n                        status: t.status.clone(),\n                        priority: t.priority.clone(),\n                    })\n                    .collect(),\n                operation: \"write\".to_string(),\n            },\n            ClaudeToolData::TodoRead { .. } => ActionType::TodoManagement {\n                todos: vec![],\n                operation: \"read\".to_string(),\n            },\n            ClaudeToolData::Glob { pattern, .. } => ActionType::Search {\n                query: pattern.clone(),\n            },\n            ClaudeToolData::LS { .. } => ActionType::Other {\n                description: \"List directory\".to_string(),\n            },\n            ClaudeToolData::Oracle { .. } => ActionType::Other {\n                description: \"Oracle\".to_string(),\n            },\n            ClaudeToolData::Mermaid { .. } => ActionType::Other {\n                description: \"Mermaid diagram\".to_string(),\n            },\n            ClaudeToolData::CodebaseSearchAgent { .. } => ActionType::Other {\n                description: \"Codebase search\".to_string(),\n            },\n            ClaudeToolData::UndoEdit { .. } => ActionType::Other {\n                description: \"Undo edit\".to_string(),\n            },\n            ClaudeToolData::AskUserQuestion { questions } => ActionType::AskUserQuestion {\n                questions: questions\n                    .iter()\n                    .map(|q| AskUserQuestionItem {\n                        question: q.question.clone(),\n                        header: q.header.clone(),\n                        options: q\n                            .options\n                            .iter()\n                            .map(|o| AskUserQuestionOption {\n                                label: o.label.clone(),\n                                description: o.description.clone(),\n                            })\n                            .collect(),\n                        multi_select: q.multi_select,\n                    })\n                    .collect(),\n            },\n            ClaudeToolData::Unknown { .. } => {\n                // Surface MCP tools as generic Tool with args\n                let name = tool_data.get_name();\n                if name.starts_with(\"mcp__\") {\n                    let parts: Vec<&str> = name.split(\"__\").collect();\n                    let label = if parts.len() >= 3 {\n                        format!(\"mcp:{}:{}\", parts[1], parts[2])\n                    } else {\n                        name.to_string()\n                    };\n                    // Extract `input` if present by serializing then deserializing to a tiny struct\n                    let args = serde_json::to_value(tool_data)\n                        .ok()\n                        .and_then(|v| serde_json::from_value::<ClaudeToolWithInput>(v).ok())\n                        .map(|w| w.input)\n                        .unwrap_or(serde_json::Value::Null);\n                    ActionType::Tool {\n                        tool_name: label,\n                        arguments: Some(args),\n                        result: None,\n                    }\n                } else {\n                    ActionType::Other {\n                        description: format!(\"Tool: {}\", tool_data.get_name()),\n                    }\n                }\n            }\n        }\n    }\n\n    /// Convert Claude JSON to normalized patches\n    fn normalize_entries(\n        &mut self,\n        claude_json: &ClaudeJson,\n        worktree_path: &str,\n        entry_index_provider: &EntryIndexProvider,\n    ) -> Vec<json_patch::Patch> {\n        let mut patches = Vec::new();\n        match claude_json {\n            ClaudeJson::System {\n                subtype,\n                api_key_source,\n                model,\n                status,\n                tool_use_id,\n                description,\n                task_type,\n                prompt,\n                summary,\n                ..\n            } => {\n                // emit billing warning if required\n                if let Some(warning) = Self::warn_if_unmanaged_key(api_key_source) {\n                    let idx = entry_index_provider.next();\n                    patches.push(ConversationPatch::add_normalized_entry(idx, warning));\n                }\n\n                // keep the existing behaviour for the normal system message\n                match subtype.as_deref() {\n                    Some(\"init\") => {\n                        if self.main_model_name.is_none() {\n                            // this name matches the model names in the usage report in the result message\n                            if let Some(model) = model {\n                                self.main_model_name = Some(model.clone());\n                                if model.contains(\"[1m]\") {\n                                    self.main_model_context_window = 1_000_000;\n                                }\n                            }\n                        }\n                        // Skip system init messages because it doesn't contain the actual model that will be used in assistant messages in case of claude-code-router.\n                        // We'll send system initialized message with first assistant message that has a model field.\n                    }\n                    Some(\"status\") => {\n                        if let Some(status) = status {\n                            patches.push(add_system_message(status.clone(), entry_index_provider));\n                        }\n                    }\n                    Some(\"compact_boundary\") => {}\n                    Some(\"task_started\") => {\n                        if let Some(tool_use_id) = tool_use_id\n                            && !self.tool_map.contains_key(tool_use_id)\n                        {\n                            let desc = description.clone().unwrap_or_else(|| \"Task\".to_string());\n                            let subagent_type = task_type.clone();\n                            let entry = Self::tool_use_entry(\n                                \"Task\".to_string(),\n                                ActionType::TaskCreate {\n                                    description: desc.clone(),\n                                    subagent_type: subagent_type.clone(),\n                                    result: None,\n                                },\n                                ToolStatus::Created,\n                                desc.clone(),\n                            );\n                            let idx = entry_index_provider.next();\n                            patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n                            self.tool_map.insert(\n                                tool_use_id.clone(),\n                                ClaudeToolCallInfo {\n                                    entry_index: idx,\n                                    tool_name: \"Task\".to_string(),\n                                    tool_data: ClaudeToolData::Task {\n                                        subagent_type,\n                                        description: description.clone(),\n                                        prompt: prompt.clone(),\n                                    },\n                                    content: desc,\n                                },\n                            );\n                        }\n                    }\n                    Some(\"task_progress\") => {\n                        if let (Some(tool_use_id), Some(desc)) = (tool_use_id, description)\n                            && let Some(info) = self.tool_map.get(tool_use_id).cloned()\n                        {\n                            let subagent_type =\n                                if let ClaudeToolData::Task { subagent_type, .. } = &info.tool_data\n                                {\n                                    subagent_type.clone()\n                                } else {\n                                    None\n                                };\n                            let entry = Self::tool_use_entry(\n                                info.tool_name.clone(),\n                                ActionType::TaskCreate {\n                                    description: info.content.clone(),\n                                    subagent_type,\n                                    result: None,\n                                },\n                                ToolStatus::Created,\n                                desc.clone(),\n                            );\n                            patches.push(ConversationPatch::replace(info.entry_index, entry));\n                        }\n                    }\n                    Some(\"task_notification\") => {\n                        if let Some(tool_use_id) = tool_use_id\n                            && let Some(info) = self.tool_map.get(tool_use_id).cloned()\n                        {\n                            let task_status = match status.as_deref() {\n                                Some(\"failed\") | Some(\"error\") => ToolStatus::Failed,\n                                _ => ToolStatus::Success,\n                            };\n                            let subagent_type =\n                                if let ClaudeToolData::Task { subagent_type, .. } = &info.tool_data\n                                {\n                                    subagent_type.clone()\n                                } else {\n                                    None\n                                };\n                            let desc = summary\n                                .clone()\n                                .or(description.clone())\n                                .unwrap_or_else(|| info.content.clone());\n                            let entry = Self::tool_use_entry(\n                                info.tool_name.clone(),\n                                ActionType::TaskCreate {\n                                    description: desc.clone(),\n                                    subagent_type,\n                                    result: None,\n                                },\n                                task_status,\n                                desc,\n                            );\n                            patches.push(ConversationPatch::replace(info.entry_index, entry));\n                        }\n                    }\n                    Some(subtype) => {\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: format!(\"System: {subtype}\"),\n                            metadata: Some(\n                                serde_json::to_value(claude_json)\n                                    .unwrap_or(serde_json::Value::Null),\n                            ),\n                        };\n                        let idx = entry_index_provider.next();\n                        patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n                    }\n                    None => {\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: \"System message\".to_string(),\n                            metadata: Some(\n                                serde_json::to_value(claude_json)\n                                    .unwrap_or(serde_json::Value::Null),\n                            ),\n                        };\n                        let idx = entry_index_provider.next();\n                        patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n                    }\n                }\n            }\n            ClaudeJson::Assistant { message, .. } => {\n                if let Some(patch) = extract_model_name(self, message, entry_index_provider) {\n                    patches.push(patch);\n                }\n\n                let mut streaming_message_state = message\n                    .id\n                    .as_ref()\n                    .and_then(|id| self.streaming_messages.remove(id));\n\n                for (content_index, item) in message.content.items().enumerate() {\n                    let entry_index = streaming_message_state\n                        .as_mut()\n                        .and_then(|state| state.content_entry_index(content_index));\n\n                    match item {\n                        ClaudeContentItem::ToolUse { id, tool_data } => {\n                            let (entry, tool_name, content_text) = Self::build_tool_use_entry(\n                                tool_data,\n                                worktree_path,\n                                ToolStatus::Created,\n                            );\n                            let existing_idx = entry_index\n                                .or_else(|| self.tool_map.get(id).map(|info| info.entry_index));\n                            let is_new = existing_idx.is_none();\n                            let id_num =\n                                existing_idx.unwrap_or_else(|| entry_index_provider.next());\n                            self.tool_map.insert(\n                                id.clone(),\n                                ClaudeToolCallInfo {\n                                    entry_index: id_num,\n                                    tool_name: tool_name.clone(),\n                                    tool_data: tool_data.clone(),\n                                    content: content_text,\n                                },\n                            );\n                            let patch = if is_new {\n                                ConversationPatch::add_normalized_entry(id_num, entry)\n                            } else {\n                                ConversationPatch::replace(id_num, entry)\n                            };\n                            patches.push(patch);\n                        }\n                        ClaudeContentItem::Text { .. } | ClaudeContentItem::Thinking { .. } => {\n                            if let Some(entry) = Self::content_item_to_normalized_entry(\n                                item,\n                                &message.role,\n                                worktree_path,\n                                &mut self.last_assistant_message,\n                            ) {\n                                let is_new = entry_index.is_none();\n                                let idx =\n                                    entry_index.unwrap_or_else(|| entry_index_provider.next());\n                                let patch = if is_new {\n                                    ConversationPatch::add_normalized_entry(idx, entry)\n                                } else {\n                                    ConversationPatch::replace(idx, entry)\n                                };\n                                patches.push(patch);\n                            }\n                        }\n                        ClaudeContentItem::ToolResult { .. } => {}\n                    }\n                }\n            }\n            ClaudeJson::User {\n                message,\n                is_synthetic,\n                is_replay,\n                ..\n            } => {\n                // Skip replay messages entirely - they're historical context from resumed sessions\n                if *is_replay {\n                    return patches;\n                }\n\n                if matches!(self.strategy, HistoryStrategy::AmpResume)\n                    && message\n                        .content\n                        .items()\n                        .any(|c| matches!(c, ClaudeContentItem::Text { .. }))\n                {\n                    let cur = entry_index_provider.current();\n                    if cur > 0 {\n                        for _ in 0..cur {\n                            patches.push(ConversationPatch::remove_diff(0.to_string()));\n                        }\n                        entry_index_provider.reset();\n                        self.tool_map.clear();\n                    }\n\n                    for item in message.content.items() {\n                        if let ClaudeContentItem::Text { text } = item {\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::UserMessage,\n                                content: text.clone(),\n                                metadata: Some(\n                                    serde_json::to_value(item).unwrap_or(serde_json::Value::Null),\n                                ),\n                            };\n                            let id = entry_index_provider.next();\n                            patches.push(ConversationPatch::add_normalized_entry(id, entry));\n                        }\n                    }\n                }\n\n                if *is_synthetic {\n                    for item in message.content.items() {\n                        if let ClaudeContentItem::Text { text } = item {\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::SystemMessage,\n                                content: text.clone(),\n                                metadata: None,\n                            };\n                            let id = entry_index_provider.next();\n                            patches.push(ConversationPatch::add_normalized_entry(id, entry));\n                        }\n                    }\n                }\n\n                if let Some(mut text) = message.content.as_text().cloned() {\n                    if text.starts_with(\"<local-command-stdout>\")\n                        && text.ends_with(\"</local-command-stdout>\")\n                    {\n                        text = text\n                            .trim_start_matches(\"<local-command-stdout>\")\n                            .trim_end_matches(\"</local-command-stdout>\")\n                            .to_string();\n                    }\n                    patches.push(add_system_message(text.clone(), entry_index_provider));\n                }\n\n                for item in message.content.items() {\n                    if let ClaudeContentItem::ToolResult {\n                        tool_use_id,\n                        content,\n                        is_error,\n                    } = item\n                        && let Some(info) = self.tool_map.get(tool_use_id).cloned()\n                    {\n                        let is_command = matches!(info.tool_data, ClaudeToolData::Bash { .. });\n\n                        let _display_tool_name = if is_command {\n                            info.tool_name.clone()\n                        } else {\n                            let raw_name = info.tool_data.get_name().to_string();\n                            if raw_name.starts_with(\"mcp__\") {\n                                let parts: Vec<&str> = raw_name.split(\"__\").collect();\n                                if parts.len() >= 3 {\n                                    format!(\"mcp:{}:{}\", parts[1], parts[2])\n                                } else {\n                                    raw_name\n                                }\n                            } else {\n                                raw_name\n                            }\n                        };\n\n                        if is_command {\n                            let content_str = if let Some(s) = content.as_str() {\n                                s.to_string()\n                            } else {\n                                content.to_string()\n                            };\n\n                            let result = if let Ok(result) =\n                                serde_json::from_str::<AmpBashResult>(&content_str)\n                            {\n                                Some(crate::logs::CommandRunResult {\n                                    exit_status: Some(crate::logs::CommandExitStatus::ExitCode {\n                                        code: result.exit_code,\n                                    }),\n                                    output: Some(result.output),\n                                })\n                            } else {\n                                Some(crate::logs::CommandRunResult {\n                                    exit_status: (*is_error).map(|is_error| {\n                                        crate::logs::CommandExitStatus::Success {\n                                            success: !is_error,\n                                        }\n                                    }),\n                                    output: Some(content_str),\n                                })\n                            };\n\n                            let status = if is_error.unwrap_or(false) {\n                                ToolStatus::Failed\n                            } else {\n                                ToolStatus::Success\n                            };\n\n                            let entry = Self::tool_use_entry(\n                                info.tool_name.clone(),\n                                ActionType::CommandRun {\n                                    command: info.content.clone(),\n                                    result,\n                                    category: CommandCategory::from_command(&info.content),\n                                },\n                                status,\n                                info.content.clone(),\n                            );\n                            patches.push(ConversationPatch::replace(info.entry_index, entry));\n                        } else if matches!(info.tool_data, ClaudeToolData::Task { .. }) {\n                            // Handle Task tool results - capture subagent output\n                            let (res_type, res_value) =\n                                Self::normalize_claude_tool_result_value(content);\n\n                            let status = if is_error.unwrap_or(false) {\n                                ToolStatus::Failed\n                            } else {\n                                ToolStatus::Success\n                            };\n\n                            // Extract subagent_type from the original tool_data\n                            let subagent_type =\n                                if let ClaudeToolData::Task { subagent_type, .. } = &info.tool_data\n                                {\n                                    subagent_type.clone()\n                                } else {\n                                    None\n                                };\n\n                            let entry = Self::tool_use_entry(\n                                info.tool_name.clone(),\n                                ActionType::TaskCreate {\n                                    description: info.content.clone(),\n                                    subagent_type,\n                                    result: Some(crate::logs::ToolResult {\n                                        r#type: res_type,\n                                        value: res_value,\n                                    }),\n                                },\n                                status,\n                                info.content.clone(),\n                            );\n                            patches.push(ConversationPatch::replace(info.entry_index, entry));\n                        } else if matches!(\n                            info.tool_data,\n                            ClaudeToolData::Unknown { .. }\n                                | ClaudeToolData::Oracle { .. }\n                                | ClaudeToolData::Mermaid { .. }\n                                | ClaudeToolData::CodebaseSearchAgent { .. }\n                                | ClaudeToolData::NotebookEdit { .. }\n                        ) {\n                            let (res_type, res_value) =\n                                Self::normalize_claude_tool_result_value(content);\n\n                            let args_to_show = serde_json::to_value(&info.tool_data)\n                                .ok()\n                                .and_then(|v| serde_json::from_value::<ClaudeToolWithInput>(v).ok())\n                                .map(|w| w.input)\n                                .unwrap_or(serde_json::Value::Null);\n\n                            let tool_name = info.tool_data.get_name().to_string();\n                            let is_mcp = tool_name.starts_with(\"mcp__\");\n                            let label = if is_mcp {\n                                let parts: Vec<&str> = tool_name.split(\"__\").collect();\n                                if parts.len() >= 3 {\n                                    format!(\"mcp:{}:{}\", parts[1], parts[2])\n                                } else {\n                                    tool_name.clone()\n                                }\n                            } else {\n                                tool_name.clone()\n                            };\n\n                            let status = if is_error.unwrap_or(false) {\n                                ToolStatus::Failed\n                            } else {\n                                ToolStatus::Success\n                            };\n\n                            let entry = Self::tool_use_entry(\n                                label.clone(),\n                                ActionType::Tool {\n                                    tool_name: label,\n                                    arguments: Some(args_to_show),\n                                    result: Some(crate::logs::ToolResult {\n                                        r#type: res_type,\n                                        value: res_value,\n                                    }),\n                                },\n                                status,\n                                info.content.clone(),\n                            );\n                            patches.push(ConversationPatch::replace(info.entry_index, entry));\n                        }\n                        // Note: With control protocol, denials are handled via protocol messages\n                        // rather than error content parsing\n                    }\n                }\n            }\n            ClaudeJson::ToolUse { tool_data, id, .. } => {\n                let (entry, tool_name_value, content_text) =\n                    Self::build_tool_use_entry(tool_data, worktree_path, ToolStatus::Created);\n                let existing = self.tool_map.get(id);\n                let (idx, is_new) = if let Some(info) = existing {\n                    (info.entry_index, false)\n                } else {\n                    (entry_index_provider.next(), true)\n                };\n                let patch = if is_new {\n                    ConversationPatch::add_normalized_entry(idx, entry)\n                } else {\n                    ConversationPatch::replace(idx, entry)\n                };\n                patches.push(patch);\n\n                self.tool_map.insert(\n                    id.clone(),\n                    ClaudeToolCallInfo {\n                        entry_index: idx,\n                        tool_name: tool_name_value,\n                        tool_data: tool_data.clone(),\n                        content: content_text,\n                    },\n                );\n            }\n            ClaudeJson::ToolResult { .. } => {\n                // Add proper ToolResult support to NormalizedEntry when the type system supports it\n            }\n            ClaudeJson::StreamEvent {\n                event,\n                parent_tool_use_id,\n                ..\n            } => match event {\n                ClaudeStreamEvent::MessageStart { message } => {\n                    if message.role == \"assistant\" {\n                        if let Some(patch) = extract_model_name(self, message, entry_index_provider)\n                        {\n                            patches.push(patch);\n                        }\n\n                        if let Some(message_id) = message.id.clone() {\n                            self.streaming_messages.insert(\n                                message_id.clone(),\n                                StreamingMessageState::new(message.role.clone()),\n                            );\n                            self.streaming_message_id = Some(message_id);\n                        } else {\n                            self.streaming_message_id = None;\n                        }\n                    } else {\n                        self.streaming_message_id = None;\n                    }\n                }\n                ClaudeStreamEvent::ContentBlockStart {\n                    index,\n                    content_block,\n                } => {\n                    if let Some(state) = self\n                        .streaming_message_id\n                        .as_ref()\n                        .and_then(|id| self.streaming_messages.get_mut(id))\n                    {\n                        state.content_block_start(*index, content_block.clone());\n                    }\n                }\n                ClaudeStreamEvent::ContentBlockDelta { index, delta } => {\n                    if let Some(state) = self\n                        .streaming_message_id\n                        .as_ref()\n                        .and_then(|id| self.streaming_messages.get_mut(id))\n                        && let Some(patch) = state.apply_content_block_delta(\n                            *index,\n                            delta,\n                            worktree_path,\n                            entry_index_provider,\n                            &mut self.last_assistant_message,\n                        )\n                    {\n                        patches.push(patch);\n                    }\n                }\n                ClaudeStreamEvent::ContentBlockStop { .. } => {}\n                ClaudeStreamEvent::MessageDelta { usage, .. } => {\n                    // do not report context token usage for subagents\n                    if parent_tool_use_id.is_none()\n                        && let Some(usage) = usage\n                    {\n                        let input_tokens = usage.input_tokens.unwrap_or(0)\n                            + usage.cache_creation_input_tokens.unwrap_or(0)\n                            + usage.cache_read_input_tokens.unwrap_or(0);\n                        let output_tokens = usage.output_tokens.unwrap_or(0);\n                        let total_tokens = input_tokens + output_tokens;\n                        self.context_tokens_used = total_tokens as u32;\n\n                        patches.push(self.add_token_usage_entry(entry_index_provider));\n                    }\n                }\n                ClaudeStreamEvent::MessageStop => {\n                    if let Some(message_id) = self.streaming_message_id.take() {\n                        let _ = self.streaming_messages.remove(&message_id);\n                    }\n                }\n                ClaudeStreamEvent::Unknown => {}\n            },\n            ClaudeJson::Result {\n                is_error,\n                model_usage,\n                subtype,\n                result,\n                ..\n            } => {\n                // get the real model context window and correct the context usage entry\n                if let Some(context_window) = model_usage.as_ref().and_then(|model_usage| {\n                    self.main_model_name\n                        .as_ref()\n                        .and_then(|name| model_usage.get(name))\n                        .and_then(|usage| usage.context_window)\n                }) {\n                    self.main_model_context_window = context_window;\n                    patches.push(self.add_token_usage_entry(entry_index_provider));\n                }\n\n                if matches!(self.strategy, HistoryStrategy::AmpResume) && is_error.unwrap_or(false)\n                {\n                    let entry = NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::ErrorMessage {\n                            error_type: NormalizedEntryError::Other,\n                        },\n                        content: serde_json::to_string(claude_json)\n                            .unwrap_or_else(|_| \"error\".to_string()),\n                        metadata: Some(\n                            serde_json::to_value(claude_json).unwrap_or(serde_json::Value::Null),\n                        ),\n                    };\n                    let idx = entry_index_provider.next();\n                    patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n                } else if matches!(subtype.as_deref(), Some(\"success\"))\n                    && let Some(text) = result.as_ref().and_then(|v| v.as_str())\n                    && (self.last_assistant_message.is_none()\n                        || matches!(&self.last_assistant_message, Some(message) if !message.contains(text)))\n                {\n                    let entry = NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::AssistantMessage,\n                        content: text.to_string(),\n                        metadata: Some(\n                            serde_json::to_value(claude_json).unwrap_or(serde_json::Value::Null),\n                        ),\n                    };\n                    let idx = entry_index_provider.next();\n                    patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n                }\n            }\n            ClaudeJson::ApprovalRequested {\n                tool_call_id,\n                tool_name: _,\n                approval_id,\n            } => {\n                self.replace_tool_entry_status(\n                    tool_call_id,\n                    ToolStatus::PendingApproval {\n                        approval_id: approval_id.clone(),\n                    },\n                    worktree_path,\n                    &mut patches,\n                );\n            }\n            ClaudeJson::ApprovalResponse {\n                tool_call_id,\n                tool_name,\n                approval_status,\n            } => {\n                if let Some(status) = ToolStatus::from_approval_status(approval_status) {\n                    self.replace_tool_entry_status(\n                        tool_call_id,\n                        status,\n                        worktree_path,\n                        &mut patches,\n                    );\n                }\n\n                let entry_opt = match approval_status {\n                    ApprovalStatus::Pending | ApprovalStatus::Approved => None,\n                    ApprovalStatus::Denied { reason } => Some(NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::UserFeedback {\n                            denied_tool: tool_name.clone(),\n                        },\n                        content: reason\n                            .as_ref()\n                            .map(|s| s.trim().to_string())\n                            .filter(|s| !s.is_empty())\n                            .unwrap_or_else(|| \"User denied this tool use request\".to_string()),\n                        metadata: None,\n                    }),\n                    ApprovalStatus::TimedOut => Some(NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::ErrorMessage {\n                            error_type: NormalizedEntryError::Other,\n                        },\n                        content: format!(\"Approval timed out for tool {tool_name}\"),\n                        metadata: None,\n                    }),\n                };\n\n                if let Some(entry) = entry_opt {\n                    let idx = entry_index_provider.next();\n                    patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n                }\n            }\n            ClaudeJson::QuestionResponse {\n                tool_call_id,\n                tool_name: _,\n                question_status,\n            } => {\n                let status = ToolStatus::from_question_status(question_status);\n                self.replace_tool_entry_status(tool_call_id, status, worktree_path, &mut patches);\n                let entry_opt = match question_status {\n                    QuestionStatus::Answered { answers } => {\n                        let qa_pairs: Vec<AnsweredQuestion> = answers\n                            .iter()\n                            .map(|qa| AnsweredQuestion {\n                                question: qa.question.clone(),\n                                answer: qa.answer.clone(),\n                            })\n                            .collect();\n                        Some(NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::UserAnsweredQuestions {\n                                answers: qa_pairs,\n                            },\n                            content: format!(\n                                \"Answered {} question{}\",\n                                answers.len(),\n                                if answers.len() != 1 { \"s\" } else { \"\" }\n                            ),\n                            metadata: None,\n                        })\n                    }\n                    QuestionStatus::TimedOut => None,\n                };\n\n                if let Some(entry) = entry_opt {\n                    let idx = entry_index_provider.next();\n                    patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n                }\n            }\n            ClaudeJson::Unknown { data } => {\n                let entry = NormalizedEntry {\n                    timestamp: None,\n                    entry_type: NormalizedEntryType::SystemMessage,\n                    content: format!(\n                        \"Unrecognized JSON message: {}\",\n                        serde_json::to_value(data).unwrap_or_default()\n                    ),\n                    metadata: None,\n                };\n                let idx = entry_index_provider.next();\n                patches.push(ConversationPatch::add_normalized_entry(idx, entry));\n            }\n            ClaudeJson::ControlRequest { .. }\n            | ClaudeJson::ControlResponse { .. }\n            | ClaudeJson::ControlCancelRequest { .. }\n            | ClaudeJson::RateLimitEvent { .. } => {}\n        }\n        patches\n    }\n    /// Generate concise, readable content for tool usage using structured data\n    fn generate_concise_content(\n        tool_data: &ClaudeToolData,\n        action_type: &ActionType,\n        worktree_path: &str,\n    ) -> String {\n        match action_type {\n            ActionType::FileRead { path } => path.to_string(),\n            ActionType::FileEdit { path, .. } => path.to_string(),\n            ActionType::CommandRun { command, .. } => command.to_string(),\n            ActionType::Search { query } => query.to_string(),\n            ActionType::WebFetch { url } => url.to_string(),\n            ActionType::TaskCreate { description, .. } => {\n                if description.is_empty() {\n                    \"Task\".to_string()\n                } else {\n                    format!(\"Task: `{description}`\")\n                }\n            }\n            ActionType::Tool { .. } => match tool_data {\n                ClaudeToolData::NotebookEdit { notebook_path, .. } => {\n                    format!(\"`{}`\", make_path_relative(notebook_path, worktree_path))\n                }\n                ClaudeToolData::Unknown { .. } => {\n                    let name = tool_data.get_name();\n                    if name.starts_with(\"mcp__\") {\n                        let parts: Vec<&str> = name.split(\"__\").collect();\n                        if parts.len() >= 3 {\n                            return format!(\"mcp:{}:{}\", parts[1], parts[2]);\n                        }\n                    }\n                    name.to_string()\n                }\n                _ => tool_data.get_name().to_string(),\n            },\n            ActionType::PlanPresentation { plan } => plan.clone(),\n            ActionType::TodoManagement { .. } => \"TODO list updated\".to_string(),\n            ActionType::AskUserQuestion { questions } => {\n                if questions.len() == 1 {\n                    questions[0].question.clone()\n                } else {\n                    format!(\"{} questions\", questions.len())\n                }\n            }\n            ActionType::Other { description: _ } => match tool_data {\n                ClaudeToolData::LS { path } => {\n                    let relative_path = make_path_relative(path, worktree_path);\n                    if relative_path.is_empty() {\n                        \"List directory\".to_string()\n                    } else {\n                        format!(\"List directory: {relative_path}\")\n                    }\n                }\n                ClaudeToolData::Glob { pattern, path, .. } => {\n                    if let Some(search_path) = path {\n                        format!(\n                            \"Find files: `{}` in {}\",\n                            pattern,\n                            make_path_relative(search_path, worktree_path)\n                        )\n                    } else {\n                        format!(\"Find files: `{pattern}`\")\n                    }\n                }\n                ClaudeToolData::Oracle { task, .. } => {\n                    if let Some(t) = task {\n                        format!(\"Oracle: `{t}`\")\n                    } else {\n                        \"Oracle\".to_string()\n                    }\n                }\n                ClaudeToolData::Mermaid { .. } => \"Mermaid diagram\".to_string(),\n                ClaudeToolData::CodebaseSearchAgent { query, path, .. } => {\n                    match (query.as_ref(), path.as_ref()) {\n                        (Some(q), Some(p)) if !q.is_empty() && !p.is_empty() => format!(\n                            \"Codebase search: `{}` in {}\",\n                            q,\n                            make_path_relative(p, worktree_path)\n                        ),\n                        (Some(q), _) if !q.is_empty() => format!(\"Codebase search: `{q}`\"),\n                        _ => \"Codebase search\".to_string(),\n                    }\n                }\n                ClaudeToolData::UndoEdit { path, .. } => {\n                    if let Some(p) = path.as_ref() {\n                        let rel = make_path_relative(p, worktree_path);\n                        if rel.is_empty() {\n                            \"Undo edit\".to_string()\n                        } else {\n                            format!(\"Undo edit: `{rel}`\")\n                        }\n                    } else {\n                        \"Undo edit\".to_string()\n                    }\n                }\n                _ => tool_data.get_name().to_string(),\n            },\n        }\n    }\n\n    fn add_token_usage_entry(\n        &mut self,\n        entry_index_provider: &EntryIndexProvider,\n    ) -> json_patch::Patch {\n        let entry = NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::TokenUsageInfo(crate::logs::TokenUsageInfo {\n                total_tokens: self.context_tokens_used,\n                model_context_window: self.main_model_context_window,\n            }),\n            content: format!(\n                \"Tokens used: {} / Context window: {}\",\n                self.context_tokens_used, self.main_model_context_window\n            ),\n            metadata: None,\n        };\n        let idx = entry_index_provider.next();\n        ConversationPatch::add_normalized_entry(idx, entry)\n    }\n}\n\nfn add_system_message(\n    content: String,\n    entry_index_provider: &EntryIndexProvider,\n) -> json_patch::Patch {\n    let entry = NormalizedEntry {\n        timestamp: None,\n        entry_type: NormalizedEntryType::SystemMessage,\n        content,\n        metadata: None,\n    };\n    let id = entry_index_provider.next();\n    ConversationPatch::add_normalized_entry(id, entry)\n}\n\nfn extract_model_name(\n    processor: &mut ClaudeLogProcessor,\n    message: &ClaudeMessage,\n    entry_index_provider: &EntryIndexProvider,\n) -> Option<json_patch::Patch> {\n    if processor.model_name.is_none()\n        && let Some(model) = message.model.as_ref()\n    {\n        processor.model_name = Some(model.clone());\n        let entry = NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::SystemMessage,\n            content: format!(\"System initialized with model: {model}\"),\n            metadata: None,\n        };\n        let id = entry_index_provider.next();\n        Some(ConversationPatch::add_normalized_entry(id, entry))\n    } else {\n        None\n    }\n}\n\nstruct StreamingMessageState {\n    role: String,\n    contents: HashMap<usize, StreamingContentState>,\n}\n\nimpl StreamingMessageState {\n    fn new(role: String) -> Self {\n        Self {\n            role,\n            contents: HashMap::new(),\n        }\n    }\n\n    fn content_block_start(&mut self, index: usize, content_block: ClaudeContentItem) {\n        if let Some(state) = StreamingContentState::from_content_block(content_block) {\n            self.contents.insert(index, state);\n        }\n    }\n\n    fn apply_content_block_delta(\n        &mut self,\n        index: usize,\n        delta: &ClaudeContentBlockDelta,\n        worktree_path: &str,\n        entry_index_provider: &EntryIndexProvider,\n        last_assistant_message: &mut Option<String>,\n    ) -> Option<json_patch::Patch> {\n        if let std::collections::hash_map::Entry::Vacant(e) = self.contents.entry(index) {\n            let new_state = StreamingContentState::from_delta(delta)?;\n            e.insert(new_state);\n        }\n\n        let entry_state = self.contents.get_mut(&index)?;\n        entry_state.apply_content_delta(delta);\n\n        let content_item = entry_state.to_content_item();\n        let entry = ClaudeLogProcessor::content_item_to_normalized_entry(\n            &content_item,\n            &self.role,\n            worktree_path,\n            last_assistant_message,\n        )?;\n\n        if let Some(existing_index) = entry_state.entry_index {\n            Some(ConversationPatch::replace(existing_index, entry))\n        } else {\n            let entry_index = entry_index_provider.next();\n            entry_state.entry_index = Some(entry_index);\n            Some(ConversationPatch::add_normalized_entry(entry_index, entry))\n        }\n    }\n\n    fn content_entry_index(&self, content_index: usize) -> Option<usize> {\n        self.contents\n            .get(&content_index)\n            .and_then(|s| s.entry_index)\n    }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\nenum StreamingContentKind {\n    Text,\n    Thinking,\n}\n\nstruct StreamingContentState {\n    kind: StreamingContentKind,\n    buffer: String,\n    entry_index: Option<usize>,\n}\n\nimpl StreamingContentState {\n    fn from_content_block(content_block: ClaudeContentItem) -> Option<Self> {\n        match content_block {\n            ClaudeContentItem::Text { text } => Some(Self {\n                kind: StreamingContentKind::Text,\n                buffer: text,\n                entry_index: None,\n            }),\n            ClaudeContentItem::Thinking { thinking } => Some(Self {\n                kind: StreamingContentKind::Thinking,\n                buffer: thinking,\n                entry_index: None,\n            }),\n            _ => None,\n        }\n    }\n\n    fn from_delta(delta: &ClaudeContentBlockDelta) -> Option<Self> {\n        match delta {\n            ClaudeContentBlockDelta::TextDelta { .. } => Some(Self {\n                kind: StreamingContentKind::Text,\n                buffer: String::new(),\n                entry_index: None,\n            }),\n            ClaudeContentBlockDelta::ThinkingDelta { .. } => Some(Self {\n                kind: StreamingContentKind::Thinking,\n                buffer: String::new(),\n                entry_index: None,\n            }),\n            _ => None,\n        }\n    }\n\n    fn apply_content_delta(&mut self, delta: &ClaudeContentBlockDelta) {\n        match (self.kind, delta) {\n            (StreamingContentKind::Text, ClaudeContentBlockDelta::TextDelta { text }) => {\n                self.buffer.push_str(text);\n            }\n            (\n                StreamingContentKind::Thinking,\n                ClaudeContentBlockDelta::ThinkingDelta { thinking },\n            ) => {\n                self.buffer.push_str(thinking);\n            }\n            // Signature deltas are sent at the end of thinking blocks for verification;\n            // they don't contain display content so we ignore them.\n            (StreamingContentKind::Thinking, ClaudeContentBlockDelta::SignatureDelta { .. }) => {}\n            _ => {\n                tracing::warn!(\n                    \"Mismatched content types: delta {:?}, kind {:?}\",\n                    delta,\n                    self.kind\n                );\n            }\n        }\n    }\n\n    fn to_content_item(&self) -> ClaudeContentItem {\n        match self.kind {\n            StreamingContentKind::Text => ClaudeContentItem::Text {\n                text: self.buffer.clone(),\n            },\n            StreamingContentKind::Thinking => ClaudeContentItem::Thinking {\n                thinking: self.buffer.clone(),\n            },\n        }\n    }\n}\n\n// Data structures for parsing Claude's JSON output format\n#[derive(Deserialize, Serialize, Debug, Clone)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum ClaudeJson {\n    System {\n        subtype: Option<String>,\n        session_id: Option<String>,\n        cwd: Option<String>,\n        tools: Option<Vec<serde_json::Value>>,\n        model: Option<String>,\n        #[serde(default, rename = \"apiKeySource\")]\n        api_key_source: Option<String>,\n        status: Option<String>,\n        #[serde(default)]\n        slash_commands: Vec<String>,\n        #[serde(default)]\n        plugins: Vec<ClaudePlugin>,\n        #[serde(default)]\n        agents: Vec<String>,\n        #[serde(default)]\n        task_id: Option<String>,\n        #[serde(default)]\n        tool_use_id: Option<String>,\n        #[serde(default)]\n        description: Option<String>,\n        #[serde(default)]\n        task_type: Option<String>,\n        #[serde(default)]\n        prompt: Option<String>,\n        #[serde(default)]\n        summary: Option<String>,\n        #[serde(default)]\n        last_tool_name: Option<String>,\n    },\n    Assistant {\n        message: ClaudeMessage,\n        session_id: Option<String>,\n        #[serde(default)]\n        uuid: Option<String>,\n    },\n    User {\n        message: ClaudeMessage,\n        session_id: Option<String>,\n        #[serde(default)]\n        uuid: Option<String>,\n        #[serde(default, rename = \"isSynthetic\")]\n        is_synthetic: bool,\n        #[serde(default, rename = \"isReplay\")]\n        is_replay: bool,\n    },\n    ToolUse {\n        id: String,\n        tool_name: String,\n        #[serde(flatten)]\n        tool_data: ClaudeToolData,\n        session_id: Option<String>,\n    },\n    ToolResult {\n        result: serde_json::Value,\n        is_error: Option<bool>,\n        session_id: Option<String>,\n    },\n    StreamEvent {\n        event: ClaudeStreamEvent,\n        #[serde(default)]\n        session_id: Option<String>,\n        #[serde(default)]\n        parent_tool_use_id: Option<String>,\n        #[serde(default)]\n        uuid: Option<String>,\n    },\n    Result {\n        #[serde(default)]\n        subtype: Option<String>,\n        #[serde(default, alias = \"isError\")]\n        is_error: Option<bool>,\n        #[serde(default, alias = \"durationMs\")]\n        duration_ms: Option<u64>,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n        #[serde(default)]\n        error: Option<String>,\n        #[serde(default, alias = \"numTurns\")]\n        num_turns: Option<u32>,\n        #[serde(default, alias = \"sessionId\")]\n        session_id: Option<String>,\n        #[serde(default, alias = \"modelUsage\")]\n        model_usage: Option<HashMap<String, ClaudeModelUsage>>,\n        #[serde(default)]\n        usage: Option<ClaudeUsage>,\n    },\n    ApprovalRequested {\n        tool_call_id: String,\n        tool_name: String,\n        approval_id: String,\n    },\n    ApprovalResponse {\n        tool_call_id: String,\n        tool_name: String,\n        approval_status: ApprovalStatus,\n    },\n    QuestionResponse {\n        tool_call_id: String,\n        tool_name: String,\n        question_status: QuestionStatus,\n    },\n    ControlRequest {\n        request_id: String,\n        request: ControlRequestType,\n    },\n    ControlResponse {\n        response: ControlResponseType,\n    },\n    ControlCancelRequest {\n        request_id: String,\n    },\n    RateLimitEvent {\n        #[serde(default)]\n        session_id: Option<String>,\n        #[serde(default)]\n        rate_limit_info: Option<serde_json::Value>,\n    },\n    // Catch-all for unknown message types\n    #[serde(untagged)]\n    Unknown {\n        #[serde(flatten)]\n        data: HashMap<String, serde_json::Value>,\n    },\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct ClaudePlugin {\n    pub name: String,\n    pub path: PathBuf,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct ClaudeMessage {\n    pub id: Option<String>,\n    #[serde(rename = \"type\")]\n    pub message_type: Option<String>,\n    pub role: String,\n    pub model: Option<String>,\n    pub content: ClaudeMessageContent,\n    pub stop_reason: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]\n#[serde(untagged)]\npub enum ClaudeMessageContent {\n    Array(Vec<ClaudeContentItem>),\n    Text(String),\n}\n\nimpl ClaudeMessageContent {\n    fn items(&self) -> impl Iterator<Item = &ClaudeContentItem> {\n        match self {\n            ClaudeMessageContent::Array(items) => items.iter(),\n            ClaudeMessageContent::Text(_) => [].iter(),\n        }\n    }\n\n    fn as_text(&self) -> Option<&String> {\n        match self {\n            ClaudeMessageContent::Text(s) => Some(s),\n            _ => None,\n        }\n    }\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(tag = \"type\")]\npub enum ClaudeContentItem {\n    #[serde(rename = \"text\")]\n    Text { text: String },\n    #[serde(rename = \"thinking\")]\n    Thinking { thinking: String },\n    #[serde(rename = \"tool_use\")]\n    ToolUse {\n        id: String,\n        #[serde(flatten)]\n        tool_data: ClaudeToolData,\n    },\n    #[serde(rename = \"tool_result\")]\n    ToolResult {\n        tool_use_id: String,\n        content: serde_json::Value,\n        is_error: Option<bool>,\n    },\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(tag = \"type\")]\npub enum ClaudeStreamEvent {\n    #[serde(rename = \"message_start\")]\n    MessageStart { message: ClaudeMessage },\n    #[serde(rename = \"content_block_start\")]\n    ContentBlockStart {\n        index: usize,\n        content_block: ClaudeContentItem,\n    },\n    #[serde(rename = \"content_block_delta\")]\n    ContentBlockDelta {\n        index: usize,\n        delta: ClaudeContentBlockDelta,\n    },\n    #[serde(rename = \"content_block_stop\")]\n    ContentBlockStop { index: usize },\n    #[serde(rename = \"message_delta\")]\n    MessageDelta {\n        #[serde(default)]\n        delta: Option<ClaudeMessageDelta>,\n        #[serde(default)]\n        usage: Option<ClaudeUsage>,\n    },\n    #[serde(rename = \"message_stop\")]\n    MessageStop,\n    #[serde(other)]\n    Unknown,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(tag = \"type\")]\npub enum ClaudeContentBlockDelta {\n    #[serde(rename = \"text_delta\")]\n    TextDelta { text: String },\n    #[serde(rename = \"thinking_delta\")]\n    ThinkingDelta { thinking: String },\n    #[serde(rename = \"signature_delta\")]\n    SignatureDelta {\n        #[serde(default)]\n        signature: String,\n    },\n    #[serde(other)]\n    Unknown,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]\npub struct ClaudeMessageDelta {\n    #[serde(default)]\n    pub stop_reason: Option<String>,\n    #[serde(default)]\n    pub stop_sequence: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]\npub struct ClaudeUsage {\n    #[serde(default)]\n    pub input_tokens: Option<u64>,\n    #[serde(default)]\n    pub output_tokens: Option<u64>,\n    #[serde(default, rename = \"cache_creation_input_tokens\")]\n    pub cache_creation_input_tokens: Option<u64>,\n    #[serde(default, rename = \"cache_read_input_tokens\")]\n    pub cache_read_input_tokens: Option<u64>,\n    #[serde(default)]\n    pub service_tier: Option<String>,\n}\n\n/// Per-model usage statistics from result message\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct ClaudeModelUsage {\n    #[serde(default)]\n    pub context_window: Option<u32>,\n}\n\n/// Structured tool data for Claude tools based on real samples\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(tag = \"name\", content = \"input\")]\npub enum ClaudeToolData {\n    #[serde(rename = \"TodoWrite\", alias = \"todo_write\")]\n    TodoWrite {\n        todos: Vec<ClaudeTodoItem>,\n    },\n    #[serde(rename = \"Task\", alias = \"task\", alias = \"Agent\")]\n    Task {\n        subagent_type: Option<String>,\n        description: Option<String>,\n        prompt: Option<String>,\n    },\n    #[serde(rename = \"Glob\", alias = \"glob\")]\n    Glob {\n        #[serde(alias = \"filePattern\")]\n        pattern: String,\n        #[serde(default)]\n        path: Option<String>,\n        #[serde(default)]\n        limit: Option<u32>,\n    },\n    #[serde(rename = \"LS\", alias = \"list_directory\", alias = \"ls\")]\n    LS {\n        path: String,\n    },\n    #[serde(rename = \"Read\", alias = \"read\")]\n    Read {\n        #[serde(alias = \"path\")]\n        file_path: String,\n    },\n    #[serde(rename = \"Bash\", alias = \"bash\")]\n    Bash {\n        #[serde(alias = \"cmd\", alias = \"command_line\")]\n        command: String,\n        #[serde(default)]\n        description: Option<String>,\n    },\n    #[serde(rename = \"Grep\", alias = \"grep\")]\n    Grep {\n        pattern: String,\n        #[serde(default)]\n        output_mode: Option<String>,\n        #[serde(default)]\n        path: Option<String>,\n    },\n    ExitPlanMode {\n        plan: String,\n    },\n    #[serde(rename = \"Edit\", alias = \"edit_file\")]\n    Edit {\n        #[serde(alias = \"path\")]\n        file_path: String,\n        #[serde(alias = \"old_str\")]\n        old_string: Option<String>,\n        #[serde(alias = \"new_str\")]\n        new_string: Option<String>,\n    },\n    #[serde(rename = \"MultiEdit\", alias = \"multi_edit\")]\n    MultiEdit {\n        #[serde(alias = \"path\")]\n        file_path: String,\n        edits: Vec<ClaudeEditItem>,\n    },\n    #[serde(rename = \"Write\", alias = \"create_file\", alias = \"write_file\")]\n    Write {\n        #[serde(alias = \"path\")]\n        file_path: String,\n        content: String,\n    },\n    #[serde(rename = \"NotebookEdit\", alias = \"notebook_edit\")]\n    NotebookEdit {\n        notebook_path: String,\n        new_source: String,\n        edit_mode: String,\n        #[serde(default)]\n        cell_id: Option<String>,\n    },\n    #[serde(rename = \"WebFetch\", alias = \"read_web_page\")]\n    WebFetch {\n        url: String,\n        #[serde(default)]\n        prompt: Option<String>,\n    },\n    #[serde(rename = \"WebSearch\", alias = \"web_search\")]\n    WebSearch {\n        query: String,\n        #[serde(default)]\n        num_results: Option<u32>,\n    },\n    // Amp-only utilities for better UX\n    #[serde(rename = \"Oracle\", alias = \"oracle\")]\n    Oracle {\n        #[serde(default)]\n        task: Option<String>,\n        #[serde(default)]\n        files: Option<Vec<String>>,\n        #[serde(default)]\n        context: Option<String>,\n    },\n    #[serde(rename = \"Mermaid\", alias = \"mermaid\")]\n    Mermaid {\n        code: String,\n    },\n    #[serde(rename = \"CodebaseSearchAgent\", alias = \"codebase_search_agent\")]\n    CodebaseSearchAgent {\n        #[serde(default)]\n        query: Option<String>,\n        #[serde(default)]\n        path: Option<String>,\n        #[serde(default)]\n        include: Option<Vec<String>>,\n        #[serde(default)]\n        exclude: Option<Vec<String>>,\n        #[serde(default)]\n        limit: Option<u32>,\n    },\n    #[serde(rename = \"UndoEdit\", alias = \"undo_edit\")]\n    UndoEdit {\n        #[serde(default, alias = \"file_path\")]\n        path: Option<String>,\n        #[serde(default)]\n        steps: Option<u32>,\n    },\n    #[serde(rename = \"TodoRead\", alias = \"todo_read\")]\n    TodoRead {},\n    AskUserQuestion {\n        questions: Vec<AskUserQuestionInputItem>,\n    },\n    #[serde(untagged)]\n    Unknown {\n        #[serde(flatten)]\n        data: std::collections::HashMap<String, serde_json::Value>,\n    },\n}\n\n// Helper structs for parsing tool_result content and generic tool input\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\nstruct ClaudeToolResultTextItem {\n    text: String,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\nstruct ClaudeToolWithInput {\n    #[serde(default)]\n    input: serde_json::Value,\n}\n\n// Amp's claude-compatible Bash tool_result content format\n// Example content (often delivered as a JSON string):\n//   {\"output\":\"...\",\"exitCode\":0}\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\nstruct AmpBashResult {\n    #[serde(default)]\n    output: String,\n    #[serde(rename = \"exitCode\")]\n    exit_code: i32,\n}\n\n#[derive(Debug, Clone)]\nstruct ClaudeToolCallInfo {\n    entry_index: usize,\n    tool_name: String,\n    tool_data: ClaudeToolData,\n    content: String,\n}\n\n/// A single question from AskUserQuestion tool input.\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct AskUserQuestionInputItem {\n    pub question: String,\n    pub header: String,\n    pub options: Vec<AskUserQuestionInputOption>,\n    #[serde(rename = \"multiSelect\")]\n    pub multi_select: bool,\n}\n\n/// An option for an AskUserQuestion question.\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct AskUserQuestionInputOption {\n    pub label: String,\n    pub description: String,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct ClaudeTodoItem {\n    #[serde(default)]\n    pub id: Option<String>,\n    pub content: String,\n    pub status: String,\n    #[serde(default)]\n    pub priority: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct ClaudeEditItem {\n    pub old_string: Option<String>,\n    pub new_string: Option<String>,\n}\n\nimpl ClaudeToolData {\n    pub fn get_name(&self) -> &str {\n        match self {\n            ClaudeToolData::TodoWrite { .. } => \"TodoWrite\",\n            ClaudeToolData::Task { .. } => \"Task\",\n            ClaudeToolData::Glob { .. } => \"Glob\",\n            ClaudeToolData::LS { .. } => \"LS\",\n            ClaudeToolData::Read { .. } => \"Read\",\n            ClaudeToolData::Bash { .. } => \"Bash\",\n            ClaudeToolData::Grep { .. } => \"Grep\",\n            ClaudeToolData::ExitPlanMode { .. } => \"ExitPlanMode\",\n            ClaudeToolData::Edit { .. } => \"Edit\",\n            ClaudeToolData::MultiEdit { .. } => \"MultiEdit\",\n            ClaudeToolData::Write { .. } => \"Write\",\n            ClaudeToolData::NotebookEdit { .. } => \"NotebookEdit\",\n            ClaudeToolData::WebFetch { .. } => \"WebFetch\",\n            ClaudeToolData::WebSearch { .. } => \"WebSearch\",\n            ClaudeToolData::TodoRead { .. } => \"TodoRead\",\n            ClaudeToolData::Oracle { .. } => \"Oracle\",\n            ClaudeToolData::Mermaid { .. } => \"Mermaid\",\n            ClaudeToolData::CodebaseSearchAgent { .. } => \"CodebaseSearchAgent\",\n            ClaudeToolData::UndoEdit { .. } => \"UndoEdit\",\n            ClaudeToolData::AskUserQuestion { .. } => \"AskUserQuestion\",\n            ClaudeToolData::Unknown { data } => data\n                .get(\"name\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"unknown\"),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::logs::utils::{EntryIndexProvider, patch::extract_normalized_entry_from_patch};\n\n    fn patches_to_entries(patches: &[json_patch::Patch]) -> Vec<NormalizedEntry> {\n        patches\n            .iter()\n            .filter_map(|patch| extract_normalized_entry_from_patch(patch).map(|(_, entry)| entry))\n            .collect()\n    }\n\n    fn normalize_helper(\n        processor: &mut ClaudeLogProcessor,\n        json: &ClaudeJson,\n        worktree: &str,\n    ) -> Vec<NormalizedEntry> {\n        let provider = EntryIndexProvider::test_new();\n        let patches = processor.normalize_entries(json, worktree, &provider);\n        patches_to_entries(&patches)\n    }\n\n    fn normalize(json: &ClaudeJson, worktree: &str) -> Vec<NormalizedEntry> {\n        let mut processor = ClaudeLogProcessor::new();\n        normalize_helper(&mut processor, json, worktree)\n    }\n\n    #[test]\n    fn test_claude_json_parsing() {\n        let system_json =\n            r#\"{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"abc123\",\"model\":\"claude-sonnet-4\"}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(system_json).unwrap();\n\n        // System messages no longer extract session_id\n        assert_eq!(ClaudeLogProcessor::extract_session_id(&parsed), None);\n\n        let entries = normalize(&parsed, \"\");\n        assert_eq!(entries.len(), 0);\n\n        let assistant_json = r#\"\n        {\"type\":\"assistant\",\"message\":{\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-20250514\",\"content\":[{\"type\":\"text\",\"text\":\"Hi! I'm Claude Code.\"}]}}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(assistant_json).unwrap();\n        let entries = normalize(&parsed, \"\");\n\n        assert_eq!(entries.len(), 2);\n        assert!(matches!(\n            entries[0].entry_type,\n            NormalizedEntryType::SystemMessage\n        ));\n        assert_eq!(\n            entries[0].content,\n            \"System initialized with model: claude-sonnet-4-20250514\"\n        );\n    }\n\n    #[test]\n    fn test_assistant_message_parsing() {\n        let assistant_json = r#\"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hello world\"}]},\"session_id\":\"abc123\"}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(assistant_json).unwrap();\n\n        let entries = normalize(&parsed, \"\");\n        assert_eq!(entries.len(), 1);\n        assert!(matches!(\n            entries[0].entry_type,\n            NormalizedEntryType::AssistantMessage\n        ));\n        assert_eq!(entries[0].content, \"Hello world\");\n    }\n\n    #[test]\n    fn test_result_message_emits_final_text_if_not_seen() {\n        let result_json = r#\"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"duration_ms\":6059,\"result\":\"Final result\"}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(result_json).unwrap();\n\n        let entries = normalize(&parsed, \"\");\n        assert_eq!(entries.len(), 1);\n        assert!(matches!(\n            entries[0].entry_type,\n            NormalizedEntryType::AssistantMessage\n        ));\n        assert_eq!(entries[0].content, \"Final result\");\n    }\n\n    #[test]\n    fn test_thinking_content() {\n        let thinking_json = r#\"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"Let me think about this...\"}]}}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(thinking_json).unwrap();\n\n        let entries = normalize(&parsed, \"\");\n        assert_eq!(entries.len(), 1);\n        assert!(matches!(\n            entries[0].entry_type,\n            NormalizedEntryType::Thinking\n        ));\n        assert_eq!(entries[0].content, \"Let me think about this...\");\n    }\n\n    #[test]\n    fn test_todo_tool_empty_list() {\n        // Test TodoWrite with empty todo list\n        let empty_data = ClaudeToolData::TodoWrite { todos: vec![] };\n\n        let action_type =\n            ClaudeLogProcessor::extract_action_type(&empty_data, \"/tmp/test-worktree\");\n        let result = ClaudeLogProcessor::generate_concise_content(\n            &empty_data,\n            &action_type,\n            \"/tmp/test-worktree\",\n        );\n\n        assert_eq!(result, \"TODO list updated\");\n    }\n\n    #[test]\n    fn test_glob_tool_content_extraction() {\n        // Test Glob with pattern and path\n        let glob_data = ClaudeToolData::Glob {\n            pattern: \"**/*.ts\".to_string(),\n            path: Some(\"/tmp/test-worktree/src\".to_string()),\n            limit: None,\n        };\n\n        let action_type = ClaudeLogProcessor::extract_action_type(&glob_data, \"/tmp/test-worktree\");\n        let result = ClaudeLogProcessor::generate_concise_content(\n            &glob_data,\n            &action_type,\n            \"/tmp/test-worktree\",\n        );\n\n        assert_eq!(result, \"**/*.ts\");\n    }\n\n    #[test]\n    fn test_glob_tool_pattern_only() {\n        // Test Glob with pattern only\n        let glob_data = ClaudeToolData::Glob {\n            pattern: \"*.js\".to_string(),\n            path: None,\n            limit: None,\n        };\n\n        let action_type = ClaudeLogProcessor::extract_action_type(&glob_data, \"/tmp/test-worktree\");\n        let result = ClaudeLogProcessor::generate_concise_content(\n            &glob_data,\n            &action_type,\n            \"/tmp/test-worktree\",\n        );\n\n        assert_eq!(result, \"*.js\");\n    }\n\n    #[test]\n    fn test_ls_tool_content_extraction() {\n        // Test LS with path\n        let ls_data = ClaudeToolData::LS {\n            path: \"/tmp/test-worktree/components\".to_string(),\n        };\n\n        let action_type = ClaudeLogProcessor::extract_action_type(&ls_data, \"/tmp/test-worktree\");\n        let result = ClaudeLogProcessor::generate_concise_content(\n            &ls_data,\n            &action_type,\n            \"/tmp/test-worktree\",\n        );\n\n        assert_eq!(result, \"List directory: components\");\n    }\n\n    #[test]\n    fn test_path_relative_conversion() {\n        // Test with relative path (should remain unchanged)\n        let relative_result = make_path_relative(\"src/main.rs\", \"/tmp/test-worktree\");\n        assert_eq!(relative_result, \"src/main.rs\");\n\n        // Test with absolute path (should become relative if possible)\n        let test_worktree = \"/tmp/test-worktree\";\n        let absolute_path = format!(\"{test_worktree}/src/main.rs\");\n        let absolute_result = make_path_relative(&absolute_path, test_worktree);\n        assert_eq!(absolute_result, \"src/main.rs\");\n    }\n\n    #[tokio::test]\n    async fn test_streaming_patch_generation() {\n        use std::sync::Arc;\n\n        use workspace_utils::msg_store::MsgStore;\n\n        let executor = ClaudeCode {\n            claude_code_router: Some(false),\n            plan: None,\n            approvals: None,\n            model: None,\n            effort: None,\n            agent: None,\n            append_prompt: AppendPrompt::default(),\n            dangerously_skip_permissions: None,\n            cmd: crate::command::CmdOverrides {\n                base_command_override: None,\n                additional_params: None,\n                env: None,\n            },\n            approvals_service: None,\n            disable_api_key: None,\n        };\n        let msg_store = Arc::new(MsgStore::new());\n        let current_dir = std::path::PathBuf::from(\"/tmp/test-worktree\");\n\n        // Push some test messages\n        msg_store.push_stdout(\n            r#\"{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"test123\"}\"#.to_string(),\n        );\n        msg_store.push_stdout(r#\"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}}\"#.to_string());\n        msg_store.push_finished();\n\n        // Start normalization (this spawns async task)\n        executor.normalize_logs(msg_store.clone(), &current_dir);\n\n        // Give some time for async processing\n        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;\n\n        // Check that the history now contains patch messages\n        let history = msg_store.get_history();\n        let patch_count = history\n            .iter()\n            .filter(|msg| matches!(msg, workspace_utils::log_msg::LogMsg::JsonPatch(_)))\n            .count();\n        assert!(\n            patch_count > 0,\n            \"Expected JsonPatch messages to be generated from streaming processing\"\n        );\n    }\n\n    #[test]\n    fn test_session_id_extraction() {\n        let system_json = r#\"{\"type\":\"system\",\"session_id\":\"test-session-123\"}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(system_json).unwrap();\n\n        // System messages no longer extract session_id\n        assert_eq!(ClaudeLogProcessor::extract_session_id(&parsed), None);\n\n        let tool_use_json = r#\"{\"id\":\"t1\",\"type\":\"tool_use\",\"tool_name\":\"read\",\"input\":{},\"session_id\":\"another-session\"}\"#;\n        let parsed_tool: ClaudeJson = serde_json::from_str(tool_use_json).unwrap();\n\n        assert_eq!(\n            ClaudeLogProcessor::extract_session_id(&parsed_tool),\n            Some(\"another-session\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_amp_tool_aliases_create_file_and_edit_file() {\n        // Amp \"create_file\" should deserialize into Write with alias field \"path\"\n        let assistant_with_create = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"create_file\",\"input\":{\"path\":\"/tmp/work/src/new.txt\",\"content\":\"hello\"}}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(assistant_with_create).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        match &entries[0].entry_type {\n            NormalizedEntryType::ToolUse { action_type, .. } => match action_type {\n                ActionType::FileEdit { path, .. } => assert_eq!(path, \"src/new.txt\"),\n                other => panic!(\"Expected FileEdit, got {other:?}\"),\n            },\n            other => panic!(\"Expected ToolUse, got {other:?}\"),\n        }\n\n        // Amp \"edit_file\" should deserialize into Edit with aliases for path/old_str/new_str\n        let assistant_with_edit = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"edit_file\",\"input\":{\"path\":\"/tmp/work/README.md\",\"old_str\":\"foo\",\"new_str\":\"bar\"}}\n                ]\n            }\n        }\"#;\n        let parsed_edit: ClaudeJson = serde_json::from_str(assistant_with_edit).unwrap();\n        let entries = normalize(&parsed_edit, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        match &entries[0].entry_type {\n            NormalizedEntryType::ToolUse { action_type, .. } => match action_type {\n                ActionType::FileEdit { path, .. } => assert_eq!(path, \"README.md\"),\n                other => panic!(\"Expected FileEdit, got {other:?}\"),\n            },\n            other => panic!(\"Expected ToolUse, got {other:?}\"),\n        }\n    }\n\n    #[test]\n    fn test_amp_tool_aliases_oracle_mermaid_codebase_undo() {\n        // Oracle with task\n        let oracle_json = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"oracle\",\"input\":{\"task\":\"Assess project status\"}}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(oracle_json).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].content, \"Oracle: `Assess project status`\");\n\n        // Mermaid with code\n        let mermaid_json = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"mermaid\",\"input\":{\"code\":\"graph TD; A-->B;\"}}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(mermaid_json).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].content, \"Mermaid diagram\");\n\n        // CodebaseSearchAgent with query\n        let csa_json = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t3\",\"name\":\"codebase_search_agent\",\"input\":{\"query\":\"TODO markers\"}}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(csa_json).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].content, \"Codebase search: `TODO markers`\");\n\n        // UndoEdit shows file path when available\n        let undo_json = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t4\",\"name\":\"undo_edit\",\"input\":{\"path\":\"README.md\"}}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(undo_json).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].content, \"Undo edit: `README.md`\");\n    }\n\n    #[test]\n    fn test_amp_bash_and_task_content() {\n        // Bash with alias field cmd\n        let bash_json = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"bash\",\"input\":{\"cmd\":\"echo hello\"}}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(bash_json).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        // Content should display the command\n        assert_eq!(entries[0].content, \"echo hello\");\n\n        // Task content should include description/prompt wrapped in backticks\n        let task_json = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"task\",\"input\":{\"subagent_type\":\"Task\",\"prompt\":\"Add header to README\"}}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(task_json).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].content, \"Task: `Add header to README`\");\n    }\n\n    #[test]\n    fn test_task_description_or_prompt_backticks() {\n        // When description present, use it\n        let with_desc = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t3\",\"name\":\"Task\",\"input\":{\n                        \"subagent_type\":\"Task\",\n                        \"prompt\":\"Fallback prompt\",\n                        \"description\":\"Primary description\"\n                    }}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(with_desc).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].content, \"Task: `Primary description`\");\n\n        // When description missing, fall back to prompt\n        let no_desc = r#\"{\n            \"type\":\"assistant\",\n            \"message\":{\n                \"role\":\"assistant\",\n                \"content\":[\n                    {\"type\":\"tool_use\",\"id\":\"t4\",\"name\":\"Task\",\"input\":{\n                        \"subagent_type\":\"Task\",\n                        \"prompt\":\"Only prompt\"\n                    }}\n                ]\n            }\n        }\"#;\n        let parsed: ClaudeJson = serde_json::from_str(no_desc).unwrap();\n        let entries = normalize(&parsed, \"/tmp/work\");\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].content, \"Task: `Only prompt`\");\n    }\n\n    #[test]\n    fn test_tool_result_parsing_ignored() {\n        let tool_result_json = r#\"{\"type\":\"tool_result\",\"result\":\"File content here\",\"is_error\":false,\"session_id\":\"test123\"}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(tool_result_json).unwrap();\n\n        // Test session ID extraction from ToolResult still works\n        assert_eq!(\n            ClaudeLogProcessor::extract_session_id(&parsed),\n            Some(\"test123\".to_string())\n        );\n\n        // ToolResult messages should be ignored (produce no entries) until proper support is added\n        let entries = normalize(&parsed, \"\");\n        assert_eq!(entries.len(), 0);\n    }\n\n    #[test]\n    fn test_content_item_tool_result_ignored() {\n        let assistant_with_tool_result = r#\"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"tool_123\",\"content\":\"Operation completed\",\"is_error\":false}]}}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(assistant_with_tool_result).unwrap();\n\n        // ToolResult content items should be ignored (produce no entries) until proper support is added\n        let entries = normalize(&parsed, \"\");\n        assert_eq!(entries.len(), 0);\n    }\n\n    #[test]\n    fn test_api_key_source_warning() {\n        // Test with ANTHROPIC_API_KEY - should generate warning\n        let system_with_env_key = r#\"{\"type\":\"system\",\"subtype\":\"init\",\"apiKeySource\":\"ANTHROPIC_API_KEY\",\"session_id\":\"test123\"}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(system_with_env_key).unwrap();\n        let entries = normalize(&parsed, \"\");\n\n        assert_eq!(entries.len(), 1);\n        assert!(matches!(\n            entries[0].entry_type,\n            NormalizedEntryType::ErrorMessage {\n                error_type: NormalizedEntryError::Other,\n            },\n        ));\n        assert_eq!(\n            entries[0].content,\n            \"Claude Code + ANTHROPIC_API_KEY detected. Usage will be billed via Anthropic pay-as-you-go instead of your Claude subscription. If this is unintended, please select the `disable_api_key` checkbox in the conding-agent-configurations settings page.\"\n        );\n\n        // Test with managed API key source - should not generate warning\n        let system_with_managed_key = r#\"{\"type\":\"system\",\"subtype\":\"init\",\"apiKeySource\":\"/login managed key\",\"session_id\":\"test123\"}\"#;\n        let parsed_managed: ClaudeJson = serde_json::from_str(system_with_managed_key).unwrap();\n        let entries_managed = normalize(&parsed_managed, \"\");\n\n        assert_eq!(entries_managed.len(), 0); // No warning for managed key\n\n        // Test with other apiKeySource values - should not generate warning\n        let system_other_key = r#\"{\"type\":\"system\",\"subtype\":\"init\",\"apiKeySource\":\"OTHER_KEY\",\"session_id\":\"test123\"}\"#;\n        let parsed_other: ClaudeJson = serde_json::from_str(system_other_key).unwrap();\n        let entries_other = normalize(&parsed_other, \"\");\n\n        assert_eq!(entries_other.len(), 0); // No warning for other keys\n\n        // Test with missing apiKeySource - should not generate warning\n        let system_no_key = r#\"{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"test123\"}\"#;\n        let parsed_no_key: ClaudeJson = serde_json::from_str(system_no_key).unwrap();\n        let entries_no_key = normalize(&parsed_no_key, \"\");\n\n        assert_eq!(entries_no_key.len(), 0); // No warning when field is missing\n    }\n\n    #[test]\n    fn test_mixed_content_with_thinking_ignores_tool_result() {\n        let complex_assistant_json = r#\"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"I need to read the file first\"},{\"type\":\"text\",\"text\":\"I'll help you with that\"},{\"type\":\"tool_result\",\"tool_use_id\":\"tool_789\",\"content\":\"Success\",\"is_error\":false}]}}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(complex_assistant_json).unwrap();\n\n        let entries = normalize(&parsed, \"\");\n        // Only thinking and text entries should be processed, tool_result ignored\n        assert_eq!(entries.len(), 2);\n\n        // Check thinking entry\n        assert!(matches!(\n            entries[0].entry_type,\n            NormalizedEntryType::Thinking\n        ));\n        assert_eq!(entries[0].content, \"I need to read the file first\");\n\n        // Check assistant message\n        assert!(matches!(\n            entries[1].entry_type,\n            NormalizedEntryType::AssistantMessage\n        ));\n        assert_eq!(entries[1].content, \"I'll help you with that\");\n\n        // ToolResult entry is ignored - no third entry\n    }\n\n    #[test]\n    fn test_control_request_with_permission_suggestions() {\n        let control_request_json = r#\"{\"type\":\"control_request\",\"request_id\":\"f559d907-b139-475b-addd-79c05591eb99\",\"request\":{\"subtype\":\"can_use_tool\",\"tool_name\":\"Bash\",\"input\":{\"command\":\"./gradlew :web:testApi\",\"timeout\":300000,\"description\":\"Run API tests\"},\"permission_suggestions\":[{\"type\":\"addRules\",\"rules\":[{\"toolName\":\"Bash\",\"ruleContent\":\"./gradlew :web:testApi:\"}],\"behavior\":\"allow\",\"destination\":\"localSettings\"}],\"tool_use_id\":\"toolu_014PR3WXsJfiftSCbjcjEbeM\"}}\"#;\n        let parsed: ClaudeJson = serde_json::from_str(control_request_json).unwrap();\n        assert!(matches!(parsed, ClaudeJson::ControlRequest { .. }));\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/codex/client.rs",
    "content": "use std::{\n    collections::{HashMap, VecDeque},\n    io,\n    sync::{\n        Arc, OnceLock,\n        atomic::{AtomicBool, Ordering},\n    },\n};\n\nuse async_trait::async_trait;\nuse codex_app_server_protocol::{\n    ClientInfo, ClientNotification, ClientRequest, CommandExecutionApprovalDecision,\n    CommandExecutionRequestApprovalResponse, ConfigBatchWriteParams, ConfigEdit, ConfigReadParams,\n    ConfigReadResponse, ConfigWriteResponse, FileChangeApprovalDecision,\n    FileChangeRequestApprovalResponse, GetAccountParams, GetAccountRateLimitsResponse,\n    GetAccountResponse, InitializeCapabilities, InitializeParams, InitializeResponse,\n    ItemCompletedNotification, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse,\n    ListMcpServerStatusParams, ListMcpServerStatusResponse, RequestId, ReviewStartParams,\n    ReviewStartResponse, ReviewTarget, ServerRequest, ThreadCompactStartParams,\n    ThreadCompactStartResponse, ThreadForkParams, ThreadForkResponse, ThreadItem, ThreadReadParams,\n    ThreadReadResponse, ThreadStartParams, ThreadStartResponse, ToolRequestUserInputAnswer,\n    ToolRequestUserInputQuestion, ToolRequestUserInputResponse, TurnCompletedNotification,\n    TurnStartParams, TurnStartResponse, TurnStatus, UserInput,\n};\nuse codex_protocol::config_types::{CollaborationMode, ModeKind, Settings};\nuse futures::TryFutureExt;\nuse serde::{Serialize, de::DeserializeOwned};\nuse serde_json::{self, Value};\nuse tokio::{\n    io::{AsyncWrite, AsyncWriteExt, BufWriter},\n    sync::Mutex,\n};\nuse tokio_util::sync::CancellationToken;\nuse workspace_utils::approvals::{ApprovalStatus, QuestionStatus};\n\nuse super::jsonrpc::{JsonRpcCallbacks, JsonRpcPeer};\nuse crate::{\n    approvals::{ExecutorApprovalError, ExecutorApprovalService},\n    env::RepoContext,\n    executors::{ExecutorError, codex::normalize_logs::Approval},\n};\n\nstruct PendingPlan {\n    item_id: String,\n}\n\npub struct AppServerClient {\n    rpc: OnceLock<JsonRpcPeer>,\n    log_writer: LogWriter,\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    thread_id: Mutex<Option<String>>,\n    pending_feedback: Mutex<VecDeque<String>>,\n    auto_approve: bool,\n    plan_mode: bool,\n    resolved_model: OnceLock<String>,\n    pending_plan: Mutex<Option<PendingPlan>>,\n    repo_context: RepoContext,\n    commit_reminder: bool,\n    commit_reminder_prompt: String,\n    commit_reminder_sent: AtomicBool,\n    cancel: CancellationToken,\n}\n\nimpl AppServerClient {\n    #[allow(clippy::too_many_arguments)]\n    pub fn new(\n        log_writer: LogWriter,\n        approvals: Option<Arc<dyn ExecutorApprovalService>>,\n        auto_approve: bool,\n        plan_mode: bool,\n        repo_context: RepoContext,\n        commit_reminder: bool,\n        commit_reminder_prompt: String,\n        cancel: CancellationToken,\n    ) -> Arc<Self> {\n        Arc::new(Self {\n            rpc: OnceLock::new(),\n            log_writer,\n            approvals,\n            auto_approve,\n            plan_mode,\n            resolved_model: OnceLock::new(),\n            pending_plan: Mutex::new(None),\n            thread_id: Mutex::new(None),\n            pending_feedback: Mutex::new(VecDeque::new()),\n            repo_context,\n            commit_reminder,\n            commit_reminder_prompt,\n            commit_reminder_sent: AtomicBool::new(false),\n            cancel,\n        })\n    }\n\n    pub fn connect(&self, peer: JsonRpcPeer) {\n        let _ = self.rpc.set(peer);\n    }\n\n    pub fn set_resolved_model(&self, model: String) {\n        let _ = self.resolved_model.set(model);\n    }\n\n    fn rpc(&self) -> &JsonRpcPeer {\n        self.rpc.get().expect(\"Codex RPC peer not attached\")\n    }\n\n    pub fn log_writer(&self) -> &LogWriter {\n        &self.log_writer\n    }\n\n    pub async fn initialize(&self) -> Result<(), ExecutorError> {\n        let request = ClientRequest::Initialize {\n            request_id: self.next_request_id(),\n            params: InitializeParams {\n                client_info: ClientInfo {\n                    name: \"vibe-codex-executor\".to_string(),\n                    title: None,\n                    version: env!(\"CARGO_PKG_VERSION\").to_string(),\n                },\n                capabilities: Some(InitializeCapabilities {\n                    experimental_api: true,\n                    ..Default::default()\n                }),\n            },\n        };\n\n        self.send_request::<InitializeResponse>(request, \"initialize\")\n            .await?;\n        self.send_message(&ClientNotification::Initialized).await\n    }\n\n    pub async fn thread_start(\n        &self,\n        params: ThreadStartParams,\n    ) -> Result<ThreadStartResponse, ExecutorError> {\n        let request = ClientRequest::ThreadStart {\n            request_id: self.next_request_id(),\n            params,\n        };\n        self.send_request(request, \"thread/start\").await\n    }\n\n    pub async fn thread_fork(\n        &self,\n        params: ThreadForkParams,\n    ) -> Result<ThreadForkResponse, ExecutorError> {\n        let request = ClientRequest::ThreadFork {\n            request_id: self.next_request_id(),\n            params,\n        };\n        self.send_request(request, \"thread/fork\").await\n    }\n\n    pub async fn turn_start(\n        &self,\n        thread_id: String,\n        input: Vec<UserInput>,\n    ) -> Result<TurnStartResponse, ExecutorError> {\n        self.turn_start_with_mode(thread_id, input, None).await\n    }\n\n    pub async fn turn_start_with_mode(\n        &self,\n        thread_id: String,\n        input: Vec<UserInput>,\n        collaboration_mode: Option<CollaborationMode>,\n    ) -> Result<TurnStartResponse, ExecutorError> {\n        let request = ClientRequest::TurnStart {\n            request_id: self.next_request_id(),\n            params: TurnStartParams {\n                thread_id,\n                input,\n                collaboration_mode,\n                ..Default::default()\n            },\n        };\n        self.send_request(request, \"turn/start\").await\n    }\n\n    fn collaboration_mode(&self, mode: ModeKind) -> Result<CollaborationMode, ExecutorError> {\n        let model = self.resolved_model.get().cloned().ok_or_else(|| {\n            tracing::error!(\"collaboration_mode called before resolved_model was set\");\n            ExecutorError::Io(io::Error::other(\n                \"resolved model not available for collaboration mode\",\n            ))\n        })?;\n        Ok(CollaborationMode {\n            mode,\n            settings: Settings {\n                model,\n                reasoning_effort: None,\n                developer_instructions: None,\n            },\n        })\n    }\n\n    pub fn initial_collaboration_mode(&self) -> Result<CollaborationMode, ExecutorError> {\n        if self.plan_mode {\n            self.collaboration_mode(ModeKind::Plan)\n        } else {\n            self.collaboration_mode(ModeKind::Default)\n        }\n    }\n\n    pub async fn get_account(&self) -> Result<GetAccountResponse, ExecutorError> {\n        let request = ClientRequest::GetAccount {\n            request_id: self.next_request_id(),\n            params: GetAccountParams {\n                refresh_token: false,\n            },\n        };\n        self.send_request(request, \"account/read\").await\n    }\n\n    pub async fn start_review(\n        &self,\n        thread_id: String,\n        target: ReviewTarget,\n    ) -> Result<ReviewStartResponse, ExecutorError> {\n        let request = ClientRequest::ReviewStart {\n            request_id: self.next_request_id(),\n            params: ReviewStartParams {\n                thread_id,\n                target,\n                delivery: None,\n            },\n        };\n        self.send_request(request, \"reviewStart\").await\n    }\n\n    pub async fn list_mcp_server_status(\n        &self,\n        cursor: Option<String>,\n    ) -> Result<ListMcpServerStatusResponse, ExecutorError> {\n        let request = ClientRequest::McpServerStatusList {\n            request_id: self.next_request_id(),\n            params: ListMcpServerStatusParams {\n                cursor,\n                limit: None,\n            },\n        };\n        self.send_request(request, \"mcpServerStatus/list\").await\n    }\n\n    pub async fn thread_compact_start(\n        &self,\n        thread_id: String,\n    ) -> Result<ThreadCompactStartResponse, ExecutorError> {\n        let request = ClientRequest::ThreadCompactStart {\n            request_id: self.next_request_id(),\n            params: ThreadCompactStartParams { thread_id },\n        };\n        self.send_request(request, \"thread/compact/start\").await\n    }\n\n    pub async fn thread_read(\n        &self,\n        thread_id: String,\n    ) -> Result<ThreadReadResponse, ExecutorError> {\n        let request = ClientRequest::ThreadRead {\n            request_id: self.next_request_id(),\n            params: ThreadReadParams {\n                thread_id,\n                include_turns: false,\n            },\n        };\n        self.send_request(request, \"thread/read\").await\n    }\n\n    pub async fn config_batch_write(\n        &self,\n        edits: Vec<ConfigEdit>,\n    ) -> Result<ConfigWriteResponse, ExecutorError> {\n        let request = ClientRequest::ConfigBatchWrite {\n            request_id: self.next_request_id(),\n            params: ConfigBatchWriteParams {\n                edits,\n                file_path: None,\n                expected_version: None,\n                reload_user_config: false,\n            },\n        };\n        self.send_request(request, \"config/batchWrite\").await\n    }\n\n    pub async fn config_read(\n        &self,\n        cwd: Option<String>,\n    ) -> Result<ConfigReadResponse, ExecutorError> {\n        let request = ClientRequest::ConfigRead {\n            request_id: self.next_request_id(),\n            params: ConfigReadParams {\n                include_layers: false,\n                cwd,\n            },\n        };\n        self.send_request(request, \"config/read\").await\n    }\n\n    pub async fn get_account_rate_limits(\n        &self,\n    ) -> Result<GetAccountRateLimitsResponse, ExecutorError> {\n        let request = ClientRequest::GetAccountRateLimits {\n            request_id: self.next_request_id(),\n            params: None,\n        };\n        self.send_request(request, \"account/rateLimits/read\").await\n    }\n\n    async fn handle_server_request(\n        &self,\n        peer: &JsonRpcPeer,\n        request: ServerRequest,\n    ) -> Result<(), ExecutorError> {\n        match request {\n            ServerRequest::FileChangeRequestApproval { request_id, params } => {\n                let call_id = params.item_id.clone();\n                let status = self\n                    .request_tool_approval(\"edit\", \"codex.apply_patch\", &call_id)\n                    .await\n                    .inspect_err(|err| {\n                        if !matches!(\n                            err,\n                            ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled)\n                        ) {\n                            tracing::error!(\n                                \"Codex file_change approval failed for item_id={}: {err}\",\n                                call_id\n                            );\n                        }\n                    })?;\n                self.log_writer\n                    .log_raw(\n                        &Approval::approval_response(\n                            call_id,\n                            \"codex.apply_patch\".to_string(),\n                            status.clone(),\n                        )\n                        .raw(),\n                    )\n                    .await?;\n                let (decision, feedback) = self.file_change_decision(&status);\n                let response = FileChangeRequestApprovalResponse { decision };\n                send_server_response(peer, request_id, response).await?;\n                if let Some(message) = feedback {\n                    tracing::debug!(\"queueing file change denial feedback: {message}\");\n                    self.enqueue_feedback(message).await;\n                }\n                Ok(())\n            }\n            ServerRequest::CommandExecutionRequestApproval { request_id, params } => {\n                let call_id = params.item_id.clone();\n                let status = self\n                    .request_tool_approval(\"bash\", \"codex.exec_command\", &call_id)\n                    .await\n                    .inspect_err(|err| {\n                        if !matches!(\n                            err,\n                            ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled)\n                        ) {\n                            tracing::error!(\n                                \"Codex command_execution approval failed for item_id={}: {err}\",\n                                call_id\n                            );\n                        }\n                    })?;\n                self.log_writer\n                    .log_raw(\n                        &Approval::approval_response(\n                            call_id,\n                            \"codex.exec_command\".to_string(),\n                            status.clone(),\n                        )\n                        .raw(),\n                    )\n                    .await?;\n                let (decision, feedback) = self.command_execution_decision(&status);\n                let response = CommandExecutionRequestApprovalResponse { decision };\n                send_server_response(peer, request_id, response).await?;\n                if let Some(message) = feedback {\n                    tracing::debug!(\"queueing exec denial feedback: {message}\");\n                    self.enqueue_feedback(message).await;\n                }\n                Ok(())\n            }\n            ServerRequest::ToolRequestUserInput { request_id, params } => {\n                let call_id = params.item_id.clone();\n                let question_count = params.questions.len();\n                let status = self\n                    .request_question_answer(question_count, &call_id)\n                    .await\n                    .inspect_err(|err| {\n                        if !matches!(\n                            err,\n                            ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled)\n                        ) {\n                            tracing::error!(\n                                \"Codex question approval failed for call_id={}: {err}\",\n                                call_id\n                            );\n                        }\n                    })?;\n                self.log_writer\n                    .log_raw(&Approval::question_response(call_id.clone(), status.clone()).raw())\n                    .await?;\n                let response = match &status {\n                    QuestionStatus::Answered { answers } => {\n                        let answers_map: HashMap<String, Vec<String>> = answers\n                            .iter()\n                            .map(|qa| (qa.question.clone(), qa.answer.clone()))\n                            .collect();\n                        answers_to_codex_format(&params.questions, &answers_map)\n                    }\n                    _ => ToolRequestUserInputResponse {\n                        answers: HashMap::new(),\n                    },\n                };\n                send_server_response(peer, request_id, response).await?;\n                Ok(())\n            }\n            ServerRequest::DynamicToolCall { .. }\n            | ServerRequest::ChatgptAuthTokensRefresh { .. }\n            | ServerRequest::McpServerElicitationRequest { .. }\n            | ServerRequest::PermissionsRequestApproval { .. } => {\n                tracing::warn!(\"received unhandled v2 server request: {:?}\", request);\n                let response = JSONRPCResponse {\n                    id: request.id().clone(),\n                    result: Value::Null,\n                };\n                peer.send(&response).await\n            }\n            ServerRequest::ApplyPatchApproval { .. }\n            | ServerRequest::ExecCommandApproval { .. } => {\n                tracing::error!(\n                    \"received deprecated v1 server request (session may have been started with legacy API): {:?}\",\n                    request\n                );\n                Err(ExecutorApprovalError::RequestFailed(\n                    \"deprecated v1 server request\".to_string(),\n                )\n                .into())\n            }\n        }\n    }\n\n    async fn request_tool_approval(\n        &self,\n        tool_name: &str,\n        display_tool_name: &str,\n        tool_call_id: &str,\n    ) -> Result<ApprovalStatus, ExecutorError> {\n        if self.auto_approve {\n            return Ok(ApprovalStatus::Approved);\n        }\n        let approval_service = self\n            .approvals\n            .as_ref()\n            .ok_or(ExecutorApprovalError::ServiceUnavailable)?;\n\n        let approval_id = approval_service\n            .create_tool_approval(tool_name)\n            .or_else(|err| async {\n                self.handle_approval_error(display_tool_name, tool_call_id)\n                    .await;\n                Err(err)\n            })\n            .await?;\n\n        let _ = self\n            .log_writer\n            .log_raw(\n                &Approval::approval_requested(\n                    tool_call_id.to_string(),\n                    display_tool_name.to_string(),\n                    approval_id.clone(),\n                )\n                .raw(),\n            )\n            .await;\n\n        approval_service\n            .wait_tool_approval(&approval_id, self.cancel.clone())\n            .or_else(|err| async {\n                self.handle_approval_error(display_tool_name, tool_call_id)\n                    .await;\n                Err(err)\n            })\n            .await\n            .map_err(ExecutorError::from)\n    }\n\n    async fn handle_approval_error(&self, display_tool_name: &str, tool_call_id: &str) {\n        let _ = self\n            .log_writer\n            .log_raw(\n                &Approval::approval_response(\n                    tool_call_id.to_string(),\n                    display_tool_name.to_string(),\n                    ApprovalStatus::TimedOut,\n                )\n                .raw(),\n            )\n            .await;\n    }\n\n    async fn request_question_answer(\n        &self,\n        question_count: usize,\n        tool_call_id: &str,\n    ) -> Result<QuestionStatus, ExecutorError> {\n        let approval_service = self\n            .approvals\n            .as_ref()\n            .ok_or(ExecutorApprovalError::ServiceUnavailable)?;\n\n        let approval_id = approval_service\n            .create_question_approval(\"question\", question_count)\n            .or_else(|err| async {\n                self.handle_question_error(tool_call_id).await;\n                Err(err)\n            })\n            .await?;\n\n        let _ = self\n            .log_writer\n            .log_raw(\n                &Approval::approval_requested(\n                    tool_call_id.to_string(),\n                    \"codex.question\".to_string(),\n                    approval_id.clone(),\n                )\n                .raw(),\n            )\n            .await;\n\n        approval_service\n            .wait_question_answer(&approval_id, self.cancel.clone())\n            .or_else(|err| async {\n                self.handle_question_error(tool_call_id).await;\n                Err(err)\n            })\n            .await\n            .map_err(ExecutorError::from)\n    }\n\n    async fn handle_question_error(&self, tool_call_id: &str) {\n        let _ = self\n            .log_writer\n            .log_raw(\n                &Approval::question_response(tool_call_id.to_string(), QuestionStatus::TimedOut)\n                    .raw(),\n            )\n            .await;\n    }\n\n    async fn handle_plan_completed(&self, plan: PendingPlan) -> Result<bool, ExecutorError> {\n        let approval_service = self\n            .approvals\n            .as_ref()\n            .ok_or(ExecutorApprovalError::ServiceUnavailable)?;\n\n        let approval_id = approval_service\n            .create_tool_approval(\"plan\")\n            .or_else(|err| async {\n                self.handle_approval_error(\"codex.plan\", &plan.item_id)\n                    .await;\n                Err(err)\n            })\n            .await?;\n\n        let _ = self\n            .log_writer\n            .log_raw(\n                &Approval::approval_requested(\n                    plan.item_id.clone(),\n                    \"codex.plan\".to_string(),\n                    approval_id.clone(),\n                )\n                .raw(),\n            )\n            .await;\n\n        let status = approval_service\n            .wait_tool_approval(&approval_id, self.cancel.clone())\n            .or_else(|err| async {\n                self.handle_approval_error(\"codex.plan\", &plan.item_id)\n                    .await;\n                Err(err)\n            })\n            .await\n            .map_err(ExecutorError::from)?;\n\n        self.log_writer\n            .log_raw(\n                &Approval::approval_response(\n                    plan.item_id,\n                    \"codex.plan\".to_string(),\n                    status.clone(),\n                )\n                .raw(),\n            )\n            .await?;\n\n        let Some(thread_id) = self.thread_id.lock().await.clone() else {\n            return Ok(true);\n        };\n\n        match status {\n            ApprovalStatus::Approved => {\n                self.spawn_turn_start(\n                    thread_id,\n                    \"Implement the plan.\".to_string(),\n                    Some(self.collaboration_mode(ModeKind::Default)?),\n                );\n                Ok(false)\n            }\n            ApprovalStatus::Denied { reason } => {\n                let feedback = reason\n                    .as_ref()\n                    .map(|s| s.trim())\n                    .filter(|s| !s.is_empty())\n                    .map(|s| s.to_string());\n                if let Some(feedback_text) = feedback {\n                    self.spawn_turn_start(\n                        thread_id,\n                        format!(\"User feedback on the plan: {feedback_text}\"),\n                        Some(self.collaboration_mode(ModeKind::Plan)?),\n                    );\n                    Ok(false)\n                } else {\n                    Ok(true)\n                }\n            }\n            ApprovalStatus::TimedOut | ApprovalStatus::Pending => Ok(true),\n        }\n    }\n\n    pub async fn register_session(&self, thread_id: &str) -> Result<(), ExecutorError> {\n        {\n            let mut guard = self.thread_id.lock().await;\n            guard.replace(thread_id.to_string());\n        }\n        self.flush_pending_feedback().await;\n        Ok(())\n    }\n\n    async fn send_message<M>(&self, message: &M) -> Result<(), ExecutorError>\n    where\n        M: Serialize + Sync,\n    {\n        self.rpc().send(message).await\n    }\n\n    async fn send_request<R>(&self, request: ClientRequest, label: &str) -> Result<R, ExecutorError>\n    where\n        R: DeserializeOwned + std::fmt::Debug,\n    {\n        let request_id = request_id(&request);\n        self.rpc()\n            .request(request_id, &request, label, self.cancel.clone())\n            .await\n    }\n\n    fn next_request_id(&self) -> RequestId {\n        self.rpc().next_request_id()\n    }\n\n    fn command_execution_decision(\n        &self,\n        status: &ApprovalStatus,\n    ) -> (CommandExecutionApprovalDecision, Option<String>) {\n        if self.auto_approve {\n            return (CommandExecutionApprovalDecision::AcceptForSession, None);\n        }\n\n        match status {\n            ApprovalStatus::Approved => (CommandExecutionApprovalDecision::Accept, None),\n            ApprovalStatus::Denied { reason } => {\n                let feedback = reason\n                    .as_ref()\n                    .map(|s| s.trim())\n                    .filter(|s| !s.is_empty())\n                    .map(|s| s.to_string());\n                if feedback.is_some() {\n                    (CommandExecutionApprovalDecision::Cancel, feedback)\n                } else {\n                    (CommandExecutionApprovalDecision::Decline, None)\n                }\n            }\n            ApprovalStatus::TimedOut => (CommandExecutionApprovalDecision::Decline, None),\n            ApprovalStatus::Pending => (CommandExecutionApprovalDecision::Decline, None),\n        }\n    }\n\n    fn file_change_decision(\n        &self,\n        status: &ApprovalStatus,\n    ) -> (FileChangeApprovalDecision, Option<String>) {\n        if self.auto_approve {\n            return (FileChangeApprovalDecision::AcceptForSession, None);\n        }\n\n        match status {\n            ApprovalStatus::Approved => (FileChangeApprovalDecision::Accept, None),\n            ApprovalStatus::Denied { reason } => {\n                let feedback = reason\n                    .as_ref()\n                    .map(|s| s.trim())\n                    .filter(|s| !s.is_empty())\n                    .map(|s| s.to_string());\n                if feedback.is_some() {\n                    (FileChangeApprovalDecision::Cancel, feedback)\n                } else {\n                    (FileChangeApprovalDecision::Decline, None)\n                }\n            }\n            ApprovalStatus::TimedOut => (FileChangeApprovalDecision::Decline, None),\n            ApprovalStatus::Pending => (FileChangeApprovalDecision::Decline, None),\n        }\n    }\n\n    async fn enqueue_feedback(&self, message: String) {\n        if message.trim().is_empty() {\n            return;\n        }\n        let mut guard = self.pending_feedback.lock().await;\n        guard.push_back(message);\n    }\n\n    /// Sends pending feedback messages as new turns.\n    /// Returns `true` if any messages were sent.\n    async fn flush_pending_feedback(&self) -> bool {\n        let messages: Vec<String> = {\n            let mut guard = self.pending_feedback.lock().await;\n            guard.drain(..).collect()\n        };\n\n        if messages.is_empty() {\n            return false;\n        }\n\n        let Some(thread_id) = self.thread_id.lock().await.clone() else {\n            tracing::warn!(\n                \"pending Codex feedback but thread id unavailable; dropping {} messages\",\n                messages.len()\n            );\n            return false;\n        };\n\n        let mut sent = false;\n        for message in messages {\n            let trimmed = message.trim();\n            if trimmed.is_empty() {\n                continue;\n            }\n            self.spawn_user_message(thread_id.clone(), format!(\"User feedback: {trimmed}\"));\n            sent = true;\n        }\n        sent\n    }\n\n    fn spawn_turn_start(\n        &self,\n        thread_id: String,\n        message: String,\n        collaboration_mode: Option<CollaborationMode>,\n    ) {\n        let peer = self.rpc().clone();\n        let cancel = self.cancel.clone();\n        let request = ClientRequest::TurnStart {\n            request_id: peer.next_request_id(),\n            params: TurnStartParams {\n                thread_id,\n                input: vec![UserInput::Text {\n                    text: message,\n                    text_elements: vec![],\n                }],\n                collaboration_mode,\n                ..Default::default()\n            },\n        };\n        tokio::spawn(async move {\n            if let Err(err) = peer\n                .request::<TurnStartResponse, _>(\n                    request_id(&request),\n                    &request,\n                    \"turn/start\",\n                    cancel,\n                )\n                .await\n            {\n                tracing::error!(\"failed to send user message: {err}\");\n            }\n        });\n    }\n\n    fn spawn_user_message(&self, thread_id: String, message: String) {\n        self.spawn_turn_start(thread_id, message, None);\n    }\n}\n\n#[async_trait]\nimpl JsonRpcCallbacks for AppServerClient {\n    async fn on_request(\n        &self,\n        peer: &JsonRpcPeer,\n        raw: &str,\n        request: JSONRPCRequest,\n    ) -> Result<(), ExecutorError> {\n        self.log_writer.log_raw(raw).await?;\n        match ServerRequest::try_from(request.clone()) {\n            Ok(server_request) => self.handle_server_request(peer, server_request).await,\n            Err(err) => {\n                tracing::debug!(\"Unhandled server request `{}`: {err}\", request.method);\n                let response = JSONRPCResponse {\n                    id: request.id,\n                    result: Value::Null,\n                };\n                peer.send(&response).await\n            }\n        }\n    }\n\n    async fn on_response(\n        &self,\n        _peer: &JsonRpcPeer,\n        raw: &str,\n        _response: &JSONRPCResponse,\n    ) -> Result<(), ExecutorError> {\n        self.log_writer.log_raw(raw).await\n    }\n\n    async fn on_error(\n        &self,\n        _peer: &JsonRpcPeer,\n        raw: &str,\n        _error: &JSONRPCError,\n    ) -> Result<(), ExecutorError> {\n        self.log_writer.log_raw(raw).await\n    }\n\n    async fn on_notification(\n        &self,\n        _peer: &JsonRpcPeer,\n        raw: &str,\n        notification: JSONRPCNotification,\n    ) -> Result<bool, ExecutorError> {\n        self.log_writer.log_raw(raw).await?;\n\n        let method = notification.method.as_str();\n\n        // Detect completed plan items in the notification stream\n        if self.plan_mode\n            && method == \"item/completed\"\n            && let Some(ref params) = notification.params\n            && let Ok(completed) =\n                serde_json::from_value::<ItemCompletedNotification>(params.clone())\n            && let ThreadItem::Plan { id, .. } = completed.item\n        {\n            *self.pending_plan.lock().await = Some(PendingPlan { item_id: id });\n        }\n\n        // V2 turn completion detection\n        if method == \"turn/completed\" {\n            let mut keep_alive = false;\n\n            if let Some(params) = notification.params\n                && let Ok(completed) = serde_json::from_value::<TurnCompletedNotification>(params)\n                && completed.turn.status == TurnStatus::Interrupted\n            {\n                tracing::debug!(\"codex turn interrupted; flushing feedback queue\");\n                if self.flush_pending_feedback().await {\n                    keep_alive = true;\n                }\n            }\n\n            // Handle plan approval on turn completion\n            let pending = if self.plan_mode {\n                self.pending_plan.lock().await.take()\n            } else {\n                None\n            };\n            if let Some(plan) = pending {\n                return self.handle_plan_completed(plan).await;\n            }\n\n            // Handle commit reminder on turn completion\n            if !keep_alive\n                && self.commit_reminder\n                && !self.commit_reminder_sent.swap(true, Ordering::SeqCst)\n                && let status = self.repo_context.check_uncommitted_changes().await\n                && !status.is_empty()\n                && let Some(thread_id) = self.thread_id.lock().await.clone()\n            {\n                let prompt = format!(\"{}\\n{}\", self.commit_reminder_prompt, status);\n                self.spawn_user_message(thread_id, prompt);\n                return Ok(false);\n            }\n\n            return Ok(!keep_alive);\n        }\n\n        Ok(false)\n    }\n\n    async fn on_non_json(&self, raw: &str) -> Result<(), ExecutorError> {\n        self.log_writer.log_raw(raw).await?;\n        Ok(())\n    }\n}\n\nasync fn send_server_response<T>(\n    peer: &JsonRpcPeer,\n    request_id: RequestId,\n    response: T,\n) -> Result<(), ExecutorError>\nwhere\n    T: Serialize,\n{\n    let payload = JSONRPCResponse {\n        id: request_id,\n        result: serde_json::to_value(response)\n            .map_err(|err| ExecutorError::Io(io::Error::other(err.to_string())))?,\n    };\n\n    peer.send(&payload).await\n}\n\n/// Convert our `HashMap<question_text, Vec<answer_labels>>` answer format to\n/// Codex's `HashMap<question_id, ToolRequestUserInputAnswer>` format.\nfn answers_to_codex_format(\n    questions: &[ToolRequestUserInputQuestion],\n    answers: &HashMap<String, Vec<String>>,\n) -> ToolRequestUserInputResponse {\n    let codex_answers = questions\n        .iter()\n        .filter_map(|q| {\n            answers.get(&q.question).map(|answer_vec| {\n                (\n                    q.id.clone(),\n                    ToolRequestUserInputAnswer {\n                        answers: answer_vec.clone(),\n                    },\n                )\n            })\n        })\n        .collect();\n\n    ToolRequestUserInputResponse {\n        answers: codex_answers,\n    }\n}\n\nfn request_id(request: &ClientRequest) -> RequestId {\n    match request {\n        ClientRequest::Initialize { request_id, .. }\n        | ClientRequest::ThreadStart { request_id, .. }\n        | ClientRequest::ThreadFork { request_id, .. }\n        | ClientRequest::TurnStart { request_id, .. }\n        | ClientRequest::GetAccount { request_id, .. }\n        | ClientRequest::ReviewStart { request_id, .. }\n        | ClientRequest::McpServerStatusList { request_id, .. }\n        | ClientRequest::ThreadCompactStart { request_id, .. }\n        | ClientRequest::ThreadRead { request_id, .. }\n        | ClientRequest::ConfigRead { request_id, .. }\n        | ClientRequest::ConfigBatchWrite { request_id, .. }\n        | ClientRequest::GetAccountRateLimits { request_id, .. } => request_id.clone(),\n        _ => unreachable!(\"request_id called for unsupported request variant\"),\n    }\n}\n\n#[derive(Clone)]\npub struct LogWriter {\n    writer: Arc<Mutex<BufWriter<Box<dyn AsyncWrite + Send + Unpin>>>>,\n}\n\nimpl LogWriter {\n    pub fn new(writer: impl AsyncWrite + Send + Unpin + 'static) -> Self {\n        Self {\n            writer: Arc::new(Mutex::new(BufWriter::new(Box::new(writer)))),\n        }\n    }\n\n    pub async fn log_raw(&self, raw: &str) -> Result<(), ExecutorError> {\n        let mut guard = self.writer.lock().await;\n        guard\n            .write_all(raw.as_bytes())\n            .await\n            .map_err(ExecutorError::Io)?;\n        guard.write_all(b\"\\n\").await.map_err(ExecutorError::Io)?;\n        guard.flush().await.map_err(ExecutorError::Io)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/codex/init_prompt.md",
    "content": "Generate a file named AGENTS.md that serves as a contributor guide for this repository.\nYour goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section.\nFollow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project.\n\nDocument Requirements\n\n- Title the document \"Repository Guidelines\".\n- Use Markdown headings (#, ##, etc.) for structure.\n- Keep the document concise. 200-400 words is optimal.\n- Keep explanations short, direct, and specific to this repository.\n- Provide examples where helpful (commands, directory paths, naming patterns).\n- Maintain a professional, instructional tone.\n\nRecommended Sections\n\nProject Structure & Module Organization\n\n- Outline the project structure, including where the source code, tests, and assets are located.\n\nBuild, Test, and Development Commands\n\n- List key commands for building, testing, and running locally (e.g., npm test, make build).\n- Briefly explain what each command does.\n\nCoding Style & Naming Conventions\n\n- Specify indentation rules, language-specific style preferences, and naming patterns.\n- Include any formatting or linting tools used.\n\nTesting Guidelines\n\n- Identify testing frameworks and coverage requirements.\n- State test naming conventions and how to run tests.\n\nCommit & Pull Request Guidelines\n\n- Summarize commit message conventions found in the project’s Git history.\n- Outline pull request requirements (descriptions, linked issues, screenshots, etc.).\n\n(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions.\n"
  },
  {
    "path": "crates/executors/src/executors/codex/jsonrpc.rs",
    "content": "//! Minimal JSON-RPC helper tailored for the Codex executor.\n//!\n//! We keep this bespoke layer because the codex-app-server client must handle server-initiated\n//! requests as well as client-initiated requests. When a bidirectional client that\n//! supports this pattern is available, this module should be straightforward to\n//! replace.\n\nuse std::{\n    collections::HashMap,\n    fmt::Debug,\n    io,\n    sync::{\n        Arc,\n        atomic::{AtomicI64, Ordering},\n    },\n};\n\nuse async_trait::async_trait;\nuse codex_app_server_protocol::{\n    JSONRPCError, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, RequestId,\n};\nuse serde::{Serialize, de::DeserializeOwned};\nuse serde_json::Value;\nuse tokio::{\n    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},\n    process::{ChildStdin, ChildStdout},\n    sync::{Mutex, oneshot},\n};\nuse tokio_util::sync::CancellationToken;\n\nuse crate::executors::{ExecutorError, ExecutorExitResult};\n\n#[derive(Debug)]\npub enum PendingResponse {\n    Result(Value),\n    Error(JSONRPCError),\n    Shutdown,\n}\n\n#[derive(Clone)]\npub struct ExitSignalSender {\n    inner: Arc<Mutex<Option<oneshot::Sender<ExecutorExitResult>>>>,\n}\n\nimpl ExitSignalSender {\n    pub fn new(sender: oneshot::Sender<ExecutorExitResult>) -> Self {\n        Self {\n            inner: Arc::new(Mutex::new(Some(sender))),\n        }\n    }\n\n    pub async fn send_exit_signal(&self, result: ExecutorExitResult) {\n        if let Some(sender) = self.inner.lock().await.take() {\n            let _ = sender.send(result);\n        }\n    }\n}\n\n#[derive(Clone)]\npub struct JsonRpcPeer {\n    stdin: Arc<Mutex<ChildStdin>>,\n    pending: Arc<Mutex<HashMap<RequestId, oneshot::Sender<PendingResponse>>>>,\n    id_counter: Arc<AtomicI64>,\n}\n\nimpl JsonRpcPeer {\n    pub fn spawn(\n        stdin: ChildStdin,\n        stdout: ChildStdout,\n        callbacks: Arc<dyn JsonRpcCallbacks>,\n        exit_tx: ExitSignalSender,\n        cancel: CancellationToken,\n    ) -> Self {\n        let peer = Self {\n            stdin: Arc::new(Mutex::new(stdin)),\n            pending: Arc::new(Mutex::new(HashMap::new())),\n            id_counter: Arc::new(AtomicI64::new(1)),\n        };\n\n        let reader_peer = peer.clone();\n        let callbacks = callbacks.clone();\n\n        tokio::spawn(async move {\n            let mut reader = BufReader::new(stdout);\n            let mut buffer = String::new();\n\n            loop {\n                buffer.clear();\n                tokio::select! {\n                    _ = cancel.cancelled() => {\n                        tracing::debug!(\"Codex executor cancelled\");\n                        break;\n                    }\n                    read_result = reader.read_line(&mut buffer) => {\n                        match read_result {\n                            Ok(0) => break,\n                            Ok(_) => {\n                                let line = buffer.trim_end_matches(['\\n', '\\r']);\n                                if line.is_empty() {\n                                    continue;\n                                }\n\n                                match serde_json::from_str::<JSONRPCMessage>(line) {\n                                    Ok(JSONRPCMessage::Response(response)) => {\n                                        let request_id = response.id.clone();\n                                        let result = response.result.clone();\n                                        if callbacks\n                                            .on_response(&reader_peer, line, &response)\n                                            .await\n                                            .is_err()\n                                        {\n                                            break;\n                                        }\n                                        reader_peer\n                                            .resolve(request_id, PendingResponse::Result(result))\n                                            .await;\n                                    }\n                                    Ok(JSONRPCMessage::Error(error)) => {\n                                        let request_id = error.id.clone();\n                                        if callbacks\n                                            .on_error(&reader_peer, line, &error)\n                                            .await\n                                            .is_err()\n                                        {\n                                            break;\n                                        }\n                                        reader_peer\n                                            .resolve(request_id, PendingResponse::Error(error))\n                                            .await;\n                                    }\n                                    Ok(JSONRPCMessage::Request(request)) => {\n                                        if callbacks\n                                            .on_request(&reader_peer, line, request)\n                                            .await\n                                            .is_err()\n                                        {\n                                            break;\n                                        }\n                                    }\n                                    Ok(JSONRPCMessage::Notification(notification)) => {\n                                        match callbacks\n                                            .on_notification(&reader_peer, line, notification)\n                                            .await\n                                        {\n                                            // finished\n                                            Ok(true) => break,\n                                            Ok(false) => {}\n                                            Err(_) => {\n                                                break;\n                                            }\n                                        }\n                                    }\n                                    Err(_) => {\n                                        if callbacks.on_non_json(line).await.is_err() {\n                                            break;\n                                        }\n                                    }\n                                }\n                            }\n                            Err(err) => {\n                                tracing::warn!(\"Error reading Codex output: {err}\");\n                                break;\n                            }\n                        }\n                    }\n                }\n            }\n\n            exit_tx.send_exit_signal(ExecutorExitResult::Success).await;\n            let _ = reader_peer.shutdown().await;\n        });\n\n        peer\n    }\n\n    pub fn next_request_id(&self) -> RequestId {\n        RequestId::Integer(self.id_counter.fetch_add(1, Ordering::Relaxed))\n    }\n\n    pub async fn register(&self, request_id: RequestId) -> PendingReceiver {\n        let (sender, receiver) = oneshot::channel();\n        self.pending.lock().await.insert(request_id, sender);\n        receiver\n    }\n\n    pub async fn resolve(&self, request_id: RequestId, response: PendingResponse) {\n        if let Some(sender) = self.pending.lock().await.remove(&request_id) {\n            let _ = sender.send(response);\n        }\n    }\n\n    pub async fn shutdown(&self) -> Result<(), ExecutorError> {\n        let mut pending = self.pending.lock().await;\n        for (_, sender) in pending.drain() {\n            let _ = sender.send(PendingResponse::Shutdown);\n        }\n        Ok(())\n    }\n\n    pub async fn send<T>(&self, message: &T) -> Result<(), ExecutorError>\n    where\n        T: Serialize + Sync,\n    {\n        let raw = serde_json::to_string(message)\n            .map_err(|err| ExecutorError::Io(io::Error::other(err.to_string())))?;\n        self.send_raw(&raw).await\n    }\n\n    pub async fn request<R, T>(\n        &self,\n        request_id: RequestId,\n        message: &T,\n        label: &str,\n        cancel: CancellationToken,\n    ) -> Result<R, ExecutorError>\n    where\n        R: DeserializeOwned + Debug,\n        T: Serialize + Sync,\n    {\n        let receiver = self.register(request_id).await;\n        self.send(message).await?;\n        await_response(receiver, label, cancel).await\n    }\n\n    async fn send_raw(&self, payload: &str) -> Result<(), ExecutorError> {\n        let mut guard = self.stdin.lock().await;\n        guard\n            .write_all(payload.as_bytes())\n            .await\n            .map_err(ExecutorError::Io)?;\n        guard.write_all(b\"\\n\").await.map_err(ExecutorError::Io)?;\n        guard.flush().await.map_err(ExecutorError::Io)?;\n        Ok(())\n    }\n}\n\npub type PendingReceiver = oneshot::Receiver<PendingResponse>;\n\npub async fn await_response<R>(\n    receiver: PendingReceiver,\n    label: &str,\n    cancel: CancellationToken,\n) -> Result<R, ExecutorError>\nwhere\n    R: DeserializeOwned + Debug,\n{\n    let response = tokio::select! {\n        _ = cancel.cancelled() => {\n            return Err(ExecutorError::Io(io::Error::other(format!(\n                \"{label} request cancelled\",\n            ))));\n        }\n        result = receiver => result,\n    };\n\n    match response {\n        Ok(PendingResponse::Result(value)) => serde_json::from_value(value).map_err(|err| {\n            ExecutorError::Io(io::Error::other(format!(\n                \"failed to decode {label} response: {err}\",\n            )))\n        }),\n        Ok(PendingResponse::Error(error)) => Err(ExecutorError::Io(io::Error::other(format!(\n            \"{label} request failed: {}\",\n            error.error.message\n        )))),\n        Ok(PendingResponse::Shutdown) => Err(ExecutorError::Io(io::Error::other(format!(\n            \"server was shutdown while waiting for {label} response\",\n        )))),\n        Err(_) => Err(ExecutorError::Io(io::Error::other(format!(\n            \"{label} request was dropped\",\n        )))),\n    }\n}\n\n#[async_trait]\npub trait JsonRpcCallbacks: Send + Sync {\n    async fn on_request(\n        &self,\n        peer: &JsonRpcPeer,\n        raw: &str,\n        request: JSONRPCRequest,\n    ) -> Result<(), ExecutorError>;\n\n    async fn on_response(\n        &self,\n        peer: &JsonRpcPeer,\n        raw: &str,\n        response: &JSONRPCResponse,\n    ) -> Result<(), ExecutorError>;\n\n    async fn on_error(\n        &self,\n        peer: &JsonRpcPeer,\n        raw: &str,\n        error: &JSONRPCError,\n    ) -> Result<(), ExecutorError>;\n\n    async fn on_notification(\n        &self,\n        peer: &JsonRpcPeer,\n        raw: &str,\n        notification: JSONRPCNotification,\n    ) -> Result<bool, ExecutorError>;\n\n    async fn on_non_json(&self, _raw: &str) -> Result<(), ExecutorError>;\n}\n"
  },
  {
    "path": "crates/executors/src/executors/codex/normalize_logs.rs",
    "content": "use std::{\n    collections::HashMap,\n    path::{Path, PathBuf},\n    sync::{Arc, LazyLock},\n    time::Duration,\n};\n\nuse codex_app_server_protocol::{\n    JSONRPCNotification, JSONRPCResponse, ServerNotification, ThreadForkResponse,\n    ThreadStartResponse,\n};\nuse codex_protocol::{\n    items::TurnItem,\n    openai_models::ReasoningEffort,\n    plan_tool::{StepStatus, UpdatePlanArgs},\n    protocol::{\n        AgentMessageDeltaEvent, AgentMessageEvent, AgentReasoningDeltaEvent, AgentReasoningEvent,\n        AgentReasoningSectionBreakEvent, ApplyPatchApprovalRequestEvent, BackgroundEventEvent,\n        ErrorEvent, EventMsg, ExecApprovalRequestEvent, ExecCommandBeginEvent, ExecCommandEndEvent,\n        ExecCommandOutputDeltaEvent, ExecOutputStream, ExitedReviewModeEvent,\n        FileChange as CodexProtoFileChange, ItemCompletedEvent, ItemStartedEvent, McpInvocation,\n        McpToolCallBeginEvent, McpToolCallEndEvent, ModelRerouteEvent, PatchApplyBeginEvent,\n        PatchApplyEndEvent, PlanDeltaEvent, RequestUserInputEvent, StreamErrorEvent,\n        ViewImageToolCallEvent, WarningEvent, WebSearchBeginEvent, WebSearchEndEvent,\n    },\n};\nuse futures::StreamExt;\nuse regex::Regex;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse workspace_utils::{\n    approvals::{ApprovalStatus, QuestionStatus},\n    diff::normalize_unified_diff,\n    msg_store::MsgStore,\n    path::make_path_relative,\n};\n\nuse crate::{\n    approvals::ToolCallMetadata,\n    logs::{\n        ActionType, AnsweredQuestion, AskUserQuestionItem, AskUserQuestionOption,\n        CommandExitStatus, CommandRunResult, FileChange, NormalizedEntry, NormalizedEntryError,\n        NormalizedEntryType, TodoItem, ToolResult, ToolResultValueType, ToolStatus,\n        plain_text_processor::PlainTextLogProcessor,\n        utils::{\n            ConversationPatch, EntryIndexProvider,\n            patch::{add_normalized_entry, replace_normalized_entry, upsert_normalized_entry},\n            shell_command_parsing::{CommandCategory, unwrap_shell_command},\n        },\n    },\n};\n\ntrait ToNormalizedEntry {\n    fn to_normalized_entry(&self) -> NormalizedEntry;\n}\n\ntrait ToNormalizedEntryOpt {\n    fn to_normalized_entry_opt(&self) -> Option<NormalizedEntry>;\n}\n\n#[derive(Debug, Deserialize)]\nstruct CodexNotificationParams {\n    #[serde(rename = \"msg\")]\n    msg: EventMsg,\n}\n\n#[derive(Default)]\nstruct StreamingText {\n    index: usize,\n    content: String,\n}\n\n#[derive(Default)]\nstruct CommandState {\n    index: Option<usize>,\n    command: String,\n    stdout: String,\n    stderr: String,\n    formatted_output: Option<String>,\n    status: ToolStatus,\n    exit_code: Option<i32>,\n    awaiting_approval: bool,\n    call_id: String,\n}\n\nimpl ToNormalizedEntry for CommandState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        let content = self.command.to_string();\n\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"bash\".to_string(),\n                action_type: ActionType::CommandRun {\n                    command: unwrap_shell_command(&self.command).to_string(),\n                    result: Some(CommandRunResult {\n                        exit_status: self\n                            .exit_code\n                            .map(|code| CommandExitStatus::ExitCode { code }),\n                        output: if self.formatted_output.is_some() {\n                            self.formatted_output.clone()\n                        } else {\n                            build_command_output(Some(&self.stdout), Some(&self.stderr))\n                        },\n                    }),\n                    category: CommandCategory::from_command(&self.command),\n                },\n                status: self.status.clone(),\n            },\n            content,\n            metadata: serde_json::to_value(ToolCallMetadata {\n                tool_call_id: self.call_id.clone(),\n            })\n            .ok(),\n        }\n    }\n}\n\nstruct McpToolState {\n    index: Option<usize>,\n    invocation: McpInvocation,\n    result: Option<ToolResult>,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for McpToolState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        let tool_name = format!(\"mcp:{}:{}\", self.invocation.server, self.invocation.tool);\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: tool_name.clone(),\n                action_type: ActionType::Tool {\n                    tool_name,\n                    arguments: self.invocation.arguments.clone(),\n                    result: self.result.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: self.invocation.tool.clone(),\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Default)]\nstruct WebSearchState {\n    index: Option<usize>,\n    query: Option<String>,\n    status: ToolStatus,\n}\n\nimpl WebSearchState {\n    fn new() -> Self {\n        Default::default()\n    }\n}\n\nimpl ToNormalizedEntry for WebSearchState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"web_search\".to_string(),\n                action_type: ActionType::WebFetch {\n                    url: self.query.clone().unwrap_or_else(|| \"...\".to_string()),\n                },\n                status: self.status.clone(),\n            },\n            content: self\n                .query\n                .clone()\n                .unwrap_or_else(|| \"Web search\".to_string()),\n            metadata: None,\n        }\n    }\n}\n\nstruct UserInputRequestState {\n    index: Option<usize>,\n    questions: Vec<AskUserQuestionItem>,\n    content: String,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for UserInputRequestState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"question\".to_string(),\n                action_type: ActionType::AskUserQuestion {\n                    questions: self.questions.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: self.content.clone(),\n            metadata: None,\n        }\n    }\n}\n\nstruct PlanState {\n    index: Option<usize>,\n    text: String,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for PlanState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"plan\".to_string(),\n                action_type: ActionType::PlanPresentation {\n                    plan: self.text.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: \"Plan\".to_string(),\n            metadata: None,\n        }\n    }\n}\n\nstruct ReviewState {\n    index: Option<usize>,\n    description: String,\n    status: ToolStatus,\n    result: Option<ToolResult>,\n}\n\nimpl ReviewState {\n    fn complete(&mut self, review_event: &ExitedReviewModeEvent, worktree_path: &str) {\n        let result_text = match &review_event.review_output {\n            Some(output) => {\n                let mut sections = Vec::new();\n                sections.push(format!(\n                    \"**Correctness:** {} | **Confidence:** {}\",\n                    output.overall_correctness, output.overall_confidence_score,\n                ));\n                let explanation = output.overall_explanation.trim();\n                if !explanation.is_empty() {\n                    sections.push(explanation.to_string());\n                }\n                if !output.findings.is_empty() {\n                    let mut lines = vec![\"### Findings\".to_string()];\n                    for finding in &output.findings {\n                        let abs_path = finding.code_location.absolute_file_path.to_string_lossy();\n                        let path = make_path_relative(&abs_path, worktree_path);\n                        let start = finding.code_location.line_range.start;\n                        let end = finding.code_location.line_range.end;\n                        lines.push(format!(\n                            \"- **P{}** | **Confidence:** {} | {}\",\n                            finding.priority, finding.confidence_score, finding.title,\n                        ));\n                        lines.push(format!(\"  `{path}:{start}-{end}`\"));\n                        for body_line in finding.body.lines() {\n                            lines.push(format!(\"  {body_line}\"));\n                        }\n                    }\n                    sections.push(lines.join(\"\\n\"));\n                }\n                if sections.is_empty() {\n                    \"Review completed\".to_string()\n                } else {\n                    sections.join(\"\\n\\n\")\n                }\n            }\n            None => \"Review completed\".to_string(),\n        };\n        self.status = ToolStatus::Success;\n        self.result = Some(ToolResult::markdown(result_text));\n    }\n}\n\nimpl ToNormalizedEntry for ReviewState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"Review\".to_string(),\n                action_type: ActionType::TaskCreate {\n                    description: self.description.clone(),\n                    subagent_type: Some(\"review\".to_string()),\n                    result: self.result.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: String::new(),\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Default)]\nstruct PatchState {\n    entries: Vec<PatchEntry>,\n}\n\nstruct PatchEntry {\n    index: Option<usize>,\n    path: String,\n    changes: Vec<FileChange>,\n    status: ToolStatus,\n    awaiting_approval: bool,\n    call_id: String,\n}\n\nimpl ToNormalizedEntry for PatchEntry {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        let content = self.path.clone();\n\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"edit\".to_string(),\n                action_type: ActionType::FileEdit {\n                    path: self.path.clone(),\n                    changes: self.changes.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content,\n            metadata: serde_json::to_value(ToolCallMetadata {\n                tool_call_id: self.call_id.clone(),\n            })\n            .ok(),\n        }\n    }\n}\n\nstruct LogState {\n    entry_index: EntryIndexProvider,\n    assistant: Option<StreamingText>,\n    thinking: Option<StreamingText>,\n    commands: HashMap<String, CommandState>,\n    mcp_tools: HashMap<String, McpToolState>,\n    patches: HashMap<String, PatchState>,\n    web_searches: HashMap<String, WebSearchState>,\n    user_input_requests: HashMap<String, UserInputRequestState>,\n    plans: HashMap<String, PlanState>,\n    review: Option<ReviewState>,\n    model_params: ModelParamsState,\n}\n\nstruct ModelParamsState {\n    index: Option<usize>,\n    model: Option<String>,\n    reasoning_effort: Option<ReasoningEffort>,\n}\n\nenum StreamingTextKind {\n    Assistant,\n    Thinking,\n}\n\nimpl LogState {\n    fn new(entry_index: EntryIndexProvider) -> Self {\n        Self {\n            entry_index,\n            assistant: None,\n            thinking: None,\n            commands: HashMap::new(),\n            mcp_tools: HashMap::new(),\n            patches: HashMap::new(),\n            web_searches: HashMap::new(),\n            user_input_requests: HashMap::new(),\n            plans: HashMap::new(),\n            review: None,\n            model_params: ModelParamsState {\n                index: None,\n                model: None,\n                reasoning_effort: None,\n            },\n        }\n    }\n\n    fn streaming_text_update(\n        &mut self,\n        content: String,\n        type_: StreamingTextKind,\n        mode: UpdateMode,\n    ) -> (NormalizedEntry, usize, bool) {\n        let index_provider = &self.entry_index;\n        let entry = match type_ {\n            StreamingTextKind::Assistant => &mut self.assistant,\n            StreamingTextKind::Thinking => &mut self.thinking,\n        };\n        let is_new = entry.is_none();\n        let (content, index) = if entry.is_none() {\n            let index = index_provider.next();\n            *entry = Some(StreamingText { index, content });\n            (&entry.as_ref().unwrap().content, index)\n        } else {\n            let streaming_state = entry.as_mut().unwrap();\n            match mode {\n                UpdateMode::Append => streaming_state.content.push_str(&content),\n                UpdateMode::Set => streaming_state.content = content,\n            }\n            (&streaming_state.content, streaming_state.index)\n        };\n        let normalized_entry = NormalizedEntry {\n            timestamp: None,\n            entry_type: match type_ {\n                StreamingTextKind::Assistant => NormalizedEntryType::AssistantMessage,\n                StreamingTextKind::Thinking => NormalizedEntryType::Thinking,\n            },\n            content: content.clone(),\n            metadata: None,\n        };\n        (normalized_entry, index, is_new)\n    }\n\n    fn streaming_text_append(\n        &mut self,\n        content: String,\n        type_: StreamingTextKind,\n    ) -> (NormalizedEntry, usize, bool) {\n        self.streaming_text_update(content, type_, UpdateMode::Append)\n    }\n\n    fn streaming_text_set(\n        &mut self,\n        content: String,\n        type_: StreamingTextKind,\n    ) -> (NormalizedEntry, usize, bool) {\n        self.streaming_text_update(content, type_, UpdateMode::Set)\n    }\n\n    fn assistant_message_append(&mut self, content: String) -> (NormalizedEntry, usize, bool) {\n        self.streaming_text_append(content, StreamingTextKind::Assistant)\n    }\n\n    fn thinking_append(&mut self, content: String) -> (NormalizedEntry, usize, bool) {\n        self.streaming_text_append(content, StreamingTextKind::Thinking)\n    }\n\n    fn assistant_message(&mut self, content: String) -> (NormalizedEntry, usize, bool) {\n        self.streaming_text_set(content, StreamingTextKind::Assistant)\n    }\n\n    fn thinking(&mut self, content: String) -> (NormalizedEntry, usize, bool) {\n        self.streaming_text_set(content, StreamingTextKind::Thinking)\n    }\n\n    fn update_tool_status(\n        &mut self,\n        call_id: &str,\n        status: ToolStatus,\n        clear_awaiting: bool,\n        msg_store: &Arc<MsgStore>,\n    ) {\n        if let Some(cmd) = self.commands.get_mut(call_id) {\n            cmd.status = status.clone();\n            if clear_awaiting {\n                cmd.awaiting_approval = false;\n            }\n            if let Some(index) = cmd.index {\n                replace_normalized_entry(msg_store, index, cmd.to_normalized_entry());\n            }\n        } else if let Some(mcp) = self.mcp_tools.get_mut(call_id) {\n            mcp.status = status.clone();\n            if let Some(index) = mcp.index {\n                replace_normalized_entry(msg_store, index, mcp.to_normalized_entry());\n            }\n        } else if let Some(patch_state) = self.patches.get_mut(call_id) {\n            for entry in &mut patch_state.entries {\n                entry.status = status.clone();\n                if clear_awaiting {\n                    entry.awaiting_approval = false;\n                }\n                if let Some(index) = entry.index {\n                    replace_normalized_entry(msg_store, index, entry.to_normalized_entry());\n                }\n            }\n        } else if let Some(input_state) = self.user_input_requests.get_mut(call_id) {\n            input_state.status = status;\n            if let Some(index) = input_state.index {\n                replace_normalized_entry(msg_store, index, input_state.to_normalized_entry());\n            }\n        } else if let Some(plan_state) = self.plans.get_mut(call_id) {\n            plan_state.status = status;\n            if let Some(index) = plan_state.index {\n                replace_normalized_entry(msg_store, index, plan_state.to_normalized_entry());\n            }\n        }\n    }\n}\n\nenum UpdateMode {\n    Append,\n    Set,\n}\n\nfn normalize_file_changes(\n    worktree_path: &str,\n    changes: &HashMap<PathBuf, CodexProtoFileChange>,\n) -> Vec<(String, Vec<FileChange>)> {\n    changes\n        .iter()\n        .map(|(path, change)| {\n            let path_str = path.to_string_lossy();\n            let relative = make_path_relative(path_str.as_ref(), worktree_path);\n            let file_changes = match change {\n                CodexProtoFileChange::Add { content } => vec![FileChange::Write {\n                    content: content.clone(),\n                }],\n                CodexProtoFileChange::Delete { .. } => vec![FileChange::Delete],\n                CodexProtoFileChange::Update {\n                    unified_diff,\n                    move_path,\n                } => {\n                    let mut edits = Vec::new();\n                    if let Some(dest) = move_path {\n                        let dest_rel =\n                            make_path_relative(dest.to_string_lossy().as_ref(), worktree_path);\n                        edits.push(FileChange::Rename { new_path: dest_rel });\n                    }\n                    let diff = normalize_unified_diff(&relative, unified_diff);\n                    edits.push(FileChange::Edit {\n                        unified_diff: diff,\n                        has_line_numbers: true,\n                    });\n                    edits\n                }\n            };\n            (relative, file_changes)\n        })\n        .collect()\n}\n\nfn format_todo_status(status: &StepStatus) -> String {\n    match status {\n        StepStatus::Pending => \"pending\",\n        StepStatus::InProgress => \"in_progress\",\n        StepStatus::Completed => \"completed\",\n    }\n    .to_string()\n}\n\n/// Stderr patterns from codex internals that should be suppressed from user-visible logs.\nconst SUPPRESSED_STDERR_PATTERNS: &[&str] = &[\n    // Codex unconditionally logs this error during its SQLite migration when a rollout file\n    // exists on disk but isn't indexed in the state DB — even when the Sqlite feature flag is\n    // disabled (which is the default). See: https://github.com/openai/codex/commit/c38a5958\n    \"state db missing rollout path for\",\n];\n\n/// Codex-specific stderr normalizer that filters noisy internal messages.\nfn normalize_codex_stderr_logs(\n    msg_store: Arc<MsgStore>,\n    entry_index_provider: EntryIndexProvider,\n) -> tokio::task::JoinHandle<()> {\n    tokio::spawn(async move {\n        let mut stderr = msg_store.stderr_chunked_stream();\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(|content: String| NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::Other,\n                },\n                content: strip_ansi_escapes::strip_str(&content),\n                metadata: None,\n            })\n            .time_gap(Duration::from_secs(2))\n            .index_provider(entry_index_provider)\n            .transform_lines(Box::new(|lines: &mut Vec<String>| {\n                lines.retain(|line| {\n                    !SUPPRESSED_STDERR_PATTERNS\n                        .iter()\n                        .any(|pattern| line.contains(pattern))\n                });\n            }))\n            .build();\n\n        while let Some(Ok(chunk)) = stderr.next().await {\n            for patch in processor.process(chunk) {\n                msg_store.push_patch(patch);\n            }\n        }\n    })\n}\n\npub fn normalize_logs(\n    msg_store: Arc<MsgStore>,\n    worktree_path: &Path,\n) -> Vec<tokio::task::JoinHandle<()>> {\n    let entry_index = EntryIndexProvider::start_from(&msg_store);\n    let h1 = normalize_codex_stderr_logs(msg_store.clone(), entry_index.clone());\n\n    let worktree_path_str = worktree_path.to_string_lossy().to_string();\n    let h2 = tokio::spawn(async move {\n        let mut state = LogState::new(entry_index.clone());\n        let mut stdout_lines = msg_store.stdout_lines_stream();\n\n        while let Some(Ok(line)) = stdout_lines.next().await {\n            if let Ok(error) = serde_json::from_str::<Error>(&line) {\n                add_normalized_entry(&msg_store, &entry_index, error.to_normalized_entry());\n                continue;\n            }\n\n            if let Ok(approval) = serde_json::from_str::<Approval>(&line) {\n                match &approval {\n                    Approval::ApprovalRequested {\n                        call_id,\n                        approval_id,\n                        ..\n                    } => {\n                        let pending_status = ToolStatus::PendingApproval {\n                            approval_id: approval_id.clone(),\n                        };\n                        state.update_tool_status(call_id, pending_status, false, &msg_store);\n                    }\n                    Approval::ApprovalResponse {\n                        call_id,\n                        approval_status,\n                        ..\n                    } => {\n                        if let Some(status) = ToolStatus::from_approval_status(approval_status) {\n                            state.update_tool_status(call_id, status, true, &msg_store);\n                        }\n\n                        if let Some(entry) = approval.to_normalized_entry_opt() {\n                            add_normalized_entry(&msg_store, &entry_index, entry);\n                        }\n                    }\n                    Approval::QuestionResponse {\n                        call_id,\n                        question_status,\n                    } => {\n                        let status = ToolStatus::from_question_status(question_status);\n                        state.update_tool_status(call_id, status, true, &msg_store);\n\n                        if let Some(entry) = approval.to_normalized_entry_opt() {\n                            add_normalized_entry(&msg_store, &entry_index, entry);\n                        }\n                    }\n                }\n                continue;\n            }\n\n            if let Ok(response) = serde_json::from_str::<JSONRPCResponse>(&line) {\n                handle_jsonrpc_response(\n                    response,\n                    &msg_store,\n                    &entry_index,\n                    &mut state.model_params,\n                );\n                continue;\n            }\n\n            if let Ok(server_notification) = serde_json::from_str::<ServerNotification>(&line) {\n                if let ServerNotification::ThreadStarted(n) = server_notification {\n                    msg_store.push_session_id(n.thread.id);\n                }\n                continue;\n            } else if let Some(session_id) = line\n                .strip_prefix(r#\"{\"method\":\"sessionConfigured\",\"params\":{\"sessionId\":\"\"#)\n                .and_then(|suffix| SESSION_ID.captures(suffix).and_then(|caps| caps.get(1)))\n            {\n                // Best-effort extraction of session ID from logs in case the JSON parsing fails.\n                // This could happen if the line is truncated due to size limits because it includes the full session history.\n                msg_store.push_session_id(session_id.as_str().to_string());\n                continue;\n            }\n\n            let notification: JSONRPCNotification = match serde_json::from_str(&line) {\n                Ok(value) => value,\n                Err(_) => continue,\n            };\n\n            if !notification.method.starts_with(\"codex/event\") {\n                continue;\n            }\n\n            let Some(params) = notification\n                .params\n                .and_then(|p| serde_json::from_value::<CodexNotificationParams>(p).ok())\n            else {\n                continue;\n            };\n\n            let event = params.msg;\n            match event {\n                EventMsg::SessionConfigured(payload) => {\n                    msg_store.push_session_id(payload.session_id.to_string());\n                    handle_model_params(\n                        Some(payload.model),\n                        payload.reasoning_effort,\n                        &msg_store,\n                        &entry_index,\n                        &mut state.model_params,\n                    );\n                }\n                EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {\n                    state.thinking = None;\n                    let (entry, index, is_new) = state.assistant_message_append(delta);\n                    upsert_normalized_entry(&msg_store, index, entry, is_new);\n                }\n                EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {\n                    state.assistant = None;\n                    let (entry, index, is_new) = state.thinking_append(delta);\n                    upsert_normalized_entry(&msg_store, index, entry, is_new);\n                }\n                EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => {\n                    state.thinking = None;\n                    let (entry, index, is_new) = state.assistant_message(message);\n                    upsert_normalized_entry(&msg_store, index, entry, is_new);\n                    state.assistant = None;\n                }\n                EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {\n                    state.assistant = None;\n                    let (entry, index, is_new) = state.thinking(text);\n                    upsert_normalized_entry(&msg_store, index, entry, is_new);\n                    state.thinking = None;\n                }\n                EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {\n                    item_id: _,\n                    summary_index: _,\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                }\n                EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {\n                    call_id,\n                    turn_id: _,\n                    command,\n                    cwd: _,\n                    reason,\n                    parsed_cmd: _,\n                    proposed_execpolicy_amendment: _,\n                    ..\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n\n                    let command_text = if command.is_empty() {\n                        reason\n                            .filter(|r| !r.is_empty())\n                            .unwrap_or_else(|| \"command execution\".to_string())\n                    } else {\n                        command.join(\" \")\n                    };\n\n                    let command_state = state.commands.entry(call_id.clone()).or_default();\n\n                    if command_state.command.is_empty() {\n                        command_state.command = command_text;\n                    }\n                    command_state.awaiting_approval = true;\n                    command_state.call_id = call_id.clone();\n                    if let Some(index) = command_state.index {\n                        replace_normalized_entry(\n                            &msg_store,\n                            index,\n                            command_state.to_normalized_entry(),\n                        );\n                    } else {\n                        let index = add_normalized_entry(\n                            &msg_store,\n                            &entry_index,\n                            command_state.to_normalized_entry(),\n                        );\n                        command_state.index = Some(index);\n                    }\n                }\n                EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {\n                    call_id,\n                    turn_id: _,\n                    changes,\n                    reason: _,\n                    grant_root: _,\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n\n                    let normalized = normalize_file_changes(&worktree_path_str, &changes);\n                    let patch_state = state.patches.entry(call_id.clone()).or_default();\n\n                    // Update existing entries in place to keep them in MsgStore\n                    let normalized_len = normalized.len();\n                    let mut iter = normalized.into_iter();\n                    for entry in &mut patch_state.entries {\n                        if let Some((path, file_changes)) = iter.next() {\n                            entry.path = path;\n                            entry.changes = file_changes;\n                            entry.awaiting_approval = true;\n                            if let Some(index) = entry.index {\n                                replace_normalized_entry(\n                                    &msg_store,\n                                    index,\n                                    entry.to_normalized_entry(),\n                                );\n                            } else {\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index,\n                                    entry.to_normalized_entry(),\n                                );\n                                entry.index = Some(index);\n                            }\n                        }\n                    }\n\n                    // Remove stale entries if new changes have fewer files\n                    if normalized_len < patch_state.entries.len() {\n                        for entry in patch_state.entries.drain(normalized_len..) {\n                            if let Some(index) = entry.index {\n                                msg_store.push_patch(ConversationPatch::remove(index));\n                            }\n                        }\n                    }\n\n                    // Add new entries if changes have more files\n                    for (path, file_changes) in iter {\n                        let mut entry = PatchEntry {\n                            index: None,\n                            path,\n                            changes: file_changes,\n                            status: ToolStatus::Created,\n                            awaiting_approval: true,\n                            call_id: call_id.clone(),\n                        };\n                        let index = add_normalized_entry(\n                            &msg_store,\n                            &entry_index,\n                            entry.to_normalized_entry(),\n                        );\n                        entry.index = Some(index);\n                        patch_state.entries.push(entry);\n                    }\n                }\n                EventMsg::ExecCommandBegin(ExecCommandBeginEvent {\n                    call_id,\n                    turn_id: _,\n                    command,\n                    cwd: _,\n                    parsed_cmd: _,\n                    source: _,\n                    interaction_input: _,\n                    process_id: _,\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                    let command_text = command.join(\" \");\n                    if command_text.is_empty() {\n                        continue;\n                    }\n                    state.commands.insert(\n                        call_id.clone(),\n                        CommandState {\n                            index: None,\n                            command: command_text,\n                            stdout: String::new(),\n                            stderr: String::new(),\n                            formatted_output: None,\n                            status: ToolStatus::Created,\n                            exit_code: None,\n                            awaiting_approval: false,\n                            call_id: call_id.clone(),\n                        },\n                    );\n                    let command_state = state.commands.get_mut(&call_id).unwrap();\n                    let index = add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        command_state.to_normalized_entry(),\n                    );\n                    command_state.index = Some(index)\n                }\n                EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {\n                    call_id,\n                    stream,\n                    chunk,\n                }) => {\n                    if let Some(command_state) = state.commands.get_mut(&call_id) {\n                        let chunk = String::from_utf8_lossy(&chunk);\n                        if chunk.is_empty() {\n                            continue;\n                        }\n                        match stream {\n                            ExecOutputStream::Stdout => command_state.stdout.push_str(&chunk),\n                            ExecOutputStream::Stderr => command_state.stderr.push_str(&chunk),\n                        }\n                        let Some(index) = command_state.index else {\n                            tracing::error!(\"missing entry index for existing command state\");\n                            continue;\n                        };\n                        replace_normalized_entry(\n                            &msg_store,\n                            index,\n                            command_state.to_normalized_entry(),\n                        );\n                    }\n                }\n                EventMsg::ExecCommandEnd(ExecCommandEndEvent {\n                    call_id,\n                    turn_id: _,\n                    command: _,\n                    cwd: _,\n                    parsed_cmd: _,\n                    source: _,\n                    interaction_input: _,\n                    stdout: _,\n                    stderr: _,\n                    aggregated_output: _,\n                    exit_code,\n                    duration: _,\n                    formatted_output,\n                    process_id: _,\n                    ..\n                }) => {\n                    if let Some(mut command_state) = state.commands.remove(&call_id) {\n                        command_state.formatted_output = Some(formatted_output);\n                        command_state.exit_code = Some(exit_code);\n                        command_state.awaiting_approval = false;\n                        command_state.status = if exit_code == 0 {\n                            ToolStatus::Success\n                        } else {\n                            ToolStatus::Failed\n                        };\n                        let Some(index) = command_state.index else {\n                            tracing::error!(\"missing entry index for existing command state\");\n                            continue;\n                        };\n                        replace_normalized_entry(\n                            &msg_store,\n                            index,\n                            command_state.to_normalized_entry(),\n                        );\n                    }\n                }\n                EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: format!(\"Background event: {message}\"),\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::StreamError(StreamErrorEvent {\n                    message,\n                    codex_error_info,\n                    ..\n                }) => {\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ErrorMessage {\n                                error_type: NormalizedEntryError::Other,\n                            },\n                            content: format!(\"Stream error: {message} {codex_error_info:?}\"),\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::McpToolCallBegin(McpToolCallBeginEvent {\n                    call_id,\n                    invocation,\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                    state.mcp_tools.insert(\n                        call_id.clone(),\n                        McpToolState {\n                            index: None,\n                            invocation,\n                            result: None,\n                            status: ToolStatus::Created,\n                        },\n                    );\n                    let mcp_tool_state = state.mcp_tools.get_mut(&call_id).unwrap();\n                    let index = add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        mcp_tool_state.to_normalized_entry(),\n                    );\n                    mcp_tool_state.index = Some(index);\n                }\n                EventMsg::McpToolCallEnd(McpToolCallEndEvent {\n                    call_id, result, ..\n                }) => {\n                    if let Some(mut mcp_tool_state) = state.mcp_tools.remove(&call_id) {\n                        match result {\n                            Ok(value) => {\n                                mcp_tool_state.status = if value.is_error.unwrap_or(false) {\n                                    ToolStatus::Failed\n                                } else {\n                                    ToolStatus::Success\n                                };\n                                if value.content.iter().all(|block| {\n                                    block.get(\"type\").and_then(|t| t.as_str()) == Some(\"text\")\n                                }) {\n                                    mcp_tool_state.result = Some(ToolResult {\n                                        r#type: ToolResultValueType::Markdown,\n                                        value: Value::String(\n                                            value\n                                                .content\n                                                .iter()\n                                                .filter_map(|block| {\n                                                    block\n                                                        .get(\"text\")\n                                                        .and_then(|t| t.as_str())\n                                                        .map(|s| s.to_owned())\n                                                })\n                                                .collect::<Vec<String>>()\n                                                .join(\"\\n\"),\n                                        ),\n                                    });\n                                } else {\n                                    mcp_tool_state.result = Some(ToolResult {\n                                        r#type: ToolResultValueType::Json,\n                                        value: value.structured_content.unwrap_or_else(|| {\n                                            serde_json::to_value(value.content).unwrap_or_default()\n                                        }),\n                                    });\n                                }\n                            }\n                            Err(err) => {\n                                mcp_tool_state.status = ToolStatus::Failed;\n                                mcp_tool_state.result = Some(ToolResult {\n                                    r#type: ToolResultValueType::Markdown,\n                                    value: Value::String(err),\n                                });\n                            }\n                        };\n                        let Some(index) = mcp_tool_state.index else {\n                            tracing::error!(\"missing entry index for existing mcp tool state\");\n                            continue;\n                        };\n                        replace_normalized_entry(\n                            &msg_store,\n                            index,\n                            mcp_tool_state.to_normalized_entry(),\n                        );\n                    }\n                }\n                EventMsg::PatchApplyBegin(PatchApplyBeginEvent {\n                    call_id, changes, ..\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                    let normalized = normalize_file_changes(&worktree_path_str, &changes);\n                    if let Some(patch_state) = state.patches.get_mut(&call_id) {\n                        let mut iter = normalized.into_iter();\n                        for entry in &mut patch_state.entries {\n                            if let Some((path, file_changes)) = iter.next() {\n                                entry.path = path;\n                                entry.changes = file_changes;\n                            }\n                            entry.status = ToolStatus::Created;\n                            entry.awaiting_approval = false;\n                            if let Some(index) = entry.index {\n                                replace_normalized_entry(\n                                    &msg_store,\n                                    index,\n                                    entry.to_normalized_entry(),\n                                );\n                            } else {\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index,\n                                    entry.to_normalized_entry(),\n                                );\n                                entry.index = Some(index);\n                            }\n                        }\n                        for (path, file_changes) in iter {\n                            let mut entry = PatchEntry {\n                                index: None,\n                                path,\n                                changes: file_changes,\n                                status: ToolStatus::Created,\n                                awaiting_approval: false,\n                                call_id: call_id.clone(),\n                            };\n                            let index = add_normalized_entry(\n                                &msg_store,\n                                &entry_index,\n                                entry.to_normalized_entry(),\n                            );\n                            entry.index = Some(index);\n                            patch_state.entries.push(entry);\n                        }\n                    } else {\n                        let mut patch_state = PatchState::default();\n                        for (path, file_changes) in normalized {\n                            patch_state.entries.push(PatchEntry {\n                                index: None,\n                                path,\n                                changes: file_changes,\n                                status: ToolStatus::Created,\n                                awaiting_approval: false,\n                                call_id: call_id.clone(),\n                            });\n                            let patch_entry = patch_state.entries.last_mut().unwrap();\n                            let index = add_normalized_entry(\n                                &msg_store,\n                                &entry_index,\n                                patch_entry.to_normalized_entry(),\n                            );\n                            patch_entry.index = Some(index);\n                        }\n                        state.patches.insert(call_id, patch_state);\n                    }\n                }\n                EventMsg::PatchApplyEnd(PatchApplyEndEvent {\n                    call_id,\n                    stdout: _,\n                    stderr: _,\n                    success,\n                    ..\n                }) => {\n                    if let Some(patch_state) = state.patches.remove(&call_id) {\n                        let status = if success {\n                            ToolStatus::Success\n                        } else {\n                            ToolStatus::Failed\n                        };\n                        for mut entry in patch_state.entries {\n                            entry.status = status.clone();\n                            let Some(index) = entry.index else {\n                                tracing::error!(\"missing entry index for existing patch entry\");\n                                continue;\n                            };\n                            replace_normalized_entry(\n                                &msg_store,\n                                index,\n                                entry.to_normalized_entry(),\n                            );\n                        }\n                    }\n                }\n                EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                    state\n                        .web_searches\n                        .insert(call_id.clone(), WebSearchState::new());\n                    let web_search_state = state.web_searches.get_mut(&call_id).unwrap();\n                    let normalized_entry = web_search_state.to_normalized_entry();\n                    let index = add_normalized_entry(&msg_store, &entry_index, normalized_entry);\n                    web_search_state.index = Some(index);\n                }\n                EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query, .. }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                    if let Some(mut entry) = state.web_searches.remove(&call_id) {\n                        entry.status = ToolStatus::Success;\n                        entry.query = Some(query.clone());\n                        let normalized_entry = entry.to_normalized_entry();\n                        let Some(index) = entry.index else {\n                            tracing::error!(\"missing entry index for existing websearch entry\");\n                            continue;\n                        };\n                        replace_normalized_entry(&msg_store, index, normalized_entry);\n                    }\n                }\n                EventMsg::ViewImageToolCall(ViewImageToolCallEvent { call_id: _, path }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                    let path_str = path.to_string_lossy().to_string();\n                    let relative_path = make_path_relative(&path_str, &worktree_path_str);\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ToolUse {\n                                tool_name: \"view_image\".to_string(),\n                                action_type: ActionType::FileRead {\n                                    path: relative_path.clone(),\n                                },\n                                status: ToolStatus::Success,\n                            },\n                            content: relative_path.to_string(),\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::PlanUpdate(UpdatePlanArgs { plan, explanation }) => {\n                    let todos: Vec<TodoItem> = plan\n                        .iter()\n                        .map(|item| TodoItem {\n                            content: item.step.clone(),\n                            status: format_todo_status(&item.status),\n                            priority: None,\n                        })\n                        .collect();\n                    let explanation = explanation\n                        .as_ref()\n                        .map(|text| text.trim())\n                        .filter(|text| !text.is_empty())\n                        .map(|text| text.to_string());\n                    let content = explanation.clone().unwrap_or_else(|| {\n                        if todos.is_empty() {\n                            \"Plan updated\".to_string()\n                        } else {\n                            format!(\"Plan updated ({} steps)\", todos.len())\n                        }\n                    });\n\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ToolUse {\n                                tool_name: \"plan\".to_string(),\n                                action_type: ActionType::TodoManagement {\n                                    todos,\n                                    operation: \"update\".to_string(),\n                                },\n                                status: ToolStatus::Success,\n                            },\n                            content,\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::Warning(WarningEvent { message }) => {\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ErrorMessage {\n                                error_type: NormalizedEntryError::Other,\n                            },\n                            content: message,\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::ModelReroute(ModelRerouteEvent {\n                    from_model,\n                    to_model,\n                    ..\n                }) => {\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: format!(\n                                \"warning: model rerouted from {from_model} to {to_model}\"\n                            ),\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::Error(ErrorEvent {\n                    message,\n                    codex_error_info,\n                }) => {\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ErrorMessage {\n                                error_type: NormalizedEntryError::Other,\n                            },\n                            content: format!(\"Error: {message} {codex_error_info:?}\"),\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::TokenCount(payload) => {\n                    if let Some(info) = payload.info {\n                        add_normalized_entry(\n                            &msg_store,\n                            &entry_index,\n                            NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::TokenUsageInfo(\n                                    crate::logs::TokenUsageInfo {\n                                        total_tokens: info.last_token_usage.total_tokens as u32,\n                                        model_context_window: info\n                                            .model_context_window\n                                            .unwrap_or_default()\n                                            as u32,\n                                    },\n                                ),\n                                content: format!(\n                                    \"Tokens used: {} / Context window: {}\",\n                                    info.last_token_usage.total_tokens,\n                                    info.model_context_window.unwrap_or_default()\n                                ),\n                                metadata: None,\n                            },\n                        );\n                    }\n                }\n                EventMsg::EnteredReviewMode(review_request) => {\n                    let mut review_state = ReviewState {\n                        index: None,\n                        description: review_request\n                            .user_facing_hint\n                            .unwrap_or_else(|| \"Reviewing code...\".to_string()),\n                        status: ToolStatus::Created,\n                        result: None,\n                    };\n                    let index = add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        review_state.to_normalized_entry(),\n                    );\n                    review_state.index = Some(index);\n                    state.review = Some(review_state);\n                }\n                EventMsg::ExitedReviewMode(review_event) => {\n                    if let Some(mut review_state) = state.review.take() {\n                        review_state.complete(&review_event, &worktree_path_str);\n                        if let Some(index) = review_state.index {\n                            replace_normalized_entry(\n                                &msg_store,\n                                index,\n                                review_state.to_normalized_entry(),\n                            );\n                        }\n                    }\n                }\n                EventMsg::RequestUserInput(RequestUserInputEvent {\n                    call_id,\n                    turn_id: _,\n                    questions: event_questions,\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n\n                    if call_id.is_empty() {\n                        continue;\n                    }\n\n                    let questions: Vec<AskUserQuestionItem> = event_questions\n                        .iter()\n                        .map(|q| AskUserQuestionItem {\n                            question: q.question.clone(),\n                            header: q.header.clone(),\n                            options: q\n                                .options\n                                .as_deref()\n                                .unwrap_or(&[])\n                                .iter()\n                                .map(|o| AskUserQuestionOption {\n                                    label: o.label.clone(),\n                                    description: o.description.clone(),\n                                })\n                                .collect(),\n                            multi_select: false,\n                        })\n                        .collect();\n\n                    let content = if questions.len() == 1 {\n                        questions[0].question.clone()\n                    } else {\n                        format!(\"{} questions\", questions.len())\n                    };\n\n                    let index = entry_index.next();\n                    let tool_state = UserInputRequestState {\n                        index: Some(index),\n                        questions,\n                        content,\n                        status: ToolStatus::Created,\n                    };\n                    upsert_normalized_entry(\n                        &msg_store,\n                        index,\n                        tool_state.to_normalized_entry(),\n                        true,\n                    );\n                    state.user_input_requests.insert(call_id, tool_state);\n                }\n                EventMsg::PlanDelta(PlanDeltaEvent { delta, item_id, .. }) => {\n                    state.thinking = None;\n                    if let Some(plan_state) = state.plans.get_mut(&item_id) {\n                        plan_state.text.push_str(&delta);\n                        if let Some(index) = plan_state.index {\n                            replace_normalized_entry(\n                                &msg_store,\n                                index,\n                                plan_state.to_normalized_entry(),\n                            );\n                        }\n                    } else {\n                        // Backward compat: if no plan state, treat as assistant text\n                        let (entry, index, is_new) = state.assistant_message_append(delta);\n                        upsert_normalized_entry(&msg_store, index, entry, is_new);\n                    }\n                }\n                EventMsg::ContextCompacted(..) => {\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: \"Context compacted\".to_string(),\n                            metadata: None,\n                        },\n                    );\n                }\n                EventMsg::ItemStarted(ItemStartedEvent {\n                    item: TurnItem::Plan(ref plan_item),\n                    ..\n                }) => {\n                    state.assistant = None;\n                    state.thinking = None;\n                    let mut plan_state = PlanState {\n                        index: None,\n                        text: String::new(),\n                        status: ToolStatus::Created,\n                    };\n                    let index = add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        plan_state.to_normalized_entry(),\n                    );\n                    plan_state.index = Some(index);\n                    state.plans.insert(plan_item.id.clone(), plan_state);\n                }\n                EventMsg::ItemCompleted(ItemCompletedEvent {\n                    item: TurnItem::Plan(ref plan_item),\n                    ..\n                }) => {\n                    if let Some(plan_state) = state.plans.get_mut(&plan_item.id) {\n                        plan_state.text = plan_item.text.clone();\n                        if let Some(index) = plan_state.index {\n                            replace_normalized_entry(\n                                &msg_store,\n                                index,\n                                plan_state.to_normalized_entry(),\n                            );\n                        }\n                    }\n                }\n                EventMsg::AgentReasoningRawContent(..)\n                | EventMsg::AgentReasoningRawContentDelta(..)\n                | EventMsg::ThreadRolledBack(..)\n                | EventMsg::TurnStarted(..)\n                | EventMsg::UserMessage(..)\n                | EventMsg::TurnDiff(..)\n                | EventMsg::GetHistoryEntryResponse(..)\n                | EventMsg::McpListToolsResponse(..)\n                | EventMsg::McpStartupComplete(..)\n                | EventMsg::McpStartupUpdate(..)\n                | EventMsg::DeprecationNotice(..)\n                | EventMsg::UndoCompleted(..)\n                | EventMsg::UndoStarted(..)\n                | EventMsg::RawResponseItem(..)\n                | EventMsg::ItemStarted(..)\n                | EventMsg::ItemCompleted(..)\n                | EventMsg::AgentMessageContentDelta(..)\n                | EventMsg::ReasoningContentDelta(..)\n                | EventMsg::ReasoningRawContentDelta(..)\n                | EventMsg::ListCustomPromptsResponse(..)\n                | EventMsg::ListSkillsResponse(..)\n                | EventMsg::SkillsUpdateAvailable\n                | EventMsg::TurnAborted(..)\n                | EventMsg::ShutdownComplete\n                | EventMsg::TerminalInteraction(..)\n                | EventMsg::ElicitationRequest(..)\n                | EventMsg::TurnComplete(..)\n                | EventMsg::CollabAgentSpawnBegin(..)\n                | EventMsg::CollabAgentSpawnEnd(..)\n                | EventMsg::CollabAgentInteractionBegin(..)\n                | EventMsg::CollabAgentInteractionEnd(..)\n                | EventMsg::CollabWaitingBegin(..)\n                | EventMsg::CollabWaitingEnd(..)\n                | EventMsg::CollabCloseBegin(..)\n                | EventMsg::CollabCloseEnd(..)\n                | EventMsg::CollabResumeBegin(..)\n                | EventMsg::CollabResumeEnd(..)\n                | EventMsg::ThreadNameUpdated(..)\n                | EventMsg::DynamicToolCallRequest(..)\n                | EventMsg::DynamicToolCallResponse(..)\n                | EventMsg::ListRemoteSkillsResponse(..)\n                | EventMsg::RemoteSkillDownloaded(..)\n                | EventMsg::RealtimeConversationStarted(..)\n                | EventMsg::RealtimeConversationRealtime(..)\n                | EventMsg::RealtimeConversationClosed(..)\n                | EventMsg::ImageGenerationBegin(..)\n                | EventMsg::ImageGenerationEnd(..)\n                | EventMsg::RequestPermissions(..)\n                | EventMsg::HookCompleted(..)\n                | EventMsg::HookStarted(..) => {}\n            }\n        }\n    });\n\n    vec![h1, h2]\n}\n\nfn handle_jsonrpc_response(\n    response: JSONRPCResponse,\n    msg_store: &Arc<MsgStore>,\n    entry_index: &EntryIndexProvider,\n    model_params: &mut ModelParamsState,\n) {\n    if let Ok(resp) = serde_json::from_value::<ThreadStartResponse>(response.result.clone()) {\n        msg_store.push_session_id(resp.thread.id);\n        handle_model_params(\n            Some(resp.model),\n            resp.reasoning_effort,\n            msg_store,\n            entry_index,\n            model_params,\n        );\n        return;\n    }\n\n    if let Ok(resp) = serde_json::from_value::<ThreadForkResponse>(response.result.clone()) {\n        msg_store.push_session_id(resp.thread.id);\n        handle_model_params(\n            Some(resp.model),\n            resp.reasoning_effort,\n            msg_store,\n            entry_index,\n            model_params,\n        );\n    }\n}\n\nfn handle_model_params(\n    model: Option<String>,\n    reasoning_effort: Option<ReasoningEffort>,\n    msg_store: &Arc<MsgStore>,\n    entry_index: &EntryIndexProvider,\n    state: &mut ModelParamsState,\n) {\n    if let Some(model) = model {\n        state.model = Some(model);\n    }\n    if let Some(reasoning_effort) = reasoning_effort {\n        state.reasoning_effort = Some(reasoning_effort);\n    }\n\n    let mut params = vec![];\n    if let Some(model) = &state.model {\n        params.push(format!(\"model: {model}\"));\n    }\n    if let Some(reasoning_effort) = &state.reasoning_effort {\n        params.push(format!(\"reasoning effort: {reasoning_effort}\"));\n    }\n\n    if params.is_empty() {\n        return;\n    }\n\n    let is_new = state.index.is_none();\n    let index = *state.index.get_or_insert_with(|| entry_index.next());\n    let entry = NormalizedEntry {\n        timestamp: None,\n        entry_type: NormalizedEntryType::SystemMessage,\n        content: params.join(\"  \"),\n        metadata: None,\n    };\n    upsert_normalized_entry(msg_store, index, entry, is_new);\n}\n\nfn build_command_output(stdout: Option<&str>, stderr: Option<&str>) -> Option<String> {\n    let mut sections = Vec::new();\n    if let Some(out) = stdout {\n        let cleaned = out.trim();\n        if !cleaned.is_empty() {\n            sections.push(format!(\"stdout:\\n{cleaned}\"));\n        }\n    }\n    if let Some(err) = stderr {\n        let cleaned = err.trim();\n        if !cleaned.is_empty() {\n            sections.push(format!(\"stderr:\\n{cleaned}\"));\n        }\n    }\n\n    if sections.is_empty() {\n        None\n    } else {\n        Some(sections.join(\"\\n\\n\"))\n    }\n}\n\nstatic SESSION_ID: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\"#)\n        .expect(\"valid regex\")\n});\n\n#[derive(Serialize, Deserialize, Debug)]\npub enum Error {\n    LaunchError { error: String },\n    AuthRequired { error: String },\n}\n\nimpl Error {\n    pub fn launch_error(error: String) -> Self {\n        Self::LaunchError { error }\n    }\n    pub fn auth_required(error: String) -> Self {\n        Self::AuthRequired { error }\n    }\n\n    pub fn raw(&self) -> String {\n        serde_json::to_string(self).unwrap_or_default()\n    }\n}\n\nimpl ToNormalizedEntry for Error {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        match self {\n            Error::LaunchError { error } => NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::Other,\n                },\n                content: error.clone(),\n                metadata: None,\n            },\n            Error::AuthRequired { error } => NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::SetupRequired,\n                },\n                content: error.clone(),\n                metadata: None,\n            },\n        }\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub enum Approval {\n    ApprovalRequested {\n        call_id: String,\n        tool_name: String,\n        approval_id: String,\n    },\n    ApprovalResponse {\n        call_id: String,\n        tool_name: String,\n        approval_status: ApprovalStatus,\n    },\n    QuestionResponse {\n        call_id: String,\n        question_status: QuestionStatus,\n    },\n}\n\nimpl Approval {\n    pub fn approval_requested(call_id: String, tool_name: String, approval_id: String) -> Self {\n        Self::ApprovalRequested {\n            call_id,\n            tool_name,\n            approval_id,\n        }\n    }\n\n    pub fn approval_response(\n        call_id: String,\n        tool_name: String,\n        approval_status: ApprovalStatus,\n    ) -> Self {\n        Self::ApprovalResponse {\n            call_id,\n            tool_name,\n            approval_status,\n        }\n    }\n\n    pub fn question_response(call_id: String, question_status: QuestionStatus) -> Self {\n        Self::QuestionResponse {\n            call_id,\n            question_status,\n        }\n    }\n\n    pub fn raw(&self) -> String {\n        serde_json::to_string(self).unwrap_or_default()\n    }\n\n    pub fn display_tool_name(&self) -> String {\n        match self {\n            Self::ApprovalRequested { tool_name, .. }\n            | Self::ApprovalResponse { tool_name, .. } => match tool_name.as_str() {\n                \"codex.exec_command\" => \"Exec Command\".to_string(),\n                \"codex.apply_patch\" => \"Edit\".to_string(),\n                \"codex.question\" => \"Question\".to_string(),\n                \"codex.plan\" => \"Plan\".to_string(),\n                other => other.to_string(),\n            },\n            Self::QuestionResponse { .. } => \"Question\".to_string(),\n        }\n    }\n}\n\nimpl ToNormalizedEntryOpt for Approval {\n    fn to_normalized_entry_opt(&self) -> Option<NormalizedEntry> {\n        let approval_status = match self {\n            Self::ApprovalResponse {\n                approval_status, ..\n            } => approval_status,\n            Self::QuestionResponse {\n                question_status, ..\n            } => {\n                return match question_status {\n                    QuestionStatus::Answered { answers } => {\n                        let qa_pairs: Vec<AnsweredQuestion> = answers\n                            .iter()\n                            .map(|qa| AnsweredQuestion {\n                                question: qa.question.clone(),\n                                answer: qa.answer.clone(),\n                            })\n                            .collect();\n                        Some(NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::UserAnsweredQuestions {\n                                answers: qa_pairs,\n                            },\n                            content: format!(\n                                \"Answered {} question{}\",\n                                answers.len(),\n                                if answers.len() != 1 { \"s\" } else { \"\" }\n                            ),\n                            metadata: None,\n                        })\n                    }\n                    QuestionStatus::TimedOut => None,\n                };\n            }\n            Self::ApprovalRequested { .. } => return None,\n        };\n        let tool_name = self.display_tool_name();\n\n        match approval_status {\n            ApprovalStatus::Pending | ApprovalStatus::Approved => None,\n            ApprovalStatus::Denied { reason } => Some(NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::UserFeedback {\n                    denied_tool: tool_name.clone(),\n                },\n                content: reason\n                    .clone()\n                    .unwrap_or_else(|| \"User denied this tool use request\".to_string())\n                    .trim()\n                    .to_string(),\n                metadata: None,\n            }),\n            ApprovalStatus::TimedOut => Some(NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::Other,\n                },\n                content: format!(\"Approval timed out for tool {tool_name}\"),\n                metadata: None,\n            }),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/codex/review.rs",
    "content": "use std::sync::Arc;\n\nuse codex_app_server_protocol::{ReviewTarget, ThreadStartParams};\n\nuse super::{client::AppServerClient, fork_params_from};\nuse crate::executors::ExecutorError;\n\npub async fn launch_codex_review(\n    thread_start_params: ThreadStartParams,\n    resume_session: Option<String>,\n    review_target: ReviewTarget,\n    client: Arc<AppServerClient>,\n) -> Result<(), ExecutorError> {\n    let account = client.get_account().await?;\n    if account.requires_openai_auth && account.account.is_none() {\n        return Err(ExecutorError::AuthRequired(\n            \"Codex authentication required\".to_string(),\n        ));\n    }\n\n    let thread_id = match resume_session {\n        Some(session_id) => {\n            let response = client\n                .thread_fork(fork_params_from(session_id, thread_start_params))\n                .await?;\n            tracing::debug!(\n                \"forked thread for review, new thread_id={}\",\n                response.thread.id\n            );\n            response.thread.id\n        }\n        None => {\n            let response = client.thread_start(thread_start_params).await?;\n            response.thread.id\n        }\n    };\n\n    client.register_session(&thread_id).await?;\n    client.start_review(thread_id, review_target).await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/executors/src/executors/codex/slash_commands.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse codex_app_server_protocol::{ConfigEdit, JSONRPCNotification, MergeStrategy};\nuse codex_protocol::{\n    config_types::ServiceTier,\n    protocol::{AgentMessageEvent, ErrorEvent, EventMsg},\n};\nuse serde_json::json;\n\nuse super::{\n    Codex,\n    client::{AppServerClient, LogWriter},\n    codex_home, fork_params_from, resolve_model,\n};\nuse crate::{\n    env::ExecutionEnv,\n    executors::{\n        ExecutorError, ExecutorExitResult, SpawnedChild,\n        utils::{SlashCommandCall, parse_slash_command},\n    },\n    stdout_dup::spawn_local_output_process,\n};\n\nconst CODEX_INIT_PROMPT: &str = include_str!(\"init_prompt.md\");\nconst DEFAULT_PROJECT_DOC_FILENAME: &str = \"AGENTS.md\";\n\n#[derive(Debug, Clone)]\npub enum CodexSlashCommand {\n    Init,\n    Compact { instructions: Option<String> },\n    Status,\n    Mcp,\n    Fast { enable: Option<bool> },\n}\n\nimpl CodexSlashCommand {\n    pub fn parse(prompt: &str) -> Option<Self> {\n        let cmd: SlashCommandCall<'_> = parse_slash_command(prompt)?;\n        match cmd.name.as_str() {\n            \"init\" => Some(Self::Init),\n            \"compact\" => Some(Self::Compact {\n                instructions: if cmd.arguments.is_empty() {\n                    None\n                } else {\n                    Some(cmd.arguments.to_string())\n                },\n            }),\n            \"status\" => Some(Self::Status),\n            \"mcp\" => Some(Self::Mcp),\n            \"fast\" => Some(Self::Fast {\n                enable: match cmd.arguments.trim() {\n                    \"on\" | \"true\" | \"1\" | \"yes\" | \"enable\" => Some(true),\n                    \"off\" | \"false\" | \"0\" | \"no\" | \"disable\" => Some(false),\n                    _ => None,\n                },\n            }),\n            _ => None,\n        }\n    }\n}\n\nimpl Codex {\n    pub async fn spawn_slash_command(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        if let Some(command) = CodexSlashCommand::parse(prompt) {\n            return match command {\n                CodexSlashCommand::Init => {\n                    let init_target = current_dir.join(DEFAULT_PROJECT_DOC_FILENAME);\n                    if init_target.exists() {\n                        let message = format!(\n                            \"`{DEFAULT_PROJECT_DOC_FILENAME}` already exists. Skipping `/init` to avoid overwriting it.\"\n                        );\n                        self.return_static_reply(current_dir, Ok(message)).await\n                    } else {\n                        self.spawn_agent_with_prompt(\n                            current_dir,\n                            CODEX_INIT_PROMPT,\n                            session_id,\n                            env,\n                        )\n                        .await\n                    }\n                }\n                CodexSlashCommand::Compact { .. } => match session_id {\n                    Some(_) => {\n                        self.handle_app_server_slash_command(current_dir, command, session_id, env)\n                            .await\n                    }\n                    None => {\n                        self.return_static_reply(\n                            current_dir,\n                            Ok(\"_No active session to compact._\".to_string()),\n                        )\n                        .await\n                    }\n                },\n                CodexSlashCommand::Status => {\n                    self.handle_app_server_slash_command(current_dir, command, session_id, env)\n                        .await\n                }\n                CodexSlashCommand::Mcp => {\n                    self.handle_app_server_slash_command(current_dir, command, None, env)\n                        .await\n                }\n                CodexSlashCommand::Fast { .. } => {\n                    self.handle_app_server_slash_command(current_dir, command, session_id, env)\n                        .await\n                }\n            };\n        }\n\n        self.spawn_agent_with_prompt(current_dir, prompt, session_id, env)\n            .await\n    }\n\n    async fn spawn_agent_with_prompt(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let command_parts = match session_id {\n            Some(_) => self.build_command_builder()?.build_follow_up(&[])?,\n            None => self.build_command_builder()?.build_initial()?,\n        };\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n        let action = super::CodexSessionAction::Chat {\n            prompt: combined_prompt,\n        };\n        self.spawn_inner(current_dir, command_parts, action, session_id, env)\n            .await\n    }\n\n    // Handle slash commands that require interaction with the app server\n    async fn handle_app_server_slash_command(\n        &self,\n        current_dir: &Path,\n        command: CodexSlashCommand,\n        session_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let command_parts = self.build_command_builder()?.build_initial()?;\n        let session_id = session_id.map(|s| s.to_string());\n        let (_, session_fast) = resolve_model(self.model.as_deref());\n        let thread_start_params = self.build_thread_start_params(current_dir);\n\n        self.spawn_app_server(\n            current_dir,\n            command_parts,\n            env,\n            move |client, exit_signal_tx| async move {\n                match command {\n                    CodexSlashCommand::Compact { .. } => {\n                        let old_thread_id = session_id.ok_or_else(|| {\n                            ExecutorError::Io(std::io::Error::other(\"No active session to compact\"))\n                        })?;\n                        let fork_response = client\n                            .thread_fork(fork_params_from(old_thread_id, thread_start_params))\n                            .await?;\n                        let thread_id = fork_response.thread.id;\n                        tracing::debug!(\"forked thread for compact, new thread_id={thread_id}\");\n                        client.thread_compact_start(thread_id).await?;\n                    }\n                    CodexSlashCommand::Status => {\n                        let message =\n                            fetch_status_message(&client, session_id.as_deref(), session_fast)\n                                .await?;\n                        log_event_raw(client.log_writer(), message).await?;\n                        exit_signal_tx\n                            .send_exit_signal(ExecutorExitResult::Success)\n                            .await;\n                    }\n                    CodexSlashCommand::Mcp => {\n                        let message = fetch_mcp_status_message(&client).await?;\n                        log_event_raw(client.log_writer(), message).await?;\n                        exit_signal_tx\n                            .send_exit_signal(ExecutorExitResult::Success)\n                            .await;\n                    }\n                    CodexSlashCommand::Fast { enable } => {\n                        // Read current config to support toggle\n                        let current_is_fast = client\n                            .config_read(None)\n                            .await\n                            .ok()\n                            .and_then(|r| r.config.service_tier)\n                            .map(|t| matches!(t, ServiceTier::Fast))\n                            .unwrap_or(false);\n                        let want_fast = match enable {\n                            Some(v) => v,\n                            None => !current_is_fast, // toggle\n                        };\n                        // Persist service_tier to codex config via config/batchWrite\n                        let config_value = if want_fast {\n                            json!(\"fast\")\n                        } else {\n                            json!(null)\n                        };\n                        let _ = client\n                            .config_batch_write(vec![ConfigEdit {\n                                key_path: \"service_tier\".to_string(),\n                                value: config_value,\n                                merge_strategy: MergeStrategy::Replace,\n                            }])\n                            .await;\n                        // Fork current session with new tier if one is active\n                        if let Some(old_thread_id) = session_id {\n                            let service_tier = if want_fast {\n                                Some(Some(ServiceTier::Fast))\n                            } else {\n                                Some(None)\n                            };\n                            let mut fork_params =\n                                fork_params_from(old_thread_id, thread_start_params);\n                            fork_params.service_tier = service_tier;\n                            let _ = client.thread_fork(fork_params).await;\n                        }\n                        let message = if want_fast {\n                            \"**Fast mode enabled.** Inference runs at higher speed (2× plan usage).\"\n                                .to_string()\n                        } else {\n                            \"**Fast mode disabled.**\".to_string()\n                        };\n                        log_event_raw(client.log_writer(), message).await?;\n                        exit_signal_tx\n                            .send_exit_signal(ExecutorExitResult::Success)\n                            .await;\n                    }\n                    _ => {\n                        return Err(ExecutorError::Io(std::io::Error::other(\n                            \"Unsupported Codex slash command\",\n                        )));\n                    }\n                }\n\n                Ok(())\n            },\n        )\n        .await\n    }\n\n    pub async fn return_static_reply(\n        &self,\n        current_dir: &Path,\n        message: Result<String, String>,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        self.spawn_static_reply_helper(\n            current_dir,\n            vec![match message {\n                Ok(message) => EventMsg::AgentMessage(AgentMessageEvent {\n                    message,\n                    phase: None,\n                }),\n                Err(message) => EventMsg::Error(ErrorEvent {\n                    message,\n                    codex_error_info: None,\n                }),\n            }],\n        )\n        .await\n    }\n\n    // Helper to spawn a process whose sole purpose is to channel back a static reply\n    pub async fn spawn_static_reply_helper(\n        &self,\n        _current_dir: &Path,\n        events: Vec<EventMsg>,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let (mut spawned, writer) = spawn_local_output_process()?;\n        let log_writer = LogWriter::new(writer);\n        let (exit_signal_tx, exit_signal_rx) = tokio::sync::oneshot::channel();\n\n        tokio::spawn(async move {\n            let mut exit_result = ExecutorExitResult::Success;\n            for event in events {\n                if let Err(err) = log_event_notification(&log_writer, event).await {\n                    tracing::error!(\"Failed to emit slash command output: {err}\");\n                    exit_result = ExecutorExitResult::Failure;\n                    break;\n                }\n            }\n            let _ = exit_signal_tx.send(exit_result);\n        });\n\n        spawned.exit_signal = Some(exit_signal_rx);\n        Ok(spawned)\n    }\n}\n\npub async fn log_event_notification(\n    log_writer: &LogWriter,\n    event: EventMsg,\n) -> Result<(), ExecutorError> {\n    let event = match event {\n        EventMsg::SessionConfigured(mut configured) => {\n            configured.initial_messages = None;\n            EventMsg::SessionConfigured(configured)\n        }\n        other => other,\n    };\n    let notification = JSONRPCNotification {\n        method: \"codex/event\".to_string(),\n        params: Some(json!({ \"msg\": event })),\n    };\n    let raw = serde_json::to_string(&notification)\n        .map_err(|err| ExecutorError::Io(std::io::Error::other(err.to_string())))?;\n    log_writer.log_raw(&raw).await\n}\n\npub async fn log_event_raw(log_writer: &LogWriter, message: String) -> Result<(), ExecutorError> {\n    log_event_notification(\n        log_writer,\n        EventMsg::AgentMessage(AgentMessageEvent {\n            message,\n            phase: None,\n        }),\n    )\n    .await\n}\n\nasync fn fetch_status_message(\n    client: &AppServerClient,\n    thread_id: Option<&str>,\n    session_fast: bool,\n) -> Result<String, ExecutorError> {\n    let mut lines = vec![\"# Session Status\\n\".to_string()];\n\n    let rollout = match thread_id {\n        Some(tid) => read_rollout_data(tid).await,\n        None => None,\n    };\n\n    let config_resp = client.config_read(None).await.ok();\n\n    lines.push(\"## Configuration\".to_string());\n    if let Some(ctx) = rollout.as_ref().and_then(|r| r.turn_context.as_ref()) {\n        if let Some(model) = &ctx.model {\n            lines.push(format!(\"- **Model**: `{model}`\"));\n        }\n        if let Some(policy) = &ctx.approval_policy {\n            lines.push(format!(\"- **Approvals**: `{policy}`\"));\n        }\n        if let Some(sandbox) = &ctx.sandbox_policy {\n            let label = sandbox\n                .get(\"type\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"unknown\");\n            lines.push(format!(\"- **Sandbox**: `{label}`\"));\n        }\n        let effort = ctx.effort.as_deref().unwrap_or(\"default\");\n        let summary = ctx.summary.as_deref().unwrap_or(\"auto\");\n        lines.push(format!(\n            \"- **Reasoning**: effort: `{effort}` summary: `{summary}`\"\n        ));\n    } else if let Some(ref resp) = config_resp {\n        let cfg = &resp.config;\n        if let Some(model) = &cfg.model {\n            lines.push(format!(\"- **Model**: `{model}`\"));\n        }\n        if let Some(provider) = &cfg.model_provider {\n            lines.push(format!(\"- **Provider**: `{provider}`\"));\n        }\n        if let Some(policy) = &cfg.approval_policy {\n            lines.push(format!(\"- **Approvals**: `{policy:?}`\"));\n        }\n        if let Some(sandbox) = &cfg.sandbox_mode {\n            lines.push(format!(\"- **Sandbox**: `{sandbox:?}`\"));\n        }\n        if let Some(effort) = &cfg.model_reasoning_effort {\n            lines.push(format!(\"- **Reasoning effort**: `{effort}`\"));\n        }\n        if let Some(summary) = &cfg.model_reasoning_summary {\n            lines.push(format!(\"- **Reasoning summary**: `{summary:?}`\"));\n        }\n    } else {\n        lines.push(\"_Config unavailable_\".to_string());\n    }\n\n    // Show fast mode\n    let global_fast = config_resp\n        .as_ref()\n        .and_then(|r| r.config.service_tier.as_ref())\n        .map(|t| matches!(t, ServiceTier::Fast))\n        .unwrap_or(false);\n    if global_fast || session_fast {\n        lines.push(\"- **Service Tier**: `fast ⚡`\".to_string());\n    }\n\n    // Thread info\n    if let Some(thread_id) = thread_id {\n        lines.push(String::new());\n        lines.push(\"## Thread\".to_string());\n        match client.thread_read(thread_id.to_string()).await {\n            Ok(resp) => {\n                let thread = &resp.thread;\n                lines.push(format!(\"- **ID**: `{}`\", thread.id));\n                if let Some(name) = &thread.name {\n                    lines.push(format!(\"- **Name**: {name}\"));\n                }\n                lines.push(format!(\"- **CWD**: `{}`\", thread.cwd.display()));\n                lines.push(format!(\"- **CLI version**: `{}`\", thread.cli_version));\n                let source_label = format!(\"{:?}\", thread.source).replace(\"VsCode\", \"Vibe Kanban\");\n                lines.push(format!(\"- **Source**: `{source_label}`\"));\n            }\n            Err(err) => {\n                lines.push(format!(\"_Thread info unavailable: {err}_\"));\n            }\n        }\n    }\n\n    // Token usage (best-effort from rollout file)\n    if let Some(rollout) = &rollout {\n        lines.push(String::new());\n        lines.push(\"## Token Usage\".to_string());\n        if let Some(info) = &rollout.token_usage {\n            let total = &info.total_token_usage;\n            let last = &info.last_token_usage;\n            lines.push(format!(\"**Total**: `{}`\", total.total_tokens));\n            lines.push(format!(\n                \"  - Input: `{}` | Output: `{}` | Reasoning: `{}` | Cached: `{}`\",\n                total.input_tokens,\n                total.output_tokens,\n                total.reasoning_output_tokens,\n                total.cached_input_tokens,\n            ));\n            lines.push(format!(\"\\n**Last Turn**: `{}`\", last.total_tokens));\n            lines.push(format!(\n                \"  - Input: `{}` | Output: `{}` | Reasoning: `{}` | Cached: `{}`\",\n                last.input_tokens,\n                last.output_tokens,\n                last.reasoning_output_tokens,\n                last.cached_input_tokens,\n            ));\n            if let Some(window) = info.model_context_window {\n                lines.push(format!(\"\\n**Context Window**: `{window}`\"));\n            }\n        } else {\n            lines.push(\"_Token usage unavailable_\".to_string());\n        }\n    }\n\n    match client.get_account_rate_limits().await {\n        Ok(resp) => {\n            let rl = &resp.rate_limits;\n            lines.push(String::new());\n            lines.push(\"## Rate Limits\".to_string());\n            if let Some(plan) = &rl.plan_type {\n                lines.push(format!(\"- **Plan**: `{plan:?}`\"));\n            }\n            if let Some(primary) = &rl.primary {\n                lines.push(format!(\"- **Primary**: `{}%` used\", primary.used_percent));\n            }\n            if let Some(secondary) = &rl.secondary {\n                lines.push(format!(\n                    \"- **Secondary**: `{}%` used\",\n                    secondary.used_percent\n                ));\n            }\n            if let Some(credits) = &rl.credits {\n                let balance = credits.balance.as_deref().unwrap_or(if credits.unlimited {\n                    \"unlimited\"\n                } else {\n                    \"none\"\n                });\n                lines.push(format!(\"- **Credits**: `{balance}`\"));\n            }\n        }\n        Err(err) => {\n            tracing::debug!(\"rate limits unavailable: {err}\");\n        }\n    }\n\n    Ok(lines.join(\"\\n\"))\n}\n\n#[derive(serde::Deserialize)]\nstruct RolloutEntry {\n    #[serde(rename = \"type\")]\n    entry_type: String,\n    #[serde(default)]\n    payload: serde_json::Value,\n}\n\n#[derive(serde::Deserialize)]\nstruct TokenCountPayload {\n    info: Option<RolloutTokenUsageInfo>,\n}\n\n#[derive(serde::Deserialize)]\nstruct RolloutTokenUsageInfo {\n    total_token_usage: RolloutTokenUsage,\n    last_token_usage: RolloutTokenUsage,\n    model_context_window: Option<u64>,\n}\n\n#[derive(serde::Deserialize)]\nstruct RolloutTokenUsage {\n    input_tokens: u64,\n    cached_input_tokens: u64,\n    output_tokens: u64,\n    reasoning_output_tokens: u64,\n    total_tokens: u64,\n}\n\n#[derive(serde::Deserialize)]\nstruct TurnContextPayload {\n    model: Option<String>,\n    approval_policy: Option<String>,\n    sandbox_policy: Option<serde_json::Value>,\n    effort: Option<String>,\n    summary: Option<String>,\n}\n\nstruct RolloutData {\n    turn_context: Option<TurnContextPayload>,\n    token_usage: Option<RolloutTokenUsageInfo>,\n}\n\nasync fn read_rollout_data(session_id: &str) -> Option<RolloutData> {\n    let sessions_dir = codex_home()?.join(\"sessions\");\n    let rollout_path = find_rollout_file(&sessions_dir, session_id).await?;\n\n    let file = tokio::fs::File::open(&rollout_path).await.ok()?;\n    let reader = tokio::io::BufReader::new(file);\n\n    let mut last_turn_context: Option<TurnContextPayload> = None;\n    let mut last_token_usage: Option<RolloutTokenUsageInfo> = None;\n\n    use tokio::io::AsyncBufReadExt;\n    let mut lines = reader.lines();\n    while let Ok(Some(line)) = lines.next_line().await {\n        let entry: RolloutEntry = match serde_json::from_str(&line) {\n            Ok(e) => e,\n            Err(_) => continue,\n        };\n        match entry.entry_type.as_str() {\n            \"turn_context\" => {\n                if let Ok(ctx) = serde_json::from_value::<TurnContextPayload>(entry.payload) {\n                    last_turn_context = Some(ctx);\n                }\n            }\n            \"event_msg\" => {\n                if let Ok(tc) = serde_json::from_value::<TokenCountPayload>(entry.payload)\n                    && tc.info.is_some()\n                {\n                    last_token_usage = tc.info;\n                }\n            }\n            _ => {}\n        }\n    }\n\n    Some(RolloutData {\n        turn_context: last_turn_context,\n        token_usage: last_token_usage,\n    })\n}\n\nasync fn find_rollout_file(dir: &Path, session_id: &str) -> Option<PathBuf> {\n    let mut entries = tokio::fs::read_dir(dir).await.ok()?;\n    while let Ok(Some(entry)) = entries.next_entry().await {\n        let path = entry.path();\n        if path.is_dir() {\n            if let Some(found) = Box::pin(find_rollout_file(&path, session_id)).await {\n                return Some(found);\n            }\n        } else if let Some(name) = path.file_name().and_then(|n| n.to_str())\n            && name.starts_with(\"rollout-\")\n            && name.contains(session_id)\n            && name.ends_with(\".jsonl\")\n        {\n            return Some(path);\n        }\n    }\n    None\n}\n\nasync fn fetch_mcp_status_message(client: &AppServerClient) -> Result<String, ExecutorError> {\n    let mut cursor = None;\n    let mut servers = Vec::new();\n    loop {\n        let response = client.list_mcp_server_status(cursor).await?;\n        servers.extend(response.data);\n        cursor = response.next_cursor;\n        if cursor.is_none() {\n            break;\n        }\n    }\n    Ok(format_mcp_status(&servers))\n}\n\nfn format_mcp_status(servers: &[codex_app_server_protocol::McpServerStatus]) -> String {\n    if servers.is_empty() {\n        return \"_No MCP servers configured._\".to_string();\n    }\n    let mut lines = vec![format!(\"# MCP Servers ({})\\n\", servers.len())];\n    for server in servers {\n        let auth = format_mcp_auth_status(&server.auth_status);\n        lines.push(format!(\"## {}\", server.name));\n        lines.push(format!(\"- **Auth**: `{auth}`\"));\n\n        let mut tools: Vec<String> = server.tools.keys().cloned().collect();\n        tools.sort();\n        if tools.is_empty() {\n            lines.push(\"- **Tools**: _none_\".to_string());\n        } else {\n            lines.push(format!(\"- **Tools**: `{}`\", tools.join(\"`, `\")));\n        }\n\n        if !server.resources.is_empty() {\n            let mut names: Vec<String> = server\n                .resources\n                .iter()\n                .map(|res| res.name.clone())\n                .collect();\n            names.sort();\n            lines.push(format!(\"- **Resources**: `{}`\", names.join(\"`, `\")));\n        }\n\n        if !server.resource_templates.is_empty() {\n            let mut names: Vec<String> = server\n                .resource_templates\n                .iter()\n                .map(|template| template.name.clone())\n                .collect();\n            names.sort();\n            lines.push(format!(\n                \"- **Resource Templates**: `{}`\",\n                names.join(\"`, `\")\n            ));\n        }\n\n        lines.push(String::new()); // Empty line between servers\n    }\n    lines.join(\"\\n\")\n}\n\nfn format_mcp_auth_status(status: &codex_app_server_protocol::McpAuthStatus) -> &'static str {\n    match status {\n        codex_app_server_protocol::McpAuthStatus::Unsupported => \"unsupported\",\n        codex_app_server_protocol::McpAuthStatus::NotLoggedIn => \"not logged in\",\n        codex_app_server_protocol::McpAuthStatus::BearerToken => \"bearer token\",\n        codex_app_server_protocol::McpAuthStatus::OAuth => \"oauth\",\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/codex.rs",
    "content": "pub mod client;\npub mod jsonrpc;\npub mod normalize_logs;\npub mod review;\npub mod slash_commands;\nuse std::{\n    collections::HashMap,\n    env,\n    path::{Path, PathBuf},\n    str::FromStr,\n    sync::Arc,\n};\n\n/// Returns the Codex home directory.\n///\n/// Checks the `CODEX_HOME` environment variable first, then falls back to `~/.codex`.\n/// This allows users to configure a custom location for Codex configuration and state.\npub fn codex_home() -> Option<PathBuf> {\n    if let Ok(codex_home) = env::var(\"CODEX_HOME\")\n        && !codex_home.trim().is_empty()\n    {\n        return Some(PathBuf::from(codex_home));\n    }\n    dirs::home_dir().map(|home| home.join(\".codex\"))\n}\n\npub(crate) fn resolve_model(model: Option<&str>) -> (Option<&str>, bool) {\n    match model.and_then(|m| m.strip_suffix(\"-fast\")) {\n        Some(base) => (Some(base), true),\n        None => (model, false),\n    }\n}\n\npub(crate) fn fork_params_from(thread_id: String, params: ThreadStartParams) -> ThreadForkParams {\n    ThreadForkParams {\n        thread_id,\n        model: params.model,\n        model_provider: params.model_provider,\n        cwd: params.cwd,\n        approval_policy: params.approval_policy,\n        sandbox: params.sandbox,\n        config: params.config,\n        base_instructions: params.base_instructions,\n        developer_instructions: params.developer_instructions,\n        service_tier: params.service_tier,\n        ..Default::default()\n    }\n}\n\nuse async_trait::async_trait;\nuse codex_app_server_protocol::{\n    AskForApproval as V2AskForApproval, ReviewTarget, SandboxMode as V2SandboxMode,\n    ThreadForkParams, ThreadStartParams, UserInput,\n};\nuse codex_protocol::config_types::ServiceTier;\nuse derivative::Derivative;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse strum_macros::{AsRefStr, EnumString};\nuse tokio::process::Command;\nuse ts_rs::TS;\nuse workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore};\n\nuse self::{\n    client::{AppServerClient, LogWriter},\n    jsonrpc::{ExitSignalSender, JsonRpcPeer},\n    normalize_logs::{Error, normalize_logs},\n};\nuse crate::{\n    approvals::ExecutorApprovalService,\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, CommandParts, apply_overrides},\n    env::ExecutionEnv,\n    executor_discovery::ExecutorDiscoveredOptions,\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, ExecutorExitResult,\n        SlashCommandDescription, SpawnedChild, StandardCodingAgentExecutor,\n    },\n    logs::utils::patch,\n    model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy, ReasoningOption},\n    profile::ExecutorConfig,\n    stdout_dup::create_stdout_pipe_writer,\n};\n\n/// Sandbox policy modes for Codex\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)]\n#[serde(rename_all = \"kebab-case\")]\n#[strum(serialize_all = \"kebab-case\")]\npub enum SandboxMode {\n    Auto,\n    ReadOnly,\n    WorkspaceWrite,\n    DangerFullAccess,\n}\n\n/// Determines when the user is consulted to approve Codex actions.\n///\n/// - `UnlessTrusted`: Read-only commands are auto-approved. Everything else will\n///   ask the user to approve.\n/// - `OnFailure`: All commands run in a restricted sandbox initially. If a\n///   command fails, the user is asked to approve execution without the sandbox.\n/// - `OnRequest`: The model decides when to ask the user for approval.\n/// - `Never`: Commands never ask for approval. Commands that fail in the\n///   restricted sandbox are not retried.\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)]\n#[serde(rename_all = \"kebab-case\")]\n#[strum(serialize_all = \"kebab-case\")]\npub enum AskForApproval {\n    UnlessTrusted,\n    OnFailure,\n    OnRequest,\n    Never,\n}\n\n/// Reasoning effort for the underlying model\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr, EnumString)]\n#[serde(rename_all = \"kebab-case\")]\n#[strum(serialize_all = \"kebab-case\")]\npub enum ReasoningEffort {\n    Low,\n    Medium,\n    High,\n    Xhigh,\n}\n\n/// Model reasoning summary style\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)]\n#[serde(rename_all = \"kebab-case\")]\n#[strum(serialize_all = \"kebab-case\")]\npub enum ReasoningSummary {\n    Auto,\n    Concise,\n    Detailed,\n    None,\n}\n\n/// Format for model reasoning summaries\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)]\n#[serde(rename_all = \"kebab-case\")]\n#[strum(serialize_all = \"kebab-case\")]\npub enum ReasoningSummaryFormat {\n    None,\n    Experimental,\n}\n\nenum CodexSessionAction {\n    Chat { prompt: String },\n    Review { target: ReviewTarget },\n}\n\n#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]\n#[derivative(Debug, PartialEq)]\npub struct Codex {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub sandbox: Option<SandboxMode>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub ask_for_approval: Option<AskForApproval>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub oss: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model_reasoning_effort: Option<ReasoningEffort>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model_reasoning_summary: Option<ReasoningSummary>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model_reasoning_summary_format: Option<ReasoningSummaryFormat>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub profile: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub base_instructions: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub include_apply_patch_tool: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model_provider: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub compact_prompt: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub developer_instructions: Option<String>,\n    #[serde(default)]\n    pub plan: bool,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n\n    #[serde(skip)]\n    #[ts(skip)]\n    #[derivative(Debug = \"ignore\", PartialEq = \"ignore\")]\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for Codex {\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n        if let Some(reasoning_id) = &executor_config.reasoning_id\n            && let Ok(reasoning_effort) = ReasoningEffort::from_str(reasoning_id)\n        {\n            self.model_reasoning_effort = Some(reasoning_effort)\n        }\n        if let Some(permission_policy) = &executor_config.permission_policy {\n            match permission_policy {\n                crate::model_selector::PermissionPolicy::Auto => {\n                    self.ask_for_approval = Some(AskForApproval::Never);\n                    self.plan = false;\n                }\n                crate::model_selector::PermissionPolicy::Supervised => {\n                    if matches!(self.ask_for_approval, None | Some(AskForApproval::Never)) {\n                        self.ask_for_approval = Some(AskForApproval::UnlessTrusted);\n                    }\n                    self.plan = false;\n                }\n                crate::model_selector::PermissionPolicy::Plan => {\n                    self.plan = true;\n                }\n            }\n        }\n    }\n\n    fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {\n        self.approvals = Some(approvals);\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        self.spawn_slash_command(current_dir, prompt, None, env)\n            .await\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        self.spawn_slash_command(current_dir, prompt, Some(session_id), env)\n            .await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        worktree_path: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        normalize_logs(msg_store, worktree_path)\n    }\n\n    fn default_mcp_config_path(&self) -> Option<PathBuf> {\n        codex_home().map(|home| home.join(\"config.toml\"))\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        if let Some(timestamp) = codex_home()\n            .and_then(|home| std::fs::metadata(home.join(\"auth.json\")).ok())\n            .and_then(|m| m.modified().ok())\n            .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())\n            .map(|d| d.as_secs() as i64)\n        {\n            return AvailabilityInfo::LoginDetected {\n                last_auth_timestamp: timestamp,\n            };\n        }\n\n        let mcp_config_found = self\n            .default_mcp_config_path()\n            .map(|p| p.exists())\n            .unwrap_or(false);\n\n        let installation_indicator_found = codex_home()\n            .map(|home| home.join(\"version.json\").exists())\n            .unwrap_or(false);\n\n        if mcp_config_found || installation_indicator_found {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        use crate::model_selector::*;\n        let permission_policy = if self.plan {\n            PermissionPolicy::Plan\n        } else if matches!(self.ask_for_approval, None | Some(AskForApproval::Never)) {\n            PermissionPolicy::Auto\n        } else {\n            PermissionPolicy::Supervised\n        };\n\n        ExecutorConfig {\n            executor: BaseCodingAgent::Codex,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: None,\n            reasoning_id: self\n                .model_reasoning_effort\n                .as_ref()\n                .map(|e| e.as_ref().to_string()),\n            permission_policy: Some(permission_policy),\n        }\n    }\n\n    async fn discover_options(\n        &self,\n        _workdir: Option<&std::path::Path>,\n        _repo_path: Option<&std::path::Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        let xhigh_reasoning_options = ReasoningOption::from_names(\n            [\n                ReasoningEffort::Low,\n                ReasoningEffort::Medium,\n                ReasoningEffort::High,\n                ReasoningEffort::Xhigh,\n            ]\n            .map(|e| e.as_ref().to_string()),\n        );\n\n        let options = ExecutorDiscoveredOptions {\n            model_selector: ModelSelectorConfig {\n                models: vec![\n                    ModelInfo {\n                        id: \"gpt-5.4\".to_string(),\n                        name: \"GPT-5.4\".to_string(),\n                        provider_id: None,\n                        reasoning_options: xhigh_reasoning_options.clone(),\n                    },\n                    ModelInfo {\n                        id: \"gpt-5.4-fast\".to_string(),\n                        name: \"GPT-5.4 Fast\".to_string(),\n                        provider_id: None,\n                        reasoning_options: xhigh_reasoning_options.clone(),\n                    },\n                    ModelInfo {\n                        id: \"gpt-5.3-codex\".to_string(),\n                        name: \"GPT-5.3 Codex\".to_string(),\n                        provider_id: None,\n                        reasoning_options: xhigh_reasoning_options.clone(),\n                    },\n                    ModelInfo {\n                        id: \"gpt-5.2-codex\".to_string(),\n                        name: \"GPT-5.2 Codex\".to_string(),\n                        provider_id: None,\n                        reasoning_options: xhigh_reasoning_options.clone(),\n                    },\n                    ModelInfo {\n                        id: \"gpt-5.2\".to_string(),\n                        name: \"GPT-5.2\".to_string(),\n                        provider_id: None,\n                        reasoning_options: xhigh_reasoning_options.clone(),\n                    },\n                    ModelInfo {\n                        id: \"gpt-5.1-codex-max\".to_string(),\n                        name: \"GPT-5.1 Codex Max\".to_string(),\n                        provider_id: None,\n                        reasoning_options: xhigh_reasoning_options,\n                    },\n                ],\n                permissions: vec![\n                    PermissionPolicy::Auto,\n                    PermissionPolicy::Supervised,\n                    PermissionPolicy::Plan,\n                ],\n                ..Default::default()\n            },\n            slash_commands: vec![\n                SlashCommandDescription {\n                    name: \"compact\".to_string(),\n                    description: Some(\n                        \"summarize conversation to prevent hitting the context limit\".to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"init\".to_string(),\n                    description: Some(\n                        \"create an AGENTS.md file with instructions for Codex\".to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"status\".to_string(),\n                    description: Some(\n                        \"show current session configuration and token usage\".to_string(),\n                    ),\n                },\n                SlashCommandDescription {\n                    name: \"mcp\".to_string(),\n                    description: Some(\"list configured MCP tools\".to_string()),\n                },\n                SlashCommandDescription {\n                    name: \"fast\".to_string(),\n                    description: Some(\n                        \"toggle fast mode for highest speed inference (2× plan usage). Use `/fast on` or `/fast off` to set explicitly\".to_string(),\n                    ),\n                },\n            ],\n            ..Default::default()\n        };\n        Ok(Box::pin(futures::stream::once(async move {\n            patch::executor_discovered_options(options)\n        })))\n    }\n\n    async fn spawn_review(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let command_parts = self.build_command_builder()?.build_initial()?;\n        let review_target = ReviewTarget::Custom {\n            instructions: prompt.to_string(),\n        };\n        let action = CodexSessionAction::Review {\n            target: review_target,\n        };\n        self.spawn_inner(current_dir, command_parts, action, session_id, env)\n            .await\n    }\n}\n\nimpl Codex {\n    pub fn base_command() -> &'static str {\n        \"npx -y @openai/codex@0.114.0\"\n    }\n\n    fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        let mut builder = CommandBuilder::new(Self::base_command());\n        builder = builder.extend_params([\"app-server\"]);\n        if self.oss.unwrap_or(false) {\n            builder = builder.extend_params([\"--oss\"]);\n        }\n\n        apply_overrides(builder, &self.cmd)\n    }\n\n    fn build_thread_start_params(&self, cwd: &Path) -> ThreadStartParams {\n        let sandbox = match self.sandbox.as_ref() {\n            None | Some(SandboxMode::Auto) => Some(V2SandboxMode::WorkspaceWrite), // match the Auto preset in codex\n            Some(SandboxMode::ReadOnly) => Some(V2SandboxMode::ReadOnly),\n            Some(SandboxMode::WorkspaceWrite) => Some(V2SandboxMode::WorkspaceWrite),\n            Some(SandboxMode::DangerFullAccess) => Some(V2SandboxMode::DangerFullAccess),\n        };\n\n        let approval_policy = match self.ask_for_approval.as_ref() {\n            None if matches!(self.sandbox.as_ref(), None | Some(SandboxMode::Auto)) => {\n                // match the Auto preset in codex\n                Some(V2AskForApproval::OnRequest)\n            }\n            None => None,\n            Some(AskForApproval::UnlessTrusted) => Some(V2AskForApproval::UnlessTrusted),\n            Some(AskForApproval::OnFailure) => Some(V2AskForApproval::OnFailure),\n            Some(AskForApproval::OnRequest) => Some(V2AskForApproval::OnRequest),\n            Some(AskForApproval::Never) => Some(V2AskForApproval::Never),\n        };\n\n        let mut config = self.build_config_overrides();\n        // V1 top-level params that moved into config overrides in v2\n        if let Some(profile) = &self.profile {\n            config\n                .get_or_insert_with(HashMap::new)\n                .insert(\"profile\".to_string(), Value::String(profile.clone()));\n        }\n        if let Some(include) = self.include_apply_patch_tool {\n            config\n                .get_or_insert_with(HashMap::new)\n                .insert(\"include_apply_patch_tool\".to_string(), Value::Bool(include));\n        }\n        if let Some(compact) = &self.compact_prompt {\n            config\n                .get_or_insert_with(HashMap::new)\n                .insert(\"compact_prompt\".to_string(), Value::String(compact.clone()));\n        }\n        if !matches!(approval_policy, None | Some(V2AskForApproval::Never)) {\n            let map = config.get_or_insert_with(HashMap::new);\n            map.insert(\n                \"features.default_mode_request_user_input\".to_string(),\n                Value::Bool(true),\n            );\n            map.insert(\n                \"suppress_unstable_features_warning\".to_string(),\n                Value::Bool(true),\n            );\n        }\n\n        let (model, is_fast) = resolve_model(self.model.as_deref());\n        let service_tier = if is_fast {\n            Some(Some(ServiceTier::Fast))\n        } else {\n            None\n        };\n\n        ThreadStartParams {\n            model: model.map(|m| m.to_string()),\n            cwd: Some(cwd.to_string_lossy().to_string()),\n            approval_policy,\n            sandbox,\n            config,\n            base_instructions: self.base_instructions.clone(),\n            model_provider: self.model_provider.clone(),\n            developer_instructions: self.developer_instructions.clone(),\n            service_tier,\n            ..Default::default()\n        }\n    }\n\n    fn build_config_overrides(&self) -> Option<HashMap<String, Value>> {\n        let mut overrides = HashMap::new();\n\n        if let Some(effort) = &self.model_reasoning_effort {\n            overrides.insert(\n                \"model_reasoning_effort\".to_string(),\n                Value::String(effort.as_ref().to_string()),\n            );\n        }\n\n        if let Some(summary) = &self.model_reasoning_summary {\n            overrides.insert(\n                \"model_reasoning_summary\".to_string(),\n                Value::String(summary.as_ref().to_string()),\n            );\n        }\n\n        if let Some(format) = &self.model_reasoning_summary_format\n            && format != &ReasoningSummaryFormat::None\n        {\n            overrides.insert(\n                \"model_reasoning_summary_format\".to_string(),\n                Value::String(format.as_ref().to_string()),\n            );\n        }\n\n        if overrides.is_empty() {\n            None\n        } else {\n            Some(overrides)\n        }\n    }\n\n    async fn spawn_inner(\n        &self,\n        current_dir: &Path,\n        command_parts: CommandParts,\n        action: CodexSessionAction,\n        resume_session: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let params = self.build_thread_start_params(current_dir);\n        let resume_session = resume_session.map(|s| s.to_string());\n\n        self.spawn_app_server(\n            current_dir,\n            command_parts,\n            env,\n            move |client, _| async move {\n                match action {\n                    CodexSessionAction::Chat { prompt } => {\n                        Self::launch_codex_agent(params, resume_session, prompt, client).await\n                    }\n                    CodexSessionAction::Review { target } => {\n                        review::launch_codex_review(params, resume_session, target, client).await\n                    }\n                }\n            },\n        )\n        .await\n    }\n\n    async fn launch_codex_agent(\n        thread_start_params: ThreadStartParams,\n        resume_session: Option<String>,\n        combined_prompt: String,\n        client: Arc<AppServerClient>,\n    ) -> Result<(), ExecutorError> {\n        let account = client.get_account().await?;\n        if account.requires_openai_auth && account.account.is_none() {\n            return Err(ExecutorError::AuthRequired(\n                \"Codex authentication required\".to_string(),\n            ));\n        }\n\n        let (thread_id, resolved_model) = match resume_session {\n            None => {\n                let response = client.thread_start(thread_start_params).await?;\n                (response.thread.id, response.model)\n            }\n            Some(session_id) => {\n                let response = client\n                    .thread_fork(fork_params_from(session_id, thread_start_params))\n                    .await?;\n                tracing::debug!(\"forked thread, new thread_id={}\", response.thread.id);\n                (response.thread.id, response.model)\n            }\n        };\n\n        client.set_resolved_model(resolved_model);\n        client.register_session(&thread_id).await?;\n        let collaboration_mode = client.initial_collaboration_mode()?;\n        client\n            .turn_start_with_mode(\n                thread_id,\n                vec![UserInput::Text {\n                    text: combined_prompt,\n                    text_elements: vec![],\n                }],\n                Some(collaboration_mode),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    /// Common boilerplate for spawning a Codex app server process\n    /// Handles process spawning, stdout/stderr piping, exit signal handling, client initialization, and error logging.\n    /// Delegates the actual Codex session logic to the provided `task` closure.\n    async fn spawn_app_server<F, Fut>(\n        &self,\n        current_dir: &Path,\n        command_parts: CommandParts,\n        env: &ExecutionEnv,\n        task: F,\n    ) -> Result<SpawnedChild, ExecutorError>\n    where\n        F: FnOnce(Arc<AppServerClient>, ExitSignalSender) -> Fut + Send + 'static,\n        Fut: std::future::Future<Output = Result<(), ExecutorError>> + Send + 'static,\n    {\n        let (program_path, args) = command_parts.into_resolved().await?;\n\n        let mut process = Command::new(program_path);\n        process\n            .kill_on_drop(true)\n            .stdin(std::process::Stdio::piped())\n            .stdout(std::process::Stdio::piped())\n            .stderr(std::process::Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .env(\"NODE_NO_WARNINGS\", \"1\")\n            .env(\"NO_COLOR\", \"1\")\n            .env(\"RUST_LOG\", \"error\")\n            .args(&args);\n\n        env.clone()\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut process);\n\n        let mut child = process.group_spawn_no_window()?;\n\n        let child_stdout = child.inner().stdout.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::other(\"Codex app server missing stdout\"))\n        })?;\n        let child_stdin = child.inner().stdin.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::other(\"Codex app server missing stdin\"))\n        })?;\n\n        let new_stdout = create_stdout_pipe_writer(&mut child)?;\n        let (exit_signal_tx, exit_signal_rx) = tokio::sync::oneshot::channel();\n        let cancel = tokio_util::sync::CancellationToken::new();\n\n        let auto_approve = matches!(\n            (&self.sandbox, &self.ask_for_approval),\n            (Some(SandboxMode::DangerFullAccess), None)\n        );\n        let plan_mode = self.plan;\n        let approvals = self.approvals.clone();\n        let repo_context = env.repo_context.clone();\n        let commit_reminder = env.commit_reminder;\n        let commit_reminder_prompt = env.commit_reminder_prompt.clone();\n        let cancel_for_task = cancel.clone();\n\n        tokio::spawn(async move {\n            let exit_signal_tx = ExitSignalSender::new(exit_signal_tx);\n            let log_writer = LogWriter::new(new_stdout);\n\n            // Initialize the AppServerClient\n            let client = AppServerClient::new(\n                log_writer.clone(),\n                approvals,\n                auto_approve,\n                plan_mode,\n                repo_context,\n                commit_reminder,\n                commit_reminder_prompt,\n                cancel_for_task.clone(),\n            );\n            let rpc_peer = JsonRpcPeer::spawn(\n                child_stdin,\n                child_stdout,\n                client.clone(),\n                exit_signal_tx.clone(),\n                cancel_for_task,\n            );\n            client.connect(rpc_peer);\n\n            let result = async {\n                client.initialize().await?;\n                task(client, exit_signal_tx.clone()).await\n            }\n            .await;\n\n            if let Err(err) = result {\n                match &err {\n                    ExecutorError::Io(io_err)\n                        if io_err.kind() == std::io::ErrorKind::BrokenPipe =>\n                    {\n                        // Broken pipe likely means the parent process exited, so we can ignore it\n                        return;\n                    }\n                    ExecutorError::AuthRequired(message) => {\n                        log_writer\n                            .log_raw(&Error::auth_required(message.clone()).raw())\n                            .await\n                            .ok();\n                        exit_signal_tx\n                            .send_exit_signal(ExecutorExitResult::Failure)\n                            .await;\n                        return;\n                    }\n                    _ => {\n                        tracing::error!(\"Codex spawn error: {}\", err);\n                        log_writer\n                            .log_raw(&Error::launch_error(err.to_string()).raw())\n                            .await\n                            .ok();\n                    }\n                }\n                exit_signal_tx\n                    .send_exit_signal(ExecutorExitResult::Failure)\n                    .await;\n            }\n        });\n\n        Ok(SpawnedChild {\n            child,\n            exit_signal: Some(exit_signal_rx),\n            cancel: Some(cancel),\n        })\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/copilot.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse derivative::Derivative;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse workspace_utils::msg_store::MsgStore;\n\npub use super::acp::AcpAgentHarness;\nuse crate::{\n    approvals::ExecutorApprovalService,\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides},\n    env::ExecutionEnv,\n    executor_discovery::ExecutorDiscoveredOptions,\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild,\n        StandardCodingAgentExecutor,\n    },\n    logs::utils::patch,\n    model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy},\n    profile::ExecutorConfig,\n};\n\n#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]\n#[derivative(Debug, PartialEq)]\npub struct Copilot {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub allow_all_tools: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub allow_tool: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub deny_tool: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub add_dir: Option<Vec<String>>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub disable_mcp_server: Option<Vec<String>>,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n    #[serde(skip)]\n    #[ts(skip)]\n    #[derivative(Debug = \"ignore\", PartialEq = \"ignore\")]\n    pub approvals: Option<Arc<dyn ExecutorApprovalService>>,\n}\n\nimpl Copilot {\n    fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        let mut builder = CommandBuilder::new(\"npx -y @github/copilot@0.0.403\");\n\n        if self.allow_all_tools.unwrap_or(false) {\n            builder = builder.extend_params([\"--allow-all-tools\"]);\n        }\n\n        if let Some(model) = &self.model {\n            builder = builder.extend_params([\"--model\", model]);\n        }\n\n        if let Some(tool) = &self.allow_tool {\n            builder = builder.extend_params([\"--allow-tool\", tool]);\n        }\n\n        if let Some(tool) = &self.deny_tool {\n            builder = builder.extend_params([\"--deny-tool\", tool]);\n        }\n\n        if let Some(dirs) = &self.add_dir {\n            for dir in dirs {\n                builder = builder.extend_params([\"--add-dir\", dir]);\n            }\n        }\n\n        if let Some(servers) = &self.disable_mcp_server {\n            for server in servers {\n                builder = builder.extend_params([\"--disable-mcp-server\", server]);\n            }\n        }\n\n        builder = builder.extend_params([\"--acp\"]);\n\n        apply_overrides(builder, &self.cmd)\n    }\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for Copilot {\n    fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {\n        self.approvals = Some(approvals);\n    }\n\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n\n        if let Some(permission_policy) = &executor_config.permission_policy {\n            self.allow_all_tools = Some(matches!(\n                permission_policy,\n                crate::model_selector::PermissionPolicy::Auto\n            ));\n        }\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let harness = AcpAgentHarness::new();\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n        let copilot_command = self.build_command_builder()?.build_initial()?;\n        harness\n            .spawn_with_command(\n                current_dir,\n                combined_prompt,\n                copilot_command,\n                env,\n                &self.cmd,\n                self.approvals.clone(),\n            )\n            .await\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let harness = AcpAgentHarness::new();\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n        let copilot_command = self.build_command_builder()?.build_follow_up(&[])?;\n        harness\n            .spawn_follow_up_with_command(\n                current_dir,\n                combined_prompt,\n                session_id,\n                copilot_command,\n                env,\n                &self.cmd,\n                self.approvals.clone(),\n            )\n            .await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        worktree_path: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        super::acp::normalize_logs(msg_store, worktree_path)\n    }\n\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        dirs::home_dir().map(|home| home.join(\".copilot\").join(\"mcp-config.json\"))\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        let mcp_config_found = self\n            .default_mcp_config_path()\n            .map(|p| p.exists())\n            .unwrap_or(false);\n\n        let installation_indicator_found = dirs::home_dir()\n            .map(|home| home.join(\".copilot\").join(\"config.json\").exists())\n            .unwrap_or(false);\n\n        if mcp_config_found || installation_indicator_found {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        ExecutorConfig {\n            executor: BaseCodingAgent::Copilot,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: None,\n            reasoning_id: None,\n            permission_policy: Some(crate::model_selector::PermissionPolicy::Auto),\n        }\n    }\n\n    async fn discover_options(\n        &self,\n        _workdir: Option<&std::path::Path>,\n        _repo_path: Option<&std::path::Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        let options = ExecutorDiscoveredOptions {\n            model_selector: ModelSelectorConfig {\n                models: [\n                    (\"gpt-5.4\", \"GPT-5.4\"),\n                    (\"claude-opus-4.6\", \"Claude Opus 4.6\"),\n                    (\"claude-opus-4.6-fast\", \"Claude Opus 4.6 Fast\"),\n                    (\"gpt-5.3-codex\", \"GPT-5.3 Codex\"),\n                    (\"claude-sonnet-4.6\", \"Claude Sonnet 4.6\"),\n                    (\"claude-haiku-4.5\", \"Claude Haiku 4.5\"),\n                    (\"gemini-3-pro-preview\", \"Gemini 3 Pro Preview\"),\n                    (\"gpt-5.2-codex\", \"GPT-5.2 Codex\"),\n                    (\"gpt-5.2\", \"GPT-5.2\"),\n                    (\"gpt-5.1-codex-max\", \"GPT-5.1 Codex Max\"),\n                    (\"gpt-5.1-codex\", \"GPT-5.1 Codex\"),\n                    (\"gpt-5.1\", \"GPT-5.1\"),\n                    (\"gpt-5.1-codex-mini\", \"GPT-5.1 Codex Mini\"),\n                    (\"gpt-5-mini\", \"GPT-5 Mini\"),\n                    (\"gpt-4.1\", \"GPT-4.1\"),\n                    (\"claude-opus-4.5\", \"Claude Opus 4.5\"),\n                    (\"claude-sonnet-4.5\", \"Claude Sonnet 4.5\"),\n                    (\"claude-sonnet-4\", \"Claude Sonnet 4\"),\n                ]\n                .into_iter()\n                .map(|(id, name)| ModelInfo {\n                    id: id.to_string(),\n                    name: name.to_string(),\n                    provider_id: None,\n                    reasoning_options: vec![],\n                })\n                .collect(),\n                permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised],\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        Ok(Box::pin(futures::stream::once(async move {\n            patch::executor_discovered_options(options)\n        })))\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/cursor/mcp.rs",
    "content": "use std::{collections::HashSet, env, io::ErrorKind, path::Path};\n\nuse sha2::{Digest, Sha256};\nuse tokio::fs;\nuse tracing::warn;\n\nuse super::CursorAgent;\nuse crate::executors::{CodingAgent, ExecutorError, StandardCodingAgentExecutor};\n\npub async fn ensure_mcp_server_trust(cursor: &CursorAgent, current_dir: &Path) {\n    if let Err(err) = ensure_mcp_server_trust_impl(cursor, current_dir).await {\n        tracing::warn!(\n            error = %err,\n            \"Cursor MCP approval bootstrap failed. MCP servers might be unavailable.\"\n        );\n    }\n}\n\nasync fn ensure_mcp_server_trust_impl(\n    cursor: &CursorAgent,\n    current_dir: &Path,\n) -> Result<(), ExecutorError> {\n    let current_dir =\n        std::fs::canonicalize(current_dir).unwrap_or_else(|_| current_dir.to_path_buf());\n\n    let Some(config_path) = cursor.default_mcp_config_path() else {\n        return Ok(());\n    };\n\n    let Some(home_dir) = dirs::home_dir() else {\n        return Ok(());\n    };\n\n    let absolute_path = if current_dir.is_absolute() {\n        current_dir.to_path_buf()\n    } else {\n        match env::current_dir() {\n            Ok(cwd) => cwd.join(current_dir),\n            Err(_) => current_dir.to_path_buf(),\n        }\n    };\n\n    let worktree_path_str = absolute_path.to_string_lossy().to_string();\n    if worktree_path_str.is_empty() {\n        return Ok(());\n    }\n\n    let Some(project_slug) = cursor_project_slug(&absolute_path) else {\n        return Ok(());\n    };\n\n    let config_value: serde_json::Value = match fs::read_to_string(&config_path).await {\n        Ok(content) => match serde_json::from_str(&content) {\n            Ok(val) => val,\n            Err(err) => {\n                warn!(\n                    error = ?err,\n                    path = %config_path.display(),\n                    \"Failed to parse Cursor MCP config; falling back to defaults for auto-approval bootstrap\"\n                );\n                default_cursor_mcp_servers(cursor)\n            }\n        },\n        Err(err) if err.kind() == ErrorKind::NotFound => default_cursor_mcp_servers(cursor),\n        Err(err) => return Err(ExecutorError::Io(err)),\n    };\n\n    let Some(servers) = config_value\n        .get(\"mcpServers\")\n        .and_then(|value| value.as_object())\n    else {\n        return Ok(());\n    };\n\n    let approvals_path = home_dir\n        .join(\".cursor\")\n        .join(\"projects\")\n        .join(&project_slug)\n        .join(\"mcp-approvals.json\");\n\n    let mut existing: Vec<String> = match fs::read_to_string(&approvals_path).await {\n        Ok(content) => match serde_json::from_str(&content) {\n            Ok(list) => list,\n            Err(err) => {\n                warn!(\n                    error = ?err,\n                    path = %approvals_path.display(),\n                    \"Failed to parse existing Cursor MCP approvals; resetting file\"\n                );\n                Vec::new()\n            }\n        },\n        Err(err) if err.kind() == ErrorKind::NotFound => Vec::new(),\n        Err(err) => return Err(ExecutorError::Io(err)),\n    };\n\n    let mut approvals_set: HashSet<String> = existing.iter().cloned().collect();\n    let mut newly_added = Vec::new();\n\n    for (server_name, definition) in servers {\n        if server_name == \"meta\" || !definition.is_object() {\n            continue;\n        }\n\n        if let Some(approval_id) =\n            compute_cursor_approval_id(server_name, definition, &worktree_path_str)\n            && approvals_set.insert(approval_id.clone())\n        {\n            newly_added.push(approval_id);\n        }\n    }\n\n    if newly_added.is_empty() {\n        return Ok(());\n    }\n\n    existing.extend(newly_added);\n\n    if let Some(parent) = approvals_path.parent() {\n        fs::create_dir_all(parent)\n            .await\n            .map_err(ExecutorError::Io)?;\n    }\n\n    let serialized = serde_json::to_string_pretty(&existing)?;\n    fs::write(&approvals_path, serialized)\n        .await\n        .map_err(ExecutorError::Io)?;\n\n    Ok(())\n}\n\nfn cursor_project_slug(path: &Path) -> Option<String> {\n    let raw = path.to_string_lossy();\n    if raw.is_empty() {\n        return None;\n    }\n\n    let slug = regex::Regex::new(r\"[^A-Za-z0-9]+\")\n        .unwrap()\n        .replace_all(&raw, \"-\")\n        .trim_matches('-')\n        .to_string();\n\n    if slug.is_empty() { None } else { Some(slug) }\n}\n\nfn compute_cursor_approval_id(\n    server_name: &str,\n    definition: &serde_json::Value,\n    worktree_path: &str,\n) -> Option<String> {\n    let payload = serde_json::json!({\n        \"path\": worktree_path,\n        \"server\": definition,\n    });\n\n    let serialized = serde_json::to_string(&payload).ok()?;\n    let mut hasher = Sha256::new();\n    hasher.update(serialized.as_bytes());\n    let digest = hasher.finalize();\n    let hex = digest\n        .iter()\n        .map(|byte| format!(\"{byte:02x}\"))\n        .collect::<String>();\n    Some(format!(\"{server_name}-{}\", &hex[..16]))\n}\n\nfn default_cursor_mcp_servers(cursor: &CursorAgent) -> serde_json::Value {\n    let mcpc = CodingAgent::CursorAgent(cursor.clone()).get_mcp_config();\n    serde_json::json!({ \"mcpServers\": mcpc.preconfigured })\n}\n"
  },
  {
    "path": "crates/executors/src/executors/cursor.rs",
    "content": "use core::str;\nuse std::{collections::HashMap, path::Path, process::Stdio, sync::Arc, time::Duration};\n\nuse async_trait::async_trait;\nuse futures::StreamExt;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse tokio::{io::AsyncWriteExt, process::Command};\nuse ts_rs::TS;\nuse workspace_utils::{\n    command_ext::GroupSpawnNoWindowExt,\n    diff::{create_unified_diff, normalize_unified_diff},\n    msg_store::MsgStore,\n    path::make_path_relative,\n    shell::resolve_executable_path_blocking,\n};\n\nuse crate::{\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides},\n    env::ExecutionEnv,\n    executor_discovery::ExecutorDiscoveredOptions,\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild,\n        StandardCodingAgentExecutor,\n    },\n    logs::{\n        ActionType, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType,\n        TodoItem, ToolStatus,\n        plain_text_processor::PlainTextLogProcessor,\n        utils::{\n            ConversationPatch, EntryIndexProvider, patch, shell_command_parsing::CommandCategory,\n        },\n    },\n    model_selector::{ModelInfo, ModelSelectorConfig, ReasoningOption},\n    profile::ExecutorConfig,\n};\n\nmod mcp;\nconst CURSOR_AUTH_REQUIRED_MSG: &str = \"Authentication required. Please run 'cursor-agent login' first, or set CURSOR_API_KEY environment variable.\";\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]\npub struct CursorAgent {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    #[schemars(description = \"Force allow commands unless explicitly denied\")]\n    pub force: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    #[schemars(\n        description = \"auto, opus-4.6, sonnet-4.6, gpt-5.4, gpt-5.4-fast, gpt-5.3-codex, gpt-5.3-codex-fast, gpt-5.3-codex-spark-preview, gpt-5.2, gpt-5.2-codex, gpt-5.2-codex-fast, gpt-5.1, gpt-5.1-codex-max, gpt-5.1-codex-mini, grok, kimi-k2.5, gemini-3.1-pro, gemini-3-pro, gemini-3-flash, opus-4.5, sonnet-4.5, composer-1.5, composer-1\"\n    )]\n    pub model: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub reasoning: Option<String>,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n}\n\n// get the model full name\nfn resolve_cursor_model_name<'a>(base_model: &'a str, reasoning: Option<&'a str>) -> &'a str {\n    match (base_model, reasoning) {\n        (\"gpt-5.4\", Some(\"medium\")) => \"gpt-5.4-medium\",\n        (\"gpt-5.4\", Some(\"high\") | None) => \"gpt-5.4-high\",\n        (\"gpt-5.4\", Some(\"xhigh\")) => \"gpt-5.4-xhigh\",\n\n        (\"gpt-5.4-fast\", Some(\"medium\")) => \"gpt-5.4-medium-fast\",\n        (\"gpt-5.4-fast\", Some(\"high\") | None) => \"gpt-5.4-high-fast\",\n        (\"gpt-5.4-fast\", Some(\"xhigh\")) => \"gpt-5.4-xhigh-fast\",\n\n        (\"gpt-5.3-codex\", Some(\"low\")) => \"gpt-5.3-codex-low\",\n        (\"gpt-5.3-codex\", Some(\"medium\")) => \"gpt-5.3-codex\",\n        (\"gpt-5.3-codex\", Some(\"high\") | None) => \"gpt-5.3-codex-high\",\n        (\"gpt-5.3-codex\", Some(\"xhigh\")) => \"gpt-5.3-codex-xhigh\",\n\n        (\"gpt-5.3-codex-fast\", Some(\"low\")) => \"gpt-5.3-codex-low-fast\",\n        (\"gpt-5.3-codex-fast\", Some(\"medium\")) => \"gpt-5.3-codex-fast\",\n        (\"gpt-5.3-codex-fast\", Some(\"high\") | None) => \"gpt-5.3-codex-high-fast\",\n        (\"gpt-5.3-codex-fast\", Some(\"xhigh\")) => \"gpt-5.3-codex-xhigh-fast\",\n\n        (\"gpt-5.2-codex\", Some(\"low\")) => \"gpt-5.2-codex-low\",\n        (\"gpt-5.2-codex\", Some(\"medium\")) => \"gpt-5.2-codex\",\n        (\"gpt-5.2-codex\", Some(\"high\") | None) => \"gpt-5.2-codex-high\",\n        (\"gpt-5.2-codex\", Some(\"xhigh\")) => \"gpt-5.2-codex-xhigh\",\n\n        (\"gpt-5.2-codex-fast\", Some(\"low\")) => \"gpt-5.2-codex-low-fast\",\n        (\"gpt-5.2-codex-fast\", Some(\"medium\")) => \"gpt-5.2-codex-fast\",\n        (\"gpt-5.2-codex-fast\", Some(\"high\") | None) => \"gpt-5.2-codex-high-fast\",\n        (\"gpt-5.2-codex-fast\", Some(\"xhigh\")) => \"gpt-5.2-codex-xhigh-fast\",\n\n        (\"gpt-5.2\", Some(\"medium\")) => \"gpt-5.2\",\n        (\"gpt-5.2\", Some(\"high\") | None) => \"gpt-5.2-high\",\n\n        (\"gpt-5.1-codex-max\", Some(\"medium\")) => \"gpt-5.1-codex-max\",\n        (\"gpt-5.1-codex-max\", Some(\"high\") | None) => \"gpt-5.1-codex-max-high\",\n\n        (\"gpt-5.1\", Some(\"medium\")) => \"gpt-5.1\",\n        (\"gpt-5.1\", Some(\"high\") | None) => \"gpt-5.1-high\",\n\n        (\"opus-4.6\", Some(\"standard\")) => \"opus-4.6\",\n        (\"opus-4.6\", Some(\"thinking\") | None) => \"opus-4.6-thinking\",\n        (\"sonnet-4.6\", Some(\"standard\")) => \"sonnet-4.6\",\n        (\"sonnet-4.6\", Some(\"thinking\") | None) => \"sonnet-4.6-thinking\",\n        (\"opus-4.5\", Some(\"standard\")) => \"opus-4.5\",\n        (\"opus-4.5\", Some(\"thinking\") | None) => \"opus-4.5-thinking\",\n        (\"sonnet-4.5\", Some(\"standard\")) => \"sonnet-4.5\",\n        (\"sonnet-4.5\", Some(\"thinking\") | None) => \"sonnet-4.5-thinking\",\n\n        _ => base_model,\n    }\n}\n\nfn cursor_reasoning_options(base_model: &str) -> Vec<ReasoningOption> {\n    match base_model {\n        \"gpt-5.4\" | \"gpt-5.4-fast\" => {\n            ReasoningOption::from_names([\"medium\", \"high\", \"xhigh\"].map(String::from))\n        }\n        \"gpt-5.3-codex\" | \"gpt-5.3-codex-fast\" | \"gpt-5.2-codex\" | \"gpt-5.2-codex-fast\" => {\n            ReasoningOption::from_names([\"low\", \"medium\", \"high\", \"xhigh\"].map(String::from))\n        }\n        \"gpt-5.2\" | \"gpt-5.1-codex-max\" | \"gpt-5.1\" => {\n            ReasoningOption::from_names([\"medium\", \"high\"].map(String::from))\n        }\n        \"opus-4.6\" | \"sonnet-4.6\" | \"opus-4.5\" | \"sonnet-4.5\" => vec![\n            ReasoningOption {\n                id: \"standard\".to_string(),\n                label: \"Standard\".to_string(),\n                is_default: false,\n            },\n            ReasoningOption {\n                id: \"thinking\".to_string(),\n                label: \"Thinking\".to_string(),\n                is_default: true,\n            },\n        ],\n        _ => vec![],\n    }\n}\n\nimpl CursorAgent {\n    pub fn base_command() -> &'static str {\n        \"cursor-agent\"\n    }\n\n    fn resolved_model(&self) -> Option<&str> {\n        self.model\n            .as_deref()\n            .map(|base| resolve_cursor_model_name(base, self.reasoning.as_deref()))\n    }\n\n    fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        let mut builder =\n            CommandBuilder::new(Self::base_command()).params([\"-p\", \"--output-format=stream-json\"]);\n\n        if self.force.unwrap_or(false) {\n            builder = builder.extend_params([\"--force\"]);\n        } else {\n            // trusting the current directory is a minimum requirement for cursor to run\n            builder = builder.extend_params([\"--trust\"]);\n        }\n\n        if let Some(model) = self.resolved_model() {\n            builder = builder.extend_params([\"--model\", model]);\n        }\n\n        apply_overrides(builder, &self.cmd)\n    }\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for CursorAgent {\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n        if let Some(reasoning_id) = &executor_config.reasoning_id {\n            self.reasoning = Some(reasoning_id.clone());\n        }\n        if let Some(permission_policy) = executor_config.permission_policy.clone() {\n            self.force = Some(matches!(\n                permission_policy,\n                crate::model_selector::PermissionPolicy::Auto\n            ));\n        }\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        mcp::ensure_mcp_server_trust(self, current_dir).await;\n\n        let command_parts = self.build_command_builder()?.build_initial()?;\n\n        let (executable_path, args) = command_parts.into_resolved().await?;\n\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n\n        let mut command = Command::new(executable_path);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .args(&args);\n\n        env.clone()\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut command);\n\n        let mut child = command.group_spawn_no_window()?;\n\n        if let Some(mut stdin) = child.inner().stdin.take() {\n            stdin.write_all(combined_prompt.as_bytes()).await?;\n            stdin.shutdown().await?;\n        }\n\n        Ok(child.into())\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        mcp::ensure_mcp_server_trust(self, current_dir).await;\n\n        let command_parts = self\n            .build_command_builder()?\n            .build_follow_up(&[\"--resume\".to_string(), session_id.to_string()])?;\n        let (executable_path, args) = command_parts.into_resolved().await?;\n\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n\n        let mut command = Command::new(executable_path);\n        command\n            .kill_on_drop(true)\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .args(&args);\n\n        env.clone()\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut command);\n\n        let mut child = command.group_spawn_no_window()?;\n\n        if let Some(mut stdin) = child.inner().stdin.take() {\n            stdin.write_all(combined_prompt.as_bytes()).await?;\n            stdin.shutdown().await?;\n        }\n\n        Ok(child.into())\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        worktree_path: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        let entry_index_provider = EntryIndexProvider::start_from(&msg_store);\n\n        // Custom stderr processor for Cursor that detects login errors\n        let msg_store_stderr = msg_store.clone();\n        let entry_index_provider_stderr = entry_index_provider.clone();\n        let h1 = tokio::spawn(async move {\n            let mut stderr = msg_store_stderr.stderr_chunked_stream();\n            let mut processor = PlainTextLogProcessor::builder()\n                .normalized_entry_producer(Box::new(|content: String| {\n                    let content = strip_ansi_escapes::strip_str(&content);\n\n                    NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::ErrorMessage {\n                            error_type: NormalizedEntryError::Other,\n                        },\n                        content,\n                        metadata: None,\n                    }\n                }))\n                .time_gap(Duration::from_secs(2))\n                .index_provider(entry_index_provider_stderr.clone())\n                .build();\n\n            while let Some(Ok(chunk)) = stderr.next().await {\n                let content = strip_ansi_escapes::strip_str(&chunk);\n                if content.contains(CURSOR_AUTH_REQUIRED_MSG) {\n                    let error_message = NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::ErrorMessage {\n                            error_type: NormalizedEntryError::SetupRequired,\n                        },\n                        content: content.to_string(),\n                        metadata: None,\n                    };\n                    let id = entry_index_provider_stderr.next();\n                    msg_store_stderr\n                        .push_patch(ConversationPatch::add_normalized_entry(id, error_message));\n                } else {\n                    // Always emit error message\n                    for patch in processor.process(chunk) {\n                        msg_store_stderr.push_patch(patch);\n                    }\n                }\n            }\n        });\n\n        // Process Cursor stdout JSONL with typed serde models\n        let current_dir = worktree_path.to_path_buf();\n        let h2 = tokio::spawn(async move {\n            let mut lines = msg_store.stdout_lines_stream();\n\n            // Assistant streaming coalescer state\n            let mut model_reported = false;\n            let mut session_id_reported = false;\n\n            let mut current_assistant_message_buffer = String::new();\n            let mut current_assistant_message_index: Option<usize> = None;\n            let mut current_thinking_message_buffer = String::new();\n            let mut current_thinking_message_index: Option<usize> = None;\n\n            let worktree_str = current_dir.to_string_lossy().to_string();\n\n            use std::collections::HashMap;\n            // Track tool call_id -> entry index\n            let mut call_index_map: HashMap<String, usize> = HashMap::new();\n\n            while let Some(Ok(line)) = lines.next().await {\n                // Parse line as CursorJson\n                let cursor_json: CursorJson = match serde_json::from_str(&line) {\n                    Ok(cursor_json) => cursor_json,\n                    Err(_) => {\n                        // Handle non-JSON output as raw system message\n                        if !line.is_empty() {\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::SystemMessage,\n                                content: line.to_string(),\n                                metadata: None,\n                            };\n\n                            let patch_id = entry_index_provider.next();\n                            let patch = ConversationPatch::add_normalized_entry(patch_id, entry);\n                            msg_store.push_patch(patch);\n                        }\n                        continue;\n                    }\n                };\n\n                // Push session_id if present\n                if !session_id_reported && let Some(session_id) = cursor_json.extract_session_id() {\n                    msg_store.push_session_id(session_id);\n                    session_id_reported = true;\n                }\n\n                let is_assistant_message = matches!(cursor_json, CursorJson::Assistant { .. });\n                let is_thinking_message = matches!(cursor_json, CursorJson::Thinking { .. });\n                if !is_assistant_message && current_assistant_message_index.is_some() {\n                    // flush\n                    current_assistant_message_index = None;\n                    current_assistant_message_buffer.clear();\n                }\n                if !is_thinking_message && current_thinking_message_index.is_some() {\n                    current_thinking_message_index = None;\n                    current_thinking_message_buffer.clear();\n                }\n\n                match &cursor_json {\n                    CursorJson::System { model, .. } => {\n                        if !model_reported && let Some(model) = model.as_ref() {\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::SystemMessage,\n                                content: format!(\"System initialized with model: {model}\"),\n                                metadata: None,\n                            };\n                            let id = entry_index_provider.next();\n                            msg_store\n                                .push_patch(ConversationPatch::add_normalized_entry(id, entry));\n                            model_reported = true;\n                        }\n                    }\n\n                    CursorJson::User { .. } => {}\n\n                    CursorJson::Assistant { message, .. } => {\n                        if let Some(chunk) = message.concat_text() {\n                            current_assistant_message_buffer.push_str(&chunk);\n                            let replace_entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::AssistantMessage,\n                                content: current_assistant_message_buffer.clone(),\n                                metadata: None,\n                            };\n                            if let Some(id) = current_assistant_message_index {\n                                msg_store.push_patch(ConversationPatch::replace(id, replace_entry))\n                            } else {\n                                let id = entry_index_provider.next();\n                                current_assistant_message_index = Some(id);\n                                msg_store.push_patch(ConversationPatch::add_normalized_entry(\n                                    id,\n                                    replace_entry,\n                                ));\n                            };\n                        }\n                    }\n                    CursorJson::Thinking { text, .. } => {\n                        if let Some(chunk) = text\n                            && !chunk.is_empty()\n                        {\n                            current_thinking_message_buffer.push_str(chunk);\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::Thinking,\n                                content: current_thinking_message_buffer.clone(),\n                                metadata: None,\n                            };\n                            if let Some(id) = current_thinking_message_index {\n                                msg_store.push_patch(ConversationPatch::replace(id, entry));\n                            } else {\n                                let id = entry_index_provider.next();\n                                current_thinking_message_index = Some(id);\n                                msg_store\n                                    .push_patch(ConversationPatch::add_normalized_entry(id, entry));\n                            }\n                        }\n                    }\n\n                    CursorJson::ToolCall {\n                        subtype,\n                        call_id,\n                        tool_call,\n                        ..\n                    } => {\n                        // Only process \"started\" subtype (completed contains results we currently ignore)\n                        if subtype\n                            .as_deref()\n                            .map(|s| s.eq_ignore_ascii_case(\"started\"))\n                            .unwrap_or(false)\n                        {\n                            let tool_name = tool_call.get_name().to_string();\n                            let (action_type, content) =\n                                tool_call.to_action_and_content(&worktree_str);\n\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::ToolUse {\n                                    tool_name,\n                                    action_type,\n                                    status: ToolStatus::Created,\n                                },\n                                content,\n                                metadata: None,\n                            };\n                            let id = entry_index_provider.next();\n                            if let Some(cid) = call_id.as_ref() {\n                                call_index_map.insert(cid.clone(), id);\n                            }\n                            msg_store\n                                .push_patch(ConversationPatch::add_normalized_entry(id, entry));\n                        } else if subtype\n                            .as_deref()\n                            .map(|s| s.eq_ignore_ascii_case(\"completed\"))\n                            .unwrap_or(false)\n                            && let Some(cid) = call_id.as_ref()\n                            && let Some(&idx) = call_index_map.get(cid)\n                        {\n                            // Compute base content and action again\n                            let (mut new_action, content_str) =\n                                tool_call.to_action_and_content(&worktree_str);\n                            if let CursorToolCall::Shell { args, result } = &tool_call {\n                                // Merge stdout/stderr and derive exit status when available using typed deserialization\n                                let (stdout_val, stderr_val, exit_code) = if let Some(res) = result\n                                {\n                                    match serde_json::from_value::<CursorShellResult>(res.clone()) {\n                                        Ok(r) => {\n                                            if let Some(out) = r.into_outcome() {\n                                                (out.stdout, out.stderr, out.exit_code)\n                                            } else {\n                                                (None, None, None)\n                                            }\n                                        }\n                                        Err(_) => (None, None, None),\n                                    }\n                                } else {\n                                    (None, None, None)\n                                };\n                                let output = match (stdout_val, stderr_val) {\n                                    (Some(sout), Some(serr)) => {\n                                        let st = sout.trim();\n                                        let se = serr.trim();\n                                        if st.is_empty() && se.is_empty() {\n                                            None\n                                        } else if st.is_empty() {\n                                            Some(serr)\n                                        } else if se.is_empty() {\n                                            Some(sout)\n                                        } else {\n                                            Some(format!(\"STDOUT:\\n{st}\\n\\nSTDERR:\\n{se}\"))\n                                        }\n                                    }\n                                    (Some(sout), None) => {\n                                        if sout.trim().is_empty() {\n                                            None\n                                        } else {\n                                            Some(sout)\n                                        }\n                                    }\n                                    (None, Some(serr)) => {\n                                        if serr.trim().is_empty() {\n                                            None\n                                        } else {\n                                            Some(serr)\n                                        }\n                                    }\n                                    (None, None) => None,\n                                };\n                                let exit_status = exit_code\n                                    .map(|code| crate::logs::CommandExitStatus::ExitCode { code });\n                                new_action = ActionType::CommandRun {\n                                    command: args.command.clone(),\n                                    result: Some(crate::logs::CommandRunResult {\n                                        exit_status,\n                                        output,\n                                    }),\n                                    category: CommandCategory::from_command(&args.command),\n                                };\n                            } else if let CursorToolCall::Mcp { args, result } = &tool_call {\n                                // Extract a human-readable text from content array using typed deserialization\n                                let md: Option<String> = if let Some(res) = result {\n                                    match serde_json::from_value::<CursorMcpResult>(res.clone()) {\n                                        Ok(r) => r.into_markdown(),\n                                        Err(_) => None,\n                                    }\n                                } else {\n                                    None\n                                };\n                                let provider = args.provider_identifier.as_deref().unwrap_or(\"mcp\");\n                                let tname = args.tool_name.as_deref().unwrap_or(&args.name);\n                                let label = format!(\"mcp:{provider}:{tname}\");\n                                new_action = ActionType::Tool {\n                                    tool_name: label.clone(),\n                                    arguments: Some(serde_json::json!({\n                                        \"name\": args.name,\n                                        \"args\": args.args,\n                                        \"providerIdentifier\": args.provider_identifier,\n                                        \"toolName\": args.tool_name,\n                                    })),\n                                    result: md.map(|s| crate::logs::ToolResult {\n                                        r#type: crate::logs::ToolResultValueType::Markdown,\n                                        value: serde_json::Value::String(s),\n                                    }),\n                                };\n                            }\n\n                            let entry = NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::ToolUse {\n                                    tool_name: match &tool_call {\n                                        CursorToolCall::Mcp { args, .. } => {\n                                            let provider = args\n                                                .provider_identifier\n                                                .as_deref()\n                                                .unwrap_or(\"mcp\");\n                                            let tname =\n                                                args.tool_name.as_deref().unwrap_or(&args.name);\n                                            format!(\"mcp:{provider}:{tname}\")\n                                        }\n                                        _ => tool_call.get_name().to_string(),\n                                    },\n                                    action_type: new_action,\n                                    status: ToolStatus::Success,\n                                },\n                                content: content_str,\n                                metadata: None,\n                            };\n                            msg_store.push_patch(ConversationPatch::replace(idx, entry));\n                        }\n                    }\n\n                    CursorJson::Result { .. } => {\n                        // no-op; metadata-only events not surfaced\n                    }\n\n                    CursorJson::Unknown => {\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: line,\n                            metadata: None,\n                        };\n                        let id = entry_index_provider.next();\n                        msg_store.push_patch(ConversationPatch::add_normalized_entry(id, entry));\n                    }\n                }\n            }\n        });\n\n        vec![h1, h2]\n    }\n\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        dirs::home_dir().map(|home| home.join(\".cursor\").join(\"mcp.json\"))\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        let binary_found = resolve_executable_path_blocking(Self::base_command()).is_some();\n        if !binary_found {\n            return AvailabilityInfo::NotFound;\n        }\n\n        let config_files_found = self\n            .default_mcp_config_path()\n            .map(|p| p.exists())\n            .unwrap_or(false);\n\n        if config_files_found {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        ExecutorConfig {\n            executor: BaseCodingAgent::CursorAgent,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: None,\n            reasoning_id: self.reasoning.clone(),\n            permission_policy: Some(crate::model_selector::PermissionPolicy::Auto),\n        }\n    }\n\n    async fn discover_options(\n        &self,\n        _workdir: Option<&std::path::Path>,\n        _repo_path: Option<&std::path::Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        let models: Vec<ModelInfo> = [\n            (\"auto\", \"Auto\"),\n            (\"composer-1.5\", \"Composer 1.5\"),\n            (\"gpt-5.4\", \"GPT-5.4\"),\n            (\"gpt-5.4-fast\", \"GPT-5.4 Fast\"),\n            (\"gemini-3.1-pro\", \"Gemini 3.1 Pro\"),\n            (\"opus-4.6\", \"Claude 4.6 Opus\"),\n            (\"sonnet-4.6\", \"Claude 4.6 Sonnet\"),\n            (\"gpt-5.3-codex\", \"GPT-5.3 Codex\"),\n            (\"gpt-5.3-codex-fast\", \"GPT-5.3 Codex Fast\"),\n            (\"gpt-5.3-codex-spark-preview\", \"GPT-5.3 Codex Spark\"),\n            (\"kimi-k2.5\", \"Kimi K2.5\"),\n            (\"opus-4.5\", \"Claude 4.5 Opus\"),\n            (\"sonnet-4.5\", \"Claude 4.5 Sonnet\"),\n            (\"gemini-3-pro\", \"Gemini 3 Pro\"),\n            (\"gemini-3-flash\", \"Gemini 3 Flash\"),\n            (\"gpt-5.2-codex\", \"GPT-5.2 Codex\"),\n            (\"gpt-5.2-codex-fast\", \"GPT-5.2 Codex Fast\"),\n            (\"gpt-5.2\", \"GPT-5.2\"),\n            (\"gpt-5.1-codex-max\", \"GPT-5.1 Codex Max\"),\n            (\"gpt-5.1\", \"GPT-5.1\"),\n            (\"gpt-5.1-codex-mini\", \"GPT-5.1 Codex Mini\"),\n            (\"grok\", \"Grok\"),\n            (\"composer-1\", \"Composer 1\"),\n        ]\n        .into_iter()\n        .map(|(id, name)| ModelInfo {\n            id: id.to_string(),\n            name: name.to_string(),\n            provider_id: None,\n            reasoning_options: cursor_reasoning_options(id),\n        })\n        .collect();\n\n        let options = ExecutorDiscoveredOptions {\n            model_selector: ModelSelectorConfig {\n                models,\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        Ok(Box::pin(futures::stream::once(async move {\n            patch::executor_discovered_options(options)\n        })))\n    }\n}\n/* ===========================\nTyped Cursor JSON structures\n=========================== */\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(tag = \"type\")]\npub enum CursorJson {\n    #[serde(rename = \"system\")]\n    System {\n        #[serde(default)]\n        subtype: Option<String>,\n        #[serde(default, rename = \"apiKeySource\")]\n        api_key_source: Option<String>,\n        #[serde(default)]\n        cwd: Option<String>,\n        #[serde(default)]\n        session_id: Option<String>,\n        #[serde(default)]\n        model: Option<String>,\n        #[serde(default, rename = \"permissionMode\")]\n        permission_mode: Option<String>,\n    },\n    #[serde(rename = \"user\")]\n    User {\n        message: CursorMessage,\n        #[serde(default)]\n        session_id: Option<String>,\n    },\n    #[serde(rename = \"assistant\")]\n    Assistant {\n        message: CursorMessage,\n        #[serde(default)]\n        session_id: Option<String>,\n    },\n    #[serde(rename = \"thinking\")]\n    Thinking {\n        #[serde(default)]\n        subtype: Option<String>,\n        #[serde(default)]\n        text: Option<String>,\n        #[serde(default)]\n        session_id: Option<String>,\n    },\n    #[serde(rename = \"tool_call\")]\n    ToolCall {\n        #[serde(default)]\n        subtype: Option<String>, // \"started\" | \"completed\"\n        #[serde(default)]\n        call_id: Option<String>,\n        tool_call: CursorToolCall,\n        #[serde(default)]\n        session_id: Option<String>,\n    },\n    #[serde(rename = \"result\")]\n    Result {\n        #[serde(default)]\n        subtype: Option<String>,\n        #[serde(default)]\n        is_error: Option<bool>,\n        #[serde(default)]\n        duration_ms: Option<u64>,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n        #[serde(default)]\n        session_id: Option<String>,\n    },\n    #[serde(other)]\n    Unknown,\n}\n\nimpl CursorJson {\n    pub fn extract_session_id(&self) -> Option<String> {\n        match self {\n            CursorJson::System { .. } => None, // session might not have been initialized yet\n            CursorJson::User { session_id, .. } => session_id.clone(),\n            CursorJson::Assistant { session_id, .. } => session_id.clone(),\n            CursorJson::Thinking { session_id, .. } => session_id.clone(),\n            CursorJson::ToolCall { session_id, .. } => session_id.clone(),\n            CursorJson::Result { session_id, .. } => session_id.clone(),\n            CursorJson::Unknown => None,\n        }\n    }\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMessage {\n    pub role: String,\n    pub content: Vec<CursorContentItem>,\n}\n\nimpl CursorMessage {\n    pub fn concat_text(&self) -> Option<String> {\n        let mut out = String::new();\n        for CursorContentItem::Text { text } in &self.content {\n            out.push_str(text);\n        }\n        if out.is_empty() { None } else { Some(out) }\n    }\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(tag = \"type\")]\npub enum CursorContentItem {\n    #[serde(rename = \"text\")]\n    Text { text: String },\n}\n\n/* ===========================\nTool call structure\n=========================== */\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub enum CursorToolCall {\n    #[serde(rename = \"shellToolCall\")]\n    Shell {\n        args: CursorShellArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"lsToolCall\")]\n    LS {\n        args: CursorLsArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"globToolCall\")]\n    Glob {\n        args: CursorGlobArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"grepToolCall\")]\n    Grep {\n        args: CursorGrepArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"semSearchToolCall\")]\n    SemSearch {\n        args: CursorSemSearchArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"writeToolCall\")]\n    Write {\n        args: CursorWriteArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"readToolCall\")]\n    Read {\n        args: CursorReadArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"editToolCall\")]\n    Edit {\n        args: CursorEditArgs,\n        #[serde(default)]\n        result: Option<CursorEditResult>,\n    },\n    #[serde(rename = \"deleteToolCall\")]\n    Delete {\n        args: CursorDeleteArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"updateTodosToolCall\")]\n    Todo {\n        args: CursorUpdateTodosArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    #[serde(rename = \"mcpToolCall\")]\n    Mcp {\n        args: CursorMcpArgs,\n        #[serde(default)]\n        result: Option<serde_json::Value>,\n    },\n    /// Generic fallback for unknown tools (amp.rs pattern)\n    #[serde(untagged)]\n    Unknown {\n        #[serde(flatten)]\n        data: std::collections::HashMap<String, serde_json::Value>,\n    },\n}\n\nimpl CursorToolCall {\n    pub fn get_name(&self) -> &str {\n        match self {\n            CursorToolCall::Shell { .. } => \"shell\",\n            CursorToolCall::LS { .. } => \"ls\",\n            CursorToolCall::Glob { .. } => \"glob\",\n            CursorToolCall::Grep { .. } => \"grep\",\n            CursorToolCall::SemSearch { .. } => \"semsearch\",\n            CursorToolCall::Write { .. } => \"write\",\n            CursorToolCall::Read { .. } => \"read\",\n            CursorToolCall::Edit { .. } => \"edit\",\n            CursorToolCall::Delete { .. } => \"delete\",\n            CursorToolCall::Todo { .. } => \"todo\",\n            CursorToolCall::Mcp { .. } => \"mcp\",\n            CursorToolCall::Unknown { data } => {\n                data.keys().next().map(|s| s.as_str()).unwrap_or(\"unknown\")\n            }\n        }\n    }\n\n    pub fn to_action_and_content(&self, worktree_path: &str) -> (ActionType, String) {\n        match self {\n            CursorToolCall::Read { args, .. } => {\n                let path = make_path_relative(&args.path, worktree_path);\n                (ActionType::FileRead { path: path.clone() }, path)\n            }\n            CursorToolCall::Write { args, .. } => {\n                let path = make_path_relative(&args.path, worktree_path);\n                (\n                    ActionType::FileEdit {\n                        path: path.clone(),\n                        changes: vec![],\n                    },\n                    path,\n                )\n            }\n            CursorToolCall::Edit { args, result, .. } => {\n                let path = make_path_relative(&args.path, worktree_path);\n                let mut changes = vec![];\n\n                if let Some(apply_patch) = &args.apply_patch {\n                    changes.push(FileChange::Edit {\n                        unified_diff: normalize_unified_diff(&path, &apply_patch.patch_content),\n                        has_line_numbers: false,\n                    });\n                }\n\n                if let Some(str_replace) = &args.str_replace {\n                    changes.push(FileChange::Edit {\n                        unified_diff: create_unified_diff(\n                            &path,\n                            &str_replace.old_text,\n                            &str_replace.new_text,\n                        ),\n                        has_line_numbers: false,\n                    });\n                }\n\n                if let Some(multi_str_replace) = &args.multi_str_replace {\n                    let edits: Vec<FileChange> = multi_str_replace\n                        .edits\n                        .iter()\n                        .map(|edit| FileChange::Edit {\n                            unified_diff: create_unified_diff(\n                                &path,\n                                &edit.old_text,\n                                &edit.new_text,\n                            ),\n                            has_line_numbers: false,\n                        })\n                        .collect();\n                    changes.extend(edits);\n                }\n\n                if changes.is_empty()\n                    && let Some(CursorEditResult::Success(CursorEditSuccessResult {\n                        diff_string: Some(diff_string),\n                        ..\n                    })) = &result\n                {\n                    changes.push(FileChange::Edit {\n                        unified_diff: normalize_unified_diff(&path, diff_string),\n                        has_line_numbers: false,\n                    });\n                }\n\n                (\n                    ActionType::FileEdit {\n                        path: path.clone(),\n                        changes,\n                    },\n                    path,\n                )\n            }\n            CursorToolCall::Delete { args, .. } => {\n                let path = make_path_relative(&args.path, worktree_path);\n                (\n                    ActionType::FileEdit {\n                        path: path.clone(),\n                        changes: vec![FileChange::Delete],\n                    },\n                    path.to_string(),\n                )\n            }\n            CursorToolCall::Shell { args, .. } => {\n                let cmd = &args.command;\n                (\n                    ActionType::CommandRun {\n                        command: cmd.clone(),\n                        result: None,\n                        category: CommandCategory::from_command(cmd),\n                    },\n                    cmd.to_string(),\n                )\n            }\n            CursorToolCall::Grep { args, .. } => {\n                let pattern = &args.pattern;\n                (\n                    ActionType::Search {\n                        query: pattern.clone(),\n                    },\n                    pattern.to_string(),\n                )\n            }\n            CursorToolCall::SemSearch { args, .. } => {\n                let query = &args.query;\n                (\n                    ActionType::Search {\n                        query: query.clone(),\n                    },\n                    query.to_string(),\n                )\n            }\n            CursorToolCall::Glob { args, .. } => {\n                let pattern = args.glob_pattern.clone().unwrap_or_else(|| \"*\".to_string());\n                if let Some(path) = args.path.as_ref().or(args.target_directory.as_ref()) {\n                    let path = make_path_relative(path, worktree_path);\n                    (\n                        ActionType::Search {\n                            query: pattern.clone(),\n                        },\n                        format!(\"Find files: `{pattern}` in {path}\"),\n                    )\n                } else {\n                    (\n                        ActionType::Search {\n                            query: pattern.clone(),\n                        },\n                        format!(\"Find files: `{pattern}`\"),\n                    )\n                }\n            }\n            CursorToolCall::LS { args, .. } => {\n                let path = make_path_relative(&args.path, worktree_path);\n                let content = if path.is_empty() {\n                    \"List directory\".to_string()\n                } else {\n                    format!(\"List directory: {path}\")\n                };\n                (\n                    ActionType::Other {\n                        description: \"List directory\".to_string(),\n                    },\n                    content,\n                )\n            }\n            CursorToolCall::Todo { args, .. } => {\n                let todos = args\n                    .todos\n                    .as_ref()\n                    .map(|todos| {\n                        todos\n                            .iter()\n                            .map(|t| TodoItem {\n                                content: t.content.clone(),\n                                status: normalize_todo_status(&t.status),\n                                priority: None, // CursorTodoItem doesn't have priority field\n                            })\n                            .collect()\n                    })\n                    .unwrap_or_default();\n\n                (\n                    ActionType::TodoManagement {\n                        todos,\n                        operation: \"write\".to_string(),\n                    },\n                    \"TODO list updated\".to_string(),\n                )\n            }\n            CursorToolCall::Mcp { args, .. } => {\n                let provider = args.provider_identifier.as_deref().unwrap_or(\"mcp\");\n                let tool_name = args.tool_name.as_deref().unwrap_or(&args.name);\n                let label = format!(\"mcp:{provider}:{tool_name}\");\n                let summary = tool_name.to_string();\n                let mut arguments = serde_json::json!({\n                    \"name\": args.name,\n                    \"args\": args.args,\n                });\n                if let Some(p) = &args.provider_identifier {\n                    arguments[\"providerIdentifier\"] = serde_json::Value::String(p.clone());\n                }\n                if let Some(tn) = &args.tool_name {\n                    arguments[\"toolName\"] = serde_json::Value::String(tn.clone());\n                }\n                (\n                    ActionType::Tool {\n                        tool_name: label,\n                        arguments: Some(arguments),\n                        result: None,\n                    },\n                    summary,\n                )\n            }\n            CursorToolCall::Unknown { .. } => (\n                ActionType::Other {\n                    description: format!(\"Tool: {}\", self.get_name()),\n                },\n                self.get_name().to_string(),\n            ),\n        }\n    }\n}\n\nfn normalize_todo_status(status: &str) -> String {\n    match status.to_lowercase().as_str() {\n        \"todo_status_pending\" => \"pending\".to_string(),\n        \"todo_status_in_progress\" => \"in_progress\".to_string(),\n        \"todo_status_completed\" => \"completed\".to_string(),\n        \"todo_status_cancelled\" => \"cancelled\".to_string(),\n        other => other.to_string(),\n    }\n}\n\n/* ===========================\nTyped tool results for Cursor\n=========================== */\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorShellOutcome {\n    #[serde(default)]\n    pub stdout: Option<String>,\n    #[serde(default)]\n    pub stderr: Option<String>,\n    #[serde(default, rename = \"exitCode\")]\n    pub exit_code: Option<i32>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorShellWrappedResult {\n    #[serde(default)]\n    pub success: Option<CursorShellOutcome>,\n    #[serde(default)]\n    pub failure: Option<CursorShellOutcome>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(untagged)]\npub enum CursorShellResult {\n    Wrapped(CursorShellWrappedResult),\n    Flat(CursorShellOutcome),\n    Unknown(serde_json::Value),\n}\n\nimpl CursorShellResult {\n    pub fn into_outcome(self) -> Option<CursorShellOutcome> {\n        match self {\n            CursorShellResult::Flat(o) => Some(o),\n            CursorShellResult::Wrapped(w) => w.success.or(w.failure),\n            CursorShellResult::Unknown(_) => None,\n        }\n    }\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMcpTextInner {\n    pub text: String,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMcpContentItem {\n    #[serde(default)]\n    pub text: Option<CursorMcpTextInner>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMcpOutcome {\n    #[serde(default)]\n    pub content: Option<Vec<CursorMcpContentItem>>,\n    #[serde(default, rename = \"isError\")]\n    pub is_error: Option<bool>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMcpWrappedResult {\n    #[serde(default)]\n    pub success: Option<CursorMcpOutcome>,\n    #[serde(default)]\n    pub failure: Option<CursorMcpOutcome>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(untagged)]\npub enum CursorMcpResult {\n    Wrapped(CursorMcpWrappedResult),\n    Flat(CursorMcpOutcome),\n    Unknown(serde_json::Value),\n}\n\nimpl CursorMcpResult {\n    pub fn into_markdown(self) -> Option<String> {\n        let outcome = match self {\n            CursorMcpResult::Flat(o) => Some(o),\n            CursorMcpResult::Wrapped(w) => w.success.or(w.failure),\n            CursorMcpResult::Unknown(_) => None,\n        }?;\n\n        let items = outcome.content.unwrap_or_default();\n        let mut parts: Vec<String> = Vec::new();\n        for item in items {\n            if let Some(t) = item.text {\n                parts.push(t.text);\n            }\n        }\n        if parts.is_empty() {\n            None\n        } else {\n            Some(parts.join(\"\\n\\n\"))\n        }\n    }\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorShellArgs {\n    pub command: String,\n    #[serde(default, alias = \"working_directory\", alias = \"workingDirectory\")]\n    pub working_directory: Option<String>,\n    #[serde(default)]\n    pub timeout: Option<u64>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorLsArgs {\n    pub path: String,\n    #[serde(default)]\n    pub ignore: Vec<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorGlobArgs {\n    #[serde(default, alias = \"globPattern\", alias = \"glob_pattern\")]\n    pub glob_pattern: Option<String>,\n    #[serde(default, alias = \"targetDirectory\")]\n    pub path: Option<String>,\n    #[serde(default, alias = \"target_directory\")]\n    pub target_directory: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorGrepArgs {\n    pub pattern: String,\n    #[serde(default)]\n    pub path: Option<String>,\n    #[serde(default, alias = \"glob\")]\n    pub glob_filter: Option<String>,\n    #[serde(default, alias = \"outputMode\", alias = \"output_mode\")]\n    pub output_mode: Option<String>,\n    #[serde(default, alias = \"-i\", alias = \"caseInsensitive\")]\n    pub case_insensitive: Option<bool>,\n    #[serde(default)]\n    pub multiline: Option<bool>,\n    #[serde(default, alias = \"headLimit\", alias = \"head_limit\")]\n    pub head_limit: Option<u64>,\n    #[serde(default)]\n    pub r#type: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorSemSearchArgs {\n    pub query: String,\n    #[serde(default, alias = \"targetDirectories\")]\n    pub target_directories: Option<Vec<String>>,\n    #[serde(default)]\n    pub explanation: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorWriteArgs {\n    pub path: String,\n    #[serde(\n        default,\n        alias = \"fileText\",\n        alias = \"file_text\",\n        alias = \"contents\",\n        alias = \"content\"\n    )]\n    pub contents: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorReadArgs {\n    pub path: String,\n    #[serde(default)]\n    pub offset: Option<u64>,\n    #[serde(default)]\n    pub limit: Option<u64>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorEditArgs {\n    pub path: String,\n    #[serde(default, rename = \"applyPatch\")]\n    pub apply_patch: Option<CursorApplyPatch>,\n    #[serde(default, rename = \"strReplace\")]\n    pub str_replace: Option<CursorStrReplace>,\n    #[serde(default, rename = \"multiStrReplace\")]\n    pub multi_str_replace: Option<CursorMultiStrReplace>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(rename_all = \"lowercase\")]\npub enum CursorEditResult {\n    Success(CursorEditSuccessResult),\n    #[serde(untagged)]\n    Unknown {\n        #[serde(flatten)]\n        data: HashMap<String, serde_json::Value>,\n    },\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorEditSuccessResult {\n    pub path: String,\n    #[serde(default, rename = \"resultForModel\")]\n    pub result_for_model: Option<String>,\n    #[serde(default, rename = \"linesAdded\")]\n    pub lines_added: Option<u64>,\n    #[serde(default, rename = \"linesRemoved\")]\n    pub lines_removed: Option<u64>,\n    #[serde(default, rename = \"diffString\")]\n    pub diff_string: Option<String>,\n    #[serde(default, rename = \"afterFullFileContent\")]\n    pub after_full_file_content: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorApplyPatch {\n    #[serde(rename = \"patchContent\")]\n    pub patch_content: String,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorStrReplace {\n    #[serde(rename = \"oldText\")]\n    pub old_text: String,\n    #[serde(rename = \"newText\")]\n    pub new_text: String,\n    #[serde(default, rename = \"replaceAll\")]\n    pub replace_all: Option<bool>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMultiStrReplace {\n    pub edits: Vec<CursorMultiEditItem>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMultiEditItem {\n    #[serde(rename = \"oldText\")]\n    pub old_text: String,\n    #[serde(rename = \"newText\")]\n    pub new_text: String,\n    #[serde(default, rename = \"replaceAll\")]\n    pub replace_all: Option<bool>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorDeleteArgs {\n    pub path: String,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorUpdateTodosArgs {\n    #[serde(default)]\n    pub todos: Option<Vec<CursorTodoItem>>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorMcpArgs {\n    pub name: String,\n    #[serde(default)]\n    pub args: serde_json::Value,\n    #[serde(default, alias = \"providerIdentifier\")]\n    pub provider_identifier: Option<String>,\n    #[serde(default, alias = \"toolName\")]\n    pub tool_name: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct CursorTodoItem {\n    #[serde(default)]\n    pub id: Option<String>,\n    pub content: String,\n    pub status: String,\n    #[serde(default, rename = \"createdAt\")]\n    pub created_at: Option<String>,\n    #[serde(default, rename = \"updatedAt\")]\n    pub updated_at: Option<String>,\n    #[serde(default)]\n    pub dependencies: Option<Vec<String>>,\n}\n\n/* ===========================\nTests\n=========================== */\n\n#[cfg(test)]\nmod tests {\n    use std::sync::Arc;\n\n    use workspace_utils::msg_store::MsgStore;\n\n    use super::*;\n\n    #[tokio::test]\n    async fn test_cursor_streaming_patch_generation() {\n        // Avoid relying on feature flag in tests; construct with a dummy command\n        let executor = CursorAgent {\n            append_prompt: AppendPrompt::default(),\n            force: None,\n            model: None,\n            reasoning: None,\n            cmd: Default::default(),\n        };\n        let msg_store = Arc::new(MsgStore::new());\n        let current_dir = std::path::PathBuf::from(\"/tmp/test-worktree\");\n\n        // A minimal synthetic init + assistant micro-chunks (as Cursor would emit)\n        msg_store.push_stdout(format!(\n            \"{}\\n\",\n            r#\"{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"sess-123\",\"model\":\"OpenAI GPT-5\"}\"#\n        ));\n        msg_store.push_stdout(format!(\n            \"{}\\n\",\n            r#\"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}}\"#\n        ));\n        msg_store.push_stdout(format!(\n            \"{}\\n\",\n            r#\"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\" world\"}]}}\"#\n        ));\n        msg_store.push_finished();\n\n        executor.normalize_logs(msg_store.clone(), &current_dir);\n\n        tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;\n\n        // Verify patches were emitted (system init + assistant add/replace)\n        let history = msg_store.get_history();\n        let patch_count = history\n            .iter()\n            .filter(|m| matches!(m, workspace_utils::log_msg::LogMsg::JsonPatch(_)))\n            .count();\n        assert!(\n            patch_count >= 2,\n            \"Expected at least 2 patches, got {patch_count}\"\n        );\n    }\n\n    #[test]\n    fn test_session_id_extraction_from_system_line() {\n        // System messages no longer extract session_id\n        let system_line = r#\"{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"abc-xyz\",\"model\":\"Claude 4 Sonnet\"}\"#;\n        let parsed: CursorJson = serde_json::from_str(system_line).unwrap();\n        assert_eq!(parsed.extract_session_id().as_deref(), None);\n    }\n\n    #[test]\n    fn test_cursor_tool_call_parsing() {\n        // Test known variant (from reference JSONL)\n        let shell_tool_json = r#\"{\"shellToolCall\":{\"args\":{\"command\":\"wc -l drill.md\",\"workingDirectory\":\"\",\"timeout\":0}}}\"#;\n        let parsed: CursorToolCall = serde_json::from_str(shell_tool_json).unwrap();\n\n        match parsed {\n            CursorToolCall::Shell { args, result } => {\n                assert_eq!(args.command, \"wc -l drill.md\");\n                assert_eq!(args.working_directory, Some(\"\".to_string()));\n                assert_eq!(args.timeout, Some(0));\n                assert_eq!(result, None);\n            }\n            _ => panic!(\"Expected Shell variant\"),\n        }\n\n        // Test unknown variant (captures raw data)\n        let unknown_tool_json =\n            r#\"{\"unknownTool\":{\"args\":{\"someData\":\"value\"},\"result\":{\"status\":\"success\"}}}\"#;\n        let parsed: CursorToolCall = serde_json::from_str(unknown_tool_json).unwrap();\n\n        match parsed {\n            CursorToolCall::Unknown { data } => {\n                assert!(data.contains_key(\"unknownTool\"));\n                let unknown_tool = &data[\"unknownTool\"];\n                assert_eq!(unknown_tool[\"args\"][\"someData\"], \"value\");\n                assert_eq!(unknown_tool[\"result\"][\"status\"], \"success\");\n            }\n            _ => panic!(\"Expected Unknown variant\"),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/droid/normalize_logs.rs",
    "content": "use std::{\n    collections::{HashMap, VecDeque},\n    path::Path,\n    sync::Arc,\n};\n\nuse futures::{StreamExt, future::ready};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse workspace_utils::{\n    diff::normalize_unified_diff, msg_store::MsgStore, path::make_path_relative,\n};\n\nuse crate::logs::{\n    ActionType, CommandExitStatus, CommandRunResult, FileChange, NormalizedEntry,\n    NormalizedEntryError, NormalizedEntryType, TodoItem, ToolResult, ToolStatus,\n    plain_text_processor::PlainTextLogProcessor,\n    utils::{\n        EntryIndexProvider,\n        patch::{add_normalized_entry, replace_normalized_entry},\n        shell_command_parsing::CommandCategory,\n    },\n};\n\npub fn normalize_logs(\n    msg_store: Arc<MsgStore>,\n    worktree_path: &Path,\n    entry_index_provider: EntryIndexProvider,\n) -> Vec<tokio::task::JoinHandle<()>> {\n    let h1 = normalize_stderr_logs(msg_store.clone(), entry_index_provider.clone());\n\n    let worktree_path = worktree_path.to_path_buf();\n    let h2 = tokio::spawn(async move {\n        let mut state = ToolCallStates::new(entry_index_provider.clone());\n        let mut session_id_extracted = false;\n        let mut sent_completion = false;\n\n        let worktree_path_str = worktree_path.to_string_lossy();\n\n        let mut lines_stream = msg_store\n            .stdout_lines_stream()\n            .filter_map(|res| ready(res.ok()));\n\n        while let Some(line) = lines_stream.next().await {\n            let trimmed = line.trim();\n            let droid_json = match serde_json::from_str::<DroidJson>(trimmed) {\n                Ok(droid_json) => droid_json,\n                Err(_) => {\n                    if let Ok(DroidErrorLog { error, .. }) =\n                        serde_json::from_str::<DroidErrorLog>(trimmed)\n                    {\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::ErrorMessage {\n                                error_type: NormalizedEntryError::Other,\n                            },\n                            content: error.message,\n                            metadata: None,\n                        };\n                        add_normalized_entry(&msg_store, &entry_index_provider, entry);\n                        continue;\n                    }\n                    // Handle non-JSON output as raw system message\n                    if !trimmed.is_empty() {\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: strip_ansi_escapes::strip_str(trimmed).to_string(),\n                            metadata: None,\n                        };\n\n                        add_normalized_entry(&msg_store, &entry_index_provider, entry);\n                    }\n                    continue;\n                }\n            };\n\n            // Extract session ID if not already done\n            if !session_id_extracted && let Some(session_id) = droid_json.session_id() {\n                msg_store.push_session_id(session_id.to_string());\n                session_id_extracted = true;\n            }\n\n            // Normalize JSON logs\n            match droid_json {\n                DroidJson::System { model, .. } => {\n                    if !state.model_reported\n                        && let Some(model) = model\n                    {\n                        state.model_reported = true;\n                        let entry = NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::SystemMessage,\n                            content: format!(\"model: {model}\"),\n                            metadata: None,\n                        };\n                        add_normalized_entry(&msg_store, &entry_index_provider, entry);\n                    }\n                }\n\n                DroidJson::Message { role, text, .. } => {\n                    if role == \"assistant\" && sent_completion {\n                        continue;\n                    }\n\n                    let entry_type = match role.as_str() {\n                        \"user\" => NormalizedEntryType::UserMessage,\n                        \"assistant\" => NormalizedEntryType::AssistantMessage,\n                        _ => NormalizedEntryType::SystemMessage,\n                    };\n\n                    let entry = NormalizedEntry {\n                        timestamp: None,\n                        entry_type,\n                        content: text.clone(),\n                        metadata: None,\n                    };\n\n                    add_normalized_entry(&msg_store, &entry_index_provider, entry);\n                }\n\n                DroidJson::ToolCall {\n                    id,\n                    tool_name,\n                    parameters: arguments,\n                    ..\n                } => {\n                    let tool_json = serde_json::json!({\n                        \"toolName\": tool_name,\n                        \"parameters\": arguments\n                    });\n\n                    if let Ok(tool_data) = serde_json::from_value::<DroidToolData>(tool_json) {\n                        match tool_data {\n                            DroidToolData::Read { file_path }\n                            | DroidToolData::LS {\n                                directory_path: file_path,\n                                ..\n                            } => {\n                                let path = make_path_relative(&file_path, &worktree_path_str);\n                                let tool_state = FileReadState {\n                                    index: None,\n                                    path: path.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.file_reads.insert(id.to_string(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Read {\n                                    tool_call_id: id.to_string(),\n                                });\n                                let tool_state = state.file_reads.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::Grep {\n                                path: file_path, ..\n                            } => {\n                                let path = file_path\n                                    .as_ref()\n                                    .map(|p| make_path_relative(p, &worktree_path_str))\n                                    .unwrap_or_default();\n                                let tool_state = FileReadState {\n                                    index: None,\n                                    path: path.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.file_reads.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Read {\n                                    tool_call_id: id.clone(),\n                                });\n                                let tool_state = state.file_reads.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::Glob { patterns, .. } => {\n                                let query = patterns.join(\", \");\n                                let tool_state = SearchState {\n                                    index: None,\n                                    query: query.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.searches.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Search {\n                                    tool_call_id: id.clone(),\n                                });\n                                let tool_state = state.searches.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::Execute { command, .. } => {\n                                let tool_state = CommandRunState {\n                                    index: None,\n                                    command: command.clone(),\n                                    output: String::new(),\n                                    status: ToolStatus::Created,\n                                    exit_code: None,\n                                };\n                                state.command_runs.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::CommandRun {\n                                    tool_call_id: id.clone(),\n                                });\n                                let tool_state = state.command_runs.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::Edit {\n                                file_path,\n                                old_string,\n                                new_string,\n                            } => {\n                                let path = make_path_relative(&file_path, &worktree_path_str);\n                                let diff = workspace_utils::diff::create_unified_diff(\n                                    &file_path,\n                                    &old_string,\n                                    &new_string,\n                                );\n                                let changes = vec![FileChange::Edit {\n                                    unified_diff: diff,\n                                    has_line_numbers: false,\n                                }];\n\n                                let tool_state = FileEditState {\n                                    index: None,\n                                    path: path.clone(),\n                                    changes: changes.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.file_edits.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::FileEdit {\n                                    tool_call_id: id.clone(),\n                                });\n                                let tool_state = state.file_edits.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::MultiEdit { file_path, edits } => {\n                                let path = make_path_relative(&file_path, &worktree_path_str);\n                                let changes: Vec<FileChange> = edits\n                                    .iter()\n                                    .filter_map(|edit| {\n                                        if edit.old_string.is_some() || edit.new_string.is_some() {\n                                            Some(FileChange::Edit {\n                                                unified_diff:\n                                                    workspace_utils::diff::create_unified_diff(\n                                                        &file_path,\n                                                        &edit\n                                                            .old_string\n                                                            .clone()\n                                                            .unwrap_or_default(),\n                                                        &edit\n                                                            .new_string\n                                                            .clone()\n                                                            .unwrap_or_default(),\n                                                    ),\n                                                has_line_numbers: false,\n                                            })\n                                        } else {\n                                            None\n                                        }\n                                    })\n                                    .collect();\n\n                                let tool_state = FileEditState {\n                                    index: None,\n                                    path: path.clone(),\n                                    changes: changes.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.file_edits.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::FileEdit {\n                                    tool_call_id: id.clone(),\n                                });\n                                let tool_state = state.file_edits.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::Create { file_path, content } => {\n                                let path = make_path_relative(&file_path, &worktree_path_str);\n                                let changes = vec![FileChange::Write { content }];\n\n                                let tool_state = FileEditState {\n                                    index: None,\n                                    path: path.clone(),\n                                    changes: changes.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.file_edits.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::FileEdit {\n                                    tool_call_id: id.clone(),\n                                });\n\n                                let tool_state = state.file_edits.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::ApplyPatch { input } => {\n                                let path = extract_path_from_patch(&input);\n                                let path = make_path_relative(&path, &worktree_path_str);\n\n                                // We get changes from tool result\n                                let tool_state = FileEditState {\n                                    index: None,\n                                    path: path.clone(),\n                                    changes: vec![],\n                                    status: ToolStatus::Created,\n                                };\n                                state.file_edits.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::FileEdit {\n                                    tool_call_id: id.clone(),\n                                });\n\n                                let tool_state = state.file_edits.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::TodoWrite { todos } => {\n                                let todo_items: Vec<TodoItem> = todos\n                                    .into_iter()\n                                    .map(|item| TodoItem {\n                                        content: item.content,\n                                        status: item.status,\n                                        priority: item.priority,\n                                    })\n                                    .collect();\n\n                                let tool_state = TodoManagementState {\n                                    index: None,\n                                    todos: todo_items.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.todo_updates.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Todo {\n                                    tool_call_id: id.clone(),\n                                });\n\n                                let tool_state = state.todo_updates.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::WebSearch { query, .. } => {\n                                let tool_state = WebFetchState {\n                                    index: None,\n                                    url: query.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.web_fetches.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Fetch {\n                                    tool_call_id: id.clone(),\n                                });\n\n                                let tool_state = state.web_fetches.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::FetchUrl { url, .. } => {\n                                let tool_state = WebFetchState {\n                                    index: None,\n                                    url: url.clone(),\n                                    status: ToolStatus::Created,\n                                };\n                                state.web_fetches.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Fetch {\n                                    tool_call_id: id.clone(),\n                                });\n\n                                let tool_state = state.web_fetches.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::ExitSpecMode { .. } => {\n                                let tool_state = TodoManagementState {\n                                    index: None,\n                                    todos: vec![],\n                                    status: ToolStatus::Created,\n                                };\n                                state.todo_updates.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Todo {\n                                    tool_call_id: id.clone(),\n                                });\n                                let tool_state = state.todo_updates.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n\n                            DroidToolData::SlackPostMessage { .. }\n                            | DroidToolData::Unknown { .. } => {\n                                let tool_state = GenericToolState {\n                                    index: None,\n                                    name: tool_name.to_string(),\n                                    arguments: Some(arguments.clone()),\n                                    result: None,\n                                    status: ToolStatus::Created,\n                                };\n                                state.generic_tools.insert(id.clone(), tool_state);\n                                state.pending_fifo.push_back(PendingToolCall::Generic {\n                                    tool_call_id: id.clone(),\n                                });\n\n                                let tool_state = state.generic_tools.get_mut(&id).unwrap();\n                                let index = add_normalized_entry(\n                                    &msg_store,\n                                    &entry_index_provider,\n                                    tool_state.to_normalized_entry(),\n                                );\n                                tool_state.index = Some(index);\n                            }\n                        }\n                    } else {\n                        tracing::warn!(\"Failed to parse tool parameters for {}\", tool_name);\n                    }\n                }\n\n                DroidJson::ToolResult {\n                    id: _,\n                    is_error,\n                    payload,\n                    ..\n                } => {\n                    if let Some(pending_tool_call) = state.pending_fifo.pop_front() {\n                        match pending_tool_call {\n                            PendingToolCall::Read { tool_call_id } => {\n                                if let Some(mut state) = state.file_reads.remove(&tool_call_id) {\n                                    state.status = if is_error {\n                                        ToolStatus::Failed\n                                    } else {\n                                        ToolStatus::Success\n                                    };\n                                    let entry = state.to_normalized_entry();\n                                    replace_normalized_entry(\n                                        &msg_store,\n                                        state.index.unwrap(),\n                                        entry,\n                                    );\n                                }\n                            }\n                            PendingToolCall::FileEdit { tool_call_id } => {\n                                if let Some(mut state) = state.file_edits.remove(&tool_call_id) {\n                                    state.status = if is_error {\n                                        ToolStatus::Failed\n                                    } else {\n                                        ToolStatus::Success\n                                    };\n\n                                    // Parse patch results if ApplyPatch tool\n                                    if let ToolResultPayload::Value { value } = payload\n                                        && tool_call_id.contains(\"ApplyPatch\")\n                                    {\n                                        let worktree_path_str = worktree_path.to_string_lossy();\n                                        if let Some(parsed) =\n                                            parse_apply_patch_result(&value, &worktree_path_str)\n                                            && let ActionType::FileEdit { changes, .. } = parsed\n                                        {\n                                            state.changes = changes;\n                                        }\n                                    }\n\n                                    let entry = state.to_normalized_entry();\n                                    replace_normalized_entry(\n                                        &msg_store,\n                                        state.index.unwrap(),\n                                        entry,\n                                    );\n                                }\n                            }\n                            PendingToolCall::CommandRun { tool_call_id } => {\n                                if let Some(mut state) = state.command_runs.remove(&tool_call_id) {\n                                    state.status = if is_error {\n                                        ToolStatus::Failed\n                                    } else {\n                                        ToolStatus::Success\n                                    };\n\n                                    match payload {\n                                        ToolResultPayload::Value { value } => {\n                                            let output = if let Some(s) = value.as_str() {\n                                                s.to_string()\n                                            } else {\n                                                serde_json::to_string_pretty(&value)\n                                                    .unwrap_or_default()\n                                            };\n\n                                            let exit_code = output\n                                                .lines()\n                                                .find(|line| {\n                                                    line.contains(\"[Process exited with code\")\n                                                })\n                                                .and_then(|line| {\n                                                    line.strip_prefix(\"[Process exited with code \")?\n                                                        .strip_suffix(\"]\")?\n                                                        .parse::<i32>()\n                                                        .ok()\n                                                });\n\n                                            state.output = output;\n                                            state.exit_code = exit_code;\n                                            if exit_code.is_some_and(|rc| rc != 0) {\n                                                state.status = ToolStatus::Failed;\n                                            }\n                                        }\n                                        ToolResultPayload::Error { error } => {\n                                            state.output = error.message;\n                                        }\n                                    }\n\n                                    let entry = state.to_normalized_entry();\n                                    replace_normalized_entry(\n                                        &msg_store,\n                                        state.index.unwrap(),\n                                        entry,\n                                    );\n                                }\n                            }\n                            PendingToolCall::Todo { tool_call_id } => {\n                                if let Some(mut state) = state.todo_updates.remove(&tool_call_id) {\n                                    state.status = if is_error {\n                                        ToolStatus::Failed\n                                    } else {\n                                        ToolStatus::Success\n                                    };\n                                    let entry = state.to_normalized_entry();\n                                    replace_normalized_entry(\n                                        &msg_store,\n                                        state.index.unwrap(),\n                                        entry,\n                                    );\n                                }\n                            }\n                            PendingToolCall::Search { tool_call_id } => {\n                                if let Some(mut state) = state.searches.remove(&tool_call_id) {\n                                    state.status = if is_error {\n                                        ToolStatus::Failed\n                                    } else {\n                                        ToolStatus::Success\n                                    };\n                                    let entry = state.to_normalized_entry();\n                                    replace_normalized_entry(\n                                        &msg_store,\n                                        state.index.unwrap(),\n                                        entry,\n                                    );\n                                }\n                            }\n                            PendingToolCall::Fetch { tool_call_id } => {\n                                if let Some(mut state) = state.web_fetches.remove(&tool_call_id) {\n                                    state.status = if is_error {\n                                        ToolStatus::Failed\n                                    } else {\n                                        ToolStatus::Success\n                                    };\n                                    let entry = state.to_normalized_entry();\n                                    replace_normalized_entry(\n                                        &msg_store,\n                                        state.index.unwrap(),\n                                        entry,\n                                    );\n                                }\n                            }\n                            PendingToolCall::Generic { tool_call_id } => {\n                                if let Some(mut state) = state.generic_tools.remove(&tool_call_id) {\n                                    state.status = if is_error {\n                                        ToolStatus::Failed\n                                    } else {\n                                        ToolStatus::Success\n                                    };\n\n                                    match payload {\n                                        ToolResultPayload::Value { value } => {\n                                            state.result = Some(value);\n                                        }\n                                        ToolResultPayload::Error { error } => {\n                                            state.result = Some(error.message.into());\n                                        }\n                                    }\n\n                                    let entry = state.to_normalized_entry();\n                                    replace_normalized_entry(\n                                        &msg_store,\n                                        state.index.unwrap(),\n                                        entry,\n                                    );\n                                }\n                            }\n                        }\n                    }\n                }\n\n                DroidJson::Completion { final_text, .. } => {\n                    let entry = NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::AssistantMessage,\n                        content: final_text.clone(),\n                        metadata: None,\n                    };\n                    add_normalized_entry(&msg_store, &entry_index_provider, entry);\n                    sent_completion = true;\n                }\n\n                DroidJson::Error { message, .. } => {\n                    let entry = NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::ErrorMessage {\n                            error_type: NormalizedEntryError::Other,\n                        },\n                        content: message.clone(),\n                        metadata: None,\n                    };\n                    add_normalized_entry(&msg_store, &state.entry_index, entry);\n                }\n            }\n        }\n    });\n\n    vec![h1, h2]\n}\n\nfn normalize_stderr_logs(\n    msg_store: Arc<MsgStore>,\n    entry_index_provider: EntryIndexProvider,\n) -> tokio::task::JoinHandle<()> {\n    tokio::spawn(async move {\n        let mut stderr = msg_store.stderr_chunked_stream();\n\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(Box::new(|content: String| NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::Other,\n                },\n                content,\n                metadata: None,\n            }))\n            .transform_lines(Box::new(|lines| {\n                lines.iter_mut().for_each(|line| {\n                    *line = strip_ansi_escapes::strip_str(&line);\n                    // noisy, but seemingly harmless message happens when session is forked\n                    if line.starts_with(\"Error fetching session \") {\n                        line.clear();\n                    }\n                });\n            }))\n            .time_gap(std::time::Duration::from_secs(2))\n            .index_provider(entry_index_provider)\n            .build();\n\n        while let Some(Ok(chunk)) = stderr.next().await {\n            for patch in processor.process(chunk) {\n                msg_store.push_patch(patch);\n            }\n        }\n    })\n}\n\n/// Extract path from ApplyPatch input format\nfn extract_path_from_patch(input: &str) -> String {\n    for line in input.lines() {\n        if line.starts_with(\"*** Update File:\") || line.starts_with(\"*** Add File:\") {\n            return line\n                .split(':')\n                .nth(1)\n                .map(|s| s.trim().to_string())\n                .unwrap_or_default();\n        }\n    }\n    String::new()\n}\n\n/// Parse ApplyPatch result to extract file changes\nfn parse_apply_patch_result(value: &Value, worktree_path: &str) -> Option<ActionType> {\n    let parsed_value;\n    let result_obj = if value.is_object() {\n        value\n    } else if let Some(s) = value.as_str() {\n        match serde_json::from_str::<Value>(s) {\n            Ok(v) => {\n                parsed_value = v;\n                &parsed_value\n            }\n            Err(e) => {\n                tracing::warn!(\n                    error = %e,\n                    input = %s,\n                    \"Failed to parse apply_patch result string as JSON\"\n                );\n                return None;\n            }\n        }\n    } else {\n        tracing::warn!(\n            value_type = ?value,\n            \"apply_patch result is neither object nor string\"\n        );\n        return None;\n    };\n\n    let file_path = result_obj\n        .get(\"file_path\")\n        .or_else(|| result_obj.get(\"value\").and_then(|v| v.get(\"file_path\")))\n        .and_then(|v: &Value| v.as_str())\n        .map(|s| s.to_string())?;\n\n    let diff = result_obj\n        .get(\"diff\")\n        .or_else(|| result_obj.get(\"value\").and_then(|v| v.get(\"diff\")))\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string());\n\n    let content = result_obj\n        .get(\"content\")\n        .or_else(|| result_obj.get(\"value\").and_then(|v| v.get(\"content\")))\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string());\n\n    let relative_path = make_path_relative(&file_path, worktree_path);\n\n    let changes = if let Some(diff_text) = diff {\n        vec![FileChange::Edit {\n            unified_diff: normalize_unified_diff(&relative_path, &diff_text),\n            has_line_numbers: true,\n        }]\n    } else if let Some(content_text) = content {\n        vec![FileChange::Write {\n            content: content_text,\n        }]\n    } else {\n        vec![]\n    };\n\n    Some(ActionType::FileEdit {\n        path: relative_path,\n        changes,\n    })\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct ToolError {\n    #[serde(rename = \"type\")]\n    pub kind: String,\n    pub message: String,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(untagged)]\npub enum ToolResultPayload {\n    Value { value: Value },\n    Error { error: ToolError },\n}\n\npub struct EditToolResult {}\n\n#[derive(Deserialize, Serialize, Debug, Clone)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum DroidJson {\n    System {\n        #[serde(default)]\n        subtype: Option<String>,\n        session_id: String,\n        #[serde(default)]\n        cwd: Option<String>,\n        #[serde(default)]\n        tools: Option<Vec<String>>,\n        #[serde(default)]\n        model: Option<String>,\n    },\n    Message {\n        role: String,\n        id: String,\n        text: String,\n        timestamp: u64,\n        session_id: String,\n    },\n    ToolCall {\n        id: String,\n        #[serde(rename = \"messageId\")]\n        message_id: String,\n        #[serde(rename = \"toolId\")]\n        tool_id: String,\n        #[serde(rename = \"toolName\")]\n        tool_name: String,\n        parameters: Value,\n        timestamp: u64,\n        session_id: String,\n    },\n    ToolResult {\n        #[serde(default)]\n        id: Option<String>,\n        #[serde(rename = \"messageId\")]\n        message_id: String,\n        #[serde(rename = \"toolId\")]\n        tool_id: String,\n        #[serde(rename = \"isError\")]\n        is_error: bool,\n        #[serde(flatten)]\n        payload: ToolResultPayload,\n        timestamp: u64,\n        session_id: String,\n    },\n    Error {\n        source: String,\n        message: String,\n        timestamp: u64,\n    },\n    Completion {\n        #[serde(rename = \"finalText\")]\n        final_text: String,\n        #[serde(default, rename = \"numTurns\")]\n        num_turns: Option<u32>,\n        #[serde(default, rename = \"durationMs\")]\n        duration_ms: Option<u64>,\n        #[serde(default)]\n        timestamp: Option<u64>,\n        session_id: String,\n    },\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\nstruct DroidErrorLog {\n    pub level: String,\n    pub error: DroidErrorDetail,\n    #[serde(default)]\n    pub path: Option<String>,\n    #[serde(default)]\n    pub tags: Option<serde_json::Value>,\n    #[serde(default)]\n    pub msg: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\nstruct DroidErrorDetail {\n    #[serde(default)]\n    pub name: Option<String>,\n    pub message: String,\n    #[serde(default)]\n    pub stack: Option<String>,\n}\n\nimpl DroidJson {\n    pub fn session_id(&self) -> Option<&str> {\n        match self {\n            DroidJson::System { .. } => None, // session might not have been initialized yet\n            DroidJson::Message { session_id, .. } => Some(session_id),\n            DroidJson::ToolCall { session_id, .. } => Some(session_id),\n            DroidJson::ToolResult { session_id, .. } => Some(session_id),\n            DroidJson::Completion { session_id, .. } => Some(session_id),\n            DroidJson::Error { .. } => None,\n        }\n    }\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\n#[serde(tag = \"toolName\", content = \"parameters\")]\npub enum DroidToolData {\n    Read {\n        #[serde(alias = \"path\")]\n        file_path: String,\n    },\n    LS {\n        directory_path: String,\n        #[serde(default)]\n        #[serde(rename = \"ignorePatterns\")]\n        ignore_patterns: Option<Vec<String>>,\n    },\n    Glob {\n        folder: String,\n        patterns: Vec<String>,\n        #[serde(default)]\n        #[serde(rename = \"excludePatterns\")]\n        exclude_patterns: Option<Vec<String>>,\n    },\n    Grep {\n        pattern: String,\n        #[serde(default)]\n        path: Option<String>,\n        #[serde(default)]\n        #[serde(rename = \"caseSensitive\")]\n        case_sensitive: Option<bool>,\n    },\n    Execute {\n        command: String,\n        #[serde(default)]\n        timeout: Option<u64>,\n        #[serde(default)]\n        #[serde(rename = \"riskLevel\")]\n        risk_level: Option<Value>,\n    },\n    Edit {\n        #[serde(alias = \"path\")]\n        file_path: String,\n        #[serde(alias = \"old_str\")]\n        old_string: String,\n        #[serde(alias = \"new_str\")]\n        new_string: String,\n    },\n    MultiEdit {\n        #[serde(alias = \"path\")]\n        file_path: String,\n        #[serde(alias = \"changes\")]\n        edits: Vec<DroidEditItem>,\n    },\n    Create {\n        #[serde(alias = \"path\")]\n        file_path: String,\n        content: String,\n    },\n    ApplyPatch {\n        input: String,\n    },\n    TodoWrite {\n        todos: Vec<DroidTodoItem>,\n    },\n    WebSearch {\n        query: String,\n        #[serde(default)]\n        max_results: Option<u32>,\n    },\n    FetchUrl {\n        url: String,\n        #[serde(default)]\n        method: Option<String>,\n    },\n    ExitSpecMode {\n        #[serde(default)]\n        reason: Option<String>,\n    },\n    #[serde(rename = \"slack_post_message\")]\n    SlackPostMessage {\n        channel: String,\n        text: String,\n    },\n    #[serde(untagged)]\n    Unknown {\n        #[serde(flatten)]\n        data: std::collections::HashMap<String, Value>,\n    },\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct DroidTodoItem {\n    #[serde(default)]\n    pub id: Option<String>,\n    pub content: String,\n    pub status: String,\n    #[serde(default)]\n    pub priority: Option<String>,\n}\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]\npub struct DroidEditItem {\n    pub old_string: Option<String>,\n    pub new_string: Option<String>,\n}\n\ntrait ToNormalizedEntry {\n    fn to_normalized_entry(&self) -> NormalizedEntry;\n}\n\n#[derive(Debug, Clone)]\nstruct FileReadState {\n    index: Option<usize>,\n    path: String,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for FileReadState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"read\".to_string(),\n                action_type: ActionType::FileRead {\n                    path: self.path.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: self.path.clone(),\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct FileEditState {\n    index: Option<usize>,\n    path: String,\n    changes: Vec<FileChange>,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for FileEditState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"edit\".to_string(),\n                action_type: ActionType::FileEdit {\n                    path: self.path.clone(),\n                    changes: self.changes.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: self.path.clone(),\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct CommandRunState {\n    index: Option<usize>,\n    command: String,\n    output: String,\n    status: ToolStatus,\n    exit_code: Option<i32>,\n}\n\nimpl ToNormalizedEntry for CommandRunState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        let result = if self.output.is_empty() && self.exit_code.is_none() {\n            None\n        } else {\n            Some(CommandRunResult {\n                exit_status: self\n                    .exit_code\n                    .map(|code| CommandExitStatus::ExitCode { code }),\n                output: if self.output.is_empty() {\n                    None\n                } else {\n                    Some(self.output.clone())\n                },\n            })\n        };\n\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"bash\".to_string(),\n                action_type: ActionType::CommandRun {\n                    command: self.command.clone(),\n                    result,\n                    category: CommandCategory::from_command(&self.command),\n                },\n                status: self.status.clone(),\n            },\n            content: self.command.clone(),\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct TodoManagementState {\n    index: Option<usize>,\n    todos: Vec<TodoItem>,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for TodoManagementState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        let content = if self.todos.is_empty() {\n            \"TODO list updated\".to_string()\n        } else {\n            format!(\"TODO list updated ({} items)\", self.todos.len())\n        };\n\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"todo\".to_string(),\n                action_type: ActionType::TodoManagement {\n                    todos: self.todos.clone(),\n                    operation: \"update\".to_string(),\n                },\n                status: self.status.clone(),\n            },\n            content,\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct SearchState {\n    index: Option<usize>,\n    query: String,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for SearchState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"search\".to_string(),\n                action_type: ActionType::Search {\n                    query: self.query.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: self.query.clone(),\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct WebFetchState {\n    index: Option<usize>,\n    url: String,\n    status: ToolStatus,\n}\n\nimpl ToNormalizedEntry for WebFetchState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"fetch\".to_string(),\n                action_type: ActionType::WebFetch {\n                    url: self.url.clone(),\n                },\n                status: self.status.clone(),\n            },\n            content: self.url.clone(),\n            metadata: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct GenericToolState {\n    index: Option<usize>,\n    name: String,\n    arguments: Option<Value>,\n    status: ToolStatus,\n    result: Option<Value>,\n}\n\nimpl ToNormalizedEntry for GenericToolState {\n    fn to_normalized_entry(&self) -> NormalizedEntry {\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: self.name.clone(),\n                action_type: ActionType::Tool {\n                    tool_name: self.name.clone(),\n                    arguments: self.arguments.clone(),\n                    result: self.result.clone().map(|value| {\n                        if let Some(str) = value.as_str() {\n                            ToolResult::markdown(str)\n                        } else {\n                            ToolResult::json(value)\n                        }\n                    }),\n                },\n                status: self.status.clone(),\n            },\n            content: self.name.clone(),\n            metadata: None,\n        }\n    }\n}\n\ntype ToolCallId = String;\n\n#[derive(Debug, Clone)]\nenum PendingToolCall {\n    Read { tool_call_id: ToolCallId },\n    FileEdit { tool_call_id: ToolCallId },\n    CommandRun { tool_call_id: ToolCallId },\n    Todo { tool_call_id: ToolCallId },\n    Search { tool_call_id: ToolCallId },\n    Fetch { tool_call_id: ToolCallId },\n    Generic { tool_call_id: ToolCallId },\n}\n\n// Tracks tool-calls from creation to completion updating tool arguments and results as they come in\n#[derive(Debug, Clone)]\nstruct ToolCallStates {\n    entry_index: EntryIndexProvider,\n    file_reads: HashMap<String, FileReadState>,\n    file_edits: HashMap<String, FileEditState>,\n    command_runs: HashMap<String, CommandRunState>,\n    todo_updates: HashMap<String, TodoManagementState>,\n    searches: HashMap<String, SearchState>,\n    web_fetches: HashMap<String, WebFetchState>,\n    generic_tools: HashMap<String, GenericToolState>,\n    pending_fifo: VecDeque<PendingToolCall>,\n    model_reported: bool,\n}\n\nimpl ToolCallStates {\n    fn new(entry_index: EntryIndexProvider) -> Self {\n        Self {\n            entry_index,\n            file_reads: HashMap::new(),\n            file_edits: HashMap::new(),\n            command_runs: HashMap::new(),\n            todo_updates: HashMap::new(),\n            searches: HashMap::new(),\n            web_fetches: HashMap::new(),\n            generic_tools: HashMap::new(),\n            pending_fifo: VecDeque::new(),\n            model_reported: false,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/droid.rs",
    "content": "use std::{path::Path, process::Stdio, sync::Arc};\n\nuse async_trait::async_trait;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse strum_macros::AsRefStr;\nuse tokio::{io::AsyncWriteExt, process::Command};\nuse ts_rs::TS;\nuse workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore};\n\nuse crate::{\n    command::{CommandBuildError, CommandBuilder, CommandParts},\n    env::ExecutionEnv,\n    executor_discovery::ExecutorDiscoveredOptions,\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild,\n        StandardCodingAgentExecutor,\n    },\n    logs::utils::{EntryIndexProvider, patch},\n    model_selector::{ModelInfo, ModelSelectorConfig},\n    profile::ExecutorConfig,\n};\n\npub mod normalize_logs;\n\nuse normalize_logs::normalize_logs;\n\n// Configuration types for Droid executor\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, TS, JsonSchema)]\n#[serde(rename_all = \"kebab-case\")]\npub enum Autonomy {\n    Normal,\n    Low,\n    Medium,\n    High,\n    SkipPermissionsUnsafe,\n}\n\nfn default_autonomy() -> Autonomy {\n    Autonomy::SkipPermissionsUnsafe\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)]\n#[serde(rename_all = \"lowercase\")]\n#[strum(serialize_all = \"lowercase\")]\n#[ts(rename = \"DroidReasoningEffort\")]\npub enum ReasoningEffortLevel {\n    None,\n    Dynamic,\n    Off,\n    Low,\n    Medium,\n    High,\n}\n\n/// Droid executor configuration\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]\npub struct Droid {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n\n    #[serde(default = \"default_autonomy\")]\n    #[schemars(\n        title = \"Autonomy Level\",\n        description = \"Permission level for file and system operations\"\n    )]\n    pub autonomy: Autonomy,\n\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    #[schemars(\n        title = \"Model\",\n        description = \"Model to use (e.g., gpt-5-codex, claude-sonnet-4-5-20250929, gpt-5-2025-08-07, claude-opus-4-1-20250805, claude-haiku-4-5-20251001, glm-4.6)\"\n    )]\n    pub model: Option<String>,\n\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    #[schemars(\n        title = \"Reasoning Effort\",\n        description = \"Reasoning effort level: none, dynamic, off, low, medium, high\"\n    )]\n    pub reasoning_effort: Option<ReasoningEffortLevel>,\n\n    #[serde(flatten)]\n    pub cmd: crate::command::CmdOverrides,\n}\n\nimpl Droid {\n    pub fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        use crate::command::{CommandBuilder, apply_overrides};\n        let mut builder =\n            CommandBuilder::new(\"droid exec\").params([\"--output-format\", \"stream-json\"]);\n        builder = match &self.autonomy {\n            Autonomy::Normal => builder,\n            Autonomy::Low => builder.extend_params([\"--auto\", \"low\"]),\n            Autonomy::Medium => builder.extend_params([\"--auto\", \"medium\"]),\n            Autonomy::High => builder.extend_params([\"--auto\", \"high\"]),\n            Autonomy::SkipPermissionsUnsafe => builder.extend_params([\"--skip-permissions-unsafe\"]),\n        };\n        if let Some(model) = &self.model {\n            builder = builder.extend_params([\"--model\", model.as_str()]);\n        }\n        if let Some(effort) = &self.reasoning_effort {\n            builder = builder.extend_params([\"--reasoning-effort\", effort.as_ref()]);\n        }\n\n        apply_overrides(builder, &self.cmd)\n    }\n}\n\nasync fn spawn_droid(\n    command_parts: CommandParts,\n    prompt: &String,\n    current_dir: &Path,\n    env: &ExecutionEnv,\n    cmd_overrides: &crate::command::CmdOverrides,\n) -> Result<SpawnedChild, ExecutorError> {\n    let (program_path, args) = command_parts.into_resolved().await?;\n\n    let mut command = Command::new(program_path);\n    command\n        .kill_on_drop(true)\n        .stdin(Stdio::piped())\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped())\n        .current_dir(current_dir)\n        .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n        .args(args);\n\n    env.clone()\n        .with_profile(cmd_overrides)\n        .apply_to_command(&mut command);\n\n    let mut child = command.group_spawn_no_window()?;\n\n    if let Some(mut stdin) = child.inner().stdin.take() {\n        stdin.write_all(prompt.as_bytes()).await?;\n        stdin.shutdown().await?;\n    }\n\n    Ok(child.into())\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for Droid {\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n        if let Some(permission_policy) = executor_config.permission_policy.clone() {\n            self.autonomy = match permission_policy {\n                crate::model_selector::PermissionPolicy::Auto => Autonomy::SkipPermissionsUnsafe,\n                crate::model_selector::PermissionPolicy::Supervised\n                | crate::model_selector::PermissionPolicy::Plan => Autonomy::Normal,\n            };\n        }\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let droid_command = self.build_command_builder()?.build_initial()?;\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n\n        spawn_droid(droid_command, &combined_prompt, current_dir, env, &self.cmd).await\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let continue_cmd = self\n            .build_command_builder()?\n            .build_follow_up(&[\"--session-id\".to_string(), session_id.to_string()])?;\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n\n        spawn_droid(continue_cmd, &combined_prompt, current_dir, env, &self.cmd).await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        current_dir: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        normalize_logs(\n            msg_store.clone(),\n            current_dir,\n            EntryIndexProvider::start_from(&msg_store),\n        )\n    }\n\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        dirs::home_dir().map(|home| home.join(\".factory\").join(\"mcp.json\"))\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        let mcp_config_found = self\n            .default_mcp_config_path()\n            .map(|p| p.exists())\n            .unwrap_or(false);\n\n        let installation_indicator_found = dirs::home_dir()\n            .map(|home| home.join(\".factory\").join(\"installation_id\").exists())\n            .unwrap_or(false);\n\n        if mcp_config_found || installation_indicator_found {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        ExecutorConfig {\n            executor: BaseCodingAgent::Droid,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: None,\n            reasoning_id: self\n                .reasoning_effort\n                .as_ref()\n                .map(|e| e.as_ref().to_string()),\n            permission_policy: Some(crate::model_selector::PermissionPolicy::Auto),\n        }\n    }\n\n    async fn discover_options(\n        &self,\n        _workdir: Option<&std::path::Path>,\n        _repo_path: Option<&std::path::Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        let options = ExecutorDiscoveredOptions {\n            model_selector: ModelSelectorConfig {\n                models: [\n                    (\"claude-opus-4-6\", \"Claude Opus 4.6\"),\n                    (\"claude-opus-4-6-fast\", \"Claude Opus 4.6 Fast Mode\"),\n                    (\"gemini-3.1-pro-preview\", \"Gemini 3.1 Pro\"),\n                    (\"glm-5\", \"GLM-5\"),\n                    (\"gpt-5.3-codex\", \"GPT 5.3 Codex\"),\n                    (\"claude-sonnet-4-6\", \"Claude Sonnet 4.6\"),\n                    (\"kimi-k2.5\", \"Kimi K2.5\"),\n                    (\"minimax-m2.5\", \"MiniMax M2.5\"),\n                    (\"glm-4.7\", \"GLM-4.7\"),\n                    (\"claude-opus-4-5-20251101\", \"Claude Opus 4.5\"),\n                    (\"claude-sonnet-4-5-20250929\", \"Claude Sonnet 4.5\"),\n                    (\"claude-haiku-4-5-20251001\", \"Claude Haiku 4.5\"),\n                    (\"gpt-5.2-codex\", \"GPT 5.2 Codex\"),\n                    (\"gpt-5.2\", \"GPT 5.2\"),\n                    (\"gemini-3-pro-preview\", \"Gemini 3 Pro\"),\n                    (\"gemini-3-flash-preview\", \"Gemini 3 Flash\"),\n                    (\"gpt-5.1-codex\", \"GPT 5.1 Codex\"),\n                    (\"gpt-5.1-codex-max\", \"GPT 5.1 Codex Max\"),\n                    (\"gpt-5.1\", \"GPT 5.1\"),\n                ]\n                .into_iter()\n                .map(|(id, name)| ModelInfo {\n                    id: id.to_string(),\n                    name: name.to_string(),\n                    provider_id: None,\n                    reasoning_options: vec![],\n                })\n                .collect(),\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        Ok(Box::pin(futures::stream::once(async move {\n            patch::executor_discovered_options(options)\n        })))\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/gemini.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse derivative::Derivative;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse workspace_utils::msg_store::MsgStore;\n\npub use super::acp::AcpAgentHarness;\nuse crate::{\n    approvals::ExecutorApprovalService,\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides},\n    env::ExecutionEnv,\n    executor_discovery::ExecutorDiscoveredOptions,\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild,\n        StandardCodingAgentExecutor,\n    },\n    logs::utils::patch,\n    model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy},\n    profile::ExecutorConfig,\n};\n\nconst SUPPRESSED_STDERR_PATTERNS: &[&str] = &[\n    \"was started but never ended. Skipping metrics.\",\n    \"YOLO mode is enabled. All tool calls will be automatically approved.\",\n];\n\n#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]\n#[derivative(Debug, PartialEq)]\npub struct Gemini {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub yolo: Option<bool>,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n    #[serde(skip)]\n    #[ts(skip)]\n    #[derivative(Debug = \"ignore\", PartialEq = \"ignore\")]\n    pub approvals: Option<Arc<dyn ExecutorApprovalService>>,\n}\n\nimpl Gemini {\n    fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        let mut builder = CommandBuilder::new(\"npx -y @google/gemini-cli@0.29.3\");\n\n        if let Some(model) = &self.model {\n            builder = builder.extend_params([\"--model\", model.as_str()]);\n        }\n\n        if self.yolo.unwrap_or(false) {\n            builder = builder.extend_params([\"--yolo\"]);\n            builder = builder.extend_params([\"--allowed-tools\", \"run_shell_command\"]);\n        }\n\n        builder = builder.extend_params([\"--experimental-acp\"]);\n\n        apply_overrides(builder, &self.cmd)\n    }\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for Gemini {\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n        if let Some(permission_policy) = executor_config.permission_policy.clone() {\n            self.yolo = Some(matches!(\n                permission_policy,\n                crate::model_selector::PermissionPolicy::Auto\n            ));\n        }\n    }\n\n    fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {\n        self.approvals = Some(approvals);\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let harness = AcpAgentHarness::new();\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n        let gemini_command = self.build_command_builder()?.build_initial()?;\n        let approvals = if self.yolo.unwrap_or(false) {\n            None\n        } else {\n            self.approvals.clone()\n        };\n        harness\n            .spawn_with_command(\n                current_dir,\n                combined_prompt,\n                gemini_command,\n                env,\n                &self.cmd,\n                approvals,\n            )\n            .await\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let harness = AcpAgentHarness::new();\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n        let gemini_command = self.build_command_builder()?.build_follow_up(&[])?;\n        let approvals = if self.yolo.unwrap_or(false) {\n            None\n        } else {\n            self.approvals.clone()\n        };\n        harness\n            .spawn_follow_up_with_command(\n                current_dir,\n                combined_prompt,\n                session_id,\n                gemini_command,\n                env,\n                &self.cmd,\n                approvals,\n            )\n            .await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        worktree_path: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        super::acp::normalize_logs_with_suppressed_stderr_patterns(\n            msg_store,\n            worktree_path,\n            SUPPRESSED_STDERR_PATTERNS,\n        )\n    }\n\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        dirs::home_dir().map(|home| home.join(\".gemini\").join(\"settings.json\"))\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        if let Some(timestamp) = dirs::home_dir()\n            .and_then(|home| std::fs::metadata(home.join(\".gemini\").join(\"oauth_creds.json\")).ok())\n            .and_then(|m| m.modified().ok())\n            .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())\n            .map(|d| d.as_secs() as i64)\n        {\n            return AvailabilityInfo::LoginDetected {\n                last_auth_timestamp: timestamp,\n            };\n        }\n\n        let mcp_config_found = self\n            .default_mcp_config_path()\n            .map(|p| p.exists())\n            .unwrap_or(false);\n\n        let installation_indicator_found = dirs::home_dir()\n            .map(|home| home.join(\".gemini\").join(\"installation_id\").exists())\n            .unwrap_or(false);\n\n        if mcp_config_found || installation_indicator_found {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        use crate::model_selector::*;\n        ExecutorConfig {\n            executor: BaseCodingAgent::Gemini,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: None,\n            reasoning_id: None,\n            permission_policy: Some(if self.yolo.unwrap_or(false) {\n                PermissionPolicy::Auto\n            } else {\n                PermissionPolicy::Supervised\n            }),\n        }\n    }\n\n    async fn discover_options(\n        &self,\n        _workdir: Option<&std::path::Path>,\n        _repo_path: Option<&std::path::Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        let options = ExecutorDiscoveredOptions {\n            model_selector: ModelSelectorConfig {\n                models: vec![\n                    ModelInfo {\n                        id: \"gemini-3.1-pro-preview\".to_string(),\n                        name: \"Gemini 3.1 Pro Preview\".to_string(),\n                        provider_id: None,\n                        reasoning_options: vec![],\n                    },\n                    ModelInfo {\n                        id: \"gemini-3-pro-preview\".to_string(),\n                        name: \"Gemini 3 Pro\".to_string(),\n                        provider_id: None,\n                        reasoning_options: vec![],\n                    },\n                    ModelInfo {\n                        id: \"gemini-3-flash-preview\".to_string(),\n                        name: \"Gemini 3 Flash\".to_string(),\n                        provider_id: None,\n                        reasoning_options: vec![],\n                    },\n                ],\n                default_model: Some(\"gemini-3-pro-preview\".to_string()),\n                permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised],\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        Ok(Box::pin(futures::stream::once(async move {\n            patch::executor_discovered_options(options)\n        })))\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/mod.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse command_group::AsyncGroupChild;\nuse enum_dispatch::enum_dispatch;\nuse futures::stream::BoxStream;\nuse futures_io::Error as FuturesIoError;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse sqlx::Type;\nuse strum_macros::{Display, EnumDiscriminants, EnumString, VariantNames};\nuse thiserror::Error;\nuse tokio::task::JoinHandle;\nuse ts_rs::TS;\nuse workspace_utils::msg_store::MsgStore;\n\n#[cfg(feature = \"qa-mode\")]\nuse crate::executors::qa_mock::QaMockExecutor;\nuse crate::{\n    actions::{ExecutorAction, review::RepoReviewContext},\n    approvals::ExecutorApprovalService,\n    command::CommandBuildError,\n    env::ExecutionEnv,\n    executors::{\n        amp::Amp, claude::ClaudeCode, codex::Codex, copilot::Copilot, cursor::CursorAgent,\n        droid::Droid, gemini::Gemini, opencode::Opencode, qwen::QwenCode,\n    },\n    logs::utils::patch,\n    mcp_config::McpConfig,\n    profile::ExecutorConfig,\n};\n\npub mod acp;\npub mod amp;\npub mod claude;\npub mod codex;\npub mod copilot;\npub mod cursor;\npub mod droid;\npub mod gemini;\npub mod opencode;\n#[cfg(feature = \"qa-mode\")]\npub mod qa_mock;\npub mod qwen;\npub mod utils;\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]\npub struct SlashCommandDescription {\n    /// Command name without the leading slash, e.g. `help` for `/help`.\n    pub name: String,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[ts(use_ts_enum)]\npub enum BaseAgentCapability {\n    SessionFork,\n    /// Agent requires a setup script before it can run (e.g., login, installation)\n    SetupHelper,\n    /// Agent reports context/token usage information\n    ContextUsage,\n}\n\n#[derive(Debug, Error)]\npub enum ExecutorError {\n    #[error(\"Follow-up is not supported: {0}\")]\n    FollowUpNotSupported(String),\n    #[error(transparent)]\n    SpawnError(#[from] FuturesIoError),\n    #[error(\"Unknown executor type: {0}\")]\n    UnknownExecutorType(String),\n    #[error(\"I/O error: {0}\")]\n    Io(std::io::Error),\n    #[error(transparent)]\n    Json(#[from] serde_json::Error),\n    #[error(transparent)]\n    TomlSerialize(#[from] toml::ser::Error),\n    #[error(transparent)]\n    TomlDeserialize(#[from] toml::de::Error),\n    #[error(transparent)]\n    ExecutorApprovalError(#[from] crate::approvals::ExecutorApprovalError),\n    #[error(transparent)]\n    CommandBuild(#[from] CommandBuildError),\n    #[error(\"Executable `{program}` not found in PATH\")]\n    ExecutableNotFound { program: String },\n    #[error(\"Setup helper not supported\")]\n    SetupHelperNotSupported,\n    #[error(\"Auth required: {0}\")]\n    AuthRequired(String),\n}\n\n#[enum_dispatch]\n#[derive(\n    Debug, Clone, Serialize, Deserialize, PartialEq, TS, Display, EnumDiscriminants, VariantNames,\n)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[strum(serialize_all = \"SCREAMING_SNAKE_CASE\")]\n#[strum_discriminants(\n    name(BaseCodingAgent),\n    // Only add Hash; Eq/PartialEq are already provided by EnumDiscriminants.\n    derive(EnumString, Hash, strum_macros::Display, Serialize, Deserialize, TS, Type),\n    strum(serialize_all = \"SCREAMING_SNAKE_CASE\"),\n    ts(use_ts_enum),\n    serde(rename_all = \"SCREAMING_SNAKE_CASE\"),\n    sqlx(type_name = \"TEXT\", rename_all = \"SCREAMING_SNAKE_CASE\")\n)]\npub enum CodingAgent {\n    ClaudeCode,\n    Amp,\n    Gemini,\n    Codex,\n    Opencode,\n    #[serde(alias = \"CURSOR\")]\n    #[strum_discriminants(serde(alias = \"CURSOR\"))]\n    #[strum_discriminants(strum(serialize = \"CURSOR\", serialize = \"CURSOR_AGENT\"))]\n    CursorAgent,\n    QwenCode,\n    Copilot,\n    Droid,\n    #[cfg(feature = \"qa-mode\")]\n    QaMock(QaMockExecutor),\n}\n\nimpl CodingAgent {\n    pub fn get_mcp_config(&self) -> McpConfig {\n        match self {\n            Self::Codex(_) => McpConfig::new(\n                vec![\"mcp_servers\".to_string()],\n                serde_json::json!({\n                    \"mcp_servers\": {}\n                }),\n                self.preconfigured_mcp(),\n                true,\n            ),\n            Self::Amp(_) => McpConfig::new(\n                vec![\"amp.mcpServers\".to_string()],\n                serde_json::json!({\n                    \"amp.mcpServers\": {}\n                }),\n                self.preconfigured_mcp(),\n                false,\n            ),\n            Self::Opencode(_) => McpConfig::new(\n                vec![\"mcp\".to_string()],\n                serde_json::json!({\n                    \"mcp\": {},\n                    \"$schema\": \"https://opencode.ai/config.json\"\n                }),\n                self.preconfigured_mcp(),\n                false,\n            ),\n            Self::Droid(_) => McpConfig::new(\n                vec![\"mcpServers\".to_string()],\n                serde_json::json!({\n                    \"mcpServers\": {}\n                }),\n                self.preconfigured_mcp(),\n                false,\n            ),\n            _ => McpConfig::new(\n                vec![\"mcpServers\".to_string()],\n                serde_json::json!({\n                    \"mcpServers\": {}\n                }),\n                self.preconfigured_mcp(),\n                false,\n            ),\n        }\n    }\n\n    pub fn supports_mcp(&self) -> bool {\n        self.default_mcp_config_path().is_some()\n    }\n\n    pub fn capabilities(&self) -> Vec<BaseAgentCapability> {\n        match self {\n            Self::ClaudeCode(_) => vec![\n                BaseAgentCapability::SessionFork,\n                BaseAgentCapability::ContextUsage,\n            ],\n            Self::Opencode(_) => vec![\n                BaseAgentCapability::SessionFork,\n                BaseAgentCapability::ContextUsage,\n            ],\n            Self::Codex(_) => vec![\n                BaseAgentCapability::SessionFork,\n                BaseAgentCapability::SetupHelper,\n                BaseAgentCapability::ContextUsage,\n            ],\n            Self::Gemini(_) | Self::QwenCode(_) => {\n                vec![BaseAgentCapability::SessionFork]\n            }\n            Self::CursorAgent(_) => vec![BaseAgentCapability::SetupHelper],\n            Self::Amp(_) | Self::Copilot(_) | Self::Droid(_) => vec![],\n            #[cfg(feature = \"qa-mode\")]\n            Self::QaMock(_) => vec![], // QA mock doesn't need special capabilities\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum AvailabilityInfo {\n    LoginDetected { last_auth_timestamp: i64 },\n    InstallationFound,\n    NotFound,\n}\n\nimpl AvailabilityInfo {\n    pub fn is_available(&self) -> bool {\n        matches!(\n            self,\n            AvailabilityInfo::LoginDetected { .. } | AvailabilityInfo::InstallationFound\n        )\n    }\n}\n\n#[async_trait]\n#[enum_dispatch(CodingAgent)]\npub trait StandardCodingAgentExecutor {\n    fn apply_overrides(&mut self, _executor_config: &ExecutorConfig) {}\n\n    fn use_approvals(&mut self, _approvals: Arc<dyn ExecutorApprovalService>) {}\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError>;\n\n    /// Continue a session, optionally resetting to a specific message.\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError>;\n\n    async fn spawn_review(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        match session_id {\n            Some(id) => {\n                self.spawn_follow_up(current_dir, prompt, id, None, env)\n                    .await\n            }\n            None => self.spawn(current_dir, prompt, env).await,\n        }\n    }\n\n    fn normalize_logs(\n        &self,\n        _raw_logs_event_store: Arc<MsgStore>,\n        _worktree_path: &Path,\n    ) -> Vec<JoinHandle<()>> {\n        vec![]\n    }\n\n    // MCP configuration methods\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf>;\n\n    async fn get_setup_helper_action(&self) -> Result<ExecutorAction, ExecutorError> {\n        Err(ExecutorError::SetupHelperNotSupported)\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        let config_files_found = self\n            .default_mcp_config_path()\n            .map(|path| path.exists())\n            .unwrap_or(false);\n\n        if config_files_found {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    /// Returns a stream of executor discovered options updates.\n    async fn discover_options(\n        &self,\n        _workdir: Option<&Path>,\n        _repo_path: Option<&Path>,\n    ) -> Result<BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        let options = crate::executor_discovery::ExecutorDiscoveredOptions::default();\n        Ok(Box::pin(futures::stream::once(async move {\n            patch::executor_discovered_options(options)\n        })))\n    }\n\n    /// Returns the default overrides defined by this preset/variant.\n    fn get_preset_options(&self) -> ExecutorConfig;\n}\n\n/// Result communicated through the exit signal\n#[derive(Debug, Clone, Copy)]\npub enum ExecutorExitResult {\n    /// Process completed successfully (exit code 0)\n    Success,\n    /// Process should be marked as failed (non-zero exit)\n    Failure,\n}\n\n/// Optional exit notification from an executor.\n/// When this receiver resolves, the container should gracefully stop the process\n/// and mark it according to the result.\npub type ExecutorExitSignal = tokio::sync::oneshot::Receiver<ExecutorExitResult>;\n\n/// Cancellation token for requesting graceful shutdown of an executor.\n/// When cancelled, the executor should attempt to cancel gracefully before being killed.\npub type CancellationToken = tokio_util::sync::CancellationToken;\n\n#[derive(Debug)]\npub struct SpawnedChild {\n    pub child: AsyncGroupChild,\n    /// Executor → Container: signals when executor wants to exit\n    pub exit_signal: Option<ExecutorExitSignal>,\n    /// Container → Executor: signals when container wants to cancel the execution\n    pub cancel: Option<CancellationToken>,\n}\n\nimpl From<AsyncGroupChild> for SpawnedChild {\n    fn from(child: AsyncGroupChild) -> Self {\n        Self {\n            child,\n            exit_signal: None,\n            cancel: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]\n#[serde(transparent)]\n#[schemars(\n    title = \"Append Prompt\",\n    description = \"Extra text appended to the prompt\",\n    extend(\"format\" = \"textarea\")\n)]\n#[derive(Default)]\npub struct AppendPrompt(pub Option<String>);\n\nimpl AppendPrompt {\n    pub fn get(&self) -> Option<String> {\n        self.0.clone()\n    }\n\n    pub fn combine_prompt(&self, prompt: &str) -> String {\n        match self {\n            AppendPrompt(Some(value)) => format!(\"{prompt}{value}\"),\n            AppendPrompt(None) => prompt.to_string(),\n        }\n    }\n}\n\npub fn build_review_prompt(\n    context: Option<&[RepoReviewContext]>,\n    additional_prompt: Option<&str>,\n) -> String {\n    let mut prompt = String::from(\"Please review the code changes.\\n\\n\");\n\n    if let Some(repos) = context {\n        for repo in repos {\n            prompt.push_str(&format!(\"Repository: {}\\n\", repo.repo_name));\n            prompt.push_str(&format!(\n                \"Review all changes from base commit {} to HEAD.\\n\",\n                repo.base_commit\n            ));\n            prompt.push_str(&format!(\n                \"Use `git diff {}..HEAD` to see the changes.\\n\",\n                repo.base_commit\n            ));\n            prompt.push('\\n');\n        }\n    }\n\n    if let Some(additional) = additional_prompt {\n        prompt.push_str(additional);\n    }\n\n    prompt\n}\n\n#[cfg(test)]\nmod tests {\n    use std::str::FromStr;\n\n    use super::*;\n\n    #[test]\n    fn test_cursor_agent_deserialization() {\n        // Test that CURSOR_AGENT is accepted\n        let result = BaseCodingAgent::from_str(\"CURSOR_AGENT\");\n        assert!(result.is_ok(), \"CURSOR_AGENT should be valid\");\n        assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent);\n\n        // Test that legacy CURSOR is still accepted for backwards compatibility\n        let result = BaseCodingAgent::from_str(\"CURSOR\");\n        assert!(\n            result.is_ok(),\n            \"CURSOR should be valid for backwards compatibility\"\n        );\n        assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent);\n\n        // Test serde deserialization for CURSOR_AGENT\n        let result: Result<BaseCodingAgent, _> = serde_json::from_str(r#\"\"CURSOR_AGENT\"\"#);\n        assert!(result.is_ok(), \"CURSOR_AGENT should deserialize via serde\");\n        assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent);\n\n        // Test serde deserialization for legacy CURSOR\n        let result: Result<BaseCodingAgent, _> = serde_json::from_str(r#\"\"CURSOR\"\"#);\n        assert!(result.is_ok(), \"CURSOR should deserialize via serde\");\n        assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent);\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/opencode/models.rs",
    "content": "use std::{\n    collections::{HashMap, HashSet},\n    sync::{LazyLock, Mutex},\n};\n\nuse serde_json::Value;\n\nuse crate::executors::opencode::{\n    sdk::{EventStreamContext, list_providers},\n    types::{MessageRole, OpencodeExecutorEvent, ProviderListResponse, SdkEvent},\n};\n\ntype ProviderId = String;\ntype ModelId = String;\ntype ContextWindowTokens = u32;\n\n// Maps (Provider, Model) -> Context Window\npub(super) type ModelContextWindows = HashMap<(ProviderId, ModelId), ContextWindowTokens>;\n\n/// Cache entry for model context windows.\n/// Keyed by a config-derived cache key (based on env vars + base command)\n/// rather than directory, since configuration determines available models.\n#[derive(Default)]\nstruct ModelCacheEntry {\n    context_windows: ModelContextWindows,\n    /// Negative cache for models that were requested but not found.\n    /// Prevents repeated API calls for models that don't return context info.\n    unknown_models: HashSet<(ProviderId, ModelId)>,\n}\n\nstruct ModelContextCache {\n    entries: Mutex<HashMap<String, ModelCacheEntry>>,\n}\n\nimpl ModelContextCache {\n    fn new() -> Self {\n        Self {\n            entries: Mutex::new(HashMap::new()),\n        }\n    }\n\n    fn get(&self, cache_key: &str, provider: &str, model: &str) -> Option<u32> {\n        let map = self.entries.lock().unwrap();\n        let entry = map.get(cache_key)?;\n\n        entry\n            .context_windows\n            .get(&(provider.to_string(), model.to_string()))\n            .copied()\n            .or_else(|| {\n                entry\n                    .unknown_models\n                    .contains(&(provider.to_string(), model.to_string()))\n                    .then_some(0)\n            })\n    }\n\n    fn update(\n        &self,\n        cache_key: &str,\n        provider: &str,\n        model: &str,\n        fetched_windows: ModelContextWindows,\n    ) -> u32 {\n        let mut cache = self.entries.lock().unwrap();\n        let entry = cache.entry(cache_key.to_string()).or_default();\n\n        entry.context_windows.extend(fetched_windows);\n        entry\n            .unknown_models\n            .retain(|key| !entry.context_windows.contains_key(key));\n\n        entry\n            .context_windows\n            .get(&(provider.to_string(), model.to_string()))\n            .copied()\n            .unwrap_or_else(|| {\n                entry\n                    .unknown_models\n                    .insert((provider.to_string(), model.to_string()));\n                0\n            })\n    }\n}\n\nstatic CONTEXT_WINDOWS_CACHE: LazyLock<ModelContextCache> = LazyLock::new(ModelContextCache::new);\n\nasync fn get_model_context_window(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    cache_key: &str,\n    provider_id: &str,\n    model_id: &str,\n) -> u32 {\n    if let Some(cached) = CONTEXT_WINDOWS_CACHE.get(cache_key, provider_id, model_id) {\n        return cached;\n    }\n\n    let Some(fetched) = fetch_model_context_windows(client, base_url, directory).await else {\n        return 0;\n    };\n    CONTEXT_WINDOWS_CACHE.update(cache_key, provider_id, model_id, fetched)\n}\n\npub(super) async fn maybe_emit_token_usage(context: &EventStreamContext<'_>, event: &Value) {\n    let Some(SdkEvent::MessageUpdated(event)) = SdkEvent::parse(event) else {\n        return;\n    };\n    let message = event.info;\n\n    if message.role != MessageRole::Assistant {\n        return;\n    }\n\n    let Some(ref tokens) = message.tokens else {\n        return;\n    };\n\n    let total_tokens =\n        tokens.input + tokens.output + tokens.cache.as_ref().map(|c| c.read).unwrap_or(0);\n\n    if total_tokens == 0 {\n        return;\n    }\n\n    let provider_id = message.provider_id();\n    let model_id = message.model_id();\n\n    let model_context_window = match (provider_id, model_id) {\n        (Some(provider), Some(model)) => {\n            get_model_context_window(\n                context.client,\n                context.base_url,\n                context.directory,\n                context.models_cache_key,\n                provider,\n                model,\n            )\n            .await\n        }\n        _ => 0,\n    };\n\n    if model_context_window == 0 {\n        return;\n    }\n\n    let _ = context\n        .log_writer\n        .log_event(&OpencodeExecutorEvent::TokenUsage {\n            total_tokens,\n            model_context_window,\n        })\n        .await;\n}\n\npub(super) fn extract_context_windows(data: &ProviderListResponse) -> ModelContextWindows {\n    let mut windows = ModelContextWindows::new();\n    for provider in &data.all {\n        for (model_id, info) in &provider.models {\n            if info.limit.context > 0 {\n                windows.insert((provider.id.clone(), model_id.clone()), info.limit.context);\n            }\n        }\n    }\n    windows\n}\n\npub(super) fn seed_context_windows_cache(cache_key: &str, windows: ModelContextWindows) {\n    let mut cache = CONTEXT_WINDOWS_CACHE.entries.lock().unwrap();\n    let entry = cache.entry(cache_key.to_string()).or_default();\n    entry.context_windows.extend(windows);\n}\n\nasync fn fetch_model_context_windows(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Option<ModelContextWindows> {\n    let parsed = match list_providers(client, base_url, directory).await {\n        Ok(p) => p,\n        Err(err) => {\n            tracing::debug!(\"OpenCode provider list request failed: {err}\");\n            return None;\n        }\n    };\n    Some(extract_context_windows(&parsed))\n}\n"
  },
  {
    "path": "crates/executors/src/executors/opencode/normalize_logs.rs",
    "content": "use std::{collections::HashMap, path::Path, sync::Arc};\n\ntype MessageId = String;\ntype PartId = String;\n\nuse futures::StreamExt;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse workspace_utils::{\n    approvals::{ApprovalStatus, QuestionStatus},\n    msg_store::MsgStore,\n    path::make_path_relative,\n};\n\nuse super::types::{\n    MessageInfo, MessagePartDeltaEvent, MessageRole, OpencodeExecutorEvent, Part,\n    PermissionAskedEvent, QuestionInfo, SdkEvent, SdkTodo, SessionStatus, ToolPart,\n    ToolStateUpdate,\n};\nuse crate::{\n    approvals::ToolCallMetadata,\n    logs::{\n        ActionType, AnsweredQuestion, AskUserQuestionItem, AskUserQuestionOption,\n        CommandExitStatus, CommandRunResult, FileChange, NormalizedEntry, NormalizedEntryError,\n        NormalizedEntryType, TodoItem, TokenUsageInfo, ToolResult, ToolStatus,\n        stderr_processor::normalize_stderr_logs,\n        utils::{\n            EntryIndexProvider,\n            patch::{add_normalized_entry, replace_normalized_entry, upsert_normalized_entry},\n            shell_command_parsing::CommandCategory,\n        },\n    },\n};\n\nfn system_message(content: String) -> NormalizedEntry {\n    NormalizedEntry {\n        timestamp: None,\n        entry_type: NormalizedEntryType::SystemMessage,\n        content,\n        metadata: None,\n    }\n}\n\npub fn normalize_logs(\n    msg_store: Arc<MsgStore>,\n    worktree_path: &Path,\n) -> Vec<tokio::task::JoinHandle<()>> {\n    let entry_index = EntryIndexProvider::start_from(&msg_store);\n    let h1 = normalize_stderr_logs(msg_store.clone(), entry_index.clone());\n\n    let worktree_path = worktree_path.to_path_buf();\n    let h2 = tokio::spawn(async move {\n        let mut stored_session_id = false;\n        let mut state = LogState::new(entry_index.clone(), msg_store.clone());\n\n        let mut stdout_lines = msg_store.stdout_lines_stream();\n        while let Some(Ok(line)) = stdout_lines.next().await {\n            let Some(event) = parse_event(&line) else {\n                let trimmed = line.trim();\n                if trimmed.is_empty() {\n                    continue;\n                }\n\n                add_normalized_entry(\n                    &msg_store,\n                    &entry_index,\n                    system_message(trimmed.to_string()),\n                );\n                continue;\n            };\n\n            match event {\n                OpencodeExecutorEvent::StartupLog { .. } => {}\n                OpencodeExecutorEvent::SessionStart { session_id } => {\n                    if !stored_session_id {\n                        msg_store.push_session_id(session_id);\n                        stored_session_id = true;\n                    }\n                }\n                OpencodeExecutorEvent::SdkEvent { event } => {\n                    state.handle_sdk_event(&event, &worktree_path, &msg_store);\n                }\n                OpencodeExecutorEvent::TokenUsage {\n                    total_tokens,\n                    model_context_window,\n                } => {\n                    add_normalized_entry(\n                        &msg_store,\n                        &entry_index,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::TokenUsageInfo(TokenUsageInfo {\n                                total_tokens,\n                                model_context_window,\n                            }),\n                            content: format!(\n                                \"Tokens used: {} / Context window: {}\",\n                                total_tokens, model_context_window\n                            ),\n                            metadata: None,\n                        },\n                    );\n                }\n                OpencodeExecutorEvent::SlashCommandResult { message } => {\n                    let idx = entry_index.next();\n                    state.add_normalized_entry_with_index(\n                        idx,\n                        NormalizedEntry {\n                            timestamp: None,\n                            entry_type: NormalizedEntryType::AssistantMessage,\n                            content: message,\n                            metadata: None,\n                        },\n                    );\n                }\n                OpencodeExecutorEvent::ApprovalRequested {\n                    tool_call_id,\n                    approval_id,\n                }\n                | OpencodeExecutorEvent::QuestionAsked {\n                    tool_call_id,\n                    approval_id,\n                } => {\n                    state.handle_approval_requested(\n                        &tool_call_id,\n                        approval_id,\n                        &worktree_path,\n                        &msg_store,\n                    );\n                }\n                OpencodeExecutorEvent::ApprovalResponse {\n                    tool_call_id,\n                    status,\n                } => {\n                    state.handle_approval_response(\n                        &tool_call_id,\n                        status,\n                        &worktree_path,\n                        &msg_store,\n                    );\n                }\n                OpencodeExecutorEvent::QuestionResponse {\n                    tool_call_id,\n                    status,\n                } => {\n                    state.handle_question_response(\n                        &tool_call_id,\n                        status,\n                        &worktree_path,\n                        &msg_store,\n                    );\n                }\n                OpencodeExecutorEvent::SystemMessage { content } => {\n                    let idx = entry_index.next();\n                    msg_store.push_patch(\n                        crate::logs::utils::ConversationPatch::add_normalized_entry(\n                            idx,\n                            NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::SystemMessage,\n                                content,\n                                metadata: None,\n                            },\n                        ),\n                    );\n                }\n                OpencodeExecutorEvent::Error { message } => {\n                    let idx = entry_index.next();\n                    msg_store.push_patch(\n                        crate::logs::utils::ConversationPatch::add_normalized_entry(\n                            idx,\n                            NormalizedEntry {\n                                timestamp: None,\n                                entry_type: NormalizedEntryType::ErrorMessage {\n                                    error_type: NormalizedEntryError::Other,\n                                },\n                                content: message,\n                                metadata: None,\n                            },\n                        ),\n                    );\n                }\n                OpencodeExecutorEvent::Done => {}\n            }\n        }\n    });\n\n    vec![h1, h2]\n}\n\nfn parse_event(line: &str) -> Option<OpencodeExecutorEvent> {\n    serde_json::from_str::<OpencodeExecutorEvent>(line.trim()).ok()\n}\n\n#[derive(Debug, Clone)]\nstruct StreamingText {\n    index: usize,\n    content: String,\n}\n\n#[derive(Debug, Clone)]\nenum UpdateMode {\n    Append,\n    Set,\n}\n\n#[derive(Debug, Clone, Copy)]\nenum PartKind {\n    AssistantText,\n    Thinking,\n}\n\n#[derive(Default)]\nstruct LogState {\n    entry_index: EntryIndexProvider,\n    msg_store: Arc<MsgStore>,\n    message_roles: HashMap<String, MessageRole>,\n    assistant_text: HashMap<PartId, StreamingText>,\n    thinking_text: HashMap<PartId, StreamingText>,\n    part_kinds: HashMap<PartId, (MessageId, PartKind)>,\n    pending_deltas: HashMap<PartId, Vec<String>>,\n    tool_states: HashMap<String, ToolCallState>,\n    approvals: HashMap<String, ApprovalStatus>,\n    model_system_message_emitted: bool,\n    todo_update_entry: Option<usize>,\n    todo_update_fingerprint: Option<String>,\n    retry_status_fingerprint: Option<String>,\n}\n\nimpl LogState {\n    fn new(entry_index: EntryIndexProvider, msg_store: Arc<MsgStore>) -> Self {\n        Self {\n            entry_index,\n            msg_store,\n            message_roles: HashMap::new(),\n            assistant_text: HashMap::new(),\n            thinking_text: HashMap::new(),\n            part_kinds: HashMap::new(),\n            pending_deltas: HashMap::new(),\n            tool_states: HashMap::new(),\n            approvals: HashMap::new(),\n            model_system_message_emitted: false,\n            todo_update_entry: None,\n            todo_update_fingerprint: None,\n            retry_status_fingerprint: None,\n        }\n    }\n\n    fn handle_sdk_event(&mut self, raw: &Value, worktree_path: &Path, msg_store: &Arc<MsgStore>) {\n        let Some(event) = SdkEvent::parse(raw) else {\n            let raw_text = raw.to_string();\n            if !raw_text.trim().is_empty() {\n                self.add_normalized_entry(system_message(format!(\n                    \"Unrecognized OpenCode SDK event: {raw_text}\"\n                )));\n            }\n            return;\n        };\n\n        match event {\n            SdkEvent::MessageUpdated(event) => {\n                let info = event.info;\n                self.maybe_emit_model_system_message(&info);\n                self.message_roles.insert(info.id, info.role);\n            }\n            SdkEvent::MessagePartUpdated(event) => {\n                self.handle_part_update(\n                    event.part,\n                    event.delta.as_deref(),\n                    worktree_path,\n                    msg_store,\n                );\n            }\n            SdkEvent::MessagePartDelta(event) => {\n                self.handle_part_delta(event, msg_store);\n            }\n            SdkEvent::TodoUpdated(event) => {\n                self.handle_todo_updated(&event.todos, msg_store);\n            }\n            SdkEvent::SessionStatus(event) => {\n                self.handle_session_status(event.status);\n            }\n            SdkEvent::SessionIdle => {}\n            SdkEvent::SessionCompacted => {\n                self.add_normalized_entry(system_message(\"Session compacted\".to_string()));\n            }\n            SdkEvent::PermissionAsked(event) => {\n                self.handle_permission_asked(event, worktree_path, msg_store);\n            }\n            SdkEvent::QuestionAsked(event) => {\n                self.handle_question_asked(event, worktree_path, msg_store);\n            }\n            SdkEvent::PermissionReplied\n            | SdkEvent::MessageRemoved\n            | SdkEvent::MessagePartRemoved\n            | SdkEvent::QuestionReplied\n            | SdkEvent::QuestionRejected\n            | SdkEvent::CommandExecuted\n            | SdkEvent::SessionDiff\n            | SdkEvent::TuiSessionSelect => {}\n            SdkEvent::SessionError(event) => {\n                let (error_type, message) = match event.error {\n                    Some(err) if err.kind() == \"ProviderAuthError\" => (\n                        NormalizedEntryError::SetupRequired,\n                        err.message()\n                            .unwrap_or_else(|| format!(\"OpenCode session error: {}\", err.raw)),\n                    ),\n                    Some(err) => (\n                        NormalizedEntryError::Other,\n                        format!(\"OpenCode session error: {}\", err.raw),\n                    ),\n                    None => (\n                        NormalizedEntryError::Other,\n                        \"OpenCode session error\".to_string(),\n                    ),\n                };\n\n                let idx = self.entry_index.next();\n                self.add_normalized_entry_with_index(\n                    idx,\n                    NormalizedEntry {\n                        timestamp: None,\n                        entry_type: NormalizedEntryType::ErrorMessage { error_type },\n                        content: message,\n                        metadata: None,\n                    },\n                );\n            }\n            SdkEvent::Unknown { type_, properties } => {\n                self.add_normalized_entry(system_message(format!(\n                    \"Unrecognized OpenCode SDK event type `{type_}`: {properties}\"\n                )));\n            }\n        }\n    }\n\n    fn handle_session_status(&mut self, status: SessionStatus) {\n        match status {\n            SessionStatus::Retry {\n                attempt,\n                message,\n                next,\n            } => {\n                let fingerprint = format!(\"{attempt}:{next}:{message}\");\n                if self.retry_status_fingerprint.as_deref() == Some(fingerprint.as_str()) {\n                    return;\n                }\n                self.retry_status_fingerprint = Some(fingerprint);\n\n                self.add_normalized_entry(system_message(format!(\n                    \"OpenCode retry (attempt {attempt}): {message} (next in {next}ms)\"\n                )));\n            }\n            SessionStatus::Idle | SessionStatus::Busy | SessionStatus::Other => {}\n        }\n    }\n\n    fn handle_todo_updated(&mut self, todos: &[SdkTodo], msg_store: &Arc<MsgStore>) {\n        let fingerprint = fingerprint_todos(todos);\n        if self.todo_update_fingerprint.as_deref() == Some(fingerprint.as_str()) {\n            return;\n        }\n        self.todo_update_fingerprint = Some(fingerprint);\n\n        let mapped = todos\n            .iter()\n            .map(|todo| TodoItem {\n                content: todo.content.clone(),\n                status: todo.status.clone(),\n                priority: Some(todo.priority.clone()),\n            })\n            .collect::<Vec<_>>();\n\n        let entry = NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: \"todo\".to_string(),\n                action_type: ActionType::TodoManagement {\n                    todos: mapped,\n                    operation: \"update\".to_string(),\n                },\n                status: ToolStatus::Success,\n            },\n            content: \"TODO list updated\".to_string(),\n            metadata: None,\n        };\n\n        if let Some(index) = self.todo_update_entry {\n            replace_normalized_entry(msg_store, index, entry);\n        } else {\n            let index = add_normalized_entry(msg_store, &self.entry_index, entry);\n            self.todo_update_entry = Some(index);\n        }\n    }\n\n    fn maybe_emit_model_system_message(&mut self, info: &MessageInfo) {\n        if self.model_system_message_emitted {\n            return;\n        }\n\n        let Some(model_id) = info.model_id() else {\n            return;\n        };\n        let Some(provider_id) = info.provider_id() else {\n            return;\n        };\n\n        self.add_normalized_entry(system_message(format!(\n            \"model: {model_id}  provider: {provider_id}\"\n        )));\n        self.model_system_message_emitted = true;\n    }\n\n    fn handle_part_update(\n        &mut self,\n        part: Part,\n        delta: Option<&str>,\n        worktree_path: &Path,\n        msg_store: &Arc<MsgStore>,\n    ) {\n        match part {\n            Part::Text(part) => {\n                let stream_key = part.id.clone().unwrap_or_else(|| part.message_id.clone());\n\n                self.part_kinds.insert(\n                    stream_key.clone(),\n                    (part.message_id.clone(), PartKind::AssistantText),\n                );\n\n                if self.message_roles.get(&part.message_id) == Some(&MessageRole::User) {\n                    return;\n                }\n\n                let buffered = self.pending_deltas.remove(&stream_key);\n\n                let (text, mode) = if let Some(delta) = delta {\n                    (delta, UpdateMode::Append)\n                } else {\n                    (part.text.as_str(), UpdateMode::Set)\n                };\n\n                let entry_index = self.entry_index.clone();\n\n                if let Some(pending) = buffered {\n                    let combined: String = pending.into_iter().collect();\n                    update_streaming_text(\n                        &entry_index,\n                        &combined,\n                        NormalizedEntryType::AssistantMessage,\n                        &stream_key,\n                        &mut self.assistant_text,\n                        msg_store,\n                        UpdateMode::Set,\n                    );\n                }\n\n                update_streaming_text(\n                    &entry_index,\n                    text,\n                    NormalizedEntryType::AssistantMessage,\n                    &stream_key,\n                    &mut self.assistant_text,\n                    msg_store,\n                    mode,\n                );\n            }\n            Part::Reasoning(part) => {\n                let stream_key = part.id.clone().unwrap_or_else(|| part.message_id.clone());\n\n                self.part_kinds.insert(\n                    stream_key.clone(),\n                    (part.message_id.clone(), PartKind::Thinking),\n                );\n\n                let buffered = self.pending_deltas.remove(&stream_key);\n\n                let (text, mode) = if let Some(delta) = delta {\n                    (delta, UpdateMode::Append)\n                } else {\n                    (part.text.as_str(), UpdateMode::Set)\n                };\n\n                let entry_index = self.entry_index.clone();\n\n                if let Some(pending) = buffered {\n                    let combined: String = pending.into_iter().collect();\n                    update_streaming_text(\n                        &entry_index,\n                        &combined,\n                        NormalizedEntryType::Thinking,\n                        &stream_key,\n                        &mut self.thinking_text,\n                        msg_store,\n                        UpdateMode::Set,\n                    );\n                }\n\n                update_streaming_text(\n                    &entry_index,\n                    text,\n                    NormalizedEntryType::Thinking,\n                    &stream_key,\n                    &mut self.thinking_text,\n                    msg_store,\n                    mode,\n                );\n            }\n            Part::Tool(part) => {\n                let part = *part;\n                if part.call_id.trim().is_empty() {\n                    tracing::debug!(\n                        \"Skipping tool part with empty call_id for message_id {}\",\n                        part.message_id\n                    );\n                }\n\n                let tool_state = self\n                    .tool_states\n                    .entry(part.call_id.clone())\n                    .or_insert_with(|| ToolCallState::new(part.call_id.clone()));\n\n                tool_state.set_approval_if_missing(self.approvals.get(&part.call_id).cloned());\n\n                tool_state.update_from_part(part);\n                let entry = tool_state.to_normalized_entry(worktree_path);\n                if let Some(index) = tool_state.index {\n                    replace_normalized_entry(msg_store, index, entry);\n                } else {\n                    let index = add_normalized_entry(msg_store, &self.entry_index, entry);\n                    tool_state.index = Some(index);\n                }\n            }\n            Part::Other => {}\n        }\n    }\n\n    fn handle_part_delta(&mut self, event: MessagePartDeltaEvent, msg_store: &Arc<MsgStore>) {\n        if event.field != \"text\" || event.delta.is_empty() {\n            return;\n        }\n\n        let Some((message_id, kind)) = self.part_kinds.get(&event.part_id) else {\n            self.pending_deltas\n                .entry(event.part_id)\n                .or_default()\n                .push(event.delta);\n            return;\n        };\n        let message_id = message_id.clone();\n        let kind = *kind;\n\n        let entry_index = self.entry_index.clone();\n        match kind {\n            PartKind::AssistantText => {\n                if self.message_roles.get(&message_id) == Some(&MessageRole::User) {\n                    return;\n                }\n                update_streaming_text(\n                    &entry_index,\n                    &event.delta,\n                    NormalizedEntryType::AssistantMessage,\n                    &event.part_id,\n                    &mut self.assistant_text,\n                    msg_store,\n                    UpdateMode::Append,\n                );\n            }\n            PartKind::Thinking => {\n                update_streaming_text(\n                    &entry_index,\n                    &event.delta,\n                    NormalizedEntryType::Thinking,\n                    &event.part_id,\n                    &mut self.thinking_text,\n                    msg_store,\n                    UpdateMode::Append,\n                );\n            }\n        }\n    }\n\n    fn handle_approval_requested(\n        &mut self,\n        tool_call_id: &str,\n        approval_id: String,\n        worktree_path: &Path,\n        msg_store: &Arc<MsgStore>,\n    ) {\n        let Some(tool_state) = self.tool_states.get_mut(tool_call_id) else {\n            return;\n        };\n\n        tool_state.approval = Some(ApprovalStatus::Pending);\n        tool_state.approval_id = Some(approval_id);\n\n        let Some(index) = tool_state.index else {\n            return;\n        };\n\n        replace_normalized_entry(\n            msg_store,\n            index,\n            tool_state.to_normalized_entry(worktree_path),\n        );\n    }\n\n    fn handle_approval_response(\n        &mut self,\n        tool_call_id: &str,\n        status: ApprovalStatus,\n        worktree_path: &Path,\n        msg_store: &Arc<MsgStore>,\n    ) {\n        self.approvals\n            .insert(tool_call_id.to_string(), status.clone());\n\n        if let ApprovalStatus::Denied { reason } = &status {\n            let tool_name = self\n                .tool_states\n                .get(tool_call_id)\n                .map(|t| t.tool_name().to_string())\n                .unwrap_or_else(|| \"tool\".to_string());\n\n            let idx = self.entry_index.next();\n            self.add_normalized_entry_with_index(\n                idx,\n                NormalizedEntry {\n                    timestamp: None,\n                    entry_type: NormalizedEntryType::UserFeedback {\n                        denied_tool: tool_name,\n                    },\n                    content: reason\n                        .clone()\n                        .unwrap_or_else(|| \"User denied this tool use request\".to_string())\n                        .trim()\n                        .to_string(),\n                    metadata: None,\n                },\n            );\n        }\n\n        let Some(tool_state) = self.tool_states.get_mut(tool_call_id) else {\n            return;\n        };\n\n        tool_state.set_approval(status);\n\n        let Some(index) = tool_state.index else {\n            return;\n        };\n\n        replace_normalized_entry(\n            msg_store,\n            index,\n            tool_state.to_normalized_entry(worktree_path),\n        );\n    }\n\n    fn handle_question_response(\n        &mut self,\n        tool_call_id: &str,\n        status: QuestionStatus,\n        worktree_path: &Path,\n        msg_store: &Arc<MsgStore>,\n    ) {\n        if let Some(tool_state) = self.tool_states.get_mut(tool_call_id) {\n            tool_state.set_question_status(status.clone());\n\n            if let Some(index) = tool_state.index {\n                replace_normalized_entry(\n                    msg_store,\n                    index,\n                    tool_state.to_normalized_entry(worktree_path),\n                );\n            }\n        }\n\n        if let QuestionStatus::Answered { answers } = &status {\n            let qa_pairs: Vec<AnsweredQuestion> = answers\n                .iter()\n                .map(|qa| AnsweredQuestion {\n                    question: qa.question.clone(),\n                    answer: qa.answer.clone(),\n                })\n                .collect();\n            self.add_normalized_entry(NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::UserAnsweredQuestions { answers: qa_pairs },\n                content: format!(\n                    \"Answered {} question{}\",\n                    answers.len(),\n                    if answers.len() != 1 { \"s\" } else { \"\" }\n                ),\n                metadata: None,\n            });\n        }\n    }\n\n    fn handle_permission_asked(\n        &mut self,\n        event: PermissionAskedEvent,\n        worktree_path: &Path,\n        msg_store: &Arc<MsgStore>,\n    ) {\n        let Some(tool) = event.tool else {\n            self.add_normalized_entry(system_message(format!(\n                \"OpenCode permission requested: {}\",\n                event.permission\n            )));\n            return;\n        };\n\n        let call_id = tool.call_id.trim();\n        if call_id.is_empty() {\n            return;\n        }\n\n        let tool_state = self\n            .tool_states\n            .entry(call_id.to_string())\n            .or_insert_with(|| ToolCallState::new(call_id.to_string()));\n\n        // `permission` is an approval category (e.g. \"edit\", \"bash\"), not necessarily the tool\n        // name (\"write\" vs \"edit\"). Only fall back to it when we haven't seen a tool name yet.\n        if tool_state.tool_name() == \"tool\" {\n            tool_state.set_tool_name(event.permission.clone());\n        }\n\n        // `permission.asked` can carry richer metadata than the initial tool part updates (e.g.\n        // diffs for file edits). Store that data so users can review it before approving.\n        if tool_state.other_metadata().is_none() && !event.metadata.is_null() {\n            tool_state.set_other_metadata(event.metadata.clone());\n        }\n\n        if tool_state.file_edit_file_path().is_none() {\n            if let Some(path) = extract_file_path_from_permission_metadata(&event.metadata) {\n                tool_state.set_file_edit_file_path(path.to_string());\n            } else if let Some(pattern) = event\n                .patterns\n                .iter()\n                .find(|p| !p.trim().is_empty() && !p.contains('*') && !p.contains('?'))\n            {\n                tool_state.set_file_edit_file_path(pattern.trim().to_string());\n            }\n        }\n\n        if let Some(diff) = extract_diff_from_metadata(&event.metadata)\n            && !diff.trim().is_empty()\n        {\n            let should_update = match tool_state.file_edit_unified_diff() {\n                None => true,\n                Some(existing) => diff.len() > existing.len(),\n            };\n            if should_update {\n                tool_state.set_file_edit_unified_diff(diff.to_string());\n            }\n        }\n\n        let entry = tool_state.to_normalized_entry(worktree_path);\n        if let Some(index) = tool_state.index {\n            replace_normalized_entry(msg_store, index, entry);\n        } else {\n            let index = add_normalized_entry(msg_store, &self.entry_index, entry);\n            tool_state.index = Some(index);\n        }\n    }\n\n    fn handle_question_asked(\n        &mut self,\n        event: super::types::QuestionAskedEvent,\n        worktree_path: &Path,\n        msg_store: &Arc<MsgStore>,\n    ) {\n        let call_id = event\n            .tool\n            .as_ref()\n            .map(|tool| tool.call_id.trim())\n            .filter(|id| !id.is_empty())\n            .unwrap_or_else(|| event.id.trim());\n        if call_id.is_empty() {\n            return;\n        }\n\n        let questions = parse_question_items_from_info(&event.questions);\n        let tool_input = serde_json::json!({ \"questions\": event.questions });\n\n        let tool_state = self\n            .tool_states\n            .entry(call_id.to_string())\n            .or_insert_with(|| ToolCallState::new(call_id.to_string()));\n\n        tool_state.set_tool_name(\"question\".to_string());\n        tool_state.state = ToolStateStatus::Pending;\n        tool_state.data = ToolData::Question { questions };\n        tool_state.apply_tool_data(Some(tool_input), None, None, None);\n        tool_state.set_approval(ApprovalStatus::Pending);\n\n        let entry = tool_state.to_normalized_entry(worktree_path);\n        if let Some(index) = tool_state.index {\n            replace_normalized_entry(msg_store, index, entry);\n        } else {\n            let index = add_normalized_entry(msg_store, &self.entry_index, entry);\n            tool_state.index = Some(index);\n        }\n    }\n\n    fn add_normalized_entry(&mut self, entry: NormalizedEntry) -> usize {\n        add_normalized_entry(&self.msg_store, &self.entry_index, entry)\n    }\n\n    fn add_normalized_entry_with_index(&mut self, index: usize, entry: NormalizedEntry) {\n        self.msg_store\n            .push_patch(crate::logs::utils::ConversationPatch::add_normalized_entry(\n                index, entry,\n            ));\n    }\n}\n\nfn update_streaming_text(\n    entry_index: &EntryIndexProvider,\n    text: &str,\n    entry_type: NormalizedEntryType,\n    stream_key: &str,\n    map: &mut HashMap<PartId, StreamingText>,\n    msg_store: &Arc<MsgStore>,\n    mode: UpdateMode,\n) {\n    if text.is_empty() {\n        return;\n    }\n\n    let is_new = !map.contains_key(stream_key);\n\n    if is_new && text == \"\\n\" {\n        return;\n    }\n\n    let state = map\n        .entry(stream_key.to_string())\n        .or_insert_with(|| StreamingText {\n            index: entry_index.next(),\n            content: String::new(),\n        });\n\n    match mode {\n        UpdateMode::Append => state.content.push_str(text),\n        UpdateMode::Set => state.content = text.to_string(),\n    }\n\n    let entry = NormalizedEntry {\n        timestamp: None,\n        entry_type,\n        content: state.content.clone(),\n        metadata: None,\n    };\n    upsert_normalized_entry(msg_store, state.index, entry, is_new);\n}\n\n#[derive(Debug, Clone)]\nstruct ToolCallState {\n    index: Option<usize>,\n    call_id: String,\n    tool_name: String,\n    state: ToolStateStatus,\n    title: Option<String>,\n    approval: Option<ApprovalStatus>,\n    question: Option<QuestionStatus>,\n    approval_id: Option<String>,\n    data: ToolData,\n}\n\n#[derive(Debug, Clone, Default)]\nenum ToolData {\n    #[default]\n    Unknown,\n    Bash {\n        command: Option<String>,\n        output: Option<String>,\n        error: Option<String>,\n        exit_code: Option<i32>,\n    },\n    Read {\n        file_path: Option<String>,\n    },\n    FileEdit {\n        kind: FileEditKind,\n        file_path: Option<String>,\n        write_content: Option<String>,\n        unified_diff: Option<String>,\n    },\n    WebFetch {\n        url: Option<String>,\n    },\n    Search {\n        query: Option<String>,\n    },\n    Todo {\n        operation: TodoOperation,\n        todos: Vec<TodoItem>,\n    },\n    Task {\n        description: Option<String>,\n        subagent_type: Option<String>,\n        output: Option<String>,\n    },\n    Question {\n        questions: Vec<AskUserQuestionItem>,\n    },\n    Other {\n        input: Option<Value>,\n        metadata: Option<Value>,\n        output: Option<String>,\n        error: Option<String>,\n    },\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\nenum FileEditKind {\n    #[default]\n    Edit,\n    Write,\n    MultiEdit,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\nenum TodoOperation {\n    Read,\n    #[default]\n    Write,\n}\n\nimpl ToolCallState {\n    fn new(call_id: String) -> Self {\n        Self {\n            index: None,\n            call_id,\n            tool_name: \"tool\".to_string(),\n            state: ToolStateStatus::Unknown,\n            title: None,\n            approval: None,\n            question: None,\n            approval_id: None,\n            data: ToolData::Other {\n                input: None,\n                metadata: None,\n                output: None,\n                error: None,\n            },\n        }\n    }\n\n    fn tool_name(&self) -> &str {\n        &self.tool_name\n    }\n\n    fn set_tool_name(&mut self, name: String) {\n        if name.trim().is_empty() {\n            return;\n        }\n        self.tool_name = name;\n        self.maybe_promote();\n    }\n\n    fn set_approval_if_missing(&mut self, approval: Option<ApprovalStatus>) {\n        if self.approval.is_none() {\n            self.approval = approval;\n        }\n    }\n\n    fn set_approval(&mut self, approval: ApprovalStatus) {\n        self.approval = Some(approval);\n    }\n\n    fn set_question_status(&mut self, question: QuestionStatus) {\n        self.question = Some(question);\n    }\n\n    fn tool_status(&self) -> ToolStatus {\n        if let Some(status) = self.question.as_ref().map(ToolStatus::from_question_status) {\n            return status;\n        }\n        if let Some(ApprovalStatus::Denied { reason }) = &self.approval {\n            return ToolStatus::Denied {\n                reason: reason.clone(),\n            };\n        }\n        if matches!(self.approval, Some(ApprovalStatus::TimedOut)) {\n            return ToolStatus::TimedOut;\n        }\n        if matches!(self.approval, Some(ApprovalStatus::Pending))\n            && let Some(ref id) = self.approval_id\n        {\n            return ToolStatus::PendingApproval {\n                approval_id: id.clone(),\n            };\n        }\n        match self.state {\n            ToolStateStatus::Completed => ToolStatus::Success,\n            ToolStateStatus::Error => ToolStatus::Failed,\n            _ => ToolStatus::Created,\n        }\n    }\n\n    fn update_from_part(&mut self, part: ToolPart) {\n        self.set_tool_name(part.tool.clone());\n\n        let (input, output, metadata, error) = match &part.state {\n            ToolStateUpdate::Pending { input } => {\n                self.state = ToolStateStatus::Pending;\n                (input.clone(), None, None, None)\n            }\n            ToolStateUpdate::Running {\n                input,\n                title,\n                metadata,\n            } => {\n                self.state = ToolStateStatus::Running;\n                if let Some(t) = title.as_ref().filter(|t| !t.trim().is_empty()) {\n                    self.title = Some(t.clone());\n                }\n                (input.clone(), None, metadata.clone(), None)\n            }\n            ToolStateUpdate::Completed {\n                input,\n                output,\n                title,\n                metadata,\n            } => {\n                self.state = ToolStateStatus::Completed;\n                if let Some(t) = title.as_ref().filter(|t| !t.trim().is_empty()) {\n                    self.title = Some(t.clone());\n                }\n                (input.clone(), output.clone(), metadata.clone(), None)\n            }\n            ToolStateUpdate::Error {\n                input,\n                error,\n                metadata,\n            } => {\n                self.state = ToolStateStatus::Error;\n                let err = error.clone().filter(|e| !e.trim().is_empty());\n                (input.clone(), None, metadata.clone(), err)\n            }\n            ToolStateUpdate::Unknown => (None, None, None, None),\n        };\n\n        self.apply_tool_data(input, output, metadata, error);\n    }\n\n    fn apply_tool_data(\n        &mut self,\n        input: Option<Value>,\n        output: Option<String>,\n        metadata: Option<Value>,\n        error: Option<String>,\n    ) {\n        match &mut self.data {\n            ToolData::Bash {\n                command,\n                output: out,\n                error: err,\n                exit_code,\n            } => {\n                if let Some(v) = input.and_then(|v| serde_json::from_value::<BashInput>(v).ok()) {\n                    *command = Some(v.command);\n                }\n                if let Some(o) = output {\n                    *out = Some(o);\n                    *err = None;\n                }\n                if let Some(e) = error {\n                    *err = Some(e);\n                }\n                if let Some(m) = metadata {\n                    *exit_code = m.get(\"exit\").and_then(Value::as_i64).map(|c| c as i32);\n                }\n            }\n            ToolData::Read { file_path } => {\n                if let Some(v) = input.and_then(|v| serde_json::from_value::<FilePathInput>(v).ok())\n                {\n                    *file_path = Some(v.file_path);\n                }\n            }\n            ToolData::FileEdit {\n                kind,\n                file_path,\n                write_content,\n                unified_diff,\n            } => {\n                if let Some(inp) = input {\n                    match kind {\n                        FileEditKind::Write => {\n                            if let Ok(v) = serde_json::from_value::<WriteInput>(inp) {\n                                *file_path = Some(v.file_path);\n                                *write_content = Some(v.content);\n                            }\n                        }\n                        FileEditKind::Edit | FileEditKind::MultiEdit => {\n                            if let Ok(v) = serde_json::from_value::<FilePathInput>(inp) {\n                                *file_path = Some(v.file_path);\n                            }\n                        }\n                    }\n                }\n                if matches!(kind, FileEditKind::Edit | FileEditKind::MultiEdit)\n                    && let Some(m) = metadata\n                    && let Some(d) = extract_diff_from_metadata(&m)\n                {\n                    *unified_diff = Some(d.to_string());\n                }\n            }\n            ToolData::WebFetch { url } => {\n                if let Some(u) =\n                    input.and_then(|v| v.get(\"url\").and_then(Value::as_str).map(str::to_string))\n                {\n                    *url = Some(u);\n                }\n            }\n            ToolData::Search { query } => {\n                if let Some(inp) = input {\n                    *query = inp\n                        .get(\"query\")\n                        .and_then(Value::as_str)\n                        .or_else(|| inp.get(\"pattern\").and_then(Value::as_str))\n                        .map(str::to_string);\n                }\n            }\n            ToolData::Todo { operation, todos } => {\n                let source = match operation {\n                    TodoOperation::Write => input,\n                    TodoOperation::Read => metadata,\n                };\n                if let Some(v) =\n                    source.and_then(|v| serde_json::from_value::<TodosContainer>(v).ok())\n                {\n                    *todos = v.todos;\n                }\n            }\n            ToolData::Task {\n                description,\n                subagent_type,\n                output: task_output,\n            } => {\n                if let Some(inp) = input {\n                    if let Some(d) = inp\n                        .get(\"description\")\n                        .and_then(Value::as_str)\n                        .map(str::to_string)\n                    {\n                        *description = Some(d);\n                    }\n                    if let Some(s) = inp\n                        .get(\"subagent_type\")\n                        .and_then(Value::as_str)\n                        .map(str::to_string)\n                    {\n                        *subagent_type = Some(s);\n                    }\n                }\n                if let Some(o) = output {\n                    *task_output = Some(o);\n                }\n            }\n            ToolData::Question { questions } => {\n                if let Some(items) =\n                    input.and_then(|v| v.get(\"questions\").and_then(Value::as_array).cloned())\n                {\n                    *questions = parse_question_items(&items);\n                }\n            }\n            ToolData::Unknown => {\n                // Upgrade Unknown to Other when we receive tool data\n                self.data = ToolData::Other {\n                    input: None,\n                    metadata: None,\n                    output: None,\n                    error: None,\n                };\n                self.apply_tool_data(input, output, metadata, error);\n            }\n            ToolData::Other {\n                input: inp,\n                metadata: meta,\n                output: out,\n                error: err,\n            } => {\n                if let Some(i) = input {\n                    *inp = Some(i);\n                }\n                if let Some(m) = metadata {\n                    *meta = Some(m);\n                }\n                if let Some(o) = output {\n                    *out = Some(o);\n                    *err = None;\n                }\n                if let Some(e) = error {\n                    *err = Some(e);\n                }\n            }\n        }\n    }\n\n    /// Promote from generic/unknown to a specific tool type when tool name is recognized.\n    fn maybe_promote(&mut self) {\n        let (inp, meta, out, err) = match &self.data {\n            ToolData::Other {\n                input,\n                metadata,\n                output,\n                error,\n            } => (\n                input.clone(),\n                metadata.clone(),\n                output.clone(),\n                error.clone(),\n            ),\n            ToolData::Unknown => (None, None, None, None),\n            _ => return, // Already promoted\n        };\n\n        self.data = match self.tool_name.as_str() {\n            \"bash\" => ToolData::Bash {\n                command: None,\n                output: out.clone(),\n                error: err.clone(),\n                exit_code: None,\n            },\n            \"read\" => ToolData::Read { file_path: None },\n            \"edit\" | \"write\" | \"multiedit\" => ToolData::FileEdit {\n                kind: match self.tool_name.as_str() {\n                    \"write\" => FileEditKind::Write,\n                    \"multiedit\" => FileEditKind::MultiEdit,\n                    _ => FileEditKind::Edit,\n                },\n                file_path: None,\n                write_content: None,\n                unified_diff: None,\n            },\n            \"webfetch\" => ToolData::WebFetch { url: None },\n            \"websearch\" | \"codesearch\" | \"grep\" | \"glob\" => ToolData::Search { query: None },\n            \"todoread\" | \"todowrite\" => ToolData::Todo {\n                operation: if self.tool_name == \"todoread\" {\n                    TodoOperation::Read\n                } else {\n                    TodoOperation::Write\n                },\n                todos: vec![],\n            },\n            \"task\" => ToolData::Task {\n                description: None,\n                subagent_type: None,\n                output: None,\n            },\n            \"question\" => ToolData::Question { questions: vec![] },\n            _ => return,\n        };\n\n        // Re-apply the data we had (only needed for non-Bash tools as Bash already has out/err)\n        self.apply_tool_data(inp, out, meta, err);\n    }\n\n    fn to_normalized_entry(&self, worktree_path: &Path) -> NormalizedEntry {\n        let action_type = self.build_action_type(worktree_path);\n        let content = self.build_content(&action_type);\n        NormalizedEntry {\n            timestamp: None,\n            entry_type: NormalizedEntryType::ToolUse {\n                tool_name: self.tool_name.clone(),\n                action_type,\n                status: self.tool_status(),\n            },\n            content,\n            metadata: serde_json::to_value(ToolCallMetadata {\n                tool_call_id: self.call_id.clone(),\n            })\n            .ok(),\n        }\n    }\n\n    fn build_action_type(&self, worktree_path: &Path) -> ActionType {\n        match &self.data {\n            ToolData::Bash {\n                command,\n                output,\n                error,\n                exit_code,\n            } => {\n                let cmd = command.clone().unwrap_or_default();\n                ActionType::CommandRun {\n                    command: cmd.clone(),\n                    result: Some(CommandRunResult {\n                        exit_status: exit_code.map(|code| CommandExitStatus::ExitCode { code }),\n                        output: output.as_deref().or(error.as_deref()).map(str::to_string),\n                    }),\n                    category: CommandCategory::from_command(&cmd),\n                }\n            }\n            ToolData::Read { file_path } => ActionType::FileRead {\n                path: file_path\n                    .as_deref()\n                    .map(|p| make_relative_path(p, worktree_path))\n                    .unwrap_or_default(),\n            },\n            ToolData::FileEdit {\n                kind,\n                file_path,\n                write_content,\n                unified_diff,\n            } => {\n                let path = file_path\n                    .as_deref()\n                    .map(|p| make_relative_path(p, worktree_path))\n                    .unwrap_or_default();\n                let changes = match kind {\n                    FileEditKind::Write => write_content\n                        .as_ref()\n                        .filter(|s| !s.is_empty())\n                        .map(|c| vec![FileChange::Write { content: c.clone() }])\n                        .unwrap_or_default(),\n                    FileEditKind::Edit | FileEditKind::MultiEdit => unified_diff\n                        .as_ref()\n                        .map(|d| {\n                            vec![FileChange::Edit {\n                                unified_diff: workspace_utils::diff::normalize_unified_diff(\n                                    &path, d,\n                                ),\n                                has_line_numbers: true,\n                            }]\n                        })\n                        .unwrap_or_default(),\n                };\n                ActionType::FileEdit { path, changes }\n            }\n            ToolData::WebFetch { url } => ActionType::WebFetch {\n                url: url.clone().unwrap_or_default(),\n            },\n            ToolData::Search { query } => ActionType::Search {\n                query: query.clone().unwrap_or_default(),\n            },\n            ToolData::Todo { operation, todos } => ActionType::TodoManagement {\n                todos: todos.clone(),\n                operation: match operation {\n                    TodoOperation::Read => \"read\",\n                    TodoOperation::Write => \"write\",\n                }\n                .to_string(),\n            },\n            ToolData::Task {\n                description,\n                subagent_type,\n                output,\n            } => ActionType::TaskCreate {\n                description: description.clone().unwrap_or_default(),\n                subagent_type: subagent_type.clone(),\n                result: output\n                    .as_deref()\n                    .map(|o| ToolResult::markdown(o.to_string())),\n            },\n            ToolData::Question { questions } => ActionType::AskUserQuestion {\n                questions: questions.clone(),\n            },\n            ToolData::Unknown => ActionType::Tool {\n                tool_name: self.tool_name.clone(),\n                arguments: None,\n                result: None,\n            },\n            ToolData::Other {\n                input,\n                output,\n                error,\n                ..\n            } => ActionType::Tool {\n                tool_name: self.tool_name.clone(),\n                arguments: input\n                    .as_ref()\n                    .and_then(|v| v.as_object().map(|_| v.clone())),\n                result: output\n                    .as_deref()\n                    .or(error.as_deref())\n                    .map(|o| ToolResult::markdown(o.to_string())),\n            },\n        }\n    }\n\n    fn build_content(&self, action_type: &ActionType) -> String {\n        let content = match action_type {\n            ActionType::CommandRun { command, .. } => command.clone(),\n            ActionType::FileRead { path } => path.clone(),\n            ActionType::FileEdit { path, .. } => path.clone(),\n            ActionType::Search { query } => query.clone(),\n            ActionType::WebFetch { url } => url.clone(),\n            ActionType::TodoManagement { .. } => \"TODO list updated\".to_string(),\n            ActionType::TaskCreate { description, .. } => {\n                if description.is_empty() {\n                    \"Task\".to_string()\n                } else {\n                    format!(\"Task: `{description}`\")\n                }\n            }\n            ActionType::AskUserQuestion { questions } => {\n                if questions.len() == 1 {\n                    questions[0].question.clone()\n                } else {\n                    format!(\"{} questions\", questions.len())\n                }\n            }\n            _ => String::new(),\n        }\n        .trim()\n        .to_string();\n\n        if !content.is_empty() {\n            content\n        } else {\n            self.title.as_deref().unwrap_or(&self.tool_name).to_string()\n        }\n    }\n\n    /// Access to metadata for permission.asked handling\n    fn other_metadata(&self) -> Option<&Value> {\n        match &self.data {\n            ToolData::Other { metadata, .. } => metadata.as_ref(),\n            _ => None,\n        }\n    }\n\n    fn set_other_metadata(&mut self, meta: Value) {\n        if let ToolData::Other { metadata, .. } = &mut self.data {\n            *metadata = Some(meta);\n        }\n    }\n\n    fn file_edit_file_path(&self) -> Option<&str> {\n        match &self.data {\n            ToolData::FileEdit { file_path, .. } => file_path.as_deref(),\n            _ => None,\n        }\n    }\n\n    fn set_file_edit_file_path(&mut self, path: String) {\n        if let ToolData::FileEdit { file_path, .. } = &mut self.data {\n            *file_path = Some(path);\n        }\n    }\n\n    fn file_edit_unified_diff(&self) -> Option<&str> {\n        match &self.data {\n            ToolData::FileEdit { unified_diff, .. } => unified_diff.as_deref(),\n            _ => None,\n        }\n    }\n\n    fn set_file_edit_unified_diff(&mut self, diff: String) {\n        if let ToolData::FileEdit { unified_diff, .. } = &mut self.data {\n            *unified_diff = Some(diff);\n        }\n    }\n}\n\n#[derive(Debug, Deserialize)]\nstruct BashInput {\n    command: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct FilePathInput {\n    #[serde(rename = \"filePath\")]\n    file_path: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct WriteInput {\n    #[serde(rename = \"filePath\")]\n    file_path: String,\n    content: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct TodosContainer {\n    #[serde(default)]\n    todos: Vec<TodoItem>,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ToolStateStatus {\n    Pending,\n    Running,\n    Completed,\n    Error,\n    Unknown,\n}\n\nfn make_relative_path(path: &str, worktree_path: &Path) -> String {\n    make_path_relative(path, &worktree_path.to_string_lossy())\n}\n\nfn fingerprint_todos(todos: &[SdkTodo]) -> String {\n    let mut parts = todos\n        .iter()\n        .map(|t| {\n            format!(\n                \"{}:{}:{}:{}\",\n                t.id.as_deref().unwrap_or(\"\"),\n                t.status,\n                t.priority,\n                t.content\n            )\n        })\n        .collect::<Vec<_>>();\n    parts.sort();\n    parts.join(\"|\")\n}\n\nfn extract_diff_from_metadata(metadata: &Value) -> Option<&str> {\n    metadata.get(\"diff\").and_then(Value::as_str).or_else(|| {\n        metadata\n            .get(\"results\")\n            .and_then(Value::as_array)\n            .and_then(|results| results.last())\n            .and_then(|last| last.get(\"diff\"))\n            .and_then(Value::as_str)\n    })\n}\n\nfn extract_file_path_from_permission_metadata(metadata: &Value) -> Option<&str> {\n    let candidate = metadata\n        .get(\"filePath\")\n        .and_then(Value::as_str)\n        .or_else(|| metadata.get(\"filepath\").and_then(Value::as_str))\n        .or_else(|| metadata.get(\"path\").and_then(Value::as_str))\n        .or_else(|| metadata.get(\"file\").and_then(Value::as_str))?;\n\n    let trimmed = candidate.trim();\n    if trimmed.is_empty() {\n        None\n    } else {\n        Some(trimmed)\n    }\n}\n\nfn parse_question_items_from_info(items: &[QuestionInfo]) -> Vec<AskUserQuestionItem> {\n    items\n        .iter()\n        .map(|q| AskUserQuestionItem {\n            question: q.question.clone(),\n            header: q.header.clone(),\n            options: q\n                .options\n                .iter()\n                .map(|o| AskUserQuestionOption {\n                    label: o.label.clone(),\n                    description: o.description.clone(),\n                })\n                .collect(),\n            multi_select: q.multiple.unwrap_or(false),\n        })\n        .collect()\n}\n\nfn parse_question_items(items: &[Value]) -> Vec<AskUserQuestionItem> {\n    let infos: Vec<QuestionInfo> = items\n        .iter()\n        .filter_map(|v| serde_json::from_value(v.clone()).ok())\n        .collect();\n    parse_question_items_from_info(&infos)\n}\n"
  },
  {
    "path": "crates/executors/src/executors/opencode/sdk.rs",
    "content": "use std::{\n    collections::{HashMap, HashSet},\n    future::Future,\n    io,\n    sync::Arc,\n    time::Duration,\n};\n\nuse base64::{Engine, engine::general_purpose::STANDARD as BASE64};\nuse eventsource_stream::Eventsource;\nuse futures::StreamExt;\nuse rand::{Rng, distributions::Alphanumeric};\nuse reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse tokio::{\n    io::{AsyncWrite, AsyncWriteExt, BufWriter},\n    sync::{Mutex as AsyncMutex, mpsc, oneshot},\n};\nuse tokio_util::sync::CancellationToken;\nuse workspace_utils::approvals::{ApprovalStatus, QuestionAnswer, QuestionStatus};\n\nuse super::{\n    slash_commands,\n    types::{OpencodeExecutorEvent, ProviderInfo, ProviderListResponse},\n};\nuse crate::{\n    approvals::{ExecutorApprovalError, ExecutorApprovalService},\n    env::RepoContext,\n    executors::{ExecutorError, opencode::models::maybe_emit_token_usage},\n};\n\n#[derive(Clone)]\npub struct LogWriter {\n    writer: Arc<AsyncMutex<BufWriter<Box<dyn AsyncWrite + Send + Unpin>>>>,\n}\n\nimpl LogWriter {\n    pub fn new(writer: impl AsyncWrite + Send + Unpin + 'static) -> Self {\n        Self {\n            writer: Arc::new(AsyncMutex::new(BufWriter::new(Box::new(writer)))),\n        }\n    }\n\n    pub async fn log_event(&self, event: &OpencodeExecutorEvent) -> Result<(), ExecutorError> {\n        let raw =\n            serde_json::to_string(event).map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n        self.log_raw(&raw).await\n    }\n\n    pub async fn log_error(&self, message: String) -> Result<(), ExecutorError> {\n        self.log_event(&OpencodeExecutorEvent::Error { message })\n            .await\n    }\n\n    pub async fn log_slash_command_result(&self, message: String) -> Result<(), ExecutorError> {\n        self.log_event(&OpencodeExecutorEvent::SlashCommandResult { message })\n            .await\n    }\n\n    async fn log_raw(&self, raw: &str) -> Result<(), ExecutorError> {\n        let mut guard = self.writer.lock().await;\n        guard\n            .write_all(raw.as_bytes())\n            .await\n            .map_err(ExecutorError::Io)?;\n        guard.write_all(b\"\\n\").await.map_err(ExecutorError::Io)?;\n        guard.flush().await.map_err(ExecutorError::Io)?;\n        Ok(())\n    }\n}\n\n#[derive(Clone)]\npub struct RunConfig {\n    pub base_url: String,\n    pub directory: String,\n    pub prompt: String,\n    pub resume_session_id: Option<String>,\n    pub model: Option<String>,\n    pub model_variant: Option<String>,\n    pub agent: Option<String>,\n    pub approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    pub auto_approve: bool,\n    pub server_password: String,\n    /// Cache key for model context windows. Should be derived from configuration\n    /// that affects available models (e.g., env vars, base command).\n    pub models_cache_key: String,\n    pub commit_reminder: bool,\n    pub commit_reminder_prompt: String,\n    pub repo_context: RepoContext,\n}\n\n/// Generate a cryptographically secure random password for OpenCode server auth.\npub fn generate_server_password() -> String {\n    rand::thread_rng()\n        .sample_iter(&Alphanumeric)\n        .take(32)\n        .map(char::from)\n        .collect()\n}\n\n#[derive(Debug, Deserialize)]\nstruct HealthResponse {\n    healthy: bool,\n    version: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SessionResponse {\n    id: String,\n}\n\n/// Information about a discovered command.\n#[derive(Debug, Deserialize, Clone)]\npub struct CommandInfo {\n    pub name: String,\n    #[serde(default)]\n    pub description: Option<String>,\n}\n\n/// Information about an agent.\n#[derive(Debug, Deserialize, Clone)]\npub struct AgentInfo {\n    pub name: String,\n    #[serde(default)]\n    pub description: Option<String>,\n}\n\n/// Configuration response from the server.\n#[derive(Debug, Deserialize)]\npub struct ConfigResponse {\n    #[serde(default)]\n    pub model: Option<String>,\n    #[serde(default)]\n    pub plugin: Vec<String>,\n}\n\n/// Provider configuration response.\n#[derive(Debug, Deserialize)]\npub struct ConfigProvidersResponse {\n    pub providers: Vec<ProviderInfo>,\n    pub default: HashMap<String, String>,\n}\n\n/// LSP server status.\n#[derive(Debug, Deserialize, Clone)]\npub struct LspStatus {\n    pub name: String,\n    pub root: String,\n    pub status: String,\n}\n\n/// Formatter status.\n#[derive(Debug, Deserialize, Clone)]\npub struct FormatterStatus {\n    pub name: String,\n    pub extensions: Vec<String>,\n    pub enabled: bool,\n}\n\n#[derive(Debug, Serialize)]\nstruct PromptRequest {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    model: Option<ModelSpec>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    agent: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    variant: Option<String>,\n    parts: Vec<TextPartInput>,\n}\n\n#[derive(Debug, Serialize, Clone)]\npub struct ModelSpec {\n    #[serde(rename = \"providerID\")]\n    pub provider_id: String,\n    #[serde(rename = \"modelID\")]\n    pub model_id: String,\n}\n\n#[derive(Debug, Serialize)]\nstruct TextPartInput {\n    r#type: &'static str,\n    text: String,\n}\n\n#[derive(Debug, Clone)]\npub enum ControlEvent {\n    Idle,\n    AuthRequired { message: String },\n    SessionError { message: String },\n    Disconnected,\n}\n\n#[derive(Clone)]\npub(crate) struct PendingApprovals {\n    inner: Arc<AsyncMutex<Vec<oneshot::Receiver<()>>>>,\n}\n\nimpl PendingApprovals {\n    pub(crate) fn new() -> Self {\n        Self {\n            inner: Arc::new(AsyncMutex::new(Vec::new())),\n        }\n    }\n\n    async fn push(&self) -> oneshot::Sender<()> {\n        let (tx, rx) = oneshot::channel();\n        self.inner.lock().await.push(rx);\n        tx\n    }\n\n    async fn wait(&self, cancel: CancellationToken) -> bool {\n        let mut waited = false;\n        loop {\n            let receivers = {\n                let mut guard = self.inner.lock().await;\n                if guard.is_empty() {\n                    return waited;\n                }\n                waited = true;\n                guard.drain(..).collect::<Vec<_>>()\n            };\n\n            for rx in receivers {\n                tokio::select! {\n                    _ = cancel.cancelled() => return waited,\n                    _ = rx => {}\n                }\n            }\n        }\n    }\n}\n\npub async fn run_session(\n    config: RunConfig,\n    log_writer: LogWriter,\n    cancel: CancellationToken,\n) -> Result<(), ExecutorError> {\n    let client = build_opencode_client(&config.directory, &config.server_password)?;\n\n    run_session_inner(config, log_writer, client, cancel).await\n}\n\npub async fn run_slash_command(\n    config: RunConfig,\n    log_writer: LogWriter,\n    command: slash_commands::OpencodeSlashCommand,\n    cancel: CancellationToken,\n) -> Result<(), ExecutorError> {\n    let client = build_opencode_client(&config.directory, &config.server_password)?;\n\n    slash_commands::execute(config, command, log_writer, client, cancel.clone()).await\n}\n\nasync fn run_session_inner(\n    config: RunConfig,\n    log_writer: LogWriter,\n    client: reqwest::Client,\n    cancel: CancellationToken,\n) -> Result<(), ExecutorError> {\n    tokio::select! {\n        _ = cancel.cancelled() => return Ok(()),\n        res = wait_for_health(&client, &config.base_url) => res?,\n    }\n\n    let session_id = match config.resume_session_id.as_deref() {\n        Some(existing) => {\n            tokio::select! {\n                _ = cancel.cancelled() => return Ok(()),\n                res = fork_session(&client, &config.base_url, &config.directory, existing) => res?,\n            }\n        }\n        None => tokio::select! {\n            _ = cancel.cancelled() => return Ok(()),\n            res = create_session(&client, &config.base_url, &config.directory) => res?,\n        },\n    };\n\n    log_writer\n        .log_event(&OpencodeExecutorEvent::SessionStart {\n            session_id: session_id.clone(),\n        })\n        .await?;\n\n    let model = config.model.as_deref().and_then(parse_model);\n\n    let (control_tx, mut control_rx) = mpsc::unbounded_channel::<ControlEvent>();\n    let pending_approvals = PendingApprovals::new();\n\n    let event_resp = tokio::select! {\n        _ = cancel.cancelled() => return Ok(()),\n        res = connect_event_stream(&client, &config.base_url, &config.directory, None) => res?,\n    };\n    let event_handle = tokio::spawn(spawn_event_listener(\n        EventListenerConfig {\n            client: client.clone(),\n            base_url: config.base_url.clone(),\n            directory: config.directory.clone(),\n            session_id: session_id.clone(),\n            log_writer: log_writer.clone(),\n            approvals: config.approvals.clone(),\n            auto_approve: config.auto_approve,\n            control_tx,\n            pending_approvals: pending_approvals.clone(),\n            models_cache_key: config.models_cache_key.clone(),\n            cancel: cancel.clone(),\n        },\n        event_resp,\n    ));\n\n    let prompt_fut = Box::pin(prompt(\n        &client,\n        &config.base_url,\n        &config.directory,\n        &session_id,\n        &config.prompt,\n        model.clone(),\n        config.model_variant.clone(),\n        config.agent.clone(),\n    ));\n    let prompt_result = run_request_with_control(\n        prompt_fut,\n        &mut control_rx,\n        &pending_approvals,\n        cancel.clone(),\n    )\n    .await;\n\n    if cancel.is_cancelled() {\n        send_abort(&client, &config.base_url, &config.directory, &session_id).await;\n        event_handle.abort();\n        return Ok(());\n    }\n\n    if let Err(err) = prompt_result {\n        let _ = pending_approvals.wait(cancel.clone()).await;\n        event_handle.abort();\n        return Err(err);\n    }\n\n    // Handle commit reminder if enabled\n    if config.commit_reminder\n        && !cancel.is_cancelled()\n        && let status = config.repo_context.check_uncommitted_changes().await\n        && !status.is_empty()\n    {\n        let reminder_prompt = format!(\"{}\\n{}\", config.commit_reminder_prompt, status);\n        tracing::debug!(\"Sending commit reminder prompt to OpenCode session\");\n\n        // Log as system message so it's visible in the UI (user_message gets filtered out)\n        let _ = log_writer\n            .log_event(&OpencodeExecutorEvent::SystemMessage {\n                content: reminder_prompt.clone(),\n            })\n            .await;\n\n        let reminder_fut = Box::pin(prompt(\n            &client,\n            &config.base_url,\n            &config.directory,\n            &session_id,\n            &reminder_prompt,\n            model,\n            config.model_variant.clone(),\n            config.agent.clone(),\n        ));\n        let reminder_result = run_request_with_control(\n            reminder_fut,\n            &mut control_rx,\n            &pending_approvals,\n            cancel.clone(),\n        )\n        .await;\n\n        if let Err(e) = reminder_result {\n            // Log but don't fail the session on commit reminder errors\n            tracing::warn!(\"Commit reminder prompt failed: {e}\");\n        }\n    }\n\n    let _ = pending_approvals.wait(cancel.clone()).await;\n\n    if cancel.is_cancelled() {\n        send_abort(&client, &config.base_url, &config.directory, &session_id).await;\n    }\n\n    event_handle.abort();\n\n    log_writer.log_event(&OpencodeExecutorEvent::Done).await?;\n\n    Ok(())\n}\n\nfn build_default_headers(directory: &str, password: &str) -> HeaderMap {\n    let mut headers = HeaderMap::new();\n    if let Ok(value) = HeaderValue::from_str(directory) {\n        headers.insert(\"x-opencode-directory\", value);\n    }\n    let credentials = BASE64.encode(format!(\"opencode:{password}\"));\n    if let Ok(value) = HeaderValue::from_str(&format!(\"Basic {credentials}\")) {\n        headers.insert(AUTHORIZATION, value);\n    }\n    headers\n}\n\n/// Build HTTP client with OpenCode authentication headers.\n/// Uses Basic Auth: \"opencode:{password}\" base64 encoded.\npub fn build_authenticated_client(\n    directory: &str,\n    password: &str,\n) -> Result<reqwest::Client, ExecutorError> {\n    build_opencode_client(directory, password)\n}\n\nfn build_opencode_client(\n    directory: &str,\n    password: &str,\n) -> Result<reqwest::Client, ExecutorError> {\n    const OPENCODE_HTTP_TIMEOUT: Duration = Duration::from_secs(30);\n    const OPENCODE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);\n\n    reqwest::Client::builder()\n        .default_headers(build_default_headers(directory, password))\n        .connect_timeout(OPENCODE_CONNECT_TIMEOUT)\n        .timeout(OPENCODE_HTTP_TIMEOUT)\n        .build()\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\nconst OPENCODE_PROMPT_TIMEOUT: Duration = Duration::from_secs(60 * 30);\n\nfn append_session_error(session_error: &mut Option<String>, message: String) {\n    match session_error {\n        Some(existing) => {\n            existing.push('\\n');\n            existing.push_str(&message);\n        }\n        None => *session_error = Some(message),\n    }\n}\n\npub async fn run_request_with_control<F>(\n    mut request_fut: F,\n    control_rx: &mut mpsc::UnboundedReceiver<ControlEvent>,\n    pending_approvals: &PendingApprovals,\n    cancel: CancellationToken,\n) -> Result<(), ExecutorError>\nwhere\n    F: Future<Output = Result<(), ExecutorError>> + Unpin,\n{\n    let mut idle_seen = false;\n    let mut session_error: Option<String> = None;\n\n    let request_result = loop {\n        tokio::select! {\n            _ = cancel.cancelled() => return Ok(()),\n            res = &mut request_fut => break res,\n            event = control_rx.recv() => match event {\n                Some(ControlEvent::AuthRequired { message }) => return Err(ExecutorError::AuthRequired(message)),\n                Some(ControlEvent::SessionError { message }) => append_session_error(&mut session_error, message),\n                Some(ControlEvent::Disconnected) if !cancel.is_cancelled() => {\n                    return Err(ExecutorError::Io(io::Error::other(\"OpenCode event stream disconnected while request was running\")));\n                }\n                Some(ControlEvent::Disconnected) => return Ok(()),\n                Some(ControlEvent::Idle) => idle_seen = true,\n                None => {}\n            }\n        }\n    };\n\n    if let Err(err) = request_result {\n        if cancel.is_cancelled() {\n            return Ok(());\n        }\n        return Err(err);\n    }\n\n    if pending_approvals.wait(cancel.clone()).await {\n        idle_seen = false;\n    }\n\n    if !idle_seen {\n        // The OpenCode server streams events independently; wait for `session.idle` so we capture\n        // tail updates reliably (e.g. final tool completion events).\n        loop {\n            tokio::select! {\n                _ = cancel.cancelled() => return Ok(()),\n                event = control_rx.recv() => match event {\n                    Some(ControlEvent::Idle) | None => break,\n                    Some(ControlEvent::AuthRequired { message }) => return Err(ExecutorError::AuthRequired(message)),\n                    Some(ControlEvent::SessionError { message }) => append_session_error(&mut session_error, message),\n                    Some(ControlEvent::Disconnected) if !cancel.is_cancelled() => {\n                        return Err(ExecutorError::Io(io::Error::other(\n                            \"OpenCode event stream disconnected while waiting for session to go idle\",\n                        )));\n                    }\n                    Some(ControlEvent::Disconnected) => return Ok(()),\n                }\n            }\n        }\n    }\n\n    if let Some(message) = session_error {\n        if cancel.is_cancelled() {\n            return Ok(());\n        }\n        return Err(ExecutorError::Io(io::Error::other(message)));\n    }\n\n    Ok(())\n}\n\npub async fn wait_for_health(\n    client: &reqwest::Client,\n    base_url: &str,\n) -> Result<(), ExecutorError> {\n    let deadline = tokio::time::Instant::now() + Duration::from_secs(20);\n    let mut last_err: Option<String> = None;\n\n    loop {\n        if tokio::time::Instant::now() > deadline {\n            return Err(ExecutorError::Io(io::Error::other(format!(\n                \"Timed out waiting for OpenCode server health: {}\",\n                last_err.unwrap_or_else(|| \"unknown error\".to_string())\n            ))));\n        }\n\n        let resp = client.get(format!(\"{base_url}/global/health\")).send().await;\n        match resp {\n            Ok(resp) => {\n                if !resp.status().is_success() {\n                    last_err = Some(format!(\"HTTP {}\", resp.status()));\n                } else if let Ok(body) = resp.json::<HealthResponse>().await {\n                    if body.healthy {\n                        return Ok(());\n                    }\n                    last_err = Some(format!(\"unhealthy server (version {})\", body.version));\n                } else {\n                    last_err = Some(\"failed to parse health response\".to_string());\n                }\n            }\n            Err(err) => {\n                last_err = Some(err.to_string());\n            }\n        }\n\n        tokio::time::sleep(Duration::from_millis(150)).await;\n    }\n}\n\npub async fn create_session(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<String, ExecutorError> {\n    let resp = client\n        .post(format!(\"{base_url}/session\"))\n        .query(&[(\"directory\", directory)])\n        .json(&serde_json::json!({}))\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(ExecutorError::Io(io::Error::other(format!(\n            \"OpenCode session.create failed: HTTP {}\",\n            resp.status()\n        ))));\n    }\n\n    let session = resp\n        .json::<SessionResponse>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n    Ok(session.id)\n}\n\npub async fn fork_session(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    session_id: &str,\n) -> Result<String, ExecutorError> {\n    let resp = client\n        .post(format!(\"{base_url}/session/{session_id}/fork\"))\n        .query(&[(\"directory\", directory)])\n        .json(&serde_json::json!({}))\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(ExecutorError::Io(io::Error::other(format!(\n            \"OpenCode session.fork failed: HTTP {}\",\n            resp.status()\n        ))));\n    }\n\n    let session = resp\n        .json::<SessionResponse>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n    Ok(session.id)\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn prompt(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    session_id: &str,\n    prompt: &str,\n    model: Option<ModelSpec>,\n    model_variant: Option<String>,\n    agent: Option<String>,\n) -> Result<(), ExecutorError> {\n    let req = PromptRequest {\n        model,\n        agent,\n        variant: model_variant,\n        parts: vec![TextPartInput {\n            r#type: \"text\",\n            text: prompt.to_string(),\n        }],\n    };\n\n    let resp = client\n        .post(format!(\"{base_url}/session/{session_id}/message\"))\n        .query(&[(\"directory\", directory)])\n        .timeout(OPENCODE_PROMPT_TIMEOUT)\n        .json(&req)\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    let status = resp.status();\n    let body = resp\n        .text()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    // The OpenCode server uses streaming responses and may set the HTTP status early; validate\n    // success using the response body shape as well.\n    if !status.is_success() {\n        return Err(ExecutorError::Io(io::Error::other(format!(\n            \"OpenCode session.prompt failed: HTTP {status} {body}\"\n        ))));\n    }\n\n    let trimmed = body.trim();\n    if trimmed.is_empty() {\n        return Err(ExecutorError::Io(io::Error::other(\n            \"OpenCode session.prompt returned empty response body\",\n        )));\n    }\n\n    let parsed: Value =\n        serde_json::from_str(trimmed).map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    // Success response: { info, parts }\n    if parsed.get(\"info\").is_some() && parsed.get(\"parts\").is_some() {\n        return Ok(());\n    }\n\n    // Error response: { name, data }\n    if let Some(name) = parsed.get(\"name\").and_then(Value::as_str) {\n        let message = parsed\n            .pointer(\"/data/message\")\n            .and_then(Value::as_str)\n            .unwrap_or(trimmed);\n        return Err(ExecutorError::Io(io::Error::other(format!(\n            \"OpenCode session.prompt failed: {name}: {message}\"\n        ))));\n    }\n\n    Err(ExecutorError::Io(io::Error::other(format!(\n        \"OpenCode session.prompt returned unexpected response: {trimmed}\"\n    ))))\n}\n\n#[derive(Debug, Serialize)]\nstruct SessionCommandRequest {\n    command: String,\n    arguments: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    agent: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    model: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    variant: Option<String>,\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn session_command(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    session_id: &str,\n    command: String,\n    arguments: String,\n    agent: Option<String>,\n    model: Option<String>,\n    model_variant: Option<String>,\n) -> Result<(), ExecutorError> {\n    let req = SessionCommandRequest {\n        command,\n        arguments,\n        agent,\n        model,\n        variant: model_variant,\n    };\n\n    let resp = client\n        .post(format!(\"{base_url}/session/{session_id}/command\"))\n        .query(&[(\"directory\", directory)])\n        .json(&req)\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    let status = resp.status();\n    let body = resp\n        .text()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !status.is_success() {\n        return Err(ExecutorError::Io(io::Error::other(format!(\n            \"OpenCode session.command failed: HTTP {status} {body}\"\n        ))));\n    }\n\n    let trimmed = body.trim();\n    if trimmed.is_empty() {\n        return Err(ExecutorError::Io(io::Error::other(\n            \"OpenCode session.command returned empty response body\",\n        )));\n    }\n\n    let parsed: Value =\n        serde_json::from_str(trimmed).map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if parsed.get(\"info\").is_some() && parsed.get(\"parts\").is_some() {\n        return Ok(());\n    }\n\n    if let Some(name) = parsed.get(\"name\").and_then(Value::as_str) {\n        let message = parsed\n            .pointer(\"/data/message\")\n            .and_then(Value::as_str)\n            .unwrap_or(trimmed);\n        return Err(ExecutorError::Io(io::Error::other(format!(\n            \"OpenCode session.command failed: {name}: {message}\"\n        ))));\n    }\n\n    Err(ExecutorError::Io(io::Error::other(format!(\n        \"OpenCode session.command returned unexpected response: {trimmed}\"\n    ))))\n}\n\n#[derive(Debug, Serialize)]\nstruct SummarizeRequest {\n    #[serde(rename = \"providerID\")]\n    provider_id: String,\n    #[serde(rename = \"modelID\")]\n    model_id: String,\n    auto: bool,\n}\n\npub async fn session_summarize(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    session_id: &str,\n    model: ModelSpec,\n) -> Result<(), ExecutorError> {\n    let req = SummarizeRequest {\n        provider_id: model.provider_id,\n        model_id: model.model_id,\n        auto: false,\n    };\n\n    let resp = client\n        .post(format!(\"{base_url}/session/{session_id}/summarize\"))\n        .query(&[(\"directory\", directory)])\n        .json(&req)\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"session.summarize\").await);\n    }\n\n    let _ = resp\n        .json::<bool>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n    Ok(())\n}\n\npub async fn list_commands(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<Vec<CommandInfo>, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/command\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"command.list\").await);\n    }\n\n    resp.json::<Vec<CommandInfo>>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\npub async fn list_agents(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<Vec<AgentInfo>, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/agent\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"agent.list\").await);\n    }\n\n    resp.json::<Vec<AgentInfo>>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\npub async fn config_get(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<ConfigResponse, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/config\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"config.get\").await);\n    }\n\n    resp.json::<ConfigResponse>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\npub async fn list_config_providers(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<ConfigProvidersResponse, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/config/providers\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"config.providers\").await);\n    }\n\n    resp.json::<ConfigProvidersResponse>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\npub async fn list_providers(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<ProviderListResponse, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/provider\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"provider.list\").await);\n    }\n\n    resp.json::<ProviderListResponse>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\npub async fn mcp_status(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<HashMap<String, Value>, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/mcp\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"mcp.status\").await);\n    }\n\n    resp.json::<HashMap<String, Value>>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\npub async fn lsp_status(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<Vec<LspStatus>, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/lsp\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"lsp.status\").await);\n    }\n\n    resp.json::<Vec<LspStatus>>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\npub async fn formatter_status(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n) -> Result<Vec<FormatterStatus>, ExecutorError> {\n    let resp = client\n        .get(format!(\"{base_url}/formatter\"))\n        .query(&[(\"directory\", directory)])\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        return Err(build_response_error(resp, \"formatter.status\").await);\n    }\n\n    resp.json::<Vec<FormatterStatus>>()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))\n}\n\nasync fn build_response_error(resp: reqwest::Response, context: &str) -> ExecutorError {\n    let status = resp.status();\n    let body = resp\n        .text()\n        .await\n        .unwrap_or_else(|_| \"<failed to read response body>\".to_string());\n    ExecutorError::Io(io::Error::other(format!(\n        \"OpenCode {context} failed: HTTP {status} {body}\"\n    )))\n}\n\npub async fn send_abort(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    session_id: &str,\n) {\n    let request = client\n        .post(format!(\"{base_url}/session/{session_id}/abort\"))\n        .query(&[(\"directory\", directory)]);\n\n    let _ = tokio::time::timeout(Duration::from_millis(800), async move {\n        let resp = request.send().await;\n        if let Ok(resp) = resp {\n            // Drain body\n            let _ = resp.bytes().await;\n        }\n    })\n    .await;\n}\n\nfn parse_model(model: &str) -> Option<ModelSpec> {\n    let (provider_id, model_id) = match model.split_once('/') {\n        Some((provider, rest)) => (provider.to_string(), rest.to_string()),\n        None => (model.to_string(), String::new()),\n    };\n\n    Some(ModelSpec {\n        provider_id,\n        model_id,\n    })\n}\n\nfn parse_model_strict(model: &str) -> Option<ModelSpec> {\n    let (provider_id, model_id) = model.split_once('/')?;\n    let model_id = model_id.trim();\n    if model_id.is_empty() {\n        return None;\n    }\n    Some(ModelSpec {\n        provider_id: provider_id.to_string(),\n        model_id: model_id.to_string(),\n    })\n}\n\npub async fn resolve_compaction_model(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    configured_model: Option<&str>,\n) -> Result<ModelSpec, ExecutorError> {\n    if let Some(model) = configured_model.and_then(parse_model_strict) {\n        return Ok(model);\n    }\n\n    let config = config_get(client, base_url, directory).await?;\n    if let Some(model) = config.model.as_deref().and_then(parse_model_strict) {\n        return Ok(model);\n    }\n\n    let providers = list_config_providers(client, base_url, directory).await?;\n    let mut provider_ids: Vec<_> = providers.default.keys().cloned().collect();\n    provider_ids.sort();\n\n    if let Some(provider_id) = provider_ids.first()\n        && let Some(model_id) = providers.default.get(provider_id)\n    {\n        return Ok(ModelSpec {\n            provider_id: provider_id.clone(),\n            model_id: model_id.clone(),\n        });\n    }\n\n    if let Some(provider) = providers.providers.first()\n        && let Some((model_id, _)) = provider.models.iter().next()\n    {\n        return Ok(ModelSpec {\n            provider_id: provider.id.clone(),\n            model_id: model_id.clone(),\n        });\n    }\n\n    Err(ExecutorError::Io(io::Error::other(\n        \"OpenCode compaction requires a configured model\",\n    )))\n}\n\npub async fn connect_event_stream(\n    client: &reqwest::Client,\n    base_url: &str,\n    directory: &str,\n    last_event_id: Option<&str>,\n) -> Result<reqwest::Response, ExecutorError> {\n    let mut req = client\n        .get(format!(\"{base_url}/event\"))\n        .header(reqwest::header::ACCEPT, \"text/event-stream\")\n        .query(&[(\"directory\", directory)]);\n\n    if let Some(last_event_id) = last_event_id {\n        req = req.header(\"Last-Event-ID\", last_event_id);\n    }\n\n    let resp = req\n        .send()\n        .await\n        .map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n    if !resp.status().is_success() {\n        let status = resp.status();\n        let body = resp\n            .text()\n            .await\n            .unwrap_or_else(|_| \"<failed to read response body>\".to_string());\n        return Err(ExecutorError::Io(io::Error::other(format!(\n            \"OpenCode event stream failed: HTTP {status} {body}\"\n        ))));\n    }\n\n    Ok(resp)\n}\n\npub struct EventListenerConfig {\n    pub client: reqwest::Client,\n    pub base_url: String,\n    pub directory: String,\n    pub session_id: String,\n    pub log_writer: LogWriter,\n    pub approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    pub auto_approve: bool,\n    pub control_tx: mpsc::UnboundedSender<ControlEvent>,\n    pub pending_approvals: PendingApprovals,\n    pub models_cache_key: String,\n    pub cancel: CancellationToken,\n}\n\npub async fn spawn_event_listener(config: EventListenerConfig, initial_resp: reqwest::Response) {\n    let EventListenerConfig {\n        client,\n        base_url,\n        directory,\n        session_id,\n        log_writer,\n        approvals,\n        auto_approve,\n        control_tx,\n        pending_approvals,\n        models_cache_key,\n        cancel,\n    } = config;\n\n    let mut seen_permissions: HashSet<String> = HashSet::new();\n    let mut last_event_id: Option<String> = None;\n    let mut base_retry_delay = Duration::from_millis(3000);\n    let mut attempt: u32 = 0;\n    let max_attempts: u32 = 20;\n    let mut resp: Option<reqwest::Response> = Some(initial_resp);\n\n    loop {\n        let current_resp = match resp.take() {\n            Some(r) => {\n                attempt = 0;\n                r\n            }\n            None => {\n                match connect_event_stream(&client, &base_url, &directory, last_event_id.as_deref())\n                    .await\n                {\n                    Ok(r) => {\n                        attempt = 0;\n                        r\n                    }\n                    Err(err) => {\n                        let _ = log_writer\n                            .log_error(format!(\"OpenCode event stream reconnect failed: {err}\"))\n                            .await;\n                        attempt += 1;\n                        if attempt >= max_attempts {\n                            let _ = control_tx.send(ControlEvent::Disconnected);\n                            return;\n                        }\n\n                        tokio::time::sleep(exponential_backoff(base_retry_delay, attempt)).await;\n                        continue;\n                    }\n                }\n            }\n        };\n\n        let outcome = process_event_stream(\n            EventStreamContext {\n                seen_permissions: &mut seen_permissions,\n                client: &client,\n                base_url: &base_url,\n                directory: &directory,\n                session_id: &session_id,\n                log_writer: &log_writer,\n                approvals: approvals.clone(),\n                auto_approve,\n                control_tx: &control_tx,\n                pending_approvals: &pending_approvals,\n                base_retry_delay: &mut base_retry_delay,\n                last_event_id: &mut last_event_id,\n                models_cache_key: &models_cache_key,\n                cancel: cancel.clone(),\n            },\n            current_resp,\n        )\n        .await;\n\n        match outcome {\n            Ok(EventStreamOutcome::Idle) => {\n                // Keep listening - there may be more prompts (e.g., commit reminder)\n                // The task will be aborted by event_handle.abort() when done\n                resp = None;\n                continue;\n            }\n            Ok(EventStreamOutcome::Terminal) => return,\n            Ok(EventStreamOutcome::Disconnected) | Err(_) => {\n                attempt += 1;\n                if attempt >= max_attempts {\n                    let _ = control_tx.send(ControlEvent::Disconnected);\n                    return;\n                }\n            }\n        }\n\n        tokio::time::sleep(exponential_backoff(base_retry_delay, attempt)).await;\n        resp = None;\n    }\n}\n\nfn exponential_backoff(base: Duration, attempt: u32) -> Duration {\n    let exp = attempt.saturating_sub(1).min(10);\n    let mult = 1u32 << exp;\n    base.checked_mul(mult)\n        .unwrap_or(Duration::from_secs(30))\n        .min(Duration::from_secs(30))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum EventStreamOutcome {\n    Idle,\n    Terminal,\n    Disconnected,\n}\n\npub(super) struct EventStreamContext<'a> {\n    seen_permissions: &'a mut HashSet<String>,\n    pub client: &'a reqwest::Client,\n    pub base_url: &'a str,\n    pub directory: &'a str,\n    pub session_id: &'a str,\n    pub log_writer: &'a LogWriter,\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    auto_approve: bool,\n    control_tx: &'a mpsc::UnboundedSender<ControlEvent>,\n    pending_approvals: &'a PendingApprovals,\n    base_retry_delay: &'a mut Duration,\n    last_event_id: &'a mut Option<String>,\n    /// Cache key for model context windows, derived from config that affects available models.\n    pub models_cache_key: &'a str,\n    cancel: CancellationToken,\n}\n\nasync fn process_event_stream(\n    ctx: EventStreamContext<'_>,\n    resp: reqwest::Response,\n) -> Result<EventStreamOutcome, ExecutorError> {\n    let mut stream = resp.bytes_stream().eventsource();\n\n    loop {\n        let evt = tokio::select! {\n            _ = ctx.cancel.cancelled() => {\n                return Ok(EventStreamOutcome::Terminal);\n            }\n            evt = stream.next() => {\n                match evt {\n                    Some(evt) => evt,\n                    None => break,\n                }\n            }\n        };\n        let evt = evt.map_err(|err| ExecutorError::Io(io::Error::other(err)))?;\n\n        if !evt.id.trim().is_empty() {\n            *ctx.last_event_id = Some(evt.id.trim().to_string());\n        }\n        if let Some(retry) = evt.retry {\n            *ctx.base_retry_delay = retry;\n        }\n\n        let trimmed = evt.data.trim();\n        if trimmed.is_empty() {\n            continue;\n        }\n\n        let Ok(data) = serde_json::from_str::<Value>(trimmed) else {\n            let _ = ctx\n                .log_writer\n                .log_error(format!(\n                    \"OpenCode event stream delivered non-JSON event payload: {trimmed}\"\n                ))\n                .await;\n            continue;\n        };\n\n        let Some(event_type) = data.get(\"type\").and_then(Value::as_str) else {\n            continue;\n        };\n\n        if !event_matches_session(event_type, &data, ctx.session_id) {\n            continue;\n        }\n\n        let _ = ctx\n            .log_writer\n            .log_event(&OpencodeExecutorEvent::SdkEvent {\n                event: data.clone(),\n            })\n            .await;\n\n        match event_type {\n            \"message.updated\" => {\n                maybe_emit_token_usage(&ctx, &data).await;\n            }\n            \"session.status\" => {\n                if let Some(status) = data\n                    .pointer(\"/properties/status/type\")\n                    .and_then(Value::as_str)\n                    && status.eq_ignore_ascii_case(\"idle\")\n                {\n                    let _ = ctx.control_tx.send(ControlEvent::Idle);\n                    return Ok(EventStreamOutcome::Idle);\n                }\n            }\n            \"session.idle\" => {\n                let _ = ctx.control_tx.send(ControlEvent::Idle);\n                return Ok(EventStreamOutcome::Idle);\n            }\n            \"session.error\" => {\n                let error_type = data\n                    .pointer(\"/properties/error/name\")\n                    .or_else(|| data.pointer(\"/properties/error/type\"))\n                    .and_then(Value::as_str)\n                    .unwrap_or(\"unknown\");\n                let message = data\n                    .pointer(\"/properties/error/data/message\")\n                    .or_else(|| data.pointer(\"/properties/error/message\"))\n                    .and_then(Value::as_str)\n                    .unwrap_or(\"OpenCode session error\")\n                    .to_string();\n\n                if error_type == \"ProviderAuthError\" {\n                    let _ = ctx.control_tx.send(ControlEvent::AuthRequired { message });\n                    return Ok(EventStreamOutcome::Terminal);\n                }\n\n                let _ = ctx.control_tx.send(ControlEvent::SessionError { message });\n            }\n            \"question.asked\" => {\n                let request_id = data\n                    .pointer(\"/properties/id\")\n                    .and_then(Value::as_str)\n                    .unwrap_or_default()\n                    .to_string();\n\n                if request_id.is_empty() || !ctx.seen_permissions.insert(request_id.clone()) {\n                    continue;\n                }\n\n                let tool_call_id = data\n                    .pointer(\"/properties/tool/callID\")\n                    .and_then(Value::as_str)\n                    .map(str::trim)\n                    .filter(|id| !id.is_empty())\n                    .unwrap_or(request_id.as_str())\n                    .to_string();\n\n                let questions = data\n                    .pointer(\"/properties/questions\")\n                    .and_then(Value::as_array)\n                    .cloned()\n                    .unwrap_or_default();\n                let question_count = questions.len().max(1);\n\n                let approvals = ctx.approvals.clone();\n                let client = ctx.client.clone();\n                let base_url = ctx.base_url.to_string();\n                let directory = ctx.directory.to_string();\n                let log_writer = ctx.log_writer.clone();\n                let cancel = ctx.cancel.clone();\n                let done_tx = ctx.pending_approvals.push().await;\n                tokio::spawn(async move {\n                    let status = match create_question_approval(approvals.clone(), question_count)\n                        .await\n                    {\n                        Ok(created) => {\n                            let _ = log_writer\n                                .log_event(&OpencodeExecutorEvent::QuestionAsked {\n                                    tool_call_id: tool_call_id.clone(),\n                                    approval_id: created.approval_id.clone(),\n                                })\n                                .await;\n\n                            match wait_question_approval(approvals, &created.approval_id, cancel)\n                                .await\n                            {\n                                Ok(status) => Some(status),\n                                Err(err) => {\n                                    handle_approval_error(\n                                        err,\n                                        &format!(\"OpenCode question approval wait failed for request_id={request_id}\"),\n                                        &log_writer,\n                                        &tool_call_id,\n                                        true,\n                                    ).await;\n                                    None\n                                }\n                            }\n                        }\n                        Err(err) => {\n                            handle_approval_error(\n                                err,\n                                &format!(\"OpenCode question approval create failed for request_id={request_id}\"),\n                                &log_writer,\n                                &tool_call_id,\n                                true,\n                            ).await;\n                            None\n                        }\n                    };\n\n                    if let Some(status) = status {\n                        log_question_response(&log_writer, &tool_call_id, status.clone()).await;\n\n                        match status {\n                            QuestionStatus::Answered { answers } => {\n                                let opencode_answers =\n                                    answers_to_opencode_format(&questions, &answers);\n                                let resp = client\n                                    .post(format!(\"{base_url}/question/{request_id}/reply\"))\n                                    .query(&[(\"directory\", directory.as_str())])\n                                    .json(&serde_json::json!({ \"answers\": opencode_answers }))\n                                    .send()\n                                    .await;\n                                match resp {\n                                    Ok(resp) if !resp.status().is_success() => {\n                                        let status = resp.status();\n                                        let body = resp.text().await.unwrap_or_default();\n                                        let truncated: String =\n                                            body.chars().take(400).collect::<String>();\n                                        tracing::warn!(\n                                            \"OpenCode question reply failed request_id={} status={} body={}\",\n                                            request_id,\n                                            status,\n                                            truncated\n                                        );\n                                    }\n                                    Ok(_) => {}\n                                    Err(err) => {\n                                        let is_timeout = err.is_timeout();\n                                        tracing::warn!(\n                                            \"OpenCode question reply error request_id={} timeout={}: {err}\",\n                                            request_id,\n                                            is_timeout\n                                        );\n                                    }\n                                }\n                            }\n                            QuestionStatus::TimedOut => {\n                                let _ = client\n                                    .post(format!(\"{base_url}/question/{request_id}/reject\"))\n                                    .query(&[(\"directory\", directory.as_str())])\n                                    .send()\n                                    .await;\n                            }\n                        }\n                    }\n\n                    let _ = done_tx.send(());\n                });\n            }\n            \"permission.asked\" => {\n                let request_id = data\n                    .pointer(\"/properties/id\")\n                    .and_then(Value::as_str)\n                    .unwrap_or_default()\n                    .to_string();\n\n                if request_id.is_empty() || !ctx.seen_permissions.insert(request_id.clone()) {\n                    continue;\n                }\n\n                let tool_call_id = data\n                    .pointer(\"/properties/tool/callID\")\n                    .and_then(Value::as_str)\n                    .unwrap_or(&request_id)\n                    .to_string();\n\n                let permission = data\n                    .pointer(\"/properties/permission\")\n                    .and_then(Value::as_str)\n                    .unwrap_or(\"tool\")\n                    .to_string();\n\n                let approvals = ctx.approvals.clone();\n                let client = ctx.client.clone();\n                let base_url = ctx.base_url.to_string();\n                let directory = ctx.directory.to_string();\n                let log_writer = ctx.log_writer.clone();\n                let auto_approve = ctx.auto_approve;\n                let cancel = ctx.cancel.clone();\n                let done_tx = ctx.pending_approvals.push().await;\n                tokio::spawn(async move {\n                    let created = match create_permission_approval(\n                        auto_approve,\n                        approvals.clone(),\n                        &permission,\n                    )\n                    .await\n                    {\n                        Ok(Some(created)) => created,\n                        Ok(None) => {\n                            // Auto-approved, no approval needed\n                            log_approval_response(\n                                &log_writer,\n                                &tool_call_id,\n                                ApprovalStatus::Approved,\n                            )\n                            .await;\n\n                            let _ = client\n                                .post(format!(\"{base_url}/permission/{request_id}/reply\"))\n                                .query(&[(\"directory\", directory.as_str())])\n                                .json(&serde_json::json!({ \"reply\": \"once\" }))\n                                .send()\n                                .await;\n                            let _ = done_tx.send(());\n                            return;\n                        }\n                        Err(err) => {\n                            handle_approval_error(\n                                err,\n                                &format!(\"OpenCode approval create failed for tool_call_id={tool_call_id}\"),\n                                &log_writer,\n                                &tool_call_id,\n                                false,\n                            ).await;\n                            let _ = done_tx.send(());\n                            return;\n                        }\n                    };\n\n                    let _ = log_writer\n                        .log_event(&OpencodeExecutorEvent::ApprovalRequested {\n                            tool_call_id: tool_call_id.clone(),\n                            approval_id: created.approval_id.clone(),\n                        })\n                        .await;\n\n                    let status =\n                        match wait_permission_approval(approvals, &created.approval_id, cancel)\n                            .await\n                        {\n                            Ok(status) => status,\n                            Err(err) => {\n                                handle_approval_error(\n                                err,\n                                &format!(\n                                    \"OpenCode approval wait failed for tool_call_id={tool_call_id}\"\n                                ),\n                                &log_writer,\n                                &tool_call_id,\n                                false,\n                            )\n                            .await;\n                                let _ = done_tx.send(());\n                                return;\n                            }\n                        };\n\n                    log_approval_response(&log_writer, &tool_call_id, status.clone()).await;\n\n                    let (reply, message) = match status {\n                        ApprovalStatus::Approved => (\"once\", None),\n                        ApprovalStatus::Denied { reason } => {\n                            let msg = reason\n                                .unwrap_or_else(|| \"User denied this tool use request\".to_string())\n                                .trim()\n                                .to_string();\n                            let msg = if msg.is_empty() {\n                                \"User denied this tool use request\".to_string()\n                            } else {\n                                msg\n                            };\n                            (\"reject\", Some(msg))\n                        }\n                        ApprovalStatus::TimedOut => (\n                            \"reject\",\n                            Some(\n                                \"Approval request timed out; proceed without using this tool call.\"\n                                    .to_string(),\n                            ),\n                        ),\n                        ApprovalStatus::Pending => (\n                            \"reject\",\n                            Some(\n                                \"Approval request could not be completed; proceed without using this tool call.\"\n                                    .to_string(),\n                            ),\n                        ),\n                    };\n\n                    // If we reject without a message, OpenCode treats it as a hard stop.\n                    // Provide a message so the agent can continue with guidance.\n                    let payload = if reply == \"reject\" {\n                        serde_json::json!({ \"reply\": reply, \"message\": message.unwrap_or_else(|| \"User denied this tool use request\".to_string()) })\n                    } else {\n                        serde_json::json!({ \"reply\": reply })\n                    };\n\n                    let _ = client\n                        .post(format!(\"{base_url}/permission/{request_id}/reply\"))\n                        .query(&[(\"directory\", directory.as_str())])\n                        .json(&payload)\n                        .send()\n                        .await;\n\n                    let _ = done_tx.send(());\n                });\n            }\n            _ => {}\n        }\n    }\n\n    Ok(EventStreamOutcome::Disconnected)\n}\n\nfn event_matches_session(event_type: &str, event: &Value, session_id: &str) -> bool {\n    let extracted = match event_type {\n        \"message.updated\" => event\n            .pointer(\"/properties/info/sessionID\")\n            .and_then(Value::as_str),\n        \"message.part.updated\" => event\n            .pointer(\"/properties/part/sessionID\")\n            .and_then(Value::as_str),\n        \"message.part.delta\" => event\n            .pointer(\"/properties/sessionID\")\n            .and_then(Value::as_str),\n        \"permission.asked\" | \"permission.replied\" | \"question.asked\" | \"question.replied\"\n        | \"question.rejected\" | \"session.idle\" | \"session.error\" => event\n            .pointer(\"/properties/sessionID\")\n            .and_then(Value::as_str),\n        _ => event\n            .pointer(\"/properties/sessionID\")\n            .and_then(Value::as_str)\n            .or_else(|| {\n                event\n                    .pointer(\"/properties/info/sessionID\")\n                    .and_then(Value::as_str)\n            })\n            .or_else(|| {\n                event\n                    .pointer(\"/properties/part/sessionID\")\n                    .and_then(Value::as_str)\n            }),\n    };\n\n    extracted == Some(session_id)\n}\n\nasync fn handle_approval_error(\n    err: ExecutorApprovalError,\n    error_context: &str,\n    log_writer: &LogWriter,\n    tool_call_id: &str,\n    is_question: bool,\n) {\n    if matches!(err, ExecutorApprovalError::Cancelled) {\n        return;\n    }\n    tracing::error!(\"{error_context}: {err}\");\n    if is_question {\n        log_question_response(log_writer, tool_call_id, QuestionStatus::TimedOut).await;\n    } else {\n        log_approval_response(\n            log_writer,\n            tool_call_id,\n            ApprovalStatus::Denied {\n                reason: Some(format!(\"Approval service error: {err}\")),\n            },\n        )\n        .await;\n    }\n}\n\nasync fn log_approval_response(log_writer: &LogWriter, tool_call_id: &str, status: ApprovalStatus) {\n    let _ = log_writer\n        .log_event(&OpencodeExecutorEvent::ApprovalResponse {\n            tool_call_id: tool_call_id.to_string(),\n            status,\n        })\n        .await;\n}\n\nasync fn log_question_response(log_writer: &LogWriter, tool_call_id: &str, status: QuestionStatus) {\n    let _ = log_writer\n        .log_event(&OpencodeExecutorEvent::QuestionResponse {\n            tool_call_id: tool_call_id.to_string(),\n            status,\n        })\n        .await;\n}\n\nstruct ApprovalCreated {\n    approval_id: String,\n}\n\nasync fn create_permission_approval(\n    auto_approve: bool,\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    tool_name: &str,\n) -> Result<Option<ApprovalCreated>, ExecutorApprovalError> {\n    if auto_approve {\n        return Ok(None);\n    }\n\n    let Some(approvals) = approvals else {\n        return Ok(None);\n    };\n\n    match approvals.create_tool_approval(tool_name).await {\n        Ok(approval_id) => Ok(Some(ApprovalCreated { approval_id })),\n        Err(\n            ExecutorApprovalError::ServiceUnavailable | ExecutorApprovalError::SessionNotRegistered,\n        ) => Ok(None),\n        Err(err) => Err(err),\n    }\n}\n\nasync fn wait_permission_approval(\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    approval_id: &str,\n    cancel: CancellationToken,\n) -> Result<ApprovalStatus, ExecutorApprovalError> {\n    let Some(approvals) = approvals else {\n        return Ok(ApprovalStatus::Approved);\n    };\n\n    approvals.wait_tool_approval(approval_id, cancel).await\n}\n\nasync fn create_question_approval(\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    question_count: usize,\n) -> Result<ApprovalCreated, ExecutorApprovalError> {\n    let Some(approvals) = approvals else {\n        return Err(ExecutorApprovalError::ServiceUnavailable);\n    };\n\n    let approval_id = approvals\n        .create_question_approval(\"question\", question_count)\n        .await?;\n    Ok(ApprovalCreated { approval_id })\n}\n\nasync fn wait_question_approval(\n    approvals: Option<Arc<dyn ExecutorApprovalService>>,\n    approval_id: &str,\n    cancel: CancellationToken,\n) -> Result<QuestionStatus, ExecutorApprovalError> {\n    let Some(approvals) = approvals else {\n        return Err(ExecutorApprovalError::ServiceUnavailable);\n    };\n\n    approvals.wait_question_answer(approval_id, cancel).await\n}\n\nfn answers_to_opencode_format(questions: &[Value], answers: &[QuestionAnswer]) -> Vec<Vec<String>> {\n    questions\n        .iter()\n        .map(|q| {\n            let question_text = q.get(\"question\").and_then(Value::as_str).unwrap_or(\"\");\n            answers\n                .iter()\n                .find(|qa| qa.question == question_text)\n                .map(|qa| qa.answer.clone())\n                .unwrap_or_else(|| {\n                    tracing::warn!(\n                        ?questions,\n                        ?answers,\n                        \"No answer found for question: {question_text}. This may cause issues with OpenCode processing the reply.\"\n                    );\n                    vec![]\n                })\n        })\n        .collect()\n}\n"
  },
  {
    "path": "crates/executors/src/executors/opencode/slash_commands.rs",
    "content": "//! OpenCode slash command parsing, execution, and result formatting.\n//!\n//! This module is the central place for all slash command handling in OpenCode.\n//! It defines the command enum, parses prompts, executes commands via the SDK,\n//! and formats results as markdown.\n\nuse std::{collections::HashMap, future::Future, io, pin::Pin};\n\nuse serde_json::Value;\nuse tokio::sync::mpsc;\nuse tokio_util::sync::CancellationToken;\n\nuse super::{\n    sdk::{\n        self, AgentInfo, CommandInfo, ConfigProvidersResponse, ConfigResponse, ControlEvent,\n        EventListenerConfig, FormatterStatus, LogWriter, LspStatus, RunConfig,\n    },\n    types::{OpencodeExecutorEvent, ProviderListResponse},\n};\nuse crate::executors::{\n    ExecutorError, SlashCommandDescription,\n    utils::{SlashCommandCall, parse_slash_command},\n};\n\n/// OpenCode slash command with known variants and custom fallback.\n#[derive(Debug, Clone)]\npub enum OpencodeSlashCommand {\n    Compact,\n    Commands,\n    Models {\n        provider: Option<String>,\n    },\n    Agents,\n    Status,\n    Mcp,\n    /// A custom command not in the built-in list.\n    Custom {\n        name: String,\n        arguments: String,\n    },\n}\n\nimpl OpencodeSlashCommand {\n    /// Parse a prompt string into a slash command.\n    pub fn parse(prompt: &str) -> Option<Self> {\n        parse_slash_command(prompt)\n    }\n\n    /// Returns true if this command requires an existing session.\n    pub fn requires_existing_session(&self) -> bool {\n        matches!(self, Self::Compact)\n    }\n\n    /// Returns true if this command should fork the session.\n    pub fn should_fork_session(&self) -> bool {\n        true\n    }\n}\n\nimpl<'a> From<SlashCommandCall<'a>> for OpencodeSlashCommand {\n    fn from(call: SlashCommandCall<'a>) -> Self {\n        match call.name.as_str() {\n            \"compact\" | \"summarize\" => Self::Compact,\n            \"commands\" => Self::Commands,\n            \"models\" => Self::Models {\n                provider: call.arguments.split_whitespace().next().map(String::from),\n            },\n            \"agents\" => Self::Agents,\n            \"status\" => Self::Status,\n            \"mcp\" => Self::Mcp,\n            _ => Self::Custom {\n                name: call.name,\n                arguments: call.arguments.to_string(),\n            },\n        }\n    }\n}\n\n/// Build the list of hardcoded slash commands for discovery.\npub fn hardcoded_slash_commands() -> Vec<SlashCommandDescription> {\n    vec![\n        SlashCommandDescription {\n            name: \"compact\".to_string(),\n            description: Some(\"compact the session\".to_string()),\n        },\n        SlashCommandDescription {\n            name: \"commands\".to_string(),\n            description: Some(\"show all commands\".to_string()),\n        },\n        SlashCommandDescription {\n            name: \"models\".to_string(),\n            description: Some(\"list models\".to_string()),\n        },\n        SlashCommandDescription {\n            name: \"agents\".to_string(),\n            description: Some(\"list agents\".to_string()),\n        },\n        SlashCommandDescription {\n            name: \"status\".to_string(),\n            description: Some(\"show status\".to_string()),\n        },\n        SlashCommandDescription {\n            name: \"mcp\".to_string(),\n            description: Some(\"show MCP status\".to_string()),\n        },\n    ]\n}\n\n/// Format a list of commands as markdown.\nfn format_commands(commands: &[CommandInfo]) -> String {\n    if commands.is_empty() {\n        return \"_No commands available._\".to_string();\n    }\n\n    let mut sorted = commands.to_vec();\n    sorted.sort_by(|a, b| a.name.cmp(&b.name));\n\n    let mut lines = vec![\"## Available Commands\".to_string(), String::new()];\n    for command in sorted {\n        let name = command.name.strip_prefix('/').unwrap_or(&command.name);\n        let desc = command\n            .description\n            .as_ref()\n            .filter(|d| !d.trim().is_empty())\n            .map(|d| format!(\" — {d}\"))\n            .unwrap_or_default();\n        lines.push(format!(\"- `/{name}`{desc}\"));\n    }\n    lines.join(\"\\n\")\n}\n\n/// Format a list of agents as markdown.\nfn format_agents(agents: &[AgentInfo]) -> String {\n    if agents.is_empty() {\n        return \"_No agents available._\".to_string();\n    }\n\n    let mut sorted = agents.to_vec();\n    sorted.sort_by(|a, b| a.name.cmp(&b.name));\n\n    let mut lines = vec![\"## Available Agents\".to_string(), String::new()];\n    for agent in sorted {\n        let desc = agent\n            .description\n            .as_ref()\n            .filter(|d| !d.trim().is_empty())\n            .map(|d| format!(\" — {d}\"))\n            .unwrap_or_default();\n        lines.push(format!(\"- **{}**{desc}\", agent.name));\n    }\n    lines.join(\"\\n\")\n}\n\n/// Format models list as markdown.\nfn format_models(\n    config_providers: &ConfigProvidersResponse,\n    provider_list: Option<&ProviderListResponse>,\n    provider_filter: Option<&str>,\n) -> String {\n    let mut providers: Vec<_> = config_providers.providers.iter().collect();\n    providers.sort_by(|a, b| a.id.cmp(&b.id));\n\n    if providers.is_empty() {\n        return \"_No models available._\".to_string();\n    }\n\n    if let Some(filter) = provider_filter\n        && !providers.iter().any(|p| p.id == filter)\n    {\n        return format!(\"_Provider not found: `{filter}`_\");\n    }\n\n    let mut lines = vec![\"## Models\".to_string(), String::new()];\n\n    for provider in providers {\n        if let Some(filter) = provider_filter\n            && provider.id != filter\n        {\n            continue;\n        }\n\n        let default_note = config_providers\n            .default\n            .get(&provider.id)\n            .map(|m| format!(\" (default: `{m}`)\"))\n            .unwrap_or_default();\n        lines.push(format!(\"### {}{default_note}\", provider.id));\n        lines.push(String::new());\n\n        let mut model_ids: Vec<_> = provider.models.keys().cloned().collect();\n        model_ids.sort();\n        for model_id in model_ids {\n            lines.push(format!(\"- `{}/{model_id}`\", provider.id));\n        }\n        lines.push(String::new());\n    }\n\n    if let Some(list) = provider_list\n        && !list.connected.is_empty()\n    {\n        let mut connected = list.connected.clone();\n        connected.sort();\n        lines.push(format!(\"**Connected:** {}\", connected.join(\", \")));\n    }\n\n    lines.join(\"\\n\").trim_end().to_string()\n}\n\n/// Format status information as markdown.\nfn format_status(\n    mcp: &HashMap<String, Value>,\n    lsp: &[LspStatus],\n    formatter: &[FormatterStatus],\n    config: &ConfigResponse,\n) -> String {\n    let mut sections = Vec::new();\n\n    sections.push(format_mcp_section(mcp));\n    sections.push(format_lsp_section(lsp));\n    sections.push(format_formatter_section(formatter));\n\n    let plugins = if config.plugin.is_empty() {\n        \"**Plugins:** _none_\".to_string()\n    } else {\n        format!(\"**Plugins:** {}\", config.plugin.join(\", \"))\n    };\n    sections.push(plugins);\n\n    sections.join(\"\\n\\n\")\n}\n\n/// Format MCP status as markdown.\nfn format_mcp(mcp: &HashMap<String, Value>) -> String {\n    format_mcp_section(mcp)\n}\n\nfn format_mcp_section(mcp: &HashMap<String, Value>) -> String {\n    let mut lines = vec![\"### MCP Servers\".to_string(), String::new()];\n\n    if mcp.is_empty() {\n        lines.push(\"_No MCP servers configured._\".to_string());\n    } else {\n        let mut names: Vec<_> = mcp.keys().cloned().collect();\n        names.sort();\n\n        for name in names {\n            let entry = mcp.get(&name).unwrap_or(&Value::Null);\n            let status = entry\n                .get(\"status\")\n                .and_then(Value::as_str)\n                .unwrap_or(\"unknown\");\n\n            let error_note = entry\n                .get(\"error\")\n                .and_then(Value::as_str)\n                .map(|e| format!(\" — _{e}_\"))\n                .unwrap_or_default();\n\n            lines.push(format!(\"- **{name}**: {status}{error_note}\"));\n        }\n    }\n\n    lines.join(\"\\n\")\n}\n\nfn format_lsp_section(lsp: &[LspStatus]) -> String {\n    let mut lines = vec![\"### LSP Servers\".to_string(), String::new()];\n\n    if lsp.is_empty() {\n        lines.push(\"_No LSP servers active._\".to_string());\n    } else {\n        let mut entries = lsp.to_vec();\n        entries.sort_by(|a, b| a.name.cmp(&b.name));\n\n        for entry in entries {\n            lines.push(format!(\n                \"- **{}** ({}) — `{}`\",\n                entry.name, entry.status, entry.root\n            ));\n        }\n    }\n\n    lines.join(\"\\n\")\n}\n\nfn format_formatter_section(formatter: &[FormatterStatus]) -> String {\n    let mut lines = vec![\"### Formatters\".to_string(), String::new()];\n\n    if formatter.is_empty() {\n        lines.push(\"_No formatters configured._\".to_string());\n    } else {\n        let mut entries = formatter.to_vec();\n        entries.sort_by(|a, b| a.name.cmp(&b.name));\n\n        for entry in entries {\n            let status = if entry.enabled { \"enabled\" } else { \"disabled\" };\n            let extensions = if entry.extensions.is_empty() {\n                String::new()\n            } else {\n                format!(\" — {}\", entry.extensions.join(\", \"))\n            };\n            lines.push(format!(\"- **{}** [{status}]{extensions}\", entry.name));\n        }\n    }\n\n    lines.join(\"\\n\")\n}\n\n/// Format a \"command not found\" message.\nfn format_command_not_found(name: &str) -> String {\n    format!(\"_Command not found: `/{name}`_\")\n}\n\n/// Format a \"no session\" message.\nfn format_no_session() -> String {\n    \"_No session available to run this command yet._\".to_string()\n}\n\n/// Log a slash command result as an event.\nasync fn log_result(log_writer: &LogWriter, message: String) -> Result<(), ExecutorError> {\n    log_writer.log_slash_command_result(message).await\n}\n\n/// Log completion of a slash command.\nasync fn log_done(log_writer: &LogWriter) -> Result<(), ExecutorError> {\n    log_writer.log_event(&OpencodeExecutorEvent::Done).await\n}\n\n/// Log a result and mark as done.\nasync fn log_result_and_done(log_writer: &LogWriter, message: String) -> Result<(), ExecutorError> {\n    log_result(log_writer, message).await?;\n    log_done(log_writer).await\n}\n\n/// Execute a slash command using the OpenCode SDK.\npub async fn execute(\n    config: RunConfig,\n    command: OpencodeSlashCommand,\n    log_writer: LogWriter,\n    client: reqwest::Client,\n    cancel: CancellationToken,\n) -> Result<(), ExecutorError> {\n    tokio::select! {\n        _ = cancel.cancelled() => return Ok(()),\n        res = sdk::wait_for_health(&client, &config.base_url) => res?,\n    }\n\n    // Handle commands that don't require a session first\n    match &command {\n        OpencodeSlashCommand::Commands => {\n            let commands = sdk::list_commands(&client, &config.base_url, &config.directory).await?;\n            log_result_and_done(&log_writer, format_commands(&commands)).await?;\n            return Ok(());\n        }\n        OpencodeSlashCommand::Models { provider } => {\n            let config_providers =\n                sdk::list_config_providers(&client, &config.base_url, &config.directory).await?;\n            let provider_list = sdk::list_providers(&client, &config.base_url, &config.directory)\n                .await\n                .ok();\n            log_result_and_done(\n                &log_writer,\n                format_models(\n                    &config_providers,\n                    provider_list.as_ref(),\n                    provider.as_deref(),\n                ),\n            )\n            .await?;\n            return Ok(());\n        }\n        OpencodeSlashCommand::Agents => {\n            let agents = sdk::list_agents(&client, &config.base_url, &config.directory).await?;\n            log_result_and_done(&log_writer, format_agents(&agents)).await?;\n            return Ok(());\n        }\n        OpencodeSlashCommand::Status => {\n            let mcp = sdk::mcp_status(&client, &config.base_url, &config.directory).await?;\n            let lsp = sdk::lsp_status(&client, &config.base_url, &config.directory).await?;\n            let formatter =\n                sdk::formatter_status(&client, &config.base_url, &config.directory).await?;\n            let cfg = sdk::config_get(&client, &config.base_url, &config.directory).await?;\n            log_result_and_done(&log_writer, format_status(&mcp, &lsp, &formatter, &cfg)).await?;\n            return Ok(());\n        }\n        OpencodeSlashCommand::Mcp => {\n            let mcp = sdk::mcp_status(&client, &config.base_url, &config.directory).await?;\n            log_result_and_done(&log_writer, format_mcp(&mcp)).await?;\n            return Ok(());\n        }\n        // Session-dependent commands handled below\n        OpencodeSlashCommand::Compact | OpencodeSlashCommand::Custom { .. } => {}\n    }\n\n    // Validate custom commands exist\n    if let OpencodeSlashCommand::Custom { name, .. } = &command {\n        let available = sdk::list_commands(&client, &config.base_url, &config.directory).await?;\n        let normalized = name.trim_start_matches('/');\n        if !available\n            .iter()\n            .any(|cmd| cmd.name.trim_start_matches('/') == normalized)\n        {\n            log_result_and_done(&log_writer, format_command_not_found(normalized)).await?;\n            return Ok(());\n        }\n    }\n\n    if command.requires_existing_session() && config.resume_session_id.is_none() {\n        log_writer\n            .log_slash_command_result(format_no_session())\n            .await?;\n        log_writer.log_event(&OpencodeExecutorEvent::Done).await?;\n        return Ok(());\n    }\n\n    let session_id = match config.resume_session_id.as_deref() {\n        Some(existing) if command.should_fork_session() => {\n            tokio::select! {\n                _ = cancel.cancelled() => return Ok(()),\n                res = sdk::fork_session(&client, &config.base_url, &config.directory, existing) => res?,\n            }\n        }\n        Some(existing) => existing.to_string(),\n        None => tokio::select! {\n            _ = cancel.cancelled() => return Ok(()),\n            res = sdk::create_session(&client, &config.base_url, &config.directory) => res?,\n        },\n    };\n\n    log_writer\n        .log_event(&OpencodeExecutorEvent::SessionStart {\n            session_id: session_id.clone(),\n        })\n        .await?;\n\n    let is_compact = matches!(&command, OpencodeSlashCommand::Compact);\n    let compaction_model = if is_compact {\n        Some(\n            sdk::resolve_compaction_model(\n                &client,\n                &config.base_url,\n                &config.directory,\n                config.model.as_deref(),\n            )\n            .await?,\n        )\n    } else {\n        None\n    };\n\n    let (control_tx, mut control_rx) = mpsc::unbounded_channel::<ControlEvent>();\n    let pending_approvals = sdk::PendingApprovals::new();\n    let event_resp = tokio::select! {\n        _ = cancel.cancelled() => return Ok(()),\n        res = sdk::connect_event_stream(&client, &config.base_url, &config.directory, None) => res?,\n    };\n    let event_handle = tokio::spawn(sdk::spawn_event_listener(\n        EventListenerConfig {\n            client: client.clone(),\n            base_url: config.base_url.clone(),\n            directory: config.directory.clone(),\n            session_id: session_id.clone(),\n            log_writer: log_writer.clone(),\n            approvals: config.approvals.clone(),\n            auto_approve: config.auto_approve,\n            control_tx,\n            pending_approvals: pending_approvals.clone(),\n            models_cache_key: config.models_cache_key.clone(),\n            cancel: cancel.clone(),\n        },\n        event_resp,\n    ));\n\n    let request_client = client.clone();\n    let request_base_url = config.base_url.clone();\n    let request_directory = config.directory.clone();\n    let request_session_id = session_id.clone();\n    let request_agent = config.agent.clone();\n    let request_model = config.model.clone();\n    let request_model_variant = config.model_variant.clone();\n\n    let request_fut: Pin<Box<dyn Future<Output = Result<(), ExecutorError>> + Send>> = match command\n    {\n        OpencodeSlashCommand::Compact => {\n            let model = compaction_model.ok_or_else(|| {\n                ExecutorError::Io(io::Error::other(\"OpenCode compaction model missing\"))\n            })?;\n            Box::pin(async move {\n                sdk::session_summarize(\n                    &request_client,\n                    &request_base_url,\n                    &request_directory,\n                    &request_session_id,\n                    model,\n                )\n                .await\n            })\n        }\n        OpencodeSlashCommand::Custom { name, arguments } => Box::pin(async move {\n            sdk::session_command(\n                &request_client,\n                &request_base_url,\n                &request_directory,\n                &request_session_id,\n                name,\n                arguments,\n                request_agent,\n                request_model,\n                request_model_variant,\n            )\n            .await\n        }),\n        _ => unreachable!(\"handled non-session commands earlier\"),\n    };\n\n    let request_result = sdk::run_request_with_control(\n        request_fut,\n        &mut control_rx,\n        &pending_approvals,\n        cancel.clone(),\n    )\n    .await;\n\n    if cancel.is_cancelled() {\n        sdk::send_abort(&client, &config.base_url, &config.directory, &session_id).await;\n        event_handle.abort();\n        return Ok(());\n    }\n\n    event_handle.abort();\n\n    request_result?;\n    log_writer.log_event(&OpencodeExecutorEvent::Done).await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/executors/src/executors/opencode/types.rs",
    "content": "use std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse workspace_utils::approvals::{ApprovalStatus, QuestionStatus};\n\n/// JSON log events emitted by the OpenCode SDK executor.\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum OpencodeExecutorEvent {\n    StartupLog {\n        message: String,\n    },\n    SessionStart {\n        session_id: String,\n    },\n    SlashCommandResult {\n        message: String,\n    },\n    SdkEvent {\n        event: serde_json::Value,\n    },\n    TokenUsage {\n        total_tokens: u32,\n        model_context_window: u32,\n    },\n    ApprovalRequested {\n        tool_call_id: String,\n        approval_id: String,\n    },\n    ApprovalResponse {\n        tool_call_id: String,\n        status: ApprovalStatus,\n    },\n    QuestionAsked {\n        tool_call_id: String,\n        approval_id: String,\n    },\n    QuestionResponse {\n        tool_call_id: String,\n        status: QuestionStatus,\n    },\n    SystemMessage {\n        content: String,\n    },\n    Error {\n        message: String,\n    },\n    Done,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct SdkEventEnvelope {\n    #[serde(rename = \"type\")]\n    pub(super) type_: String,\n    #[serde(default)]\n    pub(super) properties: Value,\n}\n\n#[derive(Debug)]\npub(super) enum SdkEvent {\n    MessageUpdated(MessageUpdatedEvent),\n    MessagePartUpdated(MessagePartUpdatedEvent),\n    MessagePartDelta(MessagePartDeltaEvent),\n    MessageRemoved,\n    MessagePartRemoved,\n    PermissionAsked(PermissionAskedEvent),\n    PermissionReplied,\n    SessionIdle,\n    SessionStatus(SessionStatusEvent),\n    SessionDiff,\n    SessionCompacted,\n    SessionError(SessionErrorEvent),\n    TodoUpdated(TodoUpdatedEvent),\n    QuestionAsked(QuestionAskedEvent),\n    QuestionReplied,\n    QuestionRejected,\n    CommandExecuted,\n    TuiSessionSelect,\n    Unknown { type_: String, properties: Value },\n}\n\nimpl SdkEvent {\n    pub(super) fn parse(value: &Value) -> Option<Self> {\n        let envelope = serde_json::from_value::<SdkEventEnvelope>(value.clone()).ok()?;\n\n        let event = match envelope.type_.as_str() {\n            \"message.updated\" => {\n                SdkEvent::MessageUpdated(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"message.part.updated\" => {\n                SdkEvent::MessagePartUpdated(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"message.part.delta\" => {\n                SdkEvent::MessagePartDelta(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"message.removed\" => SdkEvent::MessageRemoved,\n            \"message.part.removed\" => SdkEvent::MessagePartRemoved,\n            \"permission.asked\" => {\n                SdkEvent::PermissionAsked(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"permission.replied\" => SdkEvent::PermissionReplied,\n            \"session.idle\" => SdkEvent::SessionIdle,\n            \"session.status\" => {\n                SdkEvent::SessionStatus(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"session.diff\" => SdkEvent::SessionDiff,\n            \"session.compacted\" => SdkEvent::SessionCompacted,\n            \"session.error\" => {\n                SdkEvent::SessionError(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"todo.updated\" => {\n                SdkEvent::TodoUpdated(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"question.asked\" => {\n                SdkEvent::QuestionAsked(serde_json::from_value(envelope.properties).ok()?)\n            }\n            \"question.replied\" => SdkEvent::QuestionReplied,\n            \"question.rejected\" => SdkEvent::QuestionRejected,\n            \"command.executed\" => SdkEvent::CommandExecuted,\n            \"tui.session.select\" => SdkEvent::TuiSessionSelect,\n            _ => SdkEvent::Unknown {\n                type_: envelope.type_,\n                properties: envelope.properties,\n            },\n        };\n\n        Some(event)\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub(super) enum MessageRole {\n    User,\n    Assistant,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct MessageUpdatedEvent {\n    pub(super) info: MessageInfo,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct MessageInfo {\n    pub(super) id: String,\n    pub(super) role: MessageRole,\n    #[serde(default)]\n    pub(super) model: Option<MessageModelInfo>,\n    #[serde(rename = \"providerID\", default)]\n    pub(super) provider_id: Option<String>,\n    #[serde(rename = \"modelID\", default)]\n    pub(super) model_id: Option<String>,\n    #[serde(default)]\n    pub(super) tokens: Option<MessageTokens>,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct MessageTokens {\n    #[serde(default, deserialize_with = \"deserialize_f64_as_u32\")]\n    pub(super) input: u32,\n    #[serde(default, deserialize_with = \"deserialize_f64_as_u32\")]\n    pub(super) output: u32,\n    pub(super) cache: Option<MessageTokensCache>,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct MessageTokensCache {\n    #[serde(default, deserialize_with = \"deserialize_f64_as_u32\")]\n    pub(super) read: u32,\n}\n\nfn deserialize_f64_as_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>\nwhere\n    D: serde::Deserializer<'de>,\n{\n    let v = Option::<f64>::deserialize(deserializer)?;\n    Ok(v.filter(|f| f.is_finite() && *f >= 0.0)\n        .map(|f| f.round() as u32)\n        .unwrap_or(0))\n}\n\nimpl MessageInfo {\n    pub(super) fn provider_id(&self) -> Option<&str> {\n        self.model\n            .as_ref()\n            .map(|m| m.provider_id.as_str())\n            .or(self.provider_id.as_deref())\n    }\n\n    pub(super) fn model_id(&self) -> Option<&str> {\n        self.model\n            .as_ref()\n            .map(|m| m.model_id.as_str())\n            .or(self.model_id.as_deref())\n    }\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct MessageModelInfo {\n    #[serde(rename = \"providerID\", alias = \"providerId\")]\n    pub(super) provider_id: String,\n    #[serde(rename = \"modelID\", alias = \"modelId\")]\n    pub(super) model_id: String,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct MessagePartUpdatedEvent {\n    pub(super) part: Part,\n    #[serde(default)]\n    pub(super) delta: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct MessagePartDeltaEvent {\n    #[allow(dead_code)]\n    #[serde(rename = \"messageID\")]\n    pub(super) message_id: String,\n    #[serde(rename = \"partID\")]\n    pub(super) part_id: String,\n    pub(super) field: String,\n    pub(super) delta: String,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct PermissionAskedEvent {\n    #[allow(dead_code)]\n    pub(super) id: String,\n    pub(super) permission: String,\n    #[serde(default)]\n    pub(super) patterns: Vec<String>,\n    #[serde(default)]\n    pub(super) metadata: Value,\n    #[serde(default)]\n    pub(super) tool: Option<PermissionToolInfo>,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct PermissionToolInfo {\n    #[serde(rename = \"callID\")]\n    pub(super) call_id: String,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct QuestionAskedEvent {\n    pub(super) id: String,\n    pub(super) questions: Vec<QuestionInfo>,\n    #[serde(default)]\n    pub(super) tool: Option<QuestionAskedTool>,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct QuestionAskedTool {\n    #[allow(dead_code)]\n    #[serde(rename = \"messageID\")]\n    pub(super) message_id: String,\n    #[serde(rename = \"callID\")]\n    pub(super) call_id: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub(super) struct QuestionInfo {\n    pub(super) question: String,\n    pub(super) header: String,\n    #[serde(default)]\n    pub(super) options: Vec<QuestionOption>,\n    #[serde(default)]\n    pub(super) multiple: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub(super) struct QuestionOption {\n    pub(super) label: String,\n    #[serde(default)]\n    pub(super) description: String,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct SessionStatusEvent {\n    pub(super) status: SessionStatus,\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"lowercase\")]\npub(super) enum SessionStatus {\n    Idle,\n    Busy,\n    Retry {\n        attempt: u64,\n        message: String,\n        next: u64,\n    },\n    #[serde(other)]\n    Other,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct TodoUpdatedEvent {\n    pub(super) todos: Vec<SdkTodo>,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct SdkTodo {\n    #[serde(default)]\n    pub(super) id: Option<String>,\n    pub(super) content: String,\n    pub(super) status: String,\n    pub(super) priority: String,\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(tag = \"type\")]\npub(super) enum Part {\n    #[serde(rename = \"text\")]\n    Text(TextPart),\n    #[serde(rename = \"reasoning\")]\n    Reasoning(ReasoningPart),\n    #[serde(rename = \"tool\")]\n    Tool(Box<ToolPart>),\n    #[serde(other)]\n    Other,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct TextPart {\n    #[serde(default)]\n    pub(super) id: Option<String>,\n    #[serde(rename = \"messageID\")]\n    pub(super) message_id: String,\n    pub(super) text: String,\n}\n\n/// Same structure as TextPart, used for reasoning content\npub(super) type ReasoningPart = TextPart;\n\n#[derive(Debug, Deserialize)]\npub(super) struct ToolPart {\n    #[serde(rename = \"messageID\")]\n    pub(super) message_id: String,\n    #[serde(rename = \"callID\")]\n    pub(super) call_id: String,\n    #[serde(default)]\n    pub(super) tool: String,\n    pub(super) state: ToolStateUpdate,\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(tag = \"status\", rename_all = \"lowercase\")]\npub(super) enum ToolStateUpdate {\n    Pending {\n        #[serde(default)]\n        input: Option<Value>,\n    },\n    Running {\n        #[serde(default)]\n        input: Option<Value>,\n        #[serde(default)]\n        title: Option<String>,\n        #[serde(default)]\n        metadata: Option<Value>,\n    },\n    Completed {\n        #[serde(default)]\n        input: Option<Value>,\n        #[serde(default)]\n        output: Option<String>,\n        #[serde(default)]\n        title: Option<String>,\n        #[serde(default)]\n        metadata: Option<Value>,\n    },\n    Error {\n        #[serde(default)]\n        input: Option<Value>,\n        #[serde(default)]\n        error: Option<String>,\n        #[serde(default)]\n        metadata: Option<Value>,\n    },\n    #[serde(other)]\n    Unknown,\n}\n\n#[derive(Debug, Deserialize)]\npub(super) struct SessionErrorEvent {\n    #[serde(default)]\n    pub(super) error: Option<SdkError>,\n}\n\n#[derive(Debug)]\npub(super) struct SdkError {\n    pub(super) raw: Value,\n}\n\nimpl SdkError {\n    pub(super) fn kind(&self) -> &str {\n        self.raw\n            .get(\"name\")\n            .or_else(|| self.raw.get(\"type\"))\n            .and_then(Value::as_str)\n            .unwrap_or(\"unknown\")\n    }\n\n    pub(super) fn message(&self) -> Option<String> {\n        self.raw\n            .pointer(\"/data/message\")\n            .or_else(|| self.raw.get(\"message\"))\n            .and_then(Value::as_str)\n            .map(|s| s.to_string())\n    }\n}\n\nimpl<'de> Deserialize<'de> for SdkError {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>,\n    {\n        let raw = Value::deserialize(deserializer)?;\n        Ok(Self { raw })\n    }\n}\n\n/// Configuration response from /config endpoint\n#[derive(Debug, Deserialize)]\npub(super) struct Config {\n    #[serde(default)]\n    pub(super) model: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Default)]\npub struct ProviderModelInfo {\n    #[serde(default)]\n    pub id: String,\n    #[serde(default)]\n    pub name: String,\n    #[serde(default)]\n    pub release_date: Option<String>,\n    #[serde(default)]\n    pub variants: Option<HashMap<String, Value>>,\n    #[serde(default)]\n    pub limit: ProviderModelLimit,\n}\n\n#[derive(Debug, Deserialize, Default)]\npub struct ProviderModelLimit {\n    #[serde(default, deserialize_with = \"deserialize_f64_as_u32\")]\n    pub context: u32,\n}\n\n#[derive(Debug, Deserialize)]\npub struct ProviderInfo {\n    pub id: String,\n    #[serde(default)]\n    pub name: String,\n    #[serde(default)]\n    pub models: HashMap<String, ProviderModelInfo>,\n}\n\n#[derive(Debug, Deserialize)]\npub struct ProviderListResponse {\n    pub all: Vec<ProviderInfo>,\n    #[serde(default)]\n    pub connected: Vec<String>,\n}\n"
  },
  {
    "path": "crates/executors/src/executors/opencode.rs",
    "content": "use std::{cmp::Ordering, path::Path, sync::Arc, time::Duration};\n\nuse async_trait::async_trait;\nuse command_group::AsyncGroupChild;\nuse convert_case::{Case, Casing};\nuse derivative::Derivative;\nuse futures::StreamExt;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse tokio::{io::AsyncBufReadExt, process::Command};\nuse ts_rs::TS;\nuse workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore};\n\nuse crate::{\n    approvals::ExecutorApprovalService,\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides},\n    env::{ExecutionEnv, RepoContext},\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, ExecutorExitResult,\n        SlashCommandDescription, SpawnedChild, StandardCodingAgentExecutor,\n        opencode::types::OpencodeExecutorEvent, utils::reorder_slash_commands,\n    },\n    logs::utils::patch,\n    model_selector::{AgentInfo, ModelInfo, ModelProvider, PermissionPolicy, ReasoningOption},\n    profile::ExecutorConfig,\n    stdout_dup::create_stdout_pipe_writer,\n};\n\nmod models;\nmod normalize_logs;\npub(crate) mod sdk;\nmod slash_commands;\npub(crate) mod types;\n\nuse sdk::{\n    AgentInfo as SDKAgentInfo, LogWriter, RunConfig, build_authenticated_client,\n    generate_server_password, list_agents, list_commands, list_providers, run_session,\n    run_slash_command,\n};\nuse slash_commands::{OpencodeSlashCommand, hardcoded_slash_commands};\nuse types::{Config, ProviderModelInfo};\n\n#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]\n#[derivative(Debug, PartialEq)]\npub struct Opencode {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub variant: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\", alias = \"mode\")]\n    pub agent: Option<String>,\n    /// Auto-approve agent actions\n    #[serde(default = \"default_to_true\")]\n    pub auto_approve: bool,\n    /// Enable auto-compaction when the context length approaches the model's context window limit\n    #[serde(default = \"default_to_true\")]\n    pub auto_compact: bool,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n    #[serde(skip)]\n    #[ts(skip)]\n    #[derivative(Debug = \"ignore\", PartialEq = \"ignore\")]\n    pub approvals: Option<Arc<dyn ExecutorApprovalService>>,\n}\n\n/// Represents a spawned OpenCode server with its base URL\nstruct OpencodeServer {\n    #[allow(unused)]\n    child: Option<AsyncGroupChild>,\n    base_url: String,\n    server_password: ServerPassword,\n}\n\nimpl Drop for OpencodeServer {\n    fn drop(&mut self) {\n        // kill the process properly using the kill helper as the native kill_on_drop doesn't work reliably causing orphaned processes and memory leaks\n        if let Some(mut child) = self.child.take() {\n            tokio::spawn(async move {\n                let _ = workspace_utils::process::kill_process_group(&mut child).await;\n            });\n        }\n    }\n}\n\ntype ServerPassword = String;\n\nimpl Opencode {\n    fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        let builder = CommandBuilder::new(\"npx -y opencode-ai@1.2.27\")\n            // Pass hostname/port as separate args so OpenCode treats them as explicitly set\n            // (it checks `process.argv.includes(\\\"--port\\\")` / `\\\"--hostname\\\"`).\n            .extend_params([\"serve\", \"--hostname\", \"127.0.0.1\", \"--port\", \"0\"]);\n        apply_overrides(builder, &self.cmd)\n    }\n\n    /// Compute a cache key for model context windows based on configuration that can affect the list of available models.\n    fn compute_models_cache_key(&self) -> String {\n        serde_json::to_string(&self.cmd).unwrap_or_default()\n    }\n\n    /// Common boilerplate for spawning an OpenCode server process.\n    async fn spawn_server_process(\n        &self,\n        current_dir: &Path,\n        env: &ExecutionEnv,\n    ) -> Result<(AsyncGroupChild, ServerPassword), ExecutorError> {\n        let command_parts = self.build_command_builder()?.build_initial()?;\n        let (program_path, args) = command_parts.into_resolved().await?;\n\n        let server_password = generate_server_password();\n\n        let mut command = Command::new(program_path);\n        command\n            .kill_on_drop(true)\n            .stdin(std::process::Stdio::null())\n            .stdout(std::process::Stdio::piped())\n            .stderr(std::process::Stdio::piped())\n            .current_dir(current_dir)\n            .env(\"NPM_CONFIG_LOGLEVEL\", \"error\")\n            .env(\"NODE_NO_WARNINGS\", \"1\")\n            .env(\"NO_COLOR\", \"1\")\n            .env(\"OPENCODE_SERVER_USERNAME\", \"opencode\")\n            .env(\"OPENCODE_SERVER_PASSWORD\", &server_password)\n            .args(&args);\n\n        env.clone()\n            .with_profile(&self.cmd)\n            .apply_to_command(&mut command);\n\n        let child = command.group_spawn_no_window()?;\n\n        Ok((child, server_password))\n    }\n\n    /// Handles process spawning, waiting for the server URL\n    async fn spawn_server(\n        &self,\n        current_dir: &Path,\n        env: &ExecutionEnv,\n    ) -> Result<OpencodeServer, ExecutorError> {\n        let (mut child, server_password) = self.spawn_server_process(current_dir, env).await?;\n        let server_stdout = child.inner().stdout.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::other(\"OpenCode server missing stdout\"))\n        })?;\n\n        let base_url = wait_for_server_url(server_stdout, None).await?;\n\n        Ok(OpencodeServer {\n            child: Some(child),\n            base_url,\n            server_password,\n        })\n    }\n\n    async fn spawn_inner(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        resume_session: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let slash_command = OpencodeSlashCommand::parse(prompt);\n        let combined_prompt = if slash_command.is_some() {\n            prompt.to_string()\n        } else {\n            self.append_prompt.combine_prompt(prompt)\n        };\n\n        let (mut child, server_password) = self.spawn_server_process(current_dir, env).await?;\n        let server_stdout = child.inner().stdout.take().ok_or_else(|| {\n            ExecutorError::Io(std::io::Error::other(\"OpenCode server missing stdout\"))\n        })?;\n\n        let stdout = create_stdout_pipe_writer(&mut child)?;\n        let log_writer = LogWriter::new(stdout);\n\n        let (exit_signal_tx, exit_signal_rx) = tokio::sync::oneshot::channel();\n        let cancel = tokio_util::sync::CancellationToken::new();\n\n        // Prepare config values that will be moved into the spawned task\n        let directory = current_dir.to_string_lossy().to_string();\n        let approvals = self.approvals.clone();\n        let model = self.model.clone();\n        let model_variant = self.variant.clone();\n        let agent = self.agent.clone();\n        let auto_approve = self.auto_approve;\n        let resume_session_id = resume_session.map(|s| s.to_string());\n        let models_cache_key = self.compute_models_cache_key();\n        let cancel_for_task = cancel.clone();\n        let commit_reminder = env.commit_reminder;\n        let commit_reminder_prompt = env.commit_reminder_prompt.clone();\n        let repo_context = env.repo_context.clone();\n\n        tokio::spawn(async move {\n            // Wait for server to print listening URL\n            let base_url = match wait_for_server_url(server_stdout, Some(log_writer.clone())).await\n            {\n                Ok(url) => url,\n                Err(err) => {\n                    let _ = log_writer\n                        .log_error(format!(\"OpenCode startup error: {err}\"))\n                        .await;\n                    let _ = exit_signal_tx.send(ExecutorExitResult::Failure);\n                    return;\n                }\n            };\n\n            let config = RunConfig {\n                base_url,\n                directory,\n                prompt: combined_prompt,\n                resume_session_id,\n                model,\n                model_variant,\n                agent,\n                approvals,\n                auto_approve,\n                server_password,\n                models_cache_key,\n                commit_reminder,\n                commit_reminder_prompt,\n                repo_context,\n            };\n\n            let result = match slash_command {\n                Some(command) => {\n                    run_slash_command(config, log_writer.clone(), command, cancel_for_task).await\n                }\n                None => run_session(config, log_writer.clone(), cancel_for_task).await,\n            };\n            let exit_result = match result {\n                Ok(()) => ExecutorExitResult::Success,\n                Err(err) => {\n                    let _ = log_writer\n                        .log_error(format!(\"OpenCode executor error: {err}\"))\n                        .await;\n                    ExecutorExitResult::Failure\n                }\n            };\n            let _ = exit_signal_tx.send(exit_result);\n        });\n\n        Ok(SpawnedChild {\n            child,\n            exit_signal: Some(exit_signal_rx),\n            cancel: Some(cancel),\n        })\n    }\n\n    /// Transform raw model data into ModelInfo structs.\n    fn transform_models(\n        &self,\n        models: &std::collections::HashMap<String, ProviderModelInfo>,\n        provider_id: &str,\n    ) -> Vec<ModelInfo> {\n        let mut ordered = models.values().collect::<Vec<_>>();\n        ordered.sort_by(|a, b| match (&a.release_date, &b.release_date) {\n            (Some(a_date), Some(b_date)) => b_date.cmp(a_date),\n            (Some(_), None) => Ordering::Less,\n            (None, Some(_)) => Ordering::Greater,\n            (None, None) => a.name.cmp(&b.name),\n        });\n\n        ordered\n            .into_iter()\n            .map(|m| {\n                let reasoning_options = m\n                    .variants\n                    .as_ref()\n                    .map(|variants| ReasoningOption::from_names(variants.keys().cloned()))\n                    .unwrap_or_default();\n\n                ModelInfo {\n                    id: m.id.clone(),\n                    name: m.name.clone(),\n                    provider_id: Some(provider_id.to_string()),\n                    reasoning_options,\n                }\n            })\n            .collect()\n    }\n}\n\nfn map_opencode_agents(agents: &[SDKAgentInfo]) -> Vec<AgentInfo> {\n    let default_agent_name = if agents\n        .iter()\n        .any(|a| a.name.eq_ignore_ascii_case(\"sisyphus\"))\n    {\n        \"sisyphus\"\n    } else {\n        \"build\"\n    };\n\n    agents\n        .iter()\n        .map(|agent| AgentInfo {\n            id: agent.name.clone(),\n            label: agent.name.to_case(Case::Title),\n            description: agent.description.clone(),\n            is_default: agent.name.eq_ignore_ascii_case(default_agent_name),\n        })\n        .collect()\n}\n\nfn format_tail(captured: Vec<String>) -> String {\n    captured\n        .into_iter()\n        .rev()\n        .take(12)\n        .collect::<Vec<_>>()\n        .into_iter()\n        .rev()\n        .collect::<Vec<_>>()\n        .join(\"\\n\")\n}\n\nasync fn wait_for_server_url(\n    stdout: tokio::process::ChildStdout,\n    log_writer: Option<LogWriter>,\n) -> Result<String, ExecutorError> {\n    let mut lines = tokio::io::BufReader::new(stdout).lines();\n    let deadline = tokio::time::Instant::now() + Duration::from_secs(180);\n    let mut captured: Vec<String> = Vec::new();\n\n    loop {\n        if tokio::time::Instant::now() > deadline {\n            return Err(ExecutorError::Io(std::io::Error::other(format!(\n                \"Timed out waiting for OpenCode server to print listening URL.\\nServer output tail:\\n{}\",\n                format_tail(captured)\n            ))));\n        }\n\n        let line = match tokio::time::timeout_at(deadline, lines.next_line()).await {\n            Ok(Ok(Some(line))) => line,\n            Ok(Ok(None)) => {\n                return Err(ExecutorError::Io(std::io::Error::other(format!(\n                    \"OpenCode server exited before printing listening URL.\\nServer output tail:\\n{}\",\n                    format_tail(captured)\n                ))));\n            }\n            Ok(Err(err)) => return Err(ExecutorError::Io(err)),\n            Err(_) => continue,\n        };\n\n        if let Some(log_writer) = &log_writer {\n            log_writer\n                .log_event(&OpencodeExecutorEvent::StartupLog {\n                    message: line.clone(),\n                })\n                .await?;\n        }\n        if captured.len() < 64 {\n            captured.push(line.clone());\n        }\n\n        if let Some(url) = line.trim().strip_prefix(\"opencode server listening on \") {\n            // Keep draining stdout to avoid backpressure on the server, but don't block startup.\n            tokio::spawn(async move {\n                let mut lines = tokio::io::BufReader::new(lines.into_inner()).lines();\n                while let Ok(Some(_)) = lines.next_line().await {}\n            });\n            return Ok(url.trim().to_string());\n        }\n    }\n}\n\nfn default_discovered_options() -> crate::executor_discovery::ExecutorDiscoveredOptions {\n    use crate::{\n        executor_discovery::ExecutorDiscoveredOptions, model_selector::ModelSelectorConfig,\n    };\n    ExecutorDiscoveredOptions {\n        model_selector: ModelSelectorConfig {\n            providers: vec![],\n            models: vec![],\n            default_model: None,\n            agents: vec![],\n            permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised],\n        },\n        slash_commands: hardcoded_slash_commands(),\n        loading_models: false,\n        loading_agents: false,\n        loading_slash_commands: false,\n        error: None,\n    }\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for Opencode {\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = &executor_config.model_id {\n            self.model = Some(model_id.clone());\n        }\n\n        if let Some(agent_id) = &executor_config.agent_id {\n            self.agent = Some(agent_id.clone());\n        }\n\n        if let Some(permission_policy) = executor_config.permission_policy.clone() {\n            self.auto_approve = matches!(permission_policy, PermissionPolicy::Auto);\n        }\n\n        if let Some(reasoning_id) = &executor_config.reasoning_id {\n            self.variant = Some(reasoning_id.clone());\n        }\n    }\n\n    fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {\n        self.approvals = Some(approvals);\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let env = setup_permissions_env(self.auto_approve, env);\n        let env = setup_compaction_env(self.auto_compact, &env);\n        self.spawn_inner(current_dir, prompt, None, &env).await\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let env = setup_permissions_env(self.auto_approve, env);\n        let env = setup_compaction_env(self.auto_compact, &env);\n        self.spawn_inner(current_dir, prompt, Some(session_id), &env)\n            .await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        worktree_path: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        normalize_logs::normalize_logs(msg_store, worktree_path)\n    }\n\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        #[cfg(not(windows))]\n        {\n            let base_dirs = xdg::BaseDirectories::with_prefix(\"opencode\");\n            // First try opencode.json, then opencode.jsonc\n            base_dirs\n                .get_config_file(\"opencode.json\")\n                .filter(|p| p.exists())\n                .or_else(|| base_dirs.get_config_file(\"opencode.jsonc\"))\n        }\n        #[cfg(windows)]\n        {\n            let config_dir = std::env::var(\"XDG_CONFIG_HOME\")\n                .map(std::path::PathBuf::from)\n                .ok()\n                .or_else(|| dirs::home_dir().map(|p| p.join(\".config\")))\n                .map(|p| p.join(\"opencode\"))?;\n\n            let path = Some(config_dir.join(\"opencode.json\"))\n                .filter(|p| p.exists())\n                .unwrap_or_else(|| config_dir.join(\"opencode.jsonc\"));\n            Some(path)\n        }\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        let mcp_config_found = self\n            .default_mcp_config_path()\n            .map(|p| p.exists())\n            .unwrap_or(false);\n\n        // Check multiple installation indicator paths:\n        // 1. XDG config dir: $XDG_CONFIG_HOME/opencode\n        // 2. XDG data dir: $XDG_DATA_HOME/opencode\n        // 3. XDG state dir: $XDG_STATE_HOME/opencode\n        // 4. OpenCode CLI home: ~/.opencode\n        #[cfg(not(windows))]\n        let installation_indicator_found = {\n            let base_dirs = xdg::BaseDirectories::with_prefix(\"opencode\");\n\n            let config_dir_exists = base_dirs\n                .get_config_home()\n                .map(|config| config.exists())\n                .unwrap_or(false);\n\n            let data_dir_exists = base_dirs\n                .get_data_home()\n                .map(|data| data.exists())\n                .unwrap_or(false);\n\n            let state_dir_exists = base_dirs\n                .get_state_home()\n                .map(|state| state.exists())\n                .unwrap_or(false);\n\n            config_dir_exists || data_dir_exists || state_dir_exists\n        };\n\n        #[cfg(windows)]\n        let installation_indicator_found = std::env::var(\"XDG_CONFIG_HOME\")\n            .ok()\n            .map(std::path::PathBuf::from)\n            .and_then(|p| p.join(\"opencode\").exists().then_some(()))\n            .or_else(|| {\n                dirs::home_dir()\n                    .and_then(|p| p.join(\".config\").join(\"opencode\").exists().then_some(()))\n            })\n            .is_some();\n\n        let home_opencode_exists = dirs::home_dir()\n            .map(|home| home.join(\".opencode\").exists())\n            .unwrap_or(false);\n\n        if mcp_config_found || installation_indicator_found || home_opencode_exists {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    async fn discover_options(\n        &self,\n        workdir: Option<&Path>,\n        repo_path: Option<&Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        use crate::{\n            executor_discovery::ExecutorConfigCacheKey, executors::utils::executor_options_cache,\n        };\n\n        let cache = executor_options_cache();\n        let cmd_key = self.compute_models_cache_key();\n        let base_executor = BaseCodingAgent::Opencode;\n\n        let (target_path, initial_options) = if let Some(wd) = workdir {\n            let wd_buf = wd.to_path_buf();\n            let target_key =\n                ExecutorConfigCacheKey::new(Some(&wd_buf), cmd_key.clone(), base_executor);\n            if let Some(cached) = cache.get(&target_key) {\n                return Ok(Box::pin(futures::stream::once(async move {\n                    patch::executor_discovered_options(cached.as_ref().clone().with_loading(false))\n                })));\n            }\n            let provisional = repo_path\n                .and_then(|rp| {\n                    let rp_buf = rp.to_path_buf();\n                    let repo_key =\n                        ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor);\n                    cache.get(&repo_key)\n                })\n                .or_else(|| {\n                    let global_key =\n                        ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor);\n                    cache.get(&global_key)\n                });\n            (\n                Some(wd.to_path_buf()),\n                provisional\n                    .map(|p| p.as_ref().clone().with_loading(true))\n                    .unwrap_or_else(|| default_discovered_options().with_loading(true)),\n            )\n        } else if let Some(rp) = repo_path {\n            let rp_buf = rp.to_path_buf();\n            let target_key =\n                ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor);\n            if let Some(cached) = cache.get(&target_key) {\n                return Ok(Box::pin(futures::stream::once(async move {\n                    patch::executor_discovered_options(cached.as_ref().clone().with_loading(false))\n                })));\n            }\n            let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor);\n            let provisional = cache.get(&global_key);\n            (\n                Some(rp.to_path_buf()),\n                provisional\n                    .map(|p| p.as_ref().clone().with_loading(true))\n                    .unwrap_or_else(|| default_discovered_options().with_loading(true)),\n            )\n        } else {\n            let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor);\n            if let Some(cached) = cache.get(&global_key) {\n                return Ok(Box::pin(futures::stream::once(async move {\n                    patch::executor_discovered_options(cached.as_ref().clone().with_loading(false))\n                })));\n            }\n            (None, default_discovered_options().with_loading(true))\n        };\n\n        let initial_patch = patch::executor_discovered_options(initial_options);\n\n        let this = self.clone();\n        let cmd_key_for_discovery = cmd_key.clone();\n\n        let discovery_stream = async_stream::stream! {\n            let discovery_path = target_path.as_deref().unwrap_or(Path::new(\".\")).to_path_buf();\n            let mut final_options = default_discovered_options();\n\n            let env = ExecutionEnv::new(RepoContext::default(), false, String::new());\n            let env = setup_permissions_env(this.auto_approve, &env);\n\n            let server = match this.spawn_server(&discovery_path, &env).await {\n                Ok(s) => s,\n                Err(e) => {\n                    tracing::warn!(\"Failed to spawn OpenCode server: {}\", e);\n                    yield patch::discovery_error(e.to_string());\n                    return;\n                }\n            };\n\n            let directory = discovery_path.to_string_lossy();\n            let client = match build_authenticated_client(&directory, &server.server_password) {\n                Ok(c) => c,\n                Err(e) => {\n                    tracing::warn!(\"Failed to build authenticated client: {}\", e);\n                    yield patch::discovery_error(e.to_string());\n                    return;\n                }\n            };\n\n            let base_url = server.base_url.clone();\n            let directory_str = directory.to_string();\n\n            let providers_future = list_providers(&client, &base_url, &directory_str);\n            let agents_future = list_agents(&client, &base_url, &directory_str);\n            let commands_future = list_commands(&client, &base_url, &directory_str);\n\n            let config_future = async {\n                let resp = client\n                    .get(format!(\"{}/config\", base_url))\n                    .query(&[(\"directory\", &directory_str)])\n                    .send()\n                    .await\n                    .map_err(|e| ExecutorError::Io(std::io::Error::other(format!(\"HTTP request failed: {e}\"))))?;\n\n                if resp.status().is_success() {\n                    resp.json::<Config>().await.map_err(|e| {\n                        ExecutorError::Io(std::io::Error::other(format!(\n                            \"Failed to parse config response: {e}\"\n                        )))\n                    })\n                } else {\n                    Ok(Config { model: None })\n                }\n            };\n\n            let (providers_result, agents_result, commands_result, config_result) =\n                tokio::join!(providers_future, agents_future, commands_future, config_future);\n\n            match providers_result {\n                Ok(data) => {\n                    models::seed_context_windows_cache(\n                        &cmd_key_for_discovery,\n                        models::extract_context_windows(&data),\n                    );\n\n                    final_options.model_selector.providers = data\n                        .all\n                        .iter()\n                        .filter(|p| data.connected.contains(&p.id))\n                        .map(|p| ModelProvider {\n                            id: p.id.clone(),\n                            name: p.name.clone(),\n                        })\n                        .collect();\n\n                    final_options.model_selector.models = data\n                        .all\n                        .iter()\n                        .filter(|p| data.connected.contains(&p.id))\n                        .flat_map(|p| this.transform_models(&p.models, &p.id))\n                        .collect();\n\n                    yield patch::update_providers(final_options.model_selector.providers.clone());\n                    yield patch::update_models(final_options.model_selector.models.clone());\n                    yield patch::models_loaded();\n                }\n                Err(e) => {\n                    tracing::warn!(\"Failed to fetch OpenCode providers: {}\", e);\n                }\n            }\n\n            match config_result {\n                Ok(config) => {\n                    final_options.model_selector.default_model = config.model;\n                    yield patch::update_default_model(final_options.model_selector.default_model.clone());\n                }\n                Err(e) => {\n                    tracing::warn!(\"Failed to fetch OpenCode config: {}\", e);\n                }\n            }\n\n            match agents_result {\n                Ok(agents) => {\n                    final_options.model_selector.agents = map_opencode_agents(&agents);\n                    yield patch::update_agents(final_options.model_selector.agents.clone());\n                    yield patch::agents_loaded();\n                }\n                Err(e) => {\n                    tracing::warn!(\"Failed to fetch OpenCode agents: {}\", e);\n                }\n            }\n\n            match commands_result {\n                Ok(commands) => {\n                    let defaults = hardcoded_slash_commands();\n                    let mut seen: std::collections::HashSet<String> =\n                        defaults.iter().map(|cmd| cmd.name.clone()).collect();\n                    let discovered: Vec<SlashCommandDescription> = commands\n                        .into_iter()\n                        .map(|cmd| SlashCommandDescription {\n                            name: cmd.name.trim_start_matches('/').to_string(),\n                            description: cmd.description,\n                        })\n                        .filter(|cmd| seen.insert(cmd.name.clone()))\n                        .chain(defaults)\n                        .collect();\n                    final_options.slash_commands = reorder_slash_commands(discovered);\n                    yield patch::update_slash_commands(final_options.slash_commands.clone());\n                    yield patch::slash_commands_loaded();\n                }\n                Err(e) => {\n                    tracing::warn!(\"Failed to fetch OpenCode commands: {}\", e);\n                    final_options.slash_commands = hardcoded_slash_commands();\n                    yield patch::update_slash_commands(final_options.slash_commands.clone());\n                    yield patch::slash_commands_loaded();\n                }\n            }\n\n            let cache = executor_options_cache();\n            if let Some(path) = &target_path {\n                let target_cache_key = ExecutorConfigCacheKey::new(\n                    Some(path),\n                    cmd_key_for_discovery.clone(),\n                    BaseCodingAgent::Opencode,\n                );\n                cache.put(target_cache_key, final_options.clone());\n            }\n            let global_cache_key = ExecutorConfigCacheKey::new(\n                None,\n                cmd_key_for_discovery,\n                BaseCodingAgent::Opencode,\n            );\n            cache.put(global_cache_key, final_options);\n        };\n\n        Ok(Box::pin(\n            futures::stream::once(async move { initial_patch }).chain(discovery_stream),\n        ))\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        ExecutorConfig {\n            executor: BaseCodingAgent::Opencode,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: self.agent.clone(),\n            reasoning_id: self.variant.clone(),\n            permission_policy: Some(if self.auto_approve {\n                PermissionPolicy::Auto\n            } else {\n                PermissionPolicy::Supervised\n            }),\n        }\n    }\n}\n\nfn default_to_true() -> bool {\n    true\n}\n\nfn setup_permissions_env(auto_approve: bool, env: &ExecutionEnv) -> ExecutionEnv {\n    let mut env = env.clone();\n\n    let permissions = match env.get(\"OPENCODE_PERMISSION\") {\n        Some(existing) => Some(existing.to_string()),\n        None => build_default_permissions(auto_approve),\n    };\n\n    if let Some(permissions) = permissions {\n        env.insert(\"OPENCODE_PERMISSION\", &permissions);\n    }\n    env\n}\n\nfn build_default_permissions(auto_approve: bool) -> Option<String> {\n    if auto_approve {\n        None\n    } else {\n        Some(r#\"{\"edit\":\"ask\",\"bash\":\"ask\",\"webfetch\":\"ask\",\"doom_loop\":\"ask\",\"external_directory\":\"ask\",\"question\":\"allow\"}\"#.to_string())\n    }\n}\n\nfn setup_compaction_env(auto_compact: bool, env: &ExecutionEnv) -> ExecutionEnv {\n    if !auto_compact {\n        return env.clone();\n    }\n\n    let mut env = env.clone();\n    let merged = merge_compaction_config(env.get(\"OPENCODE_CONFIG_CONTENT\").map(String::as_str));\n    env.insert(\"OPENCODE_CONFIG_CONTENT\", merged);\n    env\n}\n\nfn merge_compaction_config(existing_json: Option<&str>) -> String {\n    let mut config: Map<String, Value> = existing_json\n        .and_then(|value| serde_json::from_str(value.trim()).ok())\n        .unwrap_or_default();\n\n    let mut compaction = config\n        .remove(\"compaction\")\n        .and_then(|value| value.as_object().cloned())\n        .unwrap_or_default();\n    compaction.insert(\"auto\".to_string(), Value::Bool(true));\n    config.insert(\"compaction\".to_string(), Value::Object(compaction));\n\n    serde_json::to_string(&config).unwrap_or_else(|_| r#\"{\"compaction\":{\"auto\":true}}\"#.to_string())\n}\n"
  },
  {
    "path": "crates/executors/src/executors/qa_mock.rs",
    "content": "//! QA Mode: Mock executor for testing\n//!\n//! This module provides a mock executor that:\n//! 1. Performs random file operations (create, delete, modify)\n//! 2. Streams 10 mock log entries over 10 seconds\n//! 3. Outputs logs in ClaudeJson format for compatibility with existing log normalization\n\nuse std::{path::Path, process::Stdio, sync::Arc};\n\nuse async_trait::async_trait;\nuse rand::seq::SliceRandom as _;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse tracing::{info, warn};\nuse ts_rs::TS;\nuse workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore};\n\nuse crate::{\n    env::ExecutionEnv,\n    executors::{\n        BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor,\n        claude::{\n            ClaudeContentItem, ClaudeJson, ClaudeMessage, ClaudeMessageContent, ClaudeToolData,\n        },\n    },\n    logs::utils::EntryIndexProvider,\n    profile::ExecutorConfig,\n};\n\n/// Mock executor for QA testing\n#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, TS, JsonSchema)]\npub struct QaMockExecutor;\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for QaMockExecutor {\n    fn apply_overrides(&mut self, _executor_config: &ExecutorConfig) {}\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        _env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        info!(\"QA Mock Executor: spawning mock execution\");\n\n        // 1. Perform file operations before spawning the log output process\n        perform_file_operations(current_dir).await;\n\n        // 2. Generate mock logs and write to temp file to avoid shell escaping issues\n        let logs = generate_mock_logs(prompt);\n        let temp_dir = std::env::temp_dir();\n        let log_file = temp_dir.join(format!(\"qa_mock_logs_{}.jsonl\", uuid::Uuid::new_v4()));\n\n        // Write all logs to file, one per line\n        let content = logs.join(\"\\n\") + \"\\n\";\n        tokio::fs::write(&log_file, &content)\n            .await\n            .map_err(|e| ExecutorError::Io(std::io::Error::other(e)))?;\n\n        // 3. Create shell script that reads file and outputs with delays\n        // Using IFS= read -r to preserve exact content (no word splitting, no backslash interpretation)\n        let script = format!(\n            r#\"while IFS= read -r line; do echo \"$line\"; sleep 1; done < \"{}\"; rm -f \"{}\"\"#,\n            log_file.display(),\n            log_file.display()\n        );\n\n        let mut cmd = tokio::process::Command::new(\"sh\");\n        cmd.arg(\"-c\")\n            .arg(&script)\n            .current_dir(current_dir)\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped());\n\n        let child = cmd.group_spawn_no_window().map_err(ExecutorError::Io)?;\n        Ok(SpawnedChild::from(child))\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        _session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        // QA mode doesn't support real sessions, just spawn fresh\n        info!(\"QA Mock Executor: follow-up request treated as new spawn\");\n        self.spawn(current_dir, prompt, env).await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        current_dir: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        // Reuse Claude's log processor since we output ClaudeJson format\n        let entry_index_provider = EntryIndexProvider::start_from(&msg_store);\n        let h1 = crate::executors::claude::ClaudeLogProcessor::process_logs(\n            msg_store,\n            current_dir,\n            entry_index_provider,\n            crate::executors::claude::HistoryStrategy::Default,\n        );\n        vec![h1]\n    }\n\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        None // QA mock doesn't need MCP config\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        ExecutorConfig {\n            executor: BaseCodingAgent::QaMock,\n            variant: None,\n            model_id: Some(\"qa-mock\".to_string()),\n            agent_id: None,\n            reasoning_id: None,\n            permission_policy: Some(crate::model_selector::PermissionPolicy::Auto),\n        }\n    }\n}\n\n/// Perform random file operations in the worktree\nasync fn perform_file_operations(dir: &Path) {\n    info!(\"QA Mock: performing file operations in {:?}\", dir);\n\n    // Create: qa_created_{uuid}.txt\n    let uuid = uuid::Uuid::new_v4();\n    let new_file = dir.join(format!(\"qa_created_{}.txt\", uuid));\n    match tokio::fs::write(&new_file, \"QA mode created this file\\n\").await {\n        Ok(_) => info!(\"QA Mock: created file {:?}\", new_file),\n        Err(e) => warn!(\"QA Mock: failed to create file: {}\", e),\n    }\n\n    // Find files (excluding .git and binary files)\n    let files: Vec<_> = walkdir::WalkDir::new(dir)\n        .max_depth(3) // Limit depth to avoid long walks\n        .into_iter()\n        .filter_map(|e| e.ok())\n        .filter(|e| e.file_type().is_file())\n        .filter(|e| !e.path().to_string_lossy().contains(\".git\"))\n        .filter(|e| {\n            e.path()\n                .extension()\n                .and_then(|ext| ext.to_str())\n                .is_some_and(|ext| [\"rs\", \"ts\", \"js\", \"txt\", \"md\", \"json\"].contains(&ext))\n        })\n        .collect();\n\n    if files.len() >= 2 {\n        // Pick random indices before any await points (thread_rng is not Send)\n        let (remove_idx, modify_idx) = {\n            let mut rng = rand::thread_rng();\n            let mut indices: Vec<usize> = (0..files.len()).collect();\n            indices.shuffle(&mut rng);\n            (indices.first().copied(), indices.get(1).copied())\n        };\n\n        // Remove a random file (first shuffled index)\n        if let Some(idx) = remove_idx {\n            let file_to_remove = files[idx].path().to_path_buf();\n            // Don't remove the file we just created\n            if file_to_remove != new_file {\n                match tokio::fs::remove_file(&file_to_remove).await {\n                    Ok(_) => info!(\"QA Mock: removed file {:?}\", file_to_remove),\n                    Err(e) => warn!(\"QA Mock: failed to remove file: {}\", e),\n                }\n            }\n        }\n\n        // Modify a different random file (second shuffled index)\n        if let Some(idx) = modify_idx {\n            let file_to_modify = files[idx].path().to_path_buf();\n            // Don't modify the file we just created\n            if file_to_modify != new_file {\n                match tokio::fs::read_to_string(&file_to_modify).await {\n                    Ok(content) => {\n                        let modified = format!(\n                            \"{}\\n// QA modification at {}\\n\",\n                            content,\n                            chrono::Utc::now().format(\"%Y-%m-%d %H:%M:%S UTC\")\n                        );\n                        match tokio::fs::write(&file_to_modify, modified).await {\n                            Ok(_) => info!(\"QA Mock: modified file {:?}\", file_to_modify),\n                            Err(e) => warn!(\"QA Mock: failed to write modified file: {}\", e),\n                        }\n                    }\n                    Err(e) => warn!(\"QA Mock: failed to read file for modification: {}\", e),\n                }\n            }\n        }\n    } else {\n        info!(\n            \"QA Mock: not enough files found for remove/modify operations (found {})\",\n            files.len()\n        );\n    }\n}\n\n/// Generate 10 mock log entries in ClaudeJson format using strongly-typed structs\nfn generate_mock_logs(prompt: &str) -> Vec<String> {\n    let session_id = uuid::Uuid::new_v4().to_string();\n\n    let logs: Vec<ClaudeJson> = vec![\n        // 1. System init\n        ClaudeJson::System {\n            subtype: Some(\"init\".to_string()),\n            session_id: Some(session_id.clone()),\n            cwd: None,\n            tools: None,\n            model: Some(\"qa-mock-executor\".to_string()),\n            api_key_source: Some(\"unknown\".to_string()),\n            status: None,\n            slash_commands: vec![],\n            plugins: vec![],\n            agents: vec![],\n            task_id: None,\n            tool_use_id: None,\n            description: None,\n            task_type: None,\n            prompt: None,\n            summary: None,\n            last_tool_name: None,\n        },\n        // 2. Assistant thinking\n        ClaudeJson::Assistant {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-1\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"assistant\".to_string(),\n                model: Some(\"qa-mock\".to_string()),\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::Thinking {\n                    thinking: \"Analyzing the QA task and preparing mock execution...\".to_string(),\n                }]),\n                stop_reason: None,\n            },\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-1\".to_string()),\n        },\n        // 3. Read tool use\n        ClaudeJson::Assistant {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-2\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"assistant\".to_string(),\n                model: Some(\"qa-mock\".to_string()),\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolUse {\n                    id: \"qa-tool-1\".to_string(),\n                    tool_data: ClaudeToolData::Read {\n                        file_path: \"README.md\".to_string(),\n                    },\n                }]),\n                stop_reason: None,\n            },\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-2\".to_string()),\n        },\n        // 4. Read tool result\n        ClaudeJson::User {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-3\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"user\".to_string(),\n                model: None,\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolResult {\n                    tool_use_id: \"qa-tool-1\".to_string(),\n                    content: serde_json::json!(\n                        \"# Project README\\\\n\\\\nThis is a QA test repository.\"\n                    ),\n                    is_error: Some(false),\n                }]),\n                stop_reason: None,\n            },\n            is_synthetic: false,\n            is_replay: false,\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-3\".to_string()),\n        },\n        // 5. Write tool use\n        ClaudeJson::Assistant {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-4\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"assistant\".to_string(),\n                model: Some(\"qa-mock\".to_string()),\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolUse {\n                    id: \"qa-tool-2\".to_string(),\n                    tool_data: ClaudeToolData::Write {\n                        file_path: \"qa_output.txt\".to_string(),\n                        content: \"QA generated content\".to_string(),\n                    },\n                }]),\n                stop_reason: None,\n            },\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-4\".to_string()),\n        },\n        // 6. Write tool result\n        ClaudeJson::User {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-5\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"user\".to_string(),\n                model: None,\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolResult {\n                    tool_use_id: \"qa-tool-2\".to_string(),\n                    content: serde_json::json!(\"File written successfully\"),\n                    is_error: Some(false),\n                }]),\n                stop_reason: None,\n            },\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-5\".to_string()),\n            is_synthetic: false,\n            is_replay: false,\n        },\n        // 7. Bash tool use\n        ClaudeJson::Assistant {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-6\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"assistant\".to_string(),\n                model: Some(\"qa-mock\".to_string()),\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolUse {\n                    id: \"qa-tool-3\".to_string(),\n                    tool_data: ClaudeToolData::Bash {\n                        command: \"echo 'QA test complete'\".to_string(),\n                        description: Some(\"Run QA test command\".to_string()),\n                    },\n                }]),\n                stop_reason: None,\n            },\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-6\".to_string()),\n        },\n        // 8. Bash tool result\n        ClaudeJson::User {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-7\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"user\".to_string(),\n                model: None,\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolResult {\n                    tool_use_id: \"qa-tool-3\".to_string(),\n                    content: serde_json::json!(\"QA test complete\\\\n\"),\n                    is_error: Some(false),\n                }]),\n                stop_reason: None,\n            },\n            is_synthetic: false,\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-7\".to_string()),\n            is_replay: false,\n        },\n        // 9. Assistant final message\n        ClaudeJson::Assistant {\n            message: ClaudeMessage {\n                id: Some(\"msg-qa-8\".to_string()),\n                message_type: Some(\"message\".to_string()),\n                role: \"assistant\".to_string(),\n                model: Some(\"qa-mock\".to_string()),\n                content: ClaudeMessageContent::Array(vec![ClaudeContentItem::Text {\n                    text: format!(\n                        \"QA mode execution completed successfully.\\\\n\\\\nI performed the following operations:\\\\n1. Read README.md\\\\n2. Created qa_output.txt\\\\n3. Ran a test command\\\\nOriginal prompt: {}\",\n                        prompt\n                    ),\n                }]),\n                stop_reason: Some(\"end_turn\".to_string()),\n            },\n            session_id: Some(session_id.clone()),\n            uuid: Some(\"uuid-qa-8\".to_string()),\n        },\n        // 10. Result success\n        ClaudeJson::Result {\n            subtype: Some(\"success\".to_string()),\n            is_error: Some(false),\n            duration_ms: Some(10000),\n            result: None,\n            error: None,\n            num_turns: Some(3),\n            session_id: Some(session_id),\n            model_usage: None,\n            usage: None,\n        },\n    ];\n\n    // Serialize to JSON strings - this ensures proper escaping\n    logs.into_iter()\n        .map(|log| serde_json::to_string(&log).expect(\"ClaudeJson should serialize\"))\n        .collect()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_generate_mock_logs_count() {\n        let logs = generate_mock_logs(\"test prompt\");\n        assert_eq!(logs.len(), 10, \"Should generate exactly 10 log entries\");\n    }\n\n    #[test]\n    fn test_generate_mock_logs_valid_json() {\n        let logs = generate_mock_logs(\"test prompt\");\n        for (i, log) in logs.iter().enumerate() {\n            let parsed: Result<serde_json::Value, _> = serde_json::from_str(log);\n            assert!(\n                parsed.is_ok(),\n                \"Log entry {} should be valid JSON: {}\",\n                i,\n                log\n            );\n        }\n    }\n\n    #[test]\n    fn test_generate_mock_logs_deserializes_to_claudejson() {\n        let logs = generate_mock_logs(\"test prompt\");\n        for (i, log) in logs.iter().enumerate() {\n            let parsed: Result<ClaudeJson, _> = serde_json::from_str(log);\n            assert!(\n                parsed.is_ok(),\n                \"Log entry {} should deserialize to ClaudeJson: {} - error: {:?}\",\n                i,\n                log,\n                parsed.err()\n            );\n        }\n    }\n\n    #[test]\n    fn test_escape_special_characters() {\n        let logs = generate_mock_logs(\"test with \\\"quotes\\\" and\\nnewlines\");\n        // The final assistant message (index 8) should contain the prompt\n        let final_log = &logs[8];\n        let parsed: ClaudeJson = serde_json::from_str(final_log).unwrap();\n\n        if let ClaudeJson::Assistant { message, .. } = parsed {\n            if let ClaudeMessageContent::Array(items) = &message.content {\n                if let Some(ClaudeContentItem::Text { text }) = items.first() {\n                    assert!(text.contains(\"test with \\\"quotes\\\" and\\nnewlines\"));\n                } else {\n                    panic!(\"Expected Text content item\");\n                }\n            } else {\n                panic!(\"Expected Array content\");\n            }\n        } else {\n            panic!(\"Expected Assistant variant\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/qwen.rs",
    "content": "use std::{path::Path, sync::Arc};\n\nuse async_trait::async_trait;\nuse derivative::Derivative;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse workspace_utils::msg_store::MsgStore;\n\nuse crate::{\n    approvals::ExecutorApprovalService,\n    command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides},\n    env::ExecutionEnv,\n    executor_discovery::ExecutorDiscoveredOptions,\n    executors::{\n        AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild,\n        StandardCodingAgentExecutor, gemini::AcpAgentHarness,\n    },\n    logs::utils::patch,\n    model_selector::{ModelSelectorConfig, PermissionPolicy},\n    profile::ExecutorConfig,\n};\n\n#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]\n#[derivative(Debug, PartialEq)]\npub struct QwenCode {\n    #[serde(default)]\n    pub append_prompt: AppendPrompt,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\", alias = \"mode\")]\n    pub agent: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub yolo: Option<bool>,\n    #[serde(flatten)]\n    pub cmd: CmdOverrides,\n    #[serde(skip)]\n    #[ts(skip)]\n    #[derivative(Debug = \"ignore\", PartialEq = \"ignore\")]\n    pub approvals: Option<Arc<dyn ExecutorApprovalService>>,\n}\n\nimpl QwenCode {\n    fn build_command_builder(&self) -> Result<CommandBuilder, CommandBuildError> {\n        let mut builder = CommandBuilder::new(\"npx -y @qwen-code/qwen-code@0.9.1\");\n\n        if let Some(model) = &self.model {\n            builder = builder.extend_params([\"--model\", model.as_str()]);\n        }\n\n        if self.yolo.unwrap_or(false) {\n            builder = builder.extend_params([\"--yolo\"]);\n        }\n        builder = builder.extend_params([\"--acp\"]);\n        apply_overrides(builder, &self.cmd)\n    }\n}\n\n#[async_trait]\nimpl StandardCodingAgentExecutor for QwenCode {\n    fn apply_overrides(&mut self, executor_config: &ExecutorConfig) {\n        if let Some(model_id) = executor_config.model_id.as_ref() {\n            self.model = Some(model_id.clone());\n        }\n\n        if let Some(agent_id) = executor_config.agent_id.as_ref() {\n            self.agent = Some(agent_id.clone());\n        }\n        if let Some(permission_policy) = executor_config.permission_policy.clone() {\n            self.yolo = Some(matches!(\n                permission_policy,\n                crate::model_selector::PermissionPolicy::Auto\n            ));\n        }\n    }\n\n    fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {\n        self.approvals = Some(approvals);\n    }\n\n    async fn spawn(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let qwen_command = self.build_command_builder()?.build_initial()?;\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n        let mut harness = AcpAgentHarness::with_session_namespace(\"qwen_sessions\");\n        if let Some(model) = &self.model {\n            harness = harness.with_model(model);\n        }\n        if let Some(agent) = &self.agent {\n            harness = harness.with_mode(agent);\n        }\n        let approvals = if self.yolo.unwrap_or(false) {\n            None\n        } else {\n            self.approvals.clone()\n        };\n        harness\n            .spawn_with_command(\n                current_dir,\n                combined_prompt,\n                qwen_command,\n                env,\n                &self.cmd,\n                approvals,\n            )\n            .await\n    }\n\n    async fn spawn_follow_up(\n        &self,\n        current_dir: &Path,\n        prompt: &str,\n        session_id: &str,\n        _reset_to_message_id: Option<&str>,\n        env: &ExecutionEnv,\n    ) -> Result<SpawnedChild, ExecutorError> {\n        let qwen_command = self.build_command_builder()?.build_follow_up(&[])?;\n        let combined_prompt = self.append_prompt.combine_prompt(prompt);\n        let mut harness = AcpAgentHarness::with_session_namespace(\"qwen_sessions\");\n        if let Some(model) = &self.model {\n            harness = harness.with_model(model);\n        }\n        if let Some(agent) = &self.agent {\n            harness = harness.with_mode(agent);\n        }\n        let approvals = if self.yolo.unwrap_or(false) {\n            None\n        } else {\n            self.approvals.clone()\n        };\n        harness\n            .spawn_follow_up_with_command(\n                current_dir,\n                combined_prompt,\n                session_id,\n                qwen_command,\n                env,\n                &self.cmd,\n                approvals,\n            )\n            .await\n    }\n\n    fn normalize_logs(\n        &self,\n        msg_store: Arc<MsgStore>,\n        worktree_path: &Path,\n    ) -> Vec<tokio::task::JoinHandle<()>> {\n        crate::executors::acp::normalize_logs(msg_store, worktree_path)\n    }\n\n    // MCP configuration methods\n    fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {\n        dirs::home_dir().map(|home| home.join(\".qwen\").join(\"settings.json\"))\n    }\n\n    fn get_availability_info(&self) -> AvailabilityInfo {\n        let mcp_config_found = self\n            .default_mcp_config_path()\n            .map(|p| p.exists())\n            .unwrap_or(false);\n\n        let installation_indicator_found = dirs::home_dir()\n            .map(|home| home.join(\".qwen\").join(\"installation_id\").exists())\n            .unwrap_or(false);\n\n        if mcp_config_found || installation_indicator_found {\n            AvailabilityInfo::InstallationFound\n        } else {\n            AvailabilityInfo::NotFound\n        }\n    }\n\n    fn get_preset_options(&self) -> ExecutorConfig {\n        use crate::model_selector::*;\n        ExecutorConfig {\n            executor: BaseCodingAgent::QwenCode,\n            variant: None,\n            model_id: self.model.clone(),\n            agent_id: self.agent.clone(),\n            reasoning_id: None,\n            permission_policy: Some(if self.yolo.unwrap_or(false) {\n                PermissionPolicy::Auto\n            } else {\n                PermissionPolicy::Supervised\n            }),\n        }\n    }\n\n    async fn discover_options(\n        &self,\n        _workdir: Option<&std::path::Path>,\n        _repo_path: Option<&std::path::Path>,\n    ) -> Result<futures::stream::BoxStream<'static, json_patch::Patch>, ExecutorError> {\n        let options = ExecutorDiscoveredOptions {\n            model_selector: ModelSelectorConfig {\n                permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised],\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        Ok(Box::pin(futures::stream::once(async move {\n            patch::executor_discovered_options(options)\n        })))\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/executors/utils.rs",
    "content": "use std::{\n    hash::Hash,\n    num::NonZeroUsize,\n    sync::{Arc, Mutex, OnceLock},\n    time::{Duration, Instant},\n};\n\nuse futures::StreamExt;\nuse lru::LruCache;\n\nuse super::{BaseCodingAgent, SlashCommandDescription, StandardCodingAgentExecutor};\nuse crate::{\n    executor_discovery::{ExecutorConfigCacheKey, ExecutorDiscoveredOptions},\n    profile::ExecutorConfigs,\n};\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct SlashCommandCall<'a> {\n    /// The command name in lowercase (without the leading slash)\n    pub name: String,\n    /// The arguments after the command name\n    pub arguments: &'a str,\n}\n\npub fn parse_slash_command<'a, T>(prompt: &'a str) -> Option<T>\nwhere\n    T: From<SlashCommandCall<'a>>,\n{\n    let trimmed = prompt.trim_start();\n    let without_slash = trimmed.strip_prefix('/')?;\n    let mut parts = without_slash.splitn(2, |ch: char| ch.is_whitespace());\n    let name = parts.next()?.trim().to_lowercase();\n    if name.is_empty() {\n        return None;\n    }\n    let arguments = parts.next().map(|s| s.trim()).unwrap_or(\"\");\n    Some(T::from(SlashCommandCall { name, arguments }))\n}\n\n/// Reorder slash commands to prioritize compact then review.\n#[must_use]\npub fn reorder_slash_commands(\n    commands: impl IntoIterator<Item = SlashCommandDescription>,\n) -> Vec<SlashCommandDescription> {\n    let mut compact_command = None;\n    let mut review_commands = None;\n    let mut remaining_commands = Vec::new();\n\n    for command in commands {\n        match command.name.as_str() {\n            \"compact\" => compact_command = Some(command),\n            \"review\" => review_commands = Some(command),\n            _ => remaining_commands.push(command),\n        }\n    }\n\n    compact_command\n        .into_iter()\n        .chain(review_commands)\n        .chain(remaining_commands)\n        .collect()\n}\n\n#[derive(Clone, Debug)]\nstruct CacheEntry<V> {\n    cached_at: Instant,\n    value: Arc<V>,\n}\n\npub struct TtlCache<K, V> {\n    cache: Mutex<LruCache<K, CacheEntry<V>>>,\n    ttl: Duration,\n}\n\nimpl<K, V> TtlCache<K, V>\nwhere\n    K: Hash + Eq,\n{\n    pub fn new(capacity: usize, ttl: Duration) -> Self {\n        Self {\n            cache: Mutex::new(LruCache::new(\n                NonZeroUsize::new(capacity).unwrap_or_else(|| NonZeroUsize::new(1).unwrap()),\n            )),\n            ttl,\n        }\n    }\n\n    #[must_use]\n    pub fn get(&self, key: &K) -> Option<Arc<V>> {\n        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());\n        let entry = cache.get(key)?;\n        let value = entry.value.clone();\n        let expired = entry.cached_at.elapsed() > self.ttl;\n        if expired {\n            cache.pop(key);\n            None\n        } else {\n            Some(value)\n        }\n    }\n\n    pub fn put(&self, key: K, value: V) {\n        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());\n        cache.put(\n            key,\n            CacheEntry {\n                cached_at: Instant::now(),\n                value: Arc::new(value),\n            },\n        );\n    }\n}\n\npub const EXECUTOR_OPTIONS_CACHE_CAPACITY: usize = 64;\npub const DEFAULT_CACHE_TTL: Duration = Duration::from_mins(5);\n\npub fn executor_options_cache()\n-> &'static TtlCache<ExecutorConfigCacheKey, ExecutorDiscoveredOptions> {\n    static INSTANCE: OnceLock<TtlCache<ExecutorConfigCacheKey, ExecutorDiscoveredOptions>> =\n        OnceLock::new();\n    INSTANCE.get_or_init(|| TtlCache::new(EXECUTOR_OPTIONS_CACHE_CAPACITY, DEFAULT_CACHE_TTL))\n}\n\n/// Spawn a background task to refresh the global cache for an executor.\n/// This should be called on every use to keep the cache warm.\npub fn spawn_global_cache_refresh_for_agent(base_agent: BaseCodingAgent) {\n    spawn_global_cache_refresh_for_agent_with_configs(base_agent, ExecutorConfigs::get_cached());\n}\n\nfn spawn_global_cache_refresh_for_agent_with_configs(\n    base_agent: BaseCodingAgent,\n    configs: ExecutorConfigs,\n) {\n    let profile_id = crate::profile::ExecutorProfileId::new(base_agent);\n\n    if let Some(coding_agent) = configs.get_coding_agent(&profile_id) {\n        tokio::spawn(async move {\n            if let Ok(mut stream) = coding_agent.discover_options(None, None).await {\n                while stream.next().await.is_some() {}\n            }\n        });\n    }\n}\n\n/// Preload the global cache for all executors with DEFAULT presets.\n/// This should be called on startup to warm the cache.\npub async fn preload_global_executor_options_cache() {\n    let configs = ExecutorConfigs::get_cached();\n    let executors: Vec<BaseCodingAgent> = configs.executors.keys().copied().collect();\n\n    for base_agent in executors {\n        spawn_global_cache_refresh_for_agent_with_configs(base_agent, configs.clone());\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/lib.rs",
    "content": "pub mod actions;\npub mod approvals;\npub mod command;\npub mod env;\npub mod executor_discovery;\npub mod executors;\npub mod logs;\npub mod mcp_config;\npub mod model_selector;\npub mod profile;\npub mod stdout_dup;\n"
  },
  {
    "path": "crates/executors/src/logs/mod.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse workspace_utils::approvals::{ApprovalStatus, QuestionStatus};\n\nuse crate::logs::utils::shell_command_parsing::CommandCategory;\n\npub mod plain_text_processor;\npub mod stderr_processor;\npub mod utils;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum ToolResultValueType {\n    Markdown,\n    Json,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ToolResult {\n    pub r#type: ToolResultValueType,\n    /// For Markdown, this will be a JSON string; for JSON, a structured value\n    pub value: serde_json::Value,\n}\n\nimpl ToolResult {\n    pub fn markdown<S: Into<String>>(markdown: S) -> Self {\n        Self {\n            r#type: ToolResultValueType::Markdown,\n            value: serde_json::Value::String(markdown.into()),\n        }\n    }\n\n    pub fn json(value: serde_json::Value) -> Self {\n        Self {\n            r#type: ToolResultValueType::Json,\n            value,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum CommandExitStatus {\n    ExitCode { code: i32 },\n    Success { success: bool },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CommandRunResult {\n    pub exit_status: Option<CommandExitStatus>,\n    pub output: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct NormalizedConversation {\n    pub entries: Vec<NormalizedEntry>,\n    pub session_id: Option<String>,\n    pub executor_type: String,\n    pub prompt: Option<String>,\n    pub summary: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum NormalizedEntryError {\n    SetupRequired,\n    Other,\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum NormalizedEntryType {\n    UserMessage,\n    UserFeedback {\n        denied_tool: String,\n    },\n    AssistantMessage,\n    ToolUse {\n        tool_name: String,\n        action_type: ActionType,\n        status: ToolStatus,\n    },\n    SystemMessage,\n    ErrorMessage {\n        error_type: NormalizedEntryError,\n    },\n    Thinking,\n    Loading,\n    NextAction {\n        failed: bool,\n        execution_processes: usize,\n        needs_setup: bool,\n    },\n    TokenUsageInfo(TokenUsageInfo),\n    UserAnsweredQuestions {\n        answers: Vec<AnsweredQuestion>,\n    },\n}\n\n/// A question–answer pair from a completed AskUserQuestion interaction.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct AnsweredQuestion {\n    pub question: String,\n    pub answer: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct TokenUsageInfo {\n    pub total_tokens: u32,\n    pub model_context_window: u32,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct NormalizedEntry {\n    pub timestamp: Option<String>,\n    pub entry_type: NormalizedEntryType,\n    pub content: String,\n    #[ts(skip)]\n    pub metadata: Option<serde_json::Value>,\n}\n\nimpl NormalizedEntry {\n    pub fn with_tool_status(&self, status: ToolStatus) -> Option<Self> {\n        if let NormalizedEntryType::ToolUse {\n            tool_name,\n            action_type,\n            ..\n        } = &self.entry_type\n        {\n            Some(Self {\n                entry_type: NormalizedEntryType::ToolUse {\n                    tool_name: tool_name.clone(),\n                    action_type: action_type.clone(),\n                    status,\n                },\n                ..self.clone()\n            })\n        } else {\n            None\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\n#[serde(tag = \"status\", rename_all = \"snake_case\")]\npub enum ToolStatus {\n    #[default]\n    Created,\n    Success,\n    Failed,\n    Denied {\n        reason: Option<String>,\n    },\n    PendingApproval {\n        approval_id: String,\n    },\n    TimedOut,\n}\n\nimpl ToolStatus {\n    pub fn from_approval_status(status: &ApprovalStatus) -> Option<Self> {\n        match status {\n            ApprovalStatus::Approved => Some(ToolStatus::Created),\n            ApprovalStatus::Denied { reason } => Some(ToolStatus::Denied {\n                reason: reason.clone(),\n            }),\n            ApprovalStatus::TimedOut => Some(ToolStatus::TimedOut),\n            ApprovalStatus::Pending => None,\n        }\n    }\n\n    pub fn from_question_status(status: &QuestionStatus) -> Self {\n        match status {\n            QuestionStatus::Answered { .. } => ToolStatus::Success,\n            QuestionStatus::TimedOut => ToolStatus::TimedOut,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct TodoItem {\n    pub content: String,\n    pub status: String,\n    #[serde(default)]\n    pub priority: Option<String>,\n}\n\n/// Types of tool actions that can be performed\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"action\", rename_all = \"snake_case\")]\npub enum ActionType {\n    FileRead {\n        path: String,\n    },\n    FileEdit {\n        path: String,\n        changes: Vec<FileChange>,\n    },\n    CommandRun {\n        command: String,\n        #[serde(default)]\n        result: Option<CommandRunResult>,\n        #[serde(default)]\n        category: CommandCategory,\n    },\n    Search {\n        query: String,\n    },\n    WebFetch {\n        url: String,\n    },\n    /// Generic tool with optional arguments and result for rich rendering\n    Tool {\n        tool_name: String,\n        #[serde(default)]\n        arguments: Option<serde_json::Value>,\n        #[serde(default)]\n        result: Option<ToolResult>,\n    },\n    TaskCreate {\n        description: String,\n        #[serde(default)]\n        subagent_type: Option<String>,\n        #[serde(default)]\n        result: Option<ToolResult>,\n    },\n    PlanPresentation {\n        plan: String,\n    },\n    TodoManagement {\n        todos: Vec<TodoItem>,\n        operation: String,\n    },\n    AskUserQuestion {\n        questions: Vec<AskUserQuestionItem>,\n    },\n    Other {\n        description: String,\n    },\n}\n\n/// A single question in an AskUserQuestion tool call.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct AskUserQuestionItem {\n    pub question: String,\n    pub header: String,\n    pub options: Vec<AskUserQuestionOption>,\n    #[serde(rename = \"multiSelect\")]\n    pub multi_select: bool,\n}\n\n/// An option for an AskUserQuestion question.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct AskUserQuestionOption {\n    pub label: String,\n    pub description: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"action\", rename_all = \"snake_case\")]\npub enum FileChange {\n    /// Create a file if it doesn't exist, and overwrite its content.\n    Write { content: String },\n    /// Delete a file.\n    Delete,\n    /// Rename a file.\n    Rename { new_path: String },\n    /// Edit a file with a unified diff.\n    Edit {\n        /// Unified diff containing file header and hunks.\n        unified_diff: String,\n        /// Whether line number in the hunks are reliable.\n        has_line_numbers: bool,\n    },\n}\n"
  },
  {
    "path": "crates/executors/src/logs/plain_text_processor.rs",
    "content": "//! Reusable log processor for plain-text streams with flexible clustering and formatting.\n//!\n//! Clusters messages into entries based on configurable size and time-gap heuristics, and supports\n//! pluggable formatters for transforming or annotating chunks (e.g., inserting line breaks or parsing tool calls).\n//!\n//! Capable of handling mixed-format streams, including interleaved tool calls and assistant messages,\n//! with custom split predicates to detect embedded markers and emit separate entries.\n//!\n//! ## Use cases\n//! - **stderr_processor**: Cluster stderr lines by time gap and format as `ErrorMessage` log entries.\n//!   See [`stderr_processor::normalize_stderr_logs`].\n//! - **Gemini executor**: Post-process Gemini CLI output to make it prettier, then format it as assistant messages clustered by size.\n//!   See [`crate::executors::gemini::Gemini::format_stdout_chunk`].\n//! - **Tool call support**: detect lines starting with a distinct marker via `message_boundary_predicate` to separate tool invocations.\nuse std::{\n    time::{Duration, Instant},\n    vec,\n};\n\nuse bon::bon;\nuse json_patch::Patch;\n\nuse super::{\n    NormalizedEntry,\n    utils::{ConversationPatch, EntryIndexProvider},\n};\n\n/// Controls message boundary for advanced executors.\n/// The main use-case is to support mixed-content log streams where tool calls and assistant messages are interleaved.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum MessageBoundary {\n    /// Conclude the current message entry at the given line.\n    /// Useful when we detect a message of a different kind than the current one, e.g., when a tool call starts we need to close the current assistant message.\n    Split(usize),\n    /// Request more content. Signals that the current entry is incomplete and should not be emitted yet.\n    /// This should only be the case in tool calls, as assistant messages can be partially emitted.\n    IncompleteContent,\n}\n\n/// Internal buffer for collecting streaming text into individual lines.\n/// Maintains line and size information for heuristics and processing.\n#[derive(Debug)]\nstruct PlainTextBuffer {\n    /// All lines including last partial line. Complete lines have trailing \\n, partial line doesn't\n    lines: Vec<String>,\n    /// Current buffered length\n    total_len: usize,\n}\n\nimpl PlainTextBuffer {\n    /// Create a new empty buffer\n    pub fn new() -> Self {\n        Self {\n            lines: Vec::new(),\n            total_len: 0,\n        }\n    }\n\n    /// Ingest a new text chunk into the buffer.\n    pub fn ingest(&mut self, text_chunk: String) {\n        debug_assert!(!text_chunk.is_empty());\n\n        // Add a new lines or grow the current partial line\n        let current_partial = if self.lines.last().is_some_and(|l| !l.ends_with('\\n')) {\n            let partial = self.lines.pop().unwrap();\n            self.total_len = self.total_len.saturating_sub(partial.len());\n            partial\n        } else {\n            String::new()\n        };\n\n        // Process chunk\n        let combined_text = current_partial + &text_chunk;\n        let size = combined_text.len();\n\n        // Append new lines\n        let parts: Vec<String> = combined_text\n            .split_inclusive('\\n')\n            .map(ToString::to_string)\n            .collect();\n        self.lines.extend(parts);\n        self.total_len += size;\n    }\n\n    /// Remove and return the first `n` buffered lines,\n    pub fn drain_lines(&mut self, n: usize) -> Vec<String> {\n        let n = n.min(self.lines.len());\n        let drained: Vec<String> = self.lines.drain(..n).collect();\n\n        // Update total_bytes\n        for line in &drained {\n            self.total_len = self.total_len.saturating_sub(line.len());\n        }\n\n        drained\n    }\n\n    /// Remove and return lines until the content length is at least `len`.\n    /// Useful for size-based splitting of content.\n    pub fn drain_size(&mut self, len: usize) -> Vec<String> {\n        let mut drained_len = 0;\n        let mut lines_to_drain = 0;\n\n        for line in &self.lines {\n            if drained_len >= len && lines_to_drain > 0 {\n                break;\n            }\n            drained_len += line.len();\n            lines_to_drain += 1;\n        }\n\n        self.drain_lines(lines_to_drain)\n    }\n\n    /// Empty the buffer, removing and returning all content,\n    pub fn flush(&mut self) -> Vec<String> {\n        let result = self.lines.drain(..).collect();\n        self.total_len = 0;\n        result\n    }\n\n    /// Return the total length of content.\n    pub fn total_len(&self) -> usize {\n        self.total_len\n    }\n\n    /// View lines.\n    pub fn lines(&self) -> &[String] {\n        &self.lines\n    }\n\n    /// Mutably view lines for in-place transformations.\n    pub fn lines_mut(&mut self) -> &mut Vec<String> {\n        &mut self.lines\n    }\n\n    /// Recompute cached total length from current lines.\n    pub fn recompute_len(&mut self) {\n        self.total_len = self.lines.iter().map(|s| s.len()).sum();\n    }\n\n    /// Get the current partial line.\n    pub fn partial_line(&self) -> Option<&str> {\n        if let Some(last) = self.lines.last()\n            && !last.ends_with('\\n')\n        {\n            return Some(last);\n        }\n        None\n    }\n\n    /// Check if the buffer is empty.\n    pub fn is_empty(&self) -> bool {\n        debug_assert!(self.lines.len() == 0 || self.total_len > 0);\n        self.total_len == 0\n    }\n}\n\nimpl Default for PlainTextBuffer {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n/// Optional content formatting function. Can be used post-process raw output before creating normalized entries.\npub type FormatChunkFn = Box<dyn Fn(Option<&str>, String) -> String + Send + 'static>;\n\n/// Optional predicate function to determine message boundaries. This enables detecting tool calls interleaved with assistant messages.\npub type MessageBoundaryPredicateFn =\n    Box<dyn Fn(&[String]) -> Option<MessageBoundary> + Send + 'static>;\n\n/// Function to create a `NormalizedEntry` from content.\npub type NormalizedEntryProducerFn = Box<dyn Fn(String) -> NormalizedEntry + Send + 'static>;\n\n/// Optional function to transform buffered lines in-place before boundary checks.\npub type LinesTransformFn = Box<dyn FnMut(&mut Vec<String>) + Send + 'static>;\n\n/// High-level plain text log processor with configurable formatting and splitting\npub struct PlainTextLogProcessor {\n    buffer: PlainTextBuffer,\n    index_provider: EntryIndexProvider,\n    entry_size_threshold: Option<usize>,\n    time_gap: Option<Duration>,\n    format_chunk: Option<FormatChunkFn>,\n    transform_lines: Option<LinesTransformFn>,\n    message_boundary_predicate: Option<MessageBoundaryPredicateFn>,\n    normalized_entry_producer: NormalizedEntryProducerFn,\n    last_chunk_arrival_time: Instant, // time since last chunk arrived\n    current_entry_index: Option<usize>,\n}\n\nimpl PlainTextLogProcessor {\n    /// Process incoming text and return JSON patches for any complete entries\n    pub fn process(&mut self, text_chunk: String) -> Vec<Patch> {\n        if text_chunk.is_empty() {\n            return vec![];\n        }\n\n        if !self.buffer.is_empty() {\n            // If the new content arrived after the (**Optional**) time threshold between messages, we consider it a new entry.\n            // Useful for stderr streams where we want to group related lines into a single entry.\n            if self\n                .time_gap\n                .is_some_and(|time_gap| self.last_chunk_arrival_time.elapsed() >= time_gap)\n            {\n                let lines = self.buffer.flush();\n                if !lines.is_empty() {\n                    return vec![self.create_patch(lines)];\n                }\n                self.current_entry_index = None;\n            }\n        }\n\n        self.last_chunk_arrival_time = Instant::now();\n\n        let formatted_chunk = if let Some(format_chunk) = self.format_chunk.as_ref() {\n            format_chunk(self.buffer.partial_line(), text_chunk)\n        } else {\n            text_chunk\n        };\n\n        if formatted_chunk.is_empty() {\n            return vec![];\n        }\n\n        // Let the buffer handle text buffering\n        self.buffer.ingest(formatted_chunk);\n\n        if let Some(transform_lines) = self.transform_lines.as_mut() {\n            transform_lines(self.buffer.lines_mut());\n            self.buffer.recompute_len();\n            if self.buffer.is_empty() {\n                // Nothing left to process after transformation\n                return vec![];\n            }\n        }\n\n        let mut patches = Vec::new();\n\n        // Check if we have a custom message boundary predicate\n        loop {\n            let message_boundary_predicate = self\n                .message_boundary_predicate\n                .as_ref()\n                .and_then(|predicate| predicate(self.buffer.lines()));\n\n            match message_boundary_predicate {\n                // Predicate decided to conclude the current entry at `line_idx`\n                Some(MessageBoundary::Split(line_idx)) => {\n                    let lines = self.buffer.drain_lines(line_idx);\n                    if !lines.is_empty() {\n                        patches.push(self.create_patch(lines));\n                        // Move to next entry after split\n                        self.current_entry_index = None;\n                    }\n                }\n                // Predicate decided that current content cannot be sent yet.\n                Some(MessageBoundary::IncompleteContent) => {\n                    // Stop processing, wait for more content.\n                    // Partial updates will be disabled.\n                    return patches;\n                }\n                None => {\n                    // No more splits, break and continue to size/latency heuristics\n                    break;\n                }\n            }\n        }\n\n        // Check message size. If entry is large enough, break it into smaller entries.\n        if let Some(size_threshold) = self.entry_size_threshold {\n            // Check message size. If entry is large enough, create a new entry.\n            while self.buffer.total_len() >= size_threshold {\n                let lines = self.buffer.drain_size(size_threshold);\n                if lines.is_empty() {\n                    break;\n                }\n                patches.push(self.create_patch(lines));\n                // Move to next entry after size split\n                self.current_entry_index = None;\n            }\n        }\n\n        // Send partial udpdates\n        if !self.buffer.is_empty() {\n            // Stream updates without consuming buffer\n            patches.push(self.create_patch(self.buffer.lines().to_vec()));\n        }\n        patches\n    }\n\n    /// Create patch\n    fn create_patch(&mut self, lines: Vec<String>) -> Patch {\n        let content = lines.concat();\n        let entry = (self.normalized_entry_producer)(content);\n\n        let added = self.current_entry_index.is_some();\n        let index = if let Some(idx) = self.current_entry_index {\n            idx\n        } else {\n            // If no current index, get next from provider\n            let idx = self.index_provider.next();\n            self.current_entry_index = Some(idx);\n            idx\n        };\n\n        if !added {\n            ConversationPatch::add_normalized_entry(index, entry)\n        } else {\n            ConversationPatch::replace(index, entry)\n        }\n    }\n}\n\n#[bon]\nimpl PlainTextLogProcessor {\n    /// Create a builder for configuring PlainTextLogProcessor.\n    ///\n    /// # Parameters\n    /// * `normalized_entry_producer` - Required function to convert text content into a `NormalizedEntry`.\n    /// * `size_threshold` - Optional size threshold for individual entries. Once an entry content exceeds this size, a new entry is created.\n    /// * `time_gap` - Optional time gap between individual entries. When new content arrives after this duration, it is considered a new entry.\n    /// * `format_chunk` - Optional function to fix raw output before creating normalized entries.\n    /// * `message_boundary_predicate` - Optional function to determine custom message boundaries. Useful when content is heterogeneous (e.g., tool calls interleaved with assistant messages).\n    /// * `index_provider` - Required sharable atomic counter for tracking entry indices.\n    ///\n    /// When both `size_threshold` and `time_gap` are `None`, a default size threshold of 8 KiB is used.\n    #[builder]\n    pub fn new(\n        normalized_entry_producer: impl Fn(String) -> NormalizedEntry + 'static + Send,\n        size_threshold: Option<usize>,\n        time_gap: Option<Duration>,\n        format_chunk: Option<FormatChunkFn>,\n        transform_lines: Option<LinesTransformFn>,\n        message_boundary_predicate: Option<MessageBoundaryPredicateFn>,\n        index_provider: EntryIndexProvider,\n    ) -> Self {\n        Self {\n            buffer: PlainTextBuffer::new(),\n            index_provider,\n            entry_size_threshold: if size_threshold.is_none() && time_gap.is_none() {\n                Some(8 * 1024) // Default 8KiB when neither is set\n            } else {\n                size_threshold\n            },\n            time_gap,\n            format_chunk: format_chunk.map(|f| {\n                Box::new(f) as Box<dyn Fn(Option<&str>, String) -> String + Send + 'static>\n            }),\n            transform_lines: transform_lines\n                .map(|f| Box::new(f) as Box<dyn FnMut(&mut Vec<String>) + Send + 'static>),\n            message_boundary_predicate: message_boundary_predicate.map(|p| {\n                Box::new(p) as Box<dyn Fn(&[String]) -> Option<MessageBoundary> + Send + 'static>\n            }),\n            normalized_entry_producer: Box::new(normalized_entry_producer),\n            last_chunk_arrival_time: Instant::now(),\n            current_entry_index: None,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::logs::{NormalizedEntryType, ToolStatus};\n\n    #[test]\n    fn test_plain_buffer_flush() {\n        let mut buffer = PlainTextBuffer::new();\n\n        buffer.ingest(\"line1\\npartial\".to_string());\n        assert_eq!(buffer.lines().len(), 2);\n\n        let lines = buffer.flush();\n        assert_eq!(lines, vec![\"line1\\n\", \"partial\"]);\n        assert_eq!(buffer.lines().len(), 0);\n    }\n\n    #[test]\n    fn test_plain_buffer_len() {\n        let mut buffer = PlainTextBuffer::new();\n\n        buffer.ingest(\"abc\\ndef\\n\".to_string());\n        assert_eq!(buffer.total_len(), 8); // \"abc\\n\" + \"def\\n\"\n\n        buffer.drain_lines(1);\n        assert_eq!(buffer.total_len(), 4); // \"def\\n\"\n    }\n\n    #[test]\n    fn test_drain_until_size() {\n        let mut buffer = PlainTextBuffer::new();\n\n        buffer.ingest(\"short\\nlonger line\\nvery long line here\\n\".to_string());\n\n        // Drain until we have at least 10 bytes\n        let drained = buffer.drain_size(10);\n        assert_eq!(drained.len(), 2); // \"short\\n\" (6) + \"longer line\\n\" (12) = 18 bytes total\n        assert_eq!(drained, vec![\"short\\n\", \"longer line\\n\"]);\n    }\n\n    #[test]\n    fn test_processor_simple() {\n        let producer = |content: String| -> NormalizedEntry {\n            NormalizedEntry {\n                timestamp: None, // Avoid creating artificial timestamps during normalization\n                entry_type: NormalizedEntryType::SystemMessage,\n                content: content.to_string(),\n                metadata: None,\n            }\n        };\n\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(producer)\n            .index_provider(EntryIndexProvider::test_new())\n            .build();\n\n        let patches = processor.process(\"hello world\\n\".to_string());\n        assert_eq!(patches.len(), 1);\n    }\n\n    #[test]\n    fn test_processor_custom_log_formatter() {\n        // Example Level 1 producer that parses tool calls\n        let tool_producer = |content: String| -> NormalizedEntry {\n            if content.starts_with(\"TOOL:\") {\n                let tool_name = content.strip_prefix(\"TOOL:\").unwrap_or(\"unknown\").trim();\n                NormalizedEntry {\n                    timestamp: None,\n                    entry_type: NormalizedEntryType::ToolUse {\n                        tool_name: tool_name.to_string(),\n                        action_type: super::super::ActionType::Other {\n                            description: tool_name.to_string(),\n                        },\n                        status: ToolStatus::Success,\n                    },\n                    content,\n                    metadata: None,\n                }\n            } else {\n                NormalizedEntry {\n                    timestamp: None,\n                    entry_type: NormalizedEntryType::SystemMessage,\n                    content: content.to_string(),\n                    metadata: None,\n                }\n            }\n        };\n\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(tool_producer)\n            .index_provider(EntryIndexProvider::test_new())\n            .build();\n\n        let patches = processor.process(\"TOOL: file_read\\n\".to_string());\n        assert_eq!(patches.len(), 1);\n    }\n\n    #[test]\n    fn test_processor_transform_lines_clears_first_line() {\n        let producer = |content: String| -> NormalizedEntry {\n            NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::SystemMessage,\n                content,\n                metadata: None,\n            }\n        };\n\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(producer)\n            .transform_lines(Box::new(|lines: &mut Vec<String>| {\n                // Drop a specific leading banner line if present\n                if !lines.is_empty()\n                    && lines.first().map(|s| s.as_str()) == Some(\"BANNER LINE TO DROP\\n\")\n                {\n                    lines.remove(0);\n                }\n            }))\n            .index_provider(EntryIndexProvider::test_new())\n            .build();\n\n        // Provide a single-line chunk. The transform removes it, leaving nothing to emit.\n        let patches = processor.process(\"BANNER LINE TO DROP\\n\".to_string());\n        assert_eq!(patches.len(), 0);\n\n        // Next, add actual content; should emit one patch with the content\n        let patches = processor.process(\"real content\\n\".to_string());\n        assert_eq!(patches.len(), 1);\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/logs/stderr_processor.rs",
    "content": "//! Standard stderr log processor for executors\n//!\n//! Uses `PlainTextLogProcessor` with a 2-second `latency_threshold` to split stderr streams into entries.\n//! Each entry is normalized as `ErrorMessage` and emitted as JSON patches to the message store.\n//!\n//! Example:\n//! ```rust,ignore\n//! normalize_stderr_logs(msg_store.clone(), EntryIndexProvider::new());\n//! ```\n//!\nuse std::{sync::Arc, time::Duration};\n\nuse futures::StreamExt;\nuse workspace_utils::msg_store::MsgStore;\n\nuse super::{\n    NormalizedEntry, NormalizedEntryError, NormalizedEntryType,\n    plain_text_processor::PlainTextLogProcessor,\n};\nuse crate::logs::utils::EntryIndexProvider;\n\n/// Standard stderr log normalizer that uses PlainTextLogProcessor to stream error logs.\n///\n/// Splits stderr output into discrete entries based on a latency threshold (2s) to group\n/// related lines into a single error entry. Each entry is normalized as an `ErrorMessage`\n/// and emitted as JSON patches for downstream consumption (e.g., UI or log aggregation).\n///\n/// # Options\n/// - `latency_threshold`: 2 seconds to separate error messages based on time gaps.\n/// - `normalized_entry_producer`: maps each chunk into an `ErrorMessage` entry.\n///\n/// # Use case\n/// Intended for executor stderr streams, grouping multi-line errors into cohesive entries\n/// instead of emitting each line separately.\n///\n/// # Arguments\n/// * `msg_store` - the message store providing a stream of stderr chunks and accepting patches.\n/// * `entry_index_provider` - provider of incremental entry indices for patch ordering.\npub fn normalize_stderr_logs(\n    msg_store: Arc<MsgStore>,\n    entry_index_provider: EntryIndexProvider,\n) -> tokio::task::JoinHandle<()> {\n    tokio::spawn(async move {\n        let mut stderr = msg_store.stderr_chunked_stream();\n\n        // Create a processor with time-based emission for stderr\n        let mut processor = PlainTextLogProcessor::builder()\n            .normalized_entry_producer(Box::new(|content: String| NormalizedEntry {\n                timestamp: None,\n                entry_type: NormalizedEntryType::ErrorMessage {\n                    error_type: NormalizedEntryError::Other,\n                },\n                content: strip_ansi_escapes::strip_str(&content),\n                metadata: None,\n            }))\n            .time_gap(Duration::from_secs(2)) // Break messages if they are 2 seconds apart\n            .index_provider(entry_index_provider)\n            .build();\n\n        while let Some(Ok(chunk)) = stderr.next().await {\n            for patch in processor.process(chunk) {\n                msg_store.push_patch(patch);\n            }\n        }\n    })\n}\n"
  },
  {
    "path": "crates/executors/src/logs/utils/entry_index.rs",
    "content": "//! Entry Index Provider for thread-safe monotonic indexing\n\nuse std::sync::{\n    Arc,\n    atomic::{AtomicUsize, Ordering},\n};\n\nuse json_patch::PatchOperation;\nuse workspace_utils::{log_msg::LogMsg, msg_store::MsgStore};\n\n/// Thread-safe provider for monotonically increasing entry indexes\n#[derive(Debug, Clone)]\npub struct EntryIndexProvider(Arc<AtomicUsize>);\n\nimpl EntryIndexProvider {\n    /// Create a new index provider starting from 0 (private; prefer seeding)\n    fn new() -> Self {\n        Self(Arc::new(AtomicUsize::new(0)))\n    }\n\n    /// Get the next available index\n    pub fn next(&self) -> usize {\n        self.0.fetch_add(1, Ordering::Relaxed)\n    }\n\n    /// Get the current index without incrementing\n    pub fn current(&self) -> usize {\n        self.0.load(Ordering::Relaxed)\n    }\n\n    pub fn reset(&self) {\n        self.0.store(0, Ordering::Relaxed);\n    }\n\n    /// Create a provider starting from the maximum existing normalized-entry index\n    /// observed in prior JSON patches in `MsgStore`.\n    pub fn start_from(msg_store: &MsgStore) -> Self {\n        let provider = EntryIndexProvider::new();\n\n        let max_index: Option<usize> = msg_store\n            .get_history()\n            .iter()\n            .filter_map(|msg| {\n                if let LogMsg::JsonPatch(patch) = msg {\n                    patch.iter().find_map(|op| {\n                        if let PatchOperation::Add(add) = op {\n                            add.path\n                                .strip_prefix(\"/entries/\")\n                                .and_then(|n_str| n_str.parse::<usize>().ok())\n                        } else {\n                            None\n                        }\n                    })\n                } else {\n                    None\n                }\n            })\n            .max();\n\n        let start_at = max_index.map_or(0, |n| n.saturating_add(1));\n        provider.0.store(start_at, Ordering::Relaxed);\n        provider\n    }\n}\n\nimpl Default for EntryIndexProvider {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[cfg(test)]\nimpl EntryIndexProvider {\n    /// Test-only constructor for a fresh provider starting at 0\n    pub fn test_new() -> Self {\n        Self::new()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_entry_index_provider() {\n        let provider = EntryIndexProvider::test_new();\n        assert_eq!(provider.next(), 0);\n        assert_eq!(provider.next(), 1);\n        assert_eq!(provider.next(), 2);\n    }\n\n    #[test]\n    fn test_entry_index_provider_clone() {\n        let provider1 = EntryIndexProvider::test_new();\n        let provider2 = provider1.clone();\n\n        assert_eq!(provider1.next(), 0);\n        assert_eq!(provider2.next(), 1);\n        assert_eq!(provider1.next(), 2);\n    }\n\n    #[test]\n    fn test_current_index() {\n        let provider = EntryIndexProvider::test_new();\n        assert_eq!(provider.current(), 0);\n\n        provider.next();\n        assert_eq!(provider.current(), 1);\n\n        provider.next();\n        assert_eq!(provider.current(), 2);\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/logs/utils/mod.rs",
    "content": "//! Utility modules for executor framework\n\npub mod entry_index;\npub mod patch;\n\npub use entry_index::EntryIndexProvider;\npub use patch::ConversationPatch;\npub mod shell_command_parsing;\n"
  },
  {
    "path": "crates/executors/src/logs/utils/patch.rs",
    "content": "use std::{collections::HashSet, sync::Arc};\n\nuse json_patch::Patch;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{from_value, json, to_value};\nuse ts_rs::TS;\nuse workspace_utils::{diff::Diff, msg_store::MsgStore};\n\nuse crate::{\n    executor_discovery::ExecutorDiscoveredOptions,\n    executors::SlashCommandDescription,\n    logs::{NormalizedEntry, utils::EntryIndexProvider},\n};\n\n#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, TS)]\n#[serde(rename_all = \"lowercase\")]\nenum PatchOperation {\n    Add,\n    Replace,\n    Remove,\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(Serialize, TS)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\", tag = \"type\", content = \"content\")]\npub enum PatchType {\n    NormalizedEntry(NormalizedEntry),\n    Stdout(String),\n    Stderr(String),\n    Diff(Diff),\n}\n\n#[derive(Serialize)]\nstruct PatchEntry {\n    op: PatchOperation,\n    path: String,\n    value: PatchType,\n}\n\npub fn escape_json_pointer_segment(s: &str) -> String {\n    s.replace('~', \"~0\").replace('/', \"~1\")\n}\n\n/// Helper functions to create JSON patches for conversation entries\npub struct ConversationPatch;\n\nimpl ConversationPatch {\n    /// Create an ADD patch for a new conversation entry at the given index\n    pub fn add_normalized_entry(entry_index: usize, entry: NormalizedEntry) -> Patch {\n        let patch_entry = PatchEntry {\n            op: PatchOperation::Add,\n            path: format!(\"/entries/{entry_index}\"),\n            value: PatchType::NormalizedEntry(entry),\n        };\n\n        from_value(json!([patch_entry])).unwrap()\n    }\n\n    /// Create an ADD patch for a new string at the given index\n    pub fn add_stdout(entry_index: usize, entry: String) -> Patch {\n        let patch_entry = PatchEntry {\n            op: PatchOperation::Add,\n            path: format!(\"/entries/{entry_index}\"),\n            value: PatchType::Stdout(entry),\n        };\n\n        from_value(json!([patch_entry])).unwrap()\n    }\n\n    /// Create an ADD patch for a new string at the given index\n    pub fn add_stderr(entry_index: usize, entry: String) -> Patch {\n        let patch_entry = PatchEntry {\n            op: PatchOperation::Add,\n            path: format!(\"/entries/{entry_index}\"),\n            value: PatchType::Stderr(entry),\n        };\n\n        from_value(json!([patch_entry])).unwrap()\n    }\n\n    /// Create an ADD patch for a new diff at the given index\n    pub fn add_diff(entry_index: String, diff: Diff) -> Patch {\n        let patch_entry = PatchEntry {\n            op: PatchOperation::Add,\n            path: format!(\"/entries/{entry_index}\"),\n            value: PatchType::Diff(diff),\n        };\n\n        from_value(json!([patch_entry])).unwrap()\n    }\n\n    /// Create an ADD patch for a new diff at the given index\n    pub fn replace_diff(entry_index: String, diff: Diff) -> Patch {\n        let patch_entry = PatchEntry {\n            op: PatchOperation::Replace,\n            path: format!(\"/entries/{entry_index}\"),\n            value: PatchType::Diff(diff),\n        };\n\n        from_value(json!([patch_entry])).unwrap()\n    }\n\n    /// Create a REMOVE patch for removing a diff\n    pub fn remove_diff(entry_index: String) -> Patch {\n        from_value(json!([{\n            \"op\": PatchOperation::Remove,\n            \"path\": format!(\"/entries/{entry_index}\"),\n        }]))\n        .unwrap()\n    }\n\n    /// Create a REPLACE patch for updating an existing conversation entry at the given index\n    pub fn replace(entry_index: usize, entry: NormalizedEntry) -> Patch {\n        let patch_entry = PatchEntry {\n            op: PatchOperation::Replace,\n            path: format!(\"/entries/{entry_index}\"),\n            value: PatchType::NormalizedEntry(entry),\n        };\n\n        from_value(json!([patch_entry])).unwrap()\n    }\n\n    pub fn remove(entry_index: usize) -> Patch {\n        from_value(json!([{\n            \"op\": PatchOperation::Remove,\n            \"path\": format!(\"/entries/{entry_index}\"),\n        }]))\n        .unwrap()\n    }\n}\n\n/// Extract the entry index and `NormalizedEntry` from a JsonPatch if it contains one\npub fn extract_normalized_entry_from_patch(patch: &Patch) -> Option<(usize, NormalizedEntry)> {\n    let value = to_value(patch).ok()?;\n    let ops = value.as_array()?;\n    ops.iter().rev().find_map(|op| {\n        let path = op.get(\"path\")?.as_str()?;\n        let entry_index = path.strip_prefix(\"/entries/\")?.parse::<usize>().ok()?;\n\n        let value = op.get(\"value\")?;\n        (value.get(\"type\")?.as_str()? == \"NORMALIZED_ENTRY\")\n            .then(|| value.get(\"content\"))\n            .flatten()\n            .and_then(|c| from_value::<NormalizedEntry>(c.clone()).ok())\n            .map(|entry| (entry_index, entry))\n    })\n}\n\npub fn upsert_normalized_entry(\n    msg_store: &Arc<MsgStore>,\n    index: usize,\n    normalized_entry: NormalizedEntry,\n    is_new: bool,\n) {\n    if is_new {\n        msg_store.push_patch(ConversationPatch::add_normalized_entry(\n            index,\n            normalized_entry,\n        ));\n    } else {\n        msg_store.push_patch(ConversationPatch::replace(index, normalized_entry));\n    }\n}\n\npub fn add_normalized_entry(\n    msg_store: &Arc<MsgStore>,\n    index_provider: &EntryIndexProvider,\n    normalized_entry: NormalizedEntry,\n) -> usize {\n    let index = index_provider.next();\n    upsert_normalized_entry(msg_store, index, normalized_entry, true);\n    index\n}\n\npub fn replace_normalized_entry(\n    msg_store: &Arc<MsgStore>,\n    index: usize,\n    normalized_entry: NormalizedEntry,\n) {\n    upsert_normalized_entry(msg_store, index, normalized_entry, false);\n}\n\n/// Extract the path string from a Patch (assumes single-operation patches).\npub fn patch_entry_path(patch: &Patch) -> Option<String> {\n    patch.0.first().map(|op| op.path().to_string())\n}\n\npub fn is_add_or_replace(patch: &Patch) -> bool {\n    use json_patch::PatchOperation::*;\n    patch.0.iter().all(|op| matches!(op, Add(..) | Replace(..)))\n}\n\n// Use the \"replace\" op for sent paths and \"add\" for new paths\npub fn fix_patch_ops(mut patch: Patch, sent_paths: &mut HashSet<String>) -> Patch {\n    for op in &mut patch.0 {\n        let path_sent = sent_paths.contains(op.path().as_str());\n        match op {\n            json_patch::PatchOperation::Add(add) if path_sent => {\n                *op = json_patch::PatchOperation::Replace(json_patch::ReplaceOperation {\n                    path: add.path.clone(),\n                    value: add.value.clone(),\n                });\n            }\n            json_patch::PatchOperation::Replace(replace) if !path_sent => {\n                *op = json_patch::PatchOperation::Add(json_patch::AddOperation {\n                    path: replace.path.clone(),\n                    value: replace.value.clone(),\n                });\n            }\n            _ => {}\n        };\n        if !path_sent {\n            sent_paths.insert(op.path().to_string());\n        }\n    }\n    patch\n}\n\npub fn executor_discovered_options(options: ExecutorDiscoveredOptions) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options\", \"value\": options},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn slash_commands(\n    commands: Vec<SlashCommandDescription>,\n    discovering: bool,\n    error: Option<String>,\n) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/commands\", \"value\": commands},\n        {\"op\": \"replace\", \"path\": \"/discovering\", \"value\": discovering},\n        {\"op\": \"replace\", \"path\": \"/error\", \"value\": error},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn update_models(models: Vec<crate::model_selector::ModelInfo>) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/model_selector/models\", \"value\": models},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn models_loaded() -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/loading_models\", \"value\": false},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn update_agents(agents: Vec<crate::model_selector::AgentInfo>) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/model_selector/agents\", \"value\": agents},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn agents_loaded() -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/loading_agents\", \"value\": false},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn update_slash_commands(\n    slash_commands: Vec<crate::executors::SlashCommandDescription>,\n) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/slash_commands\", \"value\": slash_commands},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn slash_commands_loaded() -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/loading_slash_commands\", \"value\": false},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn update_providers(providers: Vec<crate::model_selector::ModelProvider>) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/model_selector/providers\", \"value\": providers},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn update_default_model(default_model: Option<String>) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/model_selector/default_model\", \"value\": default_model},\n    ]))\n    .unwrap_or_default()\n}\n\npub fn discovery_error(error: String) -> Patch {\n    serde_json::from_value(json!([\n        {\"op\": \"replace\", \"path\": \"/options/error\", \"value\": error},\n        {\"op\": \"replace\", \"path\": \"/options/loading_models\", \"value\": false},\n        {\"op\": \"replace\", \"path\": \"/options/loading_agents\", \"value\": false},\n        {\"op\": \"replace\", \"path\": \"/options/loading_slash_commands\", \"value\": false},\n    ]))\n    .unwrap_or_default()\n}\n"
  },
  {
    "path": "crates/executors/src/logs/utils/shell_command_parsing.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\n/// Simple categories for common bash commands\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum CommandCategory {\n    /// File reading commands (cat, head, tail, sed without -i)\n    Read,\n    /// File/directory search commands (grep, rg, find, awk)\n    Search,\n    /// File editing commands (any command with >, sed -i, tee, chmod, rm, mv, cp)\n    Edit,\n    /// Network fetch commands (curl, wget)\n    Fetch,\n    /// Default category for everything else\n    #[default]\n    Other,\n}\n\nimpl CommandCategory {\n    /// Categorize a bash command string.\n    pub fn from_command(command: &str) -> Self {\n        let command = command.trim();\n\n        if command.is_empty() {\n            return Self::Other;\n        }\n\n        let command = unwrap_shell_command(command);\n\n        // Any output redirect to a real file is an edit operation, e.g. echo > file\n        if has_file_redirect(command) {\n            return Self::Edit;\n        }\n\n        let cmd = command\n            .split_whitespace()\n            .next()\n            .and_then(|s| s.rsplit('/').next())\n            .unwrap_or(\"\")\n            .to_lowercase();\n\n        match cmd.as_str() {\n            // File reading commands (ls lists directory contents)\n            \"cat\" | \"head\" | \"tail\" | \"zcat\" | \"gzcat\" | \"ls\" => Self::Read,\n\n            // Search commands\n            \"grep\" | \"rg\" | \"find\" | \"awk\" => Self::Search,\n\n            // sed: -i means in-place edit, otherwise read-only\n            \"sed\" if command.contains(\"-i\") => Self::Edit,\n            \"sed\" => Self::Read,\n\n            // Direct file edits\n            \"tee\" | \"truncate\" | \"chmod\" | \"chown\" | \"rm\" | \"mv\" | \"cp\" | \"touch\" | \"ln\" => {\n                Self::Edit\n            }\n\n            // Web Fetch commands\n            \"curl\" | \"wget\" => Self::Fetch,\n\n            _ => Self::Other,\n        }\n    }\n}\n\n/// Check whether a command contains a redirect to an actual file (not `/dev/null` or fd dup).\n///\n/// Uses shlex to tokenize (handles quoting), then looks for tokens containing `>`\n/// and checks whether the redirect target is a real file.\nfn has_file_redirect(command: &str) -> bool {\n    if !command.contains('>') {\n        return false;\n    }\n\n    let tokens: Vec<String> = shlex::Shlex::new(command).collect();\n    let mut i = 0;\n    while i < tokens.len() {\n        let t = &tokens[i];\n        if let Some(target) = redirect_target(t) {\n            if is_file_target(target) {\n                return true;\n            }\n        } else if (t == \">\" || t == \">>\" || t.ends_with('>') || t.ends_with(\">>\"))\n            && let Some(next) = tokens.get(i + 1)\n        {\n            if is_file_target(next) {\n                return true;\n            }\n            i += 1;\n        }\n        i += 1;\n    }\n    false\n}\n\n/// Given a token containing `>`, extract the redirect target if it's inline.\n/// E.g. \">file\" => Some(\"file\"), \"2>/dev/null\" => Some(\"/dev/null\"), \">\" => None\nfn redirect_target(token: &str) -> Option<&str> {\n    let pos = token.find('>')?;\n    let after = &token[pos + 1..];\n    let after = after.strip_prefix('>').unwrap_or(after);\n    if after.is_empty() { None } else { Some(after) }\n}\n\n/// Returns true if the redirect target is a real file (not /dev/null or &fd).\nfn is_file_target(target: &str) -> bool {\n    !target.starts_with('&') && target != \"/dev/null\"\n}\n\n/// Unwrap shell wrappers to get the actual command.\n///\n/// Handles: `zsh -c \"command\"` / `bash -lc 'command'` / etc.\npub fn unwrap_shell_command(command: &str) -> &str {\n    let mut remaining = command;\n\n    loop {\n        let trimmed = remaining.trim_start();\n\n        // Find first word\n        let first_word_end = trimmed\n            .find(|c: char| c.is_whitespace())\n            .unwrap_or(trimmed.len());\n        let first_word = &trimmed[..first_word_end];\n\n        let cmd_name = first_word.rsplit('/').next().unwrap_or(first_word);\n\n        // Check for shell -c \"command\"\n        if matches!(cmd_name, \"sh\" | \"bash\" | \"zsh\") {\n            let after_cmd = &trimmed[first_word_end..];\n            if let Some(cmd_str) = extract_command_after_c_flag(after_cmd) {\n                remaining = cmd_str;\n                continue;\n            }\n        }\n\n        break;\n    }\n\n    remaining\n}\n\n/// Extract the command string after a -c flag in shell arguments.\n/// Handles: -c 'cmd', -c \"cmd\", -lc cmd, -cl 'cmd', etc.\nfn extract_command_after_c_flag(args: &str) -> Option<&str> {\n    let mut idx = 0;\n    while idx < args.len() {\n        let remaining = &args[idx..];\n        let dash_pos = remaining.find('-')?;\n        let after_dash = &remaining[dash_pos + 1..];\n\n        let flag_end = after_dash\n            .find(|c: char| !c.is_alphabetic())\n            .unwrap_or(after_dash.len());\n        let flags = &after_dash[..flag_end];\n\n        if flags.contains('c') {\n            let cmd_start = dash_pos + 1 + flag_end;\n            return Some(strip_quotes(remaining[cmd_start..].trim_start()));\n        }\n\n        idx += dash_pos + 1 + flag_end;\n    }\n\n    None\n}\n\n/// Strip surrounding quotes from a command string.\nfn strip_quotes(s: &str) -> &str {\n    let s = s.trim();\n    if s.len() >= 2 {\n        let first = s.as_bytes()[0];\n        let last = s.as_bytes()[s.len() - 1];\n        if (first == b'\"' || first == b'\\'') && first == last {\n            return &s[1..s.len() - 1];\n        }\n    }\n    s\n}\n"
  },
  {
    "path": "crates/executors/src/mcp_config.rs",
    "content": "//! Utilities for reading and writing external agent config files (not the server's own config).\n//!\n//! These helpers abstract over JSON vs TOML vs JSONC formats used by different agents.\n//! JSONC (JSON with Comments) is supported with comment preservation using jsonc-parser's CST.\n\nuse std::{collections::HashMap, path::Path, sync::LazyLock};\n\nuse jsonc_parser::{\n    ParseOptions,\n    cst::{CstObject, CstRootNode},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse tokio::fs;\nuse ts_rs::TS;\n\nuse crate::executors::{CodingAgent, ExecutorError};\n\nfn is_jsonc_file(path: &Path) -> bool {\n    path.extension()\n        .and_then(|e| e.to_str())\n        .is_some_and(|e| e.eq_ignore_ascii_case(\"jsonc\"))\n}\n\nstatic DEFAULT_MCP_JSON: &str = include_str!(\"../default_mcp.json\");\npub static PRECONFIGURED_MCP_SERVERS: LazyLock<Value> = LazyLock::new(|| {\n    serde_json::from_str::<Value>(DEFAULT_MCP_JSON).expect(\"Failed to parse default MCP JSON\")\n});\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct McpConfig {\n    servers: HashMap<String, serde_json::Value>,\n    pub servers_path: Vec<String>,\n    pub template: serde_json::Value,\n    pub preconfigured: serde_json::Value,\n    pub is_toml_config: bool,\n}\n\nimpl McpConfig {\n    pub fn new(\n        servers_path: Vec<String>,\n        template: serde_json::Value,\n        preconfigured: serde_json::Value,\n        is_toml_config: bool,\n    ) -> Self {\n        Self {\n            servers: HashMap::new(),\n            servers_path,\n            template,\n            preconfigured,\n            is_toml_config,\n        }\n    }\n    pub fn set_servers(&mut self, servers: HashMap<String, serde_json::Value>) {\n        self.servers = servers;\n    }\n}\n\npub async fn read_agent_config(\n    config_path: &std::path::Path,\n    mcp_config: &McpConfig,\n) -> Result<Value, ExecutorError> {\n    if let Ok(file_content) = fs::read_to_string(config_path).await {\n        if mcp_config.is_toml_config {\n            if file_content.trim().is_empty() {\n                return Ok(serde_json::json!({}));\n            }\n            let toml_val: toml::Value = toml::from_str(&file_content)?;\n            let json_string = serde_json::to_string(&toml_val)?;\n            Ok(serde_json::from_str(&json_string)?)\n        } else if is_jsonc_file(config_path) {\n            if file_content.trim().is_empty() {\n                return Ok(serde_json::json!({}));\n            }\n            match jsonc_parser::parse_to_serde_value(&file_content, &ParseOptions::default()) {\n                Ok(Some(value)) => Ok(value),\n                Ok(None) => Ok(serde_json::json!({})),\n                Err(_) => Ok(serde_json::from_str(&file_content)?),\n            }\n        } else {\n            Ok(serde_json::from_str(&file_content)?)\n        }\n    } else {\n        Ok(mcp_config.template.clone())\n    }\n}\n\npub async fn write_agent_config(\n    config_path: &std::path::Path,\n    mcp_config: &McpConfig,\n    config: &Value,\n) -> Result<(), ExecutorError> {\n    if mcp_config.is_toml_config {\n        let toml_value: toml::Value = serde_json::from_str(&serde_json::to_string(config)?)?;\n        let toml_content = toml::to_string_pretty(&toml_value)?;\n        fs::write(config_path, toml_content).await?;\n    } else if is_jsonc_file(config_path) {\n        write_jsonc_preserving_comments(config_path, config).await?;\n    } else {\n        let json_content = serde_json::to_string_pretty(config)?;\n        fs::write(config_path, json_content).await?;\n    }\n    Ok(())\n}\n\nasync fn write_jsonc_preserving_comments(\n    config_path: &std::path::Path,\n    new_config: &Value,\n) -> Result<(), ExecutorError> {\n    let current_content = fs::read_to_string(config_path)\n        .await\n        .unwrap_or_else(|_| \"{}\".to_string());\n\n    let output = update_jsonc_content(&current_content, new_config);\n\n    fs::write(config_path, output).await?;\n    Ok(())\n}\n\nfn update_jsonc_content(current_content: &str, new_config: &Value) -> String {\n    let root = CstRootNode::parse(current_content, &ParseOptions::default())\n        .unwrap_or_else(|_| CstRootNode::parse(\"{}\", &ParseOptions::default()).unwrap());\n\n    let root_obj = root.object_value_or_set();\n\n    if let Some(obj) = new_config.as_object() {\n        deep_merge_cst_object(&root_obj, obj);\n    }\n\n    root.to_string()\n}\n\n/// Recursively merges a serde_json Map into an existing CST object.\n/// This preserves comments by navigating into existing nested objects rather than replacing them.\nfn deep_merge_cst_object(cst_obj: &CstObject, new_obj: &Map<String, Value>) {\n    let existing_keys: Vec<String> = cst_obj\n        .properties()\n        .iter()\n        .filter_map(|p| p.name().and_then(|n| n.decoded_value().ok()))\n        .collect();\n\n    for key in &existing_keys {\n        if !new_obj.contains_key(key)\n            && let Some(prop) = cst_obj.get(key)\n        {\n            prop.remove();\n        }\n    }\n\n    for (key, new_value) in new_obj {\n        if let Some(prop) = cst_obj.get(key) {\n            if let (Some(existing_obj), Some(new_obj_map)) =\n                (prop.object_value(), new_value.as_object())\n            {\n                deep_merge_cst_object(&existing_obj, new_obj_map);\n            } else {\n                prop.set_value(serde_json_to_cst_input(new_value));\n            }\n        } else {\n            cst_obj.append(key, serde_json_to_cst_input(new_value));\n        }\n    }\n}\n\nfn serde_json_to_cst_input(value: &Value) -> jsonc_parser::cst::CstInputValue {\n    use jsonc_parser::cst::CstInputValue;\n\n    match value {\n        Value::Null => CstInputValue::Null,\n        Value::Bool(b) => CstInputValue::Bool(*b),\n        Value::Number(n) => {\n            if let Some(i) = n.as_i64() {\n                CstInputValue::Number(i.to_string())\n            } else if let Some(f) = n.as_f64() {\n                CstInputValue::Number(f.to_string())\n            } else {\n                CstInputValue::Number(n.to_string())\n            }\n        }\n        Value::String(s) => CstInputValue::String(s.clone()),\n        Value::Array(arr) => {\n            CstInputValue::Array(arr.iter().map(serde_json_to_cst_input).collect())\n        }\n        Value::Object(obj) => CstInputValue::Object(\n            obj.iter()\n                .map(|(k, v)| (k.clone(), serde_json_to_cst_input(v)))\n                .collect(),\n        ),\n    }\n}\n\ntype ServerMap = Map<String, Value>;\n\nfn is_http_server(s: &Map<String, Value>) -> bool {\n    matches!(s.get(\"type\").and_then(Value::as_str), Some(\"http\"))\n}\n\nfn is_stdio(s: &Map<String, Value>) -> bool {\n    !is_http_server(s) && s.get(\"command\").is_some()\n}\n\nfn extract_meta(mut obj: ServerMap) -> (ServerMap, Option<Value>) {\n    let meta = obj.remove(\"meta\");\n    (obj, meta)\n}\n\nfn attach_meta(mut obj: ServerMap, meta: Option<Value>) -> Value {\n    if let Some(m) = meta {\n        obj.insert(\"meta\".to_string(), m);\n    }\n    Value::Object(obj)\n}\n\nfn ensure_header(headers: &mut Map<String, Value>, key: &str, val: &str) {\n    match headers.get_mut(key) {\n        Some(Value::String(_)) => {}\n        _ => {\n            headers.insert(key.to_string(), Value::String(val.to_string()));\n        }\n    }\n}\n\nfn transform_http_servers<F>(mut servers: ServerMap, mut f: F) -> ServerMap\nwhere\n    F: FnMut(Map<String, Value>) -> Map<String, Value>,\n{\n    for (_k, v) in servers.iter_mut() {\n        if let Value::Object(s) = v\n            && is_http_server(s)\n        {\n            let taken = std::mem::take(s);\n            *s = f(taken);\n        }\n    }\n    servers\n}\n\n// --- Adapters ---------------------------------------------------------------\n\nfn adapt_passthrough(servers: ServerMap, meta: Option<Value>) -> Value {\n    attach_meta(servers, meta)\n}\n\nfn adapt_gemini(servers: ServerMap, meta: Option<Value>) -> Value {\n    let servers = transform_http_servers(servers, |mut s| {\n        let url = s\n            .remove(\"url\")\n            .unwrap_or_else(|| Value::String(String::new()));\n        let mut headers = s\n            .remove(\"headers\")\n            .and_then(|v| v.as_object().cloned())\n            .unwrap_or_default();\n\n        ensure_header(\n            &mut headers,\n            \"Accept\",\n            \"application/json, text/event-stream\",\n        );\n        Map::from_iter([\n            (\"httpUrl\".to_string(), url),\n            (\"headers\".to_string(), Value::Object(headers)),\n        ])\n    });\n    attach_meta(servers, meta)\n}\n\nfn adapt_cursor(servers: ServerMap, meta: Option<Value>) -> Value {\n    let servers = transform_http_servers(servers, |mut s| {\n        let url = s\n            .remove(\"url\")\n            .unwrap_or_else(|| Value::String(String::new()));\n        let headers = s\n            .remove(\"headers\")\n            .unwrap_or_else(|| Value::Object(Default::default()));\n        Map::from_iter([(\"url\".to_string(), url), (\"headers\".to_string(), headers)])\n    });\n    attach_meta(servers, meta)\n}\n\nfn adapt_codex(mut servers: ServerMap, mut meta: Option<Value>) -> Value {\n    servers.retain(|_, v| v.as_object().map(is_stdio).unwrap_or(false));\n\n    if let Some(Value::Object(ref mut m)) = meta {\n        m.retain(|k, _| servers.contains_key(k));\n        servers.insert(\"meta\".to_string(), Value::Object(std::mem::take(m)));\n        meta = None; // already attached above\n    }\n    attach_meta(servers, meta)\n}\n\nfn adapt_opencode(servers: ServerMap, meta: Option<Value>) -> Value {\n    let mut servers = transform_http_servers(servers, |mut s| {\n        let url = s\n            .remove(\"url\")\n            .unwrap_or_else(|| Value::String(String::new()));\n\n        let mut headers = s\n            .remove(\"headers\")\n            .and_then(|v| v.as_object().cloned())\n            .unwrap_or_default();\n\n        ensure_header(\n            &mut headers,\n            \"Accept\",\n            \"application/json, text/event-stream\",\n        );\n\n        Map::from_iter([\n            (\"type\".to_string(), Value::String(\"remote\".to_string())),\n            (\"url\".to_string(), url),\n            (\"headers\".to_string(), Value::Object(headers)),\n            (\"enabled\".to_string(), Value::Bool(true)),\n        ])\n    });\n\n    for (_k, v) in servers.iter_mut() {\n        if let Value::Object(s) = v\n            && is_stdio(s)\n        {\n            let command_str = s\n                .remove(\"command\")\n                .and_then(|v| match v {\n                    Value::String(s) => Some(s),\n                    _ => None,\n                })\n                .unwrap_or_default();\n\n            let mut cmd_vec: Vec<Value> = Vec::new();\n            if !command_str.is_empty() {\n                cmd_vec.push(Value::String(command_str));\n            }\n\n            if let Some(arr) = s.remove(\"args\").and_then(|v| match v {\n                Value::Array(arr) => Some(arr),\n                _ => None,\n            }) {\n                for a in arr {\n                    match a {\n                        Value::String(s) => cmd_vec.push(Value::String(s)),\n                        other => cmd_vec.push(other), // fall back to raw value if not string\n                    }\n                }\n            }\n\n            let mut new_map = Map::new();\n            new_map.insert(\"type\".to_string(), Value::String(\"local\".to_string()));\n            new_map.insert(\"command\".to_string(), Value::Array(cmd_vec));\n            new_map.insert(\"enabled\".to_string(), Value::Bool(true));\n            *s = new_map;\n        }\n    }\n\n    attach_meta(servers, meta)\n}\n\nfn adapt_copilot(mut servers: ServerMap, meta: Option<Value>) -> Value {\n    for (_, value) in servers.iter_mut() {\n        if let Value::Object(s) = value\n            && !s.contains_key(\"tools\")\n        {\n            s.insert(\n                \"tools\".to_string(),\n                Value::Array(vec![Value::String(\"*\".to_string())]),\n            );\n        }\n    }\n    attach_meta(servers, meta)\n}\n\nenum Adapter {\n    Passthrough,\n    Gemini,\n    Cursor,\n    Codex,\n    Opencode,\n    Copilot,\n}\n\nfn apply_adapter(adapter: Adapter, canonical: Value) -> Value {\n    let (servers_only, meta) = match canonical.as_object() {\n        Some(map) => extract_meta(map.clone()),\n        None => (ServerMap::new(), None),\n    };\n\n    match adapter {\n        Adapter::Passthrough => adapt_passthrough(servers_only, meta),\n        Adapter::Gemini => adapt_gemini(servers_only, meta),\n        Adapter::Cursor => adapt_cursor(servers_only, meta),\n        Adapter::Codex => adapt_codex(servers_only, meta),\n        Adapter::Opencode => adapt_opencode(servers_only, meta),\n        Adapter::Copilot => adapt_copilot(servers_only, meta),\n    }\n}\n\nimpl CodingAgent {\n    pub fn preconfigured_mcp(&self) -> Value {\n        use Adapter::*;\n\n        let adapter = match self {\n            CodingAgent::ClaudeCode(_) | CodingAgent::Amp(_) | CodingAgent::Droid(_) => Passthrough,\n            CodingAgent::QwenCode(_) | CodingAgent::Gemini(_) => Gemini,\n            CodingAgent::CursorAgent(_) => Cursor,\n            CodingAgent::Codex(_) => Codex,\n            CodingAgent::Opencode(_) => Opencode,\n            CodingAgent::Copilot(..) => Copilot,\n            #[cfg(feature = \"qa-mode\")]\n            CodingAgent::QaMock(_) => Passthrough, // QA mock doesn't need MCP\n        };\n\n        let canonical = PRECONFIGURED_MCP_SERVERS.clone();\n        apply_adapter(adapter, canonical)\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/model_selector.rs",
    "content": "use convert_case::{Case, Casing};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\n/// Provider information\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ModelProvider {\n    /// Provider identifier\n    pub id: String,\n    /// Display name\n    pub name: String,\n}\n\n/// Basic model information\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ModelInfo {\n    /// Model identifier\n    pub id: String,\n    /// Display name\n    pub name: String,\n    /// Provider this model belongs to\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub provider_id: Option<String>,\n    /// Configurable reasoning options if supported\n    #[serde(default)]\n    pub reasoning_options: Vec<ReasoningOption>,\n}\n\n/// Reasoning option (simple selectable choice).\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ReasoningOption {\n    pub id: String,\n    pub label: String,\n    #[serde(default)]\n    pub is_default: bool,\n}\n\n/// Available agent option provided by an executor.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct AgentInfo {\n    pub id: String,\n    pub label: String,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    #[serde(default)]\n    pub is_default: bool,\n}\n\n/// Permission policy for tool operations\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq, Eq, Default)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[ts(use_ts_enum)]\npub enum PermissionPolicy {\n    #[default]\n    /// Skip all permission checks\n    Auto,\n    /// Require approval for risky operations\n    Supervised,\n    /// Plan mode before execution (executor-defined meaning)\n    Plan,\n}\n\n/// Full model selector configuration\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]\npub struct ModelSelectorConfig {\n    /// Available providers\n    pub providers: Vec<ModelProvider>,\n\n    /// Available models\n    pub models: Vec<ModelInfo>,\n\n    /// Global default model (format: provider_id/model_id)\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub default_model: Option<String>,\n\n    /// Available agents\n    pub agents: Vec<AgentInfo>,\n\n    /// Supported permission policies\n    pub permissions: Vec<PermissionPolicy>,\n}\n\nimpl ReasoningOption {\n    pub fn from_names(names: impl IntoIterator<Item = impl Into<String>>) -> Vec<ReasoningOption> {\n        Self::from_names_with_labels(names.into_iter().map(|n| (n.into(), None)))\n    }\n\n    pub fn from_names_with_labels(\n        pairs: impl IntoIterator<Item = (String, Option<String>)>,\n    ) -> Vec<ReasoningOption> {\n        let rank_key = |id: &str| match id.to_lowercase().as_str() {\n            \"none\" => Some(0),\n            \"low\" => Some(1),\n            \"medium\" => Some(2),\n            \"high\" => Some(3),\n            \"xhigh\" => Some(4),\n            \"max\" => Some(5),\n            _ => None,\n        };\n\n        let mut options: Vec<ReasoningOption> = pairs\n            .into_iter()\n            .map(|(id, label)| {\n                let label = label.unwrap_or_else(|| reasoning_label(&id));\n                let is_default = id.eq_ignore_ascii_case(\"high\");\n                ReasoningOption {\n                    id,\n                    label,\n                    is_default,\n                }\n            })\n            .collect();\n\n        options.sort_by(|a, b| match (rank_key(&a.id), rank_key(&b.id)) {\n            (Some(a_rank), Some(b_rank)) => a_rank.cmp(&b_rank),\n            (Some(_), None) => std::cmp::Ordering::Less,\n            (None, Some(_)) => std::cmp::Ordering::Greater,\n            (None, None) => a.label.cmp(&b.label),\n        });\n\n        options\n    }\n}\n\nfn reasoning_label(id: &str) -> String {\n    match id {\n        \"xhigh\" => \"Extra High\".to_string(),\n        _ => id.to_case(Case::Title),\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/profile.rs",
    "content": "use std::{\n    collections::HashMap,\n    fs,\n    str::FromStr,\n    sync::{LazyLock, RwLock},\n};\n\nuse convert_case::{Case, Casing};\nuse serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};\nuse thiserror::Error;\nuse ts_rs::TS;\n\nuse crate::{\n    executors::{AvailabilityInfo, BaseCodingAgent, CodingAgent, StandardCodingAgentExecutor},\n    model_selector::PermissionPolicy,\n};\n\n/// Return the canonical form for variant keys.\n/// – \"DEFAULT\" is kept as-is  \n/// – everything else is converted to SCREAMING_SNAKE_CASE\npub fn canonical_variant_key<S: AsRef<str>>(raw: S) -> String {\n    let key = raw.as_ref();\n    if key.eq_ignore_ascii_case(\"DEFAULT\") {\n        \"DEFAULT\".to_string()\n    } else {\n        // Convert to SCREAMING_SNAKE_CASE by first going to snake_case then uppercase\n        key.to_case(Case::Snake).to_case(Case::ScreamingSnake)\n    }\n}\n\n#[derive(Error, Debug)]\npub enum ProfileError {\n    #[error(\"Built-in executor '{executor}' cannot be deleted\")]\n    CannotDeleteExecutor { executor: BaseCodingAgent },\n\n    #[error(\"Built-in configuration '{executor}:{variant}' cannot be deleted\")]\n    CannotDeleteBuiltInConfig {\n        executor: BaseCodingAgent,\n        variant: String,\n    },\n\n    #[error(\"Validation error: {0}\")]\n    Validation(String),\n\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n\n    #[error(transparent)]\n    Serde(#[from] serde_json::Error),\n\n    #[error(\"No available executor profile\")]\n    NoAvailableExecutorProfile,\n}\n\nstatic EXECUTOR_PROFILES_CACHE: LazyLock<RwLock<ExecutorConfigs>> =\n    LazyLock::new(|| RwLock::new(ExecutorConfigs::load()));\n\n// New format default profiles (v3 - flattened)\nconst DEFAULT_PROFILES_JSON: &str = include_str!(\"../default_profiles.json\");\n\n// Executor-centric profile identifier\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Hash, Eq)]\npub struct ExecutorProfileId {\n    /// The executor type (e.g., \"CLAUDE_CODE\", \"AMP\")\n    #[serde(alias = \"profile\", deserialize_with = \"de_base_coding_agent_kebab\")]\n    // Backwards compatibility with ProfileVariantIds, esp stored in DB under ExecutorAction\n    pub executor: BaseCodingAgent,\n    /// Optional variant name (e.g., \"PLAN\", \"ROUTER\")\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub variant: Option<String>,\n}\n\n// Convert legacy profile/executor names from kebab-case to SCREAMING_SNAKE_CASE, can be deleted 14 days from 3/9/25\nfn de_base_coding_agent_kebab<'de, D>(de: D) -> Result<BaseCodingAgent, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let raw = String::deserialize(de)?;\n    // kebab-case -> SCREAMING_SNAKE_CASE\n    let norm = raw.replace('-', \"_\").to_ascii_uppercase();\n    BaseCodingAgent::from_str(&norm)\n        .map_err(|_| D::Error::custom(format!(\"unknown executor '{raw}' (normalized to '{norm}')\")))\n}\n\nimpl ExecutorProfileId {\n    /// Create a new executor profile ID with default variant\n    pub fn new(executor: BaseCodingAgent) -> Self {\n        Self {\n            executor,\n            variant: None,\n        }\n    }\n\n    /// Create a new executor profile ID with specific variant\n    pub fn with_variant(executor: BaseCodingAgent, variant: String) -> Self {\n        Self {\n            executor,\n            variant: Some(variant),\n        }\n    }\n\n    /// Get cache key for this executor profile\n    pub fn cache_key(&self) -> String {\n        match &self.variant {\n            Some(variant) => format!(\"{}:{}\", self.executor, variant),\n            None => self.executor.clone().to_string(),\n        }\n    }\n}\n\nimpl std::fmt::Display for ExecutorProfileId {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match &self.variant {\n            Some(variant) => write!(f, \"{}:{}\", self.executor, variant),\n            None => write!(f, \"{}\", self.executor),\n        }\n    }\n}\n\n/// Unified executor identity + user-selectable overrides.\n///\n/// This is the single object that flows through API requests, action types,\n/// scratch persistence, and frontend state whenever an executor is used.\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct ExecutorConfig {\n    /// The executor type (e.g., CLAUDE_CODE, AMP)\n    #[serde(alias = \"profile\", deserialize_with = \"de_base_coding_agent_kebab\")]\n    pub executor: BaseCodingAgent,\n    /// Optional variant/preset name (e.g., \"PLAN\", \"ROUTER\")\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub variant: Option<String>,\n    /// Model override (e.g., \"anthropic/claude-sonnet-4-20250514\")\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub model_id: Option<String>,\n    /// Agent mode override\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub agent_id: Option<String>,\n    /// Reasoning effort override (e.g., \"high\", \"medium\")\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub reasoning_id: Option<String>,\n    /// Permission policy override\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub permission_policy: Option<PermissionPolicy>,\n}\n\nimpl ExecutorConfig {\n    /// Create from just an executor (default variant, no overrides)\n    pub fn new(executor: BaseCodingAgent) -> Self {\n        Self {\n            executor,\n            variant: None,\n            model_id: None,\n            agent_id: None,\n            reasoning_id: None,\n            permission_policy: None,\n        }\n    }\n\n    /// Extract the profile identity portion for profile lookup\n    pub fn profile_id(&self) -> ExecutorProfileId {\n        ExecutorProfileId {\n            executor: self.executor,\n            variant: self.variant.clone(),\n        }\n    }\n\n    /// Returns true if any override field is set\n    pub fn has_overrides(&self) -> bool {\n        self.model_id.is_some()\n            || self.agent_id.is_some()\n            || self.reasoning_id.is_some()\n            || self.permission_policy.is_some()\n    }\n}\n\nimpl From<ExecutorProfileId> for ExecutorConfig {\n    fn from(id: ExecutorProfileId) -> Self {\n        Self {\n            executor: id.executor,\n            variant: id.variant,\n            model_id: None,\n            agent_id: None,\n            reasoning_id: None,\n            permission_policy: None,\n        }\n    }\n}\n\nimpl std::fmt::Display for ExecutorConfig {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        self.profile_id().fmt(f)\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct ExecutorProfile {\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub recently_used_models: Option<ExecutorRecentModels>,\n    #[serde(flatten)]\n    pub configurations: HashMap<String, CodingAgent>,\n}\n\nimpl ExecutorProfile {\n    /// Get variant configuration by name, or None if not found\n    pub fn get_variant(&self, variant: &str) -> Option<&CodingAgent> {\n        self.configurations.get(variant)\n    }\n\n    /// Get the default configuration for this executor\n    pub fn get_default(&self) -> Option<&CodingAgent> {\n        self.configurations.get(\"DEFAULT\")\n    }\n\n    /// Create a new executor profile with just a default configuration\n    pub fn new_with_default(default_config: CodingAgent) -> Self {\n        let mut configurations = HashMap::new();\n        configurations.insert(\"DEFAULT\".to_string(), default_config);\n        Self {\n            recently_used_models: None,\n            configurations,\n        }\n    }\n\n    /// Add or update a variant configuration\n    pub fn set_variant(\n        &mut self,\n        variant_name: String,\n        config: CodingAgent,\n    ) -> Result<(), &'static str> {\n        let key = canonical_variant_key(&variant_name);\n        if key == \"DEFAULT\" {\n            return Err(\n                \"Cannot override 'DEFAULT' variant using set_variant, use set_default instead\",\n            );\n        }\n        self.configurations.insert(key, config);\n        Ok(())\n    }\n\n    /// Set the default configuration\n    pub fn set_default(&mut self, config: CodingAgent) {\n        self.configurations.insert(\"DEFAULT\".to_string(), config);\n    }\n\n    /// Get all variant names (excluding \"DEFAULT\")\n    pub fn variant_names(&self) -> Vec<&String> {\n        self.configurations\n            .keys()\n            .filter(|k| *k != \"DEFAULT\")\n            .collect()\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Default)]\npub struct ExecutorRecentModels {\n    /// Ordered list of recently used model keys (most recent last).\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub models: Vec<String>,\n    /// Last-used reasoning effort per model\n    #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n    pub reasoning_by_model: HashMap<String, String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct ExecutorConfigs {\n    pub executors: HashMap<BaseCodingAgent, ExecutorProfile>,\n}\n\nimpl ExecutorConfigs {\n    /// Normalise all variant keys in-place\n    fn canonicalise(&mut self) {\n        for profile in self.executors.values_mut() {\n            let mut replacements = Vec::new();\n            for key in profile.configurations.keys().cloned().collect::<Vec<_>>() {\n                let canon = canonical_variant_key(&key);\n                if canon != key {\n                    replacements.push((key, canon));\n                }\n            }\n            for (old, new) in replacements {\n                if let Some(cfg) = profile.configurations.remove(&old) {\n                    // If both lowercase and canonical forms existed, keep canonical one\n                    profile.configurations.entry(new).or_insert(cfg);\n                }\n            }\n        }\n    }\n\n    /// Get cached executor profiles\n    pub fn get_cached() -> ExecutorConfigs {\n        EXECUTOR_PROFILES_CACHE.read().unwrap().clone()\n    }\n\n    /// Reload executor profiles cache\n    pub fn reload() {\n        let mut cache = EXECUTOR_PROFILES_CACHE.write().unwrap();\n        *cache = Self::load();\n    }\n\n    /// Load executor profiles from file or defaults\n    pub fn load() -> Self {\n        let profiles_path = workspace_utils::assets::profiles_path();\n\n        // Load defaults first\n        let mut defaults = Self::from_defaults();\n        defaults.canonicalise();\n\n        // Try to load user overrides\n        let content = match fs::read_to_string(&profiles_path) {\n            Ok(content) => content,\n            Err(_) => {\n                tracing::info!(\"No user profiles.json found, using defaults only\");\n                return defaults;\n            }\n        };\n\n        // Parse user overrides\n        match serde_json::from_str::<Self>(&content) {\n            Ok(mut user_overrides) => {\n                tracing::info!(\"Loaded user profile overrides from profiles.json\");\n                user_overrides.canonicalise();\n                Self::merge_with_defaults(defaults, user_overrides)\n            }\n            Err(e) => {\n                tracing::error!(\n                    \"Failed to parse user profiles.json: {}, using defaults only\",\n                    e\n                );\n                defaults\n            }\n        }\n    }\n\n    /// Save user profile overrides to file (only saves what differs from defaults)\n    pub fn save_overrides(&self) -> Result<(), ProfileError> {\n        let profiles_path = workspace_utils::assets::profiles_path();\n        let mut defaults = Self::from_defaults();\n        defaults.canonicalise();\n\n        // Canonicalise current config before computing overrides\n        let mut self_clone = self.clone();\n        self_clone.canonicalise();\n\n        // Compute differences from defaults\n        let overrides = Self::compute_overrides(&defaults, &self_clone)?;\n\n        // Validate the merged result would be valid\n        let merged = Self::merge_with_defaults(defaults, overrides.clone());\n        Self::validate_merged(&merged)?;\n\n        // Write overrides directly to file\n        let content = serde_json::to_string_pretty(&overrides)?;\n        fs::write(&profiles_path, content)?;\n\n        tracing::info!(\"Saved profile overrides to {:?}\", profiles_path);\n        Ok(())\n    }\n\n    /// Deep merge defaults with user overrides\n    fn merge_with_defaults(mut defaults: Self, overrides: Self) -> Self {\n        for (executor_key, override_profile) in overrides.executors {\n            match defaults.executors.get_mut(&executor_key) {\n                Some(default_profile) => {\n                    // Merge configurations (user configs override defaults, new ones are added)\n                    for (config_name, config) in override_profile.configurations {\n                        default_profile.configurations.insert(config_name, config);\n                    }\n                    if override_profile.recently_used_models.is_some() {\n                        default_profile.recently_used_models =\n                            override_profile.recently_used_models;\n                    }\n                }\n                None => {\n                    // New executor, add completely\n                    defaults.executors.insert(executor_key, override_profile);\n                }\n            }\n        }\n\n        defaults\n    }\n\n    /// Compute what overrides are needed to transform defaults into current config\n    fn compute_overrides(defaults: &Self, current: &Self) -> Result<Self, ProfileError> {\n        let mut overrides = Self {\n            executors: HashMap::new(),\n        };\n\n        // Fast scan for any illegal deletions BEFORE allocating/cloning\n        for (executor_key, default_profile) in &defaults.executors {\n            // Check if executor was removed entirely\n            if !current.executors.contains_key(executor_key) {\n                return Err(ProfileError::CannotDeleteExecutor {\n                    executor: *executor_key,\n                });\n            }\n\n            let current_profile = &current.executors[executor_key];\n\n            // Check if ANY built-in configuration was removed\n            for config_name in default_profile.configurations.keys() {\n                if !current_profile.configurations.contains_key(config_name) {\n                    return Err(ProfileError::CannotDeleteBuiltInConfig {\n                        executor: *executor_key,\n                        variant: config_name.clone(),\n                    });\n                }\n            }\n        }\n\n        for (executor_key, current_profile) in &current.executors {\n            if let Some(default_profile) = defaults.executors.get(executor_key) {\n                let mut override_configurations = HashMap::new();\n\n                // Check each configuration in current profile\n                for (config_name, current_config) in &current_profile.configurations {\n                    if let Some(default_config) = default_profile.configurations.get(config_name) {\n                        // Only include if different from default\n                        if current_config != default_config {\n                            override_configurations\n                                .insert(config_name.clone(), current_config.clone());\n                        }\n                    } else {\n                        // New configuration, always include\n                        override_configurations.insert(config_name.clone(), current_config.clone());\n                    }\n                }\n\n                let mut override_profile = ExecutorProfile {\n                    recently_used_models: None,\n                    configurations: override_configurations,\n                };\n\n                if current_profile.recently_used_models != default_profile.recently_used_models {\n                    override_profile.recently_used_models = current_profile\n                        .recently_used_models\n                        .clone()\n                        .or_else(|| Some(ExecutorRecentModels::default()));\n                }\n\n                if !override_profile.configurations.is_empty()\n                    || override_profile.recently_used_models.is_some()\n                {\n                    overrides.executors.insert(*executor_key, override_profile);\n                }\n            } else {\n                // New executor, include completely\n                overrides\n                    .executors\n                    .insert(*executor_key, current_profile.clone());\n            }\n        }\n\n        Ok(overrides)\n    }\n\n    /// Validate that merged profiles are consistent and valid\n    fn validate_merged(merged: &Self) -> Result<(), ProfileError> {\n        for (executor_key, profile) in &merged.executors {\n            // Ensure default configuration exists\n            let default_config = profile.configurations.get(\"DEFAULT\").ok_or_else(|| {\n                ProfileError::Validation(format!(\n                    \"Executor '{executor_key}' is missing required 'default' configuration\"\n                ))\n            })?;\n\n            // Validate that the default agent type matches the executor key\n            if BaseCodingAgent::from(default_config) != *executor_key {\n                return Err(ProfileError::Validation(format!(\n                    \"Executor key '{executor_key}' does not match the agent variant '{default_config}'\"\n                )));\n            }\n\n            // Ensure configuration names don't conflict with reserved words\n            for config_name in profile.configurations.keys() {\n                if config_name.starts_with(\"__\") {\n                    return Err(ProfileError::Validation(format!(\n                        \"Configuration name '{config_name}' is reserved (starts with '__')\"\n                    )));\n                }\n            }\n        }\n        Ok(())\n    }\n\n    /// Load from the new v3 defaults\n    pub fn from_defaults() -> Self {\n        serde_json::from_str(DEFAULT_PROFILES_JSON).unwrap_or_else(|e| {\n            tracing::error!(\"Failed to parse embedded default_profiles.json: {}\", e);\n            panic!(\"Default profiles v3 JSON is invalid\")\n        })\n    }\n\n    pub fn get_coding_agent(&self, executor_profile_id: &ExecutorProfileId) -> Option<CodingAgent> {\n        self.executors\n            .get(&executor_profile_id.executor)\n            .and_then(|executor| {\n                executor.get_variant(\n                    &executor_profile_id\n                        .variant\n                        .clone()\n                        .unwrap_or(\"DEFAULT\".to_string()),\n                )\n            })\n            .cloned()\n    }\n\n    pub fn get_coding_agent_or_default(\n        &self,\n        executor_profile_id: &ExecutorProfileId,\n    ) -> CodingAgent {\n        self.get_coding_agent(executor_profile_id)\n            .unwrap_or_else(|| {\n                let mut default_executor_profile_id = executor_profile_id.clone();\n                default_executor_profile_id.variant = Some(\"DEFAULT\".to_string());\n                self.get_coding_agent(&default_executor_profile_id)\n                    .expect(\"No default variant found\")\n            })\n    }\n    pub async fn get_recommended_executor_profile(\n        &self,\n    ) -> Result<ExecutorProfileId, ProfileError> {\n        let mut agents_with_info: Vec<(BaseCodingAgent, AvailabilityInfo)> = Vec::new();\n\n        for &base_agent in self.executors.keys() {\n            let profile_id = ExecutorProfileId::new(base_agent);\n            if let Some(coding_agent) = self.get_coding_agent(&profile_id) {\n                let info = coding_agent.get_availability_info();\n                if info.is_available() {\n                    agents_with_info.push((base_agent, info));\n                }\n            }\n        }\n\n        if agents_with_info.is_empty() {\n            return Err(ProfileError::NoAvailableExecutorProfile);\n        }\n\n        agents_with_info.sort_by(|a, b| {\n            use crate::executors::AvailabilityInfo;\n            match (&a.1, &b.1) {\n                // Both have login detected - compare timestamps (most recent first)\n                (\n                    AvailabilityInfo::LoginDetected {\n                        last_auth_timestamp: time_a,\n                    },\n                    AvailabilityInfo::LoginDetected {\n                        last_auth_timestamp: time_b,\n                    },\n                ) => time_b.cmp(time_a),\n                // LoginDetected > InstallationFound\n                (AvailabilityInfo::LoginDetected { .. }, AvailabilityInfo::InstallationFound) => {\n                    std::cmp::Ordering::Less\n                }\n                (AvailabilityInfo::InstallationFound, AvailabilityInfo::LoginDetected { .. }) => {\n                    std::cmp::Ordering::Greater\n                }\n                // LoginDetected > NotFound\n                (AvailabilityInfo::LoginDetected { .. }, AvailabilityInfo::NotFound) => {\n                    std::cmp::Ordering::Less\n                }\n                (AvailabilityInfo::NotFound, AvailabilityInfo::LoginDetected { .. }) => {\n                    std::cmp::Ordering::Greater\n                }\n                // InstallationFound > NotFound\n                (AvailabilityInfo::InstallationFound, AvailabilityInfo::NotFound) => {\n                    std::cmp::Ordering::Less\n                }\n                (AvailabilityInfo::NotFound, AvailabilityInfo::InstallationFound) => {\n                    std::cmp::Ordering::Greater\n                }\n                // Same state - equal\n                _ => std::cmp::Ordering::Equal,\n            }\n        });\n\n        let selected = agents_with_info[0].0;\n        tracing::info!(\"Recommended executor: {}\", selected);\n        Ok(ExecutorProfileId::new(selected))\n    }\n}\n\npub fn to_default_variant(id: &ExecutorProfileId) -> ExecutorProfileId {\n    ExecutorProfileId {\n        executor: id.executor,\n        variant: None,\n    }\n}\n"
  },
  {
    "path": "crates/executors/src/stdout_dup.rs",
    "content": "//! Cross-platform stdout duplication utility for child processes\n//!\n//! Provides a single function to duplicate a child process's stdout stream.\n//! Supports Unix and Windows platforms.\n\n#[cfg(unix)]\nuse std::os::unix::io::{FromRawFd, IntoRawFd, OwnedFd};\n#[cfg(windows)]\nuse std::os::windows::io::{FromRawHandle, IntoRawHandle, OwnedHandle};\n\nuse command_group::AsyncGroupChild;\nuse futures::{StreamExt, stream::BoxStream};\nuse tokio::io::{AsyncWrite, AsyncWriteExt};\nuse tokio_stream::wrappers::UnboundedReceiverStream;\nuse tokio_util::io::ReaderStream;\nuse workspace_utils::command_ext::GroupSpawnNoWindowExt;\n\nuse crate::executors::{ExecutorError, SpawnedChild};\n\n/// Duplicate stdout from AsyncGroupChild.\n///\n/// Creates a stream that mirrors stdout of child process without consuming it.\n///\n/// # Returns\n/// A stream of `io::Result<String>` that receives a copy of all stdout data.\npub fn duplicate_stdout(\n    child: &mut AsyncGroupChild,\n) -> Result<BoxStream<'static, std::io::Result<String>>, ExecutorError> {\n    // The implementation strategy is:\n    // 1. create a new file descriptor.\n    // 2. read the original stdout file descriptor.\n    // 3. write the data to both the new file descriptor and a duplicate stream.\n\n    // Take the original stdout\n    let original_stdout = child.inner().stdout.take().ok_or_else(|| {\n        ExecutorError::Io(std::io::Error::new(\n            std::io::ErrorKind::NotFound,\n            \"Child process has no stdout\",\n        ))\n    })?;\n\n    // Create a new file descriptor in a cross-platform way (using os_pipe crate)\n    let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| {\n        ExecutorError::Io(std::io::Error::other(format!(\"Failed to create pipe: {e}\")))\n    })?;\n    // Use fd as new child stdout\n    child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?);\n\n    // Obtain writer from fd\n    let mut fd_writer = wrap_fd_as_tokio_writer(pipe_writer)?;\n\n    // Create the duplicate stdout stream\n    let (dup_writer, dup_reader) =\n        tokio::sync::mpsc::unbounded_channel::<std::io::Result<String>>();\n\n    // Read original stdout and write to both new ChildStdout and duplicate stream\n    tokio::spawn(async move {\n        let mut stdout_stream = ReaderStream::new(original_stdout);\n\n        while let Some(res) = stdout_stream.next().await {\n            match res {\n                Ok(data) => {\n                    let _ = fd_writer.write_all(&data).await;\n\n                    let string_chunk = String::from_utf8_lossy(&data).into_owned();\n                    let _ = dup_writer.send(Ok(string_chunk));\n                }\n                Err(err) => {\n                    tracing::error!(\"Error reading from child stdout: {}\", err);\n                    let _ = dup_writer.send(Err(err));\n                }\n            }\n        }\n    });\n\n    // Return the channel receiver as a boxed stream\n    Ok(Box::pin(UnboundedReceiverStream::new(dup_reader)))\n}\n\n/// Handle to append additional lines into the child's stdout stream.\n#[derive(Clone)]\npub struct StdoutAppender {\n    tx: tokio::sync::mpsc::UnboundedSender<String>,\n}\n\nimpl StdoutAppender {\n    pub fn append_line<S: Into<String>>(&self, line: S) {\n        // Best-effort; ignore send errors if writer task ended\n        let mut line = line.into();\n        while line.ends_with('\\n') || line.ends_with('\\r') {\n            line.pop();\n        }\n        let _ = self.tx.send(line);\n    }\n}\n\n/// Tee the child's stdout and provide both a duplicate stream and an appender to write additional\n/// lines into the child's stdout. This keeps the original stdout functional and mirrors output to\n/// the returned duplicate stream.\npub fn tee_stdout_with_appender(\n    child: &mut AsyncGroupChild,\n) -> Result<(BoxStream<'static, std::io::Result<String>>, StdoutAppender), ExecutorError> {\n    // Take original stdout\n    let original_stdout = child.inner().stdout.take().ok_or_else(|| {\n        ExecutorError::Io(std::io::Error::new(\n            std::io::ErrorKind::NotFound,\n            \"Child process has no stdout\",\n        ))\n    })?;\n\n    // Create replacement pipe and set as new child stdout\n    let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| {\n        ExecutorError::Io(std::io::Error::other(format!(\"Failed to create pipe: {e}\")))\n    })?;\n    child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?);\n\n    // Single shared writer for both original stdout forwarding and injected lines\n    let writer = wrap_fd_as_tokio_writer(pipe_writer)?;\n    let shared_writer = std::sync::Arc::new(tokio::sync::Mutex::new(writer));\n\n    // Create duplicate stream publisher\n    let (dup_tx, dup_rx) = tokio::sync::mpsc::unbounded_channel::<std::io::Result<String>>();\n    // Create injector channel\n    let (inj_tx, mut inj_rx) = tokio::sync::mpsc::unbounded_channel::<String>();\n\n    // Task 1: forward original stdout to child stdout and duplicate stream\n    {\n        let shared_writer = shared_writer.clone();\n        tokio::spawn(async move {\n            let mut stdout_stream = ReaderStream::new(original_stdout);\n            while let Some(res) = stdout_stream.next().await {\n                match res {\n                    Ok(data) => {\n                        // forward to child stdout\n                        let mut w = shared_writer.lock().await;\n                        let _ = w.write_all(&data).await;\n                        // publish duplicate\n                        let string_chunk = String::from_utf8_lossy(&data).into_owned();\n                        let _ = dup_tx.send(Ok(string_chunk));\n                    }\n                    Err(err) => {\n                        let _ = dup_tx.send(Err(err));\n                    }\n                }\n            }\n        });\n    }\n\n    // Task 2: write injected lines to child stdout\n    {\n        let shared_writer = shared_writer.clone();\n        tokio::spawn(async move {\n            while let Some(line) = inj_rx.recv().await {\n                let mut data = line.into_bytes();\n                data.push(b'\\n');\n                let mut w = shared_writer.lock().await;\n                let _ = w.write_all(&data).await;\n            }\n        });\n    }\n\n    Ok((\n        Box::pin(UnboundedReceiverStream::new(dup_rx)),\n        StdoutAppender { tx: inj_tx },\n    ))\n}\n\n/// Create a fresh stdout pipe for the child process and return an async writer\n/// that writes directly to the child's new stdout.\n///\n/// This helper does not read or duplicate any existing stdout; it simply\n/// replaces the child's stdout with a new pipe reader and returns the\n/// corresponding async writer for the caller to write into.\npub fn create_stdout_pipe_writer<'b>(\n    child: &mut AsyncGroupChild,\n) -> Result<impl AsyncWrite + 'b, ExecutorError> {\n    // Create replacement pipe and set as new child stdout\n    let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| {\n        ExecutorError::Io(std::io::Error::other(format!(\"Failed to create pipe: {e}\")))\n    })?;\n    child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?);\n\n    // Return async writer to the caller\n    wrap_fd_as_tokio_writer(pipe_writer)\n}\n\n/// Create a helper child process to be used only for stdout duplication.\npub fn spawn_local_output_process()\n-> Result<(SpawnedChild, impl AsyncWrite + Send + Unpin), ExecutorError> {\n    let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| {\n        ExecutorError::Io(std::io::Error::other(format!(\n            \"Failed to create stdout pipe: {e}\"\n        )))\n    })?;\n\n    #[cfg(unix)]\n    let mut cmd = {\n        let mut cmd = tokio::process::Command::new(\"/bin/sh\");\n        cmd.args([\"-c\", \"while :; do sleep 3600; done\"])\n            .stdin(std::process::Stdio::null())\n            .stdout(std::process::Stdio::piped())\n            .stderr(std::process::Stdio::piped());\n        cmd\n    };\n\n    #[cfg(windows)]\n    let mut cmd = {\n        let mut cmd = tokio::process::Command::new(\"powershell.exe\");\n        cmd.args([\n            \"-NoLogo\",\n            \"-NonInteractive\",\n            \"-Command\",\n            \"[System.Threading.Thread]::Sleep([int]::MaxValue)\",\n        ])\n        .stdin(std::process::Stdio::null())\n        .stdout(std::process::Stdio::piped())\n        .stderr(std::process::Stdio::piped());\n        cmd\n    };\n\n    cmd.kill_on_drop(true);\n\n    let mut child = cmd.group_spawn_no_window()?;\n\n    // Replace stdout with our pipe\n    child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?);\n\n    let writer = wrap_fd_as_tokio_writer(pipe_writer)?;\n\n    let spawned = SpawnedChild {\n        child,\n        exit_signal: None,\n        cancel: None,\n    };\n\n    Ok((spawned, writer))\n}\n\n// =========================================\n// OS file descriptor helper functions\n// =========================================\n\n/// Convert os_pipe::PipeReader to tokio::process::ChildStdout\nfn wrap_fd_as_child_stdout(\n    pipe_reader: os_pipe::PipeReader,\n) -> Result<tokio::process::ChildStdout, ExecutorError> {\n    #[cfg(unix)]\n    {\n        // On Unix: PipeReader -> raw fd -> OwnedFd -> std::process::ChildStdout -> tokio::process::ChildStdout\n        let raw_fd = pipe_reader.into_raw_fd();\n        let owned_fd = unsafe { OwnedFd::from_raw_fd(raw_fd) };\n        let std_stdout = std::process::ChildStdout::from(owned_fd);\n        tokio::process::ChildStdout::from_std(std_stdout).map_err(ExecutorError::Io)\n    }\n\n    #[cfg(windows)]\n    {\n        // On Windows: PipeReader -> raw handle -> OwnedHandle -> std::process::ChildStdout -> tokio::process::ChildStdout\n        let raw_handle = pipe_reader.into_raw_handle();\n        let owned_handle = unsafe { OwnedHandle::from_raw_handle(raw_handle) };\n        let std_stdout = std::process::ChildStdout::from(owned_handle);\n        tokio::process::ChildStdout::from_std(std_stdout).map_err(ExecutorError::Io)\n    }\n}\n\n/// Convert os_pipe::PipeWriter to a tokio file for async writing\nfn wrap_fd_as_tokio_writer(\n    pipe_writer: os_pipe::PipeWriter,\n) -> Result<impl AsyncWrite, ExecutorError> {\n    #[cfg(unix)]\n    {\n        // On Unix: PipeWriter -> raw fd -> OwnedFd -> std::fs::File -> tokio::fs::File\n        let raw_fd = pipe_writer.into_raw_fd();\n        let owned_fd = unsafe { OwnedFd::from_raw_fd(raw_fd) };\n        let std_file = std::fs::File::from(owned_fd);\n        Ok(tokio::fs::File::from_std(std_file))\n    }\n\n    #[cfg(windows)]\n    {\n        // On Windows: PipeWriter -> raw handle -> OwnedHandle -> std::fs::File -> tokio::fs::File\n        let raw_handle = pipe_writer.into_raw_handle();\n        let owned_handle = unsafe { OwnedHandle::from_raw_handle(raw_handle) };\n        let std_file = std::fs::File::from(owned_handle);\n        Ok(tokio::fs::File::from_std(std_file))\n    }\n}\n"
  },
  {
    "path": "crates/git/Cargo.toml",
    "content": "[package]\nname = \"git\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[features]\ndefault = []\ncloud = []\n\n[dependencies]\nchrono = { version = \"0.4\", features = [\"serde\"] }\ndirs = \"5.0\"\ngit2 = { workspace = true }\nserde = { workspace = true }\ntempfile = \"3.21\"\nthiserror = { workspace = true }\ntracing = { workspace = true }\nts-rs = { workspace = true }\nutils = { path = \"../utils\" }\n\n[dev-dependencies]\ntempfile = \"3.21\"\n"
  },
  {
    "path": "crates/git/src/cli.rs",
    "content": "//! Why we prefer the Git CLI here\n//!\n//! - Safer working-tree semantics: the `git` CLI refuses to clobber uncommitted\n//!   tracked changes and untracked files during checkout/merge/rebase unless you\n//!   explicitly force it. libgit2 does not enforce those protections by default,\n//!   which means callers must re‑implement a lot of safety checks to avoid data loss.\n//! - Sparse‑checkout correctness: the CLI natively respects sparse‑checkout.\n//!   libgit2 does not yet support sparse‑checkout semantics the same way, which\n//!   led to incorrect diffs and staging in our workflows.\n//! - Cross‑platform stability: we observed libgit2 corrupt repositories shared\n//!   between WSL and Windows in scenarios where the `git` CLI did not. Delegating\n//!   working‑tree mutations to the CLI has proven more reliable in practice.\n//!\n//! Given these reasons, this module centralizes destructive or working‑tree‑\n//! touching operations (rebase, merge, checkout, add/commit, etc.) through the\n//! `git` CLI, while keeping libgit2 for read‑only graph queries and credentialed\n//! network operations when useful.\nuse std::{\n    ffi::{OsStr, OsString},\n    io::Write as _,\n    path::Path,\n    process::{Command, Stdio},\n};\n\nuse thiserror::Error;\nuse utils::{path::ALWAYS_SKIP_DIRS, shell::resolve_executable_path_blocking};\n\nuse super::Commit;\n\n#[derive(Debug, Error)]\npub enum GitCliError {\n    #[error(\"git executable not found or not runnable\")]\n    NotAvailable,\n    #[error(\"git command failed: {0}\")]\n    CommandFailed(String),\n    #[error(\"authentication failed: {0}\")]\n    AuthFailed(String),\n    #[error(\"push rejected: {0}\")]\n    PushRejected(String),\n    #[error(\"rebase in progress in this worktree\")]\n    RebaseInProgress,\n}\n\n#[derive(Clone, Default)]\npub struct GitCli;\n\n/// Parsed change type from `git diff --name-status` output\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum ChangeType {\n    Added,\n    Modified,\n    Deleted,\n    Renamed,\n    Copied,\n    TypeChanged,\n    Unmerged,\n    Unknown(String),\n}\n\n/// One entry from a status diff (name-status + paths)\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct StatusDiffEntry {\n    pub change: ChangeType,\n    pub path: String,\n    pub old_path: Option<String>,\n}\n\n/// Parsed worktree entry from `git worktree list --porcelain`\n#[derive(Debug, Clone)]\npub struct WorktreeEntry {\n    pub path: String,\n    pub branch: Option<String>,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct StatusDiffOptions {\n    pub path_filter: Option<Vec<String>>, // pathspecs to limit diff\n}\n\nimpl GitCli {\n    pub fn new() -> Self {\n        Self {}\n    }\n    /// Run `git -C <repo> worktree add <path> <branch>` (optionally creating the branch with -b)\n    pub fn worktree_add(\n        &self,\n        repo_path: &Path,\n        worktree_path: &Path,\n        branch: &str,\n        create_branch: bool,\n    ) -> Result<(), GitCliError> {\n        self.ensure_available()?;\n\n        let mut args: Vec<OsString> = vec![\"worktree\".into(), \"add\".into()];\n        if create_branch {\n            args.push(\"-b\".into());\n            args.push(OsString::from(branch));\n        }\n        args.push(worktree_path.as_os_str().into());\n        args.push(OsString::from(branch));\n        self.git(repo_path, args)?;\n\n        // Good practice: reapply sparse-checkout in the new worktree to ensure materialization matches\n        // Non-fatal if it fails or not configured.\n        let _ = self.git(worktree_path, [\"sparse-checkout\", \"reapply\"]);\n\n        Ok(())\n    }\n\n    /// Run `git -C <repo> worktree remove <path>`\n    pub fn worktree_remove(\n        &self,\n        repo_path: &Path,\n        worktree_path: &Path,\n        force: bool,\n    ) -> Result<(), GitCliError> {\n        self.ensure_available()?;\n        let mut args: Vec<OsString> = vec![\"worktree\".into(), \"remove\".into()];\n        if force {\n            args.push(\"--force\".into());\n        }\n        args.push(worktree_path.as_os_str().into());\n        self.git(repo_path, args)?;\n        Ok(())\n    }\n\n    /// Run `git -C <repo> worktree move <old_path> <new_path>`\n    pub fn worktree_move(\n        &self,\n        repo_path: &Path,\n        old_path: &Path,\n        new_path: &Path,\n    ) -> Result<(), GitCliError> {\n        self.ensure_available()?;\n        self.git(\n            repo_path,\n            [\n                \"worktree\",\n                \"move\",\n                old_path.to_str().ok_or_else(|| {\n                    GitCliError::CommandFailed(\"Invalid old worktree path\".to_string())\n                })?,\n                new_path.to_str().ok_or_else(|| {\n                    GitCliError::CommandFailed(\"Invalid new worktree path\".to_string())\n                })?,\n            ],\n        )?;\n        Ok(())\n    }\n\n    /// Prune stale worktree metadata\n    pub fn worktree_prune(&self, repo_path: &Path) -> Result<(), GitCliError> {\n        self.git(repo_path, [\"worktree\", \"prune\"])?;\n        Ok(())\n    }\n\n    /// Return true if there are any changes in the working tree (staged or unstaged).\n    pub fn has_changes(&self, worktree_path: &Path) -> Result<bool, GitCliError> {\n        let out = self.git(\n            worktree_path,\n            [\"--no-optional-locks\", \"status\", \"--porcelain\"],\n        )?;\n        Ok(!out.is_empty())\n    }\n\n    /// Diff status vs a base branch using a temporary index (always includes untracked).\n    /// Path filter limits the reported paths.\n    pub fn diff_status(\n        &self,\n        worktree_path: &Path,\n        base_commit: &Commit,\n        opts: StatusDiffOptions,\n    ) -> Result<Vec<StatusDiffEntry>, GitCliError> {\n        // Create a temp index file\n        let tmp_dir = tempfile::TempDir::new()\n            .map_err(|e| GitCliError::CommandFailed(format!(\"temp dir create failed: {e}\")))?;\n        let tmp_index = tmp_dir.path().join(\"index\");\n        let envs = vec![(\n            OsString::from(\"GIT_INDEX_FILE\"),\n            tmp_index.as_os_str().to_os_string(),\n        )];\n\n        // Use a temp index from HEAD to accurately track renames in untracked files\n        let _ = self.git_with_env(worktree_path, [\"read-tree\", \"HEAD\"], &envs)?;\n\n        // Stage changed and untracked files explicitly, which is faster than `git add -A` for large repos.\n        // Use raw paths from `get_worktree_status` to avoid lossy UTF-8 conversions for odd filenames.\n        let status = self.get_worktree_status(worktree_path)?;\n        let mut paths_to_add: Vec<Vec<u8>> = Vec::new();\n        for entry in status.entries {\n            paths_to_add.push(entry.path);\n            if let Some(orig) = entry.orig_path {\n                paths_to_add.push(orig);\n            }\n        }\n        if !paths_to_add.is_empty() {\n            paths_to_add.extend(\n                Self::get_default_pathspec_excludes()\n                    .iter()\n                    .map(|s| s.as_encoded_bytes().to_vec()),\n            );\n            let mut input = Vec::new();\n            for p in paths_to_add {\n                input.extend_from_slice(&p);\n                input.push(0);\n            }\n            let args = vec![\n                OsString::from(\"add\"),\n                OsString::from(\"-A\"),\n                OsString::from(\"--pathspec-from-file=-\"),\n                OsString::from(\"--pathspec-file-nul\"),\n            ];\n            self.git_with_stdin(worktree_path, args, Some(&envs), &input)?;\n        }\n        // git diff --cached\n        let mut args: Vec<OsString> = vec![\n            \"-c\".into(),\n            \"core.quotepath=false\".into(),\n            \"diff\".into(),\n            \"--cached\".into(),\n            \"-M\".into(),\n            \"--name-status\".into(),\n            OsString::from(base_commit.to_string()),\n        ];\n        args = Self::apply_pathspec_filter(args, opts.path_filter.as_ref());\n        let out = self.git_with_env(worktree_path, args, &envs)?;\n        Ok(Self::parse_name_status(&out))\n    }\n\n    /// Return `git status --porcelain` parsed into a structured summary\n    pub fn get_worktree_status(&self, worktree_path: &Path) -> Result<WorktreeStatus, GitCliError> {\n        // Using -z for NUL-separated output which correctly handles paths with special chars.\n        // Format: XY<space>PATH<NUL>[ORIGPATH<NUL>] where ORIGPATH only present for R/C.\n        let args = Self::apply_default_excludes(vec![\n            \"--no-optional-locks\",\n            \"status\",\n            \"--porcelain\",\n            \"-z\",\n            \"--untracked-files=normal\",\n        ]);\n        let out = self.git_impl(worktree_path, args, None, None)?;\n        let mut entries = Vec::new();\n        let mut uncommitted_tracked = 0usize;\n        let mut untracked = 0usize;\n        let mut parts = out.split(|b| *b == 0);\n        while let Some(part) = parts.next() {\n            if part.is_empty() || part.len() < 4 {\n                continue;\n            }\n            let staged = part[0] as char;\n            let unstaged = part[1] as char;\n            let path = part[3..].to_vec();\n\n            let mut orig_path = None;\n            if (staged == 'R' || unstaged == 'R' || staged == 'C' || unstaged == 'C')\n                && let Some(old_path) = parts.next()\n                && !old_path.is_empty()\n            {\n                orig_path = Some(old_path.to_vec());\n            }\n            if staged == '?' && unstaged == '?' {\n                untracked += 1;\n                entries.push(StatusEntry {\n                    staged,\n                    unstaged,\n                    path,\n                    orig_path,\n                    is_untracked: true,\n                });\n            } else {\n                if staged != ' ' || unstaged != ' ' {\n                    uncommitted_tracked += 1;\n                }\n                entries.push(StatusEntry {\n                    staged,\n                    unstaged,\n                    path,\n                    orig_path,\n                    is_untracked: false,\n                });\n            }\n        }\n        Ok(WorktreeStatus {\n            uncommitted_tracked,\n            untracked,\n            entries,\n        })\n    }\n\n    /// Stage all changes in the working tree (respects sparse-checkout semantics).\n    pub fn add_all(&self, worktree_path: &Path) -> Result<(), GitCliError> {\n        self.git(\n            worktree_path,\n            Self::apply_default_excludes(vec![\"add\", \"-A\"]),\n        )?;\n        Ok(())\n    }\n\n    pub fn list_worktrees(&self, repo_path: &Path) -> Result<Vec<WorktreeEntry>, GitCliError> {\n        let out = self.git(repo_path, [\"worktree\", \"list\", \"--porcelain\"])?;\n        let mut entries = Vec::new();\n        let mut current_path: Option<String> = None;\n        let mut current_head: Option<String> = None;\n        let mut current_branch: Option<String> = None;\n\n        for line in out.lines() {\n            let line = line.trim();\n\n            if line.is_empty() {\n                // End of current worktree entry, save it if we have required data\n                if let (Some(path), Some(_head)) = (current_path.take(), current_head.take()) {\n                    entries.push(WorktreeEntry {\n                        path,\n                        branch: current_branch.take(),\n                    });\n                }\n            } else if let Some(path) = line.strip_prefix(\"worktree \") {\n                current_path = Some(path.to_string());\n            } else if let Some(head) = line.strip_prefix(\"HEAD \") {\n                current_head = Some(head.to_string());\n            } else if let Some(branch_ref) = line.strip_prefix(\"branch \") {\n                // Extract branch name from refs/heads/branch-name\n                current_branch = branch_ref\n                    .strip_prefix(\"refs/heads/\")\n                    .map(|name| name.to_string());\n            }\n        }\n\n        // Handle the last entry if no trailing empty line\n        if let (Some(path), Some(_head)) = (current_path, current_head) {\n            entries.push(WorktreeEntry {\n                path,\n                branch: current_branch,\n            });\n        }\n\n        Ok(entries)\n    }\n\n    /// Commit staged changes with the given message.\n    pub fn commit(&self, worktree_path: &Path, message: &str) -> Result<(), GitCliError> {\n        self.git(worktree_path, [\"commit\", \"-m\", message])?;\n        Ok(())\n    }\n    /// Fetch a branch to the given remote using native git authentication.\n    pub fn fetch_with_refspec(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        refspec: &str,\n    ) -> Result<(), GitCliError> {\n        let envs = vec![(OsString::from(\"GIT_TERMINAL_PROMPT\"), OsString::from(\"0\"))];\n\n        let args = [\n            OsString::from(\"fetch\"),\n            OsString::from(remote_url),\n            OsString::from(refspec),\n        ];\n\n        match self.git_with_env(repo_path, args, &envs) {\n            Ok(_) => Ok(()),\n            Err(GitCliError::CommandFailed(msg)) => Err(self.classify_cli_error(msg)),\n            Err(err) => Err(err),\n        }\n    }\n\n    /// Push a branch to the given remote using native git authentication.\n    pub fn push(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        branch: &str,\n        force: bool,\n    ) -> Result<(), GitCliError> {\n        let refspec = if force {\n            format!(\"+refs/heads/{branch}:refs/heads/{branch}\")\n        } else {\n            format!(\"refs/heads/{branch}:refs/heads/{branch}\")\n        };\n        let envs = vec![(OsString::from(\"GIT_TERMINAL_PROMPT\"), OsString::from(\"0\"))];\n\n        let args = [\n            OsString::from(\"push\"),\n            OsString::from(remote_url),\n            OsString::from(refspec),\n        ];\n\n        match self.git_with_env(repo_path, args, &envs) {\n            Ok(_) => Ok(()),\n            Err(GitCliError::CommandFailed(msg)) => Err(self.classify_cli_error(msg)),\n            Err(err) => Err(err),\n        }\n    }\n\n    /// This directly queries the remote without fetching.\n    pub fn check_remote_branch_exists(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        branch_name: &str,\n    ) -> Result<bool, GitCliError> {\n        let envs = vec![(OsString::from(\"GIT_TERMINAL_PROMPT\"), OsString::from(\"0\"))];\n\n        let args = [\n            OsString::from(\"ls-remote\"),\n            OsString::from(\"--heads\"),\n            OsString::from(remote_url),\n            OsString::from(format!(\"refs/heads/{branch_name}\")),\n        ];\n\n        match self.git_with_env(repo_path, args, &envs) {\n            Ok(output) => Ok(!output.trim().is_empty()),\n            Err(GitCliError::CommandFailed(msg)) => Err(self.classify_cli_error(msg)),\n            Err(err) => Err(err),\n        }\n    }\n\n    /// Delete a local branch from the repository (force delete).\n    pub fn delete_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), GitCliError> {\n        self.ensure_available()?;\n        self.git(repo_path, [\"branch\", \"-D\", branch_name])?;\n        Ok(())\n    }\n\n    pub fn get_remote_url(\n        &self,\n        repo_path: &Path,\n        remote_name: &str,\n    ) -> Result<String, GitCliError> {\n        let output = self.git(repo_path, [\"remote\", \"get-url\", remote_name])?;\n        Ok(output.trim().to_string())\n    }\n\n    /// List all remotes with their URLs using `git remote -v`.\n    /// Returns a Vec of (name, url) tuples, deduplicated (fetch/push show the same URL).\n    pub fn list_remotes(&self, repo_path: &Path) -> Result<Vec<(String, String)>, GitCliError> {\n        let output = self.git(repo_path, [\"remote\", \"-v\"])?;\n        let mut seen = std::collections::HashSet::new();\n        let mut remotes = Vec::new();\n\n        for line in output.lines() {\n            let line = line.trim();\n            if line.is_empty() {\n                continue;\n            }\n            // Format: \"name\\turl (fetch)\" or \"name\\turl (push)\"\n            let parts: Vec<&str> = line.split('\\t').collect();\n            if parts.len() >= 2 {\n                let name = parts[0].to_string();\n                // Remove the \" (fetch)\" or \" (push)\" suffix from URL\n                let url = parts[1]\n                    .strip_suffix(\" (fetch)\")\n                    .or_else(|| parts[1].strip_suffix(\" (push)\"))\n                    .unwrap_or(parts[1])\n                    .to_string();\n\n                if seen.insert(name.clone()) {\n                    remotes.push((name, url));\n                }\n            }\n        }\n\n        Ok(remotes)\n    }\n\n    // Parse `git diff --name-status` output into structured entries.\n    // Handles rename/copy scores like `R100` by matching the first letter.\n    fn parse_name_status(output: &str) -> Vec<StatusDiffEntry> {\n        let mut out = Vec::new();\n        for line in output.lines() {\n            let line = line.trim_end();\n            if line.is_empty() {\n                continue;\n            }\n            let mut parts = line.split('\\t');\n            let code = parts.next().unwrap_or(\"\");\n            let change = match code.chars().next().unwrap_or('?') {\n                'A' => ChangeType::Added,\n                'M' => ChangeType::Modified,\n                'D' => ChangeType::Deleted,\n                'R' => ChangeType::Renamed,\n                'C' => ChangeType::Copied,\n                'T' => ChangeType::TypeChanged,\n                'U' => ChangeType::Unmerged,\n                other => ChangeType::Unknown(other.to_string()),\n            };\n\n            match change {\n                ChangeType::Renamed | ChangeType::Copied => {\n                    if let (Some(old), Some(newp)) = (parts.next(), parts.next()) {\n                        out.push(StatusDiffEntry {\n                            change,\n                            path: newp.to_string(),\n                            old_path: Some(old.to_string()),\n                        });\n                    }\n                }\n                _ => {\n                    if let Some(p) = parts.next() {\n                        out.push(StatusDiffEntry {\n                            change,\n                            path: p.to_string(),\n                            old_path: None,\n                        });\n                    }\n                }\n            }\n        }\n        out\n    }\n\n    /// Return the merge base commit sha of two refs in the given worktree.\n    /// If `git merge-base --fork-point` fails, falls back to regular `merge-base`.\n    pub fn merge_base(\n        &self,\n        worktree_path: &Path,\n        a: &str,\n        b: &str,\n    ) -> Result<String, GitCliError> {\n        let out = self\n            .git(worktree_path, [\"merge-base\", \"--fork-point\", a, b])\n            .unwrap_or(self.git(worktree_path, [\"merge-base\", a, b])?);\n        Ok(out.trim().to_string())\n    }\n\n    /// Perform `git rebase --onto <new_base> <old_base>` on <task_branch> in `worktree_path`.\n    pub fn rebase_onto(\n        &self,\n        worktree_path: &Path,\n        new_base: &str,\n        old_base: &str,\n        task_branch: &str,\n    ) -> Result<(), GitCliError> {\n        // If a rebase is in progress, refuse to proceed. The caller can\n        // choose to abort or continue; we avoid destructive actions here.\n        if self.is_rebase_in_progress(worktree_path).unwrap_or(false) {\n            return Err(GitCliError::RebaseInProgress);\n        }\n        // compute the merge base of task_branch from old_base\n        let merge_base = self\n            .merge_base(worktree_path, old_base, task_branch)\n            .unwrap_or(old_base.to_string());\n\n        self.git(\n            worktree_path,\n            [\"rebase\", \"--onto\", new_base, &merge_base, task_branch],\n        )?;\n        Ok(())\n    }\n\n    /// Return true if there is a rebase in progress in this worktree.\n    /// We treat this as true when either of Git's rebase state directories exists:\n    /// - rebase-merge (interactive rebase)\n    /// - rebase-apply (am-based rebase)\n    pub fn is_rebase_in_progress(&self, worktree_path: &Path) -> Result<bool, GitCliError> {\n        let rebase_merge = self.git(worktree_path, [\"rev-parse\", \"--git-path\", \"rebase-merge\"])?;\n        let rebase_apply = self.git(worktree_path, [\"rev-parse\", \"--git-path\", \"rebase-apply\"])?;\n        let rm_exists = std::path::Path::new(rebase_merge.trim()).exists();\n        let ra_exists = std::path::Path::new(rebase_apply.trim()).exists();\n        Ok(rm_exists || ra_exists)\n    }\n\n    /// Return true if a merge is in progress (MERGE_HEAD exists).\n    pub fn is_merge_in_progress(&self, worktree_path: &Path) -> Result<bool, GitCliError> {\n        match self.git(worktree_path, [\"rev-parse\", \"--verify\", \"MERGE_HEAD\"]) {\n            Ok(_) => Ok(true),\n            Err(GitCliError::CommandFailed(_)) => Ok(false),\n            Err(e) => Err(e),\n        }\n    }\n\n    /// Return true if a cherry-pick is in progress (CHERRY_PICK_HEAD exists).\n    pub fn is_cherry_pick_in_progress(&self, worktree_path: &Path) -> Result<bool, GitCliError> {\n        match self.git(worktree_path, [\"rev-parse\", \"--verify\", \"CHERRY_PICK_HEAD\"]) {\n            Ok(_) => Ok(true),\n            Err(GitCliError::CommandFailed(_)) => Ok(false),\n            Err(e) => Err(e),\n        }\n    }\n\n    /// Return true if a revert is in progress (REVERT_HEAD exists).\n    pub fn is_revert_in_progress(&self, worktree_path: &Path) -> Result<bool, GitCliError> {\n        match self.git(worktree_path, [\"rev-parse\", \"--verify\", \"REVERT_HEAD\"]) {\n            Ok(_) => Ok(true),\n            Err(GitCliError::CommandFailed(_)) => Ok(false),\n            Err(e) => Err(e),\n        }\n    }\n\n    /// Abort an in-progress rebase in this worktree. If no rebase is in progress,\n    /// this is a no-op and returns Ok(()).\n    pub fn abort_rebase(&self, worktree_path: &Path) -> Result<(), GitCliError> {\n        // If nothing to abort, return success\n        if !self.is_rebase_in_progress(worktree_path)? {\n            return Ok(());\n        }\n        // Best-effort: if `git rebase --abort` fails, surface the error message\n        self.git(worktree_path, [\"rebase\", \"--abort\"]).map(|_| ())\n    }\n\n    /// Quit an in-progress rebase (cleanup metadata without modifying commits).\n    /// If no rebase is in progress, it's a no-op.\n    pub fn quit_rebase(&self, worktree_path: &Path) -> Result<(), GitCliError> {\n        if !self.is_rebase_in_progress(worktree_path)? {\n            return Ok(());\n        }\n        self.git(worktree_path, [\"rebase\", \"--quit\"]).map(|_| ())\n    }\n\n    /// Continue an in-progress rebase. Returns error if no rebase is in progress\n    /// or if there are unresolved conflicts.\n    pub fn continue_rebase(&self, worktree_path: &Path) -> Result<(), GitCliError> {\n        if !self.is_rebase_in_progress(worktree_path)? {\n            return Err(GitCliError::CommandFailed(\n                \"No rebase in progress\".to_string(),\n            ));\n        }\n        self.git(worktree_path, [\"rebase\", \"--continue\"])\n            .map(|_| ())\n    }\n\n    /// Return true if there are staged changes (index differs from HEAD)\n    pub fn has_staged_changes(&self, repo_path: &Path) -> Result<bool, GitCliError> {\n        use utils::command_ext::NoWindowExt;\n        // `git diff --cached --quiet` returns exit code 1 if there are differences\n        let out =\n            Command::new(resolve_executable_path_blocking(\"git\").ok_or(GitCliError::NotAvailable)?)\n                .arg(\"-C\")\n                .arg(repo_path)\n                .arg(\"diff\")\n                .arg(\"--cached\")\n                .arg(\"--quiet\")\n                .no_window()\n                .output()\n                .map_err(|e| GitCliError::CommandFailed(e.to_string()))?;\n        match out.status.code() {\n            Some(0) => Ok(false),\n            Some(1) => Ok(true),\n            _ => Err(GitCliError::CommandFailed(\n                String::from_utf8_lossy(&out.stderr).trim().to_string(),\n            )),\n        }\n    }\n\n    /// Checkout base branch, squash-merge from_branch, and commit with message. Returns new HEAD sha.\n    pub fn merge_squash_commit(\n        &self,\n        repo_path: &Path,\n        base_branch: &str,\n        from_branch: &str,\n        message: &str,\n    ) -> Result<String, GitCliError> {\n        self.git(repo_path, [\"checkout\", base_branch]).map(|_| ())?;\n        self.git(repo_path, [\"merge\", \"--squash\", \"--no-commit\", from_branch])\n            .map(|_| ())?;\n        self.git(repo_path, [\"commit\", \"-m\", message]).map(|_| ())?;\n        let sha = self\n            .git(repo_path, [\"rev-parse\", \"HEAD\"])?\n            .trim()\n            .to_string();\n        Ok(sha)\n    }\n\n    /// Update a ref to a specific sha in the repo.\n    pub fn update_ref(\n        &self,\n        repo_path: &Path,\n        refname: &str,\n        sha: &str,\n    ) -> Result<(), GitCliError> {\n        self.git(repo_path, [\"update-ref\", refname, sha])\n            .map(|_| ())\n    }\n\n    pub fn abort_merge(&self, worktree_path: &Path) -> Result<(), GitCliError> {\n        if !self.is_merge_in_progress(worktree_path)? {\n            return Ok(());\n        }\n        self.git(worktree_path, [\"merge\", \"--abort\"]).map(|_| ())\n    }\n\n    pub fn abort_cherry_pick(&self, worktree_path: &Path) -> Result<(), GitCliError> {\n        if !self.is_cherry_pick_in_progress(worktree_path)? {\n            return Ok(());\n        }\n        self.git(worktree_path, [\"cherry-pick\", \"--abort\"])\n            .map(|_| ())\n    }\n\n    pub fn abort_revert(&self, worktree_path: &Path) -> Result<(), GitCliError> {\n        if !self.is_revert_in_progress(worktree_path)? {\n            return Ok(());\n        }\n        self.git(worktree_path, [\"revert\", \"--abort\"]).map(|_| ())\n    }\n\n    /// List files currently in a conflicted (unmerged) state in the worktree.\n    pub fn get_conflicted_files(&self, worktree_path: &Path) -> Result<Vec<String>, GitCliError> {\n        // `--diff-filter=U` lists paths with unresolved conflicts\n        let out = self.git(worktree_path, [\"diff\", \"--name-only\", \"--diff-filter=U\"])?;\n        let mut files = Vec::new();\n        for line in out.lines() {\n            let p = line.trim();\n            if !p.is_empty() {\n                files.push(p.to_string());\n            }\n        }\n        Ok(files)\n    }\n}\n\n// Private methods\nimpl GitCli {\n    fn classify_cli_error(&self, msg: String) -> GitCliError {\n        let lower = msg.to_ascii_lowercase();\n        if lower.contains(\"authentication failed\")\n            || lower.contains(\"could not read username\")\n            || lower.contains(\"invalid username or password\")\n        {\n            GitCliError::AuthFailed(msg)\n        } else if lower.contains(\"non-fast-forward\")\n            || lower.contains(\"failed to push some refs\")\n            || lower.contains(\"fetch first\")\n            || lower.contains(\"updates were rejected because the tip\")\n        {\n            GitCliError::PushRejected(msg)\n        } else {\n            GitCliError::CommandFailed(msg)\n        }\n    }\n\n    /// Ensure `git` is available on PATH\n    fn ensure_available(&self) -> Result<(), GitCliError> {\n        use utils::command_ext::NoWindowExt;\n        let git = resolve_executable_path_blocking(\"git\").ok_or(GitCliError::NotAvailable)?;\n        let out = Command::new(&git)\n            .arg(\"--version\")\n            .no_window()\n            .output()\n            .map_err(|_| GitCliError::NotAvailable)?;\n        if out.status.success() {\n            Ok(())\n        } else {\n            Err(GitCliError::NotAvailable)\n        }\n    }\n\n    /// Run `git -C <repo_path> <args...>` and return stdout bytes on success.\n    /// Prefer adding specific helpers (e.g. `get_worktree_status`, `diff_status`)\n    /// instead of calling this directly, so all parsing and command choices are\n    /// centralized here. This makes it easier to change the underlying commands\n    /// without adjusting callers. Use this low-level method directly only in\n    /// tests or when no dedicated helper exists yet.\n    ///\n    /// About `OsStr`/`OsString` usage:\n    /// - `Command` and `Path` operate on `OsStr` to support non‑UTF‑8 paths and\n    ///   arguments across platforms. Using `String` would force lossy conversion\n    ///   or partial failures. This API accepts anything that implements\n    ///   `AsRef<OsStr>` so typical call sites can still pass `&str` literals or\n    ///   owned `String`s without friction.\n    fn git_impl<I, S>(\n        &self,\n        repo_path: &Path,\n        args: I,\n        envs: Option<&[(OsString, OsString)]>,\n        stdin: Option<&[u8]>,\n    ) -> Result<Vec<u8>, GitCliError>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        self.ensure_available()?;\n        let git = resolve_executable_path_blocking(\"git\").ok_or(GitCliError::NotAvailable)?;\n        let mut cmd = Command::new(&git);\n        cmd.arg(\"-C\").arg(repo_path);\n\n        if let Some(envs) = envs {\n            for (k, v) in envs {\n                cmd.env(k, v);\n            }\n        }\n\n        for a in args {\n            cmd.arg(a);\n        }\n\n        if stdin.is_some() {\n            cmd.stdin(Stdio::piped());\n        } else {\n            cmd.stdin(Stdio::null());\n        }\n\n        cmd.stdout(Stdio::piped());\n        cmd.stderr(Stdio::piped());\n\n        tracing::trace!(\n            stdin = ?stdin.as_ref().map(|s| String::from_utf8_lossy(s)),\n            repo = ?repo_path,\n            \"Running git command: {:?}\",\n            cmd\n        );\n\n        use utils::command_ext::NoWindowExt;\n        let mut child = cmd\n            .no_window()\n            .spawn()\n            .map_err(|e| GitCliError::CommandFailed(e.to_string()))?;\n\n        let stdin_write_result = if let Some(input) = stdin\n            && let Some(mut child_stdin) = child.stdin.take()\n        {\n            Some(child_stdin.write_all(input))\n        } else {\n            None\n        };\n\n        let out = child\n            .wait_with_output()\n            .map_err(|e| GitCliError::CommandFailed(e.to_string()))?;\n\n        if !out.status.success() {\n            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();\n            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();\n            let combined = match (stdout.is_empty(), stderr.is_empty()) {\n                (true, true) => \"Command failed with no output\".to_string(),\n                (false, false) => format!(\"--- stderr\\n{stderr}\\n--- stdout\\n{stdout}\"),\n                (false, true) => format!(\"--- stderr\\n{stdout}\"),\n                (true, false) => format!(\"--- stdout\\n{stderr}\"),\n            };\n            return Err(GitCliError::CommandFailed(combined));\n        }\n        if let Some(Err(e)) = stdin_write_result {\n            return Err(GitCliError::CommandFailed(format!(\n                \"failed to write to git stdin: {e}\"\n            )));\n        }\n        Ok(out.stdout)\n    }\n\n    pub fn git<I, S>(&self, repo_path: &Path, args: I) -> Result<String, GitCliError>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        let out = self.git_impl(repo_path, args, None, None)?;\n        Ok(String::from_utf8_lossy(&out).to_string())\n    }\n\n    fn git_with_env<I, S>(\n        &self,\n        repo_path: &Path,\n        args: I,\n        envs: &[(OsString, OsString)],\n    ) -> Result<String, GitCliError>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        let out = self.git_impl(repo_path, args, Some(envs), None)?;\n        Ok(String::from_utf8_lossy(&out).to_string())\n    }\n\n    fn git_with_stdin<I, S>(\n        &self,\n        repo_path: &Path,\n        args: I,\n        envs: Option<&[(OsString, OsString)]>,\n        stdin: &[u8],\n    ) -> Result<String, GitCliError>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        let out = self.git_impl(repo_path, args, envs, Some(stdin))?;\n        Ok(String::from_utf8_lossy(&out).to_string())\n    }\n\n    fn apply_default_excludes<I, S>(args: I) -> Vec<OsString>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        Self::apply_pathspec_filter(args, None)\n    }\n\n    fn apply_pathspec_filter<I, S>(args: I, pathspecs: Option<&Vec<String>>) -> Vec<OsString>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        let filters = Self::build_pathspec_filter(pathspecs);\n        let mut args = args\n            .into_iter()\n            .map(|s| s.as_ref().to_os_string())\n            .collect::<Vec<_>>();\n        if !filters.is_empty() {\n            args.push(\"--\".into());\n            args.extend(filters);\n        }\n        args\n    }\n\n    fn build_pathspec_filter(pathspecs: Option<&Vec<String>>) -> Vec<OsString> {\n        let mut filters = Vec::new();\n        filters.extend(Self::get_default_pathspec_excludes());\n        if let Some(pathspecs) = pathspecs {\n            for p in pathspecs {\n                if p.trim().is_empty() {\n                    continue;\n                }\n                filters.push(OsString::from(p));\n            }\n        }\n        filters\n    }\n\n    fn get_default_pathspec_excludes() -> Vec<OsString> {\n        ALWAYS_SKIP_DIRS\n            .iter()\n            .map(|d| OsString::from(format!(\":(glob,exclude)**/{d}/\")))\n            .collect()\n    }\n}\n/// Parsed entry from `git status --porcelain`\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct StatusEntry {\n    /// Single-letter staged status (column X) or '?' for untracked\n    pub staged: char,\n    /// Single-letter unstaged status (column Y) or '?' for untracked\n    pub unstaged: char,\n    /// Current path (raw bytes to avoid lossy UTF-8 conversion)\n    pub path: Vec<u8>,\n    /// Original path (for renames), raw bytes\n    pub orig_path: Option<Vec<u8>>,\n    /// True if this entry is untracked (\"??\")\n    pub is_untracked: bool,\n}\n\n/// Summary + entries for a working tree status\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct WorktreeStatus {\n    pub uncommitted_tracked: usize,\n    pub untracked: usize,\n    pub entries: Vec<StatusEntry>,\n}\n"
  },
  {
    "path": "crates/git/src/lib.rs",
    "content": "use std::{collections::HashMap, path::Path};\n\nuse chrono::{DateTime, Utc};\nuse git2::{\n    BranchType, Delta, DiffFindOptions, DiffOptions, Error as GitError, Reference, Remote,\n    Repository, Sort,\n};\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse utils::diff::{Diff, DiffChangeKind, FileDiffDetails, compute_line_change_counts};\n\nmod cli;\nmod validation;\n\nuse cli::{ChangeType, StatusDiffEntry, StatusDiffOptions};\npub use cli::{GitCli, GitCliError, StatusEntry, WorktreeStatus};\npub use utils::path::ALWAYS_SKIP_DIRS;\npub use validation::is_valid_branch_prefix;\n\n/// Statistics for a single file based on git history\n#[derive(Clone, Debug)]\npub struct FileStat {\n    /// Index in the commit history (0 = HEAD, 1 = parent of HEAD, ...)\n    pub last_index: usize,\n    /// Number of times this file was changed in recent commits\n    pub commit_count: u32,\n    /// Timestamp of the most recent change\n    pub last_time: DateTime<Utc>,\n}\n\n#[derive(Debug, Error)]\npub enum GitServiceError {\n    #[error(transparent)]\n    Git(#[from] GitError),\n    #[error(transparent)]\n    GitCLI(#[from] GitCliError),\n    #[error(transparent)]\n    IoError(#[from] std::io::Error),\n    #[error(\"Invalid repository: {0}\")]\n    InvalidRepository(String),\n    #[error(\"Branch not found: {0}\")]\n    BranchNotFound(String),\n    #[error(\"Merge conflicts: {message}\")]\n    MergeConflicts {\n        message: String,\n        conflicted_files: Vec<String>,\n    },\n    #[error(\"Branches diverged: {0}\")]\n    BranchesDiverged(String),\n    #[error(\"{0} has uncommitted changes: {1}\")]\n    WorktreeDirty(String, String),\n    #[error(\"Rebase in progress; resolve or abort it before retrying\")]\n    RebaseInProgress,\n}\n/// Service for managing Git operations in task execution workflows\n#[derive(Clone)]\npub struct GitService {}\n\n// Max inline diff size for UI (in bytes). Files larger than this will have\n// their contents omitted from the diff stream to avoid UI crashes.\nconst MAX_INLINE_DIFF_BYTES: usize = 2 * 1024 * 1024; // ~2MB\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(rename_all = \"snake_case\")]\npub enum ConflictOp {\n    Rebase,\n    Merge,\n    CherryPick,\n    Revert,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct GitBranch {\n    pub name: String,\n    pub is_current: bool,\n    pub is_remote: bool,\n    #[ts(type = \"Date\")]\n    pub last_commit_date: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, TS)]\npub struct GitRemote {\n    pub name: String,\n    pub url: String,\n}\n\n#[derive(Debug, Clone)]\npub struct HeadInfo {\n    pub branch: String,\n    pub oid: String,\n}\n\n#[derive(Debug, Clone)]\npub struct Commit(git2::Oid);\n\nimpl Commit {\n    pub fn new(id: git2::Oid) -> Self {\n        Self(id)\n    }\n    pub fn as_oid(&self) -> git2::Oid {\n        self.0\n    }\n}\n\nimpl std::fmt::Display for Commit {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\n#[derive(Debug, Clone, Copy)]\npub struct WorktreeResetOptions {\n    pub perform_reset: bool,\n    pub force_when_dirty: bool,\n    pub is_dirty: bool,\n    pub log_skip_when_dirty: bool,\n}\n\nimpl WorktreeResetOptions {\n    pub fn new(\n        perform_reset: bool,\n        force_when_dirty: bool,\n        is_dirty: bool,\n        log_skip_when_dirty: bool,\n    ) -> Self {\n        Self {\n            perform_reset,\n            force_when_dirty,\n            is_dirty,\n            log_skip_when_dirty,\n        }\n    }\n}\n\n#[derive(Debug, Default, Clone, Copy)]\npub struct WorktreeResetOutcome {\n    pub needed: bool,\n    pub applied: bool,\n}\n\n/// Target for diff generation\npub enum DiffTarget<'p> {\n    /// Work-in-progress branch checked out in this worktree\n    Worktree {\n        worktree_path: &'p Path,\n        base_commit: &'p Commit,\n    },\n    /// Fully committed branch vs base branch\n    Branch {\n        repo_path: &'p Path,\n        branch_name: &'p str,\n        base_branch: &'p str,\n    },\n    /// Specific commit vs base branch\n    Commit {\n        repo_path: &'p Path,\n        commit_sha: &'p str,\n    },\n}\n\nimpl Default for GitService {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl GitService {\n    /// Create a new GitService for the given repository path\n    pub fn new() -> Self {\n        Self {}\n    }\n\n    pub fn is_branch_name_valid(&self, name: &str) -> bool {\n        git2::Branch::name_is_valid(name).unwrap_or(false)\n    }\n\n    /// Open the repository\n    pub fn open_repo(&self, repo_path: &Path) -> Result<Repository, GitServiceError> {\n        Repository::open(repo_path).map_err(GitServiceError::from)\n    }\n\n    /// Ensure local (repo-scoped) identity exists for CLI commits.\n    /// Sets user.name/email only if missing in the repo config.\n    fn ensure_cli_commit_identity(&self, repo_path: &Path) -> Result<(), GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        let cfg = repo.config()?;\n        let has_name = cfg.get_string(\"user.name\").is_ok();\n        let has_email = cfg.get_string(\"user.email\").is_ok();\n        if !(has_name && has_email) {\n            let mut cfg = repo.config()?;\n            cfg.set_str(\"user.name\", \"Vibe Kanban\")?;\n            cfg.set_str(\"user.email\", \"noreply@vibekanban.com\")?;\n        }\n        Ok(())\n    }\n\n    /// Get a signature for libgit2 commits with a safe fallback identity.\n    fn signature_with_fallback<'a>(\n        &self,\n        repo: &'a Repository,\n    ) -> Result<git2::Signature<'a>, GitServiceError> {\n        match repo.signature() {\n            Ok(sig) => Ok(sig),\n            Err(_) => git2::Signature::now(\"Vibe Kanban\", \"noreply@vibekanban.com\")\n                .map_err(GitServiceError::from),\n        }\n    }\n\n    fn default_remote(\n        &self,\n        repo: &Repository,\n        repo_path: &Path,\n    ) -> Result<GitRemote, GitServiceError> {\n        let mut remotes = GitCli::new().list_remotes(repo_path)?;\n\n        // Check for pushDefault config\n        if let Ok(config) = repo.config()\n            && let Ok(default_name) = config.get_string(\"remote.pushDefault\")\n            && let Some(idx) = remotes.iter().position(|(name, _)| name == &default_name)\n        {\n            let (name, url) = remotes.swap_remove(idx);\n            return Ok(GitRemote { name, url });\n        }\n\n        // Fall back to first remote\n        remotes\n            .into_iter()\n            .next()\n            .map(|(name, url)| GitRemote { name, url })\n            .ok_or_else(|| GitServiceError::InvalidRepository(\"No remotes configured\".to_string()))\n    }\n\n    /// Initialize a new git repository with a main branch and initial commit\n    pub fn initialize_repo_with_main_branch(\n        &self,\n        repo_path: &Path,\n    ) -> Result<(), GitServiceError> {\n        // Create directory if it doesn't exist\n        if !repo_path.exists() {\n            std::fs::create_dir_all(repo_path)?;\n        }\n\n        // Initialize git repository with main branch\n        let repo = Repository::init_opts(\n            repo_path,\n            git2::RepositoryInitOptions::new()\n                .initial_head(\"main\")\n                .mkdir(true),\n        )?;\n\n        // Create initial commit\n        self.create_initial_commit(&repo)?;\n\n        Ok(())\n    }\n\n    /// Ensure an existing repository has a main branch (for empty repos)\n    pub fn ensure_main_branch_exists(&self, repo_path: &Path) -> Result<(), GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n\n        match repo.branches(None) {\n            Ok(branches) => {\n                if branches.count() == 0 {\n                    // No branches exist - create initial commit on main branch\n                    self.create_initial_commit(&repo)?;\n                }\n            }\n            Err(e) => {\n                return Err(GitServiceError::InvalidRepository(format!(\n                    \"Failed to list branches: {e}\"\n                )));\n            }\n        }\n        Ok(())\n    }\n\n    pub fn create_initial_commit(&self, repo: &Repository) -> Result<(), GitServiceError> {\n        let signature = self.signature_with_fallback(repo)?;\n\n        let tree_id = {\n            let tree_builder = repo.treebuilder(None)?;\n            tree_builder.write()?\n        };\n        let tree = repo.find_tree(tree_id)?;\n\n        // Create initial commit on main branch\n        let _commit_id = repo.commit(\n            Some(\"refs/heads/main\"),\n            &signature,\n            &signature,\n            \"Initial commit\",\n            &tree,\n            &[],\n        )?;\n\n        // Set HEAD to point to main branch\n        repo.set_head(\"refs/heads/main\")?;\n\n        Ok(())\n    }\n\n    pub fn commit(&self, path: &Path, message: &str) -> Result<bool, GitServiceError> {\n        // Use Git CLI to respect sparse-checkout semantics for staging and commit\n        let git = GitCli::new();\n        let has_changes = git\n            .has_changes(path)\n            .map_err(|e| GitServiceError::InvalidRepository(format!(\"git status failed: {e}\")))?;\n        if !has_changes {\n            tracing::debug!(\"No changes to commit!\");\n            return Ok(false);\n        }\n\n        git.add_all(path)\n            .map_err(|e| GitServiceError::InvalidRepository(format!(\"git add failed: {e}\")))?;\n        // Only ensure identity once we know we're about to commit\n        self.ensure_cli_commit_identity(path)?;\n        git.commit(path, message)\n            .map_err(|e| GitServiceError::InvalidRepository(format!(\"git commit failed: {e}\")))?;\n        Ok(true)\n    }\n\n    /// Get diffs between branches or worktree changes\n    pub fn get_diffs(\n        &self,\n        target: DiffTarget,\n        path_filter: Option<&[&str]>,\n    ) -> Result<Vec<Diff>, GitServiceError> {\n        match target {\n            DiffTarget::Worktree {\n                worktree_path,\n                base_commit,\n            } => {\n                // Use Git CLI to compute diff vs base to avoid sparse false deletions\n                let repo = Repository::open(worktree_path)?;\n                let base_tree = repo\n                    .find_commit(base_commit.as_oid())?\n                    .tree()\n                    .map_err(|e| {\n                        GitServiceError::InvalidRepository(format!(\n                            \"Failed to find base commit tree: {e}\"\n                        ))\n                    })?;\n\n                let git = GitCli::new();\n                let cli_opts = StatusDiffOptions {\n                    path_filter: path_filter.map(|fs| fs.iter().map(|s| s.to_string()).collect()),\n                };\n                let entries = git\n                    .diff_status(worktree_path, base_commit, cli_opts)\n                    .map_err(|e| {\n                        GitServiceError::InvalidRepository(format!(\"git diff failed: {e}\"))\n                    })?;\n                Ok(entries\n                    .into_iter()\n                    .map(|e| Self::status_entry_to_diff(&repo, &base_tree, e))\n                    .collect())\n            }\n            DiffTarget::Branch {\n                repo_path,\n                branch_name,\n                base_branch,\n            } => {\n                let repo = self.open_repo(repo_path)?;\n                let base_tree = Self::find_branch(&repo, base_branch)?\n                    .get()\n                    .peel_to_commit()?\n                    .tree()?;\n                let branch_tree = Self::find_branch(&repo, branch_name)?\n                    .get()\n                    .peel_to_commit()?\n                    .tree()?;\n\n                let mut diff_opts = DiffOptions::new();\n                diff_opts.include_typechange(true);\n\n                // Add path filtering if specified\n                if let Some(paths) = path_filter {\n                    for path in paths {\n                        diff_opts.pathspec(*path);\n                    }\n                }\n\n                let mut diff = repo.diff_tree_to_tree(\n                    Some(&base_tree),\n                    Some(&branch_tree),\n                    Some(&mut diff_opts),\n                )?;\n\n                // Enable rename detection\n                let mut find_opts = DiffFindOptions::new();\n                diff.find_similar(Some(&mut find_opts))?;\n\n                self.convert_diff_to_file_diffs(diff, &repo)\n            }\n            DiffTarget::Commit {\n                repo_path,\n                commit_sha,\n            } => {\n                let repo = self.open_repo(repo_path)?;\n\n                // Resolve commit and its baseline (the parent before the squash landed)\n                let commit_oid = git2::Oid::from_str(commit_sha).map_err(|_| {\n                    GitServiceError::InvalidRepository(format!(\"Invalid commit SHA: {commit_sha}\"))\n                })?;\n                let commit = repo.find_commit(commit_oid)?;\n                let parent = commit.parent(0).map_err(|_| {\n                    GitServiceError::InvalidRepository(\n                        \"Commit has no parent; cannot diff a squash merge without a baseline\"\n                            .into(),\n                    )\n                })?;\n\n                let parent_tree = parent.tree()?;\n                let commit_tree = commit.tree()?;\n\n                // Diff options\n                let mut diff_opts = git2::DiffOptions::new();\n                diff_opts.include_typechange(true);\n\n                // Optional path filtering\n                if let Some(paths) = path_filter {\n                    for path in paths {\n                        diff_opts.pathspec(*path);\n                    }\n                }\n\n                // Compute the diff parent -> commit\n                let mut diff = repo.diff_tree_to_tree(\n                    Some(&parent_tree),\n                    Some(&commit_tree),\n                    Some(&mut diff_opts),\n                )?;\n\n                // Enable rename detection\n                let mut find_opts = git2::DiffFindOptions::new();\n                diff.find_similar(Some(&mut find_opts))?;\n\n                self.convert_diff_to_file_diffs(diff, &repo)\n            }\n        }\n    }\n\n    /// Convert git2::Diff to our Diff structs\n    fn convert_diff_to_file_diffs(\n        &self,\n        diff: git2::Diff,\n        repo: &Repository,\n    ) -> Result<Vec<Diff>, GitServiceError> {\n        let mut file_diffs = Vec::new();\n\n        let mut delta_index: usize = 0;\n        diff.foreach(\n            &mut |delta, _| {\n                if delta.status() == Delta::Unreadable {\n                    return true;\n                }\n\n                let status = delta.status();\n\n                // Decide if we should omit content due to size\n                let mut content_omitted = false;\n                // Check old blob size when applicable\n                if !matches!(status, Delta::Added) {\n                    let oid = delta.old_file().id();\n                    if !oid.is_zero()\n                        && let Ok(blob) = repo.find_blob(oid)\n                        && !blob.is_binary()\n                        && blob.size() > MAX_INLINE_DIFF_BYTES\n                    {\n                        content_omitted = true;\n                    }\n                }\n                // Check new blob size when applicable\n                if !matches!(status, Delta::Deleted) {\n                    let oid = delta.new_file().id();\n                    if !oid.is_zero()\n                        && let Ok(blob) = repo.find_blob(oid)\n                        && !blob.is_binary()\n                        && blob.size() > MAX_INLINE_DIFF_BYTES\n                    {\n                        content_omitted = true;\n                    }\n                }\n\n                // Only build old/new content if not omitted\n                let (old_path, old_content) = if matches!(status, Delta::Added) {\n                    (None, None)\n                } else {\n                    let path_opt = delta\n                        .old_file()\n                        .path()\n                        .map(|p| p.to_string_lossy().to_string());\n                    if content_omitted {\n                        (path_opt, None)\n                    } else {\n                        let details = delta\n                            .old_file()\n                            .path()\n                            .map(|p| self.create_file_details(p, &delta.old_file().id(), repo));\n                        (\n                            details.as_ref().and_then(|f| f.file_name.clone()),\n                            details.and_then(|f| f.content),\n                        )\n                    }\n                };\n\n                let (new_path, new_content) = if matches!(status, Delta::Deleted) {\n                    (None, None)\n                } else {\n                    let path_opt = delta\n                        .new_file()\n                        .path()\n                        .map(|p| p.to_string_lossy().to_string());\n                    if content_omitted {\n                        (path_opt, None)\n                    } else {\n                        let details = delta\n                            .new_file()\n                            .path()\n                            .map(|p| self.create_file_details(p, &delta.new_file().id(), repo));\n                        (\n                            details.as_ref().and_then(|f| f.file_name.clone()),\n                            details.and_then(|f| f.content),\n                        )\n                    }\n                };\n\n                let mut change = match status {\n                    Delta::Added => DiffChangeKind::Added,\n                    Delta::Deleted => DiffChangeKind::Deleted,\n                    Delta::Modified => DiffChangeKind::Modified,\n                    Delta::Renamed => DiffChangeKind::Renamed,\n                    Delta::Copied => DiffChangeKind::Copied,\n                    Delta::Untracked => DiffChangeKind::Added,\n                    _ => DiffChangeKind::Modified,\n                };\n\n                // Detect pure mode changes (e.g., chmod +/-x) and classify as PermissionChange\n                if matches!(status, Delta::Modified)\n                    && delta.old_file().mode() != delta.new_file().mode()\n                {\n                    // Only downgrade to PermissionChange if we KNOW content is unchanged\n                    if old_content.is_some() && new_content.is_some() && old_content == new_content\n                    {\n                        change = DiffChangeKind::PermissionChange;\n                    }\n                }\n\n                // Always compute line stats via libgit2 Patch\n                let (additions, deletions) = if let Ok(Some(patch)) =\n                    git2::Patch::from_diff(&diff, delta_index)\n                    && let Ok((_ctx, adds, dels)) = patch.line_stats()\n                {\n                    (Some(adds), Some(dels))\n                } else {\n                    (None, None)\n                };\n\n                file_diffs.push(Diff {\n                    change,\n                    old_path,\n                    new_path,\n                    old_content,\n                    new_content,\n                    content_omitted,\n                    additions,\n                    deletions,\n                    repo_id: None,\n                });\n\n                delta_index += 1;\n                true\n            },\n            None,\n            None,\n            None,\n        )?;\n\n        Ok(file_diffs)\n    }\n\n    /// Extract file path from a Diff (for indexing and ConversationPatch)\n    pub fn diff_path(diff: &Diff) -> String {\n        diff.new_path\n            .clone()\n            .or_else(|| diff.old_path.clone())\n            .unwrap_or_default()\n    }\n\n    /// Helper function to convert blob to string content\n    fn blob_to_string(blob: &git2::Blob) -> Option<String> {\n        if blob.is_binary() {\n            None // Skip binary files\n        } else {\n            std::str::from_utf8(blob.content())\n                .ok()\n                .map(|s| s.to_string())\n        }\n    }\n\n    /// Helper function to read file content from filesystem with safety guards\n    fn read_file_to_string(repo: &Repository, rel_path: &Path) -> Option<String> {\n        let workdir = repo.workdir()?;\n        let abs_path = workdir.join(rel_path);\n\n        // Read file from filesystem\n        let bytes = match std::fs::read(&abs_path) {\n            Ok(bytes) => bytes,\n            Err(e) => {\n                tracing::debug!(\"Failed to read file from filesystem: {:?}: {}\", abs_path, e);\n                return None;\n            }\n        };\n\n        // Size guard - skip files larger than UI inline threshold\n        if bytes.len() > MAX_INLINE_DIFF_BYTES {\n            tracing::debug!(\n                \"Skipping large file ({}KB): {:?}\",\n                bytes.len() / 1024,\n                abs_path\n            );\n            return None;\n        }\n\n        // Binary guard - skip files containing null bytes\n        if bytes.contains(&0) {\n            tracing::debug!(\"Skipping binary file: {:?}\", abs_path);\n            return None;\n        }\n\n        // UTF-8 validation\n        match String::from_utf8(bytes) {\n            Ok(content) => Some(content),\n            Err(e) => {\n                tracing::debug!(\"File is not valid UTF-8: {:?}: {}\", abs_path, e);\n                None\n            }\n        }\n    }\n\n    /// Create FileDiffDetails from path and blob with filesystem fallback\n    fn create_file_details(\n        &self,\n        path: &Path,\n        blob_id: &git2::Oid,\n        repo: &Repository,\n    ) -> FileDiffDetails {\n        let file_name = path.to_string_lossy().to_string();\n\n        // Try to get content from blob first (for non-zero OIDs)\n        let content = if !blob_id.is_zero() {\n            repo.find_blob(*blob_id)\n                .ok()\n                .and_then(|blob| Self::blob_to_string(&blob))\n                .or_else(|| {\n                    // Fallback to filesystem for unstaged changes\n                    tracing::debug!(\n                        \"Blob not found for non-zero OID, reading from filesystem: {}\",\n                        file_name\n                    );\n                    Self::read_file_to_string(repo, path)\n                })\n        } else {\n            // For zero OIDs, check filesystem directly (covers new/untracked files)\n            Self::read_file_to_string(repo, path)\n        };\n\n        FileDiffDetails {\n            file_name: Some(file_name),\n            content,\n        }\n    }\n\n    /// Create Diff entries from git_cli::StatusDiffEntry\n    /// New Diff format is flattened with change kind, paths, and optional contents.\n    fn status_entry_to_diff(repo: &Repository, base_tree: &git2::Tree, e: StatusDiffEntry) -> Diff {\n        // Map ChangeType to DiffChangeKind\n        let mut change = match e.change {\n            ChangeType::Added => DiffChangeKind::Added,\n            ChangeType::Deleted => DiffChangeKind::Deleted,\n            ChangeType::Modified => DiffChangeKind::Modified,\n            ChangeType::Renamed => DiffChangeKind::Renamed,\n            ChangeType::Copied => DiffChangeKind::Copied,\n            // Treat type changes and unmerged as modified for now\n            ChangeType::TypeChanged | ChangeType::Unmerged => DiffChangeKind::Modified,\n            ChangeType::Unknown(_) => DiffChangeKind::Modified,\n        };\n\n        // Determine old/new paths based on change\n        let (old_path_opt, new_path_opt): (Option<String>, Option<String>) = match e.change {\n            ChangeType::Added => (None, Some(e.path.clone())),\n            ChangeType::Deleted => (Some(e.old_path.unwrap_or(e.path.clone())), None),\n            ChangeType::Modified | ChangeType::TypeChanged | ChangeType::Unmerged => (\n                Some(e.old_path.unwrap_or(e.path.clone())),\n                Some(e.path.clone()),\n            ),\n            ChangeType::Renamed | ChangeType::Copied => (e.old_path.clone(), Some(e.path.clone())),\n            ChangeType::Unknown(_) => (e.old_path.clone(), Some(e.path.clone())),\n        };\n\n        // Decide if we should omit content by size (either side)\n        let mut content_omitted = false;\n        // Old side (from base tree)\n        if let Some(ref oldp) = old_path_opt {\n            let rel = std::path::Path::new(oldp);\n            if let Ok(entry) = base_tree.get_path(rel)\n                && entry.kind() == Some(git2::ObjectType::Blob)\n                && let Ok(blob) = repo.find_blob(entry.id())\n                && !blob.is_binary()\n                && blob.size() > MAX_INLINE_DIFF_BYTES\n            {\n                content_omitted = true;\n            }\n        }\n        // New side (from filesystem)\n        if let Some(ref newp) = new_path_opt\n            && let Some(workdir) = repo.workdir()\n        {\n            let abs = workdir.join(newp);\n            if let Ok(md) = std::fs::metadata(&abs)\n                && (md.len() as usize) > MAX_INLINE_DIFF_BYTES\n            {\n                content_omitted = true;\n            }\n        }\n\n        // Load contents only if not omitted\n        let (old_content, new_content) = if content_omitted {\n            (None, None)\n        } else {\n            // Load old content from base tree if possible\n            let old_content = if let Some(ref oldp) = old_path_opt {\n                let rel = std::path::Path::new(oldp);\n                match base_tree.get_path(rel) {\n                    Ok(entry) if entry.kind() == Some(git2::ObjectType::Blob) => repo\n                        .find_blob(entry.id())\n                        .ok()\n                        .and_then(|b| Self::blob_to_string(&b)),\n                    _ => None,\n                }\n            } else {\n                None\n            };\n\n            // Load new content from filesystem (worktree) when available\n            let new_content = if let Some(ref newp) = new_path_opt {\n                let rel = std::path::Path::new(newp);\n                Self::read_file_to_string(repo, rel)\n            } else {\n                None\n            };\n            (old_content, new_content)\n        };\n\n        // If reported as Modified but content is identical, treat as a permission-only change\n        if matches!(change, DiffChangeKind::Modified)\n            && old_content.is_some()\n            && new_content.is_some()\n            && old_content == new_content\n        {\n            change = DiffChangeKind::PermissionChange;\n        }\n\n        // Compute line stats from available content\n        let (additions, deletions) = match (&old_content, &new_content) {\n            (Some(old), Some(new)) => {\n                let (adds, dels) = compute_line_change_counts(old, new);\n                (Some(adds), Some(dels))\n            }\n            (Some(old), None) => {\n                // File deleted - all lines are deletions\n                (Some(0), Some(old.lines().count()))\n            }\n            (None, Some(new)) => {\n                // File added - all lines are additions\n                (Some(new.lines().count()), Some(0))\n            }\n            (None, None) => (None, None),\n        };\n\n        Diff {\n            change,\n            old_path: old_path_opt,\n            new_path: new_path_opt,\n            old_content,\n            new_content,\n            content_omitted,\n            additions,\n            deletions,\n            repo_id: None,\n        }\n    }\n\n    /// Find where a branch is currently checked out\n    fn find_checkout_path_for_branch(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n    ) -> Result<Option<std::path::PathBuf>, GitServiceError> {\n        let git_cli = GitCli::new();\n        let worktrees = git_cli.list_worktrees(repo_path).map_err(|e| {\n            GitServiceError::InvalidRepository(format!(\"git worktree list failed: {e}\"))\n        })?;\n\n        for worktree in worktrees {\n            if let Some(ref branch) = worktree.branch\n                && branch == branch_name\n            {\n                return Ok(Some(std::path::PathBuf::from(worktree.path)));\n            }\n        }\n        Ok(None)\n    }\n\n    /// Merge changes from a task branch into the base branch.\n    pub fn merge_changes(\n        &self,\n        base_worktree_path: &Path,\n        task_worktree_path: &Path,\n        task_branch_name: &str,\n        base_branch_name: &str,\n        commit_message: &str,\n    ) -> Result<String, GitServiceError> {\n        // Open the repositories\n        let task_repo = self.open_repo(task_worktree_path)?;\n        let base_repo = self.open_repo(base_worktree_path)?;\n\n        // Check if base branch is ahead of task branch - this indicates the base has moved\n        // ahead since the task was created, which should block the merge\n        let (_, task_behind) =\n            self.get_branch_status(base_worktree_path, task_branch_name, base_branch_name)?;\n\n        if task_behind > 0 {\n            return Err(GitServiceError::BranchesDiverged(format!(\n                \"Cannot merge: base branch '{base_branch_name}' is {task_behind} commits ahead of task branch '{task_branch_name}'. The base branch has moved forward since the task was created.\",\n            )));\n        }\n\n        // Check where base branch is checked out (if anywhere)\n        match self.find_checkout_path_for_branch(base_worktree_path, base_branch_name)? {\n            Some(base_checkout_path) => {\n                // base branch is checked out somewhere - use CLI merge\n                let git_cli = GitCli::new();\n\n                // Safety check: base branch has no staged changes\n                if git_cli\n                    .has_staged_changes(&base_checkout_path)\n                    .map_err(|e| {\n                        GitServiceError::InvalidRepository(format!(\"git diff --cached failed: {e}\"))\n                    })?\n                {\n                    return Err(GitServiceError::WorktreeDirty(\n                        base_branch_name.to_string(),\n                        \"staged changes present\".to_string(),\n                    ));\n                }\n\n                // Use CLI merge in base context\n                self.ensure_cli_commit_identity(&base_checkout_path)?;\n                let sha = git_cli\n                    .merge_squash_commit(\n                        &base_checkout_path,\n                        base_branch_name,\n                        task_branch_name,\n                        commit_message,\n                    )\n                    .map_err(|e| {\n                        GitServiceError::InvalidRepository(format!(\"CLI merge failed: {e}\"))\n                    })?;\n\n                // Update task branch ref for continuity\n                let task_refname = format!(\"refs/heads/{task_branch_name}\");\n                git_cli\n                    .update_ref(base_worktree_path, &task_refname, &sha)\n                    .map_err(|e| {\n                        GitServiceError::InvalidRepository(format!(\"git update-ref failed: {e}\"))\n                    })?;\n\n                Ok(sha)\n            }\n            None => {\n                // base branch not checked out anywhere - use libgit2 pure ref operations\n                let task_branch = Self::find_branch(&task_repo, task_branch_name)?;\n                let base_branch = Self::find_branch(&task_repo, base_branch_name)?;\n\n                // Resolve commits\n                let base_commit = base_branch.get().peel_to_commit()?;\n                let task_commit = task_branch.get().peel_to_commit()?;\n\n                // Create the squash commit in-memory (no checkout) and update the base branch ref\n                let signature = self.signature_with_fallback(&task_repo)?;\n                let squash_commit_id = self.perform_squash_merge(\n                    &task_repo,\n                    &base_commit,\n                    &task_commit,\n                    &signature,\n                    commit_message,\n                    base_branch_name,\n                )?;\n\n                // Update the task branch to the new squash commit so follow-up\n                // work can continue from the merged state without conflicts.\n                let task_refname = format!(\"refs/heads/{task_branch_name}\");\n                base_repo.reference(\n                    &task_refname,\n                    squash_commit_id,\n                    true,\n                    \"Reset task branch after squash merge\",\n                )?;\n\n                Ok(squash_commit_id.to_string())\n            }\n        }\n    }\n    fn get_branch_status_inner(\n        &self,\n        repo: &Repository,\n        branch_ref: &Reference,\n        base_branch_ref: &Reference,\n    ) -> Result<(usize, usize), GitServiceError> {\n        let (a, b) = repo.graph_ahead_behind(\n            branch_ref.target().ok_or(GitServiceError::BranchNotFound(\n                \"Branch not found\".to_string(),\n            ))?,\n            base_branch_ref\n                .target()\n                .ok_or(GitServiceError::BranchNotFound(\n                    \"Branch not found\".to_string(),\n                ))?,\n        )?;\n        Ok((a, b))\n    }\n\n    pub fn get_branch_status(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n        base_branch_name: &str,\n    ) -> Result<(usize, usize), GitServiceError> {\n        let repo = Repository::open(repo_path)?;\n        let branch = Self::find_branch(&repo, branch_name)?;\n        let base_branch = Self::find_branch(&repo, base_branch_name)?;\n        self.get_branch_status_inner(\n            &repo,\n            &branch.into_reference(),\n            &base_branch.into_reference(),\n        )\n    }\n\n    pub fn get_base_commit(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n        base_branch_name: &str,\n    ) -> Result<Commit, GitServiceError> {\n        let repo = Repository::open(repo_path)?;\n        let branch = Self::find_branch(&repo, branch_name)?;\n        let base_branch = Self::find_branch(&repo, base_branch_name)?;\n        // Find the common ancestor (merge base)\n        let oid = repo\n            .merge_base(\n                branch.get().peel_to_commit()?.id(),\n                base_branch.get().peel_to_commit()?.id(),\n            )\n            .map_err(GitServiceError::from)?;\n        Ok(Commit::new(oid))\n    }\n\n    pub fn get_remote_branch_status(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n        base_branch_name: Option<&str>,\n    ) -> Result<(usize, usize), GitServiceError> {\n        let repo = Repository::open(repo_path)?;\n        let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference();\n        // base branch is either given or upstream of branch_name\n        let base_branch_ref = if let Some(bn) = base_branch_name {\n            Self::find_branch(&repo, bn)?\n        } else {\n            repo.find_branch(branch_name, BranchType::Local)?\n                .upstream()?\n        }\n        .into_reference();\n        let remote = self.get_remote_from_branch_ref(&repo, &base_branch_ref)?;\n        self.fetch_all_from_remote(&repo, &remote)?;\n        self.get_branch_status_inner(&repo, &branch_ref, &base_branch_ref)\n    }\n\n    pub fn is_worktree_clean(&self, worktree_path: &Path) -> Result<bool, GitServiceError> {\n        let repo = self.open_repo(worktree_path)?;\n        match self.check_worktree_clean(&repo) {\n            Ok(()) => Ok(true),\n            Err(GitServiceError::WorktreeDirty(_, _)) => Ok(false),\n            Err(e) => Err(e),\n        }\n    }\n\n    /// Check if the worktree is clean (no uncommitted changes to tracked files)\n    fn check_worktree_clean(&self, repo: &Repository) -> Result<(), GitServiceError> {\n        let mut status_options = git2::StatusOptions::new();\n        status_options\n            .include_untracked(false) // Don't include untracked files\n            .include_ignored(false); // Don't include ignored files\n\n        let statuses = repo.statuses(Some(&mut status_options))?;\n\n        if !statuses.is_empty() {\n            let mut dirty_files = Vec::new();\n            for entry in statuses.iter() {\n                let status = entry.status();\n                // Only consider files that are actually tracked and modified\n                if status.intersects(\n                    git2::Status::INDEX_MODIFIED\n                        | git2::Status::INDEX_NEW\n                        | git2::Status::INDEX_DELETED\n                        | git2::Status::INDEX_RENAMED\n                        | git2::Status::INDEX_TYPECHANGE\n                        | git2::Status::WT_MODIFIED\n                        | git2::Status::WT_DELETED\n                        | git2::Status::WT_RENAMED\n                        | git2::Status::WT_TYPECHANGE,\n                ) && let Some(path) = entry.path()\n                {\n                    dirty_files.push(path.to_string());\n                }\n            }\n\n            if !dirty_files.is_empty() {\n                let branch_name = repo\n                    .head()\n                    .ok()\n                    .and_then(|h| h.shorthand().map(|s| s.to_string()))\n                    .unwrap_or_else(|| \"unknown branch\".to_string());\n                return Err(GitServiceError::WorktreeDirty(\n                    branch_name,\n                    dirty_files.join(\", \"),\n                ));\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Get current HEAD information including branch name and commit OID\n    pub fn get_head_info(&self, repo_path: &Path) -> Result<HeadInfo, GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        let head = repo.head()?;\n\n        let branch = if let Some(branch_name) = head.shorthand() {\n            branch_name.to_string()\n        } else {\n            \"HEAD\".to_string()\n        };\n\n        let oid = if let Some(target_oid) = head.target() {\n            target_oid.to_string()\n        } else {\n            // Handle case where HEAD exists but has no target (empty repo)\n            return Err(GitServiceError::InvalidRepository(\n                \"Repository HEAD has no target commit\".to_string(),\n            ));\n        };\n\n        Ok(HeadInfo { branch, oid })\n    }\n\n    pub fn get_current_branch(&self, repo_path: &Path) -> Result<String, git2::Error> {\n        // Thin wrapper for backward compatibility\n        match self.get_head_info(repo_path) {\n            Ok(head_info) => Ok(head_info.branch),\n            Err(GitServiceError::Git(git_err)) => Err(git_err),\n            Err(_) => Err(git2::Error::from_str(\"Failed to get head info\")),\n        }\n    }\n\n    /// Get the commit OID (as hex string) for a given branch without modifying HEAD\n    pub fn get_branch_oid(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n    ) -> Result<String, GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        let branch = Self::find_branch(&repo, branch_name)?;\n        let oid = branch.get().peel_to_commit()?.id().to_string();\n        Ok(oid)\n    }\n\n    pub fn get_fork_point(\n        &self,\n        worktree_path: &Path,\n        target_branch: &str,\n        task_branch: &str,\n    ) -> Result<String, GitServiceError> {\n        let git = GitCli::new();\n        Ok(git.merge_base(worktree_path, target_branch, task_branch)?)\n    }\n\n    /// Get the subject/summary line for a given commit OID\n    pub fn get_commit_subject(\n        &self,\n        repo_path: &Path,\n        commit_sha: &str,\n    ) -> Result<String, GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        let oid = git2::Oid::from_str(commit_sha)\n            .map_err(|_| GitServiceError::InvalidRepository(\"Invalid commit SHA\".into()))?;\n        let commit = repo.find_commit(oid)?;\n        Ok(commit.summary().unwrap_or(\"(no subject)\").to_string())\n    }\n\n    /// Compare two OIDs and return (ahead, behind) counts: how many commits\n    /// `from_oid` is ahead of and behind `to_oid`.\n    pub fn ahead_behind_commits_by_oid(\n        &self,\n        repo_path: &Path,\n        from_oid: &str,\n        to_oid: &str,\n    ) -> Result<(usize, usize), GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        let from = git2::Oid::from_str(from_oid)\n            .map_err(|_| GitServiceError::InvalidRepository(\"Invalid from OID\".into()))?;\n        let to = git2::Oid::from_str(to_oid)\n            .map_err(|_| GitServiceError::InvalidRepository(\"Invalid to OID\".into()))?;\n        let (ahead, behind) = repo.graph_ahead_behind(from, to)?;\n        Ok((ahead, behind))\n    }\n\n    /// Return the full worktree status including all entries\n    pub fn get_worktree_status(\n        &self,\n        worktree_path: &Path,\n    ) -> Result<WorktreeStatus, GitServiceError> {\n        let cli = GitCli::new();\n        cli.get_worktree_status(worktree_path)\n            .map_err(|e| GitServiceError::InvalidRepository(format!(\"git status failed: {e}\")))\n    }\n\n    /// Return (uncommitted_tracked_changes, untracked_files) counts in worktree\n    pub fn get_worktree_change_counts(\n        &self,\n        worktree_path: &Path,\n    ) -> Result<(usize, usize), GitServiceError> {\n        let st = self.get_worktree_status(worktree_path)?;\n        Ok((st.uncommitted_tracked, st.untracked))\n    }\n\n    /// Evaluate whether any action is needed to reset to `target_commit_oid` and\n    /// optionally perform the actions.\n    pub fn reconcile_worktree_to_commit(\n        &self,\n        worktree_path: &Path,\n        target_commit_oid: &str,\n        options: WorktreeResetOptions,\n    ) -> WorktreeResetOutcome {\n        let WorktreeResetOptions {\n            perform_reset,\n            force_when_dirty,\n            is_dirty,\n            log_skip_when_dirty,\n        } = options;\n\n        let head_oid = self.get_head_info(worktree_path).ok().map(|h| h.oid);\n        let mut outcome = WorktreeResetOutcome::default();\n\n        if head_oid.as_deref() != Some(target_commit_oid) || is_dirty {\n            outcome.needed = true;\n\n            if perform_reset {\n                if is_dirty && !force_when_dirty {\n                    if log_skip_when_dirty {\n                        tracing::warn!(\"Worktree dirty; skipping reset as not forced\");\n                    }\n                } else if let Err(e) = self.reset_worktree_to_commit(\n                    worktree_path,\n                    target_commit_oid,\n                    force_when_dirty,\n                ) {\n                    tracing::error!(\"Failed to reset worktree: {}\", e);\n                } else {\n                    outcome.applied = true;\n                }\n            }\n        }\n\n        outcome\n    }\n\n    /// Reset the given worktree to the specified commit SHA.\n    /// If `force` is false and the worktree is dirty, returns WorktreeDirty error.\n    pub fn reset_worktree_to_commit(\n        &self,\n        worktree_path: &Path,\n        commit_sha: &str,\n        force: bool,\n    ) -> Result<(), GitServiceError> {\n        let repo = self.open_repo(worktree_path)?;\n        if !force {\n            // Avoid clobbering uncommitted changes unless explicitly forced\n            self.check_worktree_clean(&repo)?;\n        }\n        let cli = GitCli::new();\n        cli.git(worktree_path, [\"reset\", \"--hard\", commit_sha])\n            .map_err(|e| {\n                GitServiceError::InvalidRepository(format!(\"git reset --hard failed: {e}\"))\n            })?;\n        if force {\n            cli.git(worktree_path, [\"clean\", \"-fd\"]).map_err(|e| {\n                GitServiceError::InvalidRepository(format!(\"git clean -fd failed: {e}\"))\n            })?;\n        }\n        // Reapply sparse-checkout if configured (non-fatal)\n        let _ = cli.git(worktree_path, [\"sparse-checkout\", \"reapply\"]);\n        Ok(())\n    }\n\n    /// Add a worktree for a branch, optionally creating the branch\n    pub fn add_worktree(\n        &self,\n        repo_path: &Path,\n        worktree_path: &Path,\n        branch: &str,\n        create_branch: bool,\n    ) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        git.worktree_add(repo_path, worktree_path, branch, create_branch)\n            .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?;\n        Ok(())\n    }\n\n    /// Remove a worktree\n    pub fn remove_worktree(\n        &self,\n        repo_path: &Path,\n        worktree_path: &Path,\n        force: bool,\n    ) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        git.worktree_remove(repo_path, worktree_path, force)\n            .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?;\n        Ok(())\n    }\n\n    /// Move a worktree to a new location\n    pub fn move_worktree(\n        &self,\n        repo_path: &Path,\n        old_path: &Path,\n        new_path: &Path,\n    ) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        git.worktree_move(repo_path, old_path, new_path)\n            .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn prune_worktrees(&self, repo_path: &Path) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        git.worktree_prune(repo_path)\n            .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn delete_branch(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n    ) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        git.delete_branch(repo_path, branch_name)\n            .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn get_all_branches(&self, repo_path: &Path) -> Result<Vec<GitBranch>, git2::Error> {\n        let repo = Repository::open(repo_path)?;\n        let current_branch = self.get_current_branch(repo_path).unwrap_or_default();\n        let mut branches = Vec::new();\n\n        // Helper function to get last commit date for a branch\n        let get_last_commit_date = |branch: &git2::Branch| -> Result<DateTime<Utc>, git2::Error> {\n            if let Some(target) = branch.get().target()\n                && let Ok(commit) = repo.find_commit(target)\n            {\n                let timestamp = commit.time().seconds();\n                return Ok(DateTime::from_timestamp(timestamp, 0).unwrap_or_else(Utc::now));\n            }\n            Ok(Utc::now()) // Default to now if we can't get the commit date\n        };\n\n        // Get local branches\n        let local_branches = repo.branches(Some(BranchType::Local))?;\n        for branch_result in local_branches {\n            let (branch, _) = branch_result?;\n            if let Some(name) = branch.name()? {\n                let last_commit_date = get_last_commit_date(&branch)?;\n                branches.push(GitBranch {\n                    name: name.to_string(),\n                    is_current: name == current_branch,\n                    is_remote: false,\n                    last_commit_date,\n                });\n            }\n        }\n\n        // Get remote branches\n        let remote_branches = repo.branches(Some(BranchType::Remote))?;\n        for branch_result in remote_branches {\n            let (branch, _) = branch_result?;\n            if let Some(name) = branch.name()? {\n                // Skip remote HEAD references\n                if !name.ends_with(\"/HEAD\") {\n                    let last_commit_date = get_last_commit_date(&branch)?;\n                    branches.push(GitBranch {\n                        name: name.to_string(),\n                        is_current: false,\n                        is_remote: true,\n                        last_commit_date,\n                    });\n                }\n            }\n        }\n\n        // Sort branches: current first, then by most recent commit date\n        branches.sort_by(|a, b| {\n            if a.is_current && !b.is_current {\n                std::cmp::Ordering::Less\n            } else if !a.is_current && b.is_current {\n                std::cmp::Ordering::Greater\n            } else {\n                // Sort by most recent commit date (newest first)\n                b.last_commit_date.cmp(&a.last_commit_date)\n            }\n        });\n\n        Ok(branches)\n    }\n\n    /// Perform a squash merge of task branch into base branch, but fail on conflicts\n    fn perform_squash_merge(\n        &self,\n        repo: &Repository,\n        base_commit: &git2::Commit,\n        task_commit: &git2::Commit,\n        signature: &git2::Signature,\n        commit_message: &str,\n        base_branch_name: &str,\n    ) -> Result<git2::Oid, GitServiceError> {\n        // In-memory merge to detect conflicts without touching the working tree\n        let mut merge_opts = git2::MergeOptions::new();\n        // Safety and correctness options\n        merge_opts.find_renames(true); // improve rename handling\n        merge_opts.fail_on_conflict(true); // bail out instead of generating conflicted index\n        let mut index = repo.merge_commits(base_commit, task_commit, Some(&merge_opts))?;\n\n        // If there are conflicts, return an error\n        if index.has_conflicts() {\n            return Err(GitServiceError::MergeConflicts {\n                message: \"Merge failed due to conflicts. Please resolve conflicts manually.\"\n                    .to_string(),\n                conflicted_files: vec![],\n            });\n        }\n\n        // Write the merged tree back to the repository\n        let tree_id = index.write_tree_to(repo)?;\n        let tree = repo.find_tree(tree_id)?;\n\n        // Create a squash commit: use merged tree with base_commit as sole parent\n        let squash_commit_id = repo.commit(\n            None,           // Don't update any reference yet\n            signature,      // Author\n            signature,      // Committer\n            commit_message, // Custom message\n            &tree,          // Merged tree content\n            &[base_commit], // Single parent: base branch commit\n        )?;\n\n        // Update the base branch reference to point to the new commit\n        let refname = format!(\"refs/heads/{base_branch_name}\");\n        repo.reference(&refname, squash_commit_id, true, \"Squash merge\")?;\n\n        Ok(squash_commit_id)\n    }\n\n    /// Rebase a worktree branch onto a new base\n    pub fn rebase_branch(\n        &self,\n        repo_path: &Path,\n        worktree_path: &Path,\n        new_base_branch: &str,\n        old_base_branch: &str,\n        task_branch: &str,\n    ) -> Result<String, GitServiceError> {\n        let worktree_repo = Repository::open(worktree_path)?;\n        let main_repo = self.open_repo(repo_path)?;\n\n        // Safety guard: never operate on a dirty worktree. This preserves any\n        // uncommitted changes to tracked files by failing fast instead of\n        // resetting or cherry-picking over them. Untracked files are allowed.\n        self.check_worktree_clean(&worktree_repo)?;\n\n        // If a rebase is already in progress, refuse to proceed instead of\n        // aborting (which might destroy user changes mid-rebase).\n        let git = GitCli::new();\n        if git.is_rebase_in_progress(worktree_path).unwrap_or(false) {\n            return Err(GitServiceError::RebaseInProgress);\n        }\n\n        // Get the target base branch reference\n        let nbr = Self::find_branch(&main_repo, new_base_branch)?.into_reference();\n        // If the target base is remote, update it first so CLI sees latest\n        if nbr.is_remote() {\n            self.fetch_branch_from_remote(&main_repo, &nbr)?;\n        }\n\n        // Ensure identity for any commits produced by rebase\n        self.ensure_cli_commit_identity(worktree_path)?;\n        // Use git CLI rebase to carry out the operation safely\n        match git.rebase_onto(worktree_path, new_base_branch, old_base_branch, task_branch) {\n            Ok(()) => {}\n            Err(GitCliError::RebaseInProgress) => {\n                return Err(GitServiceError::RebaseInProgress);\n            }\n            Err(GitCliError::CommandFailed(stderr)) => {\n                // If the CLI indicates conflicts, return a concise, actionable error.\n                let looks_like_conflict = stderr.contains(\"could not apply\")\n                    || stderr.contains(\"CONFLICT\")\n                    || stderr.to_lowercase().contains(\"resolve all conflicts\");\n                if looks_like_conflict {\n                    // Determine current attempt branch name for clarity\n                    let attempt_branch = worktree_repo\n                        .head()\n                        .ok()\n                        .and_then(|h| h.shorthand().map(|s| s.to_string()))\n                        .unwrap_or_else(|| \"(unknown)\".to_string());\n                    // List conflicted files (best-effort)\n                    let conflicted_files =\n                        git.get_conflicted_files(worktree_path).unwrap_or_default();\n                    let files_part = if conflicted_files.is_empty() {\n                        \"\".to_string()\n                    } else {\n                        let mut sample = conflicted_files.clone();\n                        let total = sample.len();\n                        sample.truncate(10);\n                        let list = sample.join(\", \");\n                        if total > sample.len() {\n                            format!(\n                                \" Conflicted files (showing {} of {}): {}.\",\n                                sample.len(),\n                                total,\n                                list\n                            )\n                        } else {\n                            format!(\" Conflicted files: {list}.\")\n                        }\n                    };\n                    let msg = format!(\n                        \"Rebase encountered merge conflicts while rebasing '{attempt_branch}' onto '{new_base_branch}'.{files_part} Resolve conflicts and then continue or abort.\"\n                    );\n                    return Err(GitServiceError::MergeConflicts {\n                        message: msg,\n                        conflicted_files,\n                    });\n                }\n                return Err(GitServiceError::InvalidRepository(format!(\n                    \"Rebase failed: {}\",\n                    stderr.lines().next().unwrap_or(\"\")\n                )));\n            }\n            Err(e) => {\n                return Err(GitServiceError::InvalidRepository(format!(\n                    \"git rebase failed: {e}\"\n                )));\n            }\n        }\n\n        // Return resulting HEAD commit\n        let final_commit = worktree_repo.head()?.peel_to_commit()?;\n        Ok(final_commit.id().to_string())\n    }\n\n    pub fn find_branch_type(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n    ) -> Result<BranchType, GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        // Try to find the branch as a local branch first\n        match repo.find_branch(branch_name, BranchType::Local) {\n            Ok(_) => Ok(BranchType::Local),\n            Err(_) => {\n                // If not found, try to find it as a remote branch\n                match repo.find_branch(branch_name, BranchType::Remote) {\n                    Ok(_) => Ok(BranchType::Remote),\n                    Err(_) => Err(GitServiceError::BranchNotFound(branch_name.to_string())),\n                }\n            }\n        }\n    }\n\n    pub fn check_branch_exists(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n    ) -> Result<bool, GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        match repo.find_branch(branch_name, BranchType::Local) {\n            Ok(_) => Ok(true),\n            Err(_) => match repo.find_branch(branch_name, BranchType::Remote) {\n                Ok(_) => Ok(true),\n                Err(_) => Ok(false),\n            },\n        }\n    }\n\n    pub fn rename_local_branch(\n        &self,\n        worktree_path: &Path,\n        old_branch_name: &str,\n        new_branch_name: &str,\n    ) -> Result<(), GitServiceError> {\n        let repo = self.open_repo(worktree_path)?;\n\n        let mut branch = repo\n            .find_branch(old_branch_name, BranchType::Local)\n            .map_err(|_| GitServiceError::BranchNotFound(old_branch_name.to_string()))?;\n\n        branch.rename(new_branch_name, false)?;\n\n        repo.set_head(&format!(\"refs/heads/{new_branch_name}\"))?;\n\n        Ok(())\n    }\n\n    /// Return true if a rebase is currently in progress in this worktree.\n    pub fn is_rebase_in_progress(&self, worktree_path: &Path) -> Result<bool, GitServiceError> {\n        let git = GitCli::new();\n        git.is_rebase_in_progress(worktree_path).map_err(|e| {\n            GitServiceError::InvalidRepository(format!(\"git rebase state check failed: {e}\"))\n        })\n    }\n\n    pub fn detect_conflict_op(\n        &self,\n        worktree_path: &Path,\n    ) -> Result<Option<ConflictOp>, GitServiceError> {\n        let git = GitCli::new();\n        if git.is_rebase_in_progress(worktree_path).unwrap_or(false) {\n            return Ok(Some(ConflictOp::Rebase));\n        }\n        if git.is_merge_in_progress(worktree_path).unwrap_or(false) {\n            return Ok(Some(ConflictOp::Merge));\n        }\n        if git\n            .is_cherry_pick_in_progress(worktree_path)\n            .unwrap_or(false)\n        {\n            return Ok(Some(ConflictOp::CherryPick));\n        }\n        if git.is_revert_in_progress(worktree_path).unwrap_or(false) {\n            return Ok(Some(ConflictOp::Revert));\n        }\n        Ok(None)\n    }\n\n    /// List conflicted (unmerged) files in the worktree.\n    pub fn get_conflicted_files(\n        &self,\n        worktree_path: &Path,\n    ) -> Result<Vec<String>, GitServiceError> {\n        let git = GitCli::new();\n        git.get_conflicted_files(worktree_path).map_err(|e| {\n            GitServiceError::InvalidRepository(format!(\"git diff for conflicts failed: {e}\"))\n        })\n    }\n\n    /// Abort an in-progress rebase in this worktree (no-op if none).\n    pub fn abort_rebase(&self, worktree_path: &Path) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        git.abort_rebase(worktree_path).map_err(|e| {\n            GitServiceError::InvalidRepository(format!(\"git rebase --abort failed: {e}\"))\n        })\n    }\n\n    /// Continue an in-progress rebase. Fails if there are unresolved conflicts.\n    pub fn continue_rebase(&self, worktree_path: &Path) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        git.continue_rebase(worktree_path).map_err(|e| {\n            GitServiceError::InvalidRepository(format!(\"git rebase --continue failed: {e}\"))\n        })\n    }\n\n    pub fn abort_conflicts(&self, worktree_path: &Path) -> Result<(), GitServiceError> {\n        let git = GitCli::new();\n        if git.is_rebase_in_progress(worktree_path).unwrap_or(false) {\n            // If there are no conflicted files, prefer `git rebase --quit` to clean up metadata\n            let has_conflicts = !self\n                .get_conflicted_files(worktree_path)\n                .unwrap_or_default()\n                .is_empty();\n            if has_conflicts {\n                return self.abort_rebase(worktree_path);\n            } else {\n                return git.quit_rebase(worktree_path).map_err(|e| {\n                    GitServiceError::InvalidRepository(format!(\"git rebase --quit failed: {e}\"))\n                });\n            }\n        }\n        if git.is_merge_in_progress(worktree_path).unwrap_or(false) {\n            return git.abort_merge(worktree_path).map_err(|e| {\n                GitServiceError::InvalidRepository(format!(\"git merge --abort failed: {e}\"))\n            });\n        }\n        if git\n            .is_cherry_pick_in_progress(worktree_path)\n            .unwrap_or(false)\n        {\n            return git.abort_cherry_pick(worktree_path).map_err(|e| {\n                GitServiceError::InvalidRepository(format!(\"git cherry-pick --abort failed: {e}\"))\n            });\n        }\n        if git.is_revert_in_progress(worktree_path).unwrap_or(false) {\n            return git.abort_revert(worktree_path).map_err(|e| {\n                GitServiceError::InvalidRepository(format!(\"git revert --abort failed: {e}\"))\n            });\n        }\n        Ok(())\n    }\n\n    pub fn find_branch<'a>(\n        repo: &'a Repository,\n        branch_name: &str,\n    ) -> Result<git2::Branch<'a>, GitServiceError> {\n        // Try to find the branch as a local branch first\n        match repo.find_branch(branch_name, BranchType::Local) {\n            Ok(branch) => Ok(branch),\n            Err(_) => {\n                // If not found, try to find it as a remote branch\n                match repo.find_branch(branch_name, BranchType::Remote) {\n                    Ok(branch) => Ok(branch),\n                    Err(_) => Err(GitServiceError::BranchNotFound(branch_name.to_string())),\n                }\n            }\n        }\n    }\n\n    pub fn get_remote_from_branch_name(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n    ) -> Result<GitRemote, GitServiceError> {\n        let repo = Repository::open(repo_path)?;\n        let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference();\n        let remote = self.get_remote_from_branch_ref(&repo, &branch_ref)?;\n        let name = remote.name().map(|name| name.to_string()).ok_or_else(|| {\n            GitServiceError::InvalidRepository(format!(\n                \"Remote for branch '{branch_name}' has no name\"\n            ))\n        })?;\n        let url = remote.url().map(|url| url.to_string()).ok_or_else(|| {\n            GitServiceError::InvalidRepository(format!(\n                \"Remote for branch '{branch_name}' has no URL\"\n            ))\n        })?;\n        Ok(GitRemote { name, url })\n    }\n\n    pub fn get_remote_url(\n        &self,\n        repo_path: &Path,\n        remote_name: &str,\n    ) -> Result<String, GitServiceError> {\n        let cli = GitCli::new();\n        cli.get_remote_url(repo_path, remote_name)\n            .map_err(GitServiceError::from)\n    }\n\n    pub fn get_default_remote(&self, repo_path: &Path) -> Result<GitRemote, GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        self.default_remote(&repo, repo_path)\n    }\n\n    pub fn list_remotes(&self, repo_path: &Path) -> Result<Vec<GitRemote>, GitServiceError> {\n        let cli = GitCli::new();\n        let remotes = cli.list_remotes(repo_path)?;\n\n        Ok(remotes\n            .into_iter()\n            .map(|(name, url)| GitRemote { name, url })\n            .collect())\n    }\n\n    pub fn check_remote_branch_exists(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        branch_name: &str,\n    ) -> Result<bool, GitServiceError> {\n        let git_cli = GitCli::new();\n        git_cli\n            .check_remote_branch_exists(repo_path, remote_url, branch_name)\n            .map_err(GitServiceError::from)\n    }\n\n    pub fn fetch_branch(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        branch_name: &str,\n    ) -> Result<(), GitServiceError> {\n        let git_cli = GitCli::new();\n        let refspec = format!(\"+refs/heads/{branch_name}:refs/heads/{branch_name}\");\n        git_cli\n            .fetch_with_refspec(repo_path, remote_url, &refspec)\n            .map_err(GitServiceError::from)\n    }\n\n    pub fn resolve_remote_for_branch(\n        &self,\n        repo_path: &Path,\n        branch_name: &str,\n    ) -> Result<GitRemote, GitServiceError> {\n        self.get_remote_from_branch_name(repo_path, branch_name)\n            .or_else(|_| self.get_default_remote(repo_path))\n    }\n\n    fn get_remote_from_branch_ref<'a>(\n        &self,\n        repo: &'a Repository,\n        branch_ref: &Reference,\n    ) -> Result<Remote<'a>, GitServiceError> {\n        let branch_name = branch_ref\n            .name()\n            .map(|name| name.to_string())\n            .ok_or_else(|| GitServiceError::InvalidRepository(\"Invalid branch ref\".into()))?;\n        let remote_name_buf = repo.branch_remote_name(&branch_name)?;\n\n        let remote_name = str::from_utf8(&remote_name_buf)\n            .map_err(|e| {\n                GitServiceError::InvalidRepository(format!(\n                    \"Invalid remote name for branch {branch_name}: {e}\"\n                ))\n            })?\n            .to_string();\n        repo.find_remote(&remote_name).map_err(|_| {\n            GitServiceError::InvalidRepository(format!(\n                \"Remote '{remote_name}' for branch '{branch_name}' not found\"\n            ))\n        })\n    }\n\n    pub fn push_to_remote(\n        &self,\n        worktree_path: &Path,\n        branch_name: &str,\n        force: bool,\n    ) -> Result<(), GitServiceError> {\n        let repo = Repository::open(worktree_path)?;\n        self.check_worktree_clean(&repo)?;\n\n        // Get the remote\n        let remote = self.default_remote(&repo, worktree_path)?;\n\n        let git_cli = GitCli::new();\n        if let Err(e) = git_cli.push(worktree_path, &remote.url, branch_name, force) {\n            tracing::error!(\"Push to remote failed: {}\", e);\n            return Err(e.into());\n        }\n\n        let mut branch = Self::find_branch(&repo, branch_name)?;\n        if !branch.get().is_remote() {\n            if let Some(branch_target) = branch.get().target() {\n                let remote_ref = format!(\"refs/remotes/{}/{branch_name}\", remote.name);\n                repo.reference(\n                    &remote_ref,\n                    branch_target,\n                    true,\n                    \"update remote tracking branch\",\n                )?;\n            }\n            branch.set_upstream(Some(&format!(\"{}/{branch_name}\", remote.name)))?;\n        }\n\n        Ok(())\n    }\n\n    /// Fetch from remote repository using native git authentication\n    fn fetch_from_remote(\n        &self,\n        repo: &Repository,\n        remote: &Remote,\n        refspec: &str,\n    ) -> Result<(), GitServiceError> {\n        // Get the remote\n        let remote_url = remote\n            .url()\n            .ok_or_else(|| GitServiceError::InvalidRepository(\"Remote has no URL\".to_string()))?;\n\n        let git_cli = GitCli::new();\n        if let Err(e) = git_cli.fetch_with_refspec(repo.path(), remote_url, refspec) {\n            tracing::error!(\"Fetch from GitHub failed: {}\", e);\n            return Err(e.into());\n        }\n        Ok(())\n    }\n\n    /// Fetch from remote repository using native git authentication\n    fn fetch_branch_from_remote(\n        &self,\n        repo: &Repository,\n        branch: &Reference,\n    ) -> Result<(), GitServiceError> {\n        let remote = self.get_remote_from_branch_ref(repo, branch)?;\n        let default_remote = self.default_remote(repo, repo.path())?;\n        let remote_name = remote.name().unwrap_or(&default_remote.name);\n        let dest_ref = branch\n            .name()\n            .ok_or_else(|| GitServiceError::InvalidRepository(\"Invalid branch ref\".into()))?;\n        let remote_prefix = format!(\"refs/remotes/{remote_name}/\");\n        let src_ref = dest_ref.replacen(&remote_prefix, \"refs/heads/\", 1);\n        let refspec = format!(\"+{src_ref}:{dest_ref}\");\n        self.fetch_from_remote(repo, &remote, &refspec)\n    }\n\n    /// Fetch from remote repository using native git authentication\n    fn fetch_all_from_remote(\n        &self,\n        repo: &Repository,\n        remote: &Remote,\n    ) -> Result<(), GitServiceError> {\n        let default_remote = self.default_remote(repo, repo.path())?;\n        let remote_name = remote.name().unwrap_or(&default_remote.name);\n        let refspec = format!(\"+refs/heads/*:refs/remotes/{remote_name}/*\");\n        self.fetch_from_remote(repo, remote, &refspec)\n    }\n\n    /// Clone a repository to the specified directory\n    #[cfg(feature = \"cloud\")]\n    pub fn clone_repository(\n        clone_url: &str,\n        target_path: &Path,\n        token: Option<&str>,\n    ) -> Result<Repository, GitServiceError> {\n        use git2::{Cred, FetchOptions, RemoteCallbacks};\n\n        if let Some(parent) = target_path.parent() {\n            std::fs::create_dir_all(parent)?;\n        }\n\n        // Set up callbacks for authentication if token is provided\n        let mut callbacks = RemoteCallbacks::new();\n        if let Some(token) = token {\n            callbacks.credentials(|_url, username_from_url, _allowed_types| {\n                Cred::userpass_plaintext(username_from_url.unwrap_or(\"git\"), token)\n            });\n        } else {\n            // Fallback to SSH agent and key file authentication\n            callbacks.credentials(|_url, username_from_url, _| {\n                // Try SSH agent first\n                if let Some(username) = username_from_url\n                    && let Ok(cred) = Cred::ssh_key_from_agent(username)\n                {\n                    return Ok(cred);\n                }\n\n                // Fallback to key file (~/.ssh/id_rsa)\n                let home = dirs::home_dir()\n                    .ok_or_else(|| git2::Error::from_str(\"Could not find home directory\"))?;\n                let key_path = home.join(\".ssh\").join(\"id_rsa\");\n                Cred::ssh_key(username_from_url.unwrap_or(\"git\"), None, &key_path, None)\n            });\n        }\n\n        // Set up fetch options with our callbacks\n        let mut fetch_opts = FetchOptions::new();\n        fetch_opts.remote_callbacks(callbacks);\n\n        // Create a repository builder with fetch options\n        let mut builder = git2::build::RepoBuilder::new();\n        builder.fetch_options(fetch_opts);\n\n        let repo = builder.clone(clone_url, target_path)?;\n\n        tracing::info!(\n            \"Successfully cloned repository from {} to {}\",\n            clone_url,\n            target_path.display()\n        );\n\n        Ok(repo)\n    }\n\n    /// Collect file statistics from recent commits for ranking purposes\n    pub fn collect_recent_file_stats(\n        &self,\n        repo_path: &Path,\n        commit_limit: usize,\n    ) -> Result<HashMap<String, FileStat>, GitServiceError> {\n        let repo = self.open_repo(repo_path)?;\n        let mut stats: HashMap<String, FileStat> = HashMap::new();\n\n        // Set up revision walk from HEAD\n        let mut revwalk = repo.revwalk()?;\n        revwalk.push_head()?;\n        revwalk.set_sorting(Sort::TIME)?;\n\n        // Iterate through recent commits\n        for (commit_index, oid_result) in revwalk.take(commit_limit).enumerate() {\n            let oid = oid_result?;\n            let commit = repo.find_commit(oid)?;\n\n            // Get commit timestamp\n            let commit_time = {\n                let time = commit.time();\n                DateTime::from_timestamp(time.seconds(), 0).unwrap_or_else(Utc::now)\n            };\n\n            // Get the commit tree\n            let commit_tree = commit.tree()?;\n\n            // For the first commit (no parent), diff against empty tree\n            let parent_tree = if commit.parent_count() == 0 {\n                None\n            } else {\n                Some(commit.parent(0)?.tree()?)\n            };\n\n            // Create diff between parent and current commit\n            let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;\n\n            // Process each changed file in this commit\n            diff.foreach(\n                &mut |delta, _progress| {\n                    // Get the file path - prefer new file path, fall back to old\n                    if let Some(path) = delta.new_file().path().or_else(|| delta.old_file().path())\n                    {\n                        let path_str = path.to_string_lossy().to_string();\n\n                        // Update or insert file stats\n                        let stat = stats.entry(path_str).or_insert(FileStat {\n                            last_index: commit_index,\n                            commit_count: 0,\n                            last_time: commit_time,\n                        });\n\n                        // Increment commit count\n                        stat.commit_count += 1;\n\n                        // Keep the most recent change (smallest index)\n                        if commit_index < stat.last_index {\n                            stat.last_index = commit_index;\n                            stat.last_time = commit_time;\n                        }\n                    }\n\n                    true // Continue iteration\n                },\n                None, // No binary callback\n                None, // No hunk callback\n                None, // No line callback\n            )?;\n        }\n\n        Ok(stats)\n    }\n}\n"
  },
  {
    "path": "crates/git/src/validation.rs",
    "content": "pub fn is_valid_branch_prefix(prefix: &str) -> bool {\n    if prefix.is_empty() {\n        return true;\n    }\n\n    if prefix.contains('/') {\n        return false;\n    }\n\n    git2::Branch::name_is_valid(&format!(\"{prefix}/x\")).unwrap_or_default()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_valid_prefixes() {\n        assert!(is_valid_branch_prefix(\"\"));\n        assert!(is_valid_branch_prefix(\"vk\"));\n        assert!(is_valid_branch_prefix(\"feature\"));\n        assert!(is_valid_branch_prefix(\"hotfix-123\"));\n        assert!(is_valid_branch_prefix(\"foo.bar\"));\n        assert!(is_valid_branch_prefix(\"foo_bar\"));\n        assert!(is_valid_branch_prefix(\"FOO-Bar\"));\n    }\n\n    #[test]\n    fn test_invalid_prefixes() {\n        assert!(!is_valid_branch_prefix(\"foo/bar\"));\n        assert!(!is_valid_branch_prefix(\"foo..bar\"));\n        assert!(!is_valid_branch_prefix(\"foo@{\"));\n        assert!(!is_valid_branch_prefix(\"foo.lock\"));\n        // Note: git2 allows trailing dots in some contexts, but we enforce stricter rules\n        // for prefixes by checking the full branch name format\n        assert!(!is_valid_branch_prefix(\"foo bar\"));\n        assert!(!is_valid_branch_prefix(\"foo?\"));\n        assert!(!is_valid_branch_prefix(\"foo*\"));\n        assert!(!is_valid_branch_prefix(\"foo~\"));\n        assert!(!is_valid_branch_prefix(\"foo^\"));\n        assert!(!is_valid_branch_prefix(\"foo:\"));\n        assert!(!is_valid_branch_prefix(\"foo[\"));\n        assert!(!is_valid_branch_prefix(\"/foo\"));\n        assert!(!is_valid_branch_prefix(\"foo/\"));\n        assert!(!is_valid_branch_prefix(\".foo\"));\n    }\n}\n"
  },
  {
    "path": "crates/git/tests/git_ops_safety.rs",
    "content": "use std::{\n    fs,\n    io::Write,\n    path::{Path, PathBuf},\n};\n\nuse git::{GitCli, GitCliError, GitService};\nuse git2::{PushOptions, Repository, build::CheckoutBuilder};\nuse tempfile::TempDir;\n// Avoid direct git CLI usage in tests; exercise GitService instead.\n\nfn write_file<P: AsRef<Path>>(base: P, rel: &str, content: &str) {\n    let path = base.as_ref().join(rel);\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).unwrap();\n    }\n    let mut f = fs::File::create(&path).unwrap();\n    f.write_all(content.as_bytes()).unwrap();\n}\n\nfn commit_all(repo: &Repository, message: &str) {\n    let mut index = repo.index().unwrap();\n    index\n        .add_all([\"*\"].iter(), git2::IndexAddOption::DEFAULT, None)\n        .unwrap();\n    index.write().unwrap();\n    let tree_id = index.write_tree().unwrap();\n    let tree = repo.find_tree(tree_id).unwrap();\n    let sig = repo.signature().unwrap();\n    let parents: Vec<git2::Commit> = match repo.head() {\n        Ok(h) => vec![h.peel_to_commit().unwrap()],\n        Err(e) if e.code() == git2::ErrorCode::UnbornBranch => vec![],\n        Err(e) => panic!(\"failed to read HEAD: {e}\"),\n    };\n    let parent_refs: Vec<&git2::Commit> = parents.iter().collect();\n    let update_ref = if repo.head().is_ok() {\n        Some(\"HEAD\")\n    } else {\n        None\n    };\n    repo.commit(update_ref, &sig, &sig, message, &tree, &parent_refs)\n        .unwrap();\n}\n\nfn checkout_branch(repo: &Repository, name: &str) {\n    repo.set_head(&format!(\"refs/heads/{name}\")).unwrap();\n    let mut co = CheckoutBuilder::new();\n    co.force();\n    repo.checkout_head(Some(&mut co)).unwrap();\n}\n\nfn create_branch_from_head(repo: &Repository, name: &str) {\n    let head = repo.head().unwrap().peel_to_commit().unwrap();\n    let _ = repo.branch(name, &head, true).unwrap();\n}\n\nfn configure_user(repo: &Repository) {\n    let mut cfg = repo.config().unwrap();\n    cfg.set_str(\"user.name\", \"Test User\").unwrap();\n    cfg.set_str(\"user.email\", \"test@example.com\").unwrap();\n}\n\nfn push_ref(repo: &Repository, local: &str, remote: &str) {\n    let mut remote_handle = repo.find_remote(\"origin\").unwrap();\n    let mut opts = PushOptions::new();\n    let spec = format!(\"+{local}:{remote}\");\n    remote_handle\n        .push(&[spec.as_str()], Some(&mut opts))\n        .unwrap();\n}\n\nfn add_path(repo_path: &Path, path: &str) {\n    let git = GitCli::new();\n    git.git(repo_path, [\"add\", path]).unwrap();\n}\n\nuse git::DiffTarget;\n\n// Non-conflicting setup used by several tests\nfn setup_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) {\n    let repo_path = root.path().join(\"repo\");\n    let worktree_path = root.path().join(\"wt-feature\");\n\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&repo_path)\n        .expect(\"init repo\");\n\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n\n    write_file(&repo_path, \"common.txt\", \"base\\n\");\n    commit_all(&repo, \"initial main commit\");\n\n    create_branch_from_head(&repo, \"old-base\");\n    checkout_branch(&repo, \"old-base\");\n    write_file(&repo_path, \"base.txt\", \"from old-base\\n\");\n    commit_all(&repo, \"old-base commit\");\n\n    checkout_branch(&repo, \"main\");\n    create_branch_from_head(&repo, \"new-base\");\n    checkout_branch(&repo, \"new-base\");\n    write_file(&repo_path, \"base.txt\", \"from new-base\\n\");\n    commit_all(&repo, \"new-base commit\");\n\n    checkout_branch(&repo, \"old-base\");\n    create_branch_from_head(&repo, \"feature\");\n\n    let svc = GitService::new();\n    svc.add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .expect(\"create worktree\");\n\n    write_file(&worktree_path, \"feat.txt\", \"feat change\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature commit\");\n\n    (repo_path, worktree_path)\n}\n\n// Conflicting setup to simulate interactive rebase interruption\nfn setup_conflict_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) {\n    let repo_path = root.path().join(\"repo\");\n    let worktree_path = root.path().join(\"wt-feature\");\n\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&repo_path)\n        .expect(\"init repo\");\n\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n\n    write_file(&repo_path, \"conflict.txt\", \"base\\n\");\n    commit_all(&repo, \"initial main commit\");\n\n    // old-base modifies conflict.txt one way\n    create_branch_from_head(&repo, \"old-base\");\n    checkout_branch(&repo, \"old-base\");\n    write_file(&repo_path, \"conflict.txt\", \"old-base version\\n\");\n    commit_all(&repo, \"old-base change\");\n\n    // feature builds on old-base and modifies same lines differently\n    create_branch_from_head(&repo, \"feature\");\n\n    // new-base modifies in a conflicting way\n    checkout_branch(&repo, \"main\");\n    create_branch_from_head(&repo, \"new-base\");\n    checkout_branch(&repo, \"new-base\");\n    write_file(&repo_path, \"conflict.txt\", \"new-base version\\n\");\n    commit_all(&repo, \"new-base change\");\n\n    // add a worktree for feature and create the conflicting commit\n    let svc = GitService::new();\n    svc.add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .expect(\"create worktree\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    write_file(&worktree_path, \"conflict.txt\", \"feature version\\n\");\n    commit_all(&wt_repo, \"feature conflicting change\");\n\n    (repo_path, worktree_path)\n}\n\n// Setup where feature has no unique commits (feature == old-base)\nfn setup_no_unique_feature_repo(root: &TempDir) -> (PathBuf, PathBuf) {\n    let repo_path = root.path().join(\"repo\");\n    let worktree_path = root.path().join(\"wt-feature\");\n\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&repo_path)\n        .expect(\"init repo\");\n\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n\n    write_file(&repo_path, \"base.txt\", \"main base\\n\");\n    commit_all(&repo, \"initial main commit\");\n\n    // Create old-base at this point\n    create_branch_from_head(&repo, \"old-base\");\n    // Create new-base diverging\n    checkout_branch(&repo, \"main\");\n    create_branch_from_head(&repo, \"new-base\");\n    checkout_branch(&repo, \"new-base\");\n    write_file(&repo_path, \"advance.txt\", \"new base\\n\");\n    commit_all(&repo, \"advance new-base\");\n\n    // Create feature equal to old-base (no unique commits)\n    checkout_branch(&repo, \"old-base\");\n    create_branch_from_head(&repo, \"feature\");\n    let svc = GitService::new();\n    svc.add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .expect(\"create worktree\");\n\n    (repo_path, worktree_path)\n}\n\n// Simple two-way conflict between main and feature on the same file\nfn setup_direct_conflict_repo(root: &TempDir) -> (PathBuf, PathBuf) {\n    let repo_path = root.path().join(\"repo\");\n    let worktree_path = root.path().join(\"wt-feature\");\n\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&repo_path)\n        .expect(\"init repo\");\n\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n\n    write_file(&repo_path, \"conflict.txt\", \"base\\n\");\n    commit_all(&repo, \"initial main commit\");\n\n    // Create feature and commit conflicting change\n    create_branch_from_head(&repo, \"feature\");\n    let svc = GitService::new();\n    svc.add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .expect(\"create worktree\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    write_file(&worktree_path, \"conflict.txt\", \"feature change\\n\");\n    commit_all(&wt_repo, \"feature change\");\n\n    // Change main in a conflicting way\n    checkout_branch(&repo, \"main\");\n    write_file(&repo_path, \"conflict.txt\", \"main change\\n\");\n    commit_all(&repo, \"main change\");\n\n    (repo_path, worktree_path)\n}\n\n#[test]\nfn push_reports_non_fast_forward() {\n    let temp_dir = TempDir::new().unwrap();\n    let remote_path = temp_dir.path().join(\"remote.git\");\n    Repository::init_bare(&remote_path).expect(\"init bare remote\");\n    let remote_url = remote_path.to_str().expect(\"remote path str\");\n\n    // Seed the bare repo with an initial main branch commit\n    let seed_path = temp_dir.path().join(\"seed\");\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&seed_path)\n        .expect(\"init seed repo\");\n    let seed_repo = Repository::open(&seed_path).expect(\"open seed repo\");\n    configure_user(&seed_repo);\n    seed_repo.remote(\"origin\", remote_url).expect(\"add remote\");\n    push_ref(&seed_repo, \"refs/heads/main\", \"refs/heads/main\");\n    Repository::open_bare(&remote_path)\n        .expect(\"open bare remote\")\n        .set_head(\"refs/heads/main\")\n        .expect(\"set remote HEAD\");\n\n    // Local clone that will attempt the push later\n    let local_path = temp_dir.path().join(\"local\");\n    let local_repo = Repository::clone(remote_url, &local_path).expect(\"clone local\");\n    configure_user(&local_repo);\n    checkout_branch(&local_repo, \"main\");\n    write_file(&local_path, \"file.txt\", \"initial local\\n\");\n    commit_all(&local_repo, \"initial local commit\");\n    push_ref(&local_repo, \"refs/heads/main\", \"refs/heads/main\");\n\n    // Separate clone simulates someone else pushing first\n    let updater_path = temp_dir.path().join(\"updater\");\n    let updater_repo = Repository::clone(remote_url, &updater_path).expect(\"clone updater\");\n    configure_user(&updater_repo);\n    checkout_branch(&updater_repo, \"main\");\n    write_file(&updater_path, \"file.txt\", \"upstream change\\n\");\n    commit_all(&updater_repo, \"upstream commit\");\n    push_ref(&updater_repo, \"refs/heads/main\", \"refs/heads/main\");\n\n    // Local branch diverges but has not fetched the updater's commit\n    write_file(&local_path, \"file.txt\", \"local change\\n\");\n    commit_all(&local_repo, \"local commit\");\n    let remote = local_repo.find_remote(\"origin\").expect(\"origin remote\");\n    let remote_url_string = remote.url().expect(\"origin url\").to_string();\n\n    let git_cli = GitCli::new();\n    let result = git_cli.push(&local_path, &remote_url_string, \"main\", false);\n    match result {\n        Err(GitCliError::PushRejected(msg)) => {\n            let lower = msg.to_ascii_lowercase();\n            assert!(\n                lower.contains(\"failed to push some refs\") || lower.contains(\"fetch first\"),\n                \"unexpected stderr: {msg}\"\n            );\n        }\n        Err(other) => panic!(\"expected push rejected, got {other:?}\"),\n        Ok(_) => panic!(\"push unexpectedly succeeded\"),\n    }\n}\n\n#[test]\nfn fetch_with_missing_ref_returns_error() {\n    let temp_dir = TempDir::new().unwrap();\n    let remote_path = temp_dir.path().join(\"remote.git\");\n    Repository::init_bare(&remote_path).expect(\"init bare remote\");\n    let remote_url = remote_path.to_str().expect(\"remote path str\");\n\n    let seed_path = temp_dir.path().join(\"seed\");\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&seed_path)\n        .expect(\"init seed repo\");\n    let seed_repo = Repository::open(&seed_path).expect(\"open seed repo\");\n    configure_user(&seed_repo);\n    seed_repo.remote(\"origin\", remote_url).expect(\"add remote\");\n    push_ref(&seed_repo, \"refs/heads/main\", \"refs/heads/main\");\n    Repository::open_bare(&remote_path)\n        .expect(\"open bare remote\")\n        .set_head(\"refs/heads/main\")\n        .expect(\"set remote HEAD\");\n\n    let local_path = temp_dir.path().join(\"local\");\n    Repository::clone(remote_url, &local_path).expect(\"clone local\");\n\n    let git_cli = GitCli::new();\n    let refspec = \"+refs/heads/missing:refs/remotes/origin/missing\";\n    let result = git_cli.fetch_with_refspec(&local_path, remote_url, refspec);\n    match result {\n        Err(GitCliError::CommandFailed(msg)) => {\n            assert!(\n                msg.to_ascii_lowercase()\n                    .contains(\"couldn't find remote ref\"),\n                \"unexpected stderr: {msg}\"\n            );\n        }\n        Err(other) => panic!(\"expected command failed, got {other:?}\"),\n        Ok(_) => panic!(\"fetch unexpectedly succeeded\"),\n    }\n}\n\n#[test]\nfn push_and_fetch_roundtrip_updates_tracking_branch() {\n    let temp_dir = TempDir::new().unwrap();\n    let remote_path = temp_dir.path().join(\"remote.git\");\n    Repository::init_bare(&remote_path).expect(\"init bare remote\");\n    let remote_url = remote_path.to_str().expect(\"remote path str\");\n\n    let seed_path = temp_dir.path().join(\"seed\");\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&seed_path)\n        .expect(\"init seed repo\");\n    let seed_repo = Repository::open(&seed_path).expect(\"open seed repo\");\n    configure_user(&seed_repo);\n    seed_repo.remote(\"origin\", remote_url).expect(\"add remote\");\n    push_ref(&seed_repo, \"refs/heads/main\", \"refs/heads/main\");\n    Repository::open_bare(&remote_path)\n        .expect(\"open bare remote\")\n        .set_head(\"refs/heads/main\")\n        .expect(\"set remote HEAD\");\n\n    let producer_path = temp_dir.path().join(\"producer\");\n    let producer_repo = Repository::clone(remote_url, &producer_path).expect(\"clone producer\");\n    configure_user(&producer_repo);\n    checkout_branch(&producer_repo, \"main\");\n\n    let consumer_path = temp_dir.path().join(\"consumer\");\n    let consumer_repo = Repository::clone(remote_url, &consumer_path).expect(\"clone consumer\");\n    configure_user(&consumer_repo);\n    checkout_branch(&consumer_repo, \"main\");\n    let old_oid = consumer_repo\n        .find_reference(\"refs/remotes/origin/main\")\n        .expect(\"consumer tracking ref\")\n        .target()\n        .expect(\"consumer tracking ref\");\n\n    write_file(&producer_path, \"file.txt\", \"new work\\n\");\n    commit_all(&producer_repo, \"producer commit\");\n\n    let remote = producer_repo.find_remote(\"origin\").expect(\"origin remote\");\n    let remote_url_string = remote.url().expect(\"origin url\").to_string();\n\n    let git_cli = GitCli::new();\n    git_cli\n        .push(&producer_path, &remote_url_string, \"main\", false)\n        .expect(\"push succeeded\");\n\n    let new_oid = producer_repo\n        .head()\n        .expect(\"producer head\")\n        .target()\n        .expect(\"producer head oid\");\n    assert_ne!(old_oid, new_oid, \"producer created new commit\");\n\n    git_cli\n        .fetch_with_refspec(\n            &consumer_path,\n            &remote_url_string,\n            \"+refs/heads/main:refs/remotes/origin/main\",\n        )\n        .expect(\"fetch succeeded\");\n\n    let updated_oid = consumer_repo\n        .find_reference(\"refs/remotes/origin/main\")\n        .expect(\"updated tracking ref\")\n        .target()\n        .expect(\"updated tracking ref\");\n    assert_eq!(\n        updated_oid, new_oid,\n        \"tracking branch advanced to remote head\"\n    );\n}\n\n#[test]\nfn rebase_preserves_untracked_files() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    write_file(&worktree_path, \"scratch/untracked.txt\", \"temporary note\\n\");\n\n    let service = GitService::new();\n    let res = service.rebase_branch(\n        &repo_path,\n        &worktree_path,\n        \"new-base\",\n        \"old-base\",\n        \"feature\",\n    );\n    assert!(res.is_ok(), \"rebase should succeed: {res:?}\");\n\n    let scratch = worktree_path.join(\"scratch/untracked.txt\");\n    let content = fs::read_to_string(&scratch).expect(\"untracked file exists\");\n    assert_eq!(content, \"temporary note\\n\");\n}\n\n#[test]\nfn rebase_aborts_on_uncommitted_tracked_changes() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    write_file(&worktree_path, \"feat.txt\", \"feat change (edited)\\n\");\n\n    let service = GitService::new();\n    let res = service.rebase_branch(\n        &repo_path,\n        &worktree_path,\n        \"new-base\",\n        \"old-base\",\n        \"feature\",\n    );\n    assert!(res.is_err(), \"rebase should fail on dirty worktree\");\n\n    let edited = fs::read_to_string(worktree_path.join(\"feat.txt\")).unwrap();\n    assert_eq!(edited, \"feat change (edited)\\n\");\n}\n\n#[test]\nfn rebase_aborts_if_untracked_would_be_overwritten_by_base() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    write_file(&worktree_path, \"base.txt\", \"my scratch note\\n\");\n\n    let service = GitService::new();\n    let res = service.rebase_branch(\n        &repo_path,\n        &worktree_path,\n        \"new-base\",\n        \"old-base\",\n        \"feature\",\n    );\n    assert!(\n        res.is_err(),\n        \"rebase should fail due to untracked overwrite risk\"\n    );\n\n    let content = std::fs::read_to_string(worktree_path.join(\"base.txt\")).unwrap();\n    assert_eq!(content, \"my scratch note\\n\");\n}\n\n#[test]\nfn merge_does_not_overwrite_main_repo_untracked_files() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    write_file(&worktree_path, \"danger.txt\", \"tracked from feature\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"add danger.txt in feature\");\n\n    write_file(&repo_path, \"danger.txt\", \"my untracked data\\n\");\n    let main_repo = Repository::open(&repo_path).unwrap();\n    checkout_branch(&main_repo, \"main\");\n\n    let service = GitService::new();\n    let res = service.merge_changes(\n        &repo_path,\n        &worktree_path,\n        \"feature\",\n        \"main\",\n        \"squash merge\",\n    );\n    assert!(\n        res.is_err(),\n        \"merge should refuse due to untracked conflict\"\n    );\n\n    // Untracked file remains untouched\n    let content = std::fs::read_to_string(repo_path.join(\"danger.txt\")).unwrap();\n    assert_eq!(content, \"my untracked data\\n\");\n}\n\n#[test]\nfn merge_does_not_touch_tracked_uncommitted_changes_in_base_worktree() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    // Prepare: modify a tracked file in the base worktree (main) without committing\n    let _main_repo = Repository::open(&repo_path).unwrap();\n    // Base branch commits will be advanced by the merge operation; record before via service\n    let g = GitService::new();\n    let before_oid = g.get_branch_oid(&repo_path, \"main\").unwrap();\n\n    // Create a tracked file that will also be added by feature branch to simulate overlap\n    write_file(&repo_path, \"danger2.txt\", \"my staged change\\n\");\n    {\n        // stage and then unstage to leave WT_MODIFIED? Simpler: just modify an existing tracked file\n        // Use common.txt which is tracked\n        write_file(&repo_path, \"common.txt\", \"edited locally\\n\");\n    }\n\n    // Feature adds a change and is committed in worktree\n    write_file(&worktree_path, \"danger2.txt\", \"feature tracked\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature adds danger2.txt\");\n\n    // Merge via service (squash into main) should not modify files in the main worktree\n    let service = GitService::new();\n    let res = service.merge_changes(\n        &repo_path,\n        &worktree_path,\n        \"feature\",\n        \"main\",\n        \"squash merge\",\n    );\n    assert!(\n        res.is_ok(),\n        \"merge should succeed without touching worktree\"\n    );\n\n    // Confirm the local edit to tracked file remains\n    let content = std::fs::read_to_string(repo_path.join(\"common.txt\")).unwrap();\n    assert_eq!(content, \"edited locally\\n\");\n\n    // Confirm the main branch ref advanced\n    let after_oid = g.get_branch_oid(&repo_path, \"main\").unwrap();\n    assert_ne!(before_oid, after_oid, \"main ref should be updated by merge\");\n}\n\n#[test]\nfn merge_refuses_with_staged_changes_on_base() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let s = GitService::new();\n    // ensure main is checked out\n    let repo = Repository::open(&repo_path).unwrap();\n    checkout_branch(&repo, \"main\");\n    // feature adds change and commits\n    write_file(&worktree_path, \"m.txt\", \"feature\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feat change\");\n    // main has staged change\n    write_file(&repo_path, \"staged.txt\", \"staged\\n\");\n    add_path(&repo_path, \"staged.txt\");\n    let res = s.merge_changes(&repo_path, &worktree_path, \"feature\", \"main\", \"squash\");\n    assert!(res.is_err(), \"should refuse merge due to staged changes\");\n    // staged file remains\n    let content = std::fs::read_to_string(repo_path.join(\"staged.txt\")).unwrap();\n    assert_eq!(content, \"staged\\n\");\n}\n\n#[test]\nfn merge_preserves_unstaged_changes_on_base() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let s = GitService::new();\n    let repo = Repository::open(&repo_path).unwrap();\n    checkout_branch(&repo, \"main\");\n    // modify unstaged\n    write_file(&repo_path, \"common.txt\", \"local edited\\n\");\n    // feature modifies a different file\n    write_file(&worktree_path, \"merged.txt\", \"merged content\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature merged\");\n\n    let _sha = s\n        .merge_changes(&repo_path, &worktree_path, \"feature\", \"main\", \"squash\")\n        .unwrap();\n    // local edit preserved\n    let loc = std::fs::read_to_string(repo_path.join(\"common.txt\")).unwrap();\n    assert_eq!(loc, \"local edited\\n\");\n    // merged file updated\n    let m = std::fs::read_to_string(repo_path.join(\"merged.txt\")).unwrap();\n    assert_eq!(m, \"merged content\\n\");\n}\n\n#[test]\nfn update_ref_does_not_destroy_feature_worktree_dirty_state() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let s = GitService::new();\n    let repo = Repository::open(&repo_path).unwrap();\n    // ensure main is checked out\n    checkout_branch(&repo, \"main\");\n    // feature makes an initial change and commits\n    write_file(&worktree_path, \"f.txt\", \"feat\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feat commit\");\n    // dirty change in feature worktree (uncommitted)\n    write_file(&worktree_path, \"dirty.txt\", \"unstaged\\n\");\n    // merge from feature into main (CLI path updates task ref via update-ref)\n    let sha = s\n        .merge_changes(&repo_path, &worktree_path, \"feature\", \"main\", \"squash\")\n        .unwrap();\n    // uncommitted change in feature worktree preserved\n    let dirty = std::fs::read_to_string(worktree_path.join(\"dirty.txt\")).unwrap();\n    assert_eq!(dirty, \"unstaged\\n\");\n    // feature branch ref updated to the squash commit in main repo\n    let feature_oid = s.get_branch_oid(&repo_path, \"feature\").unwrap();\n    assert_eq!(feature_oid, sha);\n    // and the feature worktree HEAD now points to that commit\n    let head = s.get_head_info(&worktree_path).unwrap();\n    assert_eq!(head.branch, \"feature\");\n    assert_eq!(head.oid, sha);\n}\n\n#[test]\nfn libgit2_merge_updates_base_ref_in_both_repos() {\n    // Ensure we hit the libgit2 path by NOT checking out the base branch in main repo\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let s = GitService::new();\n\n    // Record current main OID from both main repo and worktree repo; they should match pre-merge\n    let before_main_repo = s.get_branch_oid(&repo_path, \"main\").unwrap();\n    let before_main_wt = s.get_branch_oid(&worktree_path, \"main\").unwrap();\n    assert_eq!(before_main_repo, before_main_wt);\n\n    // Perform merge (squash) while main repo is NOT on base branch (libgit2 path)\n    let sha = s\n        .merge_changes(&repo_path, &worktree_path, \"feature\", \"main\", \"squash\")\n        .expect(\"merge should succeed via libgit2 path\");\n\n    // Base branch ref advanced in both main and worktree repositories\n    let after_main_repo = s.get_branch_oid(&repo_path, \"main\").unwrap();\n    let after_main_wt = s.get_branch_oid(&worktree_path, \"main\").unwrap();\n    assert_eq!(after_main_repo, sha);\n    assert_eq!(after_main_wt, sha);\n}\n\n#[test]\nfn libgit2_merge_updates_task_ref_and_feature_head_preserves_dirty() {\n    // Hit libgit2 path (main repo not on base) and verify task ref + HEAD update safely\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let s = GitService::new();\n\n    // Make an uncommitted change in the feature worktree to ensure it's preserved\n    write_file(&worktree_path, \"dirty2.txt\", \"keep me\\n\");\n\n    // Perform merge (squash) from feature into main; this path uses libgit2\n    let sha = s\n        .merge_changes(&repo_path, &worktree_path, \"feature\", \"main\", \"squash\")\n        .expect(\"merge should succeed via libgit2 path\");\n\n    // Dirty file preserved in worktree\n    let dirty = std::fs::read_to_string(worktree_path.join(\"dirty2.txt\")).unwrap();\n    assert_eq!(dirty, \"keep me\\n\");\n\n    // Task branch (feature) updated to squash commit in both repos\n    let feat_main_repo = s.get_branch_oid(&repo_path, \"feature\").unwrap();\n    let feat_worktree = s.get_branch_oid(&worktree_path, \"feature\").unwrap();\n    assert_eq!(feat_main_repo, sha);\n    assert_eq!(feat_worktree, sha);\n\n    // Feature worktree HEAD points to the new squash commit\n    let head = s.get_head_info(&worktree_path).unwrap();\n    assert_eq!(head.branch, \"feature\");\n    assert_eq!(head.oid, sha);\n}\n\n#[test]\nfn rebase_refuses_to_abort_existing_rebase() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_conflict_repo_with_worktree(&td);\n\n    // Start a rebase via GitService that will pause/conflict\n    let svc = GitService::new();\n    let _ = svc\n        .rebase_branch(\n            &repo_path,\n            &worktree_path,\n            \"new-base\",\n            \"old-base\",\n            \"feature\",\n        )\n        .expect_err(\"first rebase should error and leave in-progress state\");\n\n    // Our service should refuse to proceed and not abort the user's rebase\n    let service = GitService::new();\n    let res = service.rebase_branch(\n        &repo_path,\n        &worktree_path,\n        \"new-base\",\n        \"old-base\",\n        \"feature\",\n    );\n    assert!(res.is_err(), \"should error because rebase is in progress\");\n    // Note: We do not auto-abort; user should resolve or abort explicitly\n}\n\n#[test]\nfn rebase_fast_forwards_when_no_unique_commits() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_no_unique_feature_repo(&td);\n    let g = GitService::new();\n    let before = g.get_head_info(&worktree_path).unwrap().oid;\n    let new_base_oid = g.get_branch_oid(&repo_path, \"new-base\").unwrap();\n\n    let _res = g\n        .rebase_branch(\n            &repo_path,\n            &worktree_path,\n            \"new-base\",\n            \"old-base\",\n            \"feature\",\n        )\n        .expect(\"rebase should succeed\");\n    let after_oid = g.get_head_info(&worktree_path).unwrap().oid;\n    assert_ne!(before, after_oid, \"HEAD should move after rebase\");\n    assert_eq!(after_oid, new_base_oid, \"fast-forward onto new-base\");\n}\n\n#[test]\nfn rebase_applies_multiple_commits_onto_ahead_base() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let repo = Repository::open(&repo_path).unwrap();\n    // Advance new-base further\n    checkout_branch(&repo, \"new-base\");\n    write_file(&repo_path, \"base_more.txt\", \"nb more\\n\");\n    commit_all(&repo, \"advance new-base more\");\n\n    // Add another commit to feature\n    write_file(&worktree_path, \"feat2.txt\", \"second change\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature second commit\");\n\n    // Rebase feature onto new-base\n    let service = GitService::new();\n    let _ = service\n        .rebase_branch(\n            &repo_path,\n            &worktree_path,\n            \"new-base\",\n            \"old-base\",\n            \"feature\",\n        )\n        .expect(\"rebase should succeed\");\n\n    // Verify both files exist with expected content in the rebased worktree\n    let feat = std::fs::read_to_string(worktree_path.join(\"feat.txt\")).unwrap();\n    let feat2 = std::fs::read_to_string(worktree_path.join(\"feat2.txt\")).unwrap();\n    assert_eq!(feat, \"feat change\\n\");\n    assert_eq!(feat2, \"second change\\n\");\n}\n\n#[test]\nfn merge_when_base_ahead_and_feature_ahead_fails() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let repo = Repository::open(&repo_path).unwrap();\n    // Advance base (main) after feature was created\n    checkout_branch(&repo, \"main\");\n    write_file(&repo_path, \"base_ahead.txt\", \"base ahead\\n\");\n    commit_all(&repo, \"base ahead commit\");\n\n    // Feature adds its own file (already has feat.txt from setup) and commit another\n    write_file(&worktree_path, \"another.txt\", \"feature ahead\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature ahead extra\");\n\n    let g = GitService::new();\n    let before_main = g.get_branch_oid(&repo_path, \"main\").unwrap();\n\n    // Attempt to merge (squash) into main - should fail because base is ahead\n    let service = GitService::new();\n    let res = service.merge_changes(\n        &repo_path,\n        &worktree_path,\n        \"feature\",\n        \"main\",\n        \"squash merge\",\n    );\n\n    assert!(\n        res.is_err(),\n        \"merge should fail when base branch is ahead of task branch\"\n    );\n\n    // Verify main branch was not modified\n    let after_main = g.get_branch_oid(&repo_path, \"main\").unwrap();\n    assert_eq!(\n        before_main, after_main,\n        \"main ref should remain unchanged when merge fails\"\n    );\n}\n\n#[test]\nfn merge_conflict_does_not_move_base_ref() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_direct_conflict_repo(&td);\n\n    // Record main ref before\n    let _repo = Repository::open(&repo_path).unwrap();\n    let g = GitService::new();\n    let before = g.get_branch_oid(&repo_path, \"main\").unwrap();\n\n    let service = GitService::new();\n    let res = service.merge_changes(\n        &repo_path,\n        &worktree_path,\n        \"feature\",\n        \"main\",\n        \"squash merge\",\n    );\n\n    assert!(res.is_err(), \"conflicting merge should fail\");\n\n    let after = g.get_branch_oid(&repo_path, \"main\").unwrap();\n    assert_eq!(before, after, \"main ref must remain unchanged on conflict\");\n}\n\n#[test]\nfn merge_delete_vs_modify_conflict_behaves_safely() {\n    // main modifies file, feature deletes it -> but now blocked by branch ahead check\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n    let repo = Repository::open(&repo_path).unwrap();\n\n    // start from main with a file\n    checkout_branch(&repo, \"main\");\n    write_file(&repo_path, \"conflict_dm.txt\", \"base\\n\");\n    commit_all(&repo, \"add conflict file\");\n\n    // feature deletes it and commits\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    let path = worktree_path.join(\"conflict_dm.txt\");\n    if path.exists() {\n        std::fs::remove_file(&path).unwrap();\n    }\n    commit_all(&wt_repo, \"delete in feature\");\n\n    // main modifies same file (this puts main ahead of feature)\n    write_file(&repo_path, \"conflict_dm.txt\", \"main modify\\n\");\n    commit_all(&repo, \"modify in main\");\n\n    // Capture main state AFTER all setup commits\n    let g = GitService::new();\n    let before = g.get_branch_oid(&repo_path, \"main\").unwrap();\n\n    let service = GitService::new();\n    let res = service.merge_changes(\n        &repo_path,\n        &worktree_path,\n        \"feature\",\n        \"main\",\n        \"squash merge\",\n    );\n\n    // Should now fail due to base branch being ahead, not due to merge conflicts\n    assert!(res.is_err(), \"merge should fail when base branch is ahead\");\n\n    // Ensure base ref unchanged on failure\n    let after = g.get_branch_oid(&repo_path, \"main\").unwrap();\n    assert_eq!(before, after, \"main ref must remain unchanged on failure\");\n}\n\n#[test]\nfn rebase_preserves_rename_changes() {\n    // feature renames a file; rebase onto new-base preserves rename\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    // feature: rename feat.txt -> feat_renamed.txt\n    std::fs::rename(\n        worktree_path.join(\"feat.txt\"),\n        worktree_path.join(\"feat_renamed.txt\"),\n    )\n    .unwrap();\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"rename feat\");\n\n    // rebase onto new-base\n    let service = GitService::new();\n    let _ = service\n        .rebase_branch(\n            &repo_path,\n            &worktree_path,\n            \"new-base\",\n            \"old-base\",\n            \"feature\",\n        )\n        .expect(\"rebase should succeed\");\n    // after rebase, renamed file present; original absent\n    assert!(worktree_path.join(\"feat_renamed.txt\").exists());\n    assert!(!worktree_path.join(\"feat.txt\").exists());\n}\n\n#[test]\nfn merge_refreshes_main_worktree_when_on_base() {\n    let td = TempDir::new().unwrap();\n    // Initialize repo and ensure main is checked out\n    let repo_path = td.path().join(\"repo_refresh\");\n    let s = GitService::new();\n    s.initialize_repo_with_main_branch(&repo_path).unwrap();\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n    // Baseline file\n    write_file(&repo_path, \"file.txt\", \"base\\n\");\n    let _ = s.commit(&repo_path, \"add base\").unwrap();\n\n    // Create feature branch and worktree\n    create_branch_from_head(&repo, \"feature\");\n    let wt = td.path().join(\"wt_refresh\");\n    s.add_worktree(&repo_path, &wt, \"feature\", false).unwrap();\n    // Modify file in worktree and commit\n    write_file(&wt, \"file.txt\", \"feature change\\n\");\n    let _ = s.commit(&wt, \"feature change\").unwrap();\n\n    // Merge into main (squash) and ensure main worktree is updated since it is on base\n    let merge_sha = s\n        .merge_changes(&repo_path, &wt, \"feature\", \"main\", \"squash\")\n        .unwrap();\n    // Since main is on base branch and we use safe CLI merge, both working tree\n    // and ref should reflect the merged content.\n    let content = std::fs::read_to_string(repo_path.join(\"file.txt\")).unwrap();\n    assert_eq!(content, \"feature change\\n\");\n    let oid = s.get_branch_oid(&repo_path, \"main\").unwrap();\n    assert_eq!(oid, merge_sha);\n}\n\n#[test]\nfn sparse_checkout_respected_in_worktree_diffs_and_commit() {\n    let td = TempDir::new().unwrap();\n    let repo_path = td.path().join(\"repo_sparse\");\n    let s = GitService::new();\n    s.initialize_repo_with_main_branch(&repo_path).unwrap();\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n    // baseline content\n    write_file(&repo_path, \"included/a.txt\", \"A\\n\");\n    write_file(&repo_path, \"excluded/b.txt\", \"B\\n\");\n    let _ = s.commit(&repo_path, \"baseline\").unwrap();\n\n    // enable sparse-checkout for 'included' only\n    let cli = GitCli::new();\n    cli.git(&repo_path, [\"sparse-checkout\", \"init\", \"--cone\"])\n        .unwrap();\n    cli.git(&repo_path, [\"sparse-checkout\", \"set\", \"included\"])\n        .unwrap();\n\n    // create feature branch and worktree\n    create_branch_from_head(&repo, \"feature\");\n    let wt = td.path().join(\"wt_sparse\");\n    s.add_worktree(&repo_path, &wt, \"feature\", false).unwrap();\n\n    // materialization check: included exists, excluded does not\n    assert!(wt.join(\"included/a.txt\").exists());\n    assert!(!wt.join(\"excluded/b.txt\").exists());\n\n    // modify included file\n    write_file(&wt, \"included/a.txt\", \"A-mod\\n\");\n    let base_commit = s.get_base_commit(&repo_path, \"feature\", \"main\").unwrap();\n    // get worktree diffs vs main, ensure excluded/b.txt is NOT reported deleted\n    let diffs = s\n        .get_diffs(\n            DiffTarget::Worktree {\n                worktree_path: Path::new(&wt),\n                base_commit: &base_commit,\n            },\n            None,\n        )\n        .unwrap();\n    assert!(\n        diffs\n            .iter()\n            .any(|d| d.new_path.as_deref() == Some(\"included/a.txt\"))\n    );\n    assert!(\n        !diffs\n            .iter()\n            .any(|d| d.old_path.as_deref() == Some(\"excluded/b.txt\")\n                || d.new_path.as_deref() == Some(\"excluded/b.txt\"))\n    );\n\n    // commit and verify commit diffs also only include included/ changes\n    let _ = s.commit(&wt, \"modify included\").unwrap();\n    let head_sha = s.get_head_info(&wt).unwrap().oid;\n    let commit_diffs = s\n        .get_diffs(\n            DiffTarget::Commit {\n                repo_path: Path::new(&wt),\n                commit_sha: &head_sha,\n            },\n            None,\n        )\n        .unwrap();\n    assert!(\n        commit_diffs\n            .iter()\n            .any(|d| d.new_path.as_deref() == Some(\"included/a.txt\"))\n    );\n    assert!(\n        commit_diffs\n            .iter()\n            .all(|d| d.new_path.as_deref() != Some(\"excluded/b.txt\")\n                && d.old_path.as_deref() != Some(\"excluded/b.txt\"))\n    );\n}\n\n#[test]\nfn worktree_diff_ignores_commits_where_base_branch_is_ahead() {\n    let td = TempDir::new().unwrap();\n    let repo_path = td.path().join(\"repo_base_ahead\");\n    let s = GitService::new();\n    s.initialize_repo_with_main_branch(&repo_path).unwrap();\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n\n    write_file(&repo_path, \"shared.txt\", \"base\\n\");\n    let _ = s.commit(&repo_path, \"add shared\").unwrap();\n\n    create_branch_from_head(&repo, \"feature\");\n    let wt = td.path().join(\"wt_base_ahead\");\n    s.add_worktree(&repo_path, &wt, \"feature\", false).unwrap();\n\n    write_file(&repo_path, \"base_only.txt\", \"main ahead\\n\");\n    let _ = s.commit(&repo_path, \"main ahead\").unwrap();\n\n    write_file(&wt, \"feature.txt\", \"feature change\\n\");\n    let base_commit = s.get_base_commit(&repo_path, \"feature\", \"main\").unwrap();\n\n    let diffs = s\n        .get_diffs(\n            DiffTarget::Worktree {\n                worktree_path: Path::new(&wt),\n                base_commit: &base_commit,\n            },\n            None,\n        )\n        .unwrap();\n\n    assert!(\n        diffs\n            .iter()\n            .any(|d| d.new_path.as_deref() == Some(\"feature.txt\"))\n    );\n    assert!(diffs.iter().all(|d| {\n        d.new_path.as_deref() != Some(\"base_only.txt\")\n            && d.old_path.as_deref() != Some(\"base_only.txt\")\n    }));\n}\n\n// Helper: initialize a repo with main, configure user via service\nfn init_repo_only_service(root: &TempDir) -> PathBuf {\n    let repo_path = root.path().join(\"repo_svc\");\n    let s = GitService::new();\n    s.initialize_repo_with_main_branch(&repo_path).unwrap();\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n    checkout_branch(&repo, \"main\");\n    repo_path\n}\n\n#[test]\nfn merge_binary_conflict_does_not_move_ref() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_only_service(&td);\n    let repo = Repository::open(&repo_path).unwrap();\n    let s = GitService::new();\n    // seed\n    let _ = s.commit(&repo_path, \"seed\").unwrap();\n    // create feature branch and worktree\n    create_branch_from_head(&repo, \"feature\");\n    let worktree_path = td.path().join(\"wt_bin\");\n    s.add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .unwrap();\n\n    // feature adds/commits binary file\n    let mut f = fs::File::create(worktree_path.join(\"bin.dat\")).unwrap();\n    f.write_all(&[0, 1, 2, 3]).unwrap();\n    let _ = s.commit(&worktree_path, \"feature bin\").unwrap();\n\n    // main adds conflicting binary content\n    let mut f2 = fs::File::create(repo_path.join(\"bin.dat\")).unwrap();\n    f2.write_all(&[9, 8, 7, 6]).unwrap();\n    let _ = s.commit(&repo_path, \"main bin\").unwrap();\n\n    let before = s.get_branch_oid(&repo_path, \"main\").unwrap();\n    let res = s.merge_changes(&repo_path, &worktree_path, \"feature\", \"main\", \"merge bin\");\n    assert!(res.is_err(), \"binary conflict should fail\");\n    let after = s.get_branch_oid(&repo_path, \"main\").unwrap();\n    assert_eq!(before, after, \"main ref unchanged on conflict\");\n}\n\n#[test]\nfn merge_rename_vs_modify_conflict_does_not_move_ref() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_only_service(&td);\n    let repo = Repository::open(&repo_path).unwrap();\n    let s = GitService::new();\n    // base file\n    fs::write(repo_path.join(\"conflict.txt\"), b\"base\\n\").unwrap();\n    let _ = s.commit(&repo_path, \"base\").unwrap();\n    create_branch_from_head(&repo, \"feature\");\n    let worktree_path = td.path().join(\"wt_ren\");\n    s.add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .unwrap();\n\n    // feature renames file\n    std::fs::rename(\n        worktree_path.join(\"conflict.txt\"),\n        worktree_path.join(\"conflict_renamed.txt\"),\n    )\n    .unwrap();\n    let _ = s.commit(&worktree_path, \"rename\").unwrap();\n\n    // main modifies original path\n    fs::write(repo_path.join(\"conflict.txt\"), b\"main change\\n\").unwrap();\n    let _ = s.commit(&repo_path, \"modify main\").unwrap();\n\n    let before = s.get_branch_oid(&repo_path, \"main\").unwrap();\n    let res = s.merge_changes(\n        &repo_path,\n        &worktree_path,\n        \"feature\",\n        \"main\",\n        \"merge rename\",\n    );\n    match res {\n        Err(_) => {\n            let after = s.get_branch_oid(&repo_path, \"main\").unwrap();\n            assert_eq!(before, after, \"main unchanged on conflict\");\n        }\n        Ok(sha) => {\n            // ensure main advanced and result contains either renamed or modified content\n            let after = s.get_branch_oid(&repo_path, \"main\").unwrap();\n            assert_eq!(after, sha);\n            let diffs = s\n                .get_diffs(\n                    DiffTarget::Commit {\n                        repo_path: Path::new(&repo_path),\n                        commit_sha: &after,\n                    },\n                    None,\n                )\n                .unwrap();\n            let has_renamed = diffs\n                .iter()\n                .any(|d| d.new_path.as_deref() == Some(\"conflict_renamed.txt\"));\n            let has_modified = diffs.iter().any(|d| {\n                d.new_path.as_deref() == Some(\"conflict.txt\")\n                    && d.new_content.as_deref() == Some(\"main change\\n\")\n            });\n            assert!(has_renamed || has_modified);\n        }\n    }\n}\n\n#[test]\nfn merge_leaves_no_staged_changes_on_target_branch() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    // Ensure main repo is on the base branch (triggers CLI merge path)\n    let s = GitService::new();\n    let repo = Repository::open(&repo_path).unwrap();\n    checkout_branch(&repo, \"main\");\n\n    // Feature branch makes some changes\n    write_file(&worktree_path, \"feature_file.txt\", \"feature content\\n\");\n    write_file(&worktree_path, \"common.txt\", \"modified by feature\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature changes\");\n\n    // Perform the merge\n    let _merge_sha = s\n        .merge_changes(\n            &repo_path,\n            &worktree_path,\n            \"feature\",\n            \"main\",\n            \"merge feature\",\n        )\n        .expect(\"merge should succeed\");\n\n    // THE KEY CHECK: Verify no staged changes remain on target branch\n    let git_cli = GitCli::new();\n    let has_staged = git_cli\n        .has_staged_changes(&repo_path)\n        .expect(\"should be able to check staged changes\");\n\n    assert!(\n        !has_staged,\n        \"Target branch should have no staged changes after merge\"\n    );\n\n    // Debug info if test fails\n    if has_staged {\n        let status_output = git_cli.git(&repo_path, [\"status\", \"--porcelain\"]).unwrap();\n        panic!(\"Found staged changes after merge:\\n{status_output}\");\n    }\n}\n\n#[test]\nfn worktree_to_worktree_merge_leaves_no_staged_changes() {\n    let td = TempDir::new().unwrap();\n    let repo_path = td.path().join(\"repo\");\n    let worktree_a_path = td.path().join(\"wt-feature-a\");\n    let worktree_b_path = td.path().join(\"wt-feature-b\");\n\n    // Setup: Initialize repo with main branch\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&repo_path)\n        .expect(\"init repo\");\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n\n    write_file(&repo_path, \"base.txt\", \"base content\\n\");\n    commit_all(&repo, \"initial commit\");\n\n    // Create two feature branches\n    create_branch_from_head(&repo, \"feature-a\");\n    create_branch_from_head(&repo, \"feature-b\");\n\n    // Create worktrees for both feature branches\n    service\n        .add_worktree(&repo_path, &worktree_a_path, \"feature-a\", false)\n        .expect(\"create worktree A\");\n    service\n        .add_worktree(&repo_path, &worktree_b_path, \"feature-b\", false)\n        .expect(\"create worktree B\");\n\n    // Make changes in worktree A\n    write_file(\n        &worktree_a_path,\n        \"feature_a.txt\",\n        \"content from feature A\\n\",\n    );\n    write_file(&worktree_a_path, \"base.txt\", \"modified by feature A\\n\");\n    let wt_a_repo = Repository::open(&worktree_a_path).unwrap();\n    commit_all(&wt_a_repo, \"feature A changes\");\n\n    // Ensure main repo is on different branch (neither feature-a nor feature-b)\n    checkout_branch(&repo, \"main\");\n\n    let _sha = service.merge_changes(\n        &repo_path,\n        &worktree_a_path,\n        \"feature-a\",\n        \"feature-b\",\n        \"merge feature-a into feature-b\",\n    );\n\n    // Verify no staged changes were introduced\n    let git_cli = GitCli::new();\n    let has_staged_main = git_cli\n        .has_staged_changes(&repo_path)\n        .expect(\"should be able to check staged changes in main repo\");\n    let has_staged_target = git_cli\n        .has_staged_changes(&worktree_b_path)\n        .expect(\"should be able to check staged changes in target worktree\");\n\n    assert!(\n        !has_staged_main,\n        \"Main repo should have no staged changes after failed merge\"\n    );\n    assert!(\n        !has_staged_target,\n        \"Target worktree should have no staged changes after failed merge\"\n    );\n}\n\n#[test]\nfn merge_into_orphaned_branch_uses_libgit2_fallback() {\n    let td = TempDir::new().unwrap();\n    let (repo_path, worktree_path) = setup_repo_with_worktree(&td);\n\n    // Create an \"orphaned\" target branch that exists as ref but isn't checked out anywhere\n    let service = GitService::new();\n    let repo = Repository::open(&repo_path).unwrap();\n\n    // Create orphaned-feature branch from current main HEAD but don't check it out\n    let main_commit = repo.head().unwrap().peel_to_commit().unwrap();\n    repo.branch(\"orphaned-feature\", &main_commit, false)\n        .unwrap();\n\n    // Ensure main repo is on different branch and no worktree has orphaned-feature\n    checkout_branch(&repo, \"main\");\n\n    // Make changes in source worktree\n    write_file(\n        &worktree_path,\n        \"feature_content.txt\",\n        \"content from feature\\n\",\n    );\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature changes\");\n\n    // orphaned-feature is not checked out anywhere, so should trigger libgit2 path\n\n    // Perform merge into orphaned branch (should use libgit2 fallback)\n    let merge_sha = service\n        .merge_changes(\n            &repo_path,\n            &worktree_path,\n            \"feature\",\n            \"orphaned-feature\",\n            \"merge into orphaned branch\",\n        )\n        .expect(\"libgit2 merge into orphaned branch should succeed\");\n\n    // Verify merge worked - orphaned-feature branch should now point to merge commit\n    let orphaned_branch_oid = service\n        .get_branch_oid(&repo_path, \"orphaned-feature\")\n        .unwrap();\n    assert_eq!(\n        orphaned_branch_oid, merge_sha,\n        \"orphaned-feature branch should point to merge commit\"\n    );\n\n    // Verify no working tree was affected (since branch wasn't checked out anywhere)\n    let main_git_cli = GitCli::new();\n    let main_has_staged = main_git_cli.has_staged_changes(&repo_path).unwrap();\n    let worktree_has_staged = main_git_cli.has_staged_changes(&worktree_path).unwrap();\n\n    assert!(\n        !main_has_staged,\n        \"Main repo should remain clean after libgit2 merge\"\n    );\n    assert!(\n        !worktree_has_staged,\n        \"Source worktree should remain clean after libgit2 merge\"\n    );\n}\n\n#[test]\nfn merge_base_ahead_of_task_should_error() {\n    let td = TempDir::new().unwrap();\n    let repo_path = td.path().join(\"repo\");\n    let worktree_path = td.path().join(\"wt-feature\");\n\n    // Setup: Initialize repo with main branch\n    let service = GitService::new();\n    service\n        .initialize_repo_with_main_branch(&repo_path)\n        .expect(\"init repo\");\n    let repo = Repository::open(&repo_path).unwrap();\n    configure_user(&repo);\n\n    // Initial commit on main\n    write_file(&repo_path, \"base.txt\", \"initial content\\n\");\n    commit_all(&repo, \"initial commit\");\n\n    // Create feature branch from this point\n    create_branch_from_head(&repo, \"feature\");\n    service\n        .add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .expect(\"create worktree\");\n\n    // Feature makes a change and commits\n    write_file(&worktree_path, \"feature.txt\", \"feature content\\n\");\n    let wt_repo = Repository::open(&worktree_path).unwrap();\n    commit_all(&wt_repo, \"feature change\");\n\n    // Main branch advances ahead of feature (this is the key scenario)\n    checkout_branch(&repo, \"main\");\n    write_file(&repo_path, \"main_advance.txt\", \"main advanced\\n\");\n    commit_all(&repo, \"main advances ahead\");\n    write_file(&repo_path, \"main_advance2.txt\", \"main advanced more\\n\");\n    commit_all(&repo, \"main advances further\");\n\n    // Attempt to merge feature into main when main is ahead\n    // This should error because base branch has moved ahead of task branch\n    let res = service.merge_changes(\n        &repo_path,\n        &worktree_path,\n        \"feature\",\n        \"main\",\n        \"attempt merge when base ahead\",\n    );\n\n    // TDD: This test will initially fail because merge currently succeeds\n    // Later we'll fix the merge logic to detect this scenario and error\n    assert!(\n        res.is_err(),\n        \"Merge should error when base branch is ahead of task branch\"\n    );\n}\n"
  },
  {
    "path": "crates/git/tests/git_workflow.rs",
    "content": "use std::{\n    fs,\n    io::Write,\n    path::{Path, PathBuf},\n};\n\nuse git::{DiffTarget, GitCli, GitService};\nuse git2::{Repository, build::CheckoutBuilder};\nuse tempfile::TempDir;\nuse utils::diff::DiffChangeKind;\n\nfn add_path(repo_path: &Path, path: &str) {\n    let git = GitCli::new();\n    git.git(repo_path, [\"add\", path]).unwrap();\n}\n\nfn get_commit_author(repo_path: &Path, commit_sha: &str) -> (Option<String>, Option<String>) {\n    let repo = git2::Repository::open(repo_path).unwrap();\n    let oid = git2::Oid::from_str(commit_sha).unwrap();\n    let commit = repo.find_commit(oid).unwrap();\n    let author = commit.author();\n    (\n        author.name().map(|s| s.to_string()),\n        author.email().map(|s| s.to_string()),\n    )\n}\n\nfn get_head_author(repo_path: &Path) -> (Option<String>, Option<String>) {\n    let repo = git2::Repository::open(repo_path).unwrap();\n    let head = repo.head().unwrap();\n    let oid = head.target().unwrap();\n    let commit = repo.find_commit(oid).unwrap();\n    let author = commit.author();\n    (\n        author.name().map(|s| s.to_string()),\n        author.email().map(|s| s.to_string()),\n    )\n}\n\nfn write_file<P: AsRef<Path>>(base: P, rel: &str, content: &str) {\n    let path = base.as_ref().join(rel);\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).unwrap();\n    }\n    let mut f = fs::File::create(&path).unwrap();\n    f.write_all(content.as_bytes()).unwrap();\n}\n\nfn configure_user(repo_path: &Path, name: &str, email: &str) {\n    let repo = git2::Repository::open(repo_path).unwrap();\n    let mut cfg = repo.config().unwrap();\n    cfg.set_str(\"user.name\", name).unwrap();\n    cfg.set_str(\"user.email\", email).unwrap();\n}\n\nfn init_repo_main(root: &TempDir) -> PathBuf {\n    let path = root.path().join(\"repo\");\n    let s = GitService::new();\n    s.initialize_repo_with_main_branch(&path).unwrap();\n    configure_user(&path, \"Test User\", \"test@example.com\");\n    checkout_branch(&path, \"main\");\n    path\n}\n\nfn checkout_branch(repo_path: &Path, name: &str) {\n    let repo = Repository::open(repo_path).unwrap();\n    repo.set_head(&format!(\"refs/heads/{name}\")).unwrap();\n    let mut co = CheckoutBuilder::new();\n    co.force();\n    repo.checkout_head(Some(&mut co)).unwrap();\n}\n\nfn create_branch(repo_path: &Path, name: &str) {\n    let repo = Repository::open(repo_path).unwrap();\n    let head = repo.head().unwrap().peel_to_commit().unwrap();\n    let _ = repo.branch(name, &head, true).unwrap();\n}\n\n#[test]\nfn commit_empty_message_behaviour() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    write_file(&repo_path, \"x.txt\", \"x\\n\");\n    let s = GitService::new();\n    let res = s.commit(&repo_path, \"\");\n    // Some environments disallow empty commit messages by default.\n    // Accept either success or a clear error.\n    if let Err(e) = &res {\n        let msg = format!(\"{e}\");\n        assert!(msg.contains(\"empty commit message\") || msg.contains(\"git commit failed\"));\n    }\n}\n\nfn has_global_git_identity() -> bool {\n    if let Ok(cfg) = git2::Config::open_default() {\n        let has_name = cfg.get_string(\"user.name\").is_ok();\n        let has_email = cfg.get_string(\"user.email\").is_ok();\n        return has_name && has_email;\n    }\n    false\n}\n\n#[test]\nfn initialize_repo_without_user_creates_initial_commit() {\n    let td = TempDir::new().unwrap();\n    let repo_path = td.path().join(\"repo_no_user_init\");\n    let s = GitService::new();\n    // No configure_user call; rely on fallback signature for initial commit\n    s.initialize_repo_with_main_branch(&repo_path).unwrap();\n    let head = s.get_head_info(&repo_path).unwrap();\n    assert_eq!(head.branch, \"main\");\n    assert!(!head.oid.is_empty());\n    // Verify author is set: either global identity (if configured) or fallback\n    let (name, email) = get_head_author(&repo_path);\n    if has_global_git_identity() {\n        assert!(name.is_some() && email.is_some());\n    } else {\n        assert_eq!(name.as_deref(), Some(\"Vibe Kanban\"));\n        assert_eq!(email.as_deref(), Some(\"noreply@vibekanban.com\"));\n    }\n}\n\n#[test]\nfn commit_without_user_config_succeeds() {\n    let td = TempDir::new().unwrap();\n    let repo_path = td.path().join(\"repo_no_user\");\n    let s = GitService::new();\n    s.initialize_repo_with_main_branch(&repo_path).unwrap();\n    write_file(&repo_path, \"f.txt\", \"x\\n\");\n    // No configure_user call here\n    let res = s.commit(&repo_path, \"no user config\");\n    assert!(res.is_ok());\n}\n\n#[test]\nfn commit_fails_when_index_locked() {\n    use std::fs::File;\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    write_file(&repo_path, \"y.txt\", \"y\\n\");\n    // Simulate index lock\n    let git_dir = repo_path.join(\".git\");\n    let _lock = File::create(git_dir.join(\"index.lock\")).unwrap();\n    let s = GitService::new();\n    let res = s.commit(&repo_path, \"should fail\");\n    assert!(res.is_err());\n}\n\n#[test]\nfn staged_but_uncommitted_changes_is_dirty() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    let s = GitService::new();\n    // seed tracked file\n    write_file(&repo_path, \"t1.txt\", \"a\\n\");\n    let _ = s.commit(&repo_path, \"seed\").unwrap();\n    // modify and stage\n    write_file(&repo_path, \"t1.txt\", \"b\\n\");\n    add_path(&repo_path, \"t1.txt\");\n    assert!(!s.is_worktree_clean(&repo_path).unwrap());\n}\n\n#[test]\nfn worktree_clean_detects_staged_deleted_and_renamed() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    write_file(&repo_path, \"t1.txt\", \"1\\n\");\n    write_file(&repo_path, \"t2.txt\", \"2\\n\");\n    let s = GitService::new();\n    let _ = s.commit(&repo_path, \"seed\").unwrap();\n\n    // delete tracked file\n    std::fs::remove_file(repo_path.join(\"t2.txt\")).unwrap();\n    assert!(!s.is_worktree_clean(&repo_path).unwrap());\n\n    // restore and test rename\n    write_file(&repo_path, \"t2.txt\", \"2\\n\");\n    let _ = s.commit(&repo_path, \"restore t2\").unwrap();\n    std::fs::rename(repo_path.join(\"t2.txt\"), repo_path.join(\"t2-renamed.txt\")).unwrap();\n    assert!(!s.is_worktree_clean(&repo_path).unwrap());\n}\n\n#[test]\nfn diff_added_binary_file_has_no_content() {\n    // ensure binary file content is not loaded (null byte guard)\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    // base\n    let s = GitService::new();\n    let _ = s.commit(&repo_path, \"base\").unwrap();\n    // branch with binary file\n    create_branch(&repo_path, \"feature\");\n    checkout_branch(&repo_path, \"feature\");\n    // write binary with null byte\n    let mut f = fs::File::create(repo_path.join(\"bin.dat\")).unwrap();\n    f.write_all(&[0u8, 1, 2, 3]).unwrap();\n    let _ = s.commit(&repo_path, \"add binary\").unwrap();\n\n    let s = GitService::new();\n    let diffs = s\n        .get_diffs(\n            DiffTarget::Branch {\n                repo_path: Path::new(&repo_path),\n                branch_name: \"feature\",\n                base_branch: \"main\",\n            },\n            None,\n        )\n        .unwrap();\n    let bin = diffs\n        .iter()\n        .find(|d| d.new_path.as_deref() == Some(\"bin.dat\"))\n        .expect(\"binary diff present\");\n    assert!(bin.new_content.is_none());\n}\n\n#[test]\nfn initialize_and_default_branch_and_head_info() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n\n    let s = GitService::new();\n    // Head info branch should be main\n    let head = s.get_head_info(&repo_path).unwrap();\n    assert_eq!(head.branch, \"main\");\n\n    // Repo has an initial commit (OID parsable)\n    assert!(!head.oid.is_empty());\n}\n\n#[test]\nfn commit_and_is_worktree_clean() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    write_file(&repo_path, \"foo.txt\", \"hello\\n\");\n\n    let s = GitService::new();\n    let committed = s.commit(&repo_path, \"add foo\").unwrap();\n    assert!(committed);\n    assert!(s.is_worktree_clean(&repo_path).unwrap());\n\n    // Verify commit contains file\n    let diffs = s\n        .get_diffs(\n            DiffTarget::Commit {\n                repo_path: Path::new(&repo_path),\n                commit_sha: &s.get_head_info(&repo_path).unwrap().oid,\n            },\n            None,\n        )\n        .unwrap();\n    assert!(\n        diffs\n            .iter()\n            .any(|d| d.new_path.as_deref() == Some(\"foo.txt\"))\n    );\n}\n\n#[test]\nfn commit_in_detached_head_succeeds_via_service() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    // initial parent\n    write_file(&repo_path, \"a.txt\", \"a\\n\");\n    let s = GitService::new();\n    let _ = s.commit(&repo_path, \"add a\").unwrap();\n    // detach via service\n    let repo = git2::Repository::open(&repo_path).unwrap();\n    let oid = repo.head().unwrap().target().unwrap();\n    repo.set_head_detached(oid).unwrap();\n    // commit while detached\n    write_file(&repo_path, \"b.txt\", \"b\\n\");\n    let ok = s.commit(&repo_path, \"detached commit\").unwrap();\n    assert!(ok);\n}\n\n#[test]\nfn branch_status_ahead_and_behind() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    let s = GitService::new();\n\n    // main: initial commit\n    write_file(&repo_path, \"base.txt\", \"base\\n\");\n    let _ = s.commit(&repo_path, \"base\").unwrap();\n\n    // create feature from main\n    create_branch(&repo_path, \"feature\");\n    // advance feature by 1\n    checkout_branch(&repo_path, \"feature\");\n    write_file(&repo_path, \"feature.txt\", \"f1\\n\");\n    let _ = s.commit(&repo_path, \"f1\").unwrap();\n\n    // advance main by 1\n    checkout_branch(&repo_path, \"main\");\n    write_file(&repo_path, \"main.txt\", \"m1\\n\");\n    let _ = s.commit(&repo_path, \"m1\").unwrap();\n\n    let s = GitService::new();\n    let (ahead, behind) = s.get_branch_status(&repo_path, \"feature\", \"main\").unwrap();\n    assert_eq!((ahead, behind), (1, 1));\n\n    // advance feature by one more (ahead 2, behind 1)\n    checkout_branch(&repo_path, \"feature\");\n    write_file(&repo_path, \"feature2.txt\", \"f2\\n\");\n    let _ = s.commit(&repo_path, \"f2\").unwrap();\n    let (ahead2, behind2) = s.get_branch_status(&repo_path, \"feature\", \"main\").unwrap();\n    assert_eq!((ahead2, behind2), (2, 1));\n}\n\n#[test]\nfn get_all_branches_lists_current_and_others() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    create_branch(&repo_path, \"feature\");\n\n    let s = GitService::new();\n    let branches = s.get_all_branches(&repo_path).unwrap();\n    let names: Vec<_> = branches.iter().map(|b| b.name.as_str()).collect();\n    assert!(names.contains(&\"main\"));\n    assert!(names.contains(&\"feature\"));\n    // current should be main\n    let main_entry = branches.iter().find(|b| b.name == \"main\").unwrap();\n    assert!(main_entry.is_current);\n}\n\n#[test]\nfn get_branch_diffs_between_branches() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    let s = GitService::new();\n    // base commit on main\n    write_file(&repo_path, \"a.txt\", \"a\\n\");\n    let _ = s.commit(&repo_path, \"add a\").unwrap();\n\n    // create branch and add new file\n    create_branch(&repo_path, \"feature\");\n    checkout_branch(&repo_path, \"feature\");\n    write_file(&repo_path, \"b.txt\", \"b\\n\");\n    let _ = s.commit(&repo_path, \"add b\").unwrap();\n\n    let s = GitService::new();\n    let diffs = s\n        .get_diffs(\n            DiffTarget::Branch {\n                repo_path: Path::new(&repo_path),\n                branch_name: \"feature\",\n                base_branch: \"main\",\n            },\n            None,\n        )\n        .unwrap();\n    assert!(diffs.iter().any(|d| d.new_path.as_deref() == Some(\"b.txt\")));\n}\n\n#[test]\nfn worktree_diff_respects_path_filter() {\n    // Use git CLI status diff under the hood\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n\n    // main baseline\n    write_file(&repo_path, \"src/keep.txt\", \"k\\n\");\n    write_file(&repo_path, \"other/skip.txt\", \"s\\n\");\n    let s = GitService::new();\n    let _ = s.commit(&repo_path, \"baseline\").unwrap();\n\n    // create feature and work in place (worktree is repo_path)\n    create_branch(&repo_path, \"feature\");\n\n    // modify files without committing\n    write_file(&repo_path, \"src/only.txt\", \"only\\n\");\n    write_file(&repo_path, \"other/skip2.txt\", \"skip\\n\");\n\n    let s = GitService::new();\n    let base_commit = s.get_base_commit(&repo_path, \"feature\", \"main\").unwrap();\n    let diffs = s\n        .get_diffs(\n            DiffTarget::Worktree {\n                worktree_path: Path::new(&repo_path),\n                base_commit: &base_commit,\n            },\n            Some(&[\"src\"]),\n        )\n        .unwrap();\n    assert!(\n        diffs\n            .iter()\n            .any(|d| d.new_path.as_deref() == Some(\"src/only.txt\"))\n    );\n    assert!(\n        !diffs\n            .iter()\n            .any(|d| d.new_path.as_deref() == Some(\"other/skip2.txt\"))\n    );\n}\n\n#[test]\nfn get_branch_oid_nonexistent_errors() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    let s = GitService::new();\n    let res = s.get_branch_oid(&repo_path, \"no-such-branch\");\n    assert!(res.is_err());\n}\n\n#[test]\nfn create_unicode_branch_and_list() {\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    let s = GitService::new();\n    // base commit\n    write_file(&repo_path, \"file.txt\", \"ok\\n\");\n    let _ = s.commit(&repo_path, \"base\");\n    // unicode/slash branch name (valid ref)\n    let bname = \"feature/ünicode\";\n    create_branch(&repo_path, bname);\n    let names: Vec<_> = s\n        .get_all_branches(&repo_path)\n        .unwrap()\n        .into_iter()\n        .map(|b| b.name)\n        .collect();\n    assert!(names.iter().any(|n| n == bname));\n}\n\n#[cfg(unix)]\n#[test]\nfn worktree_diff_permission_only_change() {\n    use std::os::unix::fs::PermissionsExt;\n    let td = TempDir::new().unwrap();\n    let repo_path = init_repo_main(&td);\n    let s = GitService::new();\n    // baseline commit\n    write_file(&repo_path, \"p.sh\", \"echo hi\\n\");\n    let _ = s.commit(&repo_path, \"add p.sh\").unwrap();\n    // create a feature branch baseline at HEAD\n    create_branch(&repo_path, \"feature\");\n\n    // change only the permission (chmod +x)\n    let mut perms = std::fs::metadata(repo_path.join(\"p.sh\"))\n        .unwrap()\n        .permissions();\n    perms.set_mode(perms.mode() | 0o111);\n    std::fs::set_permissions(repo_path.join(\"p.sh\"), perms).unwrap();\n\n    let base_commit = s.get_base_commit(&repo_path, \"feature\", \"main\").unwrap();\n    // Compute worktree diff vs main on feature\n    let diffs = s\n        .get_diffs(\n            DiffTarget::Worktree {\n                worktree_path: Path::new(&repo_path),\n                base_commit: &base_commit,\n            },\n            None,\n        )\n        .unwrap();\n    let d = diffs\n        .into_iter()\n        .find(|d| d.new_path.as_deref() == Some(\"p.sh\"))\n        .expect(\"p.sh diff present\");\n    assert!(matches!(d.change, DiffChangeKind::PermissionChange));\n    assert_eq!(d.old_content, d.new_content);\n}\n\n#[test]\nfn squash_merge_libgit2_sets_author_without_user() {\n    // Verify merge_changes (libgit2 path) uses fallback author when no config exists\n    use git2::Repository;\n\n    let td = TempDir::new().unwrap();\n    let repo_path = td.path().join(\"repo_fallback_merge\");\n    let worktree_path = td.path().join(\"wt_feature\");\n    let s = GitService::new();\n\n    // Init repo without user config\n    s.initialize_repo_with_main_branch(&repo_path).unwrap();\n\n    // Create feature branch and worktree\n    create_branch(&repo_path, \"feature\");\n    s.add_worktree(&repo_path, &worktree_path, \"feature\", false)\n        .unwrap();\n\n    // Make a feature commit in the worktree via libgit2 using an explicit signature\n    write_file(&worktree_path, \"f.txt\", \"feat\\n\");\n    {\n        let repo = Repository::open(&worktree_path).unwrap();\n        // stage all\n        let mut index = repo.index().unwrap();\n        index\n            .add_all([\"*\"].iter(), git2::IndexAddOption::DEFAULT, None)\n            .unwrap();\n        index.write().unwrap();\n        let tree_id = index.write_tree().unwrap();\n        let tree = repo.find_tree(tree_id).unwrap();\n        let sig = git2::Signature::now(\"Other Author\", \"other@example.com\").unwrap();\n        let parent = repo.head().unwrap().peel_to_commit().unwrap();\n        let _cid = repo\n            .commit(Some(\"HEAD\"), &sig, &sig, \"feat\", &tree, &[&parent])\n            .unwrap();\n    }\n\n    // Ensure main repo is NOT on base branch so merge_changes takes libgit2 path\n    create_branch(&repo_path, \"dev\");\n    checkout_branch(&repo_path, \"dev\");\n\n    // Merge feature -> main (libgit2 squash)\n    let merge_sha = s\n        .merge_changes(&repo_path, &worktree_path, \"feature\", \"main\", \"squash\")\n        .unwrap();\n\n    // The squash commit author should not be the feature commit's author, and must be present.\n    let (name, email) = get_commit_author(&repo_path, &merge_sha);\n    assert_ne!(name.as_deref(), Some(\"Other Author\"));\n    assert_ne!(email.as_deref(), Some(\"other@example.com\"));\n    if has_global_git_identity() {\n        assert!(name.is_some() && email.is_some());\n    } else {\n        assert_eq!(name.as_deref(), Some(\"Vibe Kanban\"));\n        assert_eq!(email.as_deref(), Some(\"noreply@vibekanban.com\"));\n    }\n}\n"
  },
  {
    "path": "crates/git-host/Cargo.toml",
    "content": "[package]\nname = \"git-host\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\nasync-trait = { workspace = true }\nbackon = \"1.5.1\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\ndb = { path = \"../db\" }\nenum_dispatch = \"0.3.13\"\nserde = { workspace = true }\nserde_json = { workspace = true }\ntempfile = \"3.21\"\nthiserror = { workspace = true }\ntokio = { workspace = true }\ntracing = { workspace = true }\nts-rs = { workspace = true }\nurl = \"2.5\"\nutils = { path = \"../utils\" }\n"
  },
  {
    "path": "crates/git-host/src/azure/cli.rs",
    "content": "//! Minimal helpers around the Azure CLI (`az repos`).\n//!\n//! This module provides low-level access to the Azure CLI for Azure DevOps\n//! repository and pull request operations.\n\nuse std::{\n    ffi::{OsStr, OsString},\n    path::Path,\n    process::Command,\n};\n\nuse chrono::{DateTime, Utc};\nuse db::models::merge::{MergeStatus, PullRequestInfo};\nuse serde::Deserialize;\nuse thiserror::Error;\nuse utils::{command_ext::NoWindowExt, shell::resolve_executable_path_blocking};\n\nuse crate::types::{CreatePrRequest, UnifiedPrComment};\n\n#[derive(Debug, Clone)]\npub struct AzureRepoInfo {\n    pub organization_url: String,\n    pub project: String,\n    pub project_id: String,\n    pub repo_name: String,\n    pub repo_id: String,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzPrResponse {\n    pull_request_id: i64,\n    status: Option<String>,\n    closed_date: Option<String>,\n    repository: Option<AzRepository>,\n    last_merge_commit: Option<AzCommit>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzRepository {\n    web_url: Option<String>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzCommit {\n    commit_id: Option<String>,\n}\n\n#[derive(Deserialize)]\nstruct AzThreadsResponse {\n    value: Vec<AzThread>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzThread {\n    comments: Option<Vec<AzThreadComment>>,\n    thread_context: Option<AzThreadContext>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzThreadContext {\n    file_path: Option<String>,\n    right_file_start: Option<AzFilePosition>,\n}\n\n#[derive(Deserialize)]\nstruct AzFilePosition {\n    line: Option<i64>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzThreadComment {\n    id: Option<i64>,\n    author: Option<AzAuthor>,\n    content: Option<String>,\n    published_date: Option<String>,\n    comment_type: Option<String>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzAuthor {\n    display_name: Option<String>,\n}\n\n/// Response item from `az repos list`\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AzRepoListItem {\n    id: String,\n    name: String,\n    project: AzRepoProject,\n    remote_url: String,\n    ssh_url: Option<String>,\n}\n\n#[derive(Deserialize)]\nstruct AzRepoProject {\n    id: String,\n    name: String,\n}\n\n#[derive(Debug, Error)]\npub enum AzCliError {\n    #[error(\"Azure CLI (`az`) executable not found or not runnable\")]\n    NotAvailable,\n    #[error(\"Azure CLI command failed: {0}\")]\n    CommandFailed(String),\n    #[error(\"Azure CLI authentication failed: {0}\")]\n    AuthFailed(String),\n    #[error(\"Azure CLI returned unexpected output: {0}\")]\n    UnexpectedOutput(String),\n}\n\n#[derive(Debug, Clone, Default)]\npub struct AzCli;\n\nimpl AzCli {\n    pub fn new() -> Self {\n        Self {}\n    }\n\n    /// Ensure the Azure CLI binary is discoverable.\n    fn ensure_available(&self) -> Result<(), AzCliError> {\n        resolve_executable_path_blocking(\"az\").ok_or(AzCliError::NotAvailable)?;\n        Ok(())\n    }\n\n    fn run<I, S>(&self, args: I, dir: Option<&Path>) -> Result<String, AzCliError>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        self.ensure_available()?;\n        let az = resolve_executable_path_blocking(\"az\").ok_or(AzCliError::NotAvailable)?;\n        let mut cmd = Command::new(&az);\n\n        if let Some(d) = dir {\n            cmd.current_dir(d);\n        }\n\n        for arg in args {\n            cmd.arg(arg);\n        }\n        tracing::debug!(\"Running Azure CLI command: {:?} {:?}\", az, cmd.get_args());\n\n        let output = cmd\n            .no_window()\n            .output()\n            .map_err(|err| AzCliError::CommandFailed(err.to_string()))?;\n\n        if output.status.success() {\n            return Ok(String::from_utf8_lossy(&output.stdout).to_string());\n        }\n\n        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();\n\n        // Check for authentication errors\n        let lower = stderr.to_ascii_lowercase();\n        if lower.contains(\"az login\")\n            || lower.contains(\"not logged in\")\n            || lower.contains(\"authentication\")\n            || lower.contains(\"unauthorized\")\n            || lower.contains(\"credentials\")\n            || lower.contains(\"please run 'az login'\")\n        {\n            return Err(AzCliError::AuthFailed(stderr));\n        }\n\n        Err(AzCliError::CommandFailed(stderr))\n    }\n    pub fn get_repo_info(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n    ) -> Result<AzureRepoInfo, AzCliError> {\n        let raw = self.run(\n            [\"repos\", \"list\", \"--detect\", \"true\", \"--output\", \"json\"],\n            Some(repo_path),\n        )?;\n\n        let repos: Vec<AzRepoListItem> = serde_json::from_str(raw.trim()).map_err(|e| {\n            AzCliError::UnexpectedOutput(format!(\"Failed to parse repos list: {e}; raw: {raw}\"))\n        })?;\n\n        // Find the repo that matches our remote URL (check both HTTPS and SSH)\n        let is_ssh = remote_url.starts_with(\"git@\") || remote_url.starts_with(\"ssh://\");\n        let repo = repos\n            .into_iter()\n            .find(|r| {\n                if is_ssh {\n                    r.ssh_url\n                        .as_ref()\n                        .map(|ssh| Self::urls_match(ssh, remote_url))\n                        .unwrap_or(false)\n                } else {\n                    Self::urls_match(&r.remote_url, remote_url)\n                }\n            })\n            .ok_or_else(|| {\n                AzCliError::UnexpectedOutput(format!(\n                    \"No repo found matching remote URL: {}\",\n                    remote_url\n                ))\n            })?;\n\n        let organization_url =\n            Self::extract_organization_url(&repo.remote_url).ok_or_else(|| {\n                AzCliError::UnexpectedOutput(format!(\n                    \"Could not extract organization URL from: {}\",\n                    repo.remote_url\n                ))\n            })?;\n\n        tracing::debug!(\n            \"Got Azure DevOps repo info: org_url='{}', project='{}' ({}), repo='{}' ({})\",\n            organization_url,\n            repo.project.name,\n            repo.project.id,\n            repo.name,\n            repo.id\n        );\n\n        Ok(AzureRepoInfo {\n            organization_url,\n            project: repo.project.name,\n            project_id: repo.project.id,\n            repo_name: repo.name,\n            repo_id: repo.id,\n        })\n    }\n\n    fn urls_match(url1: &str, url2: &str) -> bool {\n        let normalize = |url: &str| {\n            let mut s = url.to_lowercase();\n            // Normalize ssh:// prefix to scp-style\n            if let Some(rest) = s.strip_prefix(\"ssh://\") {\n                s = rest.to_string();\n            }\n            s.trim_end_matches('/').trim_end_matches(\".git\").to_string()\n        };\n        normalize(url1) == normalize(url2)\n    }\n\n    /// Extract the organization URL from a remote URL.\n    /// Returns the base URL that can be used with Azure CLI commands.\n    fn extract_organization_url(url: &str) -> Option<String> {\n        // dev.azure.com format: https://dev.azure.com/{org}/... -> https://dev.azure.com/{org}\n        if url.contains(\"dev.azure.com\") {\n            let parts: Vec<&str> = url.split('/').collect();\n            let azure_idx = parts.iter().position(|&p| p.contains(\"dev.azure.com\"))?;\n            let org = parts.get(azure_idx + 1)?;\n            return Some(format!(\"https://dev.azure.com/{}\", org));\n        }\n\n        // Legacy format: https://{org}.visualstudio.com/... -> https://{org}.visualstudio.com\n        if url.contains(\".visualstudio.com\") {\n            let parts: Vec<&str> = url.split('/').collect();\n            for part in parts.iter() {\n                if part.contains(\".visualstudio.com\") {\n                    return Some(format!(\"https://{}\", part));\n                }\n            }\n        }\n\n        None\n    }\n\n    pub fn create_pr(\n        &self,\n        request: &CreatePrRequest,\n        organization_url: &str,\n        project: &str,\n        repo_name: &str,\n    ) -> Result<PullRequestInfo, AzCliError> {\n        let body = request.body.as_deref().unwrap_or(\"\");\n\n        let mut args: Vec<OsString> = Vec::with_capacity(20);\n        args.push(OsString::from(\"repos\"));\n        args.push(OsString::from(\"pr\"));\n        args.push(OsString::from(\"create\"));\n        args.push(OsString::from(\"--organization\"));\n        args.push(OsString::from(organization_url));\n        args.push(OsString::from(\"--project\"));\n        args.push(OsString::from(project));\n        args.push(OsString::from(\"--repository\"));\n        args.push(OsString::from(repo_name));\n        args.push(OsString::from(\"--source-branch\"));\n        args.push(OsString::from(&request.head_branch));\n        args.push(OsString::from(\"--target-branch\"));\n        args.push(OsString::from(&request.base_branch));\n        args.push(OsString::from(\"--title\"));\n        args.push(OsString::from(&request.title));\n        args.push(OsString::from(\"--description\"));\n        args.push(OsString::from(body));\n        args.push(OsString::from(\"--output\"));\n        args.push(OsString::from(\"json\"));\n\n        if request.draft.unwrap_or(false) {\n            args.push(OsString::from(\"--draft\"));\n        }\n\n        let raw = self.run(args, None)?;\n        Self::parse_pr_response(&raw)\n    }\n\n    pub fn view_pr(&self, pr_url: &str) -> Result<PullRequestInfo, AzCliError> {\n        let (organization, pr_id) = Self::parse_pr_url(pr_url).ok_or_else(|| {\n            AzCliError::UnexpectedOutput(format!(\"Could not parse Azure DevOps PR URL: {pr_url}\"))\n        })?;\n\n        let org_url = format!(\"https://dev.azure.com/{}\", organization);\n\n        let raw = self.run(\n            [\n                \"repos\",\n                \"pr\",\n                \"show\",\n                \"--id\",\n                &pr_id.to_string(),\n                \"--organization\",\n                &org_url,\n                \"--output\",\n                \"json\",\n            ],\n            None,\n        )?;\n\n        Self::parse_pr_response(&raw)\n    }\n\n    pub fn list_prs_for_branch(\n        &self,\n        organization_url: &str,\n        project: &str,\n        repo_name: &str,\n        branch: &str,\n    ) -> Result<Vec<PullRequestInfo>, AzCliError> {\n        let raw = self.run(\n            [\n                \"repos\",\n                \"pr\",\n                \"list\",\n                \"--organization\",\n                organization_url,\n                \"--project\",\n                project,\n                \"--repository\",\n                repo_name,\n                \"--source-branch\",\n                branch,\n                \"--status\",\n                \"all\",\n                \"--output\",\n                \"json\",\n            ],\n            None,\n        )?;\n\n        Self::parse_pr_list_response(&raw)\n    }\n\n    pub fn get_pr_threads(\n        &self,\n        organization_url: &str,\n        project_id: &str,\n        repo_id: &str,\n        pr_id: i64,\n    ) -> Result<Vec<UnifiedPrComment>, AzCliError> {\n        let mut args: Vec<OsString> = Vec::with_capacity(16);\n        args.push(OsString::from(\"devops\"));\n        args.push(OsString::from(\"invoke\"));\n        args.push(OsString::from(\"--area\"));\n        args.push(OsString::from(\"git\"));\n        args.push(OsString::from(\"--resource\"));\n        args.push(OsString::from(\"pullRequestThreads\"));\n        args.push(OsString::from(\"--route-parameters\"));\n        args.push(OsString::from(format!(\"project={}\", project_id)));\n        args.push(OsString::from(format!(\"repositoryId={}\", repo_id)));\n        args.push(OsString::from(format!(\"pullRequestId={}\", pr_id)));\n        args.push(OsString::from(\"--organization\"));\n        args.push(OsString::from(organization_url));\n        args.push(OsString::from(\"--api-version\"));\n        args.push(OsString::from(\"7.0\"));\n        args.push(OsString::from(\"--output\"));\n        args.push(OsString::from(\"json\"));\n\n        let raw = self.run(args, None)?;\n        Self::parse_pr_threads(&raw)\n    }\n\n    /// Parse PR URL to extract organization and PR ID.\n    ///\n    /// Only extracts the minimal info needed for `az repos pr show`.\n    /// Format: `https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id}`\n    pub fn parse_pr_url(url: &str) -> Option<(String, i64)> {\n        let url_lower = url.to_lowercase();\n\n        if url_lower.contains(\"dev.azure.com\") && url_lower.contains(\"/pullrequest/\") {\n            let parts: Vec<&str> = url.split('/').collect();\n            if let Some(pr_idx) = parts.iter().position(|&p| p == \"pullrequest\")\n                && parts.len() > pr_idx + 1\n            {\n                let pr_id: i64 = parts[pr_idx + 1].parse().ok()?;\n                if let Some(azure_idx) = parts.iter().position(|&p| p.contains(\"dev.azure.com\"))\n                    && parts.len() > azure_idx + 1\n                {\n                    let organization = parts[azure_idx + 1].to_string();\n                    return Some((organization, pr_id));\n                }\n            }\n        }\n\n        // Legacy format: https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id}\n        if url_lower.contains(\".visualstudio.com\") && url_lower.contains(\"/pullrequest/\") {\n            let parts: Vec<&str> = url.split('/').collect();\n            for part in parts.iter() {\n                if let Some(org) = part.strip_suffix(\".visualstudio.com\")\n                    && let Some(pr_idx) = parts.iter().position(|&p| p == \"pullrequest\")\n                    && parts.len() > pr_idx + 1\n                {\n                    let pr_id: i64 = parts[pr_idx + 1].parse().ok()?;\n                    return Some((org.to_string(), pr_id));\n                }\n            }\n        }\n\n        None\n    }\n}\n\nimpl AzCli {\n    /// Parse PR response from Azure CLI.\n    /// Works for both `az repos pr create` and `az repos pr show`.\n    fn parse_pr_response(raw: &str) -> Result<PullRequestInfo, AzCliError> {\n        let pr: AzPrResponse = serde_json::from_str(raw.trim()).map_err(|e| {\n            AzCliError::UnexpectedOutput(format!(\"Failed to parse PR response: {e}; raw: {raw}\"))\n        })?;\n        Ok(Self::az_pr_to_info(pr))\n    }\n\n    fn parse_pr_list_response(raw: &str) -> Result<Vec<PullRequestInfo>, AzCliError> {\n        let prs: Vec<AzPrResponse> = serde_json::from_str(raw.trim()).map_err(|e| {\n            AzCliError::UnexpectedOutput(format!(\"Failed to parse PR list: {e}; raw: {raw}\"))\n        })?;\n        Ok(prs.into_iter().map(Self::az_pr_to_info).collect())\n    }\n\n    /// Convert Azure PR response to PullRequestInfo.\n    fn az_pr_to_info(pr: AzPrResponse) -> PullRequestInfo {\n        let url = pr\n            .repository\n            .and_then(|r| r.web_url)\n            .map(|u| format!(\"{}/pullrequest/{}\", u, pr.pull_request_id))\n            .unwrap_or_else(|| format!(\"pullrequest/{}\", pr.pull_request_id));\n\n        let status = pr.status.as_deref().unwrap_or(\"active\");\n        let merged_at = pr\n            .closed_date\n            .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n            .map(|dt| dt.with_timezone(&Utc));\n        let merge_commit_sha = pr.last_merge_commit.and_then(|c| c.commit_id);\n\n        PullRequestInfo {\n            number: pr.pull_request_id,\n            url,\n            status: Self::map_azure_status(status),\n            merged_at,\n            merge_commit_sha,\n        }\n    }\n\n    fn parse_pr_threads(raw: &str) -> Result<Vec<UnifiedPrComment>, AzCliError> {\n        // REST API returns { \"value\": [...threads...] } wrapper\n        let response: AzThreadsResponse = serde_json::from_str(raw.trim()).map_err(|e| {\n            AzCliError::UnexpectedOutput(format!(\"Failed to parse threads: {e}; raw: {raw}\"))\n        })?;\n        let threads = response.value;\n\n        let mut comments = Vec::new();\n\n        for thread in threads {\n            let file_path = thread\n                .thread_context\n                .as_ref()\n                .and_then(|c| c.file_path.clone());\n            let line = thread\n                .thread_context\n                .as_ref()\n                .and_then(|c| c.right_file_start.as_ref())\n                .and_then(|p| p.line);\n\n            if let Some(thread_comments) = thread.comments {\n                for c in thread_comments {\n                    // Skip system-generated comments\n                    if c.comment_type.as_deref() == Some(\"system\") {\n                        continue;\n                    }\n\n                    let id = c.id.unwrap_or(0);\n                    let author = c\n                        .author\n                        .and_then(|a| a.display_name)\n                        .unwrap_or_else(|| \"unknown\".to_string());\n                    let body = c.content.unwrap_or_default();\n                    let created_at = c\n                        .published_date\n                        .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n                        .map(|dt| dt.with_timezone(&Utc))\n                        .unwrap_or_else(Utc::now);\n\n                    if let Some(ref path) = file_path {\n                        comments.push(UnifiedPrComment::Review {\n                            id,\n                            author,\n                            author_association: None,\n                            body,\n                            created_at,\n                            url: None,\n                            path: path.clone(),\n                            line,\n                            side: None,\n                            diff_hunk: None,\n                        });\n                    } else {\n                        comments.push(UnifiedPrComment::General {\n                            id: id.to_string(),\n                            author,\n                            author_association: None,\n                            body,\n                            created_at,\n                            url: None,\n                        });\n                    }\n                }\n            }\n        }\n\n        comments.sort_by_key(|c| c.created_at());\n        Ok(comments)\n    }\n\n    /// Map Azure DevOps PR status to MergeStatus\n    fn map_azure_status(status: &str) -> MergeStatus {\n        match status.to_lowercase().as_str() {\n            \"active\" => MergeStatus::Open,\n            \"completed\" => MergeStatus::Merged,\n            \"abandoned\" => MergeStatus::Closed,\n            _ => MergeStatus::Unknown,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_pr_url() {\n        // dev.azure.com format\n        let (org, id) = AzCli::parse_pr_url(\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/123\",\n        )\n        .unwrap();\n        assert_eq!(org, \"myorg\");\n        assert_eq!(id, 123);\n    }\n\n    #[test]\n    fn test_parse_pr_url_visualstudio() {\n        // Legacy visualstudio.com format\n        let (org, id) = AzCli::parse_pr_url(\n            \"https://myorg.visualstudio.com/myproject/_git/myrepo/pullrequest/456\",\n        )\n        .unwrap();\n        assert_eq!(org, \"myorg\");\n        assert_eq!(id, 456);\n    }\n\n    #[test]\n    fn test_parse_pr_url_invalid() {\n        // GitHub URL should return None\n        assert!(AzCli::parse_pr_url(\"https://github.com/owner/repo/pull/123\").is_none());\n        // Missing pullrequest path\n        assert!(AzCli::parse_pr_url(\"https://dev.azure.com/myorg/myproject/_git/myrepo\").is_none());\n    }\n\n    #[test]\n    fn test_map_azure_status() {\n        assert!(matches!(\n            AzCli::map_azure_status(\"active\"),\n            MergeStatus::Open\n        ));\n        assert!(matches!(\n            AzCli::map_azure_status(\"completed\"),\n            MergeStatus::Merged\n        ));\n        assert!(matches!(\n            AzCli::map_azure_status(\"abandoned\"),\n            MergeStatus::Closed\n        ));\n        assert!(matches!(\n            AzCli::map_azure_status(\"unknown\"),\n            MergeStatus::Unknown\n        ));\n    }\n\n    #[test]\n    fn test_urls_match() {\n        // Exact match\n        assert!(AzCli::urls_match(\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo\",\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo\"\n        ));\n\n        // Trailing slash\n        assert!(AzCli::urls_match(\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo/\",\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo\"\n        ));\n\n        // .git suffix\n        assert!(AzCli::urls_match(\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo.git\",\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo\"\n        ));\n\n        // Case insensitive\n        assert!(AzCli::urls_match(\n            \"https://dev.azure.com/MyOrg/MyProject/_git/MyRepo\",\n            \"https://dev.azure.com/myorg/myproject/_git/myrepo\"\n        ));\n\n        // Different repos should not match\n        assert!(!AzCli::urls_match(\n            \"https://dev.azure.com/myorg/myproject/_git/repo1\",\n            \"https://dev.azure.com/myorg/myproject/_git/repo2\"\n        ));\n\n        // SSH URLs\n        assert!(AzCli::urls_match(\n            \"git@ssh.dev.azure.com:v3/myorg/myproject/myrepo\",\n            \"git@ssh.dev.azure.com:v3/myorg/myproject/myrepo\"\n        ));\n\n        // SSH URL with ssh:// prefix should match scp-style\n        assert!(AzCli::urls_match(\n            \"ssh://git@ssh.dev.azure.com:v3/myorg/myproject/myrepo\",\n            \"git@ssh.dev.azure.com:v3/myorg/myproject/myrepo\"\n        ));\n    }\n\n    #[test]\n    fn test_extract_organization_url_dev_azure() {\n        let org_url =\n            AzCli::extract_organization_url(\"https://dev.azure.com/myorg/myproject/_git/myrepo\")\n                .unwrap();\n        assert_eq!(org_url, \"https://dev.azure.com/myorg\");\n    }\n\n    #[test]\n    fn test_extract_organization_url_visualstudio() {\n        let org_url =\n            AzCli::extract_organization_url(\"https://myorg.visualstudio.com/myproject/_git/myrepo\")\n                .unwrap();\n        assert_eq!(org_url, \"https://myorg.visualstudio.com\");\n    }\n\n    #[test]\n    fn test_extract_organization_url_invalid() {\n        assert!(AzCli::extract_organization_url(\"https://github.com/owner/repo\").is_none());\n    }\n}\n"
  },
  {
    "path": "crates/git-host/src/azure/mod.rs",
    "content": "//! Azure DevOps hosting service implementation.\n\nmod cli;\n\nuse std::{path::Path, time::Duration};\n\nuse async_trait::async_trait;\nuse backon::{ExponentialBuilder, Retryable};\npub use cli::AzCli;\nuse cli::{AzCliError, AzureRepoInfo};\nuse db::models::merge::PullRequestInfo;\nuse tokio::task;\nuse tracing::info;\n\nuse crate::{\n    GitHostProvider,\n    types::{CreatePrRequest, GitHostError, OpenPrInfo, ProviderKind, UnifiedPrComment},\n};\n\n#[derive(Debug, Clone)]\npub struct AzureDevOpsProvider {\n    az_cli: AzCli,\n}\n\nimpl AzureDevOpsProvider {\n    pub fn new() -> Result<Self, GitHostError> {\n        Ok(Self {\n            az_cli: AzCli::new(),\n        })\n    }\n\n    async fn get_repo_info(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n    ) -> Result<AzureRepoInfo, GitHostError> {\n        let cli = self.az_cli.clone();\n        let path = repo_path.to_path_buf();\n        let url = remote_url.to_string();\n        task::spawn_blocking(move || cli.get_repo_info(&path, &url))\n            .await\n            .map_err(|err| GitHostError::Repository(format!(\"Failed to get repo info: {err}\")))?\n            .map_err(Into::into)\n    }\n}\n\nimpl From<AzCliError> for GitHostError {\n    fn from(error: AzCliError) -> Self {\n        match &error {\n            AzCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()),\n            AzCliError::NotAvailable => GitHostError::CliNotInstalled {\n                provider: ProviderKind::AzureDevOps,\n            },\n            AzCliError::CommandFailed(msg) => {\n                let lower = msg.to_ascii_lowercase();\n                if lower.contains(\"403\") || lower.contains(\"forbidden\") {\n                    GitHostError::InsufficientPermissions(msg.clone())\n                } else if lower.contains(\"404\") || lower.contains(\"not found\") {\n                    GitHostError::RepoNotFoundOrNoAccess(msg.clone())\n                } else if lower.contains(\"not a git repository\") {\n                    GitHostError::NotAGitRepository(msg.clone())\n                } else {\n                    GitHostError::PullRequest(msg.clone())\n                }\n            }\n            AzCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()),\n        }\n    }\n}\n\n#[async_trait]\nimpl GitHostProvider for AzureDevOpsProvider {\n    async fn create_pr(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        request: &CreatePrRequest,\n    ) -> Result<PullRequestInfo, GitHostError> {\n        if let Some(head_url) = &request.head_repo_url\n            && head_url != remote_url\n        {\n            return Err(GitHostError::PullRequest(\n                \"Cross-fork pull requests are not supported for Azure DevOps\".to_string(),\n            ));\n        }\n\n        let repo_info = self.get_repo_info(repo_path, remote_url).await?;\n\n        (|| async {\n            let cli = self.az_cli.clone();\n            let request_clone = request.clone();\n            let organization_url = repo_info.organization_url.clone();\n            let project = repo_info.project.clone();\n            let repo_name = repo_info.repo_name.clone();\n\n            let cli_result = task::spawn_blocking(move || {\n                cli.create_pr(&request_clone, &organization_url, &project, &repo_name)\n            })\n            .await\n            .map_err(|err| {\n                GitHostError::PullRequest(format!(\n                    \"Failed to execute Azure CLI for PR creation: {err}\"\n                ))\n            })?\n            .map_err(GitHostError::from)?;\n\n            info!(\n                \"Created Azure DevOps PR #{} for branch {}\",\n                cli_result.number, request.head_branch\n            );\n\n            Ok(cli_result)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"Azure DevOps API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn get_pr_status(&self, pr_url: &str) -> Result<PullRequestInfo, GitHostError> {\n        (|| async {\n            let cli = self.az_cli.clone();\n            let url = pr_url.to_string();\n\n            let pr = task::spawn_blocking(move || cli.view_pr(&url))\n                .await\n                .map_err(|err| {\n                    GitHostError::PullRequest(format!(\n                        \"Failed to execute Azure CLI for viewing PR: {err}\"\n                    ))\n                })?;\n            pr.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|err: &GitHostError| err.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"Azure DevOps API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn list_prs_for_branch(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        branch_name: &str,\n    ) -> Result<Vec<PullRequestInfo>, GitHostError> {\n        let repo_info = self.get_repo_info(repo_path, remote_url).await?;\n\n        (|| async {\n            let cli = self.az_cli.clone();\n            let organization_url = repo_info.organization_url.clone();\n            let project = repo_info.project.clone();\n            let repo_name = repo_info.repo_name.clone();\n            let branch = branch_name.to_string();\n\n            let prs = task::spawn_blocking(move || {\n                cli.list_prs_for_branch(&organization_url, &project, &repo_name, &branch)\n            })\n            .await\n            .map_err(|err| {\n                GitHostError::PullRequest(format!(\n                    \"Failed to execute Azure CLI for listing PRs: {err}\"\n                ))\n            })?;\n            prs.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"Azure DevOps API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn get_pr_comments(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        pr_number: i64,\n    ) -> Result<Vec<UnifiedPrComment>, GitHostError> {\n        let repo_info = self.get_repo_info(repo_path, remote_url).await?;\n\n        (|| async {\n            let cli = self.az_cli.clone();\n            let organization_url = repo_info.organization_url.clone();\n            let project_id = repo_info.project_id.clone();\n            let repo_id = repo_info.repo_id.clone();\n\n            let comments = task::spawn_blocking(move || {\n                cli.get_pr_threads(&organization_url, &project_id, &repo_id, pr_number)\n            })\n            .await\n            .map_err(|err| {\n                GitHostError::PullRequest(format!(\n                    \"Failed to execute Azure CLI for fetching PR comments: {err}\"\n                ))\n            })?;\n            comments.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"Azure DevOps API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn list_open_prs(\n        &self,\n        _repo_path: &Path,\n        _remote_url: &str,\n    ) -> Result<Vec<OpenPrInfo>, GitHostError> {\n        // TODO: Implement list_open_prs for Azure DevOps\n        Err(GitHostError::UnsupportedProvider)\n    }\n\n    fn provider_kind(&self) -> ProviderKind {\n        ProviderKind::AzureDevOps\n    }\n}\n"
  },
  {
    "path": "crates/git-host/src/detection.rs",
    "content": "//! Git hosting provider detection from repository URLs.\n\nuse crate::types::ProviderKind;\n\n/// Detect the git hosting provider from a remote URL.\n///\n/// Supports:\n/// - GitHub.com: `https://github.com/owner/repo` or `git@github.com:owner/repo.git`\n/// - GitHub Enterprise: URLs containing `github.` (e.g., `https://github.company.com/owner/repo`)\n/// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo` or legacy `https://org.visualstudio.com/...`\npub fn detect_provider_from_url(url: &str) -> ProviderKind {\n    let url_lower = url.to_lowercase();\n\n    if url_lower.contains(\"github.com\") {\n        return ProviderKind::GitHub;\n    }\n\n    // Check Azure patterns before GHE to avoid false positives\n    if url_lower.contains(\"dev.azure.com\")\n        || url_lower.contains(\".visualstudio.com\")\n        || url_lower.contains(\"ssh.dev.azure.com\")\n    {\n        return ProviderKind::AzureDevOps;\n    }\n\n    // /_git/ is unique to Azure DevOps\n    if url_lower.contains(\"/_git/\") {\n        return ProviderKind::AzureDevOps;\n    }\n\n    // GitHub Enterprise (contains \"github.\" but not the Azure patterns above)\n    if url_lower.contains(\"github.\") {\n        return ProviderKind::GitHub;\n    }\n\n    ProviderKind::Unknown\n}\n\n/// Detect the git hosting provider from a PR URL.\n///\n/// Supports:\n/// - GitHub: `https://github.com/owner/repo/pull/123`\n/// - GitHub Enterprise: `https://github.company.com/owner/repo/pull/123`\n/// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo/pullrequest/123`\n#[cfg(test)]\nfn detect_provider_from_pr_url(pr_url: &str) -> ProviderKind {\n    let url_lower = pr_url.to_lowercase();\n\n    // GitHub pattern: contains /pull/ in the path\n    if url_lower.contains(\"/pull/\") {\n        // Could be github.com or GHE\n        if url_lower.contains(\"github.com\") || url_lower.contains(\"github.\") {\n            return ProviderKind::GitHub;\n        }\n    }\n\n    // Azure DevOps pattern: contains /pullrequest/ in the path\n    if url_lower.contains(\"/pullrequest/\") {\n        return ProviderKind::AzureDevOps;\n    }\n\n    // Fall back to general URL detection\n    detect_provider_from_url(pr_url)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_github_com_https() {\n        assert_eq!(\n            detect_provider_from_url(\"https://github.com/owner/repo\"),\n            ProviderKind::GitHub\n        );\n        assert_eq!(\n            detect_provider_from_url(\"https://github.com/owner/repo.git\"),\n            ProviderKind::GitHub\n        );\n    }\n\n    #[test]\n    fn test_github_com_ssh() {\n        assert_eq!(\n            detect_provider_from_url(\"git@github.com:owner/repo.git\"),\n            ProviderKind::GitHub\n        );\n    }\n\n    #[test]\n    fn test_github_enterprise() {\n        assert_eq!(\n            detect_provider_from_url(\"https://github.company.com/owner/repo\"),\n            ProviderKind::GitHub\n        );\n        assert_eq!(\n            detect_provider_from_url(\"https://github.acme.corp/team/project\"),\n            ProviderKind::GitHub\n        );\n        assert_eq!(\n            detect_provider_from_url(\"git@github.internal.io:org/repo.git\"),\n            ProviderKind::GitHub\n        );\n    }\n\n    #[test]\n    fn test_azure_devops_https() {\n        assert_eq!(\n            detect_provider_from_url(\"https://dev.azure.com/org/project/_git/repo\"),\n            ProviderKind::AzureDevOps\n        );\n    }\n\n    #[test]\n    fn test_azure_devops_ssh() {\n        assert_eq!(\n            detect_provider_from_url(\"git@ssh.dev.azure.com:v3/org/project/repo\"),\n            ProviderKind::AzureDevOps\n        );\n    }\n\n    #[test]\n    fn test_azure_devops_legacy_visualstudio() {\n        assert_eq!(\n            detect_provider_from_url(\"https://org.visualstudio.com/project/_git/repo\"),\n            ProviderKind::AzureDevOps\n        );\n    }\n\n    #[test]\n    fn test_azure_devops_git_path() {\n        // Any URL with /_git/ is Azure DevOps\n        assert_eq!(\n            detect_provider_from_url(\"https://custom.domain.com/org/project/_git/repo\"),\n            ProviderKind::AzureDevOps\n        );\n    }\n\n    #[test]\n    fn test_unknown_provider() {\n        assert_eq!(\n            detect_provider_from_url(\"https://gitlab.com/owner/repo\"),\n            ProviderKind::Unknown\n        );\n        assert_eq!(\n            detect_provider_from_url(\"https://bitbucket.org/owner/repo\"),\n            ProviderKind::Unknown\n        );\n    }\n\n    #[test]\n    fn test_pr_url_github() {\n        assert_eq!(\n            detect_provider_from_pr_url(\"https://github.com/owner/repo/pull/123\"),\n            ProviderKind::GitHub\n        );\n        assert_eq!(\n            detect_provider_from_pr_url(\"https://github.company.com/owner/repo/pull/456\"),\n            ProviderKind::GitHub\n        );\n    }\n\n    #[test]\n    fn test_pr_url_azure() {\n        assert_eq!(\n            detect_provider_from_pr_url(\n                \"https://dev.azure.com/org/project/_git/repo/pullrequest/123\"\n            ),\n            ProviderKind::AzureDevOps\n        );\n        assert_eq!(\n            detect_provider_from_pr_url(\n                \"https://org.visualstudio.com/project/_git/repo/pullrequest/456\"\n            ),\n            ProviderKind::AzureDevOps\n        );\n    }\n}\n"
  },
  {
    "path": "crates/git-host/src/github/cli.rs",
    "content": "//! Minimal helpers around the GitHub CLI (`gh`).\n//!\n//! This module provides low-level access to the GitHub CLI for operations\n//! the REST client does not cover well.\n\nuse std::{\n    ffi::{OsStr, OsString},\n    io::Write,\n    path::Path,\n    process::Command,\n};\n\nuse chrono::{DateTime, Utc};\nuse db::models::merge::{MergeStatus, PullRequestInfo};\nuse serde::Deserialize;\nuse tempfile::NamedTempFile;\nuse thiserror::Error;\nuse url::Url;\nuse utils::{command_ext::NoWindowExt, shell::resolve_executable_path_blocking};\n\nuse crate::types::{\n    CreatePrRequest, OpenPrInfo, PrComment, PrCommentAuthor, PrReviewComment, ReviewCommentUser,\n};\n\n#[derive(Debug, Clone)]\npub struct GitHubRepoInfo {\n    pub owner: String,\n    pub repo_name: String,\n    /// GitHub hostname (e.g., \"github.com\" or enterprise hostname)\n    pub hostname: Option<String>,\n}\n\nimpl GitHubRepoInfo {\n    pub fn repo_spec(&self) -> String {\n        match &self.hostname {\n            Some(host) => format!(\"{}/{}/{}\", host, self.owner, self.repo_name),\n            None => format!(\"{}/{}\", self.owner, self.repo_name),\n        }\n    }\n}\n\n#[derive(Deserialize)]\nstruct GhRepoViewResponse {\n    owner: GhRepoOwner,\n    name: String,\n    url: String,\n}\n\n#[derive(Deserialize)]\nstruct GhRepoOwner {\n    login: String,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct GhCommentResponse {\n    id: String,\n    author: Option<GhUserLogin>,\n    #[serde(default)]\n    author_association: String,\n    #[serde(default)]\n    body: String,\n    created_at: Option<DateTime<Utc>>,\n    #[serde(default)]\n    url: String,\n}\n\n#[derive(Deserialize)]\nstruct GhCommentsWrapper {\n    comments: Vec<GhCommentResponse>,\n}\n\n#[derive(Deserialize)]\nstruct GhUserLogin {\n    login: Option<String>,\n}\n\n#[derive(Deserialize)]\nstruct GhReviewCommentResponse {\n    id: i64,\n    user: Option<GhUserLogin>,\n    #[serde(default)]\n    body: String,\n    created_at: Option<DateTime<Utc>>,\n    #[serde(default)]\n    html_url: String,\n    #[serde(default)]\n    path: String,\n    line: Option<i64>,\n    side: Option<String>,\n    #[serde(default)]\n    diff_hunk: String,\n    #[serde(default)]\n    author_association: String,\n}\n\n#[derive(Deserialize)]\nstruct GhMergeCommit {\n    oid: Option<String>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct GhPrResponse {\n    number: i64,\n    url: String,\n    #[serde(default)]\n    state: String,\n    merged_at: Option<DateTime<Utc>>,\n    merge_commit: Option<GhMergeCommit>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct GhPrListExtendedResponse {\n    number: i64,\n    url: String,\n    #[serde(default)]\n    title: String,\n    head_ref_name: String,\n    base_ref_name: String,\n}\n\n#[derive(Debug, Error)]\npub enum GhCliError {\n    #[error(\"GitHub CLI (`gh`) executable not found or not runnable\")]\n    NotAvailable,\n    #[error(\"GitHub CLI command failed: {0}\")]\n    CommandFailed(String),\n    #[error(\"GitHub CLI authentication failed: {0}\")]\n    AuthFailed(String),\n    #[error(\"GitHub CLI returned unexpected output: {0}\")]\n    UnexpectedOutput(String),\n}\n\n#[derive(Debug, Clone, Default)]\npub struct GhCli;\n\nimpl GhCli {\n    pub fn new() -> Self {\n        Self {}\n    }\n\n    /// Ensure the GitHub CLI binary is discoverable.\n    fn ensure_available(&self) -> Result<(), GhCliError> {\n        resolve_executable_path_blocking(\"gh\").ok_or(GhCliError::NotAvailable)?;\n        Ok(())\n    }\n\n    fn run<I, S>(&self, args: I, dir: Option<&Path>) -> Result<String, GhCliError>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        self.ensure_available()?;\n        let gh = resolve_executable_path_blocking(\"gh\").ok_or(GhCliError::NotAvailable)?;\n        let mut cmd = Command::new(&gh);\n        if let Some(d) = dir {\n            cmd.current_dir(d);\n        }\n        for arg in args {\n            cmd.arg(arg);\n        }\n        let output = cmd\n            .no_window()\n            .output()\n            .map_err(|err| GhCliError::CommandFailed(err.to_string()))?;\n\n        if output.status.success() {\n            return Ok(String::from_utf8_lossy(&output.stdout).to_string());\n        }\n\n        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();\n\n        // Check exit code first - gh CLI uses exit code 4 for auth failures\n        if output.status.code() == Some(4) {\n            return Err(GhCliError::AuthFailed(stderr));\n        }\n\n        // Fall back to string matching for older gh versions or other auth scenarios\n        let lower = stderr.to_ascii_lowercase();\n        if lower.contains(\"authentication failed\")\n            || lower.contains(\"must authenticate\")\n            || lower.contains(\"bad credentials\")\n            || lower.contains(\"unauthorized\")\n            || lower.contains(\"gh auth login\")\n        {\n            return Err(GhCliError::AuthFailed(stderr));\n        }\n\n        Err(GhCliError::CommandFailed(stderr))\n    }\n\n    pub fn get_repo_info(\n        &self,\n        remote_url: &str,\n        repo_path: &Path,\n    ) -> Result<GitHubRepoInfo, GhCliError> {\n        let raw = self.run(\n            [\"repo\", \"view\", remote_url, \"--json\", \"owner,name,url\"],\n            Some(repo_path),\n        )?;\n        Self::parse_repo_info_response(&raw)\n    }\n\n    fn parse_repo_info_response(raw: &str) -> Result<GitHubRepoInfo, GhCliError> {\n        let resp: GhRepoViewResponse = serde_json::from_str(raw).map_err(|e| {\n            GhCliError::UnexpectedOutput(format!(\"Failed to parse gh repo view response: {e}\"))\n        })?;\n\n        let hostname = Url::parse(&resp.url)\n            .ok()\n            .and_then(|u| u.host_str().map(String::from));\n\n        Ok(GitHubRepoInfo {\n            owner: resp.owner.login,\n            repo_name: resp.name,\n            hostname,\n        })\n    }\n\n    /// Run `gh pr create` and parse the response.\n    ///\n    /// The `repo_path` parameter specifies the working directory for the command.\n    /// This is required for compatibility with older `gh` CLI versions (e.g., v2.4.0)\n    /// that require running from within a git repository.\n    pub fn create_pr(\n        &self,\n        request: &CreatePrRequest,\n        repo_info: &GitHubRepoInfo,\n        repo_path: &Path,\n    ) -> Result<PullRequestInfo, GhCliError> {\n        // Write body to temp file to avoid shell escaping and length issues\n        let body = request.body.as_deref().unwrap_or(\"\");\n        let mut body_file = NamedTempFile::new()\n            .map_err(|e| GhCliError::CommandFailed(format!(\"Failed to create temp file: {e}\")))?;\n        body_file\n            .write_all(body.as_bytes())\n            .map_err(|e| GhCliError::CommandFailed(format!(\"Failed to write body: {e}\")))?;\n\n        let repo_spec = repo_info.repo_spec();\n\n        let mut args: Vec<OsString> = Vec::with_capacity(14);\n        args.push(OsString::from(\"pr\"));\n        args.push(OsString::from(\"create\"));\n        args.push(OsString::from(\"--repo\"));\n        args.push(OsString::from(&repo_spec));\n        args.push(OsString::from(\"--head\"));\n        args.push(OsString::from(&request.head_branch));\n        args.push(OsString::from(\"--base\"));\n        args.push(OsString::from(&request.base_branch));\n        args.push(OsString::from(\"--title\"));\n        args.push(OsString::from(&request.title));\n        args.push(OsString::from(\"--body-file\"));\n        args.push(body_file.path().as_os_str().to_os_string());\n\n        if request.draft.unwrap_or(false) {\n            args.push(OsString::from(\"--draft\"));\n        }\n\n        let raw = self.run(args, Some(repo_path))?;\n        Self::parse_pr_create_text(&raw)\n    }\n\n    /// Retrieve details for a pull request by URL.\n    pub fn view_pr(&self, pr_url: &str) -> Result<PullRequestInfo, GhCliError> {\n        let raw = self.run(\n            [\n                \"pr\",\n                \"view\",\n                pr_url,\n                \"--json\",\n                \"number,url,state,mergedAt,mergeCommit\",\n            ],\n            None,\n        )?;\n        Self::parse_pr_view(&raw)\n    }\n\n    /// List pull requests for a branch (includes closed/merged).\n    pub fn list_prs_for_branch(\n        &self,\n        repo_info: &GitHubRepoInfo,\n        branch: &str,\n    ) -> Result<Vec<PullRequestInfo>, GhCliError> {\n        let repo_spec = repo_info.repo_spec();\n        let raw = self.run(\n            [\n                \"pr\",\n                \"list\",\n                \"--repo\",\n                &repo_spec,\n                \"--state\",\n                \"all\",\n                \"--head\",\n                branch,\n                \"--json\",\n                \"number,url,state,mergedAt,mergeCommit\",\n            ],\n            None,\n        )?;\n        Self::parse_pr_list(&raw)\n    }\n\n    pub fn list_open_prs(&self, owner: &str, repo: &str) -> Result<Vec<OpenPrInfo>, GhCliError> {\n        let raw = self.run(\n            [\n                \"pr\",\n                \"list\",\n                \"--repo\",\n                &format!(\"{owner}/{repo}\"),\n                \"--state\",\n                \"open\",\n                \"--json\",\n                \"number,url,title,headRefName,baseRefName\",\n            ],\n            None,\n        )?;\n        Self::parse_open_pr_list(&raw)\n    }\n\n    /// Fetch comments for a pull request.\n    pub fn get_pr_comments(\n        &self,\n        repo_info: &GitHubRepoInfo,\n        pr_number: i64,\n    ) -> Result<Vec<PrComment>, GhCliError> {\n        let repo_spec = repo_info.repo_spec();\n        let raw = self.run(\n            [\n                \"pr\",\n                \"view\",\n                &pr_number.to_string(),\n                \"--repo\",\n                &repo_spec,\n                \"--json\",\n                \"comments\",\n            ],\n            None,\n        )?;\n        Self::parse_pr_comments(&raw)\n    }\n\n    /// Fetch inline review comments for a pull request via API.\n    pub fn get_pr_review_comments(\n        &self,\n        repo_info: &GitHubRepoInfo,\n        pr_number: i64,\n    ) -> Result<Vec<PrReviewComment>, GhCliError> {\n        let mut args = vec![\n            \"api\".to_string(),\n            format!(\n                \"repos/{}/{}/pulls/{}/comments\",\n                repo_info.owner, repo_info.repo_name, pr_number\n            ),\n        ];\n        if let Some(ref host) = repo_info.hostname {\n            args.push(\"--hostname\".to_string());\n            args.push(host.clone());\n        }\n        let raw = self.run(args, None)?;\n        Self::parse_pr_review_comments(&raw)\n    }\n\n    pub fn pr_checkout(\n        &self,\n        repo_path: &Path,\n        owner: &str,\n        repo: &str,\n        pr_number: i64,\n    ) -> Result<(), GhCliError> {\n        self.run(\n            [\n                \"pr\",\n                \"checkout\",\n                &pr_number.to_string(),\n                \"--repo\",\n                &format!(\"{owner}/{repo}\"),\n                \"--force\",\n            ],\n            Some(repo_path),\n        )?;\n        Ok(())\n    }\n}\n\nimpl GhCli {\n    fn parse_pr_create_text(raw: &str) -> Result<PullRequestInfo, GhCliError> {\n        let pr_url = raw\n            .lines()\n            .rev()\n            .flat_map(|line| line.split_whitespace())\n            .map(|token| token.trim_matches(|c: char| c == '<' || c == '>'))\n            .find(|token| token.starts_with(\"http\") && token.contains(\"/pull/\"))\n            .ok_or_else(|| {\n                GhCliError::UnexpectedOutput(format!(\n                    \"gh pr create did not return a pull request URL; raw output: {raw}\"\n                ))\n            })?\n            .trim_end_matches(['.', ',', ';'])\n            .to_string();\n\n        let number = pr_url\n            .rsplit('/')\n            .next()\n            .ok_or_else(|| {\n                GhCliError::UnexpectedOutput(format!(\n                    \"Failed to extract PR number from URL '{pr_url}'\"\n                ))\n            })?\n            .trim_end_matches(|c: char| !c.is_ascii_digit())\n            .parse::<i64>()\n            .map_err(|err| {\n                GhCliError::UnexpectedOutput(format!(\n                    \"Failed to parse PR number from URL '{pr_url}': {err}\"\n                ))\n            })?;\n\n        Ok(PullRequestInfo {\n            number,\n            url: pr_url,\n            status: MergeStatus::Open,\n            merged_at: None,\n            merge_commit_sha: None,\n        })\n    }\n\n    fn parse_pr_view(raw: &str) -> Result<PullRequestInfo, GhCliError> {\n        let pr: GhPrResponse = serde_json::from_str(raw.trim()).map_err(|err| {\n            GhCliError::UnexpectedOutput(format!(\n                \"Failed to parse gh pr view response: {err}; raw: {raw}\"\n            ))\n        })?;\n        Ok(Self::pr_response_to_info(pr))\n    }\n\n    fn parse_pr_list(raw: &str) -> Result<Vec<PullRequestInfo>, GhCliError> {\n        let prs: Vec<GhPrResponse> = serde_json::from_str(raw.trim()).map_err(|err| {\n            GhCliError::UnexpectedOutput(format!(\n                \"Failed to parse gh pr list response: {err}; raw: {raw}\"\n            ))\n        })?;\n        Ok(prs.into_iter().map(Self::pr_response_to_info).collect())\n    }\n\n    fn parse_open_pr_list(raw: &str) -> Result<Vec<OpenPrInfo>, GhCliError> {\n        let prs: Vec<GhPrListExtendedResponse> =\n            serde_json::from_str(raw.trim()).map_err(|err| {\n                GhCliError::UnexpectedOutput(format!(\n                    \"Failed to parse gh pr list response: {err}; raw: {raw}\"\n                ))\n            })?;\n        Ok(prs\n            .into_iter()\n            .map(|pr| OpenPrInfo {\n                number: pr.number,\n                url: pr.url,\n                title: pr.title,\n                head_branch: pr.head_ref_name,\n                base_branch: pr.base_ref_name,\n            })\n            .collect())\n    }\n\n    fn pr_response_to_info(pr: GhPrResponse) -> PullRequestInfo {\n        let state = if pr.state.is_empty() {\n            \"OPEN\"\n        } else {\n            &pr.state\n        };\n        PullRequestInfo {\n            number: pr.number,\n            url: pr.url,\n            status: match state.to_ascii_uppercase().as_str() {\n                \"OPEN\" => MergeStatus::Open,\n                \"MERGED\" => MergeStatus::Merged,\n                \"CLOSED\" => MergeStatus::Closed,\n                _ => MergeStatus::Unknown,\n            },\n            merged_at: pr.merged_at,\n            merge_commit_sha: pr.merge_commit.and_then(|c| c.oid),\n        }\n    }\n\n    fn parse_pr_comments(raw: &str) -> Result<Vec<PrComment>, GhCliError> {\n        let wrapper: GhCommentsWrapper = serde_json::from_str(raw.trim()).map_err(|err| {\n            GhCliError::UnexpectedOutput(format!(\n                \"Failed to parse gh pr view --json comments response: {err}; raw: {raw}\"\n            ))\n        })?;\n\n        Ok(wrapper\n            .comments\n            .into_iter()\n            .map(|c| PrComment {\n                id: c.id,\n                author: PrCommentAuthor {\n                    login: c\n                        .author\n                        .and_then(|a| a.login)\n                        .unwrap_or_else(|| \"unknown\".to_string()),\n                },\n                author_association: c.author_association,\n                body: c.body,\n                created_at: c.created_at.unwrap_or_else(Utc::now),\n                url: c.url,\n            })\n            .collect())\n    }\n\n    fn parse_pr_review_comments(raw: &str) -> Result<Vec<PrReviewComment>, GhCliError> {\n        let items: Vec<GhReviewCommentResponse> =\n            serde_json::from_str(raw.trim()).map_err(|err| {\n                GhCliError::UnexpectedOutput(format!(\n                    \"Failed to parse review comments API response: {err}; raw: {raw}\"\n                ))\n            })?;\n\n        Ok(items\n            .into_iter()\n            .map(|c| PrReviewComment {\n                id: c.id,\n                user: ReviewCommentUser {\n                    login: c\n                        .user\n                        .and_then(|u| u.login)\n                        .unwrap_or_else(|| \"unknown\".to_string()),\n                },\n                body: c.body,\n                created_at: c.created_at.unwrap_or_else(Utc::now),\n                html_url: c.html_url,\n                path: c.path,\n                line: c.line,\n                side: c.side,\n                diff_hunk: c.diff_hunk,\n                author_association: c.author_association,\n            })\n            .collect())\n    }\n}\n"
  },
  {
    "path": "crates/git-host/src/github/mod.rs",
    "content": "//! GitHub hosting service implementation.\n\nmod cli;\n\nuse std::{path::Path, time::Duration};\n\nuse async_trait::async_trait;\nuse backon::{ExponentialBuilder, Retryable};\npub use cli::GhCli;\nuse cli::{GhCliError, GitHubRepoInfo};\nuse db::models::merge::PullRequestInfo;\nuse tokio::task;\nuse tracing::info;\n\nuse crate::{\n    GitHostProvider,\n    types::{\n        CreatePrRequest, GitHostError, OpenPrInfo, PrComment, PrReviewComment, ProviderKind,\n        UnifiedPrComment,\n    },\n};\n\n#[derive(Debug, Clone)]\npub struct GitHubProvider {\n    gh_cli: GhCli,\n}\n\nimpl GitHubProvider {\n    pub fn new() -> Result<Self, GitHostError> {\n        Ok(Self {\n            gh_cli: GhCli::new(),\n        })\n    }\n\n    async fn get_repo_info(\n        &self,\n        remote_url: &str,\n        repo_path: &Path,\n    ) -> Result<GitHubRepoInfo, GitHostError> {\n        let cli = self.gh_cli.clone();\n        let url = remote_url.to_string();\n        let path = repo_path.to_path_buf();\n        task::spawn_blocking(move || cli.get_repo_info(&url, &path))\n            .await\n            .map_err(|err| {\n                GitHostError::Repository(format!(\"Failed to get repo info from URL: {err}\"))\n            })?\n            .map_err(Into::into)\n    }\n\n    async fn fetch_general_comments(\n        &self,\n        cli: &GhCli,\n        repo_info: &GitHubRepoInfo,\n        pr_number: i64,\n    ) -> Result<Vec<PrComment>, GitHostError> {\n        let cli = cli.clone();\n        let repo_info = repo_info.clone();\n\n        (|| async {\n            let cli = cli.clone();\n            let repo_info = repo_info.clone();\n\n            let comments = task::spawn_blocking(move || cli.get_pr_comments(&repo_info, pr_number))\n                .await\n                .map_err(|err| {\n                    GitHostError::PullRequest(format!(\n                        \"Failed to execute GitHub CLI for fetching PR comments: {err}\"\n                    ))\n                })?;\n            comments.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"GitHub API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn fetch_review_comments(\n        &self,\n        cli: &GhCli,\n        repo_info: &GitHubRepoInfo,\n        pr_number: i64,\n    ) -> Result<Vec<PrReviewComment>, GitHostError> {\n        let cli = cli.clone();\n        let repo_info = repo_info.clone();\n\n        (|| async {\n            let cli = cli.clone();\n            let repo_info = repo_info.clone();\n\n            let comments =\n                task::spawn_blocking(move || cli.get_pr_review_comments(&repo_info, pr_number))\n                    .await\n                    .map_err(|err| {\n                        GitHostError::PullRequest(format!(\n                            \"Failed to execute GitHub CLI for fetching review comments: {err}\"\n                        ))\n                    })?;\n            comments.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"GitHub API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n}\n\nimpl From<GhCliError> for GitHostError {\n    fn from(error: GhCliError) -> Self {\n        match &error {\n            GhCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()),\n            GhCliError::NotAvailable => GitHostError::CliNotInstalled {\n                provider: ProviderKind::GitHub,\n            },\n            GhCliError::CommandFailed(msg) => {\n                let lower = msg.to_ascii_lowercase();\n                if lower.contains(\"403\") || lower.contains(\"forbidden\") {\n                    GitHostError::InsufficientPermissions(msg.clone())\n                } else if lower.contains(\"404\") || lower.contains(\"not found\") {\n                    GitHostError::RepoNotFoundOrNoAccess(msg.clone())\n                } else if lower.contains(\"not a git repository\") {\n                    GitHostError::NotAGitRepository(msg.clone())\n                } else {\n                    GitHostError::PullRequest(msg.clone())\n                }\n            }\n            GhCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()),\n        }\n    }\n}\n\n#[async_trait]\nimpl GitHostProvider for GitHubProvider {\n    async fn create_pr(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        request: &CreatePrRequest,\n    ) -> Result<PullRequestInfo, GitHostError> {\n        // Get owner/repo from the remote URL (target repo for the PR).\n        let target_repo_info = self.get_repo_info(remote_url, repo_path).await?;\n\n        // For cross-fork PRs, get the head repo info to format head_branch as \"owner:branch\".\n        let head_branch = if let Some(head_url) = &request.head_repo_url {\n            let head_repo_info = self.get_repo_info(head_url, repo_path).await?;\n            if head_repo_info.owner != target_repo_info.owner {\n                format!(\"{}:{}\", head_repo_info.owner, request.head_branch)\n            } else {\n                request.head_branch.clone()\n            }\n        } else {\n            request.head_branch.clone()\n        };\n\n        let mut request_clone = request.clone();\n        request_clone.head_branch = head_branch;\n\n        (|| async {\n            let cli = self.gh_cli.clone();\n            let request = request_clone.clone();\n            let target_repo = target_repo_info.clone();\n            let repo_path = repo_path.to_path_buf();\n\n            let cli_result =\n                task::spawn_blocking(move || cli.create_pr(&request, &target_repo, &repo_path))\n                    .await\n                    .map_err(|err| {\n                        GitHostError::PullRequest(format!(\n                            \"Failed to execute GitHub CLI for PR creation: {err}\"\n                        ))\n                    })?\n                    .map_err(GitHostError::from)?;\n\n            info!(\n                \"Created GitHub PR #{} for branch {}\",\n                cli_result.number, request_clone.head_branch\n            );\n\n            Ok(cli_result)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"GitHub API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn get_pr_status(&self, pr_url: &str) -> Result<PullRequestInfo, GitHostError> {\n        let cli = self.gh_cli.clone();\n        let url = pr_url.to_string();\n\n        (|| async {\n            let cli = cli.clone();\n            let url = url.clone();\n            let pr = task::spawn_blocking(move || cli.view_pr(&url))\n                .await\n                .map_err(|err| {\n                    GitHostError::PullRequest(format!(\n                        \"Failed to execute GitHub CLI for viewing PR: {err}\"\n                    ))\n                })?;\n            pr.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|err: &GitHostError| err.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"GitHub API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn list_prs_for_branch(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        branch_name: &str,\n    ) -> Result<Vec<PullRequestInfo>, GitHostError> {\n        let repo_info = self.get_repo_info(remote_url, repo_path).await?;\n\n        let cli = self.gh_cli.clone();\n        let branch = branch_name.to_string();\n\n        (|| async {\n            let cli = cli.clone();\n            let repo_info = repo_info.clone();\n            let branch = branch.clone();\n\n            let prs = task::spawn_blocking(move || cli.list_prs_for_branch(&repo_info, &branch))\n                .await\n                .map_err(|err| {\n                    GitHostError::PullRequest(format!(\n                        \"Failed to execute GitHub CLI for listing PRs: {err}\"\n                    ))\n                })?;\n            prs.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"GitHub API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    async fn get_pr_comments(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        pr_number: i64,\n    ) -> Result<Vec<UnifiedPrComment>, GitHostError> {\n        let repo_info = self.get_repo_info(remote_url, repo_path).await?;\n\n        // Fetch both types of comments in parallel\n        let cli1 = self.gh_cli.clone();\n        let cli2 = self.gh_cli.clone();\n\n        let (general_result, review_result) = tokio::join!(\n            self.fetch_general_comments(&cli1, &repo_info, pr_number),\n            self.fetch_review_comments(&cli2, &repo_info, pr_number)\n        );\n\n        let general_comments = general_result?;\n        let review_comments = review_result?;\n\n        // Convert and merge into unified timeline\n        let mut unified: Vec<UnifiedPrComment> = Vec::new();\n\n        for c in general_comments {\n            unified.push(UnifiedPrComment::General {\n                id: c.id,\n                author: c.author.login,\n                author_association: Some(c.author_association),\n                body: c.body,\n                created_at: c.created_at,\n                url: Some(c.url),\n            });\n        }\n\n        for c in review_comments {\n            unified.push(UnifiedPrComment::Review {\n                id: c.id,\n                author: c.user.login,\n                author_association: Some(c.author_association),\n                body: c.body,\n                created_at: c.created_at,\n                url: Some(c.html_url),\n                path: c.path,\n                line: c.line,\n                side: c.side,\n                diff_hunk: Some(c.diff_hunk),\n            });\n        }\n\n        // Sort by creation time\n        unified.sort_by_key(|c| c.created_at());\n\n        Ok(unified)\n    }\n\n    async fn list_open_prs(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n    ) -> Result<Vec<OpenPrInfo>, GitHostError> {\n        let repo_info = self.get_repo_info(remote_url, repo_path).await?;\n\n        let cli = self.gh_cli.clone();\n\n        (|| async {\n            let cli = cli.clone();\n            let owner = repo_info.owner.clone();\n            let repo_name = repo_info.repo_name.clone();\n\n            let prs = task::spawn_blocking(move || cli.list_open_prs(&owner, &repo_name))\n                .await\n                .map_err(|err| {\n                    GitHostError::PullRequest(format!(\n                        \"Failed to execute GitHub CLI for listing open PRs: {err}\"\n                    ))\n                })?;\n            prs.map_err(GitHostError::from)\n        })\n        .retry(\n            &ExponentialBuilder::default()\n                .with_min_delay(Duration::from_secs(1))\n                .with_max_delay(Duration::from_secs(30))\n                .with_max_times(3)\n                .with_jitter(),\n        )\n        .when(|e: &GitHostError| e.should_retry())\n        .notify(|err: &GitHostError, dur: Duration| {\n            tracing::warn!(\n                \"GitHub API call failed, retrying after {:.2}s: {}\",\n                dur.as_secs_f64(),\n                err\n            );\n        })\n        .await\n    }\n\n    fn provider_kind(&self) -> ProviderKind {\n        ProviderKind::GitHub\n    }\n}\n"
  },
  {
    "path": "crates/git-host/src/lib.rs",
    "content": "mod detection;\nmod types;\n\npub mod azure;\npub mod github;\n\nuse std::path::Path;\n\nuse async_trait::async_trait;\nuse db::models::merge::PullRequestInfo;\nuse detection::detect_provider_from_url;\nuse enum_dispatch::enum_dispatch;\npub use types::{\n    CreatePrRequest, GitHostError, OpenPrInfo, PrComment, PrCommentAuthor, PrReviewComment,\n    ProviderKind, ReviewCommentUser, UnifiedPrComment,\n};\n\nuse self::{azure::AzureDevOpsProvider, github::GitHubProvider};\n\n#[async_trait]\n#[enum_dispatch(GitHostService)]\npub trait GitHostProvider: Send + Sync {\n    async fn create_pr(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        request: &CreatePrRequest,\n    ) -> Result<PullRequestInfo, GitHostError>;\n\n    async fn get_pr_status(&self, pr_url: &str) -> Result<PullRequestInfo, GitHostError>;\n\n    async fn list_prs_for_branch(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        branch_name: &str,\n    ) -> Result<Vec<PullRequestInfo>, GitHostError>;\n\n    async fn get_pr_comments(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n        pr_number: i64,\n    ) -> Result<Vec<UnifiedPrComment>, GitHostError>;\n\n    async fn list_open_prs(\n        &self,\n        repo_path: &Path,\n        remote_url: &str,\n    ) -> Result<Vec<OpenPrInfo>, GitHostError>;\n\n    fn provider_kind(&self) -> ProviderKind;\n}\n\n#[enum_dispatch]\npub enum GitHostService {\n    GitHub(GitHubProvider),\n    AzureDevOps(AzureDevOpsProvider),\n}\n\nimpl GitHostService {\n    pub fn from_url(url: &str) -> Result<Self, GitHostError> {\n        match detect_provider_from_url(url) {\n            ProviderKind::GitHub => Ok(Self::GitHub(GitHubProvider::new()?)),\n            ProviderKind::AzureDevOps => Ok(Self::AzureDevOps(AzureDevOpsProvider::new()?)),\n            ProviderKind::Unknown => Err(GitHostError::UnsupportedProvider),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/git-host/src/types.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\npub enum ProviderKind {\n    GitHub,\n    AzureDevOps,\n    Unknown,\n}\n\nimpl std::fmt::Display for ProviderKind {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            ProviderKind::GitHub => write!(f, \"GitHub\"),\n            ProviderKind::AzureDevOps => write!(f, \"Azure DevOps\"),\n            ProviderKind::Unknown => write!(f, \"Unknown\"),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct CreatePrRequest {\n    pub title: String,\n    pub body: Option<String>,\n    pub head_branch: String,\n    pub base_branch: String,\n    pub draft: Option<bool>,\n    /// URL of the repo containing the head branch (for cross-fork PRs).\n    pub head_repo_url: Option<String>,\n}\n\n#[derive(Debug, Error)]\npub enum GitHostError {\n    #[error(\"Repository error: {0}\")]\n    Repository(String),\n    #[error(\"Pull request error: {0}\")]\n    PullRequest(String),\n    #[error(\"Authentication failed: {0}\")]\n    AuthFailed(String),\n    #[error(\"Insufficient permissions: {0}\")]\n    InsufficientPermissions(String),\n    #[error(\"Repository not found or no access: {0}\")]\n    RepoNotFoundOrNoAccess(String),\n    #[error(\"{provider} CLI is not installed or not available in PATH\")]\n    CliNotInstalled { provider: ProviderKind },\n    #[error(\"Not a git repository: {0}\")]\n    NotAGitRepository(String),\n    #[error(\"Unsupported git hosting provider\")]\n    UnsupportedProvider,\n    #[error(\"CLI returned unexpected output: {0}\")]\n    UnexpectedOutput(String),\n}\n\nimpl GitHostError {\n    pub fn should_retry(&self) -> bool {\n        !matches!(\n            self,\n            GitHostError::AuthFailed(_)\n                | GitHostError::InsufficientPermissions(_)\n                | GitHostError::RepoNotFoundOrNoAccess(_)\n                | GitHostError::CliNotInstalled { .. }\n                | GitHostError::NotAGitRepository(_)\n                | GitHostError::UnsupportedProvider\n        )\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct PrCommentAuthor {\n    pub login: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\npub struct PrComment {\n    pub id: String,\n    pub author: PrCommentAuthor,\n    pub author_association: String,\n    pub body: String,\n    pub created_at: DateTime<Utc>,\n    pub url: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ReviewCommentUser {\n    pub login: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct PrReviewComment {\n    pub id: i64,\n    pub user: ReviewCommentUser,\n    pub body: String,\n    pub created_at: DateTime<Utc>,\n    pub html_url: String,\n    pub path: String,\n    pub line: Option<i64>,\n    pub side: Option<String>,\n    pub diff_hunk: String,\n    pub author_association: String,\n}\n\n#[derive(Debug, Clone, Serialize, TS)]\n#[serde(tag = \"comment_type\", rename_all = \"snake_case\")]\n#[ts(tag = \"comment_type\", rename_all = \"snake_case\")]\npub enum UnifiedPrComment {\n    General {\n        id: String,\n        author: String,\n        author_association: Option<String>,\n        body: String,\n        created_at: DateTime<Utc>,\n        url: Option<String>,\n    },\n    Review {\n        id: i64,\n        author: String,\n        author_association: Option<String>,\n        body: String,\n        created_at: DateTime<Utc>,\n        url: Option<String>,\n        path: String,\n        line: Option<i64>,\n        side: Option<String>,\n        diff_hunk: Option<String>,\n    },\n}\n\nimpl UnifiedPrComment {\n    pub fn created_at(&self) -> DateTime<Utc> {\n        match self {\n            UnifiedPrComment::General { created_at, .. } => *created_at,\n            UnifiedPrComment::Review { created_at, .. } => *created_at,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct OpenPrInfo {\n    pub number: i64,\n    pub url: String,\n    pub title: String,\n    pub head_branch: String,\n    pub base_branch: String,\n}\n"
  },
  {
    "path": "crates/local-deployment/Cargo.toml",
    "content": "[package]\nname = \"local-deployment\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\napi-types = { path = \"../api-types\" }\ndb = { path = \"../db\" }\nexecutors = { path=\"../executors\" }\ndeployment = { path = \"../deployment\" }\nrelay-control = { path = \"../relay-control\" }\nserver-info = { path = \"../server-info\" }\nservices = { path = \"../services\" }\nworktree-manager = { path = \"../worktree-manager\" }\nworkspace-manager = { path = \"../workspace-manager\" }\nutils = { path = \"../utils\" }\ngit = { path = \"../git\" }\ntrusted-key-auth = { path = \"../trusted-key-auth\" }\ntokio-util = { version = \"0.7\", features = [\"io\"] }\nserde_json = { workspace = true }\nanyhow = { workspace = true }\ntracing = { workspace = true }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nasync-trait = { workspace = true }\nthiserror = { workspace = true }\ncommand-group = { version = \"5.0\", features = [\"with-tokio\"] }\nfutures = \"0.3\"\ntokio = { workspace = true }\nglobwalk = \"0.9\"\nportable-pty = \"0.8\"\n\n[build-dependencies]\ndotenv = \"0.15\"\n\n[dev-dependencies]\ntempfile = \"3.8\"\n"
  },
  {
    "path": "crates/local-deployment/build.rs",
    "content": "use std::path::Path;\n\nfn main() {\n    // Load .env from the workspace root\n    let workspace_root = Path::new(env!(\"CARGO_MANIFEST_DIR\")).join(\"../..\");\n    let env_file = workspace_root.join(\".env\");\n    dotenv::from_path(&env_file).ok();\n\n    // Recompile when VK_SHARED_API_BASE changes, since it's read via option_env!()\n    println!(\"cargo:rerun-if-env-changed=VK_SHARED_API_BASE\");\n    if env_file.exists() {\n        println!(\"cargo:rerun-if-changed={}\", env_file.display());\n    }\n\n    // Pass VK_SHARED_API_BASE to the compiler so option_env!() sees it\n    if let Ok(val) = std::env::var(\"VK_SHARED_API_BASE\") {\n        println!(\"cargo:rustc-env=VK_SHARED_API_BASE={}\", val);\n    }\n}\n"
  },
  {
    "path": "crates/local-deployment/src/command.rs",
    "content": "use command_group::AsyncGroupChild;\nuse services::services::container::ContainerError;\n\npub async fn kill_process_group(child: &mut AsyncGroupChild) -> Result<(), ContainerError> {\n    utils::process::kill_process_group(child)\n        .await\n        .map_err(ContainerError::KillFailed)\n}\n"
  },
  {
    "path": "crates/local-deployment/src/container.rs",
    "content": "use std::{\n    collections::HashMap,\n    io,\n    path::{Path, PathBuf},\n    sync::Arc,\n    time::{Duration, Instant},\n};\n\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse command_group::AsyncGroupChild;\nuse db::{\n    DBService,\n    models::{\n        coding_agent_turn::CodingAgentTurn,\n        execution_process::{\n            ExecutionContext, ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus,\n        },\n        execution_process_repo_state::ExecutionProcessRepoState,\n        repo::Repo,\n        scratch::{DraftFollowUpData, Scratch, ScratchType},\n        session::{Session, SessionError},\n        workspace::Workspace,\n        workspace_repo::WorkspaceRepo,\n    },\n};\nuse deployment::DeploymentError;\nuse executors::{\n    actions::{\n        Executable, ExecutorAction, ExecutorActionType,\n        coding_agent_follow_up::CodingAgentFollowUpRequest,\n        coding_agent_initial::CodingAgentInitialRequest,\n    },\n    approvals::{ExecutorApprovalService, NoopExecutorApprovalService},\n    env::{ExecutionEnv, RepoContext},\n    executors::{BaseCodingAgent, CancellationToken, ExecutorExitResult, ExecutorExitSignal},\n    logs::{NormalizedEntryType, utils::patch::extract_normalized_entry_from_patch},\n};\nuse futures::{FutureExt, TryStreamExt, stream::select};\nuse git::GitService;\nuse serde_json::json;\nuse services::services::{\n    analytics::AnalyticsContext,\n    approvals::{Approvals, executor_approvals::ExecutorApprovalBridge},\n    config::{Config, DEFAULT_COMMIT_REMINDER_PROMPT},\n    container::{ContainerError, ContainerRef, ContainerService},\n    diff_stream::{self, DiffStreamHandle},\n    file::FileService,\n    notification::NotificationService,\n    queued_message::QueuedMessageService,\n    remote_client::RemoteClient,\n    remote_sync,\n};\nuse tokio::{sync::RwLock, task::JoinHandle};\nuse tokio_util::io::ReaderStream;\nuse utils::{\n    log_msg::LogMsg,\n    msg_store::MsgStore,\n    text::{git_branch_id, short_uuid, truncate_to_char_boundary},\n};\nuse uuid::Uuid;\nuse workspace_manager::{RepoWorkspaceInput, WorkspaceError, WorkspaceManager};\n\nuse crate::{command, copy};\n\nconst WORKSPACE_TOUCH_DEBOUNCE: Duration = Duration::from_mins(2);\n\n#[derive(Clone)]\npub struct LocalContainerService {\n    db: DBService,\n    workspace_manager: WorkspaceManager,\n    child_store: Arc<RwLock<HashMap<Uuid, Arc<RwLock<AsyncGroupChild>>>>>,\n    cancellation_tokens: Arc<RwLock<HashMap<Uuid, CancellationToken>>>,\n    msg_stores: Arc<RwLock<HashMap<Uuid, Arc<MsgStore>>>>,\n    /// Tracks background tasks that stream logs to the database.\n    /// When stopping execution, we await these to ensure logs are fully persisted.\n    db_stream_handles: Arc<RwLock<HashMap<Uuid, JoinHandle<()>>>>,\n    exit_monitor_handles: Arc<RwLock<HashMap<Uuid, JoinHandle<()>>>>,\n    workspace_touch_times: Arc<RwLock<HashMap<Uuid, Instant>>>,\n    config: Arc<RwLock<Config>>,\n    git: GitService,\n    file_service: FileService,\n    analytics: Option<AnalyticsContext>,\n    approvals: Approvals,\n    queued_message_service: QueuedMessageService,\n    notification_service: NotificationService,\n    remote_client: Option<RemoteClient>,\n}\n\nimpl LocalContainerService {\n    #[allow(clippy::too_many_arguments)]\n    pub async fn new(\n        db: DBService,\n        workspace_manager: WorkspaceManager,\n        msg_stores: Arc<RwLock<HashMap<Uuid, Arc<MsgStore>>>>,\n        config: Arc<RwLock<Config>>,\n        git: GitService,\n        file_service: FileService,\n        analytics: Option<AnalyticsContext>,\n        approvals: Approvals,\n        queued_message_service: QueuedMessageService,\n        remote_client: Option<RemoteClient>,\n    ) -> Self {\n        let child_store = Arc::new(RwLock::new(HashMap::new()));\n        let cancellation_tokens = Arc::new(RwLock::new(HashMap::new()));\n        let db_stream_handles = Arc::new(RwLock::new(HashMap::new()));\n        let exit_monitor_handles = Arc::new(RwLock::new(HashMap::new()));\n        let workspace_touch_times = Arc::new(RwLock::new(HashMap::new()));\n        let notification_service = NotificationService::new(config.clone());\n\n        let container = LocalContainerService {\n            db,\n            workspace_manager,\n            child_store,\n            cancellation_tokens,\n            msg_stores,\n            db_stream_handles,\n            exit_monitor_handles,\n            workspace_touch_times,\n            config,\n            git,\n            file_service,\n            analytics,\n            approvals,\n            queued_message_service,\n            notification_service,\n            remote_client,\n        };\n\n        container.spawn_workspace_cleanup();\n\n        container\n    }\n\n    fn map_workspace_manager_error(err: WorkspaceError) -> ContainerError {\n        match err {\n            WorkspaceError::Database(err) => ContainerError::Sqlx(err),\n            WorkspaceError::Worktree(err) => ContainerError::Worktree(err),\n            WorkspaceError::GitService(err) => ContainerError::GitServiceError(err),\n            WorkspaceError::Io(err) => ContainerError::Io(err),\n            WorkspaceError::NoRepositories => {\n                ContainerError::Other(anyhow!(\"No repositories provided\"))\n            }\n            WorkspaceError::Repo(err) => ContainerError::Other(anyhow!(err)),\n            WorkspaceError::WorkspaceNotFound => {\n                ContainerError::Other(anyhow!(\"Workspace not found\"))\n            }\n            WorkspaceError::RepoAlreadyAttached => {\n                ContainerError::Other(anyhow!(\"Repository already attached to workspace\"))\n            }\n            WorkspaceError::BranchNotFound { repo_name, branch } => ContainerError::Other(anyhow!(\n                \"Branch '{}' does not exist in repository '{}'\",\n                branch,\n                repo_name\n            )),\n            WorkspaceError::PartialCreation(msg) => ContainerError::Other(anyhow!(msg)),\n        }\n    }\n\n    async fn workspace_repo_inputs(\n        &self,\n        workspace_id: Uuid,\n    ) -> Result<(Vec<Repo>, Vec<RepoWorkspaceInput>), ContainerError> {\n        let workspace_repos =\n            WorkspaceRepo::find_by_workspace_id(&self.db.pool, workspace_id).await?;\n        if workspace_repos.is_empty() {\n            return Err(ContainerError::Other(anyhow!(\n                \"Workspace has no repositories configured\"\n            )));\n        }\n\n        let repositories =\n            WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace_id).await?;\n        let target_branches: HashMap<_, _> = workspace_repos\n            .iter()\n            .map(|wr| (wr.repo_id, wr.target_branch.clone()))\n            .collect();\n\n        let workspace_inputs: Vec<RepoWorkspaceInput> = repositories\n            .iter()\n            .map(|repo| {\n                let target_branch = target_branches.get(&repo.id).cloned().ok_or_else(|| {\n                    ContainerError::Other(anyhow!(\n                        \"Missing target branch mapping for repo {} in workspace {}\",\n                        repo.id,\n                        workspace_id\n                    ))\n                })?;\n                Ok(RepoWorkspaceInput::new(repo.clone(), target_branch))\n            })\n            .collect::<Result<_, ContainerError>>()?;\n\n        Ok((repositories, workspace_inputs))\n    }\n\n    pub async fn get_child_from_store(&self, id: &Uuid) -> Option<Arc<RwLock<AsyncGroupChild>>> {\n        let map = self.child_store.read().await;\n        map.get(id).cloned()\n    }\n\n    pub async fn add_child_to_store(&self, id: Uuid, exec: AsyncGroupChild) {\n        let mut map = self.child_store.write().await;\n        map.insert(id, Arc::new(RwLock::new(exec)));\n    }\n\n    pub async fn remove_child_from_store(&self, id: &Uuid) {\n        let mut map = self.child_store.write().await;\n        map.remove(id);\n    }\n\n    async fn add_cancellation_token(&self, id: Uuid, token: CancellationToken) {\n        let mut map = self.cancellation_tokens.write().await;\n        map.insert(id, token);\n    }\n\n    async fn take_cancellation_token(&self, id: &Uuid) -> Option<CancellationToken> {\n        let mut map = self.cancellation_tokens.write().await;\n        map.remove(id)\n    }\n\n    async fn add_db_stream_handle(&self, id: Uuid, handle: JoinHandle<()>) {\n        let mut map = self.db_stream_handles.write().await;\n        map.insert(id, handle);\n    }\n\n    async fn take_db_stream_handle(&self, id: &Uuid) -> Option<JoinHandle<()>> {\n        let mut map = self.db_stream_handles.write().await;\n        map.remove(id)\n    }\n\n    async fn add_exit_monitor_handle(&self, id: Uuid, handle: JoinHandle<()>) {\n        let mut map = self.exit_monitor_handles.write().await;\n        map.insert(id, handle);\n    }\n\n    async fn take_exit_monitor_handle(&self, id: &Uuid) -> Option<JoinHandle<()>> {\n        let mut map = self.exit_monitor_handles.write().await;\n        map.remove(id)\n    }\n\n    pub async fn cleanup_workspace(&self, workspace: &Workspace) {\n        let Some(container_ref) = &workspace.container_ref else {\n            return;\n        };\n        let workspace_dir = PathBuf::from(container_ref);\n\n        let repositories = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id)\n            .await\n            .unwrap_or_default();\n\n        if repositories.is_empty() {\n            tracing::warn!(\n                \"No repositories found for workspace {}, cleaning up workspace directory only\",\n                workspace.id\n            );\n            if workspace_dir.exists()\n                && let Err(e) = tokio::fs::remove_dir_all(&workspace_dir).await\n            {\n                tracing::warn!(\"Failed to remove workspace directory: {}\", e);\n            }\n        } else {\n            WorkspaceManager::cleanup_workspace(&workspace_dir, &repositories)\n                .await\n                .unwrap_or_else(|e| {\n                    tracing::warn!(\n                        \"Failed to clean up workspace for workspace {}: {}\",\n                        workspace.id,\n                        e\n                    );\n                });\n        }\n\n        let _ = Workspace::mark_worktree_deleted(&self.db.pool, workspace.id).await;\n    }\n\n    pub async fn cleanup_expired_workspaces(&self) -> Result<(), DeploymentError> {\n        if std::env::var(\"DISABLE_WORKTREE_CLEANUP\").is_ok() {\n            tracing::info!(\n                \"Expired workspace cleanup is disabled via DISABLE_WORKTREE_CLEANUP environment variable\"\n            );\n            return Ok(());\n        }\n\n        let expired_workspaces = Workspace::find_expired_for_cleanup(&self.db.pool).await?;\n        if expired_workspaces.is_empty() {\n            tracing::debug!(\"No expired workspaces found\");\n            return Ok(());\n        }\n        tracing::info!(\n            \"Found {} expired workspaces to clean up\",\n            expired_workspaces.len()\n        );\n        for workspace in &expired_workspaces {\n            self.cleanup_workspace(workspace).await;\n        }\n        Ok(())\n    }\n\n    pub fn spawn_workspace_cleanup(&self) {\n        let container = self.clone();\n        tokio::spawn(async move {\n            container\n                .workspace_manager\n                .cleanup_orphan_workspaces()\n                .await;\n\n            let mut cleanup_interval =\n                tokio::time::interval(tokio::time::Duration::from_secs(1800)); // 30 minutes\n            loop {\n                cleanup_interval.tick().await;\n                tracing::info!(\"Starting periodic workspace cleanup...\");\n                container\n                    .cleanup_expired_workspaces()\n                    .await\n                    .unwrap_or_else(|e| {\n                        tracing::error!(\"Failed to clean up expired workspaces: {}\", e)\n                    });\n            }\n        });\n    }\n\n    /// Record the current HEAD commit for each repository as the \"after\" state.\n    /// Errors are silently ignored since this runs after the main execution completes\n    /// and failure should not block process finalization.\n    async fn update_after_head_commits(&self, exec_id: Uuid) {\n        if let Ok(ctx) = ExecutionProcess::load_context(&self.db.pool, exec_id).await {\n            let workspace_root = self.workspace_to_current_dir(&ctx.workspace);\n            for repo in &ctx.repos {\n                let repo_path = workspace_root.join(&repo.name);\n                if let Ok(head) = self.git().get_head_info(&repo_path) {\n                    let _ = ExecutionProcessRepoState::update_after_head_commit(\n                        &self.db.pool,\n                        exec_id,\n                        repo.id,\n                        &head.oid,\n                    )\n                    .await;\n                }\n            }\n        }\n    }\n\n    /// Get the commit message based on the execution run reason.\n    async fn get_commit_message(&self, ctx: &ExecutionContext) -> String {\n        match ctx.execution_process.run_reason {\n            ExecutionProcessRunReason::CodingAgent => {\n                // Try to retrieve the task summary from the coding agent turn\n                // otherwise fallback to default message\n                match CodingAgentTurn::find_by_execution_process_id(\n                    &self.db().pool,\n                    ctx.execution_process.id,\n                )\n                .await\n                {\n                    Ok(Some(turn)) if turn.summary.is_some() => turn.summary.unwrap(),\n                    Ok(_) => {\n                        tracing::debug!(\n                            \"No summary found for execution process {}, using default message\",\n                            ctx.execution_process.id\n                        );\n                        format!(\n                            \"Commit changes from coding agent for workspace {}\",\n                            ctx.workspace.id\n                        )\n                    }\n                    Err(e) => {\n                        tracing::debug!(\n                            \"Failed to retrieve summary for execution process {}: {}\",\n                            ctx.execution_process.id,\n                            e\n                        );\n                        format!(\n                            \"Commit changes from coding agent for workspace {}\",\n                            ctx.workspace.id\n                        )\n                    }\n                }\n            }\n            ExecutionProcessRunReason::CleanupScript => {\n                format!(\"Cleanup script changes for workspace {}\", ctx.workspace.id)\n            }\n            _ => format!(\n                \"Changes from execution process {}\",\n                ctx.execution_process.id\n            ),\n        }\n    }\n\n    /// Check which repos have uncommitted changes. Fails if any repo is inaccessible.\n    fn check_repos_for_changes(\n        &self,\n        workspace_root: &Path,\n        repos: &[Repo],\n    ) -> Result<Vec<(Repo, PathBuf)>, ContainerError> {\n        let git = GitService::new();\n        let mut repos_with_changes = Vec::new();\n\n        for repo in repos {\n            let worktree_path = workspace_root.join(&repo.name);\n\n            match git.get_worktree_status(&worktree_path) {\n                Ok(ws) if !ws.entries.is_empty() => {\n                    repos_with_changes.push((repo.clone(), worktree_path));\n                }\n                Ok(_) => {\n                    tracing::debug!(\"No changes in repo '{}'\", repo.name);\n                }\n                Err(e) => {\n                    return Err(ContainerError::Other(anyhow!(\n                        \"Pre-flight check failed for repo '{}': {}\",\n                        repo.name,\n                        e\n                    )));\n                }\n            }\n        }\n\n        Ok(repos_with_changes)\n    }\n\n    async fn has_commits_from_execution(\n        &self,\n        ctx: &ExecutionContext,\n    ) -> Result<bool, ContainerError> {\n        let workspace_root = self.workspace_to_current_dir(&ctx.workspace);\n\n        let repo_states = ExecutionProcessRepoState::find_by_execution_process_id(\n            &self.db.pool,\n            ctx.execution_process.id,\n        )\n        .await?;\n\n        for repo in &ctx.repos {\n            let repo_path = workspace_root.join(&repo.name);\n            let current_head = self.git().get_head_info(&repo_path).ok().map(|h| h.oid);\n\n            let before_head = repo_states\n                .iter()\n                .find(|s| s.repo_id == repo.id)\n                .and_then(|s| s.before_head_commit.clone());\n\n            if current_head != before_head {\n                return Ok(true);\n            }\n        }\n\n        Ok(false)\n    }\n\n    /// Commit changes to each repo. Logs failures but continues with other repos.\n    fn commit_repos(&self, repos_with_changes: Vec<(Repo, PathBuf)>, message: &str) -> bool {\n        let mut any_committed = false;\n\n        for (repo, worktree_path) in repos_with_changes {\n            tracing::debug!(\n                \"Committing changes for repo '{}' at {:?}\",\n                repo.name,\n                &worktree_path\n            );\n\n            match self.git().commit(&worktree_path, message) {\n                Ok(true) => {\n                    any_committed = true;\n                    tracing::info!(\"Committed changes in repo '{}'\", repo.name);\n                }\n                Ok(false) => {\n                    tracing::warn!(\"No changes committed in repo '{}' (unexpected)\", repo.name);\n                }\n                Err(e) => {\n                    tracing::warn!(\"Failed to commit in repo '{}': {}\", repo.name, e);\n                }\n            }\n        }\n\n        any_committed\n    }\n\n    /// Spawn a background task that polls the child process for completion and\n    /// cleans up the execution entry when it exits.\n    pub fn spawn_exit_monitor(\n        &self,\n        exec_id: &Uuid,\n        exit_signal: Option<ExecutorExitSignal>,\n    ) -> JoinHandle<()> {\n        let exec_id = *exec_id;\n        let child_store = self.child_store.clone();\n        let msg_stores = self.msg_stores.clone();\n        let db = self.db.clone();\n        let config = self.config.clone();\n        let container = self.clone();\n        let analytics = self.analytics.clone();\n\n        let mut process_exit_rx = self.spawn_os_exit_watcher(exec_id);\n\n        tokio::spawn(async move {\n            let mut exit_signal_future = exit_signal\n                .map(|rx| rx.boxed()) // wait for result\n                .unwrap_or_else(|| std::future::pending().boxed()); // no signal, stall forever\n\n            let status_result: std::io::Result<std::process::ExitStatus>;\n\n            // Wait for process to exit, or exit signal from executor\n            tokio::select! {\n                // Exit signal with result.\n                // Some coding agent processes do not automatically exit after processing the user request; instead the executor\n                // signals when processing has finished to gracefully kill the process.\n                exit_result = &mut exit_signal_future => {\n                    // Executor signaled completion: kill group and use the provided result\n                    if let Some(child_lock) = child_store.read().await.get(&exec_id).cloned() {\n                        let mut child = child_lock.write().await ;\n                        if let Err(err) = command::kill_process_group(&mut child).await {\n                            tracing::error!(\"Failed to kill process group after exit signal: {} {}\", exec_id, err);\n                        }\n                    }\n\n                    // Map the exit result to appropriate exit status\n                    status_result = match exit_result {\n                        Ok(ExecutorExitResult::Success) => Ok(success_exit_status()),\n                        Ok(ExecutorExitResult::Failure) => Ok(failure_exit_status()),\n                        Err(_) => Ok(success_exit_status()), // Channel closed, assume success\n                    };\n                }\n                // Process exit\n                exit_status_result = &mut process_exit_rx => {\n                    status_result = exit_status_result.unwrap_or_else(|e| Err(std::io::Error::other(e)));\n                }\n            }\n\n            let (exit_code, status) = match status_result {\n                Ok(exit_status) => {\n                    let code = exit_status.code().unwrap_or(-1) as i64;\n                    let status = if exit_status.success() {\n                        ExecutionProcessStatus::Completed\n                    } else {\n                        ExecutionProcessStatus::Failed\n                    };\n                    (Some(code), status)\n                }\n                Err(_) => (None, ExecutionProcessStatus::Failed),\n            };\n\n            if !ExecutionProcess::was_stopped(&db.pool, exec_id).await\n                && let Err(e) =\n                    ExecutionProcess::update_completion(&db.pool, exec_id, status, exit_code).await\n            {\n                tracing::error!(\"Failed to update execution process completion: {}\", e);\n            }\n\n            if let Ok(ctx) = ExecutionProcess::load_context(&db.pool, exec_id).await {\n                // Update executor session summary if available\n                if let Err(e) = container.update_executor_session_summary(&exec_id).await {\n                    tracing::warn!(\"Failed to update executor session summary: {}\", e);\n                }\n\n                let success = matches!(\n                    ctx.execution_process.status,\n                    ExecutionProcessStatus::Completed\n                ) && exit_code == Some(0);\n\n                let cleanup_done = matches!(\n                    ctx.execution_process.run_reason,\n                    ExecutionProcessRunReason::CleanupScript\n                ) && !matches!(\n                    ctx.execution_process.status,\n                    ExecutionProcessStatus::Running\n                );\n\n                let mut already_finalized = false;\n\n                if success || cleanup_done {\n                    // Commit changes (if any) and get feedback about whether changes were made\n                    let changes_committed = match container.try_commit_changes(&ctx).await {\n                        Ok(committed) => committed,\n                        Err(e) => {\n                            tracing::error!(\"Failed to commit changes after execution: {}\", e);\n                            // Treat commit failures as if changes were made to be safe\n                            true\n                        }\n                    };\n\n                    let should_start_next = if matches!(\n                        ctx.execution_process.run_reason,\n                        ExecutionProcessRunReason::CodingAgent\n                    ) {\n                        // Check if agent made commits OR if we just committed uncommitted changes\n                        changes_committed\n                            || container\n                                .has_commits_from_execution(&ctx)\n                                .await\n                                .unwrap_or(false)\n                    } else {\n                        true\n                    };\n\n                    if should_start_next {\n                        // If the process exited successfully, start the next action\n                        if let Err(e) = container.try_start_next_action(&ctx).await {\n                            tracing::error!(\"Failed to start next action after completion: {}\", e);\n                        }\n                    } else {\n                        tracing::info!(\n                            \"Skipping cleanup script for workspace {} - no changes made by coding agent\",\n                            ctx.workspace.id\n                        );\n\n                        // Manually finalize task since we're bypassing normal execution flow\n                        container.finalize_task(&ctx).await;\n                        already_finalized = true;\n                    }\n                }\n\n                if !already_finalized && container.should_finalize(&ctx) {\n                    let has_chained_follow_up = ctx\n                        .execution_process\n                        .executor_action()\n                        .ok()\n                        .and_then(|action| action.next_action())\n                        .is_some();\n                    let mut started_queued_follow_up = false;\n\n                    // Only execute queued messages if the execution succeeded\n                    // If it failed or was killed, just clear the queue and finalize\n                    let should_execute_queued = !matches!(\n                        ctx.execution_process.status,\n                        ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed\n                    );\n\n                    if let Some(queued_msg) =\n                        container.queued_message_service.take_queued(ctx.session.id)\n                    {\n                        if should_execute_queued {\n                            tracing::info!(\n                                \"Found queued message for session {}, starting follow-up execution\",\n                                ctx.session.id\n                            );\n\n                            // Delete the scratch since we're consuming the queued message\n                            if let Err(e) = Scratch::delete(\n                                &db.pool,\n                                ctx.session.id,\n                                &ScratchType::DraftFollowUp,\n                            )\n                            .await\n                            {\n                                tracing::warn!(\n                                    \"Failed to delete scratch after consuming queued message: {}\",\n                                    e\n                                );\n                            }\n\n                            // Execute the queued follow-up\n                            if let Err(e) = container\n                                .start_queued_follow_up(&ctx, &queued_msg.data)\n                                .await\n                            {\n                                tracing::error!(\"Failed to start queued follow-up: {}\", e);\n                                // Fall back to finalization if follow-up fails\n                                container.finalize_task(&ctx).await;\n                            } else {\n                                started_queued_follow_up = true;\n                            }\n                        } else {\n                            // Execution failed or was killed - discard the queued message and finalize\n                            tracing::info!(\n                                \"Discarding queued message for session {} due to execution status {:?}\",\n                                ctx.session.id,\n                                ctx.execution_process.status\n                            );\n                            container.finalize_task(&ctx).await;\n                        }\n                    } else {\n                        container.finalize_task(&ctx).await;\n                    }\n\n                    let should_mark_turn_unseen = matches!(\n                        ctx.execution_process.run_reason,\n                        ExecutionProcessRunReason::CodingAgent\n                    ) && !has_chained_follow_up\n                        && !started_queued_follow_up;\n\n                    if should_mark_turn_unseen\n                        && let Err(e) = CodingAgentTurn::mark_unseen_by_execution_process_id(\n                            &db.pool,\n                            ctx.execution_process.id,\n                        )\n                        .await\n                    {\n                        tracing::warn!(\n                            \"Failed to mark coding agent turn unseen for execution {}: {}\",\n                            ctx.execution_process.id,\n                            e\n                        );\n                    }\n                }\n\n                // When a parallel setup script finishes and no coding agent is running,\n                // consume any queued message that was stuck waiting\n                if matches!(\n                    ctx.execution_process.run_reason,\n                    ExecutionProcessRunReason::SetupScript\n                ) && !container.should_finalize(&ctx)\n                {\n                    let has_running_agent = ExecutionProcess::has_running_coding_agent_for_session(\n                        &db.pool,\n                        ctx.session.id,\n                    )\n                    .await\n                    .unwrap_or(true);\n\n                    if !has_running_agent\n                        && let Some(queued_msg) =\n                            container.queued_message_service.take_queued(ctx.session.id)\n                    {\n                        tracing::info!(\n                            \"Parallel setup script finished with queued message for session {}, starting follow-up\",\n                            ctx.session.id\n                        );\n\n                        if let Err(e) =\n                            Scratch::delete(&db.pool, ctx.session.id, &ScratchType::DraftFollowUp)\n                                .await\n                        {\n                            tracing::warn!(\n                                \"Failed to delete scratch after consuming queued message: {}\",\n                                e\n                            );\n                        }\n\n                        if let Err(e) = container\n                            .start_queued_follow_up(&ctx, &queued_msg.data)\n                            .await\n                        {\n                            tracing::error!(\n                                \"Failed to start queued follow-up from setup script completion: {}\",\n                                e\n                            );\n                        }\n                    }\n                }\n\n                // Fire analytics event when CodingAgent execution has finished\n                if config.read().await.analytics_enabled\n                    && matches!(\n                        &ctx.execution_process.run_reason,\n                        ExecutionProcessRunReason::CodingAgent\n                    )\n                    && let Some(analytics) = &analytics\n                {\n                    analytics.analytics_service.track_event(&analytics.user_id, \"task_attempt_finished\", Some(json!({\n                        \"workspace_id\": ctx.workspace.id.to_string(),\n                        \"session_id\": ctx.session.id.to_string(),\n                        \"execution_success\": matches!(ctx.execution_process.status, ExecutionProcessStatus::Completed),\n                        \"exit_code\": ctx.execution_process.exit_code,\n                    })));\n                }\n\n                // Sync workspace to remote after CodingAgent execution\n                if matches!(\n                    &ctx.execution_process.run_reason,\n                    ExecutionProcessRunReason::CodingAgent\n                ) && let Some(client) = &container.remote_client\n                {\n                    let stats = diff_stream::compute_diff_stats(\n                        &container.db.pool,\n                        &container.git,\n                        &ctx.workspace,\n                    )\n                    .await;\n                    let workspace_name =\n                        Workspace::find_by_id_with_status(&container.db.pool, ctx.workspace.id)\n                            .await\n                            .ok()\n                            .flatten()\n                            .and_then(|ws| ws.workspace.name);\n                    let client = client.clone();\n                    let workspace_id = ctx.workspace.id;\n                    let archived = ctx.workspace.archived;\n                    tokio::spawn(async move {\n                        remote_sync::sync_workspace_to_remote(\n                            &client,\n                            workspace_id,\n                            workspace_name.map(Some),\n                            Some(archived),\n                            stats.as_ref(),\n                        )\n                        .await;\n                    });\n                }\n            }\n\n            // Now that commit/next-action/finalization steps for this process are complete,\n            // capture the HEAD OID as the definitive \"after\" state (best-effort).\n            container.update_after_head_commits(exec_id).await;\n\n            // Wait for DB persistence to complete before cleaning up MsgStore\n            let db_stream_handle = container.take_db_stream_handle(&exec_id).await;\n            if let Some(msg_arc) = msg_stores.write().await.remove(&exec_id) {\n                msg_arc.push_finished();\n            }\n            if let Some(handle) = db_stream_handle {\n                let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;\n            }\n\n            // Cleanup child handle\n            child_store.write().await.remove(&exec_id);\n        })\n    }\n\n    pub fn spawn_os_exit_watcher(\n        &self,\n        exec_id: Uuid,\n    ) -> tokio::sync::oneshot::Receiver<std::io::Result<std::process::ExitStatus>> {\n        let (tx, rx) = tokio::sync::oneshot::channel::<std::io::Result<std::process::ExitStatus>>();\n        let child_store = self.child_store.clone();\n        tokio::spawn(async move {\n            loop {\n                let child_lock = {\n                    let map = child_store.read().await;\n                    map.get(&exec_id).cloned()\n                };\n                if let Some(child_lock) = child_lock {\n                    let mut child_handler = child_lock.write().await;\n                    match child_handler.try_wait() {\n                        Ok(Some(status)) => {\n                            let _ = tx.send(Ok(status));\n                            break;\n                        }\n                        Ok(None) => {}\n                        Err(e) => {\n                            let _ = tx.send(Err(e));\n                            break;\n                        }\n                    }\n                } else {\n                    let _ = tx.send(Err(io::Error::other(format!(\n                        \"Child handle missing for {exec_id}\"\n                    ))));\n                    break;\n                }\n                tokio::time::sleep(Duration::from_millis(250)).await;\n            }\n        });\n        rx\n    }\n\n    pub fn dir_name_from_workspace(workspace_id: &Uuid, task_title: &str) -> String {\n        let task_title_id = git_branch_id(task_title);\n        format!(\"{}-{}\", short_uuid(workspace_id), task_title_id)\n    }\n\n    async fn track_child_msgs_in_store(&self, id: Uuid, child: &mut AsyncGroupChild) {\n        let store = Arc::new(MsgStore::new());\n\n        let out = child.inner().stdout.take().expect(\"no stdout\");\n        let err = child.inner().stderr.take().expect(\"no stderr\");\n\n        // Map stdout bytes -> LogMsg::Stdout\n        let out = ReaderStream::new(out)\n            .map_ok(|chunk| LogMsg::Stdout(String::from_utf8_lossy(&chunk).into_owned()));\n\n        // Map stderr bytes -> LogMsg::Stderr\n        let err = ReaderStream::new(err)\n            .map_ok(|chunk| LogMsg::Stderr(String::from_utf8_lossy(&chunk).into_owned()));\n\n        // If you have a JSON Patch source, map it to LogMsg::JsonPatch too, then select all three.\n\n        // Merge and forward into the store\n        let merged = select(out, err); // Stream<Item = Result<LogMsg, io::Error>>\n        store.clone().spawn_forwarder(merged);\n\n        let mut map = self.msg_stores().write().await;\n        map.insert(id, store);\n    }\n\n    /// Create a live diff log stream for ongoing attempts for WebSocket\n    /// Returns a stream that owns the filesystem watcher - when dropped, watcher is cleaned up\n    async fn create_live_diff_stream(\n        &self,\n        args: diff_stream::DiffStreamArgs,\n    ) -> Result<DiffStreamHandle, ContainerError> {\n        diff_stream::create(args)\n            .await\n            .map_err(|e| ContainerError::Other(anyhow!(\"{e}\")))\n    }\n\n    /// Extract the last assistant message from the MsgStore history\n    fn extract_last_assistant_message(&self, exec_id: &Uuid) -> Option<String> {\n        // Get the MsgStore for this execution\n        let msg_stores = self.msg_stores.try_read().ok()?;\n        let msg_store = msg_stores.get(exec_id)?;\n\n        // Get the history and scan in reverse for the last assistant message\n        let history = msg_store.get_history();\n\n        for msg in history.iter().rev() {\n            if let LogMsg::JsonPatch(patch) = msg {\n                // Try to extract a NormalizedEntry from the patch\n                if let Some((_, entry)) = extract_normalized_entry_from_patch(patch)\n                    && matches!(entry.entry_type, NormalizedEntryType::AssistantMessage)\n                {\n                    let content = entry.content.trim();\n                    if !content.is_empty() {\n                        const MAX_SUMMARY_LENGTH: usize = 4096;\n                        if content.len() > MAX_SUMMARY_LENGTH {\n                            let truncated = truncate_to_char_boundary(content, MAX_SUMMARY_LENGTH);\n                            return Some(format!(\"{truncated}...\"));\n                        }\n                        return Some(content.to_string());\n                    }\n                }\n            }\n        }\n\n        None\n    }\n\n    /// Update the coding agent turn summary with the final assistant message\n    async fn update_executor_session_summary(&self, exec_id: &Uuid) -> Result<(), anyhow::Error> {\n        // Check if there's a coding agent turn for this execution process\n        let turn = CodingAgentTurn::find_by_execution_process_id(&self.db.pool, *exec_id).await?;\n\n        if let Some(turn) = turn {\n            // Only update if summary is not already set\n            if turn.summary.is_none() {\n                if let Some(summary) = self.extract_last_assistant_message(exec_id) {\n                    CodingAgentTurn::update_summary(&self.db.pool, *exec_id, &summary).await?;\n                } else {\n                    tracing::debug!(\"No assistant message found for execution {}\", exec_id);\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Copy project files and workspace attachments to the workspace.\n    /// Skips files that already exist (fast no-op if all exist).\n    async fn copy_files_and_images(\n        &self,\n        workspace_dir: &Path,\n        workspace: &Workspace,\n    ) -> Result<(), ContainerError> {\n        let repos = WorkspaceRepo::find_repos_with_copy_files(&self.db.pool, workspace.id).await?;\n\n        for repo in &repos {\n            if let Some(copy_files) = &repo.copy_files\n                && !copy_files.trim().is_empty()\n            {\n                let worktree_path = workspace_dir.join(&repo.name);\n                self.copy_project_files(&repo.path, &worktree_path, copy_files)\n                    .await\n                    .unwrap_or_else(|e| {\n                        tracing::warn!(\n                            \"Failed to copy project files for repo '{}': {}\",\n                            repo.name,\n                            e\n                        );\n                    });\n            }\n        }\n\n        let agent_working_dir = Session::find_latest_by_workspace_id(&self.db.pool, workspace.id)\n            .await?\n            .and_then(|session| session.agent_working_dir);\n\n        if let Err(e) = self\n            .file_service\n            .copy_files_by_workspace_to_worktree(\n                workspace_dir,\n                workspace.id,\n                agent_working_dir.as_deref(),\n            )\n            .await\n        {\n            tracing::warn!(\"Failed to copy workspace files to workspace: {}\", e);\n        }\n\n        Ok(())\n    }\n\n    /// Create workspace-level CLAUDE.md and AGENTS.md files that import from each repo.\n    /// Uses the @import syntax to reference each repo's config files.\n    /// Skips creating files if they already exist or if no repos have the source file.\n    async fn create_workspace_config_files(\n        workspace_dir: &Path,\n        repos: &[Repo],\n    ) -> Result<(), ContainerError> {\n        const CONFIG_FILES: [&str; 2] = [\"CLAUDE.md\", \"AGENTS.md\"];\n\n        for config_file in CONFIG_FILES {\n            let workspace_config_path = workspace_dir.join(config_file);\n\n            if workspace_config_path.exists() {\n                tracing::trace!(\n                    \"Workspace config file {} already exists, skipping\",\n                    config_file\n                );\n                continue;\n            }\n\n            let mut import_lines = Vec::new();\n            for repo in repos {\n                let repo_config_path = workspace_dir.join(&repo.name).join(config_file);\n                if repo_config_path.exists() {\n                    import_lines.push(format!(\"@{}/{}\", repo.name, config_file));\n                }\n            }\n\n            if import_lines.is_empty() {\n                tracing::trace!(\n                    \"No repos have {}, skipping workspace config creation\",\n                    config_file\n                );\n                continue;\n            }\n\n            let content = import_lines.join(\"\\n\") + \"\\n\";\n            if let Err(e) = tokio::fs::write(&workspace_config_path, &content).await {\n                tracing::warn!(\n                    \"Failed to create workspace config file {}: {}\",\n                    config_file,\n                    e\n                );\n                continue;\n            }\n\n            tracing::info!(\n                \"Created workspace {} with {} import(s)\",\n                config_file,\n                import_lines.len()\n            );\n        }\n\n        Ok(())\n    }\n\n    /// Start a follow-up execution from a queued message\n    async fn start_queued_follow_up(\n        &self,\n        ctx: &ExecutionContext,\n        queued_data: &DraftFollowUpData,\n    ) -> Result<ExecutionProcess, ContainerError> {\n        let executor_profile_id = queued_data.executor_config.profile_id();\n\n        // Validate executor matches session if session has prior executions\n        let expected_executor: Option<String> =\n            ExecutionProcess::latest_executor_profile_for_session(&self.db.pool, ctx.session.id)\n                .await?\n                .map(|profile| profile.executor.to_string())\n                .or_else(|| ctx.session.executor.clone());\n\n        if let Some(expected) = expected_executor {\n            let actual = executor_profile_id.executor.to_string();\n            if expected != actual {\n                return Err(SessionError::ExecutorMismatch { expected, actual }.into());\n            }\n        }\n\n        if ctx.session.executor.is_none() {\n            Session::update_executor(\n                &self.db.pool,\n                ctx.session.id,\n                &executor_profile_id.executor.to_string(),\n            )\n            .await?;\n        }\n\n        // Get latest agent turn for session continuity (from coding agent turns)\n        let latest_session_info =\n            CodingAgentTurn::find_latest_session_info(&self.db.pool, ctx.session.id).await?;\n\n        let repos =\n            WorkspaceRepo::find_repos_for_workspace(&self.db.pool, ctx.workspace.id).await?;\n        let cleanup_action = self.cleanup_actions_for_repos(&repos);\n\n        let working_dir = ctx\n            .session\n            .agent_working_dir\n            .as_ref()\n            .filter(|dir| !dir.is_empty())\n            .cloned();\n\n        let action_type = if let Some(info) = latest_session_info {\n            ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest {\n                prompt: queued_data.message.clone(),\n                session_id: info.session_id,\n                reset_to_message_id: None,\n                executor_config: queued_data.executor_config.clone(),\n                working_dir: working_dir.clone(),\n            })\n        } else {\n            ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest {\n                prompt: queued_data.message.clone(),\n                executor_config: queued_data.executor_config.clone(),\n                working_dir,\n            })\n        };\n\n        let action = ExecutorAction::new(action_type, cleanup_action.map(Box::new));\n\n        self.start_execution(\n            &ctx.workspace,\n            &ctx.session,\n            &action,\n            &ExecutionProcessRunReason::CodingAgent,\n        )\n        .await\n    }\n}\n\nfn failure_exit_status() -> std::process::ExitStatus {\n    #[cfg(unix)]\n    {\n        use std::os::unix::process::ExitStatusExt;\n        ExitStatusExt::from_raw(256) // Exit code 1 (shifted by 8 bits)\n    }\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::ExitStatusExt;\n        ExitStatusExt::from_raw(1)\n    }\n}\n\n#[async_trait]\nimpl ContainerService for LocalContainerService {\n    fn msg_stores(&self) -> &Arc<RwLock<HashMap<Uuid, Arc<MsgStore>>>> {\n        &self.msg_stores\n    }\n\n    fn db(&self) -> &DBService {\n        &self.db\n    }\n\n    fn git(&self) -> &GitService {\n        &self.git\n    }\n\n    fn notification_service(&self) -> &NotificationService {\n        &self.notification_service\n    }\n\n    async fn touch(&self, workspace: &Workspace) -> Result<(), ContainerError> {\n        let now = Instant::now();\n\n        // We debounce touches to avoid excessive database writes, which in SQLites causes DB locks\n        let should_debounce = |last_touch: &Instant| -> bool {\n            now.duration_since(*last_touch) < WORKSPACE_TOUCH_DEBOUNCE\n        };\n\n        // Quick check with read lock\n        if self\n            .workspace_touch_times\n            .read()\n            .await\n            .get(&workspace.id)\n            .is_some_and(should_debounce)\n        {\n            return Ok(());\n        }\n\n        let mut map = self.workspace_touch_times.write().await;\n        // Clean up stale entries older than the debounce window, reduce memory usage over time\n        map.retain(|_, time| should_debounce(time));\n        // check in case another thread has touched already\n        if map.get(&workspace.id).is_some_and(should_debounce) {\n            return Ok(());\n        }\n        map.insert(workspace.id, now);\n        drop(map);\n\n        Workspace::touch(&self.db.pool, workspace.id).await?;\n        Ok(())\n    }\n\n    async fn store_db_stream_handle(&self, id: Uuid, handle: JoinHandle<()>) {\n        self.add_db_stream_handle(id, handle).await;\n    }\n\n    async fn take_db_stream_handle(&self, id: &Uuid) -> Option<JoinHandle<()>> {\n        LocalContainerService::take_db_stream_handle(self, id).await\n    }\n\n    async fn git_branch_prefix(&self) -> String {\n        self.config.read().await.git_branch_prefix.clone()\n    }\n\n    fn workspace_to_current_dir(&self, workspace: &Workspace) -> PathBuf {\n        PathBuf::from(workspace.container_ref.clone().unwrap_or_default())\n    }\n\n    async fn create(&self, workspace: &Workspace) -> Result<ContainerRef, ContainerError> {\n        let label = workspace.name.as_deref().unwrap_or(\"workspace\");\n        let workspace_dir_name =\n            LocalContainerService::dir_name_from_workspace(&workspace.id, label);\n        let workspace_dir = WorkspaceManager::get_workspace_base_dir().join(&workspace_dir_name);\n\n        let (repositories, workspace_inputs) = self.workspace_repo_inputs(workspace.id).await?;\n\n        let created_workspace = WorkspaceManager::create_workspace(\n            &workspace_dir,\n            &workspace_inputs,\n            &workspace.branch,\n        )\n        .await\n        .map_err(Self::map_workspace_manager_error)?;\n\n        // Copy project files and images to workspace\n        self.copy_files_and_images(&created_workspace.workspace_dir, workspace)\n            .await?;\n\n        Self::create_workspace_config_files(&created_workspace.workspace_dir, &repositories)\n            .await?;\n\n        Workspace::update_container_ref(\n            &self.db.pool,\n            workspace.id,\n            &created_workspace.workspace_dir.to_string_lossy(),\n        )\n        .await?;\n\n        Ok(created_workspace\n            .workspace_dir\n            .to_string_lossy()\n            .to_string())\n    }\n\n    async fn delete(&self, workspace: &Workspace) -> Result<(), ContainerError> {\n        self.try_stop(workspace, true).await;\n        self.cleanup_workspace(workspace).await;\n        Ok(())\n    }\n\n    async fn ensure_container_exists(\n        &self,\n        workspace: &Workspace,\n    ) -> Result<ContainerRef, ContainerError> {\n        self.touch(workspace).await?;\n        let (repositories, workspace_inputs) = self.workspace_repo_inputs(workspace.id).await?;\n\n        let workspace_dir = if let Some(container_ref) = &workspace.container_ref {\n            PathBuf::from(container_ref)\n        } else {\n            let label = workspace.name.as_deref().unwrap_or(\"workspace\");\n            let workspace_dir_name =\n                LocalContainerService::dir_name_from_workspace(&workspace.id, label);\n            WorkspaceManager::get_workspace_base_dir().join(&workspace_dir_name)\n        };\n\n        WorkspaceManager::ensure_workspace_exists(\n            &workspace_dir,\n            &workspace_inputs,\n            &workspace.branch,\n        )\n        .await\n        .map_err(Self::map_workspace_manager_error)?;\n\n        if workspace.container_ref.is_none() {\n            Workspace::update_container_ref(\n                &self.db.pool,\n                workspace.id,\n                &workspace_dir.to_string_lossy(),\n            )\n            .await?;\n        }\n\n        if workspace.worktree_deleted {\n            Workspace::clear_worktree_deleted(&self.db.pool, workspace.id).await?;\n        }\n\n        // Copy project files and images (fast no-op if already exist)\n        self.copy_files_and_images(&workspace_dir, workspace)\n            .await?;\n\n        Self::create_workspace_config_files(&workspace_dir, &repositories).await?;\n\n        Ok(workspace_dir.to_string_lossy().to_string())\n    }\n\n    async fn is_container_clean(&self, workspace: &Workspace) -> Result<bool, ContainerError> {\n        let Some(container_ref) = &workspace.container_ref else {\n            return Ok(true);\n        };\n\n        let workspace_dir = PathBuf::from(container_ref);\n        if !workspace_dir.exists() {\n            return Ok(true);\n        }\n\n        let repositories =\n            WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?;\n\n        for repo in &repositories {\n            let worktree_path = workspace_dir.join(&repo.name);\n            if worktree_path.exists() {\n                let (uncommitted, untracked) =\n                    self.git().get_worktree_change_counts(&worktree_path)?;\n                if uncommitted > 0 || untracked > 0 {\n                    return Ok(false);\n                }\n            }\n        }\n\n        Ok(true)\n    }\n\n    async fn start_execution_inner(\n        &self,\n        workspace: &Workspace,\n        execution_process: &ExecutionProcess,\n        executor_action: &ExecutorAction,\n    ) -> Result<(), ContainerError> {\n        // Get the worktree path\n        let container_ref = workspace\n            .container_ref\n            .as_ref()\n            .ok_or(ContainerError::Other(anyhow!(\n                \"Container ref not found for workspace\"\n            )))?;\n        let current_dir = PathBuf::from(container_ref);\n\n        let approvals_service: Arc<dyn ExecutorApprovalService> =\n            match executor_action.base_executor() {\n                Some(\n                    BaseCodingAgent::Codex\n                    | BaseCodingAgent::ClaudeCode\n                    | BaseCodingAgent::Gemini\n                    | BaseCodingAgent::QwenCode\n                    | BaseCodingAgent::Opencode,\n                ) => ExecutorApprovalBridge::new(\n                    self.approvals.clone(),\n                    self.db.clone(),\n                    self.notification_service.clone(),\n                    execution_process.id,\n                ),\n                _ => Arc::new(NoopExecutorApprovalService {}),\n            };\n\n        let repos = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?;\n        let repo_names: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();\n        let repo_context = RepoContext::new(current_dir.clone(), repo_names);\n\n        let config = self.config.read().await;\n        let commit_reminder_enabled = config.commit_reminder_enabled;\n        let commit_reminder_prompt = config\n            .commit_reminder_prompt\n            .clone()\n            .unwrap_or_else(|| DEFAULT_COMMIT_REMINDER_PROMPT.to_string());\n        drop(config);\n        let mut env = ExecutionEnv::new(\n            repo_context,\n            commit_reminder_enabled,\n            commit_reminder_prompt,\n        );\n\n        // Always inject workspace/session context\n        env.insert(\"VK_WORKSPACE_ID\", workspace.id.to_string());\n        env.insert(\"VK_WORKSPACE_BRANCH\", &workspace.branch);\n\n        // Create the child and stream, add to execution tracker with timeout\n        let mut spawned = tokio::time::timeout(\n            Duration::from_secs(30),\n            executor_action.spawn(&current_dir, approvals_service, &env),\n        )\n        .await\n        .map_err(|_| {\n            ContainerError::Other(anyhow!(\n                \"Timeout: process took more than 30 seconds to start\"\n            ))\n        })??;\n\n        self.track_child_msgs_in_store(execution_process.id, &mut spawned.child)\n            .await;\n\n        self.add_child_to_store(execution_process.id, spawned.child)\n            .await;\n\n        // Store cancellation token for graceful shutdown\n        if let Some(cancel) = spawned.cancel {\n            self.add_cancellation_token(execution_process.id, cancel)\n                .await;\n        }\n\n        // Spawn unified exit monitor: watches OS exit and optional executor signal\n        let hn = self.spawn_exit_monitor(&execution_process.id, spawned.exit_signal);\n        self.add_exit_monitor_handle(execution_process.id, hn).await;\n\n        Ok(())\n    }\n\n    async fn stop_execution(\n        &self,\n        execution_process: &ExecutionProcess,\n        status: ExecutionProcessStatus,\n    ) -> Result<(), ContainerError> {\n        let child = self\n            .get_child_from_store(&execution_process.id)\n            .await\n            .ok_or_else(|| {\n                ContainerError::Other(anyhow!(\"Child process not found for execution\"))\n            })?;\n        let exit_code = if status == ExecutionProcessStatus::Completed {\n            Some(0)\n        } else {\n            None\n        };\n\n        ExecutionProcess::update_completion(&self.db.pool, execution_process.id, status, exit_code)\n            .await?;\n\n        // Try graceful cancellation first, then force kill\n        if let Some(cancel) = self.take_cancellation_token(&execution_process.id).await {\n            cancel.cancel();\n\n            // Wait for exit monitor to finish gracefully\n            if let Some(monitor_handle) = self.take_exit_monitor_handle(&execution_process.id).await\n            {\n                match tokio::time::timeout(Duration::from_secs(5), monitor_handle).await {\n                    Ok(_) => {\n                        tracing::debug!(\"Process {} exited gracefully\", execution_process.id);\n                    }\n                    Err(_) => {\n                        tracing::debug!(\n                            \"Graceful shutdown timed out for process {}, force killing\",\n                            execution_process.id\n                        );\n                    }\n                }\n            }\n        }\n\n        {\n            let mut child_guard = child.write().await;\n            if let Err(e) = command::kill_process_group(&mut child_guard).await {\n                tracing::error!(\n                    \"Failed to stop execution process {}: {}\",\n                    execution_process.id,\n                    e\n                );\n                return Err(e);\n            }\n        }\n        self.remove_child_from_store(&execution_process.id).await;\n\n        // Mark the process finished in the MsgStore and wait for DB persistence\n        let db_stream_handle = self.take_db_stream_handle(&execution_process.id).await;\n        if let Some(msg) = self.msg_stores.write().await.remove(&execution_process.id) {\n            msg.push_finished();\n        }\n        if let Some(handle) = db_stream_handle {\n            let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;\n        }\n\n        tracing::debug!(\n            \"Execution process {} stopped successfully\",\n            execution_process.id\n        );\n\n        // Record after-head commit OID (best-effort)\n        self.update_after_head_commits(execution_process.id).await;\n\n        Ok(())\n    }\n\n    async fn stream_diff(\n        &self,\n        workspace: &Workspace,\n        stats_only: bool,\n    ) -> Result<futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>, ContainerError>\n    {\n        let workspace_repos =\n            WorkspaceRepo::find_by_workspace_id(&self.db.pool, workspace.id).await?;\n        let target_branches: HashMap<_, _> = workspace_repos\n            .iter()\n            .map(|wr| (wr.repo_id, wr.target_branch.clone()))\n            .collect();\n\n        let repositories =\n            WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?;\n\n        let mut streams = Vec::new();\n\n        let container_ref = self.ensure_container_exists(workspace).await?;\n        let workspace_root = PathBuf::from(container_ref);\n\n        for repo in repositories {\n            let worktree_path = workspace_root.join(&repo.name);\n            let branch = &workspace.branch;\n\n            let Some(target_branch) = target_branches.get(&repo.id) else {\n                tracing::warn!(\n                    \"Skipping diff stream for repo {}: no target branch configured\",\n                    repo.name\n                );\n                continue;\n            };\n\n            let base_commit = match self\n                .git()\n                .get_base_commit(&repo.path, branch, target_branch)\n            {\n                Ok(c) => c,\n                Err(e) => {\n                    tracing::warn!(\n                        \"Skipping diff stream for repo {}: failed to get base commit: {}\",\n                        repo.name,\n                        e\n                    );\n                    continue;\n                }\n            };\n\n            let stream = self\n                .create_live_diff_stream(diff_stream::DiffStreamArgs {\n                    git_service: self.git().clone(),\n                    db: self.db().clone(),\n                    workspace_id: workspace.id,\n                    repo_id: repo.id,\n                    repo_path: repo.path.clone(),\n                    worktree_path: worktree_path.clone(),\n                    branch: branch.to_string(),\n                    target_branch: target_branch.clone(),\n                    base_commit: base_commit.clone(),\n                    stats_only,\n                    path_prefix: Some(repo.name.clone()),\n                })\n                .await?;\n\n            streams.push(Box::pin(stream));\n        }\n\n        if streams.is_empty() {\n            return Ok(Box::pin(futures::stream::empty()));\n        }\n\n        // Merge all streams into one\n        Ok(Box::pin(futures::stream::select_all(streams)))\n    }\n\n    async fn try_commit_changes(&self, ctx: &ExecutionContext) -> Result<bool, ContainerError> {\n        if !matches!(\n            ctx.execution_process.run_reason,\n            ExecutionProcessRunReason::CodingAgent | ExecutionProcessRunReason::CleanupScript,\n        ) {\n            return Ok(false);\n        }\n\n        let message = self.get_commit_message(ctx).await;\n\n        let container_ref = ctx\n            .workspace\n            .container_ref\n            .as_ref()\n            .ok_or_else(|| ContainerError::Other(anyhow!(\"Container reference not found\")))?;\n        let workspace_root = PathBuf::from(container_ref);\n\n        let repos_with_changes = self.check_repos_for_changes(&workspace_root, &ctx.repos)?;\n        if repos_with_changes.is_empty() {\n            tracing::debug!(\"No changes to commit in any repository\");\n            return Ok(false);\n        }\n\n        Ok(self.commit_repos(repos_with_changes, &message))\n    }\n\n    /// Copy files from the original project directory to the worktree.\n    /// Skips files that already exist at target with same size.\n    async fn copy_project_files(\n        &self,\n        source_dir: &Path,\n        target_dir: &Path,\n        copy_files: &str,\n    ) -> Result<(), ContainerError> {\n        let source_dir = source_dir.to_path_buf();\n        let target_dir = target_dir.to_path_buf();\n        let copy_files = copy_files.to_string();\n\n        tokio::time::timeout(\n            std::time::Duration::from_secs(30),\n            tokio::task::spawn_blocking(move || {\n                copy::copy_project_files_impl(&source_dir, &target_dir, &copy_files)\n            }),\n        )\n        .await\n        .map_err(|_| ContainerError::Other(anyhow!(\"Copy project files timed out after 30s\")))?\n        .map_err(|e| ContainerError::Other(anyhow!(\"Copy files task failed: {e}\")))?\n    }\n\n    async fn kill_all_running_processes(&self) -> Result<(), ContainerError> {\n        tracing::info!(\"Killing all running processes\");\n        let running_processes = ExecutionProcess::find_running(&self.db.pool).await?;\n\n        tracing::info!(\n            \"Found {} running processes to kill\",\n            running_processes.len()\n        );\n\n        for process in running_processes {\n            tracing::info!(\n                \"Killing process: id={}, run_reason={:?}\",\n                process.id,\n                process.run_reason\n            );\n            if let Err(error) = self\n                .stop_execution(&process, ExecutionProcessStatus::Killed)\n                .await\n            {\n                tracing::error!(\n                    \"Failed to cleanly kill running execution process {:?}: {:?}\",\n                    process,\n                    error\n                );\n            } else {\n                tracing::info!(\"Successfully killed process: id={}\", process.id);\n            }\n        }\n\n        Ok(())\n    }\n}\nfn success_exit_status() -> std::process::ExitStatus {\n    #[cfg(unix)]\n    {\n        use std::os::unix::process::ExitStatusExt;\n        ExitStatusExt::from_raw(0)\n    }\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::ExitStatusExt;\n        ExitStatusExt::from_raw(0)\n    }\n}\n"
  },
  {
    "path": "crates/local-deployment/src/copy.rs",
    "content": "use std::{\n    collections::HashSet,\n    fs,\n    path::{Path, PathBuf},\n};\n\nuse anyhow::anyhow;\nuse globwalk::GlobWalkerBuilder;\nuse services::services::container::ContainerError;\n\n/// Normalize pattern for cross-platform glob matching (convert backslashes to forward slashes)\nfn normalize_pattern(pattern: &str) -> String {\n    pattern.replace('\\\\', \"/\")\n}\n\n/// Copy project files from source to target directory based on glob patterns.\n/// Skips files that already exist at target with same size.\npub(crate) fn copy_project_files_impl(\n    source_dir: &Path,\n    target_dir: &Path,\n    copy_files: &str,\n) -> Result<(), ContainerError> {\n    let patterns: Vec<&str> = copy_files\n        .split(',')\n        .map(|s| s.trim())\n        .filter(|s| !s.is_empty())\n        .collect();\n\n    // Track files to avoid duplicates\n    let mut seen = HashSet::new();\n\n    for pattern in patterns {\n        let pattern = normalize_pattern(pattern);\n        let pattern_path = source_dir.join(&pattern);\n\n        if pattern_path.is_file() {\n            if let Err(e) = copy_single_file(&pattern_path, source_dir, target_dir, &mut seen) {\n                tracing::warn!(\n                    \"Failed to copy file {} (from {}): {}\",\n                    pattern,\n                    pattern_path.display(),\n                    e\n                );\n            }\n            continue;\n        }\n\n        let glob_pattern = if pattern_path.is_dir() {\n            // For directories, append /** to match all contents recursively\n            format!(\"{pattern}/**\")\n        } else {\n            pattern.clone()\n        };\n\n        let walker = match GlobWalkerBuilder::from_patterns(source_dir, &[&glob_pattern])\n            .file_type(globwalk::FileType::FILE)\n            .build()\n        {\n            Ok(w) => w,\n            Err(e) => {\n                tracing::warn!(\"Invalid glob pattern '{glob_pattern}': {e}\");\n                continue;\n            }\n        };\n\n        for entry in walker.flatten() {\n            if let Err(e) = copy_single_file(entry.path(), source_dir, target_dir, &mut seen) {\n                tracing::warn!(\"Failed to copy file {:?}: {e}\", entry.path());\n            }\n        }\n    }\n\n    Ok(())\n}\n\nfn copy_single_file(\n    source_file: &Path,\n    source_root: &Path,\n    target_root: &Path,\n    seen: &mut HashSet<PathBuf>,\n) -> Result<bool, ContainerError> {\n    let canonical_source = source_root.canonicalize()?;\n    let canonical_file = source_file.canonicalize()?;\n    // Validate path is within source_dir\n    if !canonical_file.starts_with(canonical_source) {\n        return Err(ContainerError::Other(anyhow!(\n            \"File {source_file:?} is outside project directory\"\n        )));\n    }\n\n    if !seen.insert(canonical_file.clone()) {\n        return Ok(false);\n    }\n\n    let relative_path = source_file.strip_prefix(source_root).map_err(|e| {\n        ContainerError::Other(anyhow!(\n            \"Failed to get relative path for {source_file:?}: {e}\"\n        ))\n    })?;\n\n    let target_file = target_root.join(relative_path);\n\n    if target_file.exists() {\n        return Ok(false);\n    }\n\n    if let Some(parent) = target_file.parent()\n        && !parent.exists()\n    {\n        fs::create_dir_all(parent)?;\n    }\n    fs::copy(source_file, &target_file)?;\n\n    Ok(true)\n}\n\n#[cfg(test)]\nmod tests {\n    use std::fs;\n\n    use tempfile::TempDir;\n\n    use super::*;\n    #[test]\n    fn test_copy_project_files_mixed_patterns() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        fs::write(source_dir.path().join(\".env\"), \"secret\").unwrap();\n        fs::write(source_dir.path().join(\"config.json\"), \"{}\").unwrap();\n\n        let src_dir = source_dir.path().join(\"src\");\n        fs::create_dir(&src_dir).unwrap();\n        fs::write(src_dir.join(\"main.rs\"), \"code\").unwrap();\n        fs::write(src_dir.join(\"lib.rs\"), \"lib\").unwrap();\n\n        let config_dir = source_dir.path().join(\"config\");\n        fs::create_dir(&config_dir).unwrap();\n        fs::write(config_dir.join(\"app.toml\"), \"config\").unwrap();\n\n        copy_project_files_impl(\n            source_dir.path(),\n            target_dir.path(),\n            \".env, *.json, src, config\",\n        )\n        .unwrap();\n\n        assert!(target_dir.path().join(\".env\").exists());\n        assert!(target_dir.path().join(\"config.json\").exists());\n        assert!(target_dir.path().join(\"src/main.rs\").exists());\n        assert!(target_dir.path().join(\"src/lib.rs\").exists());\n        assert!(target_dir.path().join(\"config/app.toml\").exists());\n    }\n\n    #[test]\n    fn test_copy_project_files_nonexistent_pattern_ok() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        let result =\n            copy_project_files_impl(source_dir.path(), target_dir.path(), \"nonexistent.txt\");\n\n        assert!(result.is_ok());\n        assert!(!target_dir.path().join(\"nonexistent.txt\").exists());\n    }\n\n    #[test]\n    fn test_copy_project_files_empty_pattern_ok() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        let result = copy_project_files_impl(source_dir.path(), target_dir.path(), \"\");\n\n        assert!(result.is_ok());\n        assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0);\n    }\n\n    #[test]\n    fn test_copy_project_files_whitespace_handling() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        fs::write(source_dir.path().join(\"test.txt\"), \"content\").unwrap();\n\n        copy_project_files_impl(source_dir.path(), target_dir.path(), \"  test.txt  ,  \").unwrap();\n\n        assert!(target_dir.path().join(\"test.txt\").exists());\n    }\n\n    #[test]\n    fn test_copy_project_files_nested_directory() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        let config_dir = source_dir.path().join(\"config\");\n        fs::create_dir(&config_dir).unwrap();\n        fs::write(config_dir.join(\"app.json\"), \"{}\").unwrap();\n\n        let nested_dir = config_dir.join(\"nested\");\n        fs::create_dir(&nested_dir).unwrap();\n        fs::write(nested_dir.join(\"deep.txt\"), \"deep\").unwrap();\n\n        copy_project_files_impl(source_dir.path(), target_dir.path(), \"config\").unwrap();\n\n        assert!(target_dir.path().join(\"config/app.json\").exists());\n        assert!(target_dir.path().join(\"config/nested/deep.txt\").exists());\n    }\n\n    #[test]\n    fn test_copy_project_files_outside_source_skips_without_copying() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        // Create file outside of source directory (one level up)\n        let parent_dir = source_dir.path().parent().unwrap().to_path_buf();\n        let outside_file = parent_dir.join(\"secret.txt\");\n        fs::write(&outside_file, \"secret\").unwrap();\n\n        // Pattern referencing parent directory should resolve to outside_file and be rejected\n        let result = copy_project_files_impl(source_dir.path(), target_dir.path(), \"../secret.txt\");\n\n        assert!(result.is_ok());\n        assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0);\n    }\n\n    #[test]\n    fn test_copy_project_files_recursive_glob_extension_filter() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        // Create nested directory structure with YAML files\n        let config_dir = source_dir.path().join(\"config\");\n        fs::create_dir(&config_dir).unwrap();\n        fs::write(config_dir.join(\"app.yml\"), \"app: config\").unwrap();\n        fs::write(config_dir.join(\"db.json\"), \"{}\").unwrap();\n\n        let nested_dir = config_dir.join(\"nested\");\n        fs::create_dir(&nested_dir).unwrap();\n        fs::write(nested_dir.join(\"settings.yml\"), \"settings: value\").unwrap();\n        fs::write(nested_dir.join(\"other.txt\"), \"text\").unwrap();\n\n        let deep_dir = nested_dir.join(\"deep\");\n        fs::create_dir(&deep_dir).unwrap();\n        fs::write(deep_dir.join(\"deep.yml\"), \"deep: config\").unwrap();\n\n        // Copy all YAML files recursively\n        copy_project_files_impl(source_dir.path(), target_dir.path(), \"config/**/*.yml\").unwrap();\n\n        // Verify only YAML files are copied\n        assert!(target_dir.path().join(\"config/app.yml\").exists());\n        assert!(\n            target_dir\n                .path()\n                .join(\"config/nested/settings.yml\")\n                .exists()\n        );\n        assert!(\n            target_dir\n                .path()\n                .join(\"config/nested/deep/deep.yml\")\n                .exists()\n        );\n\n        // Verify non-YAML files are not copied\n        assert!(!target_dir.path().join(\"config/db.json\").exists());\n        assert!(!target_dir.path().join(\"config/nested/other.txt\").exists());\n    }\n\n    #[test]\n    fn test_copy_project_files_duplicate_patterns_ok() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        // Create source files\n        let src_dir = source_dir.path().join(\"src\");\n        fs::create_dir(&src_dir).unwrap();\n        fs::write(src_dir.join(\"lib.rs\"), \"lib code\").unwrap();\n        fs::write(src_dir.join(\"main.rs\"), \"main code\").unwrap();\n\n        // Copy with overlapping patterns: glob and specific file\n        copy_project_files_impl(source_dir.path(), target_dir.path(), \"src/*.rs, src/lib.rs\")\n            .unwrap();\n\n        // Verify file exists once (deduplication works)\n        let target_file = target_dir.path().join(\"src/lib.rs\");\n        assert!(target_file.exists());\n        assert_eq!(fs::read_to_string(target_file).unwrap(), \"lib code\");\n\n        // Verify other file from glob is also copied\n        assert!(target_dir.path().join(\"src/main.rs\").exists());\n    }\n\n    #[test]\n    fn test_copy_project_files_single_file_path() {\n        let source_dir = TempDir::new().unwrap();\n        let target_dir = TempDir::new().unwrap();\n\n        // Create source file\n        let src_dir = source_dir.path().join(\"src\");\n        fs::create_dir(&src_dir).unwrap();\n        fs::write(src_dir.join(\"lib.rs\"), \"library code\").unwrap();\n\n        // Copy single file by exact path (exercises fast path)\n        copy_project_files_impl(source_dir.path(), target_dir.path(), \"src/lib.rs\").unwrap();\n\n        // Verify file is copied\n        let target_file = target_dir.path().join(\"src/lib.rs\");\n        assert!(target_file.exists());\n        assert_eq!(fs::read_to_string(target_file).unwrap(), \"library code\");\n    }\n\n    #[cfg(unix)]\n    #[test]\n    fn test_symlink_loop_is_skipped() {\n        use std::os::unix::fs::symlink;\n        let src = TempDir::new().unwrap();\n        let dst = TempDir::new().unwrap();\n\n        let loop_dir = src.path().join(\"loop\");\n        std::fs::create_dir(&loop_dir).unwrap();\n        symlink(\".\", loop_dir.join(\"self\")).unwrap(); // loop/self -> loop\n\n        copy_project_files_impl(src.path(), dst.path(), \"loop\").unwrap();\n\n        assert_eq!(std::fs::read_dir(dst.path()).unwrap().count(), 0);\n    }\n}\n"
  },
  {
    "path": "crates/local-deployment/src/lib.rs",
    "content": "use std::{collections::HashMap, sync::Arc};\n\nuse api_types::LoginStatus;\nuse async_trait::async_trait;\nuse db::DBService;\nuse deployment::{Deployment, DeploymentError, RemoteClientNotConfigured};\nuse executors::profile::ExecutorConfigs;\nuse git::GitService;\nuse relay_control::{RelayControl, signing::RelaySigningService};\nuse server_info::ServerInfo;\nuse services::services::{\n    analytics::{AnalyticsConfig, AnalyticsContext, AnalyticsService, generate_user_id},\n    approvals::Approvals,\n    auth::AuthContext,\n    config::{Config, load_config_from_file, save_config_to_file},\n    container::ContainerService,\n    events::EventService,\n    file::FileService,\n    file_search::FileSearchCache,\n    filesystem::FilesystemService,\n    oauth_credentials::OAuthCredentials,\n    pr_monitor::PrMonitorService,\n    queued_message::QueuedMessageService,\n    remote_client::{RemoteClient, RemoteClientError},\n    repo::RepoService,\n};\nuse tokio::sync::RwLock;\nuse trusted_key_auth::runtime::TrustedKeyAuthRuntime;\nuse utils::{\n    assets::{config_path, credentials_path, server_signing_key_path, trusted_keys_path},\n    msg_store::MsgStore,\n};\nuse uuid::Uuid;\nuse workspace_manager::WorkspaceManager;\nuse worktree_manager::WorktreeManager;\n\nuse crate::{container::LocalContainerService, pty::PtyService};\nmod command;\npub mod container;\nmod copy;\npub mod pty;\n\n#[derive(Clone)]\npub struct LocalDeployment {\n    config: Arc<RwLock<Config>>,\n    user_id: String,\n    db: DBService,\n    workspace_manager: WorkspaceManager,\n    analytics: Option<AnalyticsService>,\n    container: LocalContainerService,\n    git: GitService,\n    repo: RepoService,\n    file: FileService,\n    filesystem: FilesystemService,\n    events: EventService,\n    file_search_cache: Arc<FileSearchCache>,\n    approvals: Approvals,\n    queued_message_service: QueuedMessageService,\n    remote_client: Result<RemoteClient, RemoteClientNotConfigured>,\n    shared_api_base: Option<String>,\n    auth_context: AuthContext,\n    oauth_handoffs: Arc<RwLock<HashMap<Uuid, PendingHandoff>>>,\n    trusted_key_auth: TrustedKeyAuthRuntime,\n    relay_signing: RelaySigningService,\n    relay_control: Arc<RelayControl>,\n    server_info: Arc<ServerInfo>,\n    pty: PtyService,\n}\n\n#[derive(Debug, Clone)]\nstruct PendingHandoff {\n    provider: String,\n    app_verifier: String,\n}\n\n#[async_trait]\nimpl Deployment for LocalDeployment {\n    async fn new() -> Result<Self, DeploymentError> {\n        // Run one-time process logs migration from DB to filesystem\n        services::services::execution_process::migrate_execution_logs_to_files()\n            .await\n            .map_err(|e| DeploymentError::Other(anyhow::anyhow!(\"Migration failed: {}\", e)))?;\n\n        let mut raw_config = load_config_from_file(&config_path()).await;\n\n        let profiles = ExecutorConfigs::get_cached();\n        if !raw_config.onboarding_acknowledged\n            && let Ok(recommended_executor) = profiles.get_recommended_executor_profile().await\n        {\n            raw_config.executor_profile = recommended_executor;\n        }\n\n        // Check if app version has changed and set release notes flag\n        {\n            let current_version = utils::version::APP_VERSION;\n            let stored_version = raw_config.last_app_version.as_deref();\n\n            if stored_version != Some(current_version) {\n                // Show release notes only if this is an upgrade (not first install)\n                raw_config.show_release_notes = stored_version.is_some();\n                raw_config.last_app_version = Some(current_version.to_string());\n            }\n        }\n\n        // Always save config (may have been migrated or version updated)\n        save_config_to_file(&raw_config, &config_path()).await?;\n\n        if let Some(workspace_dir) = &raw_config.workspace_dir {\n            let path = utils::path::expand_tilde(workspace_dir);\n            WorktreeManager::set_workspace_dir_override(path);\n        }\n\n        let config = Arc::new(RwLock::new(raw_config));\n        let user_id = generate_user_id();\n        let analytics = AnalyticsConfig::new().map(AnalyticsService::new);\n        let git = GitService::new();\n        let repo = RepoService::new();\n        let msg_stores = Arc::new(RwLock::new(HashMap::new()));\n        let filesystem = FilesystemService::new();\n\n        // Create shared components for EventService\n        let events_msg_store = Arc::new(MsgStore::new());\n        let events_entry_count = Arc::new(RwLock::new(0));\n\n        // Create DB with event hooks\n        let db = {\n            let hook = EventService::create_hook(\n                events_msg_store.clone(),\n                events_entry_count.clone(),\n                DBService::new().await?, // Temporary DB service for the hook\n            );\n            DBService::new_with_after_connect(hook).await?\n        };\n\n        let file = FileService::new(db.clone().pool)?;\n        {\n            let file_service = file.clone();\n            tokio::spawn(async move {\n                tracing::info!(\"Starting orphaned file cleanup...\");\n                if let Err(e) = file_service.delete_orphaned_files().await {\n                    tracing::error!(\"Failed to clean up orphaned files: {}\", e);\n                }\n            });\n        }\n\n        let approvals = Approvals::new();\n        let queued_message_service = QueuedMessageService::new();\n\n        let oauth_credentials = Arc::new(OAuthCredentials::new(credentials_path()));\n        if let Err(e) = oauth_credentials.load().await {\n            tracing::warn!(?e, \"failed to load OAuth credentials\");\n        }\n\n        let profile_cache = Arc::new(RwLock::new(None));\n        let auth_context = AuthContext::new(oauth_credentials.clone(), profile_cache.clone());\n\n        let api_base = std::env::var(\"VK_SHARED_API_BASE\")\n            .ok()\n            .or_else(|| option_env!(\"VK_SHARED_API_BASE\").map(|s| s.to_string()));\n\n        let remote_client = match &api_base {\n            Some(url) => match RemoteClient::new(url, auth_context.clone()) {\n                Ok(client) => {\n                    tracing::info!(\"Remote client initialized with URL: {}\", url);\n                    Ok(client)\n                }\n                Err(e) => {\n                    tracing::error!(?e, \"failed to create remote client\");\n                    Err(RemoteClientNotConfigured)\n                }\n            },\n            None => {\n                tracing::info!(\"VK_SHARED_API_BASE not set; remote features disabled\");\n                Err(RemoteClientNotConfigured)\n            }\n        };\n\n        let oauth_handoffs = Arc::new(RwLock::new(HashMap::new()));\n        let trusted_key_auth = TrustedKeyAuthRuntime::new(trusted_keys_path());\n        let relay_signing = RelaySigningService::load_or_generate(&server_signing_key_path())\n            .expect(\"Failed to load or generate server signing key\");\n        let relay_control = Arc::new(RelayControl::new());\n        let server_info = Arc::new(ServerInfo::new());\n\n        // We need to make analytics accessible to the ContainerService\n        // TODO: Handle this more gracefully\n        let analytics_ctx = analytics.as_ref().map(|s| AnalyticsContext {\n            user_id: user_id.clone(),\n            analytics_service: s.clone(),\n        });\n        let workspace_manager = WorkspaceManager::new(db.clone());\n        let container = LocalContainerService::new(\n            db.clone(),\n            workspace_manager.clone(),\n            msg_stores.clone(),\n            config.clone(),\n            git.clone(),\n            file.clone(),\n            analytics_ctx,\n            approvals.clone(),\n            queued_message_service.clone(),\n            remote_client.clone().ok(),\n        )\n        .await;\n\n        let events = EventService::new(db.clone(), events_msg_store, events_entry_count);\n\n        let file_search_cache = Arc::new(FileSearchCache::new());\n\n        let pty = PtyService::new();\n        {\n            let db = db.clone();\n            let analytics = analytics.as_ref().map(|s| AnalyticsContext {\n                user_id: user_id.clone(),\n                analytics_service: s.clone(),\n            });\n            let container = container.clone();\n            let rc = remote_client.clone().ok();\n            PrMonitorService::spawn(db, analytics, container, rc).await;\n        }\n\n        let deployment = Self {\n            config,\n            user_id,\n            db,\n            workspace_manager,\n            analytics,\n            container,\n            git,\n            repo,\n            file,\n            filesystem,\n            events,\n            file_search_cache,\n            approvals,\n            queued_message_service,\n            remote_client,\n            shared_api_base: api_base,\n            auth_context,\n            oauth_handoffs,\n            trusted_key_auth,\n            relay_signing,\n            relay_control,\n            server_info,\n            pty,\n        };\n\n        Ok(deployment)\n    }\n\n    fn user_id(&self) -> &str {\n        &self.user_id\n    }\n\n    fn config(&self) -> &Arc<RwLock<Config>> {\n        &self.config\n    }\n\n    fn db(&self) -> &DBService {\n        &self.db\n    }\n\n    fn analytics(&self) -> &Option<AnalyticsService> {\n        &self.analytics\n    }\n\n    fn container(&self) -> &impl ContainerService {\n        &self.container\n    }\n\n    fn git(&self) -> &GitService {\n        &self.git\n    }\n\n    fn repo(&self) -> &RepoService {\n        &self.repo\n    }\n\n    fn file(&self) -> &FileService {\n        &self.file\n    }\n\n    fn filesystem(&self) -> &FilesystemService {\n        &self.filesystem\n    }\n\n    fn events(&self) -> &EventService {\n        &self.events\n    }\n\n    fn file_search_cache(&self) -> &Arc<FileSearchCache> {\n        &self.file_search_cache\n    }\n\n    fn approvals(&self) -> &Approvals {\n        &self.approvals\n    }\n\n    fn queued_message_service(&self) -> &QueuedMessageService {\n        &self.queued_message_service\n    }\n\n    fn auth_context(&self) -> &AuthContext {\n        &self.auth_context\n    }\n\n    fn relay_control(&self) -> &Arc<RelayControl> {\n        &self.relay_control\n    }\n\n    fn relay_signing(&self) -> &RelaySigningService {\n        &self.relay_signing\n    }\n\n    fn server_info(&self) -> &Arc<ServerInfo> {\n        &self.server_info\n    }\n\n    fn trusted_key_auth(&self) -> &TrustedKeyAuthRuntime {\n        &self.trusted_key_auth\n    }\n\n    fn shared_api_base(&self) -> Option<String> {\n        self.shared_api_base.clone()\n    }\n}\n\nimpl LocalDeployment {\n    pub fn workspace_manager(&self) -> &WorkspaceManager {\n        &self.workspace_manager\n    }\n\n    pub fn remote_client(&self) -> Result<RemoteClient, RemoteClientNotConfigured> {\n        self.remote_client.clone()\n    }\n\n    pub async fn get_login_status(&self) -> LoginStatus {\n        if self.auth_context.get_credentials().await.is_none() {\n            self.auth_context.clear_profile().await;\n            return LoginStatus::LoggedOut;\n        };\n\n        if let Some(cached_profile) = self.auth_context.cached_profile().await {\n            return LoginStatus::LoggedIn {\n                profile: cached_profile,\n            };\n        }\n\n        let Ok(client) = self.remote_client() else {\n            return LoginStatus::LoggedOut;\n        };\n\n        match client.profile().await {\n            Ok(profile) => {\n                self.auth_context.set_profile(profile.clone()).await;\n                LoginStatus::LoggedIn { profile }\n            }\n            Err(RemoteClientError::Auth) => {\n                let _ = self.auth_context.clear_credentials().await;\n                self.auth_context.clear_profile().await;\n                LoginStatus::LoggedOut\n            }\n            Err(_) => LoginStatus::LoggedOut,\n        }\n    }\n\n    pub async fn store_oauth_handoff(\n        &self,\n        handoff_id: Uuid,\n        provider: String,\n        app_verifier: String,\n    ) {\n        self.oauth_handoffs.write().await.insert(\n            handoff_id,\n            PendingHandoff {\n                provider,\n                app_verifier,\n            },\n        );\n    }\n\n    pub async fn take_oauth_handoff(&self, handoff_id: &Uuid) -> Option<(String, String)> {\n        self.oauth_handoffs\n            .write()\n            .await\n            .remove(handoff_id)\n            .map(|state| (state.provider, state.app_verifier))\n    }\n\n    pub fn pty(&self) -> &PtyService {\n        &self.pty\n    }\n}\n"
  },
  {
    "path": "crates/local-deployment/src/pty.rs",
    "content": "use std::{\n    collections::HashMap,\n    io::{Read, Write},\n    path::PathBuf,\n    sync::{Arc, Mutex},\n    thread,\n};\n\nuse portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};\nuse thiserror::Error;\nuse tokio::sync::mpsc;\nuse utils::shell::get_interactive_shell;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum PtyError {\n    #[error(\"Failed to create PTY: {0}\")]\n    CreateFailed(String),\n    #[error(\"Session not found: {0}\")]\n    SessionNotFound(Uuid),\n    #[error(\"Failed to write to PTY: {0}\")]\n    WriteFailed(String),\n    #[error(\"Failed to resize PTY: {0}\")]\n    ResizeFailed(String),\n    #[error(\"Session already closed\")]\n    SessionClosed,\n}\n\nstruct PtySession {\n    writer: Box<dyn Write + Send>,\n    master: Box<dyn portable_pty::MasterPty + Send>,\n    _output_handle: thread::JoinHandle<()>,\n    closed: bool,\n}\n\n#[derive(Clone)]\npub struct PtyService {\n    sessions: Arc<Mutex<HashMap<Uuid, PtySession>>>,\n}\n\nimpl PtyService {\n    pub fn new() -> Self {\n        Self {\n            sessions: Arc::new(Mutex::new(HashMap::new())),\n        }\n    }\n\n    pub async fn create_session(\n        &self,\n        working_dir: PathBuf,\n        cols: u16,\n        rows: u16,\n    ) -> Result<(Uuid, mpsc::UnboundedReceiver<Vec<u8>>), PtyError> {\n        let session_id = Uuid::new_v4();\n        let (output_tx, output_rx) = mpsc::unbounded_channel();\n        let shell = get_interactive_shell().await;\n\n        let result = tokio::task::spawn_blocking(move || {\n            let pty_system = NativePtySystem::default();\n\n            let pty_pair = pty_system\n                .openpty(PtySize {\n                    rows,\n                    cols,\n                    pixel_width: 0,\n                    pixel_height: 0,\n                })\n                .map_err(|e| PtyError::CreateFailed(e.to_string()))?;\n\n            let mut cmd = CommandBuilder::new(&shell);\n            cmd.cwd(&working_dir);\n\n            // Configure shell-specific options\n            let shell_name = shell.file_name().and_then(|n| n.to_str()).unwrap_or(\"\");\n\n            if shell_name == \"powershell.exe\" || shell_name == \"pwsh.exe\" {\n                // PowerShell: use -NoLogo for cleaner startup\n                cmd.arg(\"-NoLogo\");\n            } else if shell_name == \"cmd.exe\" {\n                // cmd.exe: no special args needed\n            } else {\n                // Unix shells\n                cmd.env(\"VIBE_KANBAN_TERMINAL\", \"1\");\n\n                if shell_name == \"bash\" {\n                    cmd.env(\"PROMPT_COMMAND\", r#\"PS1='$ '; unset PROMPT_COMMAND\"#);\n                } else if shell_name == \"zsh\" {\n                    // PROMPT is set after spawning\n                } else {\n                    cmd.env(\"PS1\", \"$ \");\n                }\n            }\n\n            cmd.env(\"TERM\", \"xterm-256color\");\n            cmd.env(\"COLORTERM\", \"truecolor\");\n\n            let child = pty_pair\n                .slave\n                .spawn_command(cmd)\n                .map_err(|e| PtyError::CreateFailed(e.to_string()))?;\n\n            let mut writer = pty_pair\n                .master\n                .take_writer()\n                .map_err(|e| PtyError::CreateFailed(e.to_string()))?;\n\n            if shell_name == \"zsh\" {\n                let _ = writer.write_all(b\" PROMPT='$ '; RPROMPT=''\\n\");\n                let _ = writer.flush();\n                let _ = writer.write_all(b\"\\x0c\");\n                let _ = writer.flush();\n            }\n\n            let mut reader = pty_pair\n                .master\n                .try_clone_reader()\n                .map_err(|e| PtyError::CreateFailed(e.to_string()))?;\n\n            let output_handle = thread::spawn(move || {\n                let mut buf = [0u8; 4096];\n                loop {\n                    match reader.read(&mut buf) {\n                        Ok(0) => break,\n                        Ok(n) => {\n                            if output_tx.send(buf[..n].to_vec()).is_err() {\n                                break;\n                            }\n                        }\n                        Err(_) => break,\n                    }\n                }\n                drop(child);\n            });\n\n            Ok::<_, PtyError>((pty_pair.master, writer, output_handle))\n        })\n        .await\n        .map_err(|e| PtyError::CreateFailed(e.to_string()))??;\n\n        let (master, writer, output_handle) = result;\n\n        let session = PtySession {\n            writer,\n            master,\n            _output_handle: output_handle,\n            closed: false,\n        };\n\n        self.sessions\n            .lock()\n            .map_err(|e| PtyError::CreateFailed(e.to_string()))?\n            .insert(session_id, session);\n\n        Ok((session_id, output_rx))\n    }\n\n    pub async fn write(&self, session_id: Uuid, data: &[u8]) -> Result<(), PtyError> {\n        let mut sessions = self\n            .sessions\n            .lock()\n            .map_err(|e| PtyError::WriteFailed(e.to_string()))?;\n        let session = sessions\n            .get_mut(&session_id)\n            .ok_or(PtyError::SessionNotFound(session_id))?;\n\n        if session.closed {\n            return Err(PtyError::SessionClosed);\n        }\n\n        session\n            .writer\n            .write_all(data)\n            .map_err(|e| PtyError::WriteFailed(e.to_string()))?;\n\n        session\n            .writer\n            .flush()\n            .map_err(|e| PtyError::WriteFailed(e.to_string()))?;\n\n        Ok(())\n    }\n\n    pub async fn resize(&self, session_id: Uuid, cols: u16, rows: u16) -> Result<(), PtyError> {\n        let sessions = self\n            .sessions\n            .lock()\n            .map_err(|e| PtyError::ResizeFailed(e.to_string()))?;\n        let session = sessions\n            .get(&session_id)\n            .ok_or(PtyError::SessionNotFound(session_id))?;\n\n        if session.closed {\n            return Err(PtyError::SessionClosed);\n        }\n\n        session\n            .master\n            .resize(PtySize {\n                rows,\n                cols,\n                pixel_width: 0,\n                pixel_height: 0,\n            })\n            .map_err(|e| PtyError::ResizeFailed(e.to_string()))?;\n\n        Ok(())\n    }\n\n    pub async fn close_session(&self, session_id: Uuid) -> Result<(), PtyError> {\n        if let Some(mut session) = self\n            .sessions\n            .lock()\n            .map_err(|_| PtyError::SessionClosed)?\n            .remove(&session_id)\n        {\n            session.closed = true;\n        }\n        Ok(())\n    }\n\n    pub fn session_exists(&self, session_id: &Uuid) -> bool {\n        self.sessions\n            .lock()\n            .map(|s| s.contains_key(session_id))\n            .unwrap_or(false)\n    }\n}\n\nimpl Default for PtyService {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "crates/mcp/Cargo.toml",
    "content": "[package]\nname = \"mcp\"\nversion = \"0.1.33\"\nedition = \"2024\"\nautobins = false\n\n[[bin]]\nname = \"vibe-kanban-mcp\"\npath = \"src/bin/vibe_kanban_mcp.rs\"\n\n[lints.clippy]\nuninlined-format-args = \"allow\"\n\n[dependencies]\napi-types = { path = \"../api-types\" }\ndb = { path = \"../db\" }\nexecutors = { path = \"../executors\" }\nutils = { path = \"../utils\" }\ntokio = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nanyhow = { workspace = true }\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nrmcp = { version = \"0.5.0\", features = [\"server\", \"transport-io\"] }\nschemars = { workspace = true }\nsentry = { version = \"0.46.2\", default-features = false, features = [\"anyhow\", \"backtrace\", \"panic\", \"debug-images\", \"reqwest\", \"rustls\"] }\nreqwest = { workspace = true }\nrustls = { workspace = true }\nregex = \"1\"\n"
  },
  {
    "path": "crates/mcp/src/bin/vibe_kanban_mcp.rs",
    "content": "use mcp::task_server::McpServer;\nuse rmcp::{ServiceExt, transport::stdio};\nuse tracing_subscriber::{EnvFilter, prelude::*};\nuse utils::{\n    port_file::read_port_file,\n    sentry::{self as sentry_utils, SentrySource, sentry_layer},\n};\n\nconst HOST_ENV: &str = \"MCP_HOST\";\nconst PORT_ENV: &str = \"MCP_PORT\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum McpLaunchMode {\n    Global,\n    Orchestrator,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\nstruct LaunchConfig {\n    mode: McpLaunchMode,\n}\n\nfn main() -> anyhow::Result<()> {\n    let launch_config = resolve_launch_config()?;\n\n    tokio::runtime::Builder::new_multi_thread()\n        .enable_all()\n        .build()\n        .unwrap()\n        .block_on(async move {\n            let version = env!(\"CARGO_PKG_VERSION\");\n            init_process_logging(\"vibe-kanban-mcp\", version);\n\n            let base_url = resolve_base_url(\"vibe-kanban-mcp\").await?;\n            let LaunchConfig { mode } = launch_config;\n\n            let server = match mode {\n                McpLaunchMode::Global => McpServer::new_global(&base_url),\n                McpLaunchMode::Orchestrator => McpServer::new_orchestrator(&base_url),\n            };\n\n            let service = server.init().await?.serve(stdio()).await.map_err(|error| {\n                tracing::error!(\"serving error: {:?}\", error);\n                error\n            })?;\n\n            service.waiting().await?;\n            Ok(())\n        })\n}\n\nfn resolve_launch_config() -> anyhow::Result<LaunchConfig> {\n    resolve_launch_config_from_iter(std::env::args().skip(1))\n}\n\nfn resolve_launch_config_from_iter<I>(mut args: I) -> anyhow::Result<LaunchConfig>\nwhere\n    I: Iterator<Item = String>,\n{\n    let mut mode = None;\n\n    while let Some(arg) = args.next() {\n        match arg.as_str() {\n            \"--mode\" => {\n                mode = Some(args.next().ok_or_else(|| {\n                    anyhow::anyhow!(\"Missing value for --mode. Expected 'global' or 'orchestrator'\")\n                })?);\n            }\n            \"-h\" | \"--help\" => {\n                println!(\"Usage: vibe-kanban-mcp --mode <global|orchestrator>\");\n                std::process::exit(0);\n            }\n            _ => {\n                return Err(anyhow::anyhow!(\n                    \"Unknown argument '{arg}'. Usage: vibe-kanban-mcp --mode <global|orchestrator>\"\n                ));\n            }\n        }\n    }\n\n    let mode = match mode\n        .as_deref()\n        .unwrap_or(\"global\")\n        .trim()\n        .to_ascii_lowercase()\n        .as_str()\n    {\n        \"global\" => McpLaunchMode::Global,\n        \"orchestrator\" => McpLaunchMode::Orchestrator,\n        value => {\n            return Err(anyhow::anyhow!(\n                \"Invalid MCP mode '{value}'. Expected 'global' or 'orchestrator'\"\n            ));\n        }\n    };\n\n    Ok(LaunchConfig { mode })\n}\n\nasync fn resolve_base_url(log_prefix: &str) -> anyhow::Result<String> {\n    if let Ok(url) = std::env::var(\"VIBE_BACKEND_URL\") {\n        tracing::info!(\n            \"[{}] Using backend URL from VIBE_BACKEND_URL: {}\",\n            log_prefix,\n            url\n        );\n        return Ok(url);\n    }\n\n    let host = std::env::var(HOST_ENV)\n        .or_else(|_| std::env::var(\"HOST\"))\n        .unwrap_or_else(|_| \"127.0.0.1\".to_string());\n\n    let port = match std::env::var(PORT_ENV)\n        .or_else(|_| std::env::var(\"BACKEND_PORT\"))\n        .or_else(|_| std::env::var(\"PORT\"))\n    {\n        Ok(port_str) => {\n            tracing::info!(\"[{}] Using port from environment: {}\", log_prefix, port_str);\n            port_str\n                .parse::<u16>()\n                .map_err(|error| anyhow::anyhow!(\"Invalid port value '{}': {}\", port_str, error))?\n        }\n        Err(_) => {\n            let port = read_port_file(\"vibe-kanban\").await?;\n            tracing::info!(\"[{}] Using port from port file: {}\", log_prefix, port);\n            port\n        }\n    };\n\n    let url = format!(\"http://{}:{}\", host, port);\n    tracing::info!(\"[{}] Using backend URL: {}\", log_prefix, url);\n    Ok(url)\n}\n\nfn init_process_logging(log_prefix: &str, version: &str) {\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .expect(\"Failed to install rustls crypto provider\");\n\n    sentry_utils::init_once(SentrySource::Mcp);\n\n    tracing_subscriber::registry()\n        .with(\n            tracing_subscriber::fmt::layer()\n                .with_writer(std::io::stderr)\n                .with_filter(EnvFilter::new(\"debug\")),\n        )\n        .with(sentry_layer())\n        .init();\n\n    tracing::debug!(\n        \"[{}] Starting Vibe Kanban MCP server version {}...\",\n        log_prefix,\n        version\n    );\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{LaunchConfig, McpLaunchMode, resolve_launch_config_from_iter};\n\n    #[test]\n    fn orchestrator_mode_does_not_require_session_id() {\n        let config = resolve_launch_config_from_iter(\n            [\"--mode\".to_string(), \"orchestrator\".to_string()].into_iter(),\n        )\n        .expect(\"config should parse\");\n\n        assert_eq!(\n            config,\n            LaunchConfig {\n                mode: McpLaunchMode::Orchestrator\n            }\n        );\n    }\n\n    #[test]\n    fn session_id_flag_is_rejected() {\n        let error = resolve_launch_config_from_iter(\n            [\n                \"--mode\".to_string(),\n                \"orchestrator\".to_string(),\n                \"--session-id\".to_string(),\n                \"x\".to_string(),\n            ]\n            .into_iter(),\n        )\n        .expect_err(\"session id flag should be rejected\");\n\n        assert!(\n            error\n                .to_string()\n                .contains(\"Unknown argument '--session-id'\")\n        );\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/lib.rs",
    "content": "use serde::Deserialize;\n\n#[derive(Debug, Deserialize)]\npub struct ApiResponseEnvelope<T> {\n    pub success: bool,\n    pub data: Option<T>,\n    pub message: Option<String>,\n}\n\npub mod task_server;\n"
  },
  {
    "path": "crates/mcp/src/task_server/handler.rs",
    "content": "use rmcp::{\n    ServerHandler,\n    model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo},\n    tool_handler,\n};\n\nuse super::{McpMode, McpServer};\n\n#[tool_handler]\nimpl ServerHandler for McpServer {\n    fn get_info(&self) -> ServerInfo {\n        let mut tool_names = self\n            .tool_router\n            .list_all()\n            .into_iter()\n            .map(|tool| format!(\"'{}'\", tool.name))\n            .collect::<Vec<_>>();\n        tool_names.sort();\n\n        let preamble = match self.mode() {\n            McpMode::Global => {\n                \"A Vibe Kanban MCP server for task, issue, repository, workspace, and session management.\"\n            }\n            McpMode::Orchestrator => {\n                \"An orchestrator-scoped Vibe Kanban MCP server with tools limited to the configured workspace and orchestrator session context.\"\n            }\n        };\n        let mut instruction = format!(\n            \"{} Use list/read tools first when you need IDs or current state. TOOLS: {}.\",\n            preamble,\n            tool_names.join(\", \")\n        );\n        if self.context.is_some() {\n            instruction = format!(\n                \"Use 'get_context' to fetch project, issue, workspace, and orchestrator-session metadata for the active MCP context when available. {}\",\n                instruction\n            );\n        }\n\n        ServerInfo {\n            protocol_version: ProtocolVersion::V_2025_03_26,\n            capabilities: ServerCapabilities::builder().enable_tools().build(),\n            server_info: Implementation {\n                name: \"vibe-kanban-mcp\".to_string(),\n                version: \"1.0.0\".to_string(),\n            },\n            instructions: Some(instruction),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/mod.rs",
    "content": "mod handler;\nmod tools;\n\nuse std::path::Path;\n\nuse anyhow::Context;\nuse db::models::{requests::ContainerQuery, workspace::WorkspaceContext};\nuse rmcp::{handler::server::tool::ToolRouter, schemars};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\npub(crate) use crate::ApiResponseEnvelope;\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]\npub struct McpRepoContext {\n    #[schemars(description = \"The unique identifier of the repository\")]\n    pub repo_id: Uuid,\n    #[schemars(description = \"The name of the repository\")]\n    pub repo_name: String,\n    #[schemars(description = \"The target branch for this repository in this workspace\")]\n    pub target_branch: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]\npub struct McpContext {\n    #[schemars(description = \"The organization ID (if workspace is linked to remote)\")]\n    pub organization_id: Option<Uuid>,\n    #[schemars(description = \"The remote project ID (if workspace is linked to remote)\")]\n    pub project_id: Option<Uuid>,\n    #[schemars(description = \"The remote issue ID (if workspace is linked to a remote issue)\")]\n    pub issue_id: Option<Uuid>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[schemars(description = \"The orchestrator session ID when running in orchestrator mode\")]\n    pub orchestrator_session_id: Option<Uuid>,\n    pub workspace_id: Uuid,\n    pub workspace_branch: String,\n    #[schemars(\n        description = \"Repository info and target branches for each repo in this workspace\"\n    )]\n    pub workspace_repos: Vec<McpRepoContext>,\n}\n\n#[derive(Debug, Clone)]\npub enum McpMode {\n    Global,\n    Orchestrator,\n}\n\n#[derive(Debug, Clone)]\npub struct McpServer {\n    client: reqwest::Client,\n    base_url: String,\n    tool_router: ToolRouter<McpServer>,\n    context: Option<McpContext>,\n    mode: McpMode,\n}\n\nimpl McpServer {\n    pub fn new_global(base_url: &str) -> Self {\n        Self {\n            client: reqwest::Client::new(),\n            base_url: base_url.to_string(),\n            tool_router: Self::global_mode_router(),\n            context: None,\n            mode: McpMode::Global,\n        }\n    }\n\n    pub fn new_orchestrator(base_url: &str) -> Self {\n        Self {\n            client: reqwest::Client::new(),\n            base_url: base_url.to_string(),\n            tool_router: Self::orchestrator_mode_router(),\n            context: None,\n            mode: McpMode::Orchestrator,\n        }\n    }\n\n    fn url(&self, path: &str) -> String {\n        format!(\n            \"{}/{}\",\n            self.base_url.trim_end_matches('/'),\n            path.trim_start_matches('/')\n        )\n    }\n\n    pub async fn init(mut self) -> anyhow::Result<Self> {\n        let context = self.fetch_context_at_startup().await?;\n\n        if context.is_none() {\n            self.tool_router.map.remove(\"get_context\");\n            tracing::debug!(\"VK context not available, get_context tool will not be registered\");\n        } else {\n            tracing::info!(\"VK context loaded, get_context tool available\");\n        }\n\n        self.context = context;\n        Ok(self)\n    }\n\n    pub fn mode(&self) -> &McpMode {\n        &self.mode\n    }\n\n    async fn fetch_context_at_startup(&self) -> anyhow::Result<Option<McpContext>> {\n        let current_dir = std::env::current_dir().context(\"Failed to resolve current directory\")?;\n        let canonical_path = current_dir.canonicalize().unwrap_or(current_dir);\n        let normalized_path = utils::path::normalize_macos_private_alias(&canonical_path);\n\n        match self.try_fetch_attempt_context(&normalized_path).await {\n            Ok(Some(ctx)) => Ok(Some(\n                self.build_mcp_context_from_workspace_context(&ctx).await,\n            )),\n            Ok(None) | Err(_) if matches!(self.mode(), McpMode::Global) => Ok(None),\n            Ok(None) => anyhow::bail!(\n                \"Failed to load orchestrator MCP context from /api/containers/attempt-context\"\n            ),\n            Err(error) => Err(error.context(\"Failed to load orchestrator MCP context\")),\n        }\n    }\n\n    async fn try_fetch_attempt_context(\n        &self,\n        path: &Path,\n    ) -> anyhow::Result<Option<WorkspaceContext>> {\n        let url = self.url(\"/api/containers/attempt-context\");\n        let query = ContainerQuery {\n            container_ref: path.to_string_lossy().to_string(),\n        };\n\n        let response = tokio::time::timeout(\n            std::time::Duration::from_millis(500),\n            self.client.get(&url).query(&query).send(),\n        )\n        .await\n        .context(\"Timed out fetching /api/containers/attempt-context\")?\n        .context(\"Failed to fetch /api/containers/attempt-context\")?;\n\n        if !response.status().is_success() {\n            return Ok(None);\n        }\n\n        let api_response: ApiResponseEnvelope<WorkspaceContext> = response\n            .json()\n            .await\n            .context(\"Failed to parse /api/containers/attempt-context response\")?;\n\n        if !api_response.success {\n            return Ok(None);\n        }\n\n        Ok(api_response.data)\n    }\n\n    async fn build_mcp_context_from_workspace_context(&self, ctx: &WorkspaceContext) -> McpContext {\n        let workspace_repos: Vec<McpRepoContext> = ctx\n            .workspace_repos\n            .iter()\n            .map(|rwb| McpRepoContext {\n                repo_id: rwb.repo.id,\n                repo_name: rwb.repo.name.clone(),\n                target_branch: rwb.target_branch.clone(),\n            })\n            .collect();\n\n        let workspace_id = ctx.workspace.id;\n        let workspace_branch = ctx.workspace.branch.clone();\n        let orchestrator_session_id = if matches!(self.mode(), McpMode::Orchestrator) {\n            ctx.orchestrator_session_id\n        } else {\n            None\n        };\n\n        let (project_id, issue_id, organization_id) = self\n            .fetch_remote_workspace_context(workspace_id)\n            .await\n            .unwrap_or((None, None, None));\n\n        McpContext {\n            organization_id,\n            project_id,\n            issue_id,\n            orchestrator_session_id,\n            workspace_id,\n            workspace_branch,\n            workspace_repos,\n        }\n    }\n\n    async fn fetch_remote_workspace_context(\n        &self,\n        local_workspace_id: Uuid,\n    ) -> Option<(Option<Uuid>, Option<Uuid>, Option<Uuid>)> {\n        let url = self.url(&format!(\n            \"/api/remote/workspaces/by-local-id/{}\",\n            local_workspace_id\n        ));\n\n        let response = tokio::time::timeout(\n            std::time::Duration::from_millis(2000),\n            self.client.get(&url).send(),\n        )\n        .await\n        .ok()?\n        .ok()?;\n\n        if !response.status().is_success() {\n            return None;\n        }\n\n        let api_response: ApiResponseEnvelope<api_types::Workspace> = response.json().await.ok()?;\n\n        if !api_response.success {\n            return None;\n        }\n\n        let remote_ws = api_response.data?;\n        let project_id = remote_ws.project_id;\n\n        // Fetch the project to get organization_id\n        let org_id = self.fetch_remote_organization_id(project_id).await;\n\n        Some((Some(project_id), remote_ws.issue_id, org_id))\n    }\n\n    async fn fetch_remote_organization_id(&self, project_id: Uuid) -> Option<Uuid> {\n        let url = self.url(&format!(\"/api/remote/projects/{}\", project_id));\n\n        let response = tokio::time::timeout(\n            std::time::Duration::from_millis(2000),\n            self.client.get(&url).send(),\n        )\n        .await\n        .ok()?\n        .ok()?;\n\n        if !response.status().is_success() {\n            return None;\n        }\n\n        let api_response: ApiResponseEnvelope<api_types::Project> = response.json().await.ok()?;\n        let project = api_response.data?;\n        Some(project.organization_id)\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/context.rs",
    "content": "use rmcp::{ErrorData, model::CallToolResult, tool, tool_router};\n\nuse super::McpServer;\n\n#[tool_router(router = context_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(\n        description = \"Return project, issue, workspace, and orchestrator-session metadata for the current MCP context.\"\n    )]\n    async fn get_context(&self) -> Result<CallToolResult, ErrorData> {\n        let context = self.context.as_ref().expect(\"VK context should exist\");\n        McpServer::success(context)\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/issue_assignees.rs",
    "content": "use api_types::{\n    CreateIssueAssigneeRequest, IssueAssignee, ListIssueAssigneesResponse, MutationResponse,\n};\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpListIssueAssigneesRequest {\n    #[schemars(description = \"Issue ID to list assignees for\")]\n    issue_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct IssueAssigneeSummary {\n    #[schemars(description = \"Issue assignee ID\")]\n    id: String,\n    #[schemars(description = \"Issue ID\")]\n    issue_id: String,\n    #[schemars(description = \"User ID\")]\n    user_id: String,\n    #[schemars(description = \"Assignment timestamp\")]\n    assigned_at: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListIssueAssigneesResponse {\n    issue_id: String,\n    issue_assignees: Vec<IssueAssigneeSummary>,\n    count: usize,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpAssignIssueRequest {\n    #[schemars(description = \"Issue ID to assign\")]\n    issue_id: Uuid,\n    #[schemars(description = \"User ID to assign to the issue\")]\n    user_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpAssignIssueResponse {\n    issue_assignee_id: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpUnassignIssueRequest {\n    #[schemars(description = \"Issue assignee ID to remove\")]\n    issue_assignee_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpUnassignIssueResponse {\n    success: bool,\n    issue_assignee_id: String,\n}\n\n#[tool_router(router = issue_assignees_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(description = \"List assignees for an issue.\")]\n    async fn list_issue_assignees(\n        &self,\n        Parameters(McpListIssueAssigneesRequest { issue_id }): Parameters<\n            McpListIssueAssigneesRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\n            \"/api/remote/issue-assignees?issue_id={}\",\n            issue_id\n        ));\n        let response: ListIssueAssigneesResponse = match self.send_json(self.client.get(&url)).await\n        {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n\n        let assignees = response\n            .issue_assignees\n            .into_iter()\n            .map(|assignee| IssueAssigneeSummary {\n                id: assignee.id.to_string(),\n                issue_id: assignee.issue_id.to_string(),\n                user_id: assignee.user_id.to_string(),\n                assigned_at: assignee.assigned_at.to_rfc3339(),\n            })\n            .collect::<Vec<_>>();\n\n        McpServer::success(&McpListIssueAssigneesResponse {\n            issue_id: issue_id.to_string(),\n            count: assignees.len(),\n            issue_assignees: assignees,\n        })\n    }\n\n    #[tool(description = \"Assign a user to an issue.\")]\n    async fn assign_issue(\n        &self,\n        Parameters(McpAssignIssueRequest { issue_id, user_id }): Parameters<McpAssignIssueRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let payload = CreateIssueAssigneeRequest {\n            id: None,\n            issue_id,\n            user_id,\n        };\n\n        let url = self.url(\"/api/remote/issue-assignees\");\n        let response: MutationResponse<IssueAssignee> =\n            match self.send_json(self.client.post(&url).json(&payload)).await {\n                Ok(r) => r,\n                Err(e) => return Ok(e),\n            };\n\n        McpServer::success(&McpAssignIssueResponse {\n            issue_assignee_id: response.data.id.to_string(),\n        })\n    }\n\n    #[tool(description = \"Remove an assignee from an issue using issue_assignee_id.\")]\n    async fn unassign_issue(\n        &self,\n        Parameters(McpUnassignIssueRequest { issue_assignee_id }): Parameters<\n            McpUnassignIssueRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\n            \"/api/remote/issue-assignees/{}\",\n            issue_assignee_id\n        ));\n        if let Err(e) = self.send_empty_json(self.client.delete(&url)).await {\n            return Ok(e);\n        }\n\n        McpServer::success(&McpUnassignIssueResponse {\n            success: true,\n            issue_assignee_id: issue_assignee_id.to_string(),\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/issue_relationships.rs",
    "content": "use api_types::{\n    CreateIssueRelationshipRequest, IssueRelationship, IssueRelationshipType, MutationResponse,\n};\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpCreateIssueRelationshipRequest {\n    #[schemars(description = \"The source issue ID\")]\n    issue_id: Uuid,\n    #[schemars(description = \"The related issue ID\")]\n    related_issue_id: Uuid,\n    #[schemars(description = \"Relationship type: 'blocking', 'related', or 'has_duplicate'\")]\n    relationship_type: IssueRelationshipType,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpCreateIssueRelationshipResponse {\n    relationship_id: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpDeleteIssueRelationshipRequest {\n    #[schemars(\n        description = \"The relationship ID to delete (from get_issue or create_issue_relationship)\"\n    )]\n    relationship_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpDeleteIssueRelationshipResponse {\n    success: bool,\n    deleted_relationship_id: String,\n}\n\n#[tool_router(router = issue_relationships_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(\n        description = \"Create a relationship between two issues. Types: 'blocking', 'related', 'has_duplicate'.\"\n    )]\n    async fn create_issue_relationship(\n        &self,\n        Parameters(McpCreateIssueRelationshipRequest {\n            issue_id,\n            related_issue_id,\n            relationship_type,\n        }): Parameters<McpCreateIssueRelationshipRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let payload = CreateIssueRelationshipRequest {\n            id: None,\n            issue_id,\n            related_issue_id,\n            relationship_type,\n        };\n\n        let url = self.url(\"/api/remote/issue-relationships\");\n        let response: MutationResponse<IssueRelationship> =\n            match self.send_json(self.client.post(&url).json(&payload)).await {\n                Ok(r) => r,\n                Err(e) => return Ok(e),\n            };\n\n        McpServer::success(&McpCreateIssueRelationshipResponse {\n            relationship_id: response.data.id.to_string(),\n        })\n    }\n\n    #[tool(description = \"Delete a relationship between two issues.\")]\n    async fn delete_issue_relationship(\n        &self,\n        Parameters(McpDeleteIssueRelationshipRequest { relationship_id }): Parameters<\n            McpDeleteIssueRelationshipRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\n            \"/api/remote/issue-relationships/{}\",\n            relationship_id\n        ));\n        if let Err(e) = self.send_empty_json(self.client.delete(&url)).await {\n            return Ok(e);\n        }\n\n        McpServer::success(&McpDeleteIssueRelationshipResponse {\n            success: true,\n            deleted_relationship_id: relationship_id.to_string(),\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/issue_tags.rs",
    "content": "use api_types::{\n    CreateIssueTagRequest, IssueTag, ListIssueTagsResponse, ListTagsResponse, MutationResponse,\n};\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpListTagsRequest {\n    #[schemars(\n        description = \"The project ID to list tags from. Optional if running inside a workspace linked to a remote project.\"\n    )]\n    project_id: Option<Uuid>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct TagSummary {\n    #[schemars(description = \"Tag ID\")]\n    id: String,\n    #[schemars(description = \"Project ID\")]\n    project_id: String,\n    #[schemars(description = \"Tag name\")]\n    name: String,\n    #[schemars(description = \"Tag color value\")]\n    color: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListTagsResponse {\n    project_id: String,\n    tags: Vec<TagSummary>,\n    count: usize,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpListIssueTagsRequest {\n    #[schemars(description = \"Issue ID to list tags for\")]\n    issue_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct IssueTagSummary {\n    #[schemars(description = \"Issue-tag relation ID\")]\n    id: String,\n    #[schemars(description = \"Issue ID\")]\n    issue_id: String,\n    #[schemars(description = \"Tag ID\")]\n    tag_id: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListIssueTagsResponse {\n    issue_id: String,\n    issue_tags: Vec<IssueTagSummary>,\n    count: usize,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpAddIssueTagRequest {\n    #[schemars(description = \"Issue ID to attach the tag to\")]\n    issue_id: Uuid,\n    #[schemars(description = \"Tag ID to attach\")]\n    tag_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpAddIssueTagResponse {\n    issue_tag_id: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpRemoveIssueTagRequest {\n    #[schemars(description = \"Issue-tag relation ID to remove\")]\n    issue_tag_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpRemoveIssueTagResponse {\n    success: bool,\n    issue_tag_id: String,\n}\n\n#[tool_router(router = issue_tags_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(\n        description = \"List tags for a project. `project_id` is optional if running inside a workspace linked to a remote project.\"\n    )]\n    async fn list_tags(\n        &self,\n        Parameters(McpListTagsRequest { project_id }): Parameters<McpListTagsRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let project_id = match self.resolve_project_id(project_id) {\n            Ok(id) => id,\n            Err(e) => return Ok(e),\n        };\n\n        let url = self.url(&format!(\"/api/remote/tags?project_id={}\", project_id));\n        let response: ListTagsResponse = match self.send_json(self.client.get(&url)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n\n        let tags = response\n            .tags\n            .into_iter()\n            .map(|tag| TagSummary {\n                id: tag.id.to_string(),\n                project_id: tag.project_id.to_string(),\n                name: tag.name,\n                color: tag.color,\n            })\n            .collect::<Vec<_>>();\n\n        McpServer::success(&McpListTagsResponse {\n            project_id: project_id.to_string(),\n            count: tags.len(),\n            tags,\n        })\n    }\n\n    #[tool(description = \"List tags attached to an issue.\")]\n    async fn list_issue_tags(\n        &self,\n        Parameters(McpListIssueTagsRequest { issue_id }): Parameters<McpListIssueTagsRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/remote/issue-tags?issue_id={}\", issue_id));\n        let response: ListIssueTagsResponse = match self.send_json(self.client.get(&url)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n\n        let issue_tags = response\n            .issue_tags\n            .into_iter()\n            .map(|issue_tag| IssueTagSummary {\n                id: issue_tag.id.to_string(),\n                issue_id: issue_tag.issue_id.to_string(),\n                tag_id: issue_tag.tag_id.to_string(),\n            })\n            .collect::<Vec<_>>();\n\n        McpServer::success(&McpListIssueTagsResponse {\n            issue_id: issue_id.to_string(),\n            count: issue_tags.len(),\n            issue_tags,\n        })\n    }\n\n    #[tool(description = \"Attach a tag to an issue.\")]\n    async fn add_issue_tag(\n        &self,\n        Parameters(McpAddIssueTagRequest { issue_id, tag_id }): Parameters<McpAddIssueTagRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let payload = CreateIssueTagRequest {\n            id: None,\n            issue_id,\n            tag_id,\n        };\n\n        let url = self.url(\"/api/remote/issue-tags\");\n        let response: MutationResponse<IssueTag> =\n            match self.send_json(self.client.post(&url).json(&payload)).await {\n                Ok(r) => r,\n                Err(e) => return Ok(e),\n            };\n\n        McpServer::success(&McpAddIssueTagResponse {\n            issue_tag_id: response.data.id.to_string(),\n        })\n    }\n\n    #[tool(description = \"Remove a tag from an issue using issue_tag_id.\")]\n    async fn remove_issue_tag(\n        &self,\n        Parameters(McpRemoveIssueTagRequest { issue_tag_id }): Parameters<McpRemoveIssueTagRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/remote/issue-tags/{}\", issue_tag_id));\n        if let Err(e) = self.send_empty_json(self.client.delete(&url)).await {\n            return Ok(e);\n        }\n\n        McpServer::success(&McpRemoveIssueTagResponse {\n            success: true,\n            issue_tag_id: issue_tag_id.to_string(),\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/mod.rs",
    "content": "use std::str::FromStr;\n\nuse api_types::{Issue, ListProjectStatusesResponse, ProjectStatus};\nuse db::models::{execution_process::ExecutionProcessStatus, tag::Tag};\nuse executors::executors::BaseCodingAgent;\nuse regex::Regex;\nuse rmcp::{\n    ErrorData,\n    model::{CallToolResult, Content},\n};\nuse serde::{Deserialize, Serialize, de::DeserializeOwned};\nuse uuid::Uuid;\n\nuse super::{ApiResponseEnvelope, McpMode, McpServer};\n\nmod context;\nmod issue_assignees;\nmod issue_relationships;\nmod issue_tags;\nmod organizations;\nmod remote_issues;\nmod remote_projects;\nmod repos;\nmod sessions;\nmod task_attempts;\nmod workspaces;\n\nimpl McpServer {\n    pub fn global_mode_router() -> rmcp::handler::server::tool::ToolRouter<Self> {\n        Self::context_tools_router()\n            + Self::workspaces_tools_router()\n            + Self::organizations_tools_router()\n            + Self::repos_tools_router()\n            + Self::remote_projects_tools_router()\n            + Self::remote_issues_tools_router()\n            + Self::issue_assignees_tools_router()\n            + Self::issue_tags_tools_router()\n            + Self::issue_relationships_tools_router()\n            + Self::task_attempts_tools_router()\n            + Self::session_tools_router()\n    }\n\n    pub fn orchestrator_mode_router() -> rmcp::handler::server::tool::ToolRouter<Self> {\n        let mut router = Self::context_tools_router()\n            + Self::workspaces_tools_router()\n            + Self::session_tools_router();\n        router.remove_route::<(), ()>(\"list_workspaces\");\n        router.remove_route::<(), ()>(\"delete_workspace\");\n        router\n    }\n}\n\nimpl McpServer {\n    fn orchestrator_session_id(&self) -> Option<Uuid> {\n        self.context\n            .as_ref()\n            .and_then(|ctx| ctx.orchestrator_session_id)\n    }\n\n    fn scoped_workspace_id(&self) -> Option<Uuid> {\n        self.context.as_ref().map(|ctx| ctx.workspace_id)\n    }\n\n    fn success<T: Serialize>(data: &T) -> Result<CallToolResult, ErrorData> {\n        Ok(CallToolResult::success(vec![Content::text(\n            serde_json::to_string_pretty(data)\n                .unwrap_or_else(|_| \"Failed to serialize response\".to_string()),\n        )]))\n    }\n\n    fn err_value(v: serde_json::Value) -> Result<CallToolResult, ErrorData> {\n        Ok(CallToolResult::error(vec![Content::text(\n            serde_json::to_string_pretty(&v)\n                .unwrap_or_else(|_| \"Failed to serialize error\".to_string()),\n        )]))\n    }\n\n    fn err<S: Into<String>>(msg: S, details: Option<S>) -> Result<CallToolResult, ErrorData> {\n        let mut v = serde_json::json!({\"success\": false, \"error\": msg.into()});\n        if let Some(d) = details {\n            v[\"details\"] = serde_json::json!(d.into());\n        };\n        Self::err_value(v)\n    }\n\n    async fn send_json<T: DeserializeOwned>(\n        &self,\n        rb: reqwest::RequestBuilder,\n    ) -> Result<T, CallToolResult> {\n        let resp = rb\n            .send()\n            .await\n            .map_err(|e| Self::err(\"Failed to connect to VK API\", Some(&e.to_string())).unwrap())?;\n\n        if !resp.status().is_success() {\n            let status = resp.status();\n            return Err(\n                Self::err(format!(\"VK API returned error status: {}\", status), None).unwrap(),\n            );\n        }\n\n        let api_response = resp.json::<ApiResponseEnvelope<T>>().await.map_err(|e| {\n            Self::err(\"Failed to parse VK API response\", Some(&e.to_string())).unwrap()\n        })?;\n\n        if !api_response.success {\n            let msg = api_response.message.as_deref().unwrap_or(\"Unknown error\");\n            return Err(Self::err(\"VK API returned error\", Some(msg)).unwrap());\n        }\n\n        api_response\n            .data\n            .ok_or_else(|| Self::err(\"VK API response missing data field\", None).unwrap())\n    }\n\n    async fn send_empty_json(&self, rb: reqwest::RequestBuilder) -> Result<(), CallToolResult> {\n        let resp = rb\n            .send()\n            .await\n            .map_err(|e| Self::err(\"Failed to connect to VK API\", Some(&e.to_string())).unwrap())?;\n\n        if !resp.status().is_success() {\n            let status = resp.status();\n            return Err(\n                Self::err(format!(\"VK API returned error status: {}\", status), None).unwrap(),\n            );\n        }\n\n        #[derive(Deserialize)]\n        struct EmptyApiResponse {\n            success: bool,\n            message: Option<String>,\n        }\n\n        let api_response = resp.json::<EmptyApiResponse>().await.map_err(|e| {\n            Self::err(\"Failed to parse VK API response\", Some(&e.to_string())).unwrap()\n        })?;\n\n        if !api_response.success {\n            let msg = api_response.message.as_deref().unwrap_or(\"Unknown error\");\n            return Err(Self::err(\"VK API returned error\", Some(msg)).unwrap());\n        }\n\n        Ok(())\n    }\n\n    fn resolve_workspace_id(&self, explicit: Option<Uuid>) -> Result<Uuid, CallToolResult> {\n        if let Some(id) = explicit {\n            return Ok(id);\n        }\n        if let Some(workspace_id) = self.scoped_workspace_id() {\n            return Ok(workspace_id);\n        }\n        Err(Self::err(\n            \"workspace_id is required (not available from current MCP context)\",\n            None::<&str>,\n        )\n        .unwrap())\n    }\n\n    fn scope_allows_workspace(&self, workspace_id: Uuid) -> Result<(), CallToolResult> {\n        if matches!(self.mode(), McpMode::Orchestrator)\n            && let Some(scoped_workspace_id) = self.scoped_workspace_id()\n            && scoped_workspace_id != workspace_id\n        {\n            return Err(Self::err(\n                \"Operation is outside the configured workspace scope\".to_string(),\n                Some(format!(\n                    \"requested workspace_id={}, configured workspace_id={}\",\n                    workspace_id, scoped_workspace_id\n                )),\n            )\n            .unwrap());\n        }\n\n        Ok(())\n    }\n\n    // Expands @tagname references in text by replacing them with tag content.\n    async fn expand_tags(&self, text: &str) -> String {\n        let tag_pattern = match Regex::new(r\"@([^\\s@]+)\") {\n            Ok(re) => re,\n            Err(_) => return text.to_string(),\n        };\n\n        let tag_names: Vec<String> = tag_pattern\n            .captures_iter(text)\n            .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))\n            .collect::<std::collections::HashSet<_>>()\n            .into_iter()\n            .collect();\n\n        if tag_names.is_empty() {\n            return text.to_string();\n        }\n\n        let url = self.url(\"/api/tags\");\n        let tags: Vec<Tag> = match self.client.get(&url).send().await {\n            Ok(resp) if resp.status().is_success() => {\n                match resp.json::<ApiResponseEnvelope<Vec<Tag>>>().await {\n                    Ok(envelope) if envelope.success => envelope.data.unwrap_or_default(),\n                    _ => return text.to_string(),\n                }\n            }\n            _ => return text.to_string(),\n        };\n\n        let tag_map: std::collections::HashMap<&str, &str> = tags\n            .iter()\n            .map(|t| (t.tag_name.as_str(), t.content.as_str()))\n            .collect();\n\n        let result = tag_pattern.replace_all(text, |caps: &regex::Captures| {\n            let tag_name = caps.get(1).map(|m| m.as_str()).unwrap_or(\"\");\n            match tag_map.get(tag_name) {\n                Some(content) => (*content).to_string(),\n                None => caps.get(0).map(|m| m.as_str()).unwrap_or(\"\").to_string(),\n            }\n        });\n\n        result.into_owned()\n    }\n\n    // Resolves a project_id from an explicit parameter or falls back to context.\n    fn resolve_project_id(&self, explicit: Option<Uuid>) -> Result<Uuid, CallToolResult> {\n        if let Some(id) = explicit {\n            return Ok(id);\n        }\n        if let Some(ctx) = &self.context\n            && let Some(id) = ctx.project_id\n        {\n            return Ok(id);\n        }\n        Err(Self::err(\n            \"project_id is required (not available from workspace context)\",\n            None::<&str>,\n        )\n        .unwrap())\n    }\n\n    // Resolves an organization_id from an explicit parameter or falls back to context.\n    fn resolve_organization_id(&self, explicit: Option<Uuid>) -> Result<Uuid, CallToolResult> {\n        if let Some(id) = explicit {\n            return Ok(id);\n        }\n        if let Some(ctx) = &self.context\n            && let Some(id) = ctx.organization_id\n        {\n            return Ok(id);\n        }\n        Err(Self::err(\n            \"organization_id is required (not available from workspace context)\",\n            None::<&str>,\n        )\n        .unwrap())\n    }\n\n    // Fetches project statuses for a project.\n    async fn fetch_project_statuses(\n        &self,\n        project_id: Uuid,\n    ) -> Result<Vec<ProjectStatus>, CallToolResult> {\n        let url = self.url(&format!(\n            \"/api/remote/project-statuses?project_id={}\",\n            project_id\n        ));\n        let response: ListProjectStatusesResponse = self.send_json(self.client.get(&url)).await?;\n        Ok(response.project_statuses)\n    }\n\n    // Resolves a status name to status_id.\n    async fn resolve_status_id(\n        &self,\n        project_id: Uuid,\n        status_name: &str,\n    ) -> Result<Uuid, CallToolResult> {\n        let statuses = self.fetch_project_statuses(project_id).await?;\n        statuses\n            .iter()\n            .find(|s| s.name.eq_ignore_ascii_case(status_name))\n            .map(|s| s.id)\n            .ok_or_else(|| {\n                let available: Vec<&str> = statuses.iter().map(|s| s.name.as_str()).collect();\n                Self::err(\n                    format!(\n                        \"Unknown status '{}'. Available statuses: {:?}\",\n                        status_name, available\n                    ),\n                    None::<String>,\n                )\n                .unwrap()\n            })\n    }\n\n    // Gets the default status_id for a project (first non-hidden status by sort_order).\n    async fn default_status_id(&self, project_id: Uuid) -> Result<Uuid, CallToolResult> {\n        let statuses = self.fetch_project_statuses(project_id).await?;\n        statuses\n            .iter()\n            .filter(|s| !s.hidden)\n            .min_by_key(|s| s.sort_order)\n            .map(|s| s.id)\n            .ok_or_else(|| {\n                Self::err(\"No visible statuses found for project\", None::<&str>).unwrap()\n            })\n    }\n\n    // Resolves a status_id to its display name. Falls back to UUID string if lookup fails.\n    async fn resolve_status_name(&self, project_id: Uuid, status_id: Uuid) -> String {\n        match self.fetch_project_statuses(project_id).await {\n            Ok(statuses) => statuses\n                .iter()\n                .find(|s| s.id == status_id)\n                .map(|s| s.name.clone())\n                .unwrap_or_else(|| status_id.to_string()),\n            Err(_) => status_id.to_string(),\n        }\n    }\n\n    // Links a workspace to a remote issue by fetching issue.project_id and calling link endpoint.\n    async fn link_workspace_to_issue(\n        &self,\n        workspace_id: Uuid,\n        issue_id: Uuid,\n    ) -> Result<(), CallToolResult> {\n        let issue_url = self.url(&format!(\"/api/remote/issues/{}\", issue_id));\n        let issue: Issue = self.send_json(self.client.get(&issue_url)).await?;\n\n        let link_url = self.url(&format!(\"/api/workspaces/{}/links\", workspace_id));\n        let link_payload = serde_json::json!({\n            \"project_id\": issue.project_id,\n            \"issue_id\": issue_id,\n        });\n        self.send_empty_json(self.client.post(&link_url).json(&link_payload))\n            .await\n    }\n\n    fn parse_executor_agent(executor: &str) -> Result<BaseCodingAgent, CallToolResult> {\n        let normalized = executor.replace('-', \"_\").to_ascii_uppercase();\n        BaseCodingAgent::from_str(&normalized).map_err(|_| {\n            Self::err(format!(\"Unknown executor '{executor}'.\"), None::<String>).unwrap()\n        })\n    }\n\n    fn normalize_executor_name(executor: Option<&str>) -> Result<String, CallToolResult> {\n        let Some(executor) = executor.map(str::trim).filter(|value| !value.is_empty()) else {\n            return Ok(\"CODEX\".to_string());\n        };\n\n        Self::parse_executor_agent(executor)\n            .map(|agent| agent.to_string())\n            .map_err(|_| {\n                Self::err(\n                    format!(\"Unknown executor '{}' configured for session\", executor),\n                    None::<String>,\n                )\n                .unwrap()\n            })\n    }\n\n    fn execution_process_status_label(status: &ExecutionProcessStatus) -> &'static str {\n        match status {\n            ExecutionProcessStatus::Running => \"running\",\n            ExecutionProcessStatus::Completed => \"completed\",\n            ExecutionProcessStatus::Failed => \"failed\",\n            ExecutionProcessStatus::Killed => \"killed\",\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use std::{collections::BTreeSet, sync::Once};\n\n    use rmcp::handler::server::tool::ToolRouter;\n    use uuid::Uuid;\n\n    use super::McpServer;\n    use crate::task_server::{McpContext, McpMode, McpRepoContext};\n\n    static RUSTLS_PROVIDER: Once = Once::new();\n\n    fn install_rustls_provider() {\n        RUSTLS_PROVIDER.call_once(|| {\n            rustls::crypto::aws_lc_rs::default_provider()\n                .install_default()\n                .expect(\"Failed to install rustls crypto provider\");\n        });\n    }\n\n    fn tool_names(router: rmcp::handler::server::tool::ToolRouter<McpServer>) -> BTreeSet<String> {\n        router\n            .list_all()\n            .into_iter()\n            .map(|tool| tool.name.to_string())\n            .collect()\n    }\n\n    #[test]\n    fn orchestrator_mode_exposes_only_scoped_workflow_tools() {\n        let actual = tool_names(McpServer::orchestrator_mode_router());\n        let expected = BTreeSet::from([\n            \"create_session\".to_string(),\n            \"get_context\".to_string(),\n            \"get_execution\".to_string(),\n            \"list_sessions\".to_string(),\n            \"run_session_prompt\".to_string(),\n            \"update_session\".to_string(),\n            \"update_workspace\".to_string(),\n        ]);\n\n        assert_eq!(actual, expected);\n    }\n\n    #[test]\n    fn global_mode_keeps_workspace_admin_and_discovery_tools() {\n        let actual = tool_names(McpServer::global_mode_router());\n\n        assert!(actual.contains(\"list_workspaces\"));\n        assert!(actual.contains(\"delete_workspace\"));\n        assert!(!actual.contains(\"output_markdown\"));\n    }\n\n    #[test]\n    fn orchestrator_session_id_is_resolved_from_context() {\n        install_rustls_provider();\n        let session_id = Uuid::new_v4();\n        let workspace_id = Uuid::new_v4();\n        let server = McpServer {\n            client: reqwest::Client::new(),\n            base_url: \"http://127.0.0.1:3000\".to_string(),\n            tool_router: ToolRouter::default(),\n            context: Some(McpContext {\n                organization_id: None,\n                project_id: None,\n                issue_id: None,\n                orchestrator_session_id: Some(session_id),\n                workspace_id,\n                workspace_branch: \"main\".to_string(),\n                workspace_repos: vec![McpRepoContext {\n                    repo_id: Uuid::new_v4(),\n                    repo_name: \"repo\".to_string(),\n                    target_branch: \"main\".to_string(),\n                }],\n            }),\n            mode: McpMode::Global,\n        };\n\n        assert_eq!(server.orchestrator_session_id(), Some(session_id));\n        assert_eq!(server.resolve_workspace_id(None).unwrap(), workspace_id);\n    }\n\n    #[test]\n    fn orchestrator_scope_requires_context_when_missing() {\n        install_rustls_provider();\n        let server = McpServer {\n            client: reqwest::Client::new(),\n            base_url: \"http://127.0.0.1:3000\".to_string(),\n            tool_router: ToolRouter::default(),\n            context: None,\n            mode: McpMode::Orchestrator,\n        };\n\n        assert_eq!(server.orchestrator_session_id(), None);\n        assert!(server.resolve_workspace_id(None).is_err());\n        assert!(server.scope_allows_workspace(Uuid::new_v4()).is_ok());\n    }\n\n    #[test]\n    fn global_context_omits_orchestrator_session_id_from_serialized_output() {\n        install_rustls_provider();\n        let context = McpContext {\n            organization_id: None,\n            project_id: None,\n            issue_id: None,\n            orchestrator_session_id: None,\n            workspace_id: Uuid::new_v4(),\n            workspace_branch: \"main\".to_string(),\n            workspace_repos: vec![],\n        };\n\n        let serialized = serde_json::to_value(&context).expect(\"context should serialize\");\n\n        assert!(serialized.get(\"orchestrator_session_id\").is_none());\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/organizations.rs",
    "content": "use api_types::{ListMembersResponse, ListOrganizationsResponse};\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct OrganizationSummary {\n    #[schemars(description = \"The unique identifier of the organization\")]\n    id: String,\n    #[schemars(description = \"The name of the organization\")]\n    name: String,\n    #[schemars(description = \"The slug of the organization\")]\n    slug: String,\n    #[schemars(description = \"Whether this is a personal organization\")]\n    is_personal: bool,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListOrganizationsResponse {\n    organizations: Vec<OrganizationSummary>,\n    count: usize,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpListOrgMembersRequest {\n    #[schemars(\n        description = \"The organization ID to list members from. Optional if running inside a workspace linked to a remote organization.\"\n    )]\n    organization_id: Option<Uuid>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct OrganizationMemberSummary {\n    #[schemars(description = \"The user ID of the organization member\")]\n    user_id: String,\n    #[schemars(description = \"The member role in the organization\")]\n    role: String,\n    #[schemars(description = \"When the member joined the organization\")]\n    joined_at: String,\n    #[schemars(description = \"Optional first name\")]\n    first_name: Option<String>,\n    #[schemars(description = \"Optional last name\")]\n    last_name: Option<String>,\n    #[schemars(description = \"Optional username\")]\n    username: Option<String>,\n    #[schemars(description = \"Optional email\")]\n    email: Option<String>,\n    #[schemars(description = \"Optional avatar URL\")]\n    avatar_url: Option<String>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListOrgMembersResponse {\n    organization_id: String,\n    members: Vec<OrganizationMemberSummary>,\n    count: usize,\n}\n\n#[tool_router(router = organizations_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(description = \"List all the available organizations\")]\n    async fn list_organizations(&self) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(\"/api/organizations\");\n        let response: ListOrganizationsResponse = match self.send_json(self.client.get(&url)).await\n        {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n\n        let org_summaries: Vec<OrganizationSummary> = response\n            .organizations\n            .into_iter()\n            .map(|o| OrganizationSummary {\n                id: o.id.to_string(),\n                name: o.name,\n                slug: o.slug,\n                is_personal: o.is_personal,\n            })\n            .collect();\n\n        McpServer::success(&McpListOrganizationsResponse {\n            count: org_summaries.len(),\n            organizations: org_summaries,\n        })\n    }\n\n    #[tool(\n        description = \"List members of an organization. `organization_id` is optional if running inside a workspace linked to a remote organization.\"\n    )]\n    async fn list_org_members(\n        &self,\n        Parameters(McpListOrgMembersRequest { organization_id }): Parameters<\n            McpListOrgMembersRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let organization_id = match self.resolve_organization_id(organization_id) {\n            Ok(id) => id,\n            Err(e) => return Ok(e),\n        };\n\n        let url = self.url(&format!(\"/api/organizations/{}/members\", organization_id));\n        let response: ListMembersResponse = match self.send_json(self.client.get(&url)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n\n        let members: Vec<OrganizationMemberSummary> = response\n            .members\n            .into_iter()\n            .map(|member| OrganizationMemberSummary {\n                user_id: member.user_id.to_string(),\n                role: format!(\"{:?}\", member.role).to_uppercase(),\n                joined_at: member.joined_at.to_rfc3339(),\n                first_name: member.first_name,\n                last_name: member.last_name,\n                username: member.username,\n                email: member.email,\n                avatar_url: member.avatar_url,\n            })\n            .collect();\n\n        McpServer::success(&McpListOrgMembersResponse {\n            organization_id: organization_id.to_string(),\n            count: members.len(),\n            members,\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/remote_issues.rs",
    "content": "use std::collections::HashMap;\n\nuse api_types::{\n    CreateIssueRequest, Issue, IssuePriority, IssueRelationshipType, IssueSortField,\n    ListIssueRelationshipsResponse, ListIssueTagsResponse, ListIssuesResponse,\n    ListPullRequestsResponse, ListTagsResponse, MutationResponse, PullRequestStatus,\n    SearchIssuesRequest, SortDirection, UpdateIssueRequest,\n};\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpCreateIssueRequest {\n    #[schemars(\n        description = \"The ID of the project to create the issue in. Optional if running inside a workspace linked to a remote project.\"\n    )]\n    project_id: Option<Uuid>,\n    #[schemars(description = \"The title of the issue\")]\n    title: String,\n    #[schemars(description = \"Optional description of the issue\")]\n    description: Option<String>,\n    #[schemars(\n        description = \"Optional priority of the issue. Allowed values: 'urgent', 'high', 'medium', 'low'.\"\n    )]\n    priority: Option<String>,\n    #[schemars(description = \"Optional parent issue ID to create a subissue\")]\n    parent_issue_id: Option<Uuid>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpCreateIssueResponse {\n    issue_id: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpListIssuesRequest {\n    #[schemars(\n        description = \"The ID of the project to list issues from. Optional if running inside a workspace linked to a remote project.\"\n    )]\n    project_id: Option<Uuid>,\n    #[schemars(description = \"Maximum number of issues to return (default: 50)\")]\n    limit: Option<i32>,\n    #[schemars(description = \"Number of results to skip before returning rows (default: 0)\")]\n    offset: Option<i32>,\n    #[schemars(description = \"Filter by status name (case-insensitive)\")]\n    status: Option<String>,\n    #[schemars(\n        description = \"Filter by priority. Allowed values: 'urgent', 'high', 'medium', 'low'.\"\n    )]\n    priority: Option<String>,\n    #[schemars(description = \"Filter by parent issue ID (subissues of this issue)\")]\n    parent_issue_id: Option<Uuid>,\n    #[schemars(description = \"Case-insensitive substring match against title and description\")]\n    search: Option<String>,\n    #[schemars(description = \"Filter by issue simple ID (case-insensitive exact match)\")]\n    simple_id: Option<String>,\n    #[schemars(description = \"Filter to issues assigned to this user ID\")]\n    assignee_user_id: Option<Uuid>,\n    #[schemars(description = \"Filter to issues having this tag ID\")]\n    tag_id: Option<Uuid>,\n    #[schemars(description = \"Filter to issues having a tag with this name (case-insensitive)\")]\n    tag_name: Option<String>,\n    #[schemars(\n        description = \"Field to sort by. Allowed values: 'sort_order', 'priority', 'created_at', 'updated_at', 'title'. Default: 'sort_order'.\"\n    )]\n    sort_field: Option<String>,\n    #[schemars(description = \"Sort direction. Allowed values: 'asc', 'desc'. Default: 'asc'.\")]\n    sort_direction: Option<String>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct IssueSummary {\n    #[schemars(description = \"The unique identifier of the issue\")]\n    id: String,\n    #[schemars(description = \"The title of the issue\")]\n    title: String,\n    #[schemars(description = \"The human-readable issue simple ID\")]\n    simple_id: String,\n    #[schemars(description = \"Current status of the issue\")]\n    status: String,\n    #[schemars(description = \"Current priority of the issue\")]\n    priority: Option<String>,\n    #[schemars(description = \"Parent issue ID if this is a subissue\")]\n    parent_issue_id: Option<String>,\n    #[schemars(description = \"When the issue was created\")]\n    created_at: String,\n    #[schemars(description = \"When the issue was last updated\")]\n    updated_at: String,\n    #[schemars(description = \"Number of pull requests linked to this issue\")]\n    pull_request_count: usize,\n    #[schemars(description = \"URL of the most recent pull request, if any\")]\n    latest_pr_url: Option<String>,\n    #[schemars(\n        description = \"Status of the most recent pull request: 'open', 'merged', or 'closed'\"\n    )]\n    latest_pr_status: Option<PullRequestStatus>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct PullRequestSummary {\n    #[schemars(description = \"PR number\")]\n    number: i32,\n    #[schemars(description = \"URL of the pull request\")]\n    url: String,\n    #[schemars(description = \"Status of the pull request: 'open', 'merged', or 'closed'\")]\n    status: PullRequestStatus,\n    #[schemars(description = \"When the PR was merged, if applicable\")]\n    merged_at: Option<String>,\n    #[schemars(description = \"Target branch for the PR\")]\n    target_branch_name: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpTagSummary {\n    #[schemars(description = \"The tag ID\")]\n    id: String,\n    #[schemars(description = \"The tag name\")]\n    name: String,\n    #[schemars(description = \"The tag color\")]\n    color: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpRelationshipSummary {\n    #[schemars(description = \"The relationship ID (use this to delete)\")]\n    id: String,\n    #[schemars(description = \"The related issue ID\")]\n    related_issue_id: String,\n    #[schemars(description = \"The related issue's simple ID (e.g. 'PROJ-42')\")]\n    related_simple_id: String,\n    #[schemars(description = \"Relationship type: blocking, related, or has_duplicate\")]\n    relationship_type: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpSubIssueSummary {\n    #[schemars(description = \"The sub-issue ID\")]\n    id: String,\n    #[schemars(description = \"Short human-readable identifier (e.g. 'PROJ-43')\")]\n    simple_id: String,\n    #[schemars(description = \"The sub-issue title\")]\n    title: String,\n    #[schemars(description = \"Current status of the sub-issue\")]\n    status: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct IssueDetails {\n    #[schemars(description = \"The unique identifier of the issue\")]\n    id: String,\n    #[schemars(description = \"The title of the issue\")]\n    title: String,\n    #[schemars(description = \"The human-readable issue simple ID\")]\n    simple_id: String,\n    #[schemars(description = \"Optional description of the issue\")]\n    description: Option<String>,\n    #[schemars(description = \"Current status of the issue\")]\n    status: String,\n    #[schemars(description = \"The status ID (UUID)\")]\n    status_id: String,\n    #[schemars(description = \"Current priority of the issue\")]\n    priority: Option<String>,\n    #[schemars(description = \"Parent issue ID if this is a subissue\")]\n    parent_issue_id: Option<String>,\n    #[schemars(description = \"Optional planned start date\")]\n    start_date: Option<String>,\n    #[schemars(description = \"Optional planned target date\")]\n    target_date: Option<String>,\n    #[schemars(description = \"Optional completion date\")]\n    completed_at: Option<String>,\n    #[schemars(description = \"When the issue was created\")]\n    created_at: String,\n    #[schemars(description = \"When the issue was last updated\")]\n    updated_at: String,\n    #[schemars(description = \"Pull requests linked to this issue\")]\n    pull_requests: Vec<PullRequestSummary>,\n    #[schemars(description = \"Tags attached to this issue\")]\n    tags: Vec<McpTagSummary>,\n    #[schemars(description = \"Relationships to other issues\")]\n    relationships: Vec<McpRelationshipSummary>,\n    #[schemars(description = \"Sub-issues under this issue\")]\n    sub_issues: Vec<McpSubIssueSummary>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListIssuesResponse {\n    issues: Vec<IssueSummary>,\n    total_count: usize,\n    returned_count: usize,\n    limit: usize,\n    offset: usize,\n    project_id: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpUpdateIssueRequest {\n    #[schemars(description = \"The ID of the issue to update\")]\n    issue_id: Uuid,\n    #[schemars(description = \"New title for the issue\")]\n    title: Option<String>,\n    #[schemars(description = \"New description for the issue\")]\n    description: Option<String>,\n    #[schemars(description = \"New status name for the issue (must match a project status name)\")]\n    status: Option<String>,\n    #[schemars(\n        description = \"New priority for the issue. Allowed values: 'urgent', 'high', 'medium', 'low'.\"\n    )]\n    priority: Option<String>,\n    #[schemars(\n        description = \"Parent issue ID to set this as a subissue. Pass null to un-nest from parent.\"\n    )]\n    parent_issue_id: Option<Option<Uuid>>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpUpdateIssueResponse {\n    issue: IssueDetails,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpDeleteIssueRequest {\n    #[schemars(description = \"The ID of the issue to delete\")]\n    issue_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpDeleteIssueResponse {\n    deleted_issue_id: Option<String>,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpGetIssueRequest {\n    #[schemars(description = \"The ID of the issue to retrieve\")]\n    issue_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpGetIssueResponse {\n    issue: IssueDetails,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListIssuePrioritiesResponse {\n    priorities: Vec<String>,\n}\n\n#[tool_router(router = remote_issues_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(\n        description = \"Create a new issue in a project. `project_id` is optional if running inside a workspace linked to a remote project.\"\n    )]\n    async fn create_issue(\n        &self,\n        Parameters(McpCreateIssueRequest {\n            project_id,\n            title,\n            description,\n            priority,\n            parent_issue_id,\n        }): Parameters<McpCreateIssueRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let project_id = match self.resolve_project_id(project_id) {\n            Ok(id) => id,\n            Err(e) => return Ok(e),\n        };\n\n        let expanded_description = match description {\n            Some(desc) => Some(self.expand_tags(&desc).await),\n            None => None,\n        };\n\n        let status_id = match self.default_status_id(project_id).await {\n            Ok(id) => id,\n            Err(e) => return Ok(e),\n        };\n\n        let priority = match priority {\n            Some(p) => match Self::parse_issue_priority(&p) {\n                Ok(priority) => Some(priority),\n                Err(e) => return Ok(e),\n            },\n            None => None,\n        };\n\n        let payload = CreateIssueRequest {\n            id: None,\n            project_id,\n            status_id,\n            title,\n            description: expanded_description,\n            priority,\n            start_date: None,\n            target_date: None,\n            completed_at: None,\n            sort_order: 0.0,\n            parent_issue_id,\n            parent_issue_sort_order: None,\n            extension_metadata: serde_json::json!({}),\n        };\n\n        let url = self.url(\"/api/remote/issues\");\n        let response: MutationResponse<Issue> =\n            match self.send_json(self.client.post(&url).json(&payload)).await {\n                Ok(r) => r,\n                Err(e) => return Ok(e),\n            };\n\n        McpServer::success(&McpCreateIssueResponse {\n            issue_id: response.data.id.to_string(),\n        })\n    }\n\n    #[tool(\n        description = \"List all the issues in a project. `project_id` is optional if running inside a workspace linked to a remote project.\"\n    )]\n    async fn list_issues(\n        &self,\n        Parameters(McpListIssuesRequest {\n            project_id,\n            limit,\n            offset,\n            status,\n            priority,\n            parent_issue_id,\n            search,\n            simple_id,\n            assignee_user_id,\n            tag_id,\n            tag_name,\n            sort_field,\n            sort_direction,\n        }): Parameters<McpListIssuesRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let project_id = match self.resolve_project_id(project_id) {\n            Ok(id) => id,\n            Err(e) => return Ok(e),\n        };\n\n        let project_statuses = match self.fetch_project_statuses(project_id).await {\n            Ok(statuses) => Some(statuses),\n            Err(e) => {\n                if status.is_some() {\n                    return Ok(e);\n                }\n                None\n            }\n        };\n        let status_names_by_id = project_statuses.as_ref().map(|statuses| {\n            statuses\n                .iter()\n                .map(|status| (status.id, status.name.clone()))\n                .collect::<HashMap<_, _>>()\n        });\n\n        let (status_id, status_ids, missing_status_name_match) = match status.as_deref() {\n            Some(status) => match Uuid::parse_str(status) {\n                Ok(status_id) => (Some(status_id), None, false),\n                Err(_) => {\n                    let matching_status_ids = project_statuses\n                        .as_deref()\n                        .map(|statuses| {\n                            Self::matching_ids_by_name(\n                                statuses\n                                    .iter()\n                                    .map(|status| (status.id, status.name.as_str())),\n                                status,\n                            )\n                        })\n                        .unwrap_or_default();\n                    let missing_status_name_match = matching_status_ids.is_empty();\n                    (\n                        None,\n                        (!missing_status_name_match).then_some(matching_status_ids),\n                        missing_status_name_match,\n                    )\n                }\n            },\n            None => (None, None, false),\n        };\n\n        let priority = match priority {\n            Some(priority) => match Self::parse_issue_priority(&priority) {\n                Ok(priority) => Some(priority),\n                Err(e) => return Ok(e),\n            },\n            None => None,\n        };\n\n        let sort_field = match Self::parse_issue_sort_field(sort_field.as_deref()) {\n            Ok(value) => Some(value),\n            Err(e) => return Ok(e),\n        };\n        let sort_direction = match Self::parse_sort_direction(sort_direction.as_deref()) {\n            Ok(value) => Some(value),\n            Err(e) => return Ok(e),\n        };\n\n        let matching_tag_ids = match tag_name.as_deref() {\n            Some(tag_name) => match self.find_tag_ids_by_name(project_id, tag_name).await {\n                Ok(tag_ids) => Some(tag_ids),\n                Err(e) => return Ok(e),\n            },\n            None => None,\n        };\n        let (tag_id, tag_ids, missing_tag_name_match) =\n            Self::resolve_tag_filters(tag_id, matching_tag_ids);\n\n        let response = if missing_status_name_match || missing_tag_name_match {\n            ListIssuesResponse {\n                issues: Vec::new(),\n                total_count: 0,\n                limit: limit.unwrap_or(50).max(0) as usize,\n                offset: offset.unwrap_or(0).max(0) as usize,\n            }\n        } else {\n            let query = SearchIssuesRequest {\n                project_id,\n                status_id,\n                status_ids,\n                priority,\n                parent_issue_id,\n                search,\n                simple_id,\n                assignee_user_id,\n                tag_id,\n                tag_ids,\n                sort_field,\n                sort_direction,\n                limit: Some(limit.unwrap_or(50).max(0)),\n                offset: Some(offset.unwrap_or(0).max(0)),\n            };\n            let url = self.url(\"/api/remote/issues/search\");\n            match self.send_json(self.client.post(&url).json(&query)).await {\n                Ok(r) => r,\n                Err(e) => return Ok(e),\n            }\n        };\n\n        let mut summaries = Vec::with_capacity(response.issues.len());\n        for issue in &response.issues {\n            let pull_requests = self.fetch_pull_requests(issue.id).await;\n            summaries.push(self.issue_to_summary(\n                issue,\n                status_names_by_id.as_ref(),\n                &pull_requests,\n            ));\n        }\n\n        McpServer::success(&McpListIssuesResponse {\n            total_count: response.total_count,\n            returned_count: summaries.len(),\n            limit: response.limit,\n            offset: response.offset,\n            issues: summaries,\n            project_id: project_id.to_string(),\n        })\n    }\n\n    #[tool(\n        description = \"Get detailed information about a specific issue. You can use `list_issues` to find issue IDs. `issue_id` is required.\"\n    )]\n    async fn get_issue(\n        &self,\n        Parameters(McpGetIssueRequest { issue_id }): Parameters<McpGetIssueRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/remote/issues/{}\", issue_id));\n        let issue: Issue = match self.send_json(self.client.get(&url)).await {\n            Ok(i) => i,\n            Err(e) => return Ok(e),\n        };\n\n        let pull_requests = self.fetch_pull_requests(issue_id).await;\n        let details = self.issue_to_details(&issue, pull_requests).await;\n        McpServer::success(&McpGetIssueResponse { issue: details })\n    }\n\n    #[tool(\n        description = \"Update an existing issue's title, description, or status. `issue_id` is required. `title`, `description`, and `status` are optional.\"\n    )]\n    async fn update_issue(\n        &self,\n        Parameters(McpUpdateIssueRequest {\n            issue_id,\n            title,\n            description,\n            status,\n            priority,\n            parent_issue_id,\n        }): Parameters<McpUpdateIssueRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        // First get the issue to know its project_id for status resolution\n        let get_url = self.url(&format!(\"/api/remote/issues/{}\", issue_id));\n        let existing_issue: Issue = match self.send_json(self.client.get(&get_url)).await {\n            Ok(i) => i,\n            Err(e) => return Ok(e),\n        };\n\n        // Resolve status name to status_id if provided\n        let status_id = if let Some(ref status_name) = status {\n            match self\n                .resolve_status_id(existing_issue.project_id, status_name)\n                .await\n            {\n                Ok(id) => Some(id),\n                Err(e) => return Ok(e),\n            }\n        } else {\n            None\n        };\n\n        // Expand @tagname references in description\n        let expanded_description = match description {\n            Some(desc) => Some(Some(self.expand_tags(&desc).await)),\n            None => None,\n        };\n\n        let priority = if let Some(priority) = priority {\n            match Self::parse_issue_priority(&priority) {\n                Ok(parsed) => Some(Some(parsed)),\n                Err(e) => return Ok(e),\n            }\n        } else {\n            None\n        };\n\n        let payload = UpdateIssueRequest {\n            status_id,\n            title,\n            description: expanded_description,\n            priority,\n            start_date: None,\n            target_date: None,\n            completed_at: None,\n            sort_order: None,\n            parent_issue_id,\n            parent_issue_sort_order: None,\n            extension_metadata: None,\n        };\n\n        let url = self.url(&format!(\"/api/remote/issues/{}\", issue_id));\n        let response: MutationResponse<Issue> =\n            match self.send_json(self.client.patch(&url).json(&payload)).await {\n                Ok(r) => r,\n                Err(e) => return Ok(e),\n            };\n\n        let pull_requests = self.fetch_pull_requests(issue_id).await;\n        let details = self.issue_to_details(&response.data, pull_requests).await;\n        McpServer::success(&McpUpdateIssueResponse { issue: details })\n    }\n\n    #[tool(description = \"List allowed issue priority values.\")]\n    async fn list_issue_priorities(&self) -> Result<CallToolResult, ErrorData> {\n        McpServer::success(&McpListIssuePrioritiesResponse {\n            priorities: [\"urgent\", \"high\", \"medium\", \"low\"]\n                .iter()\n                .map(|s| s.to_string())\n                .collect(),\n        })\n    }\n\n    #[tool(description = \"Delete an issue. `issue_id` is required.\")]\n    async fn delete_issue(\n        &self,\n        Parameters(McpDeleteIssueRequest { issue_id }): Parameters<McpDeleteIssueRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/remote/issues/{}\", issue_id));\n        if let Err(e) = self.send_empty_json(self.client.delete(&url)).await {\n            return Ok(e);\n        }\n\n        McpServer::success(&McpDeleteIssueResponse {\n            deleted_issue_id: Some(issue_id.to_string()),\n        })\n    }\n}\n\nimpl McpServer {\n    fn parse_issue_sort_field(sort_field: Option<&str>) -> Result<IssueSortField, CallToolResult> {\n        match sort_field.unwrap_or(\"sort_order\").trim().to_ascii_lowercase().as_str() {\n            \"sort_order\" => Ok(IssueSortField::SortOrder),\n            \"priority\" => Ok(IssueSortField::Priority),\n            \"created_at\" => Ok(IssueSortField::CreatedAt),\n            \"updated_at\" => Ok(IssueSortField::UpdatedAt),\n            \"title\" => Ok(IssueSortField::Title),\n            other => Err(Self::err(\n                format!(\n                    \"Unknown sort_field '{}'. Allowed values: ['sort_order', 'priority', 'created_at', 'updated_at', 'title']\",\n                    other\n                ),\n                None::<String>,\n            )\n            .unwrap()),\n        }\n    }\n\n    fn parse_sort_direction(sort_direction: Option<&str>) -> Result<SortDirection, CallToolResult> {\n        match sort_direction\n            .unwrap_or(\"asc\")\n            .trim()\n            .to_ascii_lowercase()\n            .as_str()\n        {\n            \"asc\" => Ok(SortDirection::Asc),\n            \"desc\" => Ok(SortDirection::Desc),\n            other => Err(Self::err(\n                format!(\n                    \"Unknown sort_direction '{}'. Allowed values: ['asc', 'desc']\",\n                    other\n                ),\n                None::<String>,\n            )\n            .unwrap()),\n        }\n    }\n\n    fn issue_to_summary(\n        &self,\n        issue: &Issue,\n        status_names_by_id: Option<&HashMap<Uuid, String>>,\n        pull_requests: &ListPullRequestsResponse,\n    ) -> IssueSummary {\n        let status = status_names_by_id\n            .and_then(|status_map| status_map.get(&issue.status_id).cloned())\n            .unwrap_or_else(|| issue.status_id.to_string());\n        let latest_pr = pull_requests.pull_requests.first();\n        IssueSummary {\n            id: issue.id.to_string(),\n            title: issue.title.clone(),\n            simple_id: issue.simple_id.clone(),\n            status,\n            priority: issue\n                .priority\n                .map(Self::issue_priority_label)\n                .map(str::to_string),\n            parent_issue_id: issue.parent_issue_id.map(|id| id.to_string()),\n            created_at: issue.created_at.to_rfc3339(),\n            updated_at: issue.updated_at.to_rfc3339(),\n            pull_request_count: pull_requests.pull_requests.len(),\n            latest_pr_url: latest_pr.map(|pr| pr.url.clone()),\n            latest_pr_status: latest_pr.map(|pr| pr.status),\n        }\n    }\n\n    async fn issue_to_details(\n        &self,\n        issue: &Issue,\n        pull_requests: ListPullRequestsResponse,\n    ) -> IssueDetails {\n        let status = self\n            .resolve_status_name(issue.project_id, issue.status_id)\n            .await;\n\n        let tags = self\n            .fetch_issue_tags_resolved(issue.project_id, issue.id)\n            .await;\n\n        let relationships = self\n            .fetch_issue_relationships_resolved(issue.project_id, issue.id)\n            .await;\n\n        let sub_issues = self.fetch_sub_issues(issue.project_id, issue.id).await;\n\n        IssueDetails {\n            id: issue.id.to_string(),\n            title: issue.title.clone(),\n            simple_id: issue.simple_id.clone(),\n            description: issue.description.clone(),\n            status,\n            status_id: issue.status_id.to_string(),\n            priority: issue\n                .priority\n                .map(Self::issue_priority_label)\n                .map(str::to_string),\n            parent_issue_id: issue.parent_issue_id.map(|id| id.to_string()),\n            start_date: issue.start_date.map(|date| date.to_rfc3339()),\n            target_date: issue.target_date.map(|date| date.to_rfc3339()),\n            completed_at: issue.completed_at.map(|date| date.to_rfc3339()),\n            created_at: issue.created_at.to_rfc3339(),\n            updated_at: issue.updated_at.to_rfc3339(),\n            pull_requests: pull_requests\n                .pull_requests\n                .into_iter()\n                .map(|pr| PullRequestSummary {\n                    number: pr.number,\n                    url: pr.url,\n                    status: pr.status,\n                    merged_at: pr.merged_at.map(|dt| dt.to_rfc3339()),\n                    target_branch_name: pr.target_branch_name,\n                })\n                .collect(),\n            tags,\n            relationships,\n            sub_issues,\n        }\n    }\n\n    async fn fetch_pull_requests(&self, issue_id: Uuid) -> ListPullRequestsResponse {\n        let url = self.url(&format!(\"/api/remote/pull-requests?issue_id={}\", issue_id));\n        match self\n            .send_json::<ListPullRequestsResponse>(self.client.get(&url))\n            .await\n        {\n            Ok(response) => response,\n            Err(_) => ListPullRequestsResponse {\n                pull_requests: vec![],\n            },\n        }\n    }\n\n    /// Fetches tags for an issue, resolving tag_ids to names via project tags.\n    async fn fetch_issue_tags_resolved(\n        &self,\n        project_id: Uuid,\n        issue_id: Uuid,\n    ) -> Vec<McpTagSummary> {\n        let tags_url = self.url(&format!(\"/api/remote/tags?project_id={}\", project_id));\n        let project_tags: ListTagsResponse = match self.send_json(self.client.get(&tags_url)).await\n        {\n            Ok(r) => r,\n            Err(_) => return Vec::new(),\n        };\n        let tag_map: HashMap<Uuid, &api_types::Tag> =\n            project_tags.tags.iter().map(|t| (t.id, t)).collect();\n\n        let url = self.url(&format!(\"/api/remote/issue-tags?issue_id={}\", issue_id));\n        let response: ListIssueTagsResponse = match self.send_json(self.client.get(&url)).await {\n            Ok(r) => r,\n            Err(_) => return Vec::new(),\n        };\n\n        response\n            .issue_tags\n            .iter()\n            .filter_map(|it| {\n                tag_map.get(&it.tag_id).map(|tag| McpTagSummary {\n                    id: tag.id.to_string(),\n                    name: tag.name.clone(),\n                    color: tag.color.clone(),\n                })\n            })\n            .collect()\n    }\n\n    /// Fetches relationships for an issue, resolving related issue simple_ids.\n    async fn fetch_issue_relationships_resolved(\n        &self,\n        project_id: Uuid,\n        issue_id: Uuid,\n    ) -> Vec<McpRelationshipSummary> {\n        let rel_url = self.url(&format!(\n            \"/api/remote/issue-relationships?issue_id={}\",\n            issue_id\n        ));\n        let response: ListIssueRelationshipsResponse =\n            match self.send_json(self.client.get(&rel_url)).await {\n                Ok(r) => r,\n                Err(_) => return Vec::new(),\n            };\n\n        if response.issue_relationships.is_empty() {\n            return Vec::new();\n        }\n\n        let issues_url = self.url(&format!(\"/api/remote/issues?project_id={}\", project_id));\n        let issues_response: api_types::ListIssuesResponse = self\n            .send_json(self.client.get(&issues_url))\n            .await\n            .unwrap_or(api_types::ListIssuesResponse {\n                issues: Vec::new(),\n                total_count: 0,\n                limit: 0,\n                offset: 0,\n            });\n        let simple_id_map: HashMap<Uuid, &str> = issues_response\n            .issues\n            .iter()\n            .map(|i| (i.id, i.simple_id.as_str()))\n            .collect();\n\n        response\n            .issue_relationships\n            .into_iter()\n            .map(|r| {\n                let related_simple_id = simple_id_map\n                    .get(&r.related_issue_id)\n                    .unwrap_or(&\"\")\n                    .to_string();\n                McpRelationshipSummary {\n                    id: r.id.to_string(),\n                    related_issue_id: r.related_issue_id.to_string(),\n                    related_simple_id,\n                    relationship_type: match r.relationship_type {\n                        IssueRelationshipType::Blocking => \"blocking\".to_string(),\n                        IssueRelationshipType::Related => \"related\".to_string(),\n                        IssueRelationshipType::HasDuplicate => \"has_duplicate\".to_string(),\n                    },\n                }\n            })\n            .collect()\n    }\n\n    /// Fetches sub-issues for a given parent issue.\n    async fn fetch_sub_issues(\n        &self,\n        project_id: Uuid,\n        parent_issue_id: Uuid,\n    ) -> Vec<McpSubIssueSummary> {\n        let url = self.url(&format!(\"/api/remote/issues?project_id={}\", project_id));\n        let response: api_types::ListIssuesResponse =\n            match self.send_json(self.client.get(&url)).await {\n                Ok(r) => r,\n                Err(_) => return Vec::new(),\n            };\n\n        let status_names = self\n            .fetch_project_statuses(project_id)\n            .await\n            .ok()\n            .map(|statuses| {\n                statuses\n                    .into_iter()\n                    .map(|s| (s.id, s.name))\n                    .collect::<HashMap<_, _>>()\n            });\n\n        response\n            .issues\n            .iter()\n            .filter(|i| i.parent_issue_id == Some(parent_issue_id))\n            .map(|i| {\n                let status = status_names\n                    .as_ref()\n                    .and_then(|m| m.get(&i.status_id).cloned())\n                    .unwrap_or_else(|| i.status_id.to_string());\n                McpSubIssueSummary {\n                    id: i.id.to_string(),\n                    simple_id: i.simple_id.clone(),\n                    title: i.title.clone(),\n                    status,\n                }\n            })\n            .collect()\n    }\n\n    fn parse_issue_priority(priority: &str) -> Result<IssuePriority, CallToolResult> {\n        match priority.trim().to_ascii_lowercase().as_str() {\n            \"urgent\" => Ok(IssuePriority::Urgent),\n            \"high\" => Ok(IssuePriority::High),\n            \"medium\" => Ok(IssuePriority::Medium),\n            \"low\" => Ok(IssuePriority::Low),\n            _ => Err(Self::err(\n                format!(\n                    \"Unknown priority '{}'. Allowed values: ['urgent', 'high', 'medium', 'low']\",\n                    priority\n                ),\n                None::<String>,\n            )\n            .unwrap()),\n        }\n    }\n\n    fn issue_priority_label(priority: IssuePriority) -> &'static str {\n        match priority {\n            IssuePriority::Urgent => \"urgent\",\n            IssuePriority::High => \"high\",\n            IssuePriority::Medium => \"medium\",\n            IssuePriority::Low => \"low\",\n        }\n    }\n\n    async fn find_tag_ids_by_name(\n        &self,\n        project_id: Uuid,\n        tag_name: &str,\n    ) -> Result<Vec<Uuid>, CallToolResult> {\n        let url = self.url(&format!(\"/api/remote/tags?project_id={}\", project_id));\n        let tags: ListTagsResponse = self.send_json(self.client.get(&url)).await?;\n        Ok(Self::matching_ids_by_name(\n            tags.tags.iter().map(|tag| (tag.id, tag.name.as_str())),\n            tag_name,\n        ))\n    }\n\n    fn matching_ids_by_name<'a>(\n        items: impl IntoIterator<Item = (Uuid, &'a str)>,\n        name: &str,\n    ) -> Vec<Uuid> {\n        items\n            .into_iter()\n            .filter(|(_, item_name)| item_name.eq_ignore_ascii_case(name))\n            .map(|(id, _)| id)\n            .collect()\n    }\n\n    fn resolve_tag_filters(\n        tag_id: Option<Uuid>,\n        matching_tag_ids: Option<Vec<Uuid>>,\n    ) -> (Option<Uuid>, Option<Vec<Uuid>>, bool) {\n        match (tag_id, matching_tag_ids) {\n            (Some(tag_id), Some(matching_tag_ids)) => {\n                if matching_tag_ids.contains(&tag_id) {\n                    (Some(tag_id), None, false)\n                } else {\n                    (None, None, true)\n                }\n            }\n            (None, Some(matching_tag_ids)) => {\n                let missing_tag_name_match = matching_tag_ids.is_empty();\n                (\n                    None,\n                    (!missing_tag_name_match).then_some(matching_tag_ids),\n                    missing_tag_name_match,\n                )\n            }\n            (Some(tag_id), None) => (Some(tag_id), None, false),\n            (None, None) => (None, None, false),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn collects_all_matching_status_ids_case_insensitively() {\n        let first_id = Uuid::new_v4();\n        let second_id = Uuid::new_v4();\n        let statuses = [\n            (first_id, \"In Progress\"),\n            (second_id, \"in progress\"),\n            (Uuid::new_v4(), \"Todo\"),\n        ];\n\n        assert_eq!(\n            McpServer::matching_ids_by_name(statuses, \"IN PROGRESS\"),\n            vec![first_id, second_id]\n        );\n    }\n\n    #[test]\n    fn collects_all_matching_tag_ids_case_insensitively() {\n        let first_id = Uuid::new_v4();\n        let second_id = Uuid::new_v4();\n        let tags = [\n            (first_id, \"bug\"),\n            (second_id, \"Bug\"),\n            (Uuid::new_v4(), \"feature\"),\n        ];\n\n        assert_eq!(\n            McpServer::matching_ids_by_name(tags, \"BUG\"),\n            vec![first_id, second_id]\n        );\n    }\n\n    #[test]\n    fn resolve_tag_filters_requires_explicit_tag_id_to_match_tag_name() {\n        let tag_id = Uuid::new_v4();\n        let other_tag_id = Uuid::new_v4();\n\n        assert_eq!(\n            McpServer::resolve_tag_filters(Some(tag_id), Some(vec![other_tag_id])),\n            (None, None, true)\n        );\n    }\n\n    #[test]\n    fn resolve_tag_filters_preserves_exact_tag_id_intersection() {\n        let tag_id = Uuid::new_v4();\n        let other_tag_id = Uuid::new_v4();\n\n        assert_eq!(\n            McpServer::resolve_tag_filters(Some(tag_id), Some(vec![other_tag_id, tag_id])),\n            (Some(tag_id), None, false)\n        );\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/remote_projects.rs",
    "content": "use api_types::ListProjectsResponse;\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpListProjectsRequest {\n    #[schemars(description = \"The ID of the organization to list projects from\")]\n    organization_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct ProjectSummary {\n    #[schemars(description = \"The unique identifier of the project\")]\n    id: String,\n    #[schemars(description = \"The name of the project\")]\n    name: String,\n    #[schemars(description = \"When the project was created\")]\n    created_at: String,\n    #[schemars(description = \"When the project was last updated\")]\n    updated_at: String,\n}\n\nimpl ProjectSummary {\n    fn from_remote_project(project: api_types::Project) -> Self {\n        Self {\n            id: project.id.to_string(),\n            name: project.name,\n            created_at: project.created_at.to_rfc3339(),\n            updated_at: project.updated_at.to_rfc3339(),\n        }\n    }\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListProjectsResponse {\n    projects: Vec<ProjectSummary>,\n    count: usize,\n}\n\n#[tool_router(router = remote_projects_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(description = \"List all the available projects\")]\n    async fn list_projects(\n        &self,\n        Parameters(McpListProjectsRequest { organization_id }): Parameters<McpListProjectsRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\n            \"/api/remote/projects?organization_id={}\",\n            organization_id\n        ));\n        let response: ListProjectsResponse = match self.send_json(self.client.get(&url)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n\n        let project_summaries: Vec<ProjectSummary> = response\n            .projects\n            .into_iter()\n            .map(ProjectSummary::from_remote_project)\n            .collect();\n\n        McpServer::success(&McpListProjectsResponse {\n            count: project_summaries.len(),\n            projects: project_summaries,\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/repos.rs",
    "content": "use db::models::repo::Repo;\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpRepoSummary {\n    #[schemars(description = \"The unique identifier of the repository\")]\n    id: String,\n    #[schemars(description = \"The name of the repository\")]\n    name: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct GetRepoRequest {\n    #[schemars(description = \"The ID of the repository to retrieve\")]\n    repo_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct RepoDetails {\n    #[schemars(description = \"The unique identifier of the repository\")]\n    id: String,\n    #[schemars(description = \"The name of the repository\")]\n    name: String,\n    #[schemars(description = \"The display name of the repository\")]\n    display_name: String,\n    #[schemars(description = \"The setup script that runs when initializing a workspace\")]\n    setup_script: Option<String>,\n    #[schemars(description = \"The cleanup script that runs when tearing down a workspace\")]\n    cleanup_script: Option<String>,\n    #[schemars(description = \"The dev server script that starts the development server\")]\n    dev_server_script: Option<String>,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct UpdateSetupScriptRequest {\n    #[schemars(description = \"The ID of the repository to update\")]\n    repo_id: Uuid,\n    #[schemars(description = \"The new setup script content (use empty string to clear)\")]\n    script: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct UpdateCleanupScriptRequest {\n    #[schemars(description = \"The ID of the repository to update\")]\n    repo_id: Uuid,\n    #[schemars(description = \"The new cleanup script content (use empty string to clear)\")]\n    script: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct UpdateDevServerScriptRequest {\n    #[schemars(description = \"The ID of the repository to update\")]\n    repo_id: Uuid,\n    #[schemars(description = \"The new dev server script content (use empty string to clear)\")]\n    script: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct UpdateRepoScriptResponse {\n    #[schemars(description = \"Whether the update was successful\")]\n    success: bool,\n    #[schemars(description = \"The repository ID that was updated\")]\n    repo_id: String,\n    #[schemars(description = \"The script field that was updated\")]\n    field: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct ListReposResponse {\n    repos: Vec<McpRepoSummary>,\n    count: usize,\n}\n\n#[tool_router(router = repos_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(description = \"List all repositories.\")]\n    async fn list_repos(&self) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(\"/api/repos\");\n        let repos: Vec<Repo> = match self.send_json(self.client.get(&url)).await {\n            Ok(rs) => rs,\n            Err(e) => return Ok(e),\n        };\n\n        let repo_summaries: Vec<McpRepoSummary> = repos\n            .into_iter()\n            .map(|r| McpRepoSummary {\n                id: r.id.to_string(),\n                name: r.name,\n            })\n            .collect();\n\n        let response = ListReposResponse {\n            count: repo_summaries.len(),\n            repos: repo_summaries,\n        };\n\n        McpServer::success(&response)\n    }\n\n    #[tool(\n        description = \"Get detailed information about a repository including its scripts. Use `list_repos` to find available repo IDs.\"\n    )]\n    async fn get_repo(\n        &self,\n        Parameters(GetRepoRequest { repo_id }): Parameters<GetRepoRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/repos/{}\", repo_id));\n        let repo: Repo = match self.send_json(self.client.get(&url)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n        McpServer::success(&RepoDetails {\n            id: repo.id.to_string(),\n            name: repo.name,\n            display_name: repo.display_name,\n            setup_script: repo.setup_script,\n            cleanup_script: repo.cleanup_script,\n            dev_server_script: repo.dev_server_script,\n        })\n    }\n\n    #[tool(\n        description = \"Update a repository's setup script. The setup script runs when initializing a workspace.\"\n    )]\n    async fn update_setup_script(\n        &self,\n        Parameters(UpdateSetupScriptRequest { repo_id, script }): Parameters<\n            UpdateSetupScriptRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/repos/{}\", repo_id));\n        let script_value = if script.is_empty() {\n            None\n        } else {\n            Some(script)\n        };\n        let payload = serde_json::json!({\n            \"setup_script\": script_value\n        });\n        let _repo: Repo = match self.send_json(self.client.put(&url).json(&payload)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n        McpServer::success(&UpdateRepoScriptResponse {\n            success: true,\n            repo_id: repo_id.to_string(),\n            field: \"setup_script\".to_string(),\n        })\n    }\n\n    #[tool(\n        description = \"Update a repository's cleanup script. The cleanup script runs when tearing down a workspace.\"\n    )]\n    async fn update_cleanup_script(\n        &self,\n        Parameters(UpdateCleanupScriptRequest { repo_id, script }): Parameters<\n            UpdateCleanupScriptRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/repos/{}\", repo_id));\n        let script_value = if script.is_empty() {\n            None\n        } else {\n            Some(script)\n        };\n        let payload = serde_json::json!({\n            \"cleanup_script\": script_value\n        });\n        let _repo: Repo = match self.send_json(self.client.put(&url).json(&payload)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n        McpServer::success(&UpdateRepoScriptResponse {\n            success: true,\n            repo_id: repo_id.to_string(),\n            field: \"cleanup_script\".to_string(),\n        })\n    }\n\n    #[tool(\n        description = \"Update a repository's dev server script. The dev server script starts the development server for the repository.\"\n    )]\n    async fn update_dev_server_script(\n        &self,\n        Parameters(UpdateDevServerScriptRequest { repo_id, script }): Parameters<\n            UpdateDevServerScriptRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(&format!(\"/api/repos/{}\", repo_id));\n        let script_value = if script.is_empty() {\n            None\n        } else {\n            Some(script)\n        };\n        let payload = serde_json::json!({\n            \"dev_server_script\": script_value\n        });\n        let _repo: Repo = match self.send_json(self.client.put(&url).json(&payload)).await {\n            Ok(r) => r,\n            Err(e) => return Ok(e),\n        };\n        McpServer::success(&UpdateRepoScriptResponse {\n            success: true,\n            repo_id: repo_id.to_string(),\n            field: \"dev_server_script\".to_string(),\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/sessions.rs",
    "content": "use db::models::{\n    execution_process::{ExecutionProcess, ExecutionProcessStatus},\n    session::Session,\n};\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct CreateSessionRequest {\n    #[schemars(\n        description = \"Workspace ID to create the session in. Optional when running inside a scoped orchestrator MCP.\"\n    )]\n    workspace_id: Option<Uuid>,\n    #[schemars(description = \"Optional executor to pin this session to\")]\n    executor: Option<String>,\n    #[schemars(description = \"Optional display name for the session\")]\n    name: Option<String>,\n}\n\n#[derive(Debug, Serialize)]\nstruct CreateSessionPayload {\n    workspace_id: Uuid,\n    executor: Option<String>,\n    name: Option<String>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct SessionSummary {\n    #[schemars(description = \"Session ID\")]\n    id: String,\n    #[schemars(description = \"Workspace ID\")]\n    workspace_id: String,\n    #[schemars(description = \"Session display name (if set)\")]\n    name: Option<String>,\n    #[schemars(description = \"Session executor (if set)\")]\n    executor: Option<String>,\n    #[schemars(description = \"Creation timestamp\")]\n    created_at: String,\n    #[schemars(description = \"Last update timestamp\")]\n    updated_at: String,\n    #[schemars(description = \"True if this is the orchestrator session for this MCP server\")]\n    is_orchestrator_session: bool,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct CreateSessionResponse {\n    session: SessionSummary,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct ListSessionsRequest {\n    #[schemars(\n        description = \"Workspace ID to inspect. Optional when running inside a scoped orchestrator MCP.\"\n    )]\n    workspace_id: Option<Uuid>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct ListSessionsResponse {\n    #[schemars(description = \"Workspace ID this result is scoped to\")]\n    workspace_id: String,\n    total_count: usize,\n    sessions: Vec<SessionSummary>,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct RunCodingAgentInSessionRequest {\n    #[schemars(description = \"Session ID to run the coding agent in\")]\n    session_id: Uuid,\n    #[schemars(description = \"Prompt for the coding agent\")]\n    prompt: String,\n}\n\n#[derive(Debug, Serialize)]\nstruct FollowUpPayload {\n    prompt: String,\n    executor_config: ExecutorConfigPayload,\n    retry_process_id: Option<Uuid>,\n    force_when_dirty: Option<bool>,\n    perform_git_reset: Option<bool>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ExecutorConfigPayload {\n    executor: String,\n    variant: Option<String>,\n    model_id: Option<String>,\n    agent_id: Option<String>,\n    reasoning_id: Option<String>,\n    permission_policy: Option<String>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct RunCodingAgentInSessionResponse {\n    session_id: String,\n    execution_id: String,\n    execution: serde_json::Value,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct UpdateSessionRequest {\n    #[schemars(description = \"Session ID to update\")]\n    session_id: Uuid,\n    #[schemars(description = \"Set session display name (empty string clears it)\")]\n    name: Option<String>,\n}\n\n#[derive(Debug, Serialize)]\nstruct UpdateSessionPayload {\n    name: Option<String>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct UpdateSessionResponse {\n    success: bool,\n    session_id: String,\n    name: Option<String>,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct GetExecutionRequest {\n    #[schemars(description = \"Execution ID to inspect\")]\n    execution_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct GetExecutionResponse {\n    execution_id: String,\n    session_id: String,\n    status: String,\n    is_finished: bool,\n    execution: serde_json::Value,\n    #[schemars(description = \"Final assistant message/summary when execution has finished\")]\n    final_message: Option<String>,\n}\n\n#[tool_router(router = session_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(description = \"Create a new session in a workspace.\")]\n    async fn create_session(\n        &self,\n        Parameters(CreateSessionRequest {\n            workspace_id,\n            executor,\n            name,\n        }): Parameters<CreateSessionRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let workspace_id = match self.resolve_workspace_id(workspace_id) {\n            Ok(id) => id,\n            Err(error_result) => return Ok(error_result),\n        };\n        if let Err(error_result) = self.scope_allows_workspace(workspace_id) {\n            return Ok(error_result);\n        }\n\n        let payload = CreateSessionPayload {\n            workspace_id,\n            executor: executor.and_then(|value| {\n                let trimmed = value.trim();\n                if trimmed.is_empty() {\n                    None\n                } else {\n                    Some(trimmed.to_string())\n                }\n            }),\n            name: name.and_then(|value| {\n                let trimmed = value.trim();\n                if trimmed.is_empty() {\n                    None\n                } else {\n                    Some(trimmed.to_string())\n                }\n            }),\n        };\n\n        let url = self.url(\"/api/sessions\");\n        let session: Session = match self.send_json(self.client.post(&url).json(&payload)).await {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n\n        Self::success(&CreateSessionResponse {\n            session: self.session_summary(session),\n        })\n    }\n\n    #[tool(description = \"List all sessions for a workspace.\")]\n    async fn list_sessions(\n        &self,\n        Parameters(ListSessionsRequest { workspace_id }): Parameters<ListSessionsRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let workspace_id = match self.resolve_workspace_id(workspace_id) {\n            Ok(id) => id,\n            Err(error_result) => return Ok(error_result),\n        };\n        if let Err(error_result) = self.scope_allows_workspace(workspace_id) {\n            return Ok(error_result);\n        }\n\n        let url = self.url(&format!(\"/api/sessions?workspace_id={workspace_id}\"));\n        let sessions: Vec<Session> = match self.send_json(self.client.get(&url)).await {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n\n        let sessions = sessions\n            .into_iter()\n            .map(|session| self.session_summary(session))\n            .collect::<Vec<_>>();\n\n        Self::success(&ListSessionsResponse {\n            workspace_id: workspace_id.to_string(),\n            total_count: sessions.len(),\n            sessions,\n        })\n    }\n\n    #[tool(description = \"Update a session's name. `session_id` is required.\")]\n    async fn update_session(\n        &self,\n        Parameters(UpdateSessionRequest { session_id, name }): Parameters<UpdateSessionRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        // Verify session exists and check scope\n        let session_url = self.url(&format!(\"/api/sessions/{session_id}\"));\n        let session: Session = match self.send_json(self.client.get(&session_url)).await {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n        if let Err(error_result) = self.scope_allows_workspace(session.workspace_id) {\n            return Ok(error_result);\n        }\n\n        let payload = UpdateSessionPayload {\n            name: name.map(|value| value.trim().to_string()),\n        };\n        let url = self.url(&format!(\"/api/sessions/{session_id}\"));\n        let updated: Session = match self.send_json(self.client.put(&url).json(&payload)).await {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n\n        Self::success(&UpdateSessionResponse {\n            success: true,\n            session_id: updated.id.to_string(),\n            name: updated.name,\n        })\n    }\n\n    #[tool(\n        description = \"Run a coding agent turn in an existing session and return immediately with the execution process.\"\n    )]\n    async fn run_session_prompt(\n        &self,\n        Parameters(RunCodingAgentInSessionRequest { session_id, prompt }): Parameters<\n            RunCodingAgentInSessionRequest,\n        >,\n    ) -> Result<CallToolResult, ErrorData> {\n        let prompt = prompt.trim();\n        if prompt.is_empty() {\n            return Self::err(\"prompt must not be empty\", None);\n        }\n\n        let session_url = self.url(&format!(\"/api/sessions/{session_id}\"));\n        let session: Session = match self.send_json(self.client.get(&session_url)).await {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n        if let Err(error_result) = self.scope_allows_workspace(session.workspace_id) {\n            return Ok(error_result);\n        }\n        if self.orchestrator_session_id() == Some(session_id) {\n            return Self::err(\n                \"Cannot run coding agent in the orchestrator session\".to_string(),\n                Some(\n                    \"Create or re-use a different session and run the coding agent there.\"\n                        .to_string(),\n                ),\n            );\n        }\n\n        let executor_config = match Self::executor_config_payload_for_session(&session) {\n            Ok(config) => config,\n            Err(error_result) => return Ok(error_result),\n        };\n\n        let payload = FollowUpPayload {\n            prompt: prompt.to_string(),\n            executor_config,\n            retry_process_id: None,\n            force_when_dirty: None,\n            perform_git_reset: None,\n        };\n\n        let url = self.url(&format!(\"/api/sessions/{session_id}/follow-up\"));\n        let execution_process: ExecutionProcess =\n            match self.send_json(self.client.post(&url).json(&payload)).await {\n                Ok(value) => value,\n                Err(error_result) => return Ok(error_result),\n            };\n\n        let execution_id = execution_process.id.to_string();\n        let execution = match Self::serialize_execution_process(&execution_process) {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n\n        Self::success(&RunCodingAgentInSessionResponse {\n            session_id: session_id.to_string(),\n            execution_id,\n            execution,\n        })\n    }\n\n    #[tool(description = \"Get status for an execution.\")]\n    async fn get_execution(\n        &self,\n        Parameters(GetExecutionRequest { execution_id }): Parameters<GetExecutionRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let process_url = self.url(&format!(\"/api/execution-processes/{execution_id}\"));\n        let execution_process: ExecutionProcess =\n            match self.send_json(self.client.get(&process_url)).await {\n                Ok(value) => value,\n                Err(error_result) => return Ok(error_result),\n            };\n\n        let session_url = self.url(&format!(\"/api/sessions/{}\", execution_process.session_id));\n        let session: Session = match self.send_json(self.client.get(&session_url)).await {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n        if let Err(error_result) = self.scope_allows_workspace(session.workspace_id) {\n            return Ok(error_result);\n        }\n\n        let is_finished = execution_process.status != ExecutionProcessStatus::Running;\n\n        let execution_process_value = match Self::serialize_execution_process(&execution_process) {\n            Ok(value) => value,\n            Err(error_result) => return Ok(error_result),\n        };\n\n        Self::success(&GetExecutionResponse {\n            execution_id: execution_process.id.to_string(),\n            session_id: execution_process.session_id.to_string(),\n            status: Self::execution_process_status_label(&execution_process.status).to_string(),\n            is_finished,\n            execution: execution_process_value,\n            final_message: None,\n        })\n    }\n}\n\nimpl McpServer {\n    fn executor_config_payload_for_session(\n        session: &Session,\n    ) -> Result<ExecutorConfigPayload, CallToolResult> {\n        Ok(ExecutorConfigPayload {\n            executor: Self::normalize_executor_name(session.executor.as_deref())?,\n            variant: None,\n            model_id: None,\n            agent_id: None,\n            reasoning_id: None,\n            permission_policy: None,\n        })\n    }\n\n    fn session_summary(&self, session: Session) -> SessionSummary {\n        let is_orchestrator_session = self.orchestrator_session_id() == Some(session.id);\n        SessionSummary {\n            id: session.id.to_string(),\n            workspace_id: session.workspace_id.to_string(),\n            name: session.name,\n            executor: session.executor,\n            created_at: session.created_at.to_rfc3339(),\n            updated_at: session.updated_at.to_rfc3339(),\n            is_orchestrator_session,\n        }\n    }\n\n    fn serialize_execution_process(\n        execution_process: &ExecutionProcess,\n    ) -> Result<serde_json::Value, CallToolResult> {\n        serde_json::to_value(execution_process).map_err(|error| {\n            Self::err(\n                \"Failed to serialize execution process response\".to_string(),\n                Some(error.to_string()),\n            )\n            .unwrap()\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/task_attempts.rs",
    "content": "use db::models::requests::{\n    CreateAndStartWorkspaceRequest, CreateAndStartWorkspaceResponse, LinkedIssueInfo,\n    WorkspaceRepoInput,\n};\nuse executors::profile::ExecutorConfig;\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpWorkspaceRepoInput {\n    #[schemars(description = \"The repository ID\")]\n    repo_id: Uuid,\n    #[schemars(description = \"The branch for this repository\")]\n    branch: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct StartWorkspaceRequest {\n    #[schemars(description = \"Name for the workspace\")]\n    name: String,\n    #[schemars(\n        description = \"Optional prompt for the first workspace session. If omitted/empty, the linked issue title/description is used.\"\n    )]\n    prompt: Option<String>,\n    #[schemars(\n        description = \"The coding agent executor to run ('CLAUDE_CODE', 'AMP', 'GEMINI', 'CODEX', 'OPENCODE', 'CURSOR_AGENT', 'QWEN_CODE', 'COPILOT', 'DROID')\"\n    )]\n    executor: String,\n    #[schemars(description = \"Optional executor variant, if needed\")]\n    variant: Option<String>,\n    #[schemars(description = \"Repository selection for the workspace\")]\n    repositories: Vec<McpWorkspaceRepoInput>,\n    #[schemars(\n        description = \"Optional issue ID to link the workspace to. When provided, the workspace will be associated with this remote issue.\"\n    )]\n    issue_id: Option<Uuid>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct StartWorkspaceResponse {\n    workspace_id: String,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct LinkWorkspaceIssueRequest {\n    #[schemars(description = \"The workspace ID to link\")]\n    workspace_id: Uuid,\n    #[schemars(description = \"The issue ID to link the workspace to\")]\n    issue_id: Uuid,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct LinkWorkspaceIssueResponse {\n    #[schemars(description = \"Whether the linking was successful\")]\n    success: bool,\n    #[schemars(description = \"The workspace ID that was linked\")]\n    workspace_id: String,\n    #[schemars(description = \"The issue ID it was linked to\")]\n    issue_id: String,\n}\n\nfn build_workspace_prompt_from_issue(issue: &api_types::Issue) -> Option<String> {\n    let title = issue.title.trim();\n    let description = issue\n        .description\n        .as_deref()\n        .map(str::trim)\n        .filter(|d| !d.is_empty())\n        .unwrap_or_default();\n\n    if title.is_empty() && description.is_empty() {\n        return None;\n    }\n\n    if description.is_empty() {\n        return Some(title.to_string());\n    }\n\n    if title.is_empty() {\n        return Some(description.to_string());\n    }\n\n    Some(format!(\"{title}\\n\\n{description}\"))\n}\n\n#[tool_router(router = task_attempts_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(description = \"Create a new workspace and start its first session.\")]\n    async fn start_workspace(\n        &self,\n        Parameters(StartWorkspaceRequest {\n            name,\n            prompt,\n            executor,\n            variant,\n            repositories,\n            issue_id,\n        }): Parameters<StartWorkspaceRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        if repositories.is_empty() {\n            return Self::err(\"At least one repository must be specified.\", None::<&str>);\n        }\n\n        let executor_trimmed = executor.trim();\n        if executor_trimmed.is_empty() {\n            return Self::err(\"Executor must not be empty.\", None::<&str>);\n        }\n\n        let prompt = prompt.and_then(|prompt| {\n            let trimmed = prompt.trim();\n            if trimmed.is_empty() {\n                None\n            } else {\n                Some(trimmed.to_string())\n            }\n        });\n\n        let base_executor = match Self::parse_executor_agent(executor_trimmed) {\n            Ok(exec) => exec,\n            Err(_) => {\n                return Self::err(\n                    format!(\"Unknown executor '{executor_trimmed}'.\"),\n                    None::<String>,\n                );\n            }\n        };\n\n        let variant = variant.and_then(|v| {\n            let trimmed = v.trim();\n            if trimmed.is_empty() {\n                None\n            } else {\n                Some(trimmed.to_string())\n            }\n        });\n\n        let workspace_repos: Vec<WorkspaceRepoInput> = repositories\n            .into_iter()\n            .map(|r| WorkspaceRepoInput {\n                repo_id: r.repo_id,\n                target_branch: r.branch,\n            })\n            .collect();\n\n        let (linked_issue, issue_prompt) = if let Some(issue_id) = issue_id {\n            let issue_url = self.url(&format!(\"/api/remote/issues/{issue_id}\"));\n            let issue: api_types::Issue = match self.send_json(self.client.get(&issue_url)).await {\n                Ok(issue) => issue,\n                Err(e) => return Ok(e),\n            };\n\n            (\n                Some(LinkedIssueInfo {\n                    remote_project_id: issue.project_id,\n                    issue_id,\n                }),\n                build_workspace_prompt_from_issue(&issue),\n            )\n        } else {\n            (None, None)\n        };\n\n        let workspace_prompt = match prompt.or(issue_prompt) {\n            Some(prompt) => prompt,\n            None => {\n                return Self::err(\n                    \"Provide `prompt`, or `issue_id` that has a non-empty title/description.\",\n                    None::<&str>,\n                );\n            }\n        };\n\n        let create_and_start_payload = CreateAndStartWorkspaceRequest {\n            name: Some(name.clone()),\n            repos: workspace_repos,\n            linked_issue,\n            executor_config: ExecutorConfig {\n                executor: base_executor,\n                variant,\n                model_id: None,\n                agent_id: None,\n                reasoning_id: None,\n                permission_policy: None,\n            },\n            prompt: workspace_prompt,\n            attachment_ids: None,\n        };\n\n        let create_and_start_url = self.url(\"/api/workspaces/start\");\n        let create_and_start_response: CreateAndStartWorkspaceResponse = match self\n            .send_json(\n                self.client\n                    .post(&create_and_start_url)\n                    .json(&create_and_start_payload),\n            )\n            .await\n        {\n            Ok(response) => response,\n            Err(e) => return Ok(e),\n        };\n\n        // Link workspace to remote issue if issue_id is provided\n        if let Some(issue_id) = issue_id\n            && let Err(e) = self\n                .link_workspace_to_issue(create_and_start_response.workspace.id, issue_id)\n                .await\n        {\n            return Ok(e);\n        }\n\n        let response = StartWorkspaceResponse {\n            workspace_id: create_and_start_response.workspace.id.to_string(),\n        };\n\n        McpServer::success(&response)\n    }\n\n    #[tool(\n        description = \"Link an existing workspace to a remote issue. This associates the workspace with the issue for tracking.\"\n    )]\n    async fn link_workspace_issue(\n        &self,\n        Parameters(LinkWorkspaceIssueRequest {\n            workspace_id,\n            issue_id,\n        }): Parameters<LinkWorkspaceIssueRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        if let Err(e) = self.link_workspace_to_issue(workspace_id, issue_id).await {\n            return Ok(e);\n        }\n\n        McpServer::success(&LinkWorkspaceIssueResponse {\n            success: true,\n            workspace_id: workspace_id.to_string(),\n            issue_id: issue_id.to_string(),\n        })\n    }\n}\n"
  },
  {
    "path": "crates/mcp/src/task_server/tools/workspaces.rs",
    "content": "use db::models::{requests::UpdateWorkspace, workspace::Workspace};\nuse rmcp::{\n    ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool,\n    tool_router,\n};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::McpServer;\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpListWorkspacesRequest {\n    #[schemars(description = \"Filter by archived state\")]\n    archived: Option<bool>,\n    #[schemars(description = \"Filter by pinned state\")]\n    pinned: Option<bool>,\n    #[schemars(description = \"Filter by branch name (exact match, case-insensitive)\")]\n    branch: Option<String>,\n    #[schemars(description = \"Case-insensitive substring match against workspace name\")]\n    name_search: Option<String>,\n    #[schemars(description = \"Maximum number of workspaces to return (default: 50)\")]\n    limit: Option<i32>,\n    #[schemars(description = \"Number of results to skip before returning rows (default: 0)\")]\n    offset: Option<i32>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct WorkspaceSummary {\n    #[schemars(description = \"Workspace ID\")]\n    id: String,\n    #[schemars(description = \"Workspace branch\")]\n    branch: String,\n    #[schemars(description = \"Whether the workspace is archived\")]\n    archived: bool,\n    #[schemars(description = \"Whether the workspace is pinned\")]\n    pinned: bool,\n    #[schemars(description = \"Optional workspace display name\")]\n    name: Option<String>,\n    #[schemars(description = \"Creation timestamp\")]\n    created_at: String,\n    #[schemars(description = \"Last update timestamp\")]\n    updated_at: String,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpListWorkspacesResponse {\n    workspaces: Vec<WorkspaceSummary>,\n    total_count: usize,\n    returned_count: usize,\n    limit: usize,\n    offset: usize,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpUpdateWorkspaceRequest {\n    #[schemars(\n        description = \"Workspace ID to update. Optional if running inside that workspace context.\"\n    )]\n    workspace_id: Option<Uuid>,\n    #[schemars(description = \"Set archived state\")]\n    archived: Option<bool>,\n    #[schemars(description = \"Set pinned state\")]\n    pinned: Option<bool>,\n    #[schemars(description = \"Set workspace display name (empty string clears it)\")]\n    name: Option<String>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpUpdateWorkspaceResponse {\n    success: bool,\n    workspace_id: String,\n    archived: bool,\n    pinned: bool,\n    name: Option<String>,\n}\n\n#[derive(Debug, Deserialize, schemars::JsonSchema)]\nstruct McpDeleteWorkspaceRequest {\n    #[schemars(\n        description = \"Workspace ID to delete. Optional if running inside that workspace context.\"\n    )]\n    workspace_id: Option<Uuid>,\n    #[schemars(\n        description = \"Also delete linked remote workspace when available (default: false)\"\n    )]\n    delete_remote: Option<bool>,\n    #[schemars(description = \"Also delete workspace branches from repos (default: false)\")]\n    delete_branches: Option<bool>,\n}\n\n#[derive(Debug, Serialize, schemars::JsonSchema)]\nstruct McpDeleteWorkspaceResponse {\n    success: bool,\n    workspace_id: String,\n    delete_remote: bool,\n    delete_branches: bool,\n}\n\n#[tool_router(router = workspaces_tools_router, vis = \"pub\")]\nimpl McpServer {\n    #[tool(description = \"List local workspaces with optional filters and pagination.\")]\n    async fn list_workspaces(\n        &self,\n        Parameters(McpListWorkspacesRequest {\n            archived,\n            pinned,\n            branch,\n            name_search,\n            limit,\n            offset,\n        }): Parameters<McpListWorkspacesRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let url = self.url(\"/api/workspaces\");\n        let mut workspaces: Vec<Workspace> = match self.send_json(self.client.get(&url)).await {\n            Ok(ws) => ws,\n            Err(e) => return Ok(e),\n        };\n\n        if let Some(archived_filter) = archived {\n            workspaces.retain(|w| w.archived == archived_filter);\n        }\n        if let Some(pinned_filter) = pinned {\n            workspaces.retain(|w| w.pinned == pinned_filter);\n        }\n        if let Some(branch_filter) = branch.as_deref() {\n            workspaces.retain(|w| w.branch.eq_ignore_ascii_case(branch_filter));\n        }\n        if let Some(name_search) = name_search.as_deref() {\n            let needle = name_search.to_ascii_lowercase();\n            workspaces.retain(|w| {\n                w.name\n                    .as_deref()\n                    .map(|name| name.to_ascii_lowercase().contains(&needle))\n                    .unwrap_or(false)\n            });\n        }\n\n        // Keep ordering deterministic after filtering.\n        workspaces.sort_by(|a, b| b.created_at.cmp(&a.created_at));\n\n        let total_count = workspaces.len();\n        let offset = offset.unwrap_or(0).max(0) as usize;\n        let limit = limit.unwrap_or(50).max(0) as usize;\n\n        let workspace_summaries = workspaces\n            .into_iter()\n            .skip(offset)\n            .take(limit)\n            .map(|workspace| WorkspaceSummary {\n                id: workspace.id.to_string(),\n                branch: workspace.branch,\n                archived: workspace.archived,\n                pinned: workspace.pinned,\n                name: workspace.name,\n                created_at: workspace.created_at.to_rfc3339(),\n                updated_at: workspace.updated_at.to_rfc3339(),\n            })\n            .collect::<Vec<_>>();\n\n        McpServer::success(&McpListWorkspacesResponse {\n            returned_count: workspace_summaries.len(),\n            total_count,\n            limit,\n            offset,\n            workspaces: workspace_summaries,\n        })\n    }\n\n    #[tool(\n        description = \"Update a workspace's archived, pinned, or name fields. `workspace_id` is optional if running inside that workspace context.\"\n    )]\n    async fn update_workspace(\n        &self,\n        Parameters(McpUpdateWorkspaceRequest {\n            workspace_id,\n            archived,\n            pinned,\n            name,\n        }): Parameters<McpUpdateWorkspaceRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let workspace_id = match self.resolve_workspace_id(workspace_id) {\n            Ok(id) => id,\n            Err(error_result) => return Ok(error_result),\n        };\n        if let Err(error_result) = self.scope_allows_workspace(workspace_id) {\n            return Ok(error_result);\n        }\n\n        let url = self.url(&format!(\"/api/workspaces/{}\", workspace_id));\n        let payload = UpdateWorkspace {\n            archived,\n            pinned,\n            name,\n        };\n\n        let updated: Workspace = match self.send_json(self.client.put(&url).json(&payload)).await {\n            Ok(ws) => ws,\n            Err(e) => return Ok(e),\n        };\n\n        McpServer::success(&McpUpdateWorkspaceResponse {\n            success: true,\n            workspace_id: updated.id.to_string(),\n            archived: updated.archived,\n            pinned: updated.pinned,\n            name: updated.name,\n        })\n    }\n\n    #[tool(\n        description = \"Delete a local workspace. `workspace_id` is optional if running inside that workspace context.\"\n    )]\n    async fn delete_workspace(\n        &self,\n        Parameters(McpDeleteWorkspaceRequest {\n            workspace_id,\n            delete_remote,\n            delete_branches,\n        }): Parameters<McpDeleteWorkspaceRequest>,\n    ) -> Result<CallToolResult, ErrorData> {\n        let workspace_id = match self.resolve_workspace_id(workspace_id) {\n            Ok(id) => id,\n            Err(error_result) => return Ok(error_result),\n        };\n        if let Err(error_result) = self.scope_allows_workspace(workspace_id) {\n            return Ok(error_result);\n        }\n\n        let delete_remote = delete_remote.unwrap_or(false);\n        let delete_branches = delete_branches.unwrap_or(false);\n\n        let url = self.url(&format!(\"/api/workspaces/{}\", workspace_id));\n        if let Err(e) = self\n            .send_empty_json(self.client.delete(&url).query(&[\n                (\"delete_remote\", delete_remote),\n                (\"delete_branches\", delete_branches),\n            ]))\n            .await\n        {\n            return Ok(e);\n        }\n\n        McpServer::success(&McpDeleteWorkspaceResponse {\n            success: true,\n            workspace_id: workspace_id.to_string(),\n            delete_remote,\n            delete_branches,\n        })\n    }\n}\n"
  },
  {
    "path": "crates/relay-control/Cargo.toml",
    "content": "[package]\nname = \"relay-control\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\ntokio = { workspace = true }\ntokio-util = { version = \"0.7\", features = [\"io\"] }\nbase64 = \"0.22\"\ned25519-dalek = { version = \"2.2.0\", features = [\"rand_core\"] }\nrand = \"0.8\"\nuuid = { version = \"1.0\", features = [\"v4\"] }\n"
  },
  {
    "path": "crates/relay-control/src/lib.rs",
    "content": "pub mod signing;\n\nuse tokio::sync::RwLock;\nuse tokio_util::sync::CancellationToken;\n\n/// Controls the lifecycle of the relay tunnel connection.\n///\n/// Start/stop can be called from login/logout handlers to dynamically\n/// manage the relay without restarting the server.\npub struct RelayControl {\n    /// Token used to cancel the current relay connection\n    shutdown: RwLock<Option<CancellationToken>>,\n}\n\nimpl Default for RelayControl {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl RelayControl {\n    pub fn new() -> Self {\n        Self {\n            shutdown: RwLock::new(None),\n        }\n    }\n\n    /// Create a new cancellation token for a relay session.\n    /// Cancels any previously running session first.\n    pub async fn reset(&self) -> CancellationToken {\n        let mut guard = self.shutdown.write().await;\n        if let Some(old) = guard.take() {\n            old.cancel();\n        }\n        let token = CancellationToken::new();\n        *guard = Some(token.clone());\n        token\n    }\n\n    /// Cancel the current relay session if one is running.\n    pub async fn stop(&self) {\n        let mut guard = self.shutdown.write().await;\n        if let Some(token) = guard.take() {\n            token.cancel();\n        }\n    }\n}\n"
  },
  {
    "path": "crates/relay-control/src/signing.rs",
    "content": "use std::{\n    collections::HashMap,\n    fs, io,\n    path::Path,\n    sync::Arc,\n    time::{Duration, Instant, SystemTime, UNIX_EPOCH},\n};\n\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};\nuse rand::rngs::OsRng;\nuse tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockWriteGuard};\nuse uuid::Uuid;\n\nstruct RelaySigningSession {\n    browser_public_key: VerifyingKey,\n    created_at: Instant,\n    last_used_at: Instant,\n    seen_nonces: HashMap<String, Instant>,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum RelaySignatureValidationError {\n    TimestampOutOfDrift,\n    MissingSigningSession,\n    InvalidNonce,\n    ReplayNonce,\n    InvalidSignature,\n}\n\nimpl RelaySignatureValidationError {\n    pub fn as_str(self) -> &'static str {\n        match self {\n            Self::TimestampOutOfDrift => \"timestamp outside drift window\",\n            Self::MissingSigningSession => \"missing or expired signing session\",\n            Self::InvalidNonce => \"invalid nonce\",\n            Self::ReplayNonce => \"replayed nonce\",\n            Self::InvalidSignature => \"invalid signature\",\n        }\n    }\n}\n\nconst RELAY_SIGNATURE_MAX_TIMESTAMP_DRIFT_SECS: i64 = 30;\nconst RELAY_SIGNING_SESSION_TTL: Duration = Duration::from_secs(60 * 60);\nconst RELAY_SIGNING_SESSION_IDLE_TTL: Duration = Duration::from_secs(15 * 60);\nconst RELAY_NONCE_TTL: Duration = Duration::from_secs(2 * 60);\n\n#[derive(Clone)]\npub struct RelaySigningService {\n    sessions: Arc<RwLock<HashMap<Uuid, RelaySigningSession>>>,\n    server_signing_key: Arc<SigningKey>,\n}\n\nimpl RelaySigningService {\n    pub fn new(server_signing_key: SigningKey) -> Self {\n        Self {\n            sessions: Arc::new(RwLock::new(HashMap::new())),\n            server_signing_key: Arc::new(server_signing_key),\n        }\n    }\n\n    pub fn load_or_generate(key_path: &Path) -> io::Result<Self> {\n        let key = if let Ok(bytes) = fs::read(key_path) {\n            let arr: [u8; 32] = bytes.try_into().map_err(|_| {\n                io::Error::new(\n                    io::ErrorKind::InvalidData,\n                    \"server signing key file has invalid length (expected 32 bytes)\",\n                )\n            })?;\n            SigningKey::from_bytes(&arr)\n        } else {\n            let key = SigningKey::generate(&mut OsRng);\n\n            if let Some(parent) = key_path.parent() {\n                fs::create_dir_all(parent)?;\n            }\n\n            let tmp = key_path.with_extension(\"tmp\");\n            fs::write(&tmp, key.to_bytes())?;\n\n            #[cfg(unix)]\n            {\n                use std::os::unix::fs::PermissionsExt;\n                fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;\n            }\n\n            fs::rename(&tmp, key_path)?;\n            key\n        };\n\n        Ok(Self::new(key))\n    }\n\n    pub fn server_public_key(&self) -> VerifyingKey {\n        self.server_signing_key.verifying_key()\n    }\n\n    pub async fn create_session(&self, browser_public_key: VerifyingKey) -> Uuid {\n        let signing_session_id = Uuid::new_v4();\n        let now = Instant::now();\n        let mut sessions = self.sessions.write().await;\n        sessions.insert(\n            signing_session_id,\n            RelaySigningSession {\n                browser_public_key,\n                created_at: now,\n                last_used_at: now,\n                seen_nonces: HashMap::new(),\n            },\n        );\n        signing_session_id\n    }\n\n    pub async fn verify_message(\n        &self,\n        signing_session_id: Uuid,\n        timestamp: i64,\n        nonce: &str,\n        message: &[u8],\n        signature_b64: &str,\n    ) -> Result<(), RelaySignatureValidationError> {\n        if nonce.trim().is_empty() || nonce.len() > 128 {\n            return Err(RelaySignatureValidationError::InvalidNonce);\n        }\n\n        validate_timestamp(timestamp)?;\n\n        let signature = parse_signature_b64(signature_b64)?;\n        let mut session = self.get_valid_session(signing_session_id).await?;\n\n        session\n            .seen_nonces\n            .retain(|_, seen_at| Instant::now().duration_since(*seen_at) <= RELAY_NONCE_TTL);\n        if session.seen_nonces.contains_key(nonce) {\n            return Err(RelaySignatureValidationError::ReplayNonce);\n        }\n\n        session\n            .browser_public_key\n            .verify(message, &signature)\n            .map_err(|_| RelaySignatureValidationError::InvalidSignature)?;\n\n        session\n            .seen_nonces\n            .insert(nonce.to_string(), Instant::now());\n        session.last_used_at = Instant::now();\n\n        Ok(())\n    }\n\n    pub async fn sign_message(\n        &self,\n        signing_session_id: Uuid,\n        message: &[u8],\n    ) -> Result<String, RelaySignatureValidationError> {\n        let mut session = self.get_valid_session(signing_session_id).await?;\n        session.last_used_at = Instant::now();\n\n        let signature = self.server_signing_key.sign(message);\n        Ok(BASE64_STANDARD.encode(signature.to_bytes()))\n    }\n\n    pub async fn verify_signature(\n        &self,\n        signing_session_id: Uuid,\n        message: &[u8],\n        signature_b64: &str,\n    ) -> Result<(), RelaySignatureValidationError> {\n        let signature = parse_signature_b64(signature_b64)?;\n        let mut session = self.get_valid_session(signing_session_id).await?;\n\n        session\n            .browser_public_key\n            .verify(message, &signature)\n            .map_err(|_| RelaySignatureValidationError::InvalidSignature)?;\n\n        session.last_used_at = Instant::now();\n        Ok(())\n    }\n\n    async fn get_valid_session(\n        &self,\n        signing_session_id: Uuid,\n    ) -> Result<RwLockMappedWriteGuard<'_, RelaySigningSession>, RelaySignatureValidationError>\n    {\n        let mut sessions = self.sessions.write().await;\n        let now = Instant::now();\n        sessions.retain(|_, session| {\n            now.duration_since(session.created_at) <= RELAY_SIGNING_SESSION_TTL\n                && now.duration_since(session.last_used_at) <= RELAY_SIGNING_SESSION_IDLE_TTL\n        });\n        RwLockWriteGuard::try_map(sessions, |sessions| sessions.get_mut(&signing_session_id))\n            .map_err(|_| RelaySignatureValidationError::MissingSigningSession)\n    }\n}\n\nfn validate_timestamp(timestamp: i64) -> Result<(), RelaySignatureValidationError> {\n    let now_secs = i64::try_from(\n        SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .map_err(|_| RelaySignatureValidationError::TimestampOutOfDrift)?\n            .as_secs(),\n    )\n    .map_err(|_| RelaySignatureValidationError::TimestampOutOfDrift)?;\n\n    let drift_secs = now_secs.saturating_sub(timestamp).abs();\n    if drift_secs > RELAY_SIGNATURE_MAX_TIMESTAMP_DRIFT_SECS {\n        return Err(RelaySignatureValidationError::TimestampOutOfDrift);\n    }\n    Ok(())\n}\n\nfn parse_signature_b64(signature_b64: &str) -> Result<Signature, RelaySignatureValidationError> {\n    let sig_bytes = BASE64_STANDARD\n        .decode(signature_b64)\n        .map_err(|_| RelaySignatureValidationError::InvalidSignature)?;\n    Signature::from_slice(&sig_bytes).map_err(|_| RelaySignatureValidationError::InvalidSignature)\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-13462773c343a0812783b914d7a09b6e7148d20be4c2a5c92fa5860e1bc5bd36.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO relay_browser_sessions (host_id, user_id, auth_session_id)\\n            VALUES ($1, $2, $3)\\n            RETURNING\\n                id              AS \\\"id!: Uuid\\\",\\n                host_id         AS \\\"host_id!: Uuid\\\",\\n                user_id         AS \\\"user_id!: Uuid\\\",\\n                auth_session_id AS \\\"auth_session_id!: Uuid\\\",\\n                created_at,\\n                last_used_at,\\n                revoked_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"host_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"auth_session_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"last_used_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"revoked_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"13462773c343a0812783b914d7a09b6e7148d20be4c2a5c92fa5860e1bc5bd36\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-25045af6947be74c0a4f1784670904bd488d1cbafe997a2d8abef620d2e5497f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO hosts (\\n                owner_user_id,\\n                shared_with_organization_id,\\n                machine_id,\\n                name,\\n                status,\\n                agent_version\\n            )\\n            VALUES ($1, NULL, $2, $3, 'offline', $4)\\n            ON CONFLICT (owner_user_id, machine_id) DO UPDATE\\n                SET name = EXCLUDED.name,\\n                    agent_version = COALESCE(EXCLUDED.agent_version, hosts.agent_version),\\n                    updated_at = NOW()\\n            RETURNING id AS \\\"id!: Uuid\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"25045af6947be74c0a4f1784670904bd488d1cbafe997a2d8abef620d2e5497f\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT EXISTS (\\n                SELECT 1\\n                FROM hosts h\\n                LEFT JOIN organization_member_metadata om\\n                    ON om.organization_id = h.shared_with_organization_id\\n                    AND om.user_id = $2\\n                WHERE h.id = $1\\n                  AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL)\\n            ) AS \\\"allowed!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"allowed!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-2b81f9454b626be80c21761b0fd1e7a83b71bb53a4ababf212d4fb13636119ae.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE relay_browser_sessions\\n            SET last_used_at = date_trunc('day', NOW())\\n            WHERE id = $1\\n              AND (\\n                last_used_at IS NULL\\n                OR last_used_at < date_trunc('day', NOW())\\n              )\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"2b81f9454b626be80c21761b0fd1e7a83b71bb53a4ababf212d4fb13636119ae\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE auth_sessions\\n            SET revoked_at = NOW()\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-5ba9186639e75711df9218209ad88f91f54f9643ecf5f53af1e7bfc583727a7c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE relay_sessions\\n            SET state = 'expired',\\n                ended_at = COALESCE(ended_at, NOW())\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5ba9186639e75711df9218209ad88f91f54f9643ecf5f53af1e7bfc583727a7c\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-6aef9ee49b4bc1d9a23c0322e7733bee239e31380cb4cf8274cb60427b492299.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id              AS \\\"id!: Uuid\\\",\\n                host_id         AS \\\"host_id!: Uuid\\\",\\n                user_id         AS \\\"user_id!: Uuid\\\",\\n                auth_session_id AS \\\"auth_session_id!: Uuid\\\",\\n                created_at,\\n                last_used_at,\\n                revoked_at\\n            FROM relay_browser_sessions\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"host_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"auth_session_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"last_used_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"revoked_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"6aef9ee49b4bc1d9a23c0322e7733bee239e31380cb4cf8274cb60427b492299\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-6d64fbb63cd05c4cd9bcc3d07cbc26b165f0d0ffbc4df75391c6b205fe0abd78.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE relay_browser_sessions\\n            SET revoked_at = NOW()\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"6d64fbb63cd05c4cd9bcc3d07cbc26b165f0d0ffbc4df75391c6b205fe0abd78\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE auth_sessions\\n            SET last_used_at = date_trunc('day', NOW())\\n            WHERE id = $1\\n              AND (\\n                last_used_at IS NULL\\n                OR last_used_at < date_trunc('day', NOW())\\n              )\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-7b010dece6caaeb04e8f033c869982b97f20c60903a92f4d45634f990ffbfce3.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT status FROM hosts WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"status\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"7b010dece6caaeb04e8f033c869982b97f20c60903a92f4d45634f990ffbfce3\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-80dfc0d5bbc6db412ea0fda24a5184c3ce20064826779571cab1d9e32459c4cb.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id              AS \\\"id!: Uuid\\\",\\n                host_id         AS \\\"host_id!: Uuid\\\",\\n                request_user_id AS \\\"request_user_id!: Uuid\\\",\\n                state,\\n                created_at,\\n                expires_at,\\n                claimed_at,\\n                ended_at\\n            FROM relay_sessions\\n            WHERE id = $1 AND request_user_id = $2\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"host_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"request_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"state\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"claimed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"ended_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"80dfc0d5bbc6db412ea0fda24a5184c3ce20064826779571cab1d9e32459c4cb\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-8b865d8fca4a3d7a8f7edf6671aed582164f93f973448143883d6d2fb461caf6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO relay_auth_codes (code_hash, host_id, relay_cookie_value, expires_at)\\n            VALUES ($1, $2, $3, $4)\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\",\n        \"Text\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"8b865d8fca4a3d7a8f7edf6671aed582164f93f973448143883d6d2fb461caf6\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                          AS \\\"id!\\\",\\n                user_id                     AS \\\"user_id!: Uuid\\\",\\n                created_at                  AS \\\"created_at!\\\",\\n                last_used_at                AS \\\"last_used_at?\\\",\\n                revoked_at                  AS \\\"revoked_at?\\\",\\n                refresh_token_id           AS \\\"refresh_token_id?\\\",\\n                refresh_token_issued_at     AS \\\"refresh_token_issued_at?\\\"\\n            FROM auth_sessions\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_used_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"revoked_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"refresh_token_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"refresh_token_issued_at?\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-9c2e6a8fc112e4e2980e4dc3f1dae1ea7da376119b0f06aafbc74c7a471f17ad.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE hosts\\n            SET status = 'online',\\n                last_seen_at = NOW(),\\n                agent_version = COALESCE($2, agent_version),\\n                updated_at = NOW()\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9c2e6a8fc112e4e2980e4dc3f1dae1ea7da376119b0f06aafbc74c7a471f17ad\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id           AS \\\"id!: Uuid\\\",\\n                email        AS \\\"email!\\\",\\n                first_name   AS \\\"first_name?\\\",\\n                last_name    AS \\\"last_name?\\\",\\n                username     AS \\\"username?\\\",\\n                created_at   AS \\\"created_at!\\\",\\n                updated_at   AS \\\"updated_at!\\\"\\n            FROM users\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"first_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-e84a068d6a2ad1458cf6f45c2f2dde8511355f29677cebfd15783fccd095a131.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE relay_sessions\\n            SET state = 'active',\\n                claimed_at = COALESCE(claimed_at, NOW())\\n            WHERE id = $1\\n            RETURNING\\n                id              AS \\\"id!: Uuid\\\",\\n                host_id         AS \\\"host_id!: Uuid\\\",\\n                request_user_id AS \\\"request_user_id!: Uuid\\\",\\n                state,\\n                created_at,\\n                expires_at,\\n                claimed_at,\\n                ended_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"host_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"request_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"state\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"claimed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"ended_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"e84a068d6a2ad1458cf6f45c2f2dde8511355f29677cebfd15783fccd095a131\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-f31c0531cb5c099bc0c6b193852d3e3d0be6cfe1104dd792651d9c5434483ef0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE relay_auth_codes\\n            SET consumed_at = NOW()\\n            WHERE code_hash = $1\\n              AND host_id = $2\\n              AND consumed_at IS NULL\\n              AND expires_at > NOW()\\n            RETURNING relay_cookie_value\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"relay_cookie_value\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"f31c0531cb5c099bc0c6b193852d3e3d0be6cfe1104dd792651d9c5434483ef0\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/.sqlx/query-f6d727f8d7baa7b92464ccccccd113fbe7c69a69d91dc6f54c6052eaa65ce868.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE hosts\\n            SET status = 'offline',\\n                updated_at = NOW()\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"f6d727f8d7baa7b92464ccccccd113fbe7c69a69d91dc6f54c6052eaa65ce868\"\n}\n"
  },
  {
    "path": "crates/relay-tunnel/Cargo.toml",
    "content": "[package]\nname = \"relay-tunnel\"\nversion = \"0.1.5\"\nedition = \"2024\"\npublish = false\n\n[features]\ndefault = []\nserver = [\n    \"dep:sqlx\",\n    \"dep:jsonwebtoken\",\n    \"dep:secrecy\",\n    \"dep:sha2\",\n    \"dep:base64\",\n    \"dep:chrono\",\n    \"dep:axum-extra\",\n    \"dep:api-types\",\n    \"dep:uuid\",\n    \"dep:tower-http\",\n    \"dep:tracing-subscriber\",\n    \"dep:serde\",\n    \"dep:thiserror\",\n]\n\n[[bin]]\nname = \"relay-server\"\npath = \"src/bin/relay_server.rs\"\nrequired-features = [\"server\"]\n\n[dependencies]\nanyhow = \"1.0\"\naxum = { version = \"0.8.4\", features = [\"macros\", \"multipart\", \"ws\"] }\nbytes = \"1\"\nfutures = \"0.3\"\nfutures-util = \"0.3\"\nhttp = \"1\"\nhyper = { version = \"1\", features = [\"client\", \"server\", \"http1\"] }\nhyper-util = { version = \"0.1\", features = [\"tokio\"] }\ntokio = { version = \"1.0\", features = [\"full\"] }\ntokio-tungstenite = { version = \"0.24\", features = [\"rustls-tls-webpki-roots\"] }\ntokio-util = { version = \"0.7\", features = [\"io\"] }\ntokio-yamux = \"0.3.17\"\ntracing = \"0.1.43\"\n\n# Optional deps for the relay-server binary\napi-types = { path = \"../api-types\", optional = true }\naxum-extra = { version = \"0.10.3\", features = [\"typed-header\"], optional = true }\nbase64 = { version = \"0.22\", optional = true }\nchrono = { version = \"0.4\", features = [\"serde\"], optional = true }\njsonwebtoken = { version = \"10.2.0\", features = [\"rust_crypto\"], optional = true }\nrustls = { version = \"0.23\", default-features = false, features = [\"aws_lc_rs\", \"std\", \"tls12\"] }\nsecrecy = { version = \"0.10.3\", optional = true }\nserde = { version = \"1.0\", features = [\"derive\"], optional = true }\nsha2 = { version = \"0.10\", optional = true }\nsqlx = { version = \"0.8.6\", default-features = false, features = [\"runtime-tokio\", \"tls-rustls-aws-lc-rs\", \"postgres\", \"uuid\", \"chrono\", \"macros\"], optional = true }\nthiserror = { version = \"2.0.12\", optional = true }\ntower-http = { version = \"0.5\", features = [\"cors\", \"request-id\", \"trace\", \"fs\", \"validate-request\"], optional = true }\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"fmt\", \"json\"], optional = true }\nuuid = { version = \"1\", features = [\"serde\", \"v4\"], optional = true }\n\n[workspace]\n"
  },
  {
    "path": "crates/relay-tunnel/Dockerfile",
    "content": "# syntax=docker/dockerfile:1.6\n\nFROM rust:1.93-slim-bookworm AS builder\n\nENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse\nENV CARGO_TARGET_DIR=/app/target\n\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \\\n  && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\nCOPY rust-toolchain.toml ./\nRUN cargo --version >/dev/null\nCOPY Cargo.toml Cargo.lock ./\n# Copy workspace member manifests so Cargo can resolve workspace-shared deps\n# without invalidating the build on every unrelated source change.\nCOPY crates/server/Cargo.toml crates/server/Cargo.toml\nCOPY crates/trusted-key-auth/Cargo.toml crates/trusted-key-auth/Cargo.toml\nCOPY crates/mcp/Cargo.toml crates/mcp/Cargo.toml\nCOPY crates/db/Cargo.toml crates/db/Cargo.toml\nCOPY crates/executors/Cargo.toml crates/executors/Cargo.toml\nCOPY crates/services/Cargo.toml crates/services/Cargo.toml\nCOPY crates/worktree-manager/Cargo.toml crates/worktree-manager/Cargo.toml\nCOPY crates/workspace-manager/Cargo.toml crates/workspace-manager/Cargo.toml\nCOPY crates/relay-control/Cargo.toml crates/relay-control/Cargo.toml\nCOPY crates/server-info/Cargo.toml crates/server-info/Cargo.toml\nCOPY crates/utils/Cargo.toml crates/utils/Cargo.toml\nCOPY crates/git/Cargo.toml crates/git/Cargo.toml\nCOPY crates/git-host/Cargo.toml crates/git-host/Cargo.toml\nCOPY crates/local-deployment/Cargo.toml crates/local-deployment/Cargo.toml\nCOPY crates/deployment/Cargo.toml crates/deployment/Cargo.toml\nCOPY crates/review/Cargo.toml crates/review/Cargo.toml\nCOPY crates/api-types crates/api-types\nCOPY crates/relay-tunnel crates/relay-tunnel\n\nRUN mkdir -p /app/bin\n\nRUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \\\n    --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \\\n    --mount=type=cache,id=relay-target,target=/app/target \\\n    cargo build --locked --release --manifest-path crates/relay-tunnel/Cargo.toml --features server \\\n && cp /app/target/release/relay-server /app/bin/relay-server\n\nFROM debian:bookworm-slim\n\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends ca-certificates libssl3 wget \\\n  && rm -rf /var/lib/apt/lists/* \\\n  && useradd --system --create-home --uid 10001 appuser\n\nCOPY --from=builder /app/bin/relay-server /usr/local/bin/relay-server\n\nUSER appuser\n\nENV RELAY_LISTEN_ADDR=0.0.0.0:8082 \\\n    RUST_LOG=info\n\nEXPOSE 8082\n\nHEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\\n  CMD [\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:8082/health\"]\n\nENTRYPOINT [\"/usr/local/bin/relay-server\"]\n"
  },
  {
    "path": "crates/relay-tunnel/src/bin/relay_server.rs",
    "content": "use std::sync::Arc;\n\nuse relay_tunnel::server_bin::{\n    auth::JwtService, config::RelayServerConfig, db, routes, state::RelayAppState,\n};\nuse tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n    // Initialise tracing\n    tracing_subscriber::registry()\n        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| \"info\".into()))\n        .with(fmt::layer())\n        .init();\n\n    // Force rustls crypto provider (same as remote)\n    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();\n\n    let config = RelayServerConfig::from_env()?;\n    tracing::info!(\n        listen_addr = %config.listen_addr,\n        \"Starting relay server\"\n    );\n\n    let pool = db::create_pool(&config.database_url).await?;\n    tracing::debug!(\"Database pool created\");\n\n    let jwt = Arc::new(JwtService::new(config.jwt_secret.clone()));\n    let state = RelayAppState::new(pool, config.clone(), jwt);\n\n    let router = routes::build_router(state);\n\n    let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;\n    tracing::info!(\"Relay server listening on {}\", config.listen_addr);\n\n    axum::serve(listener, router).await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/client.rs",
    "content": "use std::convert::Infallible;\n\nuse anyhow::Context as _;\nuse axum::body::Body;\nuse futures_util::StreamExt;\nuse http::StatusCode;\nuse hyper::{\n    Request, Response, body::Incoming, client::conn::http1 as client_http1,\n    server::conn::http1 as server_http1, service::service_fn, upgrade,\n};\nuse hyper_util::rt::TokioIo;\nuse tokio::net::TcpStream;\nuse tokio_tungstenite::tungstenite::{self, client::IntoClientRequest};\nuse tokio_util::sync::CancellationToken;\nuse tokio_yamux::{Config as YamuxConfig, Session};\n\nuse crate::{\n    tls::ws_connector,\n    ws_io::{WsIoReadMessage, WsMessageStreamIo},\n};\n\npub struct RelayClientConfig {\n    pub ws_url: String,\n    pub bearer_token: String,\n    pub local_addr: String,\n    pub shutdown: CancellationToken,\n}\n\n/// Connects the relay client control channel and starts handling inbound streams.\n///\n/// Returns when shutdown is requested or when the control channel disconnects/errors.\npub async fn start_relay_client(config: RelayClientConfig) -> anyhow::Result<()> {\n    let mut request = config\n        .ws_url\n        .clone()\n        .into_client_request()\n        .context(\"Failed to build WS request\")?;\n\n    request.headers_mut().insert(\n        \"Authorization\",\n        format!(\"Bearer {}\", config.bearer_token)\n            .parse()\n            .context(\"Invalid auth header\")?,\n    );\n\n    let (ws_stream, _response) =\n        tokio_tungstenite::connect_async_tls_with_config(request, None, false, ws_connector())\n            .await\n            .context(\"Failed to connect relay control channel\")?;\n\n    let ws_io = WsMessageStreamIo::new(ws_stream, read_client_message, write_client_message);\n    let mut session = Session::new_client(ws_io, YamuxConfig::default());\n    let mut control = session.control();\n\n    tracing::debug!(\"Relay control channel connected\");\n\n    let shutdown = config.shutdown;\n    let local_addr = config.local_addr;\n\n    loop {\n        tokio::select! {\n            _ = shutdown.cancelled() => {\n                control.close().await;\n                return Ok(());\n            }\n            inbound = session.next() => {\n                let stream = inbound\n                    .ok_or_else(|| anyhow::anyhow!(\"Relay control channel closed\"))?\n                    .map_err(|e| anyhow::anyhow!(\"Relay yamux session error: {e}\"))?;\n\n                let local_addr = local_addr.clone();\n                tokio::spawn(async move {\n                    if let Err(error) = handle_inbound_stream(stream, local_addr).await {\n                        tracing::warn!(?error, \"Relay stream handling failed\");\n                    }\n                });\n            }\n        }\n    }\n}\n\nasync fn handle_inbound_stream(\n    stream: tokio_yamux::StreamHandle,\n    local_addr: String,\n) -> anyhow::Result<()> {\n    let io = TokioIo::new(stream);\n\n    server_http1::Builder::new()\n        .serve_connection(\n            io,\n            service_fn(move |request: Request<Incoming>| {\n                proxy_to_local(request, local_addr.clone())\n            }),\n        )\n        .with_upgrades()\n        .await\n        .context(\"Yamux stream server connection failed\")\n}\n\nasync fn proxy_to_local(\n    mut request: Request<Incoming>,\n    local_addr: String,\n) -> Result<Response<Body>, Infallible> {\n    request\n        .headers_mut()\n        .insert(\"x-vk-relayed\", http::HeaderValue::from_static(\"1\"));\n\n    // TODO: fix dev servers\n    let local_stream = match TcpStream::connect(local_addr.as_str()).await {\n        Ok(stream) => stream,\n        Err(error) => {\n            tracing::warn!(\n                ?error,\n                \"Failed to connect to local server for relay request\"\n            );\n            return Ok(simple_response(\n                StatusCode::BAD_GATEWAY,\n                \"Failed to connect to local server\",\n            ));\n        }\n    };\n\n    let (mut sender, connection) = match client_http1::Builder::new()\n        .handshake(TokioIo::new(local_stream))\n        .await\n    {\n        Ok(value) => value,\n        Err(error) => {\n            tracing::warn!(?error, \"Failed to create local proxy HTTP connection\");\n            return Ok(simple_response(\n                StatusCode::BAD_GATEWAY,\n                \"Failed to initialize local proxy connection\",\n            ));\n        }\n    };\n\n    tokio::spawn(async move {\n        if let Err(error) = connection.with_upgrades().await {\n            tracing::debug!(?error, \"Local proxy connection closed\");\n        }\n    });\n\n    let request_upgrade = upgrade::on(&mut request);\n\n    let mut response = match sender.send_request(request).await {\n        Ok(response) => response,\n        Err(error) => {\n            tracing::warn!(?error, \"Local proxy request failed\");\n            return Ok(simple_response(\n                StatusCode::BAD_GATEWAY,\n                \"Local proxy request failed\",\n            ));\n        }\n    };\n\n    if response.status() == StatusCode::SWITCHING_PROTOCOLS {\n        let response_upgrade = upgrade::on(&mut response);\n        tokio::spawn(async move {\n            let mut from_remote = TokioIo::new(request_upgrade.await?);\n            let mut to_local = TokioIo::new(response_upgrade.await?);\n            tokio::io::copy_bidirectional(&mut from_remote, &mut to_local).await?;\n            Ok::<_, anyhow::Error>(())\n        });\n    }\n\n    let (parts, body) = response.into_parts();\n    Ok(Response::from_parts(parts, Body::new(body)))\n}\n\nfn simple_response(status: StatusCode, body: &'static str) -> Response<Body> {\n    Response::builder()\n        .status(status)\n        .body(Body::from(body))\n        .unwrap_or_else(|_| Response::new(Body::from(body)))\n}\n\nfn read_client_message(message: tungstenite::Message) -> WsIoReadMessage {\n    match message {\n        tungstenite::Message::Binary(data) => WsIoReadMessage::Data(data.to_vec()),\n        tungstenite::Message::Text(text) => WsIoReadMessage::Data(text.as_bytes().to_vec()),\n        tungstenite::Message::Close(_) => WsIoReadMessage::Eof,\n        _ => WsIoReadMessage::Skip,\n    }\n}\n\nfn write_client_message(bytes: Vec<u8>) -> tungstenite::Message {\n    tungstenite::Message::Binary(bytes)\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/lib.rs",
    "content": "mod tls;\nmod ws_io;\n\npub mod client;\npub mod server;\n\n#[cfg(feature = \"server\")]\npub mod server_bin;\n"
  },
  {
    "path": "crates/relay-tunnel/src/server.rs",
    "content": "use std::{future::Future, sync::Arc};\n\nuse axum::{\n    body::Body,\n    extract::{\n        Request,\n        ws::{Message as AxumWsMessage, WebSocket},\n    },\n    http::{StatusCode, Uri},\n    response::{IntoResponse, Response},\n};\nuse futures_util::StreamExt;\nuse hyper::{client::conn::http1 as client_http1, upgrade};\nuse hyper_util::rt::TokioIo;\nuse tokio::sync::Mutex;\nuse tokio_yamux::{Config as YamuxConfig, Control, Session};\n\nuse crate::ws_io::{WsIoReadMessage, WsMessageStreamIo};\n\npub type SharedControl = Arc<Mutex<Control>>;\n\n/// Runs the server-side control channel over an upgraded WebSocket.\n///\n/// The provided callback is invoked once, after yamux is initialized, with a\n/// shared control handle that can be used to proxy requests over new streams.\npub async fn run_control_channel<F, Fut>(socket: WebSocket, on_connected: F) -> anyhow::Result<()>\nwhere\n    F: FnOnce(SharedControl) -> Fut,\n    Fut: Future<Output = ()>,\n{\n    let ws_io = WsMessageStreamIo::new(socket, read_server_message, write_server_message);\n    let mut session = Session::new_server(ws_io, YamuxConfig::default());\n    let control = Arc::new(Mutex::new(session.control()));\n\n    on_connected(control).await;\n\n    while let Some(stream_result) = session.next().await {\n        match stream_result {\n            Ok(_stream) => {\n                // The client side does not currently open server-initiated streams.\n            }\n            Err(error) => {\n                return Err(anyhow::anyhow!(\"relay session error: {error}\"));\n            }\n        }\n    }\n\n    Ok(())\n}\n\n/// Proxies one HTTP request over a new yamux stream using the shared control.\npub async fn proxy_request_over_control(\n    control: &Mutex<Control>,\n    request: Request,\n    strip_prefix: &str,\n) -> Response {\n    let stream = {\n        let mut control = control.lock().await;\n        match control.open_stream().await {\n            Ok(stream) => stream,\n            Err(error) => {\n                tracing::warn!(?error, \"failed to open relay stream\");\n                return (StatusCode::BAD_GATEWAY, \"Relay connection lost\").into_response();\n            }\n        }\n    };\n\n    let (mut parts, body) = request.into_parts();\n    let path = normalized_relay_path(&parts.uri, strip_prefix);\n    parts.uri = match Uri::builder().path_and_query(path).build() {\n        Ok(uri) => uri,\n        Err(error) => {\n            tracing::warn!(?error, \"failed to build relay proxy URI\");\n            return (StatusCode::BAD_REQUEST, \"Invalid request URI\").into_response();\n        }\n    };\n\n    let mut outbound = axum::http::Request::from_parts(parts, body);\n    let request_upgrade = upgrade::on(&mut outbound);\n\n    let (mut sender, connection) = match client_http1::Builder::new()\n        .handshake(TokioIo::new(stream))\n        .await\n    {\n        Ok(value) => value,\n        Err(error) => {\n            tracing::warn!(?error, \"failed to initialize relay stream proxy connection\");\n            return (StatusCode::BAD_GATEWAY, \"Relay connection failed\").into_response();\n        }\n    };\n\n    tokio::spawn(async move {\n        if let Err(error) = connection.with_upgrades().await {\n            tracing::debug!(?error, \"relay stream connection closed\");\n        }\n    });\n\n    let mut response = match sender.send_request(outbound).await {\n        Ok(response) => response,\n        Err(error) => {\n            tracing::warn!(?error, \"relay proxy request failed\");\n            return (StatusCode::BAD_GATEWAY, \"Relay request failed\").into_response();\n        }\n    };\n\n    if response.status() == StatusCode::SWITCHING_PROTOCOLS {\n        let response_upgrade = upgrade::on(&mut response);\n        tokio::spawn(async move {\n            let Ok(from_client) = request_upgrade.await else {\n                return;\n            };\n            let Ok(to_local) = response_upgrade.await else {\n                return;\n            };\n            let mut from_client = TokioIo::new(from_client);\n            let mut to_local = TokioIo::new(to_local);\n            let _ = tokio::io::copy_bidirectional(&mut from_client, &mut to_local).await;\n        });\n    }\n\n    let (parts, body) = response.into_parts();\n    Response::from_parts(parts, Body::new(body))\n}\n\nfn normalized_relay_path(uri: &axum::http::Uri, strip_prefix: &str) -> String {\n    let raw_path = uri.path();\n    let path = raw_path.strip_prefix(strip_prefix).unwrap_or(raw_path);\n    let path = if path.is_empty() { \"/\" } else { path };\n    let query = uri.query().map(|q| format!(\"?{q}\")).unwrap_or_default();\n    format!(\"{path}{query}\")\n}\n\nfn read_server_message(message: AxumWsMessage) -> WsIoReadMessage {\n    match message {\n        AxumWsMessage::Binary(data) => WsIoReadMessage::Data(data.to_vec()),\n        AxumWsMessage::Text(text) => WsIoReadMessage::Data(text.as_bytes().to_vec()),\n        AxumWsMessage::Close(_) => WsIoReadMessage::Eof,\n        _ => WsIoReadMessage::Skip,\n    }\n}\n\nfn write_server_message(bytes: Vec<u8>) -> AxumWsMessage {\n    AxumWsMessage::Binary(bytes.into())\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/auth.rs",
    "content": "use std::{collections::HashSet, sync::Arc};\n\nuse api_types::User;\nuse axum::{\n    body::Body,\n    extract::State,\n    http::{Request, StatusCode},\n    middleware::Next,\n    response::{IntoResponse, Response},\n};\nuse axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer};\nuse chrono::{DateTime, Utc};\nuse jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};\nuse secrecy::{ExposeSecret, SecretString};\nuse serde::{Deserialize, Serialize};\nuse tracing::warn;\nuse uuid::Uuid;\n\nuse super::{\n    db::{\n        auth_sessions::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION},\n        identity_errors::IdentityError,\n        users::UserRepository,\n    },\n    state::RelayAppState,\n};\n\n// ── JWT Service (decode-only subset) ──────────────────────────────────\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct AccessTokenClaims {\n    pub sub: Uuid,\n    pub session_id: Uuid,\n    pub iat: i64,\n    pub exp: i64,\n    pub aud: String,\n}\n\n#[derive(Debug, Clone)]\npub struct AccessTokenDetails {\n    pub user_id: Uuid,\n    pub session_id: Uuid,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum JwtError {\n    #[error(\"invalid token\")]\n    InvalidToken,\n    #[error(\"invalid jwt secret\")]\n    InvalidSecret,\n    #[error(transparent)]\n    Jwt(#[from] jsonwebtoken::errors::Error),\n}\n\nconst DEFAULT_JWT_LEEWAY_SECONDS: u64 = 60;\n\n#[derive(Clone)]\npub struct JwtService {\n    pub secret: Arc<SecretString>,\n}\n\nimpl std::fmt::Debug for JwtService {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"JwtService\")\n            .field(\"secret\", &\"[REDACTED]\")\n            .finish()\n    }\n}\n\nimpl JwtService {\n    pub fn new(secret: SecretString) -> Self {\n        Self {\n            secret: Arc::new(secret),\n        }\n    }\n\n    pub fn decode_access_token(&self, token: &str) -> Result<AccessTokenDetails, JwtError> {\n        if token.trim().is_empty() {\n            return Err(JwtError::InvalidToken);\n        }\n\n        let mut validation = Validation::new(Algorithm::HS256);\n        validation.validate_exp = true;\n        validation.validate_nbf = false;\n        validation.set_audience(&[\"access\"]);\n        validation.required_spec_claims =\n            HashSet::from([\"sub\".to_string(), \"exp\".to_string(), \"aud\".to_string()]);\n        validation.leeway = DEFAULT_JWT_LEEWAY_SECONDS;\n\n        let decoding_key = DecodingKey::from_base64_secret(self.secret.expose_secret())?;\n        let data = decode::<AccessTokenClaims>(token, &decoding_key, &validation)?;\n        let claims = data.claims;\n        let expires_at = DateTime::from_timestamp(claims.exp, 0).ok_or(JwtError::InvalidToken)?;\n\n        Ok(AccessTokenDetails {\n            user_id: claims.sub,\n            session_id: claims.session_id,\n            expires_at,\n        })\n    }\n}\n\n// ── Request Context ───────────────────────────────────────────────────\n\n#[derive(Clone)]\npub struct RequestContext {\n    pub user: User,\n    pub session_id: Uuid,\n    #[allow(dead_code)]\n    pub access_token_expires_at: DateTime<Utc>,\n}\n\n// ── Auth Middleware ───────────────────────────────────────────────────\n\npub async fn require_session(\n    State(state): State<RelayAppState>,\n    mut req: Request<Body>,\n    next: Next,\n) -> Response {\n    let bearer = match req.headers().typed_get::<Authorization<Bearer>>() {\n        Some(Authorization(token)) => token.token().to_owned(),\n        None => return StatusCode::UNAUTHORIZED.into_response(),\n    };\n\n    let ctx = match request_context_from_access_token(&state, &bearer).await {\n        Ok(ctx) => ctx,\n        Err(response) => return response,\n    };\n\n    req.extensions_mut().insert(ctx);\n    next.run(req).await\n}\n\nasync fn request_context_from_access_token(\n    state: &RelayAppState,\n    access_token: &str,\n) -> Result<RequestContext, Response> {\n    let identity = match state.jwt.decode_access_token(access_token) {\n        Ok(details) => details,\n        Err(error) => {\n            warn!(?error, \"failed to decode access token\");\n            return Err(StatusCode::UNAUTHORIZED.into_response());\n        }\n    };\n\n    let mut ctx = request_context_from_auth_session_id(state, identity.session_id).await?;\n    if ctx.user.id != identity.user_id {\n        warn!(\n            token_user_id = %identity.user_id,\n            session_user_id = %ctx.user.id,\n            session_id = %identity.session_id,\n            \"access token user does not match session user\"\n        );\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    ctx.access_token_expires_at = identity.expires_at;\n    Ok(ctx)\n}\n\npub async fn request_context_from_auth_session_id(\n    state: &RelayAppState,\n    session_id: Uuid,\n) -> Result<RequestContext, Response> {\n    let pool = &state.pool;\n    let session_repo = AuthSessionRepository::new(pool);\n    let session = match session_repo.get(session_id).await {\n        Ok(session) => session,\n        Err(AuthSessionError::NotFound) => {\n            warn!(\"session `{}` not found\", session_id);\n            return Err(StatusCode::UNAUTHORIZED.into_response());\n        }\n        Err(AuthSessionError::Database(error)) => {\n            warn!(?error, \"failed to load session\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n    };\n\n    if session.revoked_at.is_some() {\n        warn!(\"session `{}` rejected (revoked)\", session.id);\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION {\n        warn!(\n            \"session `{}` expired due to inactivity; revoking\",\n            session.id\n        );\n        if let Err(error) = session_repo.revoke(session.id).await {\n            warn!(?error, \"failed to revoke inactive session\");\n        }\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    let user_repo = UserRepository::new(pool);\n    let user = match user_repo.fetch_user(session.user_id).await {\n        Ok(user) => user,\n        Err(IdentityError::NotFound) => {\n            warn!(\"user `{}` missing\", session.user_id);\n            return Err(StatusCode::UNAUTHORIZED.into_response());\n        }\n        Err(IdentityError::Database(error)) => {\n            warn!(?error, \"failed to load user\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n        Err(_) => {\n            warn!(\"unexpected error loading user\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n    };\n\n    let ctx = RequestContext {\n        user,\n        session_id: session.id,\n        access_token_expires_at: Utc::now(),\n    };\n\n    match session_repo.touch(session.id).await {\n        Ok(_) => {}\n        Err(error) => warn!(?error, \"failed to update session last-used timestamp\"),\n    }\n\n    Ok(ctx)\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/config.rs",
    "content": "use std::env;\n\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse secrecy::SecretString;\n\n#[derive(Debug, Clone)]\npub struct RelayServerConfig {\n    pub database_url: String,\n    pub listen_addr: String,\n    pub jwt_secret: SecretString,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum ConfigError {\n    #[error(\"environment variable `{0}` is not set\")]\n    MissingVar(&'static str),\n    #[error(\"invalid value for environment variable `{0}`\")]\n    InvalidVar(&'static str),\n}\n\nimpl RelayServerConfig {\n    pub fn from_env() -> Result<Self, ConfigError> {\n        let database_url = env::var(\"SERVER_DATABASE_URL\")\n            .or_else(|_| env::var(\"DATABASE_URL\"))\n            .map_err(|_| ConfigError::MissingVar(\"DATABASE_URL\"))?;\n\n        let listen_addr =\n            env::var(\"RELAY_LISTEN_ADDR\").unwrap_or_else(|_| \"0.0.0.0:8082\".to_string());\n\n        let jwt_secret_str = env::var(\"VIBEKANBAN_REMOTE_JWT_SECRET\")\n            .map_err(|_| ConfigError::MissingVar(\"VIBEKANBAN_REMOTE_JWT_SECRET\"))?;\n        validate_jwt_secret(&jwt_secret_str)?;\n        let jwt_secret = SecretString::new(jwt_secret_str.into());\n\n        Ok(Self {\n            database_url,\n            listen_addr,\n            jwt_secret,\n        })\n    }\n}\n\nfn validate_jwt_secret(secret: &str) -> Result<(), ConfigError> {\n    let decoded = BASE64_STANDARD\n        .decode(secret.as_bytes())\n        .map_err(|_| ConfigError::InvalidVar(\"VIBEKANBAN_REMOTE_JWT_SECRET\"))?;\n\n    if decoded.len() < 32 {\n        return Err(ConfigError::InvalidVar(\"VIBEKANBAN_REMOTE_JWT_SECRET\"));\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/db/auth_sessions.rs",
    "content": "pub use api_types::AuthSession;\nuse chrono::Duration;\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum AuthSessionError {\n    #[error(\"auth session not found\")]\n    NotFound,\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\npub const MAX_SESSION_INACTIVITY_DURATION: Duration = Duration::days(365);\n\npub struct AuthSessionRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> AuthSessionRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn get(&self, session_id: Uuid) -> Result<AuthSession, AuthSessionError> {\n        sqlx::query_as!(\n            AuthSession,\n            r#\"\n            SELECT\n                id                          AS \"id!\",\n                user_id                     AS \"user_id!: Uuid\",\n                created_at                  AS \"created_at!\",\n                last_used_at                AS \"last_used_at?\",\n                revoked_at                  AS \"revoked_at?\",\n                refresh_token_id           AS \"refresh_token_id?\",\n                refresh_token_issued_at     AS \"refresh_token_issued_at?\"\n            FROM auth_sessions\n            WHERE id = $1\n            \"#,\n            session_id\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(AuthSessionError::NotFound)\n    }\n\n    pub async fn touch(&self, session_id: Uuid) -> Result<(), AuthSessionError> {\n        sqlx::query!(\n            r#\"\n            UPDATE auth_sessions\n            SET last_used_at = date_trunc('day', NOW())\n            WHERE id = $1\n              AND (\n                last_used_at IS NULL\n                OR last_used_at < date_trunc('day', NOW())\n              )\n            \"#,\n            session_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn revoke(&self, session_id: Uuid) -> Result<(), AuthSessionError> {\n        sqlx::query!(\n            r#\"\n            UPDATE auth_sessions\n            SET revoked_at = NOW()\n            WHERE id = $1\n            \"#,\n            session_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/db/hosts.rs",
    "content": "use api_types::RelaySession;\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\nuse super::identity_errors::IdentityError;\n\npub struct HostRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> HostRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    /// Find or create a host for the given user and machine identity.\n    /// If a host with the same owner and machine_id exists, returns it and updates name/version.\n    /// Otherwise, creates a new one.\n    pub async fn upsert_host(\n        &self,\n        owner_user_id: Uuid,\n        machine_id: &str,\n        name: &str,\n        agent_version: Option<&str>,\n    ) -> Result<Uuid, sqlx::Error> {\n        let row = sqlx::query!(\n            r#\"\n            INSERT INTO hosts (\n                owner_user_id,\n                shared_with_organization_id,\n                machine_id,\n                name,\n                status,\n                agent_version\n            )\n            VALUES ($1, NULL, $2, $3, 'offline', $4)\n            ON CONFLICT (owner_user_id, machine_id) DO UPDATE\n                SET name = EXCLUDED.name,\n                    agent_version = COALESCE(EXCLUDED.agent_version, hosts.agent_version),\n                    updated_at = NOW()\n            RETURNING id AS \"id!: Uuid\"\n            \"#,\n            owner_user_id,\n            machine_id,\n            name,\n            agent_version\n        )\n        .fetch_one(self.pool)\n        .await?;\n\n        Ok(row.id)\n    }\n\n    pub async fn assert_host_access(\n        &self,\n        host_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<(), IdentityError> {\n        let row = sqlx::query!(\n            r#\"\n            SELECT EXISTS (\n                SELECT 1\n                FROM hosts h\n                LEFT JOIN organization_member_metadata om\n                    ON om.organization_id = h.shared_with_organization_id\n                    AND om.user_id = $2\n                WHERE h.id = $1\n                  AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL)\n            ) AS \"allowed!\"\n            \"#,\n            host_id,\n            user_id\n        )\n        .fetch_one(self.pool)\n        .await?;\n\n        if row.allowed {\n            Ok(())\n        } else {\n            Err(IdentityError::PermissionDenied)\n        }\n    }\n\n    pub async fn is_host_online(&self, host_id: Uuid) -> Result<bool, sqlx::Error> {\n        let row = sqlx::query!(r#\"SELECT status FROM hosts WHERE id = $1\"#, host_id)\n            .fetch_optional(self.pool)\n            .await?;\n        Ok(row.map(|r| r.status == \"online\").unwrap_or(false))\n    }\n\n    pub async fn get_session_for_requester(\n        &self,\n        session_id: Uuid,\n        request_user_id: Uuid,\n    ) -> Result<Option<RelaySession>, sqlx::Error> {\n        sqlx::query_as!(\n            RelaySession,\n            r#\"\n            SELECT\n                id              AS \"id!: Uuid\",\n                host_id         AS \"host_id!: Uuid\",\n                request_user_id AS \"request_user_id!: Uuid\",\n                state,\n                created_at,\n                expires_at,\n                claimed_at,\n                ended_at\n            FROM relay_sessions\n            WHERE id = $1 AND request_user_id = $2\n            \"#,\n            session_id,\n            request_user_id\n        )\n        .fetch_optional(self.pool)\n        .await\n    }\n\n    pub async fn mark_session_active(&self, session_id: Uuid) -> Result<RelaySession, sqlx::Error> {\n        sqlx::query_as!(\n            RelaySession,\n            r#\"\n            UPDATE relay_sessions\n            SET state = 'active',\n                claimed_at = COALESCE(claimed_at, NOW())\n            WHERE id = $1\n            RETURNING\n                id              AS \"id!: Uuid\",\n                host_id         AS \"host_id!: Uuid\",\n                request_user_id AS \"request_user_id!: Uuid\",\n                state,\n                created_at,\n                expires_at,\n                claimed_at,\n                ended_at\n            \"#,\n            session_id\n        )\n        .fetch_one(self.pool)\n        .await\n    }\n\n    pub async fn mark_session_expired(&self, session_id: Uuid) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            r#\"\n            UPDATE relay_sessions\n            SET state = 'expired',\n                ended_at = COALESCE(ended_at, NOW())\n            WHERE id = $1\n            \"#,\n            session_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn mark_host_online(\n        &self,\n        host_id: Uuid,\n        agent_version: Option<&str>,\n    ) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            r#\"\n            UPDATE hosts\n            SET status = 'online',\n                last_seen_at = NOW(),\n                agent_version = COALESCE($2, agent_version),\n                updated_at = NOW()\n            WHERE id = $1\n            \"#,\n            host_id,\n            agent_version\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn mark_host_offline(&self, host_id: Uuid) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            r#\"\n            UPDATE hosts\n            SET status = 'offline',\n                updated_at = NOW()\n            WHERE id = $1\n            \"#,\n            host_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/db/identity_errors.rs",
    "content": "use thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum IdentityError {\n    #[error(\"identity record not found\")]\n    NotFound,\n    #[error(\"permission denied\")]\n    PermissionDenied,\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/db/mod.rs",
    "content": "pub mod auth_sessions;\npub mod hosts;\npub mod identity_errors;\npub mod relay_auth_codes;\npub mod relay_browser_sessions;\npub mod users;\n\nuse sqlx::{PgPool, postgres::PgPoolOptions};\n\npub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {\n    PgPoolOptions::new()\n        .max_connections(10)\n        .connect(database_url)\n        .await\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/db/relay_auth_codes.rs",
    "content": "use chrono::{Duration, Utc};\nuse sha2::{Digest, Sha256};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\nconst RELAY_AUTH_CODE_TTL_SECS: i64 = 30;\n\npub struct RelayAuthCodeRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> RelayAuthCodeRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    /// Create a one-time relay auth code and return its plaintext value.\n    pub async fn create(\n        &self,\n        host_id: Uuid,\n        relay_cookie_value: &str,\n    ) -> Result<String, sqlx::Error> {\n        let code = Uuid::new_v4().to_string();\n        let code_hash = hash_code(&code);\n        let expires_at = Utc::now() + Duration::seconds(RELAY_AUTH_CODE_TTL_SECS);\n\n        sqlx::query!(\n            r#\"\n            INSERT INTO relay_auth_codes (code_hash, host_id, relay_cookie_value, expires_at)\n            VALUES ($1, $2, $3, $4)\n            \"#,\n            code_hash,\n            host_id,\n            relay_cookie_value,\n            expires_at\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(code)\n    }\n\n    /// Atomically redeem a code for the expected host.\n    pub async fn redeem_for_host(\n        &self,\n        code: &str,\n        expected_host_id: Uuid,\n    ) -> Result<Option<String>, sqlx::Error> {\n        let code_hash = hash_code(code);\n\n        let redeemed = sqlx::query!(\n            r#\"\n            UPDATE relay_auth_codes\n            SET consumed_at = NOW()\n            WHERE code_hash = $1\n              AND host_id = $2\n              AND consumed_at IS NULL\n              AND expires_at > NOW()\n            RETURNING relay_cookie_value\n            \"#,\n            code_hash,\n            expected_host_id\n        )\n        .fetch_optional(self.pool)\n        .await?;\n\n        Ok(redeemed.map(|row| row.relay_cookie_value))\n    }\n}\n\nfn hash_code(code: &str) -> String {\n    let digest = Sha256::digest(code.as_bytes());\n    format!(\"{:x}\", digest)\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/db/relay_browser_sessions.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct RelayBrowserSession {\n    pub id: Uuid,\n    pub host_id: Uuid,\n    pub user_id: Uuid,\n    pub auth_session_id: Uuid,\n    pub created_at: DateTime<Utc>,\n    pub last_used_at: Option<DateTime<Utc>>,\n    pub revoked_at: Option<DateTime<Utc>>,\n}\n\npub struct RelayBrowserSessionRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> RelayBrowserSessionRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn create(\n        &self,\n        host_id: Uuid,\n        user_id: Uuid,\n        auth_session_id: Uuid,\n    ) -> Result<RelayBrowserSession, sqlx::Error> {\n        sqlx::query_as!(\n            RelayBrowserSession,\n            r#\"\n            INSERT INTO relay_browser_sessions (host_id, user_id, auth_session_id)\n            VALUES ($1, $2, $3)\n            RETURNING\n                id              AS \"id!: Uuid\",\n                host_id         AS \"host_id!: Uuid\",\n                user_id         AS \"user_id!: Uuid\",\n                auth_session_id AS \"auth_session_id!: Uuid\",\n                created_at,\n                last_used_at,\n                revoked_at\n            \"#,\n            host_id,\n            user_id,\n            auth_session_id\n        )\n        .fetch_one(self.pool)\n        .await\n    }\n\n    pub async fn get(&self, session_id: Uuid) -> Result<Option<RelayBrowserSession>, sqlx::Error> {\n        sqlx::query_as!(\n            RelayBrowserSession,\n            r#\"\n            SELECT\n                id              AS \"id!: Uuid\",\n                host_id         AS \"host_id!: Uuid\",\n                user_id         AS \"user_id!: Uuid\",\n                auth_session_id AS \"auth_session_id!: Uuid\",\n                created_at,\n                last_used_at,\n                revoked_at\n            FROM relay_browser_sessions\n            WHERE id = $1\n            \"#,\n            session_id\n        )\n        .fetch_optional(self.pool)\n        .await\n    }\n\n    pub async fn touch(&self, session_id: Uuid) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            r#\"\n            UPDATE relay_browser_sessions\n            SET last_used_at = date_trunc('day', NOW())\n            WHERE id = $1\n              AND (\n                last_used_at IS NULL\n                OR last_used_at < date_trunc('day', NOW())\n              )\n            \"#,\n            session_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn revoke(&self, session_id: Uuid) -> Result<(), sqlx::Error> {\n        sqlx::query!(\n            r#\"\n            UPDATE relay_browser_sessions\n            SET revoked_at = NOW()\n            WHERE id = $1\n            \"#,\n            session_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/db/users.rs",
    "content": "use api_types::User;\nuse sqlx::{PgPool, query_as};\nuse uuid::Uuid;\n\nuse super::identity_errors::IdentityError;\n\npub struct UserRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> UserRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn fetch_user(&self, user_id: Uuid) -> Result<User, IdentityError> {\n        query_as!(\n            User,\n            r#\"\n            SELECT\n                id           AS \"id!: Uuid\",\n                email        AS \"email!\",\n                first_name   AS \"first_name?\",\n                last_name    AS \"last_name?\",\n                username     AS \"username?\",\n                created_at   AS \"created_at!\",\n                updated_at   AS \"updated_at!\"\n            FROM users\n            WHERE id = $1\n            \"#,\n            user_id\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(IdentityError::NotFound)\n    }\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/mod.rs",
    "content": "pub mod auth;\npub mod config;\npub mod db;\npub mod relay_registry;\npub mod routes;\npub mod state;\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/relay_registry.rs",
    "content": "//! In-memory relay registry for active tunnel connections.\n//!\n//! Each connected local server gets an `ActiveRelay` entry. The remote\n//! relay proxy looks up relays by host ID and opens yamux streams over\n//! the existing control connection. One-time auth codes are DB-backed.\n\nuse std::{collections::HashMap, sync::Arc};\n\nuse tokio::sync::Mutex;\nuse uuid::Uuid;\n\nuse crate::server::SharedControl;\n\n/// An active relay connection from a local server.\npub struct ActiveRelay {\n    /// Open yamux streams to the connected local host.\n    pub control: SharedControl,\n}\n\nimpl ActiveRelay {\n    pub fn new(control: SharedControl) -> Self {\n        Self { control }\n    }\n}\n\n/// Registry of all active relay connections, indexed by host ID.\n#[derive(Default, Clone)]\npub struct RelayRegistry {\n    inner: Arc<Mutex<HashMap<Uuid, Arc<ActiveRelay>>>>,\n}\n\nimpl RelayRegistry {\n    /// Register a relay for a host. Replaces any existing relay for that host.\n    pub async fn insert(&self, host_id: Uuid, relay: Arc<ActiveRelay>) {\n        self.inner.lock().await.insert(host_id, relay);\n    }\n\n    /// Remove the relay for a host.\n    pub async fn remove(&self, host_id: &Uuid) {\n        self.inner.lock().await.remove(host_id);\n    }\n\n    /// Remove the relay for a host only when it still matches the provided relay.\n    pub async fn remove_if_same(&self, host_id: &Uuid, relay: &Arc<ActiveRelay>) -> bool {\n        let mut relays = self.inner.lock().await;\n        if relays\n            .get(host_id)\n            .is_some_and(|current| Arc::ptr_eq(current, relay))\n        {\n            relays.remove(host_id);\n            true\n        } else {\n            false\n        }\n    }\n\n    /// Look up the active relay for a host.\n    pub async fn get(&self, host_id: &Uuid) -> Option<Arc<ActiveRelay>> {\n        self.inner.lock().await.get(host_id).cloned()\n    }\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/routes/auth_code.rs",
    "content": "//! Generate one-time auth codes for relay browser-session exchange.\n\nuse api_types::RelaySessionAuthCodeResponse;\nuse axum::{\n    Extension, Json,\n    extract::{Path, State},\n    http::StatusCode,\n    response::{IntoResponse, Response},\n};\nuse chrono::Utc;\nuse uuid::Uuid;\n\nuse super::super::{\n    auth::RequestContext,\n    db::{\n        hosts::HostRepository, relay_auth_codes::RelayAuthCodeRepository,\n        relay_browser_sessions::RelayBrowserSessionRepository,\n    },\n    state::RelayAppState,\n};\n\n/// Generate a one-time auth code for a relay browser-session exchange.\npub async fn relay_session_auth_code(\n    State(state): State<RelayAppState>,\n    Path(session_id): Path<Uuid>,\n    Extension(ctx): Extension<RequestContext>,\n) -> Result<Json<RelaySessionAuthCodeResponse>, Response> {\n    let repo = HostRepository::new(&state.pool);\n    let session = match repo\n        .get_session_for_requester(session_id, ctx.user.id)\n        .await\n    {\n        Ok(Some(session)) => session,\n        Ok(None) => return Err((StatusCode::NOT_FOUND, \"Relay session not found\").into_response()),\n        Err(error) => {\n            tracing::warn!(?error, \"failed to load relay session\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n    };\n\n    if session.ended_at.is_some() || session.state == \"expired\" {\n        return Err((StatusCode::GONE, \"Relay session expired\").into_response());\n    }\n\n    if session.expires_at <= Utc::now() {\n        if let Err(error) = repo.mark_session_expired(session.id).await {\n            tracing::warn!(?error, \"failed to mark relay session expired\");\n        }\n        return Err((StatusCode::GONE, \"Relay session expired\").into_response());\n    }\n\n    // Check in-memory registry — the relay-server knows exactly which hosts are connected\n    if state.relay_registry.get(&session.host_id).await.is_none() {\n        return Err((StatusCode::NOT_FOUND, \"Host is not connected\").into_response());\n    }\n\n    if let Err(error) = repo.mark_session_active(session.id).await {\n        tracing::warn!(?error, \"failed to mark relay session active\");\n        return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n    }\n\n    let relay_browser_session_repo = RelayBrowserSessionRepository::new(&state.pool);\n    let relay_browser_session = match relay_browser_session_repo\n        .create(session.host_id, ctx.user.id, ctx.session_id)\n        .await\n    {\n        Ok(session) => session,\n        Err(error) => {\n            tracing::warn!(?error, \"failed to create relay browser session\");\n            return Err((\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"Failed to generate auth code\",\n            )\n                .into_response());\n        }\n    };\n    let browser_session_id = relay_browser_session.id.to_string();\n    let auth_code_repo = RelayAuthCodeRepository::new(&state.pool);\n    let code = match auth_code_repo\n        .create(session.host_id, &browser_session_id)\n        .await\n    {\n        Ok(code) => code,\n        Err(error) => {\n            tracing::warn!(?error, \"failed to create relay auth code\");\n            return Err((\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"Failed to generate auth code\",\n            )\n                .into_response());\n        }\n    };\n\n    Ok(Json(RelaySessionAuthCodeResponse {\n        session_id: session.id,\n        code,\n    }))\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/routes/connect.rs",
    "content": "//! WebSocket control channel handler for local server connections.\n\nuse std::sync::Arc;\n\nuse axum::{\n    Extension,\n    extract::{Query, State, ws::WebSocketUpgrade},\n    http::StatusCode,\n    response::{IntoResponse, Response},\n};\nuse serde::Deserialize;\nuse uuid::Uuid;\n\nuse super::super::{\n    auth::RequestContext,\n    db::hosts::HostRepository,\n    relay_registry::{ActiveRelay, RelayRegistry},\n    state::RelayAppState,\n};\nuse crate::server::run_control_channel;\n\n#[derive(Debug, Deserialize)]\npub struct ConnectQuery {\n    pub machine_id: String,\n    pub name: String,\n    #[serde(default)]\n    pub agent_version: Option<String>,\n}\n\n/// Local server connects here to establish a relay control channel.\n/// The host record is upserted from the authenticated user + machine_id query param.\npub async fn relay_connect(\n    State(state): State<RelayAppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ConnectQuery>,\n    ws: WebSocketUpgrade,\n) -> Response {\n    let repo = HostRepository::new(&state.pool);\n\n    let host_id = match repo\n        .upsert_host(\n            ctx.user.id,\n            &query.machine_id,\n            &query.name,\n            query.agent_version.as_deref(),\n        )\n        .await\n    {\n        Ok(id) => id,\n        Err(error) => {\n            tracing::error!(?error, \"failed to upsert host for relay connect\");\n            return StatusCode::INTERNAL_SERVER_ERROR.into_response();\n        }\n    };\n\n    if let Err(error) = repo\n        .mark_host_online(host_id, query.agent_version.as_deref())\n        .await\n    {\n        tracing::warn!(?error, \"failed to mark host online\");\n    }\n\n    let registry = state.relay_registry.clone();\n    let pool = state.pool.clone();\n\n    ws.on_upgrade(move |socket| async move {\n        handle_control_channel(socket, pool, registry, host_id).await;\n    })\n}\n\nasync fn handle_control_channel(\n    socket: axum::extract::ws::WebSocket,\n    pool: sqlx::PgPool,\n    registry: RelayRegistry,\n    host_id: Uuid,\n) {\n    let registry_for_connect = registry.clone();\n    let connected_relay = Arc::new(tokio::sync::Mutex::new(None::<Arc<ActiveRelay>>));\n    let connected_relay_for_connect = connected_relay.clone();\n    let run_result = run_control_channel(socket, move |control| {\n        let registry_for_connect = registry_for_connect.clone();\n        let connected_relay_for_connect = connected_relay_for_connect.clone();\n        async move {\n            let relay = Arc::new(ActiveRelay::new(control));\n            registry_for_connect.insert(host_id, relay.clone()).await;\n            *connected_relay_for_connect.lock().await = Some(relay);\n            tracing::debug!(%host_id, \"Relay control channel connected\");\n        }\n    })\n    .await;\n\n    if let Err(error) = run_result {\n        tracing::warn!(?error, %host_id, \"relay session error\");\n    }\n\n    let should_mark_offline = if let Some(relay) = connected_relay.lock().await.clone() {\n        registry.remove_if_same(&host_id, &relay).await\n    } else {\n        registry.get(&host_id).await.is_none()\n    };\n\n    let repo = HostRepository::new(&pool);\n    if should_mark_offline {\n        if let Err(error) = repo.mark_host_offline(host_id).await {\n            tracing::warn!(?error, \"failed to mark host offline\");\n        }\n    } else {\n        tracing::debug!(\n            %host_id,\n            \"Relay control channel disconnected; keeping host online because a newer channel is active\"\n        );\n    }\n    tracing::debug!(%host_id, \"Relay control channel disconnected\");\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/routes/mod.rs",
    "content": "mod auth_code;\npub mod connect;\npub mod path_routes;\n\nuse axum::{\n    Router,\n    http::{HeaderName, StatusCode},\n    middleware,\n    response::IntoResponse,\n    routing::{any, get, post},\n};\nuse serde::Serialize;\nuse tower_http::{\n    cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer, ExposeHeaders},\n    trace::TraceLayer,\n};\n\nuse super::{auth, state::RelayAppState};\n\npub fn build_router(state: RelayAppState) -> Router {\n    let protected = Router::new()\n        .route(\"/relay/connect\", get(connect::relay_connect))\n        .route(\n            \"/relay/sessions/{session_id}/auth-code\",\n            post(auth_code::relay_session_auth_code),\n        )\n        .layer(middleware::from_fn_with_state(\n            state.clone(),\n            auth::require_session,\n        ));\n\n    let public = Router::new()\n        .route(\"/health\", get(health))\n        .route(\n            \"/relay/h/{host_id}/exchange\",\n            get(path_routes::relay_path_exchange),\n        )\n        .route(\n            \"/relay/h/{host_id}/s/{browser_session_id}\",\n            any(path_routes::relay_path_proxy),\n        )\n        .route(\n            \"/relay/h/{host_id}/s/{browser_session_id}/\",\n            any(path_routes::relay_path_proxy),\n        )\n        .route(\n            \"/relay/h/{host_id}/s/{browser_session_id}/{*tail}\",\n            any(path_routes::relay_path_proxy_with_tail),\n        );\n\n    Router::<RelayAppState>::new()\n        .nest(\"/v1\", protected)\n        .merge(public)\n        .layer(\n            CorsLayer::new()\n                .allow_origin(AllowOrigin::mirror_request())\n                .allow_methods(AllowMethods::mirror_request())\n                .allow_headers(AllowHeaders::mirror_request())\n                .expose_headers(ExposeHeaders::list([\n                    HeaderName::from_static(\"x-vk-resp-ts\"),\n                    HeaderName::from_static(\"x-vk-resp-nonce\"),\n                    HeaderName::from_static(\"x-vk-resp-signature\"),\n                ]))\n                .allow_credentials(true),\n        )\n        .layer(TraceLayer::new_for_http())\n        .with_state(state)\n}\n\n#[derive(Serialize)]\nstruct HealthResponse {\n    status: &'static str,\n}\n\nasync fn health() -> impl IntoResponse {\n    (StatusCode::OK, axum::Json(HealthResponse { status: \"ok\" }))\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/routes/path_routes.rs",
    "content": "//! Relay path handlers: auth code exchange and proxy.\n\nuse axum::{\n    body::Body,\n    extract::{Path, Query, Request, State},\n    http::StatusCode,\n    response::{IntoResponse, Response},\n};\nuse serde::Deserialize;\nuse uuid::Uuid;\n\nuse super::super::{\n    auth::request_context_from_auth_session_id,\n    db::{\n        hosts::HostRepository, identity_errors::IdentityError,\n        relay_auth_codes::RelayAuthCodeRepository,\n        relay_browser_sessions::RelayBrowserSessionRepository,\n    },\n    state::RelayAppState,\n};\nuse crate::server::proxy_request_over_control;\n\nconst RELAY_PROXY_PREFIX: &str = \"/relay/h\";\n\n#[derive(Debug, Deserialize)]\npub(super) struct RelayExchangeQuery {\n    code: String,\n}\n\n/// Handle `GET /relay/h/{host_id}/exchange?code=...`.\npub(super) async fn relay_path_exchange(\n    State(state): State<RelayAppState>,\n    Path(host_id): Path<Uuid>,\n    Query(params): Query<RelayExchangeQuery>,\n) -> Response {\n    let auth_code_repo = RelayAuthCodeRepository::new(&state.pool);\n    match auth_code_repo.redeem_for_host(&params.code, host_id).await {\n        Ok(Some(browser_session_id)) => {\n            let location = format!(\"{RELAY_PROXY_PREFIX}/{host_id}/s/{browser_session_id}\");\n\n            Response::builder()\n                .status(StatusCode::FOUND)\n                .header(\"location\", location)\n                .body(Body::empty())\n                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())\n        }\n        Ok(None) => (StatusCode::UNAUTHORIZED, \"Invalid or expired code\").into_response(),\n        Err(error) => {\n            tracing::warn!(?error, \"failed to redeem relay auth code\");\n            StatusCode::INTERNAL_SERVER_ERROR.into_response()\n        }\n    }\n}\n\n/// Handle `ANY /relay/h/{host_id}/s/{browser_session_id}`.\npub(super) async fn relay_path_proxy(\n    State(state): State<RelayAppState>,\n    Path((host_id, browser_session_id)): Path<(Uuid, Uuid)>,\n    request: Request,\n) -> Response {\n    if let Err(response) =\n        validate_browser_session_for_host(&state, browser_session_id, host_id).await\n    {\n        return response;\n    }\n\n    do_relay_proxy_for_host(&state, host_id, browser_session_id, request).await\n}\n\n/// Handle `ANY /relay/h/{host_id}/s/{browser_session_id}/{*tail}`.\npub(super) async fn relay_path_proxy_with_tail(\n    State(state): State<RelayAppState>,\n    Path((host_id, browser_session_id, _tail)): Path<(Uuid, Uuid, String)>,\n    request: Request,\n) -> Response {\n    if let Err(response) =\n        validate_browser_session_for_host(&state, browser_session_id, host_id).await\n    {\n        return response;\n    }\n\n    do_relay_proxy_for_host(&state, host_id, browser_session_id, request).await\n}\n\nasync fn validate_browser_session_for_host(\n    state: &RelayAppState,\n    relay_browser_session_id: Uuid,\n    expected_host_id: Uuid,\n) -> Result<(), Response> {\n    let relay_browser_session_repo = RelayBrowserSessionRepository::new(&state.pool);\n    let relay_browser_session = match relay_browser_session_repo\n        .get(relay_browser_session_id)\n        .await\n    {\n        Ok(Some(session)) => session,\n        Ok(None) => return Err(StatusCode::UNAUTHORIZED.into_response()),\n        Err(error) => {\n            tracing::warn!(?error, \"failed to load relay browser session\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n    };\n\n    if relay_browser_session.revoked_at.is_some() {\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    if relay_browser_session.host_id != expected_host_id {\n        return Err((StatusCode::FORBIDDEN, \"Host access denied\").into_response());\n    }\n\n    let ctx =\n        match request_context_from_auth_session_id(state, relay_browser_session.auth_session_id)\n            .await\n        {\n            Ok(ctx) => ctx,\n            Err(response) => {\n                if let Err(error) = relay_browser_session_repo\n                    .revoke(relay_browser_session.id)\n                    .await\n                {\n                    tracing::warn!(?error, \"failed to revoke relay browser session\");\n                }\n                return Err(response);\n            }\n        };\n\n    if ctx.user.id != relay_browser_session.user_id {\n        tracing::warn!(\n            relay_browser_session_user_id = %relay_browser_session.user_id,\n            auth_session_user_id = %ctx.user.id,\n            relay_browser_session_id = %relay_browser_session.id,\n            \"relay browser session user mismatch\"\n        );\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    let host_repo = HostRepository::new(&state.pool);\n    if let Err(error) = host_repo\n        .assert_host_access(expected_host_id, ctx.user.id)\n        .await\n    {\n        return Err(match error {\n            IdentityError::PermissionDenied | IdentityError::NotFound => {\n                (StatusCode::FORBIDDEN, \"Host access denied\").into_response()\n            }\n            IdentityError::Database(db_error) => {\n                tracing::warn!(?db_error, \"failed to validate host access\");\n                StatusCode::INTERNAL_SERVER_ERROR.into_response()\n            }\n        });\n    }\n\n    if let Err(error) = relay_browser_session_repo\n        .touch(relay_browser_session.id)\n        .await\n    {\n        tracing::debug!(\n            ?error,\n            relay_browser_session_id = %relay_browser_session.id,\n            \"failed to update relay browser session last-used timestamp\"\n        );\n    }\n\n    Ok(())\n}\n\nasync fn do_relay_proxy_for_host(\n    state: &RelayAppState,\n    host_id: Uuid,\n    browser_session_id: Uuid,\n    request: Request,\n) -> Response {\n    let relay = match state.relay_registry.get(&host_id).await {\n        Some(relay) => relay,\n        None => return (StatusCode::NOT_FOUND, \"No active relay\").into_response(),\n    };\n\n    let strip_prefix = format!(\"{RELAY_PROXY_PREFIX}/{host_id}/s/{browser_session_id}\");\n    proxy_request_over_control(relay.control.as_ref(), request, &strip_prefix).await\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/server_bin/state.rs",
    "content": "use std::sync::Arc;\n\nuse sqlx::PgPool;\n\nuse super::{auth::JwtService, config::RelayServerConfig, relay_registry::RelayRegistry};\n\n#[derive(Clone)]\npub struct RelayAppState {\n    pub pool: PgPool,\n    pub config: RelayServerConfig,\n    pub jwt: Arc<JwtService>,\n    pub relay_registry: RelayRegistry,\n}\n\nimpl RelayAppState {\n    pub fn new(pool: PgPool, config: RelayServerConfig, jwt: Arc<JwtService>) -> Self {\n        Self {\n            pool,\n            config,\n            jwt,\n            relay_registry: RelayRegistry::default(),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/tls.rs",
    "content": "use tokio_tungstenite::Connector;\n\n/// Build TLS connector for the relay WebSocket client.\n///\n/// In debug builds, returns a connector that accepts all certificates (equivalent\n/// to `danger_accept_invalid_certs`) so that Caddy's internal CA and other dev\n/// certs work. In release builds, returns `None` to use the default webpki-roots\n/// validation.\npub fn ws_connector() -> Option<Connector> {\n    #[cfg(debug_assertions)]\n    {\n        use std::sync::Arc;\n\n        let config = rustls::ClientConfig::builder()\n            .dangerous()\n            .with_custom_certificate_verifier(Arc::new(AcceptAllCerts))\n            .with_no_client_auth();\n        Some(Connector::Rustls(Arc::new(config)))\n    }\n\n    #[cfg(not(debug_assertions))]\n    {\n        None\n    }\n}\n\n#[cfg(debug_assertions)]\n#[derive(Debug)]\nstruct AcceptAllCerts;\n\n#[cfg(debug_assertions)]\nimpl rustls::client::danger::ServerCertVerifier for AcceptAllCerts {\n    fn verify_server_cert(\n        &self,\n        _end_entity: &rustls::pki_types::CertificateDer<'_>,\n        _intermediates: &[rustls::pki_types::CertificateDer<'_>],\n        _server_name: &rustls::pki_types::ServerName<'_>,\n        _ocsp_response: &[u8],\n        _now: rustls::pki_types::UnixTime,\n    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {\n        Ok(rustls::client::danger::ServerCertVerified::assertion())\n    }\n\n    fn verify_tls12_signature(\n        &self,\n        _message: &[u8],\n        _cert: &rustls::pki_types::CertificateDer<'_>,\n        _dss: &rustls::DigitallySignedStruct,\n    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {\n        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())\n    }\n\n    fn verify_tls13_signature(\n        &self,\n        _message: &[u8],\n        _cert: &rustls::pki_types::CertificateDer<'_>,\n        _dss: &rustls::DigitallySignedStruct,\n    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {\n        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())\n    }\n\n    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {\n        rustls::crypto::aws_lc_rs::default_provider()\n            .signature_verification_algorithms\n            .supported_schemes()\n    }\n}\n"
  },
  {
    "path": "crates/relay-tunnel/src/ws_io.rs",
    "content": "use std::{\n    io,\n    marker::PhantomData,\n    pin::Pin,\n    task::{Context, Poll, ready},\n};\n\nuse bytes::BytesMut;\nuse futures::{Sink, Stream};\nuse tokio::io::{AsyncRead, AsyncWrite, ReadBuf};\n\npub enum WsIoReadMessage {\n    Data(Vec<u8>),\n    Skip,\n    Eof,\n}\n\n/// Adapts a WebSocket message stream into an AsyncRead/AsyncWrite byte stream.\npub struct WsMessageStreamIo<S, M, FRead, FWrite> {\n    ws: S,\n    read_buf: BytesMut,\n    read_message: FRead,\n    write_message: FWrite,\n    _message: PhantomData<fn() -> M>,\n}\n\nimpl<S, M, FRead, FWrite> WsMessageStreamIo<S, M, FRead, FWrite> {\n    pub fn new(ws: S, read_message: FRead, write_message: FWrite) -> Self {\n        Self {\n            ws,\n            read_buf: BytesMut::new(),\n            read_message,\n            write_message,\n            _message: PhantomData,\n        }\n    }\n}\n\nimpl<S, M, E, FRead, FWrite> AsyncRead for WsMessageStreamIo<S, M, FRead, FWrite>\nwhere\n    S: Stream<Item = Result<M, E>> + Unpin,\n    E: std::fmt::Display,\n    FRead: Fn(M) -> WsIoReadMessage + Unpin,\n    FWrite: Fn(Vec<u8>) -> M + Unpin,\n{\n    fn poll_read(\n        mut self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &mut ReadBuf<'_>,\n    ) -> Poll<io::Result<()>> {\n        loop {\n            let this = self.as_mut().get_mut();\n\n            if !this.read_buf.is_empty() {\n                let n = buf.remaining().min(this.read_buf.len());\n                buf.put_slice(&this.read_buf.split_to(n));\n                return Poll::Ready(Ok(()));\n            }\n\n            let message = match ready!(Pin::new(&mut this.ws).poll_next(cx)) {\n                Some(Ok(message)) => message,\n                Some(Err(error)) => return Poll::Ready(Err(io::Error::other(error.to_string()))),\n                None => return Poll::Ready(Ok(())),\n            };\n\n            match (this.read_message)(message) {\n                WsIoReadMessage::Data(data) => this.read_buf.extend_from_slice(&data),\n                WsIoReadMessage::Skip => continue,\n                WsIoReadMessage::Eof => return Poll::Ready(Ok(())),\n            }\n        }\n    }\n}\n\nimpl<S, M, E, FRead, FWrite> AsyncWrite for WsMessageStreamIo<S, M, FRead, FWrite>\nwhere\n    S: Sink<M, Error = E> + Unpin,\n    E: std::fmt::Display,\n    FRead: Fn(M) -> WsIoReadMessage + Unpin,\n    FWrite: Fn(Vec<u8>) -> M + Unpin,\n{\n    fn poll_write(\n        mut self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &[u8],\n    ) -> Poll<io::Result<usize>> {\n        if buf.is_empty() {\n            return Poll::Ready(Ok(0));\n        }\n\n        let this = self.as_mut().get_mut();\n        ready!(Pin::new(&mut this.ws).poll_ready(cx))\n            .map_err(|error| io::Error::other(error.to_string()))?;\n        Pin::new(&mut this.ws)\n            .start_send((this.write_message)(buf.to_vec()))\n            .map_err(|error| io::Error::other(error.to_string()))?;\n\n        Poll::Ready(Ok(buf.len()))\n    }\n\n    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {\n        let this = self.as_mut().get_mut();\n        ready!(Pin::new(&mut this.ws).poll_flush(cx))\n            .map_err(|error| io::Error::other(error.to_string()))?;\n        Poll::Ready(Ok(()))\n    }\n\n    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {\n        let this = self.as_mut().get_mut();\n        ready!(Pin::new(&mut this.ws).poll_close(cx))\n            .map_err(|error| io::Error::other(error.to_string()))?;\n        Poll::Ready(Ok(()))\n    }\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-00f50fdb65f4126b197b523f6fc1870571c4c121c32e0c3393f6770fc3608e95.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id,\\n                organization_id,\\n                user_id,\\n                state_token,\\n                expires_at,\\n                created_at\\n            FROM github_app_pending_installations\\n            WHERE state_token = $1 AND expires_at > NOW()\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"state_token\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"expires_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"00f50fdb65f4126b197b523f6fc1870571c4c121c32e0c3393f6770fc3608e95\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-00f5a09dfd00355a8657007f6d7b3a2a98547db4acccd485cec20d8fd29815ad.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                owner_user_id       AS \\\"owner_user_id!: Uuid\\\",\\n                issue_id            AS \\\"issue_id: Uuid\\\",\\n                local_workspace_id  AS \\\"local_workspace_id: Uuid\\\",\\n                name                AS \\\"name: String\\\",\\n                archived            AS \\\"archived!: bool\\\",\\n                files_changed       AS \\\"files_changed: i32\\\",\\n                lines_added         AS \\\"lines_added: i32\\\",\\n                lines_removed       AS \\\"lines_removed: i32\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM workspaces\\n            WHERE project_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"issue_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"local_workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"name: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"archived!: bool\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"files_changed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"lines_added: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"lines_removed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"00f5a09dfd00355a8657007f6d7b3a2a98547db4acccd485cec20d8fd29815ad\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n                UPDATE organization_invitations\\n                SET status = 'expired'\\n                WHERE id = $1\\n                \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-082aaf51a023c8ccb44002ce48287acd8ef90b0f4c8338447c6e5370ca93390b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason)\\n            VALUES ($1, $2, 'token_rotation')\\n            ON CONFLICT (token_id) DO NOTHING\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"082aaf51a023c8ccb44002ce48287acd8ef90b0f4c8338447c6e5370ca93390b\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-08fa6f887e954e3b6921f84bbd412b4c3fc5dc1df0b9a5ea3fa4a4b07a86bb55.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT b.project_id\\n            FROM attachments a\\n            INNER JOIN blobs b ON b.id = a.blob_id\\n            WHERE a.id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"project_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"08fa6f887e954e3b6921f84bbd412b4c3fc5dc1df0b9a5ea3fa4a4b07a86bb55\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-0a57abb390861f8e9ce1da411934bef0a1a4edcea151cbf78fdf4cb510a0d450.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT p.organization_id\\n            FROM issues i\\n            INNER JOIN projects p ON p.id = i.project_id\\n            WHERE i.id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"0a57abb390861f8e9ce1da411934bef0a1a4edcea151cbf78fdf4cb510a0d450\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-0c5dfb11325fb2f0ea279c9406d593376bece575358831870012d125fd053be3.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                comment_id  AS \\\"comment_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                emoji       AS \\\"emoji!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM issue_comment_reactions\\n            WHERE comment_id IN (SELECT id FROM issue_comments WHERE issue_id = $1)\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"comment_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"emoji!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"0c5dfb11325fb2f0ea279c9406d593376bece575358831870012d125fd053be3\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-0df35d620c891a94f62e7e3f7afb60819783f961be1dd36cabb478c5e3ad23c0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issue_assignees (id, issue_id, user_id)\\n            VALUES ($1, $2, $3)\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                assigned_at AS \\\"assigned_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"assigned_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"0df35d620c891a94f62e7e3f7afb60819783f961be1dd36cabb478c5e3ad23c0\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-10428c897273798508759a89323d4fb181081eb5ffea40ef41a4d5437b7b6849.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                user_id  AS \\\"user_id!: Uuid\\\"\\n            FROM issue_followers\\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"10428c897273798508759a89323d4fb181081eb5ffea40ef41a4d5437b7b6849\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-11eede7c3a324ffa6266ee5c3fe3fdb2bd3b9e894fcabeece1e8d2201d18dcc6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE oauth_handoffs\\n            SET\\n                status = 'authorized',\\n                error_code = NULL,\\n                user_id = $2,\\n                session_id = $3,\\n                app_code_hash = $4,\\n                encrypted_provider_tokens = $5,\\n                authorized_at = NOW()\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"11eede7c3a324ffa6266ee5c3fe3fdb2bd3b9e894fcabeece1e8d2201d18dcc6\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-12eb8caf8044a790e7390882bc07d8c737581e0926d473b2e0a9eaccdd0a8674.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM attachments\\n            WHERE id = $1\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                blob_id     AS \\\"blob_id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id?: Uuid\\\",\\n                comment_id  AS \\\"comment_id?: Uuid\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                expires_at  AS \\\"expires_at?: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"12eb8caf8044a790e7390882bc07d8c737581e0926d473b2e0a9eaccdd0a8674\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1565680821f93069b2b5c109a7d1ba10889ca9b98c848895de6ef2c3ef4dffa0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                blob_id     AS \\\"blob_id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id?: Uuid\\\",\\n                comment_id  AS \\\"comment_id?: Uuid\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                expires_at  AS \\\"expires_at?: DateTime<Utc>\\\"\\n            FROM attachments\\n            WHERE expires_at IS NOT NULL AND expires_at < NOW()\\n            ORDER BY expires_at ASC\\n            LIMIT $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"1565680821f93069b2b5c109a7d1ba10889ca9b98c848895de6ef2c3ef4dffa0\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-16abe1e4d69bf90ed05d8651b688e3be23a74d8dd3957a976c7b757660d5b169.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM workspaces WHERE local_workspace_id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"16abe1e4d69bf90ed05d8651b688e3be23a74d8dd3957a976c7b757660d5b169\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT role AS \\\"role!: MemberRole\\\"\\n        FROM organization_member_metadata\\n        WHERE organization_id = $1 AND user_id = $2\\n        FOR UPDATE\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-185b7fc8f6f22b7c29950a490d46bb16c4fec50cf6e8dc988f3a2c942be909c0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO pull_requests (\\n                id, url, number, status, merged_at, merge_commit_sha,\\n                target_branch_name, issue_id, workspace_id\\n            )\\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                url                 AS \\\"url!: String\\\",\\n                number              AS \\\"number!: i32\\\",\\n                status              AS \\\"status!: PullRequestStatus\\\",\\n                merged_at           AS \\\"merged_at: DateTime<Utc>\\\",\\n                merge_commit_sha    AS \\\"merge_commit_sha: String\\\",\\n                target_branch_name  AS \\\"target_branch_name!: String\\\",\\n                issue_id            AS \\\"issue_id!: Uuid\\\",\\n                workspace_id        AS \\\"workspace_id: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"url!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"number!: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"status!: PullRequestStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"pull_request_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"open\",\n                \"merged\",\n                \"closed\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"merged_at: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"merge_commit_sha: String\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"target_branch_name!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Int4\",\n        {\n          \"Custom\": {\n            \"name\": \"pull_request_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"open\",\n                \"merged\",\n                \"closed\"\n              ]\n            }\n          }\n        },\n        \"Timestamptz\",\n        \"Varchar\",\n        \"Text\",\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"185b7fc8f6f22b7c29950a490d46bb16c4fec50cf6e8dc988f3a2c942be909c0\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-187b173294e46a013a48040ca4375b65df44215d8883cae88123f762880507e9.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO organizations (name, slug, issue_prefix)\\n            VALUES ($1, $2, $3)\\n            RETURNING\\n                id           AS \\\"id!: Uuid\\\",\\n                name         AS \\\"name!\\\",\\n                slug         AS \\\"slug!\\\",\\n                is_personal  AS \\\"is_personal!\\\",\\n                issue_prefix AS \\\"issue_prefix!\\\",\\n                created_at   AS \\\"created_at!\\\",\\n                updated_at   AS \\\"updated_at!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"slug!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"is_personal!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"issue_prefix!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\",\n        \"Varchar\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"187b173294e46a013a48040ca4375b65df44215d8883cae88123f762880507e9\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-18ae849cdeff678538d5bd6782e16780da9db40e4d892a75d7d244f247db5c04.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE reviews\\n            SET status = 'failed'\\n            WHERE id = $1 AND deleted_at IS NULL\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"18ae849cdeff678538d5bd6782e16780da9db40e4d892a75d7d244f247db5c04\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-18f2fc4074de23b6b2a0c2c70403d6a1eaa57e1fda5063d9f3a292e8aab61ede.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                owner_user_id       AS \\\"owner_user_id!: Uuid\\\",\\n                issue_id            AS \\\"issue_id: Uuid\\\",\\n                local_workspace_id  AS \\\"local_workspace_id: Uuid\\\",\\n                name                AS \\\"name: String\\\",\\n                archived            AS \\\"archived!: bool\\\",\\n                files_changed       AS \\\"files_changed: i32\\\",\\n                lines_added         AS \\\"lines_added: i32\\\",\\n                lines_removed       AS \\\"lines_removed: i32\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM workspaces\\n            WHERE owner_user_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"issue_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"local_workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"name: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"archived!: bool\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"files_changed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"lines_added: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"lines_removed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"18f2fc4074de23b6b2a0c2c70403d6a1eaa57e1fda5063d9f3a292e8aab61ede\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-198df1da04fb3ffee213718de87fa49d5032545d55d45a7cb0c62dcc60db5f78.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                AS \\\"id!: Uuid\\\",\\n                issue_id          AS \\\"issue_id!: Uuid\\\",\\n                related_issue_id  AS \\\"related_issue_id!: Uuid\\\",\\n                relationship_type AS \\\"relationship_type!: IssueRelationshipType\\\",\\n                created_at        AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM issue_relationships\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"related_issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"relationship_type!: IssueRelationshipType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_relationship_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"blocking\",\n                \"related\",\n                \"has_duplicate\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"198df1da04fb3ffee213718de87fa49d5032545d55d45a7cb0c62dcc60db5f78\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1b2c4d4205244ed0fa457ebc3b42147c9446a7efb5e205cd85aa780f99824b88.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                a.id                    AS \\\"id!: Uuid\\\",\\n                a.blob_id               AS \\\"blob_id!: Uuid\\\",\\n                a.issue_id              AS \\\"issue_id?: Uuid\\\",\\n                a.comment_id            AS \\\"comment_id?: Uuid\\\",\\n                a.created_at            AS \\\"created_at!: DateTime<Utc>\\\",\\n                a.expires_at            AS \\\"expires_at?: DateTime<Utc>\\\",\\n                b.blob_path             AS \\\"blob_path!\\\",\\n                b.thumbnail_blob_path   AS \\\"thumbnail_blob_path?\\\",\\n                b.original_name         AS \\\"original_name!\\\",\\n                b.mime_type             AS \\\"mime_type?\\\",\\n                b.size_bytes            AS \\\"size_bytes!\\\",\\n                b.hash                  AS \\\"hash!\\\",\\n                b.width                 AS \\\"width?\\\",\\n                b.height                AS \\\"height?\\\"\\n            FROM attachments a\\n            INNER JOIN blobs b ON b.id = a.blob_id\\n            WHERE a.issue_id = $1\\n            ORDER BY a.created_at ASC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"1b2c4d4205244ed0fa457ebc3b42147c9446a7efb5e205cd85aa780f99824b88\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT EXISTS(\\n            SELECT 1\\n            FROM organization_member_metadata\\n            WHERE organization_id = $1 AND user_id = $2\\n        ) AS \\\"exists!\\\"\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM workspaces WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1c57e525a7060361832601f158977fffec60c36534ec8eb9affbdf648c280334.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO relay_sessions (host_id, request_user_id, state, expires_at)\\n            VALUES ($1, $2, 'requested', $3)\\n            RETURNING\\n                id              AS \\\"id!: Uuid\\\",\\n                host_id         AS \\\"host_id!: Uuid\\\",\\n                request_user_id AS \\\"request_user_id!: Uuid\\\",\\n                state,\\n                created_at,\\n                expires_at,\\n                claimed_at,\\n                ended_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"host_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"request_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"state\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"claimed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"ended_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"1c57e525a7060361832601f158977fffec60c36534ec8eb9affbdf648c280334\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1d612faf67c945cfe22cfd7ab6b6d360fbce8dceb7b64c4d17b4df108434c822.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE notifications\\n            SET seen = COALESCE($1, seen),\\n                dismissed_at = CASE\\n                    WHEN $1 = true AND dismissed_at IS NULL THEN NOW()\\n                    ELSE dismissed_at\\n                END\\n            WHERE id = $2\\n            RETURNING\\n                id,\\n                organization_id,\\n                user_id,\\n                notification_type as \\\"notification_type!: NotificationType\\\",\\n                payload as \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                issue_id,\\n                comment_id,\\n                seen,\\n                dismissed_at,\\n                created_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"notification_type!: NotificationType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"payload!: sqlx::types::Json<NotificationPayload>\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"issue_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"seen\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"dismissed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Bool\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"1d612faf67c945cfe22cfd7ab6b6d360fbce8dceb7b64c4d17b4df108434c822\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1d6f13e86897b0885ac3caa36bd56a8685e137a5e22545776b16a5814f225211.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issue_followers (id, issue_id, user_id)\\n            VALUES ($1, $2, $3)\\n            RETURNING\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                user_id  AS \\\"user_id!: Uuid\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"1d6f13e86897b0885ac3caa36bd56a8685e137a5e22545776b16a5814f225211\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1e2aef04b2d7b1ece13c96ac1dd7718d59c6e8f3dbf0606789fc9f664ac33332.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE project_statuses\\n            SET\\n                name = COALESCE($1, name),\\n                color = COALESCE($2, color),\\n                sort_order = COALESCE($3, sort_order),\\n                hidden = COALESCE($4, hidden)\\n            WHERE id = $5\\n            RETURNING\\n                id              AS \\\"id!: Uuid\\\",\\n                project_id      AS \\\"project_id!: Uuid\\\",\\n                name            AS \\\"name!\\\",\\n                color           AS \\\"color!\\\",\\n                sort_order      AS \\\"sort_order!\\\",\\n                hidden          AS \\\"hidden!\\\",\\n                created_at      AS \\\"created_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"hidden!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Varchar\",\n        \"Varchar\",\n        \"Int4\",\n        \"Bool\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"1e2aef04b2d7b1ece13c96ac1dd7718d59c6e8f3dbf0606789fc9f664ac33332\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-1ea6478b8325ce0313727f756715c988d0c03ccb74a87e67325c73c03a5dcc33.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id,\\n                organization_id,\\n                user_id,\\n                notification_type as \\\"notification_type!: NotificationType\\\",\\n                payload as \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                issue_id,\\n                comment_id,\\n                seen,\\n                dismissed_at,\\n                created_at\\n            FROM notifications\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"notification_type!: NotificationType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"payload!: sqlx::types::Json<NotificationPayload>\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"issue_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"seen\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"dismissed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"1ea6478b8325ce0313727f756715c988d0c03ccb74a87e67325c73c03a5dcc33\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-209f1b560de8e99de312394860b42251b0272fc7f8f57ea50c9a16fb026b5ae4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM issue_assignees WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"209f1b560de8e99de312394860b42251b0272fc7f8f57ea50c9a16fb026b5ae4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-2129f33c4fdf5d1bf52cfac30238e36ffacaab20fb2cf4111fa70ba4e5aa1bca.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE github_app_repositories\\n            SET review_enabled = $3\\n            WHERE id = $1 AND installation_id = $2\\n            RETURNING\\n                id,\\n                installation_id,\\n                github_repo_id,\\n                repo_full_name,\\n                review_enabled,\\n                created_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"installation_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"github_repo_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"repo_full_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"review_enabled\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Bool\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"2129f33c4fdf5d1bf52cfac30238e36ffacaab20fb2cf4111fa70ba4e5aa1bca\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-253ed3e27e9c1798ecadf943e621bf2993ffdf2267e2582679656ccde7a33c67.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                user_id  AS \\\"user_id!: Uuid\\\"\\n            FROM issue_followers\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"253ed3e27e9c1798ecadf943e621bf2993ffdf2267e2582679656ccde7a33c67\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-28f6198cfd9c7a01e437a72e5cb3e076f5183a457cb6389cb56d047c1dcce439.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM issue_comment_reactions WHERE \\\"comment_id\\\" IN (SELECT id FROM issue_comments WHERE \\\"issue_id\\\" = $1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"28f6198cfd9c7a01e437a72e5cb3e076f5183a457cb6389cb56d047c1dcce439\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-2aa7a0c029cf5fde56e413c13af502a0656051e41e1d036805cc427514c37337.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issue_tags (id, issue_id, tag_id)\\n            VALUES ($1, $2, $3)\\n            RETURNING\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                tag_id   AS \\\"tag_id!: Uuid\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"tag_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"2aa7a0c029cf5fde56e413c13af502a0656051e41e1d036805cc427514c37337\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT EXISTS (\\n                SELECT 1\\n                FROM hosts h\\n                LEFT JOIN organization_member_metadata om\\n                    ON om.organization_id = h.shared_with_organization_id\\n                    AND om.user_id = $2\\n                WHERE h.id = $1\\n                  AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL)\\n            ) AS \\\"allowed!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"allowed!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-2b3d84d8febea88a7957efbfd0ca68ee279bc57c6a60afecf9073f46445163a2.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                owner_user_id       AS \\\"owner_user_id!: Uuid\\\",\\n                issue_id            AS \\\"issue_id: Uuid\\\",\\n                local_workspace_id  AS \\\"local_workspace_id: Uuid\\\",\\n                name                AS \\\"name: String\\\",\\n                archived            AS \\\"archived!: bool\\\",\\n                files_changed       AS \\\"files_changed: i32\\\",\\n                lines_added         AS \\\"lines_added: i32\\\",\\n                lines_removed       AS \\\"lines_removed: i32\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM workspaces\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"issue_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"local_workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"name: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"archived!: bool\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"files_changed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"lines_added: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"lines_removed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"2b3d84d8febea88a7957efbfd0ca68ee279bc57c6a60afecf9073f46445163a2\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-2d85b3d08704ce8475a15a7c8d10a5c1afd97f8ae8e126d26844735f7449fb19.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM tags WHERE \\\"project_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"2d85b3d08704ce8475a15a7c8d10a5c1afd97f8ae8e126d26844735f7449fb19\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-2db4c808f8d1f22c6209027007ebeb2bd58580758abf8996797b5338d793f741.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE workspaces SET\\n                name = CASE WHEN $1 THEN $2 ELSE name END,\\n                archived = CASE WHEN $3 THEN $4 ELSE archived END,\\n                files_changed = CASE WHEN $5 THEN $6 ELSE files_changed END,\\n                lines_added = CASE WHEN $7 THEN $8 ELSE lines_added END,\\n                lines_removed = CASE WHEN $9 THEN $10 ELSE lines_removed END,\\n                updated_at = NOW()\\n            WHERE id = $11\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                owner_user_id       AS \\\"owner_user_id!: Uuid\\\",\\n                issue_id            AS \\\"issue_id: Uuid\\\",\\n                local_workspace_id  AS \\\"local_workspace_id: Uuid\\\",\\n                name                AS \\\"name: String\\\",\\n                archived            AS \\\"archived!: bool\\\",\\n                files_changed       AS \\\"files_changed: i32\\\",\\n                lines_added         AS \\\"lines_added: i32\\\",\\n                lines_removed       AS \\\"lines_removed: i32\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"issue_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"local_workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"name: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"archived!: bool\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"files_changed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"lines_added: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"lines_removed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Bool\",\n        \"Text\",\n        \"Bool\",\n        \"Bool\",\n        \"Bool\",\n        \"Int4\",\n        \"Bool\",\n        \"Int4\",\n        \"Bool\",\n        \"Int4\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"2db4c808f8d1f22c6209027007ebeb2bd58580758abf8996797b5338d793f741\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-2f3898ec50ee1386f87786c605069aac78d5177feaabd719b60e54f94f5f535e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE auth_sessions\\n            SET refresh_token_id = $3,\\n                refresh_token_issued_at = NOW()\\n            WHERE id = $1\\n              AND refresh_token_id = $2\\n            RETURNING user_id\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"2f3898ec50ee1386f87786c605069aac78d5177feaabd719b60e54f94f5f535e\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-301e398b03c6e376d3ebd8dc9373f5724ae535e773588ab75baa29468a495ef4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT COUNT(*) AS \\\"count!\\\" FROM workspaces WHERE issue_id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"count!\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"301e398b03c6e376d3ebd8dc9373f5724ae535e773588ab75baa29468a495ef4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-31c99a55082ff59e212e1fe5425b695030dcf4cfe029ffbb1b56813106a563dc.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                AS \\\"id!: Uuid\\\",\\n                user_id           AS \\\"user_id!: Uuid\\\",\\n                provider          AS \\\"provider!\\\",\\n                provider_user_id  AS \\\"provider_user_id!\\\",\\n                email             AS \\\"email?\\\",\\n                username          AS \\\"username?\\\",\\n                display_name      AS \\\"display_name?\\\",\\n                avatar_url        AS \\\"avatar_url?\\\",\\n                encrypted_provider_tokens AS \\\"encrypted_provider_tokens?\\\",\\n                created_at        AS \\\"created_at!\\\",\\n                updated_at        AS \\\"updated_at!\\\"\\n            FROM oauth_accounts\\n            WHERE provider = $1\\n              AND provider_user_id = $2\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"provider!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"provider_user_id!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"email?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"display_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"avatar_url?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"encrypted_provider_tokens?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"31c99a55082ff59e212e1fe5425b695030dcf4cfe029ffbb1b56813106a563dc\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-32388086083c01d21b0d4d052519a08002b82751e45aa59b3ac628cc96be2723.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM notifications WHERE \\\"user_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"32388086083c01d21b0d4d052519a08002b82751e45aa59b3ac628cc96be2723\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-3239a6b54374bfba7c1ee16f151333563e21af8994d0431acf029e6a2ca08bfd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id,\\n                installation_id,\\n                github_repo_id,\\n                repo_full_name,\\n                review_enabled,\\n                created_at\\n            FROM github_app_repositories\\n            WHERE installation_id = $1\\n            ORDER BY repo_full_name\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"installation_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"github_repo_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"repo_full_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"review_enabled\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"3239a6b54374bfba7c1ee16f151333563e21af8994d0431acf029e6a2ca08bfd\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-3690a7ea5e1250ceca638bad754a77df36031d8ca132402cc9256f71a57fa476.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at)\\n            SELECT gen_random_uuid(), $1, name, color, sort_order, hidden, NOW()\\n            FROM UNNEST($2::text[], $3::text[], $4::int[], $5::bool[]) AS t(name, color, sort_order, hidden)\\n            RETURNING\\n                id              AS \\\"id!: Uuid\\\",\\n                project_id      AS \\\"project_id!: Uuid\\\",\\n                name            AS \\\"name!\\\",\\n                color           AS \\\"color!\\\",\\n                sort_order      AS \\\"sort_order!\\\",\\n                hidden          AS \\\"hidden!\\\",\\n                created_at      AS \\\"created_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"hidden!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"TextArray\",\n        \"TextArray\",\n        \"Int4Array\",\n        \"BoolArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"3690a7ea5e1250ceca638bad754a77df36031d8ca132402cc9256f71a57fa476\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-37af75dde5f977838d59b57729e9a238d2d2def278d376adc1d4c1d038a918cf.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO projects (\\n                id, organization_id, name, color, sort_order,\\n                created_at, updated_at\\n            )\\n            VALUES (\\n                $1,\\n                $2,\\n                $3,\\n                $4,\\n                COALESCE(\\n                    (SELECT MAX(sort_order) + 1 FROM projects WHERE organization_id = $2),\\n                    0\\n                ),\\n                $5,\\n                $6\\n            )\\n            RETURNING\\n                id               AS \\\"id!: Uuid\\\",\\n                organization_id  AS \\\"organization_id!: Uuid\\\",\\n                name             AS \\\"name!\\\",\\n                color            AS \\\"color!\\\",\\n                sort_order       AS \\\"sort_order!\\\",\\n                created_at       AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at       AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Varchar\",\n        \"Timestamptz\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"37af75dde5f977838d59b57729e9a238d2d2def278d376adc1d4c1d038a918cf\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-389b412ed9b76973a5b1546a24167e0b752467405f024de73101b6c12e1e05f1.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT EXISTS(\\n                SELECT 1 FROM revoked_refresh_tokens WHERE token_id = $1\\n            ) as is_revoked\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"is_revoked\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"389b412ed9b76973a5b1546a24167e0b752467405f024de73101b6c12e1e05f1\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-3e682d961f272a5c1ce20366008889156928c87babc1704d3277ff9a1812193c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                AS \\\"id!: Uuid\\\",\\n                user_id           AS \\\"user_id!: Uuid\\\",\\n                provider          AS \\\"provider!\\\",\\n                provider_user_id  AS \\\"provider_user_id!\\\",\\n                email             AS \\\"email?\\\",\\n                username          AS \\\"username?\\\",\\n                display_name      AS \\\"display_name?\\\",\\n                avatar_url        AS \\\"avatar_url?\\\",\\n                encrypted_provider_tokens AS \\\"encrypted_provider_tokens?\\\",\\n                created_at        AS \\\"created_at!\\\",\\n                updated_at        AS \\\"updated_at!\\\"\\n            FROM oauth_accounts\\n            WHERE user_id = $1\\n            ORDER BY provider\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"provider!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"provider_user_id!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"email?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"display_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"avatar_url?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"encrypted_provider_tokens?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"3e682d961f272a5c1ce20366008889156928c87babc1704d3277ff9a1812193c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-3ef67cb768d55e4aa8d551401be7daa6c8a9c76a4218ce776d87db6e6d1c890c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at)\\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\\n            RETURNING\\n                id,\\n                organization_id,\\n                user_id,\\n                notification_type as \\\"notification_type!: NotificationType\\\",\\n                payload as \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                issue_id,\\n                comment_id,\\n                seen,\\n                dismissed_at,\\n                created_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"notification_type!: NotificationType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"payload!: sqlx::types::Json<NotificationPayload>\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"issue_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"seen\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"dismissed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        },\n        \"Jsonb\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"3ef67cb768d55e4aa8d551401be7daa6c8a9c76a4218ce776d87db6e6d1c890c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-40c9618c70aae933513bd931a3baace6830d78daacfcbd7af69e4f76a234d01c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id,\\n                organization_id,\\n                github_installation_id,\\n                github_account_login,\\n                github_account_type,\\n                repository_selection,\\n                installed_by_user_id,\\n                suspended_at,\\n                created_at,\\n                updated_at\\n            FROM github_app_installations\\n            WHERE github_account_login = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"github_installation_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"github_account_login\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"github_account_type\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"repository_selection\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"installed_by_user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"suspended_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"40c9618c70aae933513bd931a3baace6830d78daacfcbd7af69e4f76a234d01c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-421ed23a0bff456c54a14068ceed214fa64d0c50e432fcfe40c222991341bf68.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM issue_comments WHERE \\\"issue_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"421ed23a0bff456c54a14068ceed214fa64d0c50e432fcfe40c222991341bf68\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE auth_sessions\\n            SET revoked_at = NOW()\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-426eb8216286273dd0066a15ce4508d9fed04d2feccfff81abb4813ebfea9778.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issue_relationships (id, issue_id, related_issue_id, relationship_type)\\n            VALUES ($1, $2, $3, $4)\\n            RETURNING\\n                id                AS \\\"id!: Uuid\\\",\\n                issue_id          AS \\\"issue_id!: Uuid\\\",\\n                related_issue_id  AS \\\"related_issue_id!: Uuid\\\",\\n                relationship_type AS \\\"relationship_type!: IssueRelationshipType\\\",\\n                created_at        AS \\\"created_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"related_issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"relationship_type!: IssueRelationshipType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_relationship_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"blocking\",\n                \"related\",\n                \"has_duplicate\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        {\n          \"Custom\": {\n            \"name\": \"issue_relationship_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"blocking\",\n                \"related\",\n                \"has_duplicate\"\n              ]\n            }\n          }\n        }\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"426eb8216286273dd0066a15ce4508d9fed04d2feccfff81abb4813ebfea9778\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4274624ba6445ad370380230898232b12365b2336e235b045b1ad25c958c902d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT organization_id FROM projects WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"4274624ba6445ad370380230898232b12365b2336e235b045b1ad25c958c902d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4411b08341b6b5516505ef4d218e0e46cebe76085e49f4cb88fcdc40816d1228.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM issue_assignees WHERE \\\"issue_id\\\" IN (SELECT id FROM issues WHERE \\\"project_id\\\" = $1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"4411b08341b6b5516505ef4d218e0e46cebe76085e49f4cb88fcdc40816d1228\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4447c24a9150eb78d81edc26a441a50ee50b8523c92bfe3ccc82b09518608204.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO reviews (id, gh_pr_url, r2_path, pr_title, github_installation_id, pr_owner, pr_repo, pr_number)\\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\\n            RETURNING\\n                id,\\n                gh_pr_url,\\n                claude_code_session_id,\\n                ip_address AS \\\"ip_address: IpNetwork\\\",\\n                review_cache,\\n                last_viewed_at,\\n                r2_path,\\n                deleted_at,\\n                created_at,\\n                email,\\n                pr_title,\\n                status,\\n                github_installation_id,\\n                pr_owner,\\n                pr_repo,\\n                pr_number\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"gh_pr_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"claude_code_session_id\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"ip_address: IpNetwork\",\n        \"type_info\": \"Inet\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"review_cache\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"last_viewed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"r2_path\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"pr_title\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"status\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"github_installation_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"pr_owner\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"pr_repo\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"pr_number\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Int8\",\n        \"Text\",\n        \"Text\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"4447c24a9150eb78d81edc26a441a50ee50b8523c92bfe3ccc82b09518608204\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-45010d9fa4bde72535c3f23f06b7aa9dbf01cf287159476852e5f87496d94ea4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT is_personal FROM organizations WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"is_personal\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"45010d9fa4bde72535c3f23f06b7aa9dbf01cf287159476852e5f87496d94ea4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4508b7a46677e8da7a397979a22c1a3e1160c7407b94d7baa84d6a3cdc5667c5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT COUNT(*) as \\\"count!\\\"\\n            FROM reviews\\n            WHERE ip_address = $1\\n              AND created_at > $2\\n              AND deleted_at IS NULL\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"count!\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Inet\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"4508b7a46677e8da7a397979a22c1a3e1160c7407b94d7baa84d6a3cdc5667c5\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4593ea3d9f66cb2618bf444ddbab1e8f2b790471f32aaf192e93f9226fc042bc.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT p.organization_id\\n            FROM blobs b\\n            INNER JOIN projects p ON p.id = b.project_id\\n            WHERE b.id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"4593ea3d9f66cb2618bf444ddbab1e8f2b790471f32aaf192e93f9226fc042bc\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-471944787bb9b58a1b30628f28ab8088f60bf3390bfaddbae993e87df89b8844.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id,\\n                organization_id,\\n                github_installation_id,\\n                github_account_login,\\n                github_account_type,\\n                repository_selection,\\n                installed_by_user_id,\\n                suspended_at,\\n                created_at,\\n                updated_at\\n            FROM github_app_installations\\n            WHERE github_installation_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"github_installation_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"github_account_login\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"github_account_type\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"repository_selection\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"installed_by_user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"suspended_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"471944787bb9b58a1b30628f28ab8088f60bf3390bfaddbae993e87df89b8844\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-47c186223fb7e3c66fff44c1029cf04fb872064a1d8c14bf7d76a841cfe904a6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM blobs\\n            WHERE id = $1\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                blob_path           AS \\\"blob_path!\\\",\\n                thumbnail_blob_path AS \\\"thumbnail_blob_path?\\\",\\n                original_name       AS \\\"original_name!\\\",\\n                mime_type           AS \\\"mime_type?\\\",\\n                size_bytes          AS \\\"size_bytes!\\\",\\n                hash                AS \\\"hash!\\\",\\n                width               AS \\\"width?\\\",\\n                height              AS \\\"height?\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"47c186223fb7e3c66fff44c1029cf04fb872064a1d8c14bf7d76a841cfe904a6\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4815234c108e45d450f433e5daca76218abdb441b9475ba916a39ab9e1341030.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE issues\\n            SET\\n                status_id = COALESCE($1, status_id),\\n                title = COALESCE($2, title),\\n                description = CASE WHEN $3 THEN $4 ELSE description END,\\n                priority = CASE WHEN $5 THEN $6 ELSE priority END,\\n                start_date = CASE WHEN $7 THEN $8 ELSE start_date END,\\n                target_date = CASE WHEN $9 THEN $10 ELSE target_date END,\\n                completed_at = CASE WHEN $11 THEN $12 ELSE completed_at END,\\n                sort_order = COALESCE($13, sort_order),\\n                parent_issue_id = CASE WHEN $14 THEN $15 ELSE parent_issue_id END,\\n                parent_issue_sort_order = CASE WHEN $16 THEN $17 ELSE parent_issue_sort_order END,\\n                extension_metadata = COALESCE($18, extension_metadata),\\n                updated_at = NOW()\\n            WHERE id = $19\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                issue_number        AS \\\"issue_number!\\\",\\n                simple_id           AS \\\"simple_id!\\\",\\n                status_id           AS \\\"status_id!: Uuid\\\",\\n                title               AS \\\"title!\\\",\\n                description         AS \\\"description?\\\",\\n                priority            AS \\\"priority: IssuePriority\\\",\\n                start_date          AS \\\"start_date?: DateTime<Utc>\\\",\\n                target_date         AS \\\"target_date?: DateTime<Utc>\\\",\\n                completed_at        AS \\\"completed_at?: DateTime<Utc>\\\",\\n                sort_order          AS \\\"sort_order!\\\",\\n                parent_issue_id     AS \\\"parent_issue_id?: Uuid\\\",\\n                parent_issue_sort_order AS \\\"parent_issue_sort_order?\\\",\\n                extension_metadata  AS \\\"extension_metadata!: Value\\\",\\n                creator_user_id     AS \\\"creator_user_id?: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_number!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"simple_id!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"status_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"title!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"description?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"priority: IssuePriority\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"start_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"target_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"parent_issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"parent_issue_sort_order?\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"extension_metadata!: Value\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"creator_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 16,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 17,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Varchar\",\n        \"Bool\",\n        \"Text\",\n        \"Bool\",\n        {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        },\n        \"Bool\",\n        \"Timestamptz\",\n        \"Bool\",\n        \"Timestamptz\",\n        \"Bool\",\n        \"Timestamptz\",\n        \"Float8\",\n        \"Bool\",\n        \"Uuid\",\n        \"Bool\",\n        \"Float8\",\n        \"Jsonb\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4815234c108e45d450f433e5daca76218abdb441b9475ba916a39ab9e1341030\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4b27e9774d71a851edc8c042e682037a35bd4cdffe22f3a13e1730f0d6712485.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT\\n            id           AS \\\"id!: Uuid\\\",\\n            name         AS \\\"name!\\\",\\n            slug         AS \\\"slug!\\\",\\n            is_personal  AS \\\"is_personal!\\\",\\n            issue_prefix AS \\\"issue_prefix!\\\",\\n            created_at   AS \\\"created_at!\\\",\\n            updated_at   AS \\\"updated_at!\\\"\\n        FROM organizations\\n        WHERE slug = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"slug!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"is_personal!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"issue_prefix!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4b27e9774d71a851edc8c042e682037a35bd4cdffe22f3a13e1730f0d6712485\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4cc8dc5f57a8398ef28942eab072784543333eac379d78c5843ca0c2203b69f5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO pending_uploads (project_id, blob_path, hash, expires_at)\\n            VALUES ($1, $2, $3, $4)\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                blob_path   AS \\\"blob_path!\\\",\\n                hash        AS \\\"hash!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                expires_at  AS \\\"expires_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4cc8dc5f57a8398ef28942eab072784543333eac379d78c5843ca0c2203b69f5\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4d963a12190ee1db657446ef451c5364f8f91153f7f1bb4e5abfd3f3ddbe0461.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO auth_sessions (user_id, refresh_token_id)\\n            VALUES ($1, $2)\\n            RETURNING\\n                id                          AS \\\"id!\\\",\\n                user_id                     AS \\\"user_id!: Uuid\\\",\\n                created_at                  AS \\\"created_at!\\\",\\n                last_used_at                AS \\\"last_used_at?\\\",\\n                revoked_at                  AS \\\"revoked_at?\\\",\\n                refresh_token_id           AS \\\"refresh_token_id?\\\",\\n                refresh_token_issued_at     AS \\\"refresh_token_issued_at?\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_used_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"revoked_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"refresh_token_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"refresh_token_issued_at?\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"4d963a12190ee1db657446ef451c5364f8f91153f7f1bb4e5abfd3f3ddbe0461\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4decb0554367c10f06a45f14291e5ba2a3e16aaf63bf1c34c2e8bc0c249fe4dd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT review_enabled\\n            FROM github_app_repositories\\n            WHERE installation_id = $1 AND github_repo_id = $2\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"review_enabled\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"4decb0554367c10f06a45f14291e5ba2a3e16aaf63bf1c34c2e8bc0c249fe4dd\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4e74faa43c070a492467104f59f81a8cb7e304593dd8cc12523b2c9052a48275.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE issue_comment_reactions\\n            SET\\n                emoji = COALESCE($1, emoji)\\n            WHERE id = $2\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                comment_id  AS \\\"comment_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                emoji       AS \\\"emoji!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"comment_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"emoji!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Varchar\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4e74faa43c070a492467104f59f81a8cb7e304593dd8cc12523b2c9052a48275\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4f80d17d6ca14600ec33d3660b8aa2efb385baf0384b6e666c3d25f0dad3c902.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM issue_relationships WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"4f80d17d6ca14600ec33d3660b8aa2efb385baf0384b6e666c3d25f0dad3c902\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-4fc440b2735dfe8561c3f75440d8eaab32d1c31c994e17f319f52045bf96714f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                blob_path   AS \\\"blob_path!\\\",\\n                hash        AS \\\"hash!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                expires_at  AS \\\"expires_at!: DateTime<Utc>\\\"\\n            FROM pending_uploads\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4fc440b2735dfe8561c3f75440d8eaab32d1c31c994e17f319f52045bf96714f\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-51fe714966b7474d7f96cda8411b353e51efd935c929b689a8c33872d6a887b0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO projects (id, organization_id, name, color, created_at, updated_at)\\n            SELECT gen_random_uuid(), organization_id, name, color, created_at, NOW()\\n            FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::timestamptz[])\\n                AS t(organization_id, name, color, created_at)\\n            RETURNING id\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\",\n        \"TextArray\",\n        \"TextArray\",\n        \"TimestamptzArray\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"51fe714966b7474d7f96cda8411b353e51efd935c929b689a8c33872d6a887b0\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-547e9a424c4baa6d0a39299996fc8ee6abf88c2b6f687a17ec8216059de49596.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM pending_uploads WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"547e9a424c4baa6d0a39299996fc8ee6abf88c2b6f687a17ec8216059de49596\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-55f054b37280bfa43dbea79edd61ba969bacf776c0be43b608b5b0ca3f68c1fe.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM github_app_pending_installations\\n            WHERE expires_at < NOW()\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": []\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"55f054b37280bfa43dbea79edd61ba969bacf776c0be43b608b5b0ca3f68c1fe\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-56b1a366106974ec86702175bc3b4cad61f7437599082e142262169647df324d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                user_id  AS \\\"user_id!: Uuid\\\"\\n            FROM issue_followers\\n            WHERE issue_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"56b1a366106974ec86702175bc3b4cad61f7437599082e142262169647df324d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-56d467122fa8b6599dc8821f65c2b191f378c9a76d3707d63d8cee1ef31fe4ba.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO oauth_handoffs (\\n                provider,\\n                state,\\n                return_to,\\n                app_challenge,\\n                expires_at\\n            )\\n            VALUES ($1, $2, $3, $4, $5)\\n            RETURNING\\n                id                          AS \\\"id!\\\",\\n                provider                    AS \\\"provider!\\\",\\n                state                       AS \\\"state!\\\",\\n                return_to                   AS \\\"return_to!\\\",\\n                app_challenge               AS \\\"app_challenge!\\\",\\n                app_code_hash               AS \\\"app_code_hash?\\\",\\n                status                      AS \\\"status!\\\",\\n                error_code                  AS \\\"error_code?\\\",\\n                expires_at                  AS \\\"expires_at!\\\",\\n                authorized_at               AS \\\"authorized_at?\\\",\\n                redeemed_at                 AS \\\"redeemed_at?\\\",\\n                user_id                     AS \\\"user_id?\\\",\\n                session_id                  AS \\\"session_id?\\\",\\n                encrypted_provider_tokens   AS \\\"encrypted_provider_tokens?\\\",\\n                created_at                  AS \\\"created_at!\\\",\\n                updated_at                  AS \\\"updated_at!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"provider!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"state!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"return_to!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"app_challenge!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"app_code_hash?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"status!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"error_code?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"expires_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"authorized_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"redeemed_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"user_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"session_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"encrypted_provider_tokens?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"56d467122fa8b6599dc8821f65c2b191f378c9a76d3707d63d8cee1ef31fe4ba\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-56d8fd993d1926824c84fff5b5a7f918f06e301ba4938075305eb575d310e891.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE issue_comments\\n            SET\\n                message = COALESCE($1, message),\\n                updated_at = $2\\n            WHERE id = $3\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                author_id   AS \\\"author_id: Uuid\\\",\\n                parent_id   AS \\\"parent_id: Uuid\\\",\\n                message     AS \\\"message!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at  AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"author_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"parent_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"message!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Timestamptz\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"56d8fd993d1926824c84fff5b5a7f918f06e301ba4938075305eb575d310e891\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-56efc697008a751a659452d95248636ce60c7f13fb2a3ef3f5440a7c795b13eb.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM issue_comment_reactions WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"56efc697008a751a659452d95248636ce60c7f13fb2a3ef3f5440a7c795b13eb\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-574f50459071d9a400bad0c7623ab1618c6ae90b4a60adb8cb4a75628cb22c1c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE github_app_installations\\n            SET suspended_at = NOW(), updated_at = NOW()\\n            WHERE github_installation_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"574f50459071d9a400bad0c7623ab1618c6ae90b4a60adb8cb4a75628cb22c1c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-577b1dc54aeefe702c74a56776544a391429b561b76d36d59673e410d5d78576.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id              AS \\\"id!\\\",\\n                provider        AS \\\"provider!\\\",\\n                state           AS \\\"state!\\\",\\n                return_to       AS \\\"return_to!\\\",\\n                app_challenge   AS \\\"app_challenge!\\\",\\n                app_code_hash   AS \\\"app_code_hash?\\\",\\n                status          AS \\\"status!\\\",\\n                error_code      AS \\\"error_code?\\\",\\n                expires_at      AS \\\"expires_at!\\\",\\n                authorized_at   AS \\\"authorized_at?\\\",\\n                redeemed_at     AS \\\"redeemed_at?\\\",\\n                user_id         AS \\\"user_id?\\\",\\n                session_id                  AS \\\"session_id?\\\",\\n                encrypted_provider_tokens   AS \\\"encrypted_provider_tokens?\\\",\\n                created_at      AS \\\"created_at!\\\",\\n                updated_at      AS \\\"updated_at!\\\"\\n            FROM oauth_handoffs\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"provider!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"state!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"return_to!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"app_challenge!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"app_code_hash?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"status!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"error_code?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"expires_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"authorized_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"redeemed_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"user_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"session_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"encrypted_provider_tokens?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"577b1dc54aeefe702c74a56776544a391429b561b76d36d59673e410d5d78576\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-58d7e8202ef0fb891303c761ae83a803459ffdda3c2a43ca3d6f74c0e3ecb34d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                AS \\\"id!: Uuid\\\",\\n                issue_id          AS \\\"issue_id!: Uuid\\\",\\n                related_issue_id  AS \\\"related_issue_id!: Uuid\\\",\\n                relationship_type AS \\\"relationship_type!: IssueRelationshipType\\\",\\n                created_at        AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM issue_relationships\\n            WHERE issue_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"related_issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"relationship_type!: IssueRelationshipType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_relationship_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"blocking\",\n                \"related\",\n                \"has_duplicate\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"58d7e8202ef0fb891303c761ae83a803459ffdda3c2a43ca3d6f74c0e3ecb34d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-5a652dd2a3d8bcbc8824584f8a1d9ccbb1fa56f54575b6c9dcd855a26de1edc5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT p.pubname AS pubname, n.nspname AS schema_name, c.relname AS table_name\\n           FROM pg_publication_rel pr\\n           JOIN pg_publication p ON pr.prpubid = p.oid\\n           JOIN pg_class c ON pr.prrelid = c.oid\\n           JOIN pg_namespace n ON c.relnamespace = n.oid\\n           WHERE p.pubname = ANY($1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"pubname\",\n        \"type_info\": \"Name\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"schema_name\",\n        \"type_info\": \"Name\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"table_name\",\n        \"type_info\": \"Name\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"NameArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"5a652dd2a3d8bcbc8824584f8a1d9ccbb1fa56f54575b6c9dcd855a26de1edc5\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id AS \\\"id!\\\",\\n                organization_id AS \\\"organization_id!: Uuid\\\",\\n                invited_by_user_id AS \\\"invited_by_user_id?: Uuid\\\",\\n                email AS \\\"email!\\\",\\n                role AS \\\"role!: MemberRole\\\",\\n                status AS \\\"status!: InvitationStatus\\\",\\n                token AS \\\"token!\\\",\\n                expires_at AS \\\"expires_at!\\\",\\n                created_at AS \\\"created_at!\\\",\\n                updated_at AS \\\"updated_at!\\\"\\n            FROM organization_invitations\\n            WHERE organization_id = $1\\n            ORDER BY created_at DESC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"invited_by_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status!: InvitationStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"invitation_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"pending\",\n                \"accepted\",\n                \"declined\",\n                \"expired\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"token!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"expires_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-5ce478f8221034468e5ea9ec66051e724d7054f8c62106795bccf9fd5366696d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n                INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name)\\n                VALUES ($1, $2, $3)\\n                ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET\\n                    repo_full_name = EXCLUDED.repo_full_name\\n                \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5ce478f8221034468e5ea9ec66051e724d7054f8c62106795bccf9fd5366696d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-5dae00eb6e3bef4d8ded1db51ad1252f6df335355b877f0dd64075f74c0018b8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE oauth_accounts\\n            SET encrypted_provider_tokens = $3\\n            WHERE user_id = $1\\n              AND provider = $2\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5dae00eb6e3bef4d8ded1db51ad1252f6df335355b877f0dd64075f74c0018b8\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-5f8a332903cbf55aca62cf642bfca4e1815e2b168889f3a5983cb859c77a75b6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                AS \\\"id!: Uuid\\\",\\n                issue_id          AS \\\"issue_id!: Uuid\\\",\\n                related_issue_id  AS \\\"related_issue_id!: Uuid\\\",\\n                relationship_type AS \\\"relationship_type!: IssueRelationshipType\\\",\\n                created_at        AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM issue_relationships\\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"related_issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"relationship_type!: IssueRelationshipType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_relationship_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"blocking\",\n                \"related\",\n                \"has_duplicate\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"5f8a332903cbf55aca62cf642bfca4e1815e2b168889f3a5983cb859c77a75b6\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-61245f2cee584d03acf4fd65dec00d22076134f726e5a5f4f13d1f4fc2060974.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id              AS \\\"id!: Uuid\\\",\\n                project_id      AS \\\"project_id!: Uuid\\\",\\n                name            AS \\\"name!\\\",\\n                color           AS \\\"color!\\\",\\n                sort_order      AS \\\"sort_order!\\\",\\n                hidden          AS \\\"hidden!\\\",\\n                created_at      AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM project_statuses\\n            WHERE project_id = $1 AND LOWER(name) = LOWER($2)\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"hidden!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"61245f2cee584d03acf4fd65dec00d22076134f726e5a5f4f13d1f4fc2060974\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-6205d4d925ce5c7ab8a91e109c807b458a668304ed6262c5afab4b85a227d119.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM github_app_pending_installations\\n            WHERE organization_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"6205d4d925ce5c7ab8a91e109c807b458a668304ed6262c5afab4b85a227d119\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-622e613f8a71f6dd4d110df061bff6ab4e46636ab60dd85dbccd9181d004de33.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                tag_id   AS \\\"tag_id!: Uuid\\\"\\n            FROM issue_tags\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"tag_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"622e613f8a71f6dd4d110df061bff6ab4e46636ab60dd85dbccd9181d004de33\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-623c1d7933109030c4dbbf84d6028d1a7c94394906d1300c257c2e657925eb25.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issue_comment_reactions (id, comment_id, user_id, emoji, created_at)\\n            VALUES ($1, $2, $3, $4, $5)\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                comment_id  AS \\\"comment_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                emoji       AS \\\"emoji!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"comment_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"emoji!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Varchar\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"623c1d7933109030c4dbbf84d6028d1a7c94394906d1300c257c2e657925eb25\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-62a28e66786692c5525ac4266bd3120d75ada4b85ed14f6815231c8691604e2f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id              AS \\\"id!: Uuid\\\",\\n                project_id      AS \\\"project_id!: Uuid\\\",\\n                name            AS \\\"name!\\\",\\n                color           AS \\\"color!\\\",\\n                sort_order      AS \\\"sort_order!\\\",\\n                hidden          AS \\\"hidden!\\\",\\n                created_at      AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM project_statuses\\n            WHERE project_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"hidden!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"62a28e66786692c5525ac4266bd3120d75ada4b85ed14f6815231c8691604e2f\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-633bc2ca535b8b0078e81e188c734426421fe426dfb90697d025556cc8cb723f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO organizations (name, slug, is_personal, issue_prefix)\\n        VALUES ($1, $2, TRUE, $3)\\n        RETURNING\\n            id           AS \\\"id!: Uuid\\\",\\n            name         AS \\\"name!\\\",\\n            slug         AS \\\"slug!\\\",\\n            is_personal  AS \\\"is_personal!\\\",\\n            issue_prefix AS \\\"issue_prefix!\\\",\\n            created_at   AS \\\"created_at!\\\",\\n            updated_at   AS \\\"updated_at!\\\"\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"slug!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"is_personal!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"issue_prefix!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\",\n        \"Varchar\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"633bc2ca535b8b0078e81e188c734426421fe426dfb90697d025556cc8cb723f\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-63ad252e1cd34aca9f819e457d0184c8df21cb4d2b1606ef84c3bdf5fc4457b0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                assigned_at AS \\\"assigned_at!: DateTime<Utc>\\\"\\n            FROM issue_assignees\\n            WHERE issue_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"assigned_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"63ad252e1cd34aca9f819e457d0184c8df21cb4d2b1606ef84c3bdf5fc4457b0\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-6412e3c9c929c588d924c1f899891f5d47f92d48b19f93823fb5a795d44a736a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO workspaces (project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at)\\n            SELECT project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at\\n            FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[], $4::uuid[], $5::boolean[], $6::timestamptz[])\\n                AS t(project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at)\\n            RETURNING id\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\",\n        \"UuidArray\",\n        \"UuidArray\",\n        \"UuidArray\",\n        \"BoolArray\",\n        \"TimestamptzArray\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"6412e3c9c929c588d924c1f899891f5d47f92d48b19f93823fb5a795d44a736a\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE organization_invitations\\n            SET status = 'accepted'\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-667708775c67d5b3ee9a55730434f37f9ae7a49ba89301999fbb1e20aef9bb42.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                blob_id     AS \\\"blob_id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id?: Uuid\\\",\\n                comment_id  AS \\\"comment_id?: Uuid\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                expires_at  AS \\\"expires_at?: DateTime<Utc>\\\"\\n            FROM attachments\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"667708775c67d5b3ee9a55730434f37f9ae7a49ba89301999fbb1e20aef9bb42\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-66eefd452f6ccbd5bc757154a1da211c8134075b7c9f42dacc4fecaedd1c8737.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                o.id           AS \\\"id!: Uuid\\\",\\n                o.name         AS \\\"name!\\\",\\n                o.slug         AS \\\"slug!\\\",\\n                o.is_personal  AS \\\"is_personal!\\\",\\n                o.issue_prefix AS \\\"issue_prefix!\\\",\\n                o.created_at   AS \\\"created_at!\\\",\\n                o.updated_at   AS \\\"updated_at!\\\",\\n                m.role         AS \\\"user_role!: MemberRole\\\"\\n            FROM organizations o\\n            JOIN organization_member_metadata m ON m.organization_id = o.id\\n            WHERE m.user_id = $1\\n            ORDER BY o.created_at DESC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"slug!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"is_personal!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"issue_prefix!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"user_role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"66eefd452f6ccbd5bc757154a1da211c8134075b7c9f42dacc4fecaedd1c8737\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-68422b179dc361337c65a6bd1aa455a961708b97a673d84f7af64cd252cbfdf3.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE auth_sessions\\n            SET revoked_at = NOW()\\n            WHERE user_id = $1\\n              AND revoked_at IS NULL\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"68422b179dc361337c65a6bd1aa455a961708b97a673d84f7af64cd252cbfdf3\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-68494b64181d1ca4293962abfcf0af30e5b4d6947dd4e9509bfc21d8fe4b93d5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM github_app_repositories\\n            WHERE installation_id = $1 AND NOT (github_repo_id = ANY($2))\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8Array\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"68494b64181d1ca4293962abfcf0af30e5b4d6947dd4e9509bfc21d8fe4b93d5\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-690c16c206895598016a784884f1a764f4a921232df68cc046495ff4f39827ec.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id              AS \\\"id!: Uuid\\\",\\n                project_id      AS \\\"project_id!: Uuid\\\",\\n                name            AS \\\"name!\\\",\\n                color           AS \\\"color!\\\",\\n                sort_order      AS \\\"sort_order!\\\",\\n                hidden          AS \\\"hidden!\\\",\\n                created_at      AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM project_statuses\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"hidden!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"690c16c206895598016a784884f1a764f4a921232df68cc046495ff4f39827ec\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-6a1bfdce77c93b841ff0c3b533a71e6d9c9d333659de1b12ffbe462ae0123bd5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE github_app_repositories\\n            SET review_enabled = $2\\n            WHERE installation_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Bool\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"6a1bfdce77c93b841ff0c3b533a71e6d9c9d333659de1b12ffbe462ae0123bd5\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-6be91bb87b8d2b28f600bf4f59224281d676281278f6c6bf266a1aa3a91d44fd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                tag_id   AS \\\"tag_id!: Uuid\\\"\\n            FROM issue_tags\\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"tag_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"6be91bb87b8d2b28f600bf4f59224281d676281278f6c6bf266a1aa3a91d44fd\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT organization_id\\n            FROM projects\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-6fae9f0d59fa5fb6b03ba068d1b50e82aa1b91fa2abe782bdbddd4ccbbd7971c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM workspaces WHERE \\\"owner_user_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"6fae9f0d59fa5fb6b03ba068d1b50e82aa1b91fa2abe782bdbddd4ccbbd7971c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT EXISTS(\\n                SELECT 1 FROM reviews\\n                WHERE pr_owner = $1\\n                  AND pr_repo = $2\\n                  AND pr_number = $3\\n                  AND status = 'pending'\\n                  AND deleted_at IS NULL\\n            ) as \\\"exists!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO organization_member_metadata (organization_id, user_id, role)\\n        VALUES ($1, $2, $3)\\n        ON CONFLICT (organization_id, user_id) DO UPDATE\\n        SET role = EXCLUDED.role\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE auth_sessions\\n            SET last_used_at = date_trunc('day', NOW())\\n            WHERE id = $1\\n              AND (\\n                last_used_at IS NULL\\n                OR last_used_at < date_trunc('day', NOW())\\n              )\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-79dc2aa6cb26c21530ac05b84ec58aff9b042724bda846aadd9bf1b1a3a53791.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE reviews\\n            SET status = 'completed'\\n            WHERE id = $1 AND deleted_at IS NULL\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"79dc2aa6cb26c21530ac05b84ec58aff9b042724bda846aadd9bf1b1a3a53791\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT user_id\\n            FROM organization_member_metadata\\n            WHERE organization_id = $1 AND role = 'admin'\\n            FOR UPDATE\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-7a96ad78e02ebdb1f6d29d941a3c393b128a7165123b63c455df2c2581995e35.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT\\n            id           AS \\\"id!: Uuid\\\",\\n            email        AS \\\"email!\\\",\\n            first_name   AS \\\"first_name?\\\",\\n            last_name    AS \\\"last_name?\\\",\\n            username     AS \\\"username?\\\",\\n            created_at   AS \\\"created_at!\\\",\\n            updated_at   AS \\\"updated_at!\\\"\\n        FROM users\\n        WHERE id IN (SELECT user_id FROM organization_member_metadata WHERE organization_id = $1)\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"first_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7a96ad78e02ebdb1f6d29d941a3c393b128a7165123b63c455df2c2581995e35\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-7c54f2956d1c6f0912da45e40590e2bfbb1e5c24c374c2d68ca5b692c87cf26f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                url                 AS \\\"url!: String\\\",\\n                number              AS \\\"number!: i32\\\",\\n                status              AS \\\"status!: PullRequestStatus\\\",\\n                merged_at           AS \\\"merged_at: DateTime<Utc>\\\",\\n                merge_commit_sha    AS \\\"merge_commit_sha: String\\\",\\n                target_branch_name  AS \\\"target_branch_name!: String\\\",\\n                issue_id            AS \\\"issue_id!: Uuid\\\",\\n                workspace_id        AS \\\"workspace_id: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM pull_requests\\n            WHERE issue_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"url!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"number!: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"status!: PullRequestStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"pull_request_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"open\",\n                \"merged\",\n                \"closed\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"merged_at: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"merge_commit_sha: String\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"target_branch_name!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7c54f2956d1c6f0912da45e40590e2bfbb1e5c24c374c2d68ca5b692c87cf26f\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-7d628ce544ed41baf2d0cdc0c95f35ac324474564b8cbb6735c9a7fc6aff75fa.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE projects\\n            SET\\n                name = COALESCE($1, name),\\n                color = COALESCE($2, color),\\n                sort_order = COALESCE($3, sort_order),\\n                updated_at = $4\\n            WHERE id = $5\\n            RETURNING\\n                id               AS \\\"id!: Uuid\\\",\\n                organization_id  AS \\\"organization_id!: Uuid\\\",\\n                name             AS \\\"name!\\\",\\n                color            AS \\\"color!\\\",\\n                sort_order       AS \\\"sort_order!\\\",\\n                created_at       AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at       AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Varchar\",\n        \"Int4\",\n        \"Timestamptz\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7d628ce544ed41baf2d0cdc0c95f35ac324474564b8cbb6735c9a7fc6aff75fa\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO organization_invitations (\\n                organization_id, invited_by_user_id, email, role, token, expires_at\\n            )\\n            VALUES ($1, $2, $3, $4, $5, $6)\\n            RETURNING\\n                id AS \\\"id!\\\",\\n                organization_id AS \\\"organization_id!: Uuid\\\",\\n                invited_by_user_id AS \\\"invited_by_user_id?: Uuid\\\",\\n                email AS \\\"email!\\\",\\n                role AS \\\"role!: MemberRole\\\",\\n                status AS \\\"status!: InvitationStatus\\\",\\n                token AS \\\"token!\\\",\\n                expires_at AS \\\"expires_at!\\\",\\n                created_at AS \\\"created_at!\\\",\\n                updated_at AS \\\"updated_at!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"invited_by_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status!: InvitationStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"invitation_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"pending\",\n                \"accepted\",\n                \"declined\",\n                \"expired\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"token!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"expires_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        },\n        \"Text\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-7f100e4420b2b8c086eac892d13f0ed114a5667b9c26fe7d99dcff1f4b3b1a9f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM issues WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"7f100e4420b2b8c086eac892d13f0ed114a5667b9c26fe7d99dcff1f4b3b1a9f\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-7fb263a325db6e402761a9d0643561b134deda610f7f163d38c20625a4fdd048.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                comment_id  AS \\\"comment_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                emoji       AS \\\"emoji!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM issue_comment_reactions\\n            WHERE comment_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"comment_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"emoji!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7fb263a325db6e402761a9d0643561b134deda610f7f163d38c20625a4fdd048\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-80c3a6879ba2142e78a397340501ac402808707724c58a67db7c7bb9040a7cb9.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                assigned_at AS \\\"assigned_at!: DateTime<Utc>\\\"\\n            FROM issue_assignees\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"assigned_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"80c3a6879ba2142e78a397340501ac402808707724c58a67db7c7bb9040a7cb9\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8123b99c8d0df1c3a39ae0b2e02b8f95e438dcaa7f85e4ad37a069d962ae2e39.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n                SELECT\\n                    id,\\n                    organization_id,\\n                    user_id,\\n                    notification_type as \\\"notification_type!: NotificationType\\\",\\n                    payload as \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                    issue_id,\\n                    comment_id,\\n                    seen,\\n                    dismissed_at,\\n                    created_at\\n                FROM notifications\\n                WHERE user_id = $1\\n                ORDER BY created_at DESC\\n                \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"notification_type!: NotificationType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"payload!: sqlx::types::Json<NotificationPayload>\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"issue_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"seen\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"dismissed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"8123b99c8d0df1c3a39ae0b2e02b8f95e438dcaa7f85e4ad37a069d962ae2e39\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-823f54d7b4eb060b1c5eb4e45143e668286ae6716705e55bb4f4f0f89a5b4117.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                owner_user_id       AS \\\"owner_user_id!: Uuid\\\",\\n                issue_id            AS \\\"issue_id: Uuid\\\",\\n                local_workspace_id  AS \\\"local_workspace_id: Uuid\\\",\\n                name                AS \\\"name: String\\\",\\n                archived            AS \\\"archived!: bool\\\",\\n                files_changed       AS \\\"files_changed: i32\\\",\\n                lines_added         AS \\\"lines_added: i32\\\",\\n                lines_removed       AS \\\"lines_removed: i32\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM workspaces\\n            WHERE local_workspace_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"issue_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"local_workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"name: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"archived!: bool\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"files_changed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"lines_added: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"lines_removed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"823f54d7b4eb060b1c5eb4e45143e668286ae6716705e55bb4f4f0f89a5b4117\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-82dcc3cd88256066ad91785afe686ec03090ea549029ba2c701cdfa2c1501f0d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO workspaces (project_id, owner_user_id, local_workspace_id, issue_id, name, archived, files_changed, lines_added, lines_removed)\\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                owner_user_id       AS \\\"owner_user_id!: Uuid\\\",\\n                issue_id            AS \\\"issue_id: Uuid\\\",\\n                local_workspace_id  AS \\\"local_workspace_id: Uuid\\\",\\n                name                AS \\\"name: String\\\",\\n                archived            AS \\\"archived!: bool\\\",\\n                files_changed       AS \\\"files_changed: i32\\\",\\n                lines_added         AS \\\"lines_added: i32\\\",\\n                lines_removed       AS \\\"lines_removed: i32\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"issue_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"local_workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"name: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"archived!: bool\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"files_changed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"lines_added: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"lines_removed: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Bool\",\n        \"Int4\",\n        \"Int4\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"82dcc3cd88256066ad91785afe686ec03090ea549029ba2c701cdfa2c1501f0d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-830e7650bdeccf581f260646182b3b5af903927702022ba1a4293d9d8627f727.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            WITH existing AS (\\n                SELECT id FROM notifications\\n                WHERE user_id = $3\\n                  AND notification_type = $4\\n                  AND issue_id IS NOT DISTINCT FROM $6\\n                  AND comment_id IS NOT DISTINCT FROM $7\\n                  AND created_at > NOW() - INTERVAL '1 minute'\\n                ORDER BY created_at DESC\\n                LIMIT 1\\n            ),\\n            updated AS (\\n                UPDATE notifications\\n                SET payload = $5,\\n                    seen = FALSE,\\n                    dismissed_at = NULL,\\n                    created_at = $8\\n                WHERE id = (SELECT id FROM existing)\\n                RETURNING\\n                    id,\\n                    organization_id,\\n                    user_id,\\n                    notification_type,\\n                    payload,\\n                    issue_id,\\n                    comment_id,\\n                    seen,\\n                    dismissed_at,\\n                    created_at\\n            ),\\n            inserted AS (\\n                INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at)\\n                SELECT $1, $2, $3, $4, $5, $6, $7, $8\\n                WHERE NOT EXISTS (SELECT 1 FROM existing)\\n                RETURNING\\n                    id,\\n                    organization_id,\\n                    user_id,\\n                    notification_type,\\n                    payload,\\n                    issue_id,\\n                    comment_id,\\n                    seen,\\n                    dismissed_at,\\n                    created_at\\n            )\\n            SELECT\\n                id as \\\"id!\\\",\\n                organization_id as \\\"organization_id!\\\",\\n                user_id as \\\"user_id!\\\",\\n                notification_type as \\\"notification_type!: NotificationType\\\",\\n                payload as \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                issue_id,\\n                comment_id,\\n                seen as \\\"seen!\\\",\\n                dismissed_at,\\n                created_at as \\\"created_at!\\\"\\n            FROM updated\\n            UNION ALL\\n            SELECT\\n                id as \\\"id!\\\",\\n                organization_id as \\\"organization_id!\\\",\\n                user_id as \\\"user_id!\\\",\\n                notification_type as \\\"notification_type!: NotificationType\\\",\\n                payload as \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                issue_id,\\n                comment_id,\\n                seen as \\\"seen!\\\",\\n                dismissed_at,\\n                created_at as \\\"created_at!\\\"\\n            FROM inserted\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"notification_type!: NotificationType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"payload!: sqlx::types::Json<NotificationPayload>\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"issue_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"seen!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"dismissed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        },\n        \"Jsonb\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      null,\n      null,\n      null,\n      null,\n      null,\n      null,\n      null,\n      null,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"830e7650bdeccf581f260646182b3b5af903927702022ba1a4293d9d8627f727\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-83edd4a9b106ad4dfda19ed983d27aee591e50a5a5f4774dbe6d68265da0c6de.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT COUNT(*) AS \\\"count!\\\"\\n            FROM attachments\\n            WHERE blob_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"count!\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"83edd4a9b106ad4dfda19ed983d27aee591e50a5a5f4774dbe6d68265da0c6de\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        UPDATE organization_member_metadata\\n        SET role = $3\\n        WHERE organization_id = $1 AND user_id = $2\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-864ea9a40e219fdf04230331e225d699677200d5ccd3d4e12842060a657bd8ea.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM projects WHERE \\\"organization_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"864ea9a40e219fdf04230331e225d699677200d5ccd3d4e12842060a657bd8ea\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-86d32fea56d89959413ec714af2decbfd0b58a60ab4833cb300f15eac9061ff7.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM project_statuses WHERE \\\"project_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"86d32fea56d89959413ec714af2decbfd0b58a60ab4833cb300f15eac9061ff7\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT\\n            id         AS \\\"id!: Uuid\\\",\\n            first_name AS \\\"first_name?\\\",\\n            last_name  AS \\\"last_name?\\\",\\n            username   AS \\\"username?\\\"\\n        FROM users\\n        WHERE id = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"first_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"last_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO tags (id, project_id, name, color)\\n            VALUES ($1, $2, $3, $4)\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                name        AS \\\"name!\\\",\\n                color       AS \\\"color!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Varchar\",\n        \"Varchar\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-878adca3c3dc2383f7dd86e19026f9aa18d6b2ac20a46a97630a43f9c5ee99eb.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM issue_tags WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"878adca3c3dc2383f7dd86e19026f9aa18d6b2ac20a46a97630a43f9c5ee99eb\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-883490bd163237d721488136ba8efbe81f42357213817ce1efe61e6036184b3e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                comment_id  AS \\\"comment_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                emoji       AS \\\"emoji!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\"\\n            FROM issue_comment_reactions\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"comment_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"emoji!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"883490bd163237d721488136ba8efbe81f42357213817ce1efe61e6036184b3e\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-88ac4f15091a552f40c752a56d2894bb4f46c5eaeff9eec813e2d3c032de3e82.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                url                 AS \\\"url!: String\\\",\\n                number              AS \\\"number!: i32\\\",\\n                status              AS \\\"status!: PullRequestStatus\\\",\\n                merged_at           AS \\\"merged_at: DateTime<Utc>\\\",\\n                merge_commit_sha    AS \\\"merge_commit_sha: String\\\",\\n                target_branch_name  AS \\\"target_branch_name!: String\\\",\\n                issue_id            AS \\\"issue_id!: Uuid\\\",\\n                workspace_id        AS \\\"workspace_id: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM pull_requests\\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"url!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"number!: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"status!: PullRequestStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"pull_request_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"open\",\n                \"merged\",\n                \"closed\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"merged_at: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"merge_commit_sha: String\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"target_branch_name!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"88ac4f15091a552f40c752a56d2894bb4f46c5eaeff9eec813e2d3c032de3e82\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8b2002931058f7e268604b9ad4f2a12ec6388fa66e20f8d3cc0f70c10e3d43ea.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                issue_number        AS \\\"issue_number!\\\",\\n                simple_id           AS \\\"simple_id!\\\",\\n                status_id           AS \\\"status_id!: Uuid\\\",\\n                title               AS \\\"title!\\\",\\n                description         AS \\\"description?\\\",\\n                priority            AS \\\"priority: IssuePriority\\\",\\n                start_date          AS \\\"start_date?: DateTime<Utc>\\\",\\n                target_date         AS \\\"target_date?: DateTime<Utc>\\\",\\n                completed_at        AS \\\"completed_at?: DateTime<Utc>\\\",\\n                sort_order          AS \\\"sort_order!\\\",\\n                parent_issue_id     AS \\\"parent_issue_id?: Uuid\\\",\\n                parent_issue_sort_order AS \\\"parent_issue_sort_order?\\\",\\n                extension_metadata  AS \\\"extension_metadata!: Value\\\",\\n                creator_user_id     AS \\\"creator_user_id?: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM issues\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_number!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"simple_id!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"status_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"title!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"description?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"priority: IssuePriority\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"start_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"target_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"parent_issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"parent_issue_sort_order?\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"extension_metadata!: Value\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"creator_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 16,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 17,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"8b2002931058f7e268604b9ad4f2a12ec6388fa66e20f8d3cc0f70c10e3d43ea\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE oauth_handoffs\\n            SET\\n                status = $2,\\n                error_code = $3\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8e32d5bf86d112e2f4a16f622bd95c8f728946f01e1a994a9c66b0fac6e3ae52.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason)\\n            SELECT refresh_token_id, user_id, 'reuse_of_revoked_token'\\n            FROM auth_sessions\\n            WHERE user_id = $1\\n              AND refresh_token_id IS NOT NULL\\n            ON CONFLICT (token_id) DO NOTHING\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"8e32d5bf86d112e2f4a16f622bd95c8f728946f01e1a994a9c66b0fac6e3ae52\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8e96696a873dbbd175f1d73eb03773beab823476f6d5712c02633dbc6efa0159.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE organizations\\n            SET name = $2\\n            WHERE id = $1\\n            RETURNING\\n                id           AS \\\"id!: Uuid\\\",\\n                name         AS \\\"name!\\\",\\n                slug         AS \\\"slug!\\\",\\n                is_personal  AS \\\"is_personal!\\\",\\n                issue_prefix AS \\\"issue_prefix!\\\",\\n                created_at   AS \\\"created_at!\\\",\\n                updated_at   AS \\\"updated_at!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"slug!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"is_personal!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"issue_prefix!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"8e96696a873dbbd175f1d73eb03773beab823476f6d5712c02633dbc6efa0159\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8e9d6c188fe09693d027d408b15792cbbebc72d2dd5bd4e2de12ef533a073f75.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n                INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name, review_enabled)\\n                VALUES ($1, $2, $3, true)\\n                ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET\\n                    repo_full_name = EXCLUDED.repo_full_name\\n                \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"8e9d6c188fe09693d027d408b15792cbbebc72d2dd5bd4e2de12ef533a073f75\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-8fc5f7e1920e9d43034aeaacb0a00739e0ee3cd00d06a692beac0f0fb2324ac8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO reviews (id, gh_pr_url, claude_code_session_id, ip_address, r2_path, email, pr_title)\\n            VALUES ($1, $2, $3, $4, $5, $6, $7)\\n            RETURNING\\n                id,\\n                gh_pr_url,\\n                claude_code_session_id,\\n                ip_address AS \\\"ip_address: IpNetwork\\\",\\n                review_cache,\\n                last_viewed_at,\\n                r2_path,\\n                deleted_at,\\n                created_at,\\n                email,\\n                pr_title,\\n                status,\\n                github_installation_id,\\n                pr_owner,\\n                pr_repo,\\n                pr_number\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"gh_pr_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"claude_code_session_id\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"ip_address: IpNetwork\",\n        \"type_info\": \"Inet\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"review_cache\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"last_viewed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"r2_path\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"pr_title\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"status\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"github_installation_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"pr_owner\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"pr_repo\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"pr_number\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Inet\",\n        \"Text\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"8fc5f7e1920e9d43034aeaacb0a00739e0ee3cd00d06a692beac0f0fb2324ac8\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM organization_invitations\\n            WHERE id = $1 AND organization_id = $2\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-91776c42e46c2b2a3909baccf21dd83e2e1c88e592e94699a7a286fd396f2812.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE attachments a\\n            SET issue_id = $1, expires_at = NULL\\n            FROM blobs b\\n            WHERE a.blob_id = b.id\\n              AND a.id = ANY($2)\\n              AND a.issue_id IS NULL\\n              AND a.comment_id IS NULL\\n            RETURNING\\n                a.id                    AS \\\"id!: Uuid\\\",\\n                a.blob_id               AS \\\"blob_id!: Uuid\\\",\\n                a.issue_id              AS \\\"issue_id?: Uuid\\\",\\n                a.comment_id            AS \\\"comment_id?: Uuid\\\",\\n                a.created_at            AS \\\"created_at!: DateTime<Utc>\\\",\\n                a.expires_at            AS \\\"expires_at?: DateTime<Utc>\\\",\\n                b.blob_path             AS \\\"blob_path!\\\",\\n                b.thumbnail_blob_path   AS \\\"thumbnail_blob_path?\\\",\\n                b.original_name         AS \\\"original_name!\\\",\\n                b.mime_type             AS \\\"mime_type?\\\",\\n                b.size_bytes            AS \\\"size_bytes!\\\",\\n                b.hash                  AS \\\"hash!\\\",\\n                b.width                 AS \\\"width?\\\",\\n                b.height                AS \\\"height?\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"91776c42e46c2b2a3909baccf21dd83e2e1c88e592e94699a7a286fd396f2812\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-92768b10d8dcb0593c1c5558d847aae11301163ffc053e89f08cae48a94753a0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM organization_member_metadata WHERE \\\"organization_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"92768b10d8dcb0593c1c5558d847aae11301163ffc053e89f08cae48a94753a0\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                          AS \\\"id!\\\",\\n                user_id                     AS \\\"user_id!: Uuid\\\",\\n                created_at                  AS \\\"created_at!\\\",\\n                last_used_at                AS \\\"last_used_at?\\\",\\n                revoked_at                  AS \\\"revoked_at?\\\",\\n                refresh_token_id           AS \\\"refresh_token_id?\\\",\\n                refresh_token_issued_at     AS \\\"refresh_token_issued_at?\\\"\\n            FROM auth_sessions\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_used_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"revoked_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"refresh_token_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"refresh_token_issued_at?\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-95427f2ba8293a8aa51366aad80129a3cfdcd1b3ec4dc8298d3aa7d0c5419191.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id              AS \\\"id!\\\",\\n                provider        AS \\\"provider!\\\",\\n                state           AS \\\"state!\\\",\\n                return_to       AS \\\"return_to!\\\",\\n                app_challenge   AS \\\"app_challenge!\\\",\\n                app_code_hash   AS \\\"app_code_hash?\\\",\\n                status          AS \\\"status!\\\",\\n                error_code      AS \\\"error_code?\\\",\\n                expires_at      AS \\\"expires_at!\\\",\\n                authorized_at   AS \\\"authorized_at?\\\",\\n                redeemed_at     AS \\\"redeemed_at?\\\",\\n                user_id         AS \\\"user_id?\\\",\\n                session_id                  AS \\\"session_id?\\\",\\n                encrypted_provider_tokens   AS \\\"encrypted_provider_tokens?\\\",\\n                created_at      AS \\\"created_at!\\\",\\n                updated_at      AS \\\"updated_at!\\\"\\n            FROM oauth_handoffs\\n            WHERE state = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"provider!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"state!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"return_to!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"app_challenge!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"app_code_hash?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"status!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"error_code?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"expires_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"authorized_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"redeemed_at?\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"user_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"session_id?\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"encrypted_provider_tokens?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"95427f2ba8293a8aa51366aad80129a3cfdcd1b3ec4dc8298d3aa7d0c5419191\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-9889a5e2b2b849138e5af7bb649c9833cfa4fbc45c3bed269d25a8ada30634e4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM github_app_pending_installations\\n            WHERE state_token = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9889a5e2b2b849138e5af7bb649c9833cfa4fbc45c3bed269d25a8ada30634e4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-9b2762d25c773099f99e6ae65ccefc16ac367d725df8ebb7983420aa0fce4149.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM pending_uploads\\n            WHERE expires_at < NOW()\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                blob_path   AS \\\"blob_path!\\\",\\n                hash        AS \\\"hash!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                expires_at  AS \\\"expires_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": []\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"9b2762d25c773099f99e6ae65ccefc16ac367d725df8ebb7983420aa0fce4149\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM project_statuses WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-a1431ca78db627fef0eca6f573b34d65510e9333765126cbd80c943046dfaea8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE auth_sessions\\n            SET refresh_token_id = $2,\\n                refresh_token_issued_at = NOW()\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"a1431ca78db627fef0eca6f573b34d65510e9333765126cbd80c943046dfaea8\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-a4f5f53d0b9882e4e8147be7b618cb3dd18d0b5c74f4fd4faf13b4be6c6704ad.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id       AS \\\"id!: Uuid\\\",\\n                issue_id AS \\\"issue_id!: Uuid\\\",\\n                tag_id   AS \\\"tag_id!: Uuid\\\"\\n            FROM issue_tags\\n            WHERE issue_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"tag_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a4f5f53d0b9882e4e8147be7b618cb3dd18d0b5c74f4fd4faf13b4be6c6704ad\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM projects WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-a5d10a37114cf01163a023d70212d17b963a27528089dca9d8fe8503335ad14b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO tags (id, project_id, name, color)\\n            SELECT gen_random_uuid(), $1, name, color\\n            FROM UNNEST($2::text[], $3::text[]) AS t(name, color)\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                name        AS \\\"name!\\\",\\n                color       AS \\\"color!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"TextArray\",\n        \"TextArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a5d10a37114cf01163a023d70212d17b963a27528089dca9d8fe8503335ad14b\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-a5d1aeb3ce62a3f286a2a4bddc38c3f5caf2eb556236b561b68483b17dc24cfd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                i.id                  AS \\\"id!: Uuid\\\",\\n                i.project_id          AS \\\"project_id!: Uuid\\\",\\n                i.issue_number        AS \\\"issue_number!\\\",\\n                i.simple_id           AS \\\"simple_id!\\\",\\n                i.status_id           AS \\\"status_id!: Uuid\\\",\\n                i.title               AS \\\"title!\\\",\\n                i.description         AS \\\"description?\\\",\\n                i.priority            AS \\\"priority: IssuePriority\\\",\\n                i.start_date          AS \\\"start_date?: DateTime<Utc>\\\",\\n                i.target_date         AS \\\"target_date?: DateTime<Utc>\\\",\\n                i.completed_at        AS \\\"completed_at?: DateTime<Utc>\\\",\\n                i.sort_order          AS \\\"sort_order!\\\",\\n                i.parent_issue_id     AS \\\"parent_issue_id?: Uuid\\\",\\n                i.parent_issue_sort_order AS \\\"parent_issue_sort_order?\\\",\\n                i.extension_metadata  AS \\\"extension_metadata!: Value\\\",\\n                i.creator_user_id     AS \\\"creator_user_id?: Uuid\\\",\\n                i.created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                i.updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM issues i\\n            LEFT JOIN project_statuses ps ON ps.id = i.status_id\\n            WHERE i.project_id = $1\\n              AND ($2::uuid IS NULL OR i.status_id = $2)\\n              AND ($3::uuid[] IS NULL OR i.status_id = ANY($3))\\n              AND ($4::issue_priority IS NULL OR i.priority = $4)\\n              AND ($5::uuid IS NULL OR i.parent_issue_id = $5)\\n              AND (\\n                  $6::text IS NULL\\n                  OR i.title ILIKE $6 ESCAPE '\\\\'\\n                  OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\\\\'\\n              )\\n              AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\\\\')\\n              AND (\\n                  $8::uuid IS NULL\\n                  OR EXISTS (\\n                      SELECT 1\\n                      FROM issue_assignees ia\\n                      WHERE ia.issue_id = i.id AND ia.user_id = $8\\n                  )\\n              )\\n              AND (\\n                  $9::uuid IS NULL\\n                  OR EXISTS (\\n                      SELECT 1\\n                      FROM issue_tags it\\n                      WHERE it.issue_id = i.id AND it.tag_id = $9\\n                  )\\n              )\\n              AND (\\n                  $10::uuid[] IS NULL\\n                  OR EXISTS (\\n                      SELECT 1\\n                      FROM issue_tags it\\n                      WHERE it.issue_id = i.id AND it.tag_id = ANY($10)\\n                  )\\n              )\\n            ORDER BY\\n                CASE\\n                    WHEN $11 = 'sort_order' AND $12 = 'asc' THEN ps.sort_order\\n                END ASC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'sort_order' AND $12 = 'desc' THEN ps.sort_order\\n                END DESC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'sort_order' AND $12 = 'asc' THEN i.sort_order\\n                END ASC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'sort_order' AND $12 = 'desc' THEN i.sort_order\\n                END DESC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'priority' AND $12 = 'asc' THEN i.priority\\n                END ASC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'priority' AND $12 = 'desc' THEN i.priority\\n                END DESC NULLS FIRST,\\n                CASE\\n                    WHEN $11 = 'created_at' AND $12 = 'asc' THEN i.created_at\\n                END ASC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'created_at' AND $12 = 'desc' THEN i.created_at\\n                END DESC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'updated_at' AND $12 = 'asc' THEN i.updated_at\\n                END ASC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'updated_at' AND $12 = 'desc' THEN i.updated_at\\n                END DESC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'title' AND $12 = 'asc' THEN i.title\\n                END ASC NULLS LAST,\\n                CASE\\n                    WHEN $11 = 'title' AND $12 = 'desc' THEN i.title\\n                END DESC NULLS LAST,\\n                i.issue_number ASC\\n            LIMIT $13\\n            OFFSET $14\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_number!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"simple_id!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"status_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"title!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"description?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"priority: IssuePriority\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"start_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"target_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"parent_issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"parent_issue_sort_order?\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"extension_metadata!: Value\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"creator_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 16,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 17,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"UuidArray\",\n        {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        },\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Uuid\",\n        \"Uuid\",\n        \"UuidArray\",\n        \"Text\",\n        \"Text\",\n        \"Int8\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a5d1aeb3ce62a3f286a2a4bddc38c3f5caf2eb556236b561b68483b17dc24cfd\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                name        AS \\\"name!\\\",\\n                color       AS \\\"color!\\\"\\n            FROM tags\\n            WHERE project_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-aa31348f22b24c16e1d1365c2508ad7b6c155ef2a50cabd80b59e297001dd93a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO attachments (id, blob_id, issue_id, comment_id, expires_at)\\n            VALUES ($1, $2, $3, $4, $5)\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                blob_id     AS \\\"blob_id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id?: Uuid\\\",\\n                comment_id  AS \\\"comment_id?: Uuid\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                expires_at  AS \\\"expires_at?: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"aa31348f22b24c16e1d1365c2508ad7b6c155ef2a50cabd80b59e297001dd93a\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id           AS \\\"id!: Uuid\\\",\\n                email        AS \\\"email!\\\",\\n                first_name   AS \\\"first_name?\\\",\\n                last_name    AS \\\"last_name?\\\",\\n                username     AS \\\"username?\\\",\\n                created_at   AS \\\"created_at!\\\",\\n                updated_at   AS \\\"updated_at!\\\"\\n            FROM users\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"first_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-b33110a056cf1ed2bb527aa975f8099d52ac0c9482cdf695a980fad0223ea136.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issue_comments (id, issue_id, author_id, parent_id, message, created_at, updated_at)\\n            VALUES ($1, $2, $3, $4, $5, $6, $7)\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                author_id   AS \\\"author_id: Uuid\\\",\\n                parent_id   AS \\\"parent_id: Uuid\\\",\\n                message     AS \\\"message!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at  AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"author_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"parent_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"message!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Timestamptz\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"b33110a056cf1ed2bb527aa975f8099d52ac0c9482cdf695a980fad0223ea136\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-b5a2ccf794217a408e9ffb663183af1ba203d6d2274e9562a9e3aa938ea6d71b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id               AS \\\"id!: Uuid\\\",\\n                organization_id  AS \\\"organization_id!: Uuid\\\",\\n                name             AS \\\"name!\\\",\\n                color            AS \\\"color!\\\",\\n                sort_order       AS \\\"sort_order!\\\",\\n                created_at       AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at       AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM projects\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"b5a2ccf794217a408e9ffb663183af1ba203d6d2274e9562a9e3aa938ea6d71b\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-b97175fb9a4f5a7379119da3760be7efc1ba2bf95bd5d3e6725f4f98aa7d955a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            WITH s AS (\\n                SELECT\\n                    BOOL_OR(user_id = $2 AND role = 'admin') AS is_admin\\n                FROM organization_member_metadata\\n                WHERE organization_id = $1\\n            )\\n            DELETE FROM organizations o\\n            USING s\\n            WHERE o.id = $1\\n              AND s.is_admin = true\\n            RETURNING o.id\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"b97175fb9a4f5a7379119da3760be7efc1ba2bf95bd5d3e6725f4f98aa7d955a\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id AS \\\"id!\\\",\\n                organization_id AS \\\"organization_id!: Uuid\\\",\\n                invited_by_user_id AS \\\"invited_by_user_id?: Uuid\\\",\\n                email AS \\\"email!\\\",\\n                role AS \\\"role!: MemberRole\\\",\\n                status AS \\\"status!: InvitationStatus\\\",\\n                token AS \\\"token!\\\",\\n                expires_at AS \\\"expires_at!\\\",\\n                created_at AS \\\"created_at!\\\",\\n                updated_at AS \\\"updated_at!\\\"\\n            FROM organization_invitations\\n            WHERE token = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"invited_by_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status!: InvitationStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"invitation_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"pending\",\n                \"accepted\",\n                \"declined\",\n                \"expired\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"token!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"expires_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-baa0922dd5a1e99794f480c483124402f5e1cd014e87919a72d468cd9762ec49.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE tags\\n            SET\\n                name = COALESCE($1, name),\\n                color = COALESCE($2, color)\\n            WHERE id = $3\\n            RETURNING\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                name        AS \\\"name!\\\",\\n                color       AS \\\"color!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Varchar\",\n        \"Varchar\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"baa0922dd5a1e99794f480c483124402f5e1cd014e87919a72d468cd9762ec49\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-bc0e36b956903c2ace672e6c52516598ec5f2b0288dcb935ef4d1bc694dacf0d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM issues WHERE \\\"project_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"bc0e36b956903c2ace672e6c52516598ec5f2b0288dcb935ef4d1bc694dacf0d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-bd632f11a197d6a17fcdf3e757283a64d281a931aaacd1ed6e4b73f18f1b6a2f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM github_app_installations\\n            WHERE organization_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"bd632f11a197d6a17fcdf3e757283a64d281a931aaacd1ed6e4b73f18f1b6a2f\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-bdbdee30d4f94a94f3d449aaea132512d8334a6a2636f898facd4e916a683f5e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE attachments a\\n            SET comment_id = $1, expires_at = NULL\\n            FROM blobs b\\n            WHERE a.blob_id = b.id\\n              AND a.id = ANY($2)\\n              AND a.issue_id IS NULL\\n              AND a.comment_id IS NULL\\n            RETURNING\\n                a.id                    AS \\\"id!: Uuid\\\",\\n                a.blob_id               AS \\\"blob_id!: Uuid\\\",\\n                a.issue_id              AS \\\"issue_id?: Uuid\\\",\\n                a.comment_id            AS \\\"comment_id?: Uuid\\\",\\n                a.created_at            AS \\\"created_at!: DateTime<Utc>\\\",\\n                a.expires_at            AS \\\"expires_at?: DateTime<Utc>\\\",\\n                b.blob_path             AS \\\"blob_path!\\\",\\n                b.thumbnail_blob_path   AS \\\"thumbnail_blob_path?\\\",\\n                b.original_name         AS \\\"original_name!\\\",\\n                b.mime_type             AS \\\"mime_type?\\\",\\n                b.size_bytes            AS \\\"size_bytes!\\\",\\n                b.hash                  AS \\\"hash!\\\",\\n                b.width                 AS \\\"width?\\\",\\n                b.height                AS \\\"height?\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"bdbdee30d4f94a94f3d449aaea132512d8334a6a2636f898facd4e916a683f5e\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-bee3a7f9d08e5634eb32e750701822a8f4efa01d301c8227e67783b435ee90cc.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO pull_requests (id, url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id)\\n            SELECT gen_random_uuid(), url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id\\n            FROM UNNEST($1::text[], $2::int[], $3::pull_request_status[], $4::timestamptz[], $5::text[], $6::text[], $7::uuid[])\\n                AS t(url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id)\\n            RETURNING id\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"TextArray\",\n        \"Int4Array\",\n        {\n          \"Custom\": {\n            \"name\": \"pull_request_status[]\",\n            \"kind\": {\n              \"Array\": {\n                \"Custom\": {\n                  \"name\": \"pull_request_status\",\n                  \"kind\": {\n                    \"Enum\": [\n                      \"open\",\n                      \"merged\",\n                      \"closed\"\n                    ]\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"TimestamptzArray\",\n        \"TextArray\",\n        \"TextArray\",\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"bee3a7f9d08e5634eb32e750701822a8f4efa01d301c8227e67783b435ee90cc\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-bf4a22fed2026657255fd032fcbc1ee14c27e48df5fa21fcc6202863520dbf98.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM pull_requests WHERE \\\"issue_id\\\" IN (SELECT id FROM issues WHERE \\\"project_id\\\" = $1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"bf4a22fed2026657255fd032fcbc1ee14c27e48df5fa21fcc6202863520dbf98\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-bfc7aa46dc9d6d70c2fed471996ddcbf4d723f0e41aa17f7d2be0cf277350410.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM issue_relationships WHERE \\\"issue_id\\\" IN (SELECT id FROM issues WHERE \\\"project_id\\\" = $1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"bfc7aa46dc9d6d70c2fed471996ddcbf4d723f0e41aa17f7d2be0cf277350410\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c27e4a5df0dbc872c6ae2c35abf0868b70ba141486e15a70e61c18e97f9e9213.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issues (id, project_id, status_id, title, description, priority, sort_order, extension_metadata, created_at)\\n            SELECT\\n                gen_random_uuid(),\\n                t.project_id,\\n                (SELECT id FROM project_statuses ps WHERE ps.project_id = t.project_id AND LOWER(ps.name) = LOWER(t.status_name)),\\n                t.title,\\n                t.description,\\n                NULL,\\n                0.0,\\n                '{}'::jsonb,\\n                t.created_at\\n            FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::text[], $5::timestamptz[])\\n                AS t(project_id, status_name, title, description, created_at)\\n            RETURNING id\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\",\n        \"TextArray\",\n        \"TextArray\",\n        \"TextArray\",\n        \"TimestamptzArray\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"c27e4a5df0dbc872c6ae2c35abf0868b70ba141486e15a70e61c18e97f9e9213\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c30d54026d89b9cf9c938f5aff5cf09ca12af2ab456094aac9a417473645b7e4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT pubname FROM pg_publication WHERE pubname = ANY($1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"pubname\",\n        \"type_info\": \"Name\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"NameArray\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"c30d54026d89b9cf9c938f5aff5cf09ca12af2ab456094aac9a417473645b7e4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c392eefb0fa2803536184053eaa22d63b6af8119f60419b8117f332cf48912de.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO oauth_accounts (\\n                user_id,\\n                provider,\\n                provider_user_id,\\n                email,\\n                username,\\n                display_name,\\n                avatar_url,\\n                encrypted_provider_tokens\\n            )\\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\\n            ON CONFLICT (provider, provider_user_id) DO UPDATE\\n            SET\\n                email = EXCLUDED.email,\\n                username = EXCLUDED.username,\\n                display_name = EXCLUDED.display_name,\\n                avatar_url = EXCLUDED.avatar_url,\\n                encrypted_provider_tokens = COALESCE(\\n                    EXCLUDED.encrypted_provider_tokens,\\n                    oauth_accounts.encrypted_provider_tokens\\n                )\\n            RETURNING\\n                id                AS \\\"id!: Uuid\\\",\\n                user_id           AS \\\"user_id!: Uuid\\\",\\n                provider          AS \\\"provider!\\\",\\n                provider_user_id  AS \\\"provider_user_id!\\\",\\n                email             AS \\\"email?\\\",\\n                username          AS \\\"username?\\\",\\n                display_name      AS \\\"display_name?\\\",\\n                avatar_url        AS \\\"avatar_url?\\\",\\n                encrypted_provider_tokens AS \\\"encrypted_provider_tokens?\\\",\\n                created_at        AS \\\"created_at!\\\",\\n                updated_at        AS \\\"updated_at!\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"provider!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"provider_user_id!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"email?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"display_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"avatar_url?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"encrypted_provider_tokens?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"c392eefb0fa2803536184053eaa22d63b6af8119f60419b8117f332cf48912de\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c48b9162c22c3d0ad7d2e2f34ecca353b807876aebf3540e5a024669ac2bb613.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO notification_digest_deliveries (notification_id)\\n            SELECT notification_id\\n            FROM UNNEST($1::uuid[]) AS delivered(notification_id)\\n            ON CONFLICT (notification_id) DO NOTHING\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c48b9162c22c3d0ad7d2e2f34ecca353b807876aebf3540e5a024669ac2bb613\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id AS \\\"id!\\\",\\n                organization_id AS \\\"organization_id!: Uuid\\\",\\n                invited_by_user_id AS \\\"invited_by_user_id?: Uuid\\\",\\n                email AS \\\"email!\\\",\\n                role AS \\\"role!: MemberRole\\\",\\n                status AS \\\"status!: InvitationStatus\\\",\\n                token AS \\\"token!\\\",\\n                expires_at AS \\\"expires_at!\\\",\\n                created_at AS \\\"created_at!\\\",\\n                updated_at AS \\\"updated_at!\\\"\\n            FROM organization_invitations\\n            WHERE token = $1 AND status = 'pending'\\n            FOR UPDATE\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"invited_by_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status!: InvitationStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"invitation_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"pending\",\n                \"accepted\",\n                \"declined\",\n                \"expired\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"token!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"expires_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c6cccc00461c95d86edc5a1f66b8228fb438985f6b78f9d83663ecb11d59675f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE github_app_installations\\n            SET suspended_at = NULL, updated_at = NOW()\\n            WHERE github_installation_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c6cccc00461c95d86edc5a1f66b8228fb438985f6b78f9d83663ecb11d59675f\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c850c165c1041f6b9ef852f8bb6c36f0558bd305000151834a884d7629521d28.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                blob_path           AS \\\"blob_path!\\\",\\n                thumbnail_blob_path AS \\\"thumbnail_blob_path?\\\",\\n                original_name       AS \\\"original_name!\\\",\\n                mime_type           AS \\\"mime_type?\\\",\\n                size_bytes          AS \\\"size_bytes!\\\",\\n                hash                AS \\\"hash!\\\",\\n                width               AS \\\"width?\\\",\\n                height              AS \\\"height?\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM blobs\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"c850c165c1041f6b9ef852f8bb6c36f0558bd305000151834a884d7629521d28\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c91d02654edf56d33a7eb9d33d3423ac2b0a59bdd89eb7d8aeadbabb2af72314.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT p.organization_id\\n        FROM issues i\\n        JOIN projects p ON i.project_id = p.id\\n        WHERE i.id = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"c91d02654edf56d33a7eb9d33d3423ac2b0a59bdd89eb7d8aeadbabb2af72314\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-c9499f4408f22989b6f55f1641e7e1a82b2f32e079c6b3ee0d9f6a47a15a2522.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                project_id               AS \\\"project_id!: Uuid\\\",\\n                user_id                  AS \\\"user_id!: Uuid\\\",\\n                notify_on_issue_created  AS \\\"notify_on_issue_created!\\\",\\n                notify_on_issue_assigned AS \\\"notify_on_issue_assigned!\\\"\\n            FROM project_notification_preferences\\n            WHERE project_id = $1 AND user_id = $2\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"notify_on_issue_created!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"notify_on_issue_assigned!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"c9499f4408f22989b6f55f1641e7e1a82b2f32e079c6b3ee0d9f6a47a15a2522\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-ca680e4e2a221ccaf578639b96730fa0d0fd4451d956f9dfa46670f5980c29a8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE oauth_handoffs\\n            SET\\n                status = 'redeemed',\\n                encrypted_provider_tokens = NULL,\\n                redeemed_at = NOW()\\n            WHERE id = $1\\n              AND status = 'authorized'\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"ca680e4e2a221ccaf578639b96730fa0d0fd4451d956f9dfa46670f5980c29a8\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-cbeee2168e74df2896cbb063187cd1acc8a5429bfaec80f32764676dafd2cd1e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT DISTINCT\\n                u.id AS \\\"id!: Uuid\\\",\\n                u.email AS \\\"email!\\\",\\n                u.first_name,\\n                u.last_name,\\n                u.username\\n            FROM notifications n\\n            JOIN users u ON u.id = n.user_id\\n            WHERE n.created_at >= $1\\n              AND n.created_at < $2\\n              AND n.dismissed_at IS NULL\\n              AND n.seen = FALSE\\n              AND NOT EXISTS (\\n                  SELECT 1\\n                  FROM notification_digest_deliveries d\\n                  WHERE d.notification_id = n.id\\n              )\\n            ORDER BY u.id\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"first_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"username\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Timestamptz\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"cbeee2168e74df2896cbb063187cd1acc8a5429bfaec80f32764676dafd2cd1e\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-cc7d93b529cfbddc9921ae33533572062f2e072f5be0fcb26032cbfec2fb3118.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO blobs (\\n                id, project_id, blob_path, thumbnail_blob_path, original_name,\\n                mime_type, size_bytes, hash, width, height\\n            )\\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\\n            ON CONFLICT (blob_path) DO UPDATE SET\\n                updated_at = NOW()\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                blob_path           AS \\\"blob_path!\\\",\\n                thumbnail_blob_path AS \\\"thumbnail_blob_path?\\\",\\n                original_name       AS \\\"original_name!\\\",\\n                mime_type           AS \\\"mime_type?\\\",\\n                size_bytes          AS \\\"size_bytes!\\\",\\n                hash                AS \\\"hash!\\\",\\n                width               AS \\\"width?\\\",\\n                height              AS \\\"height?\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Int8\",\n        \"Text\",\n        \"Int4\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"cc7d93b529cfbddc9921ae33533572062f2e072f5be0fcb26032cbfec2fb3118\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-cccab845031104e7a06d411cfbbbf7465f73051b30ec06d21a4c687ec175a58c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE pull_requests SET\\n                status = CASE WHEN $1 THEN $2 ELSE status END,\\n                merged_at = CASE WHEN $3 THEN $4 ELSE merged_at END,\\n                merge_commit_sha = CASE WHEN $5 THEN $6 ELSE merge_commit_sha END,\\n                updated_at = NOW()\\n            WHERE id = $7\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                url                 AS \\\"url!: String\\\",\\n                number              AS \\\"number!: i32\\\",\\n                status              AS \\\"status!: PullRequestStatus\\\",\\n                merged_at           AS \\\"merged_at: DateTime<Utc>\\\",\\n                merge_commit_sha    AS \\\"merge_commit_sha: String\\\",\\n                target_branch_name  AS \\\"target_branch_name!: String\\\",\\n                issue_id            AS \\\"issue_id!: Uuid\\\",\\n                workspace_id        AS \\\"workspace_id: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"url!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"number!: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"status!: PullRequestStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"pull_request_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"open\",\n                \"merged\",\n                \"closed\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"merged_at: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"merge_commit_sha: String\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"target_branch_name!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Bool\",\n        {\n          \"Custom\": {\n            \"name\": \"pull_request_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"open\",\n                \"merged\",\n                \"closed\"\n              ]\n            }\n          }\n        },\n        \"Bool\",\n        \"Timestamptz\",\n        \"Bool\",\n        \"Varchar\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"cccab845031104e7a06d411cfbbbf7465f73051b30ec06d21a4c687ec175a58c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-ce7908cdeecd4b4b94c92256bd800c165567ebe5644cfd702a9e4c0bb24091d4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT n.nspname AS schema_name, c.relname AS table_name\\n           FROM pg_publication_rel pr\\n           JOIN pg_publication p ON pr.prpubid = p.oid\\n           JOIN pg_class c ON pr.prrelid = c.oid\\n           JOIN pg_namespace n ON c.relnamespace = n.oid\\n           WHERE p.pubname = 'electric_publication_default'\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"schema_name\",\n        \"type_info\": \"Name\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"table_name\",\n        \"type_info\": \"Name\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": []\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"ce7908cdeecd4b4b94c92256bd800c165567ebe5644cfd702a9e4c0bb24091d4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-d0e511b622ffba9354c3be61112b392f7c22eb9facc97730d5b4ee62c248fff8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                AS \\\"id!: Uuid\\\",\\n                user_id           AS \\\"user_id!: Uuid\\\",\\n                provider          AS \\\"provider!\\\",\\n                provider_user_id  AS \\\"provider_user_id!\\\",\\n                email             AS \\\"email?\\\",\\n                username          AS \\\"username?\\\",\\n                display_name      AS \\\"display_name?\\\",\\n                avatar_url        AS \\\"avatar_url?\\\",\\n                encrypted_provider_tokens AS \\\"encrypted_provider_tokens?\\\",\\n                created_at        AS \\\"created_at!\\\",\\n                updated_at        AS \\\"updated_at!\\\"\\n            FROM oauth_accounts\\n            WHERE user_id = $1\\n              AND provider = $2\\n            LIMIT 1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"provider!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"provider_user_id!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"email?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"display_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"avatar_url?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"encrypted_provider_tokens?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"d0e511b622ffba9354c3be61112b392f7c22eb9facc97730d5b4ee62c248fff8\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-d1a4753755833d5100bcf4b61449f58fa83f7ee511ce0b15c7dc00c2d8c01560.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM issue_followers WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"d1a4753755833d5100bcf4b61449f58fa83f7ee511ce0b15c7dc00c2d8c01560\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-d2275416ba3ddb1bbaf929787b5df4c736084582ddebdbe9f4a4aa6853727484.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT COUNT(*)::BIGINT\\n            FROM issues i\\n            WHERE i.project_id = $1\\n              AND ($2::uuid IS NULL OR i.status_id = $2)\\n              AND ($3::uuid[] IS NULL OR i.status_id = ANY($3))\\n              AND ($4::issue_priority IS NULL OR i.priority = $4)\\n              AND ($5::uuid IS NULL OR i.parent_issue_id = $5)\\n              AND (\\n                  $6::text IS NULL\\n                  OR i.title ILIKE $6 ESCAPE '\\\\'\\n                  OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\\\\'\\n              )\\n              AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\\\\')\\n              AND (\\n                  $8::uuid IS NULL\\n                  OR EXISTS (\\n                      SELECT 1\\n                      FROM issue_assignees ia\\n                      WHERE ia.issue_id = i.id AND ia.user_id = $8\\n                  )\\n              )\\n              AND (\\n                  $9::uuid IS NULL\\n                  OR EXISTS (\\n                      SELECT 1\\n                      FROM issue_tags it\\n                      WHERE it.issue_id = i.id AND it.tag_id = $9\\n                  )\\n              )\\n              AND (\\n                  $10::uuid[] IS NULL\\n                  OR EXISTS (\\n                      SELECT 1\\n                      FROM issue_tags it\\n                      WHERE it.issue_id = i.id AND it.tag_id = ANY($10)\\n                  )\\n              )\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"count\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"UuidArray\",\n        {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        },\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Uuid\",\n        \"Uuid\",\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"d2275416ba3ddb1bbaf929787b5df4c736084582ddebdbe9f4a4aa6853727484\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-d4931e5f81b8a68f983d3e43b319a0f145339b7d8f878c3c1a765f41f3f4697c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT EXISTS(SELECT 1 FROM workspaces WHERE local_workspace_id = $1) AS \\\"exists!\\\"\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"d4931e5f81b8a68f983d3e43b319a0f145339b7d8f878c3c1a765f41f3f4697c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT role AS \\\"role!: MemberRole\\\"\\n        FROM organization_member_metadata\\n        WHERE organization_id = $1 AND user_id = $2\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-da660b40d95d5fa5e9176b0b5859bb594e83fc21664f062f29ed148969b17c0b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id,\\n                gh_pr_url,\\n                claude_code_session_id,\\n                ip_address AS \\\"ip_address: IpNetwork\\\",\\n                review_cache,\\n                last_viewed_at,\\n                r2_path,\\n                deleted_at,\\n                created_at,\\n                email,\\n                pr_title,\\n                status,\\n                github_installation_id,\\n                pr_owner,\\n                pr_repo,\\n                pr_number\\n            FROM reviews\\n            WHERE id = $1 AND deleted_at IS NULL\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"gh_pr_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"claude_code_session_id\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"ip_address: IpNetwork\",\n        \"type_info\": \"Inet\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"review_cache\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"last_viewed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"r2_path\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"pr_title\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"status\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"github_installation_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"pr_owner\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"pr_repo\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"pr_number\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"da660b40d95d5fa5e9176b0b5859bb594e83fc21664f062f29ed148969b17c0b\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-db645795e781123885506fe4f8e4f1a77a82d0dde22fd876f9b84dd04063db65.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id               AS \\\"id!: Uuid\\\",\\n                organization_id  AS \\\"organization_id!: Uuid\\\",\\n                name             AS \\\"name!\\\",\\n                color            AS \\\"color!\\\",\\n                sort_order       AS \\\"sort_order!\\\",\\n                created_at       AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at       AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM projects\\n            WHERE organization_id = $1\\n            ORDER BY sort_order ASC, created_at DESC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"db645795e781123885506fe4f8e4f1a77a82d0dde22fd876f9b84dd04063db65\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO users (id, email, first_name, last_name, username)\\n        VALUES ($1, $2, $3, $4, $5)\\n        ON CONFLICT (id) DO UPDATE\\n        SET email = EXCLUDED.email,\\n            first_name = EXCLUDED.first_name,\\n            last_name = EXCLUDED.last_name,\\n            username = EXCLUDED.username\\n        RETURNING\\n            id           AS \\\"id!: Uuid\\\",\\n            email        AS \\\"email!\\\",\\n            first_name   AS \\\"first_name?\\\",\\n            last_name    AS \\\"last_name?\\\",\\n            username     AS \\\"username?\\\",\\n            created_at   AS \\\"created_at!\\\",\\n            updated_at   AS \\\"updated_at!\\\"\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"first_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM tags WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-ddb471fb54ccc7b6438a15f8de8c9eba7e32eb51866b7f2871df2300bfe7cf40.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at)\\n            VALUES ($1, $2, $3, $4, $5, $6, $7)\\n            RETURNING\\n                id              AS \\\"id!: Uuid\\\",\\n                project_id      AS \\\"project_id!: Uuid\\\",\\n                name            AS \\\"name!\\\",\\n                color           AS \\\"color!\\\",\\n                sort_order      AS \\\"sort_order!\\\",\\n                hidden          AS \\\"hidden!\\\",\\n                created_at      AS \\\"created_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"hidden!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Varchar\",\n        \"Varchar\",\n        \"Int4\",\n        \"Bool\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"ddb471fb54ccc7b6438a15f8de8c9eba7e32eb51866b7f2871df2300bfe7cf40\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-df27dcabe19b0b1433865256b090f84474986985ec0d204ab17becd6d3568d0a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM github_app_installations\\n            WHERE github_installation_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"df27dcabe19b0b1433865256b090f84474986985ec0d204ab17becd6d3568d0a\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-dfad52e56108583960a73a9b89cb91e4da97e212313adc2db73a64cc8c473a87.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                a.id                    AS \\\"id!: Uuid\\\",\\n                a.blob_id               AS \\\"blob_id!: Uuid\\\",\\n                a.issue_id              AS \\\"issue_id?: Uuid\\\",\\n                a.comment_id            AS \\\"comment_id?: Uuid\\\",\\n                a.created_at            AS \\\"created_at!: DateTime<Utc>\\\",\\n                a.expires_at            AS \\\"expires_at?: DateTime<Utc>\\\",\\n                b.blob_path             AS \\\"blob_path!\\\",\\n                b.thumbnail_blob_path   AS \\\"thumbnail_blob_path?\\\",\\n                b.original_name         AS \\\"original_name!\\\",\\n                b.mime_type             AS \\\"mime_type?\\\",\\n                b.size_bytes            AS \\\"size_bytes!\\\",\\n                b.hash                  AS \\\"hash!\\\",\\n                b.width                 AS \\\"width?\\\",\\n                b.height                AS \\\"height?\\\"\\n            FROM attachments a\\n            INNER JOIN blobs b ON b.id = a.blob_id\\n            WHERE a.comment_id = $1\\n            ORDER BY a.created_at ASC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"dfad52e56108583960a73a9b89cb91e4da97e212313adc2db73a64cc8c473a87\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-dfbf03c5f333dfc7f531f415f4816603d080a544699705329dfe2e93e33c2886.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                b.id                  AS \\\"id!: Uuid\\\",\\n                b.project_id          AS \\\"project_id!: Uuid\\\",\\n                b.blob_path           AS \\\"blob_path!\\\",\\n                b.thumbnail_blob_path AS \\\"thumbnail_blob_path?\\\",\\n                b.original_name       AS \\\"original_name!\\\",\\n                b.mime_type           AS \\\"mime_type?\\\",\\n                b.size_bytes          AS \\\"size_bytes!\\\",\\n                b.hash                AS \\\"hash!\\\",\\n                b.width               AS \\\"width?\\\",\\n                b.height              AS \\\"height?\\\",\\n                b.created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                b.updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM attachments a\\n            INNER JOIN blobs b ON b.id = a.blob_id\\n            WHERE a.id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"dfbf03c5f333dfc7f531f415f4816603d080a544699705329dfe2e93e33c2886\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-e0a011a3d29e5ae50ff06a264c39655e32be70ba76939a82184bf0dc5e8d6968.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                blob_path           AS \\\"blob_path!\\\",\\n                thumbnail_blob_path AS \\\"thumbnail_blob_path?\\\",\\n                original_name       AS \\\"original_name!\\\",\\n                mime_type           AS \\\"mime_type?\\\",\\n                size_bytes          AS \\\"size_bytes!\\\",\\n                hash                AS \\\"hash!\\\",\\n                width               AS \\\"width?\\\",\\n                height              AS \\\"height?\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM blobs\\n            WHERE project_id = $1 AND hash = $2\\n            LIMIT 1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"e0a011a3d29e5ae50ff06a264c39655e32be70ba76939a82184bf0dc5e8d6968\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-e161b18662654bba364a273f67486b9366d5a972fc4968b03aa4c9067b92389d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                a.id                    AS \\\"id!: Uuid\\\",\\n                a.blob_id               AS \\\"blob_id!: Uuid\\\",\\n                a.issue_id              AS \\\"issue_id?: Uuid\\\",\\n                a.comment_id            AS \\\"comment_id?: Uuid\\\",\\n                a.created_at            AS \\\"created_at!: DateTime<Utc>\\\",\\n                a.expires_at            AS \\\"expires_at?: DateTime<Utc>\\\",\\n                b.blob_path             AS \\\"blob_path!\\\",\\n                b.thumbnail_blob_path   AS \\\"thumbnail_blob_path?\\\",\\n                b.original_name         AS \\\"original_name!\\\",\\n                b.mime_type             AS \\\"mime_type?\\\",\\n                b.size_bytes            AS \\\"size_bytes!\\\",\\n                b.hash                  AS \\\"hash!\\\",\\n                b.width                 AS \\\"width?\\\",\\n                b.height                AS \\\"height?\\\"\\n            FROM attachments a\\n            INNER JOIN blobs b ON b.id = a.blob_id\\n            WHERE a.id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"comment_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"expires_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"blob_path!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"thumbnail_blob_path?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"original_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"mime_type?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"size_bytes!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"hash!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"width?\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"height?\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      true,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"e161b18662654bba364a273f67486b9366d5a972fc4968b03aa4c9067b92389d\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-e185203cf84e43b801dfb23b4159e34aeaef1154dcd3d6811ab504915497ccf7.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM notifications WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"e185203cf84e43b801dfb23b4159e34aeaef1154dcd3d6811ab504915497ccf7\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-e2894ddc831401000e89318423f70f221248b494ff81c1966e59c32e70a87502.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n                SELECT\\n                    id,\\n                    organization_id,\\n                    user_id,\\n                    notification_type as \\\"notification_type!: NotificationType\\\",\\n                    payload as \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                    issue_id,\\n                    comment_id,\\n                    seen,\\n                    dismissed_at,\\n                    created_at\\n                FROM notifications\\n                WHERE user_id = $1 AND dismissed_at IS NULL\\n                ORDER BY created_at DESC\\n                \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"notification_type!: NotificationType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"payload!: sqlx::types::Json<NotificationPayload>\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"issue_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"seen\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"dismissed_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"e2894ddc831401000e89318423f70f221248b494ff81c1966e59c32e70a87502\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-e2bf31db16ca8adc105f79f00c26d6af8b542f1f1e57e947ae39197d94dd3fed.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                user_id     AS \\\"user_id!: Uuid\\\",\\n                assigned_at AS \\\"assigned_at!: DateTime<Utc>\\\"\\n            FROM issue_assignees\\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"assigned_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"e2bf31db16ca8adc105f79f00c26d6af8b542f1f1e57e947ae39197d94dd3fed\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-e509e51e9b1fe5e989713ab048e2641e6d1450f5506502b5a261e93dbb284226.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                n.id AS \\\"id!: Uuid\\\",\\n                n.notification_type AS \\\"notification_type!: NotificationType\\\",\\n                n.payload AS \\\"payload!: sqlx::types::Json<NotificationPayload>\\\",\\n                n.issue_id AS \\\"issue_id?: Uuid\\\",\\n                n.created_at AS \\\"created_at!\\\",\\n                COALESCE(NULLIF(actor.first_name, ''), NULLIF(actor.username, ''), 'Someone') AS \\\"actor_name!\\\"\\n            FROM notifications n\\n            LEFT JOIN users actor\\n                ON actor.id = NULLIF(n.payload->>'actor_user_id', '')::uuid\\n            WHERE n.user_id = $1\\n              AND n.created_at >= $2\\n              AND n.created_at < $3\\n              AND n.dismissed_at IS NULL\\n              AND n.seen = FALSE\\n              AND NOT EXISTS (\\n                  SELECT 1\\n                  FROM notification_digest_deliveries d\\n                  WHERE d.notification_id = n.id\\n              )\\n            ORDER BY n.created_at DESC\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"notification_type!: NotificationType\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"notification_type\",\n            \"kind\": {\n              \"Enum\": [\n                \"issue_comment_added\",\n                \"issue_status_changed\",\n                \"issue_assignee_changed\",\n                \"issue_deleted\",\n                \"issue_title_changed\",\n                \"issue_description_changed\",\n                \"issue_priority_changed\",\n                \"issue_unassigned\",\n                \"issue_comment_reaction\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"payload!: sqlx::types::Json<NotificationPayload>\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"actor_name!\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Timestamptz\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      false,\n      null\n    ]\n  },\n  \"hash\": \"e509e51e9b1fe5e989713ab048e2641e6d1450f5506502b5a261e93dbb284226\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-e553f31a70abb9d7e39755633f67f2b9c21ab6552986181acc10a1523852655c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO github_app_pending_installations (organization_id, user_id, state_token, expires_at)\\n            VALUES ($1, $2, $3, $4)\\n            RETURNING\\n                id,\\n                organization_id,\\n                user_id,\\n                state_token,\\n                expires_at,\\n                created_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"state_token\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"expires_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"e553f31a70abb9d7e39755633f67f2b9c21ab6552986181acc10a1523852655c\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-ea41e984b0e7c1c952cb265659a443de1967c2d024be80ae1d9878e27b474986.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            UPDATE github_app_installations\\n            SET repository_selection = $2, updated_at = NOW()\\n            WHERE github_installation_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"ea41e984b0e7c1c952cb265659a443de1967c2d024be80ae1d9878e27b474986\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-ec42318654455b31681de774e8d1e07efae222e1d5c97146a4e0054f74c0b2cc.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM issue_followers WHERE \\\"issue_id\\\" IN (SELECT id FROM issues WHERE \\\"project_id\\\" = $1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"ec42318654455b31681de774e8d1e07efae222e1d5c97146a4e0054f74c0b2cc\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT\\n            omm.user_id AS \\\"user_id!: Uuid\\\",\\n            omm.role AS \\\"role!: MemberRole\\\",\\n            omm.joined_at AS \\\"joined_at!\\\",\\n            u.first_name AS \\\"first_name?\\\",\\n            u.last_name AS \\\"last_name?\\\",\\n            u.username AS \\\"username?\\\",\\n            u.email AS \\\"email?\\\",\\n            oa.avatar_url AS \\\"avatar_url?\\\"\\n        FROM organization_member_metadata omm\\n        INNER JOIN users u ON omm.user_id = u.id\\n        LEFT JOIN LATERAL (\\n            SELECT avatar_url\\n            FROM oauth_accounts\\n            WHERE user_id = omm.user_id\\n            ORDER BY created_at ASC\\n            LIMIT 1\\n        ) oa ON true\\n        WHERE omm.organization_id = $1\\n        ORDER BY omm.joined_at ASC\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"joined_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"first_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"last_name?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"username?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"email?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"avatar_url?\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f00d3b1e7ce2a7fe5e8e3132e32b7ea50b0d6865f0708b6113bea68a54d857f4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            DELETE FROM github_app_repositories\\n            WHERE installation_id = $1 AND github_repo_id = ANY($2)\\n            \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8Array\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"f00d3b1e7ce2a7fe5e8e3132e32b7ea50b0d6865f0708b6113bea68a54d857f4\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f036504d4bec68969c545881e684ab0dd9fcb85285e4d541d97a7e6be1681e38.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id                  AS \\\"id!: Uuid\\\",\\n                url                 AS \\\"url!: String\\\",\\n                number              AS \\\"number!: i32\\\",\\n                status              AS \\\"status!: PullRequestStatus\\\",\\n                merged_at           AS \\\"merged_at: DateTime<Utc>\\\",\\n                merge_commit_sha    AS \\\"merge_commit_sha: String\\\",\\n                target_branch_name  AS \\\"target_branch_name!: String\\\",\\n                issue_id            AS \\\"issue_id!: Uuid\\\",\\n                workspace_id        AS \\\"workspace_id: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM pull_requests\\n            WHERE url = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"url!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"number!: i32\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"status!: PullRequestStatus\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"pull_request_status\",\n            \"kind\": {\n              \"Enum\": [\n                \"open\",\n                \"merged\",\n                \"closed\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"merged_at: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"merge_commit_sha: String\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"target_branch_name!: String\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f036504d4bec68969c545881e684ab0dd9fcb85285e4d541d97a7e6be1681e38\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f04fc738e518f28f1f148245ae92c177289f673ef6a631d65b92bd5ee841bb52.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                author_id   AS \\\"author_id: Uuid\\\",\\n                parent_id   AS \\\"parent_id: Uuid\\\",\\n                message     AS \\\"message!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at  AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM issue_comments\\n            WHERE issue_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"author_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"parent_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"message!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f04fc738e518f28f1f148245ae92c177289f673ef6a631d65b92bd5ee841bb52\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f20260cbe7dff433f5aefa4fe14fa9bc89a6ad97d550420c768e326de6ae5ae6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id           AS \\\"id!: Uuid\\\",\\n                name         AS \\\"name!\\\",\\n                slug         AS \\\"slug!\\\",\\n                is_personal  AS \\\"is_personal!\\\",\\n                issue_prefix AS \\\"issue_prefix!\\\",\\n                created_at   AS \\\"created_at!\\\",\\n                updated_at   AS \\\"updated_at!\\\"\\n            FROM organizations\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"slug!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"is_personal!\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"issue_prefix!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f20260cbe7dff433f5aefa4fe14fa9bc89a6ad97d550420c768e326de6ae5ae6\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f2e8e193cc183a69527708cdd65fc8f0dc9ac4d9fcf67b8ac5285d068c161e06.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM issue_comments WHERE id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"f2e8e193cc183a69527708cdd65fc8f0dc9ac4d9fcf67b8ac5285d068c161e06\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f360cdb953a3e2fb64123ab8351d42029b58919a0ac0e8900320fee60c5c93b2.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id,\\n                organization_id,\\n                github_installation_id,\\n                github_account_login,\\n                github_account_type,\\n                repository_selection,\\n                installed_by_user_id,\\n                suspended_at,\\n                created_at,\\n                updated_at\\n            FROM github_app_installations\\n            WHERE organization_id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"github_installation_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"github_account_login\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"github_account_type\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"repository_selection\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"installed_by_user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"suspended_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f360cdb953a3e2fb64123ab8351d42029b58919a0ac0e8900320fee60c5c93b2\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f4015e2352122c1819ff7e7a4dff62b9387f439d80f47bf457b20663c24b861a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM users WHERE \\\"id\\\" IN (SELECT user_id FROM organization_member_metadata WHERE \\\"organization_id\\\" = $1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"f4015e2352122c1819ff7e7a4dff62b9387f439d80f47bf457b20663c24b861a\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f403f8876022d19b330e4fc0b550e2ef8bb14a08de3530cb541ae09e1a479d45.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM issue_tags WHERE \\\"issue_id\\\" IN (SELECT id FROM issues WHERE \\\"project_id\\\" = $1)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"f403f8876022d19b330e4fc0b550e2ef8bb14a08de3530cb541ae09e1a479d45\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f5eff8b44dfd3aceb4e1fc1a4b58c4e74c8fac220e9943daf4103eb9e57af051.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT 1 AS v FROM workspaces WHERE \\\"project_id\\\" = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"v\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"f5eff8b44dfd3aceb4e1fc1a4b58c4e74c8fac220e9943daf4103eb9e57af051\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        DELETE FROM organization_member_metadata\\n        WHERE organization_id = $1 AND user_id = $2\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-f9491f7f61aec53b057689bc722b6f20c2646510bfcd8b38c27576769a53e750.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT\\n            organization_id AS \\\"organization_id!: Uuid\\\",\\n            user_id         AS \\\"user_id!: Uuid\\\",\\n            role            AS \\\"role!: MemberRole\\\",\\n            joined_at       AS \\\"joined_at!\\\",\\n            last_seen_at\\n        FROM organization_member_metadata\\n        WHERE organization_id = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"organization_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"user_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"role!: MemberRole\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"member_role\",\n            \"kind\": {\n              \"Enum\": [\n                \"admin\",\n                \"member\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"joined_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"last_seen_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"f9491f7f61aec53b057689bc722b6f20c2646510bfcd8b38c27576769a53e750\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                project_id  AS \\\"project_id!: Uuid\\\",\\n                name        AS \\\"name!\\\",\\n                color       AS \\\"color!\\\"\\n            FROM tags\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"color!\",\n        \"type_info\": \"Varchar\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-fcffbcc41e058a6d055bec006e7287fcfb26b609107d753e372faeb7f9d92302.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO github_app_installations (\\n                organization_id,\\n                github_installation_id,\\n                github_account_login,\\n                github_account_type,\\n                repository_selection,\\n                installed_by_user_id\\n            )\\n            VALUES ($1, $2, $3, $4, $5, $6)\\n            ON CONFLICT (github_installation_id) DO UPDATE SET\\n                organization_id = EXCLUDED.organization_id,\\n                github_account_login = EXCLUDED.github_account_login,\\n                github_account_type = EXCLUDED.github_account_type,\\n                repository_selection = EXCLUDED.repository_selection,\\n                installed_by_user_id = EXCLUDED.installed_by_user_id,\\n                suspended_at = NULL,\\n                updated_at = NOW()\\n            RETURNING\\n                id,\\n                organization_id,\\n                github_installation_id,\\n                github_account_login,\\n                github_account_type,\\n                repository_selection,\\n                installed_by_user_id,\\n                suspended_at,\\n                created_at,\\n                updated_at\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"organization_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"github_installation_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"github_account_login\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"github_account_type\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"repository_selection\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"installed_by_user_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"suspended_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"fcffbcc41e058a6d055bec006e7287fcfb26b609107d753e372faeb7f9d92302\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-fe9ae1da931e14f97d432ad34fe636b4854c7f85665b90337b342663bdde68b9.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            SELECT\\n                id          AS \\\"id!: Uuid\\\",\\n                issue_id    AS \\\"issue_id!: Uuid\\\",\\n                author_id   AS \\\"author_id: Uuid\\\",\\n                parent_id   AS \\\"parent_id: Uuid\\\",\\n                message     AS \\\"message!\\\",\\n                created_at  AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at  AS \\\"updated_at!: DateTime<Utc>\\\"\\n            FROM issue_comments\\n            WHERE id = $1\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"issue_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"author_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"parent_id: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"message!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"fe9ae1da931e14f97d432ad34fe636b4854c7f85665b90337b342663bdde68b9\"\n}\n"
  },
  {
    "path": "crates/remote/.sqlx/query-ffea7acda162fba35e1b1acd2c6791bc917f086bf1c34816178282f0579c1eeb.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n            INSERT INTO issues (\\n                id, project_id, status_id, title, description, priority,\\n                start_date, target_date, completed_at, sort_order,\\n                parent_issue_id, parent_issue_sort_order, extension_metadata,\\n                creator_user_id\\n            )\\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\\n            RETURNING\\n                id                  AS \\\"id!: Uuid\\\",\\n                project_id          AS \\\"project_id!: Uuid\\\",\\n                issue_number        AS \\\"issue_number!\\\",\\n                simple_id           AS \\\"simple_id!\\\",\\n                status_id           AS \\\"status_id!: Uuid\\\",\\n                title               AS \\\"title!\\\",\\n                description         AS \\\"description?\\\",\\n                priority            AS \\\"priority: IssuePriority\\\",\\n                start_date          AS \\\"start_date?: DateTime<Utc>\\\",\\n                target_date         AS \\\"target_date?: DateTime<Utc>\\\",\\n                completed_at        AS \\\"completed_at?: DateTime<Utc>\\\",\\n                sort_order          AS \\\"sort_order!\\\",\\n                parent_issue_id     AS \\\"parent_issue_id?: Uuid\\\",\\n                parent_issue_sort_order AS \\\"parent_issue_sort_order?\\\",\\n                extension_metadata  AS \\\"extension_metadata!: Value\\\",\\n                creator_user_id     AS \\\"creator_user_id?: Uuid\\\",\\n                created_at          AS \\\"created_at!: DateTime<Utc>\\\",\\n                updated_at          AS \\\"updated_at!: DateTime<Utc>\\\"\\n            \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"project_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"issue_number!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"simple_id!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"status_id!: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"title!\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"description?\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"priority: IssuePriority\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"start_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"target_date?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"completed_at?: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"sort_order!\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 12,\n        \"name\": \"parent_issue_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 13,\n        \"name\": \"parent_issue_sort_order?\",\n        \"type_info\": \"Float8\"\n      },\n      {\n        \"ordinal\": 14,\n        \"name\": \"extension_metadata!: Value\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 15,\n        \"name\": \"creator_user_id?: Uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 16,\n        \"name\": \"created_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 17,\n        \"name\": \"updated_at!: DateTime<Utc>\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Varchar\",\n        \"Text\",\n        {\n          \"Custom\": {\n            \"name\": \"issue_priority\",\n            \"kind\": {\n              \"Enum\": [\n                \"urgent\",\n                \"high\",\n                \"medium\",\n                \"low\"\n              ]\n            }\n          }\n        },\n        \"Timestamptz\",\n        \"Timestamptz\",\n        \"Timestamptz\",\n        \"Float8\",\n        \"Uuid\",\n        \"Float8\",\n        \"Jsonb\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      false,\n      true,\n      true,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"ffea7acda162fba35e1b1acd2c6791bc917f086bf1c34816178282f0579c1eeb\"\n}\n"
  },
  {
    "path": "crates/remote/AGENTS.md",
    "content": "# Remote Crate — Agent Guidelines\n\nThe `remote` crate is the hosted Vibe Kanban Cloud server: an Axum HTTP API, a React SPA frontend, and real-time sync via ElectricSQL.\n\n> See also: [root AGENTS.md](../../AGENTS.md) for repo-wide conventions.\n\n## Architecture\n\n```\nremote-server (Axum, port 8081)\n  ├── /v1/*         REST API (CRUD + auth + webhooks)\n  ├── /shape/*      ElectricSQL proxy (auth-gated shape subscriptions)\n  └── /srv/static   React SPA (built by Vite, served as fallback)\n\nPostgreSQL (port 5432)\n  └── wal_level=logical, electric_sync role with REPLICATION\n\nElectricSQL (port 3000, internal)\n  └── Subscribes to Postgres via logical replication, streams shapes over HTTP\n```\n\n## Build & Run\n\n```bash\n# (from the repo root)\npnpm run remote:dev\n\n# Run desktop client against local server\nexport VK_SHARED_API_BASE=http://localhost:3000\npnpm run dev\n```\n\nTo teardown and clean the remote stack (including wiping the database):\n\n```\n(from the repo root)\npnpm run remote:dev:clean\n```\n\nMulti-stage Docker build: Node (frontend) → Rust (server) → Debian slim runtime.\n\nThe billing crate (`vk-billing` feature) is a private dependency stripped at build time when `FEATURES` is empty. Do not add imports from the `billing` crate without gating them behind `#[cfg(feature = \"vk-billing\")]`.\n\n## Key Modules\n\n| Module | Purpose |\n|--------|---------|\n| `app.rs` | Server bootstrap: pool → migrations → electric role → JWT → OAuth → services → listen |\n| `config.rs` | `RemoteServerConfig` parsed from env vars. Empty strings treated as unset. |\n| `state.rs` | `AppState` shared across all routes (pool, JWT, OAuth, billing, R2, etc.) |\n| `shapes.rs` | 16 const `ShapeDefinition<T>` instances for ElectricSQL sync |\n| `shape_definition.rs` | `ShapeDefinition` struct, `ShapeExport` trait, `define_shape!` macro |\n| `mutation_definition.rs` | `MutationBuilder` for type-safe CRUD routes + TS type generation |\n| `response.rs` | `MutationResponse<T>` — wraps data + Postgres `txid` |\n| `routes/electric_proxy.rs` | Auth-gated proxy forwarding shape requests to ElectricSQL |\n| `routes/mod.rs` | Router tree, SPA fallback from `/srv/static` |\n| `db/mod.rs` | Pool creation, migrations, `ensure_electric_role_password()` |\n| `auth/` | JWT, OAuth providers (GitHub/Google), session middleware |\n\n## ElectricSQL Integration\n\nVibe Kanban uses [ElectricSQL](https://electric-sql.com) as a read-path sync engine: Postgres → ElectricSQL → clients over HTTP shapes. Writes go through the REST API.\n\n### How It Works\n\n1. **Shapes** are single-table subscriptions with optional `WHERE`/`columns` filters, defined as constants in `shapes.rs`.\n2. The **electric proxy** (`routes/electric_proxy.rs`) checks org/project membership, then forwards shape requests to the internal ElectricSQL service.\n3. **Mutations** (create/update/delete) go through REST endpoints and return `MutationResponse<T>` containing the Postgres transaction ID (`txid`).\n4. The frontend uses `txid` to know when Electric has caught up — once the mutation appears in the Electric stream, optimistic state is dropped.\n\n### The txid Handshake\n\nEvery mutation handler must return the Postgres transaction ID:\n\n```rust\n// In a route handler\nlet result = db::issues::create_issue(&pool, &payload).await?;\n// MutationResponse includes txid from pg_current_xact_id()\nOk(Json(MutationResponse { data: result.data, txid: result.txid }))\n```\n\nThe frontend awaits this txid on the Electric stream before dropping optimistic state. Omitting the txid causes UI flicker.\n\n### Adding a New Synced Table\n\n1. **Create a migration** that creates the table and calls `ALTER TABLE ... REPLICA IDENTITY FULL` + `CALL electric.electrify('table_name')` (see `20260114000000_electric_sync_tables.sql` for examples).\n2. **Define a shape** in `shapes.rs` using the `define_shape!` macro. Shapes are parameterised by scope (organization, project, issue).\n3. **Add a proxy route** if the shape needs a new scope pattern in `electric_proxy.rs`.\n4. **Return txid** from all mutation routes for that table.\n\n### Security\n\n- **ElectricSQL is internal only** — never expose it directly to clients. All shape requests go through the auth-gated proxy in `electric_proxy.rs`.\n- Shape definitions (table, WHERE, columns) are server-controlled constants. The client cannot request arbitrary tables.\n\n## Mutation Pattern\n\nAll CRUD routes follow a consistent pattern using `MutationBuilder`:\n\n```rust\nMutationBuilder::<Entity, CreatePayload, UpdatePayload>::new(\"entities\")\n    .list(list_handler)\n    .get(get_handler)\n    .create(create_handler)\n    .update(update_handler)\n    .delete(delete_handler)\n    .build()\n```\n\nThis generates both the Axum router and TypeScript type metadata (via `HasJsonPayload<T>` trait). When adding a new entity, follow this pattern so types are auto-generated by `pnpm run generate-types`.\n\n## Authentication & Authorisation\n\n- **JWT** (`auth/jwt.rs`): Signed with `VIBEKANBAN_REMOTE_JWT_SECRET`. All protected routes use `require_session` middleware.\n- **OAuth** (`auth/provider.rs`): GitHub and Google. At least one must be configured. Empty env vars are treated as disabled.\n- **Membership**: All resource routes check organisation/project membership before DB access. Use `RequestContext` from the middleware to get user info.\n\n## Frontend (`packages/remote-web/`)\n\n- React 18 + React Router 7 + Vite + Tailwind\n- Built during Docker image creation, served from `/srv/static`\n- Uses `VITE_APP_BASE_URL` and `VITE_API_BASE_URL` (baked in at build time)\n- OAuth uses PKCE flow (`pkce.ts`)\n- ElectricSQL shapes consumed via the proxy at `/shape/*`\n\n## Database\n\n- **Migrations**: SQLx-managed in `migrations/`, run at startup. Add new migrations with timestamp prefix.\n- **Offline mode**: Use `pnpm run remote:prepare-db` to generate SQLx offline data for CI builds.\n- **Pool**: 10 max connections.\n\n## Testing\n\n```bash\ncargo test --manifest-path crates/remote/Cargo.toml\n```\n\nSQLx compile-time checks require either a running Postgres or offline query data (`.sqlx/` directory). Run `pnpm run remote:prepare-db` to do this.\n\n## Shared Types (`api-types` crate)\n\nTypes shared between the remote server and the local desktop application belong in the `api-types` crate (`crates/api-types/`), not in the remote crate itself. Both `remote` and `server` depend on `api-types`.\n\nThe crate contains:\n\n- **Row types** — API representations of database entities (`Issue`, `Project`, `User`, `Workspace`, etc.)\n- **Request types** — create/update payloads (`CreateIssueRequest`, `UpdateProjectRequest`, etc.)\n- **Shared enums** — `IssuePriority`, `MemberRole`, `PullRequestStatus`, `NotificationType`, etc.\n\nAll types derive `TS` from `ts-rs` so they can be exported to TypeScript automatically. When adding a new entity that will be used by both backends, define its types in `api-types`.\n\n## Type Generation (`generate_types.rs`)\n\nThe binary at `src/bin/generate_types.rs` generates `shared/remote-types.ts` — the single TypeScript file consumed by the remote frontend. Run it with:\n\n```bash\npnpm run remote:generate-types        # write shared/remote-types.ts\npnpm run remote:generate-types --check # CI mode — exits non-zero if file is stale\n```\n\nThe generated file contains:\n\n1. **TypeScript interfaces** for every row and request type from `api-types` (each type's `::decl()` call in the `type_decls` vec).\n2. **`ShapeDefinition<T>` constants** — one per ElectricSQL shape, sourced from `shapes::all_shapes()`.\n3. **`MutationDefinition<TRow, TCreate, TUpdate>` constants** — one per CRUD entity, sourced from `routes::all_mutation_definitions()`.\n4. **Type helpers** (`MutationRowType`, `MutationCreateType`, `MutationUpdateType`) for extracting types from a mutation definition.\n\nWhen adding a new type to `api-types` that the remote frontend needs, add its `::decl()` call to the `type_decls` vec in `generate_types.rs` and re-run the generator.\n\n> The local desktop app has a separate generator (`crates/server/src/bin/generate_types.rs`) that outputs `shared/types.ts`.\n\n## Common Pitfalls\n\n- **Empty string vs unset**: Docker Compose `${VAR:-}` produces `\"\"`, which `std::env::var()` returns as `Ok(\"\")`. Always check `!v.is_empty()` for optional config.\n- **ElectricSQL startup order**: Remote server must start first to create the `electric_sync` role. ElectricSQL will fail to connect if it starts before the server runs migrations.\n- **Billing feature gate**: All billing code must be behind `#[cfg(feature = \"vk-billing\")]`. The `billing` crate is stripped from Cargo.toml during self-hosted Docker builds.\n- **Frontend URL vars are build-time**: `VITE_*` variables are baked into the JS bundle. Changing them requires a rebuild.\n- **SPA fallback path**: The frontend is served from `/srv/static` (hardcoded). This path only exists inside the Docker container.\n"
  },
  {
    "path": "crates/remote/Cargo.toml",
    "content": "[package]\nname = \"remote\"\nversion = \"0.1.24\"\nedition = \"2024\"\npublish = false\n\n[[bin]]\nname = \"remote-generate-types\"\npath = \"src/bin/generate_types.rs\"\n\n[features]\ndefault = []\nvk-billing = [\"dep:billing\"]\n\n[dependencies]\n# private crate for billing functionality\nbilling = { git = \"ssh://git@github.com/BloopAI/vibe-kanban-private\", branch = \"main\", package = \"billing\", optional = true }\n\nanyhow = \"1.0\"\naxum = { version = \"0.8.4\", features = [\"macros\", \"multipart\", \"ws\"] }\naxum-extra = { version = \"0.10.3\", features = [\"typed-header\"] }\naes-gcm = \"0.10\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\nfutures = \"0.3\"\nfutures-util = \"0.3\"\nasync-trait = \"0.1\"\nreqwest = { version = \"0.13\", default-features = false, features = [\"json\", \"query\", \"form\", \"stream\", \"rustls\"] }\notel-reqwest = { package = \"reqwest\", version = \"0.12\", default-features = false, features = [\"blocking\", \"rustls-tls-webpki-roots-no-provider\"] }\nrustls = { version = \"0.23\", default-features = false, features = [\"aws_lc_rs\", \"std\", \"tls12\"] }\nsecrecy = \"0.10.3\"\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = { version = \"1.0\", features = [\"preserve_order\"] }\nsqlx = { version = \"0.8.6\", default-features = false, features = [\"runtime-tokio\", \"tls-rustls-aws-lc-rs\", \"postgres\", \"uuid\", \"chrono\", \"json\", \"macros\", \"migrate\", \"ipnetwork\"] }\nipnetwork = \"0.20\"\ntokio = { version = \"1.0\", features = [\"full\"] }\ntower-http = { version = \"0.5\", features = [\"cors\", \"request-id\", \"trace\", \"fs\", \"validate-request\", \"compression-gzip\", \"compression-br\"] }\ntracing = \"0.1.43\"\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"fmt\", \"json\"] }\ntracing-error = \"0.2\"\ntracing-opentelemetry = \"0.32\"\nopentelemetry = { version = \"0.31\", features = [\"trace\"] }\nopentelemetry_sdk = { version = \"0.31\", features = [\"rt-tokio\"] }\nopentelemetry-application-insights = \"0.44\"\nopentelemetry-http = { version = \"0.31\", features = [\"reqwest\", \"reqwest-blocking\"] }\nthiserror = \"2.0.12\"\nts-rs = { git = \"https://github.com/xazukx/ts-rs.git\", branch = \"use-ts-enum\", features = [\"uuid-impl\", \"chrono-impl\", \"no-serde-warnings\", \"serde-json-impl\"] }\napi-types = { path = \"../api-types\" }\nutils = { path = \"../utils\" }\nuuid = { version = \"1\", features = [\"serde\", \"v4\"] }\njsonwebtoken = { version = \"10.2.0\", features = [\"rust_crypto\"] }\nrand = \"0.9\"\nsha2 = \"0.10\"\nhmac = \"0.12\"\nsubtle = \"2.5\"\nhex = \"0.4\"\nurlencoding = \"2.1\"\nurl = \"2.5\"\nbase64 = \"0.22\"\naws-sdk-s3 = { version = \"1.65\", default-features = false, features = [\"behavior-version-latest\", \"rt-tokio\"] }\naws-credential-types = \"1.2\"\nazure_core = \"0.31\"\nazure_storage_blob = \"0.8\"\nazure_identity = \"0.31\"\ntime = \"0.3\"\nimage = { version = \"0.25\", default-features = false, features = [\"jpeg\", \"png\", \"gif\", \"webp\"] }\ntempfile = \"3\"\ntar = \"0.4\"\nflate2 = \"1.0\"\n\n\n[workspace]\n"
  },
  {
    "path": "crates/remote/Dockerfile",
    "content": "# syntax=docker/dockerfile:1.6\n\nARG APP_NAME=remote\nARG FEATURES=\"\"\nARG VITE_RELAY_API_BASE_URL=\"\"\nARG VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY=\"\"\n\nFROM node:20-alpine AS fe-builder\nARG VITE_RELAY_API_BASE_URL\nARG VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY\nWORKDIR /repo\nENV VITE_RELAY_API_BASE_URL=${VITE_RELAY_API_BASE_URL}\nENV VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY=${VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY}\n\nRUN corepack enable\n\nCOPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./\nCOPY packages/local-web/package.json packages/local-web/package.json\nCOPY packages/remote-web/package.json packages/remote-web/package.json\nCOPY packages/ui/package.json packages/ui/package.json\nCOPY packages/web-core/package.json packages/web-core/package.json\n\nRUN --mount=type=cache,id=pnpm,target=/pnpm/store \\\n    pnpm install --frozen-lockfile\n\nCOPY packages/remote-web/ packages/remote-web/\nCOPY packages/local-web/tailwind.new.config.js packages/local-web/tailwind.new.config.js\nCOPY packages/public/ packages/public/\nCOPY packages/ui/ packages/ui/\nCOPY packages/web-core/ packages/web-core/\nCOPY shared/ shared/\n\nRUN pnpm -C packages/remote-web build\n\nFROM rust:1.93-slim-bookworm AS builder\nARG APP_NAME\nARG FEATURES\nARG POSTHOG_API_KEY=\"\"\nARG POSTHOG_API_ENDPOINT=\"\"\nARG SENTRY_DSN_REMOTE=\"\"\n\nENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse\nENV CARGO_NET_GIT_FETCH_WITH_CLI=true\nENV CARGO_TARGET_DIR=/app/target\nENV POSTHOG_API_KEY=${POSTHOG_API_KEY}\nENV POSTHOG_API_ENDPOINT=${POSTHOG_API_ENDPOINT}\nENV SENTRY_DSN_REMOTE=${SENTRY_DSN_REMOTE}\n\n# Install build dependencies, plus git and openssh-client for private repo access\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates git openssh-client \\\n  && mkdir -p -m 0700 /root/.ssh \\\n  && ssh-keyscan github.com >> /root/.ssh/known_hosts \\\n  && chmod 600 /root/.ssh/known_hosts \\\n  && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\nCOPY rust-toolchain.toml ./\nRUN cargo --version >/dev/null\nCOPY Cargo.toml Cargo.lock ./\n# Copy workspace member manifests so Cargo can resolve workspace-shared deps\n# without invalidating the build on every unrelated source change.\nCOPY crates/server/Cargo.toml crates/server/Cargo.toml\nCOPY crates/trusted-key-auth/Cargo.toml crates/trusted-key-auth/Cargo.toml\nCOPY crates/mcp/Cargo.toml crates/mcp/Cargo.toml\nCOPY crates/db/Cargo.toml crates/db/Cargo.toml\nCOPY crates/executors/Cargo.toml crates/executors/Cargo.toml\nCOPY crates/services/Cargo.toml crates/services/Cargo.toml\nCOPY crates/worktree-manager/Cargo.toml crates/worktree-manager/Cargo.toml\nCOPY crates/workspace-manager/Cargo.toml crates/workspace-manager/Cargo.toml\nCOPY crates/relay-control/Cargo.toml crates/relay-control/Cargo.toml\nCOPY crates/server-info/Cargo.toml crates/server-info/Cargo.toml\nCOPY crates/git/Cargo.toml crates/git/Cargo.toml\nCOPY crates/git-host/Cargo.toml crates/git-host/Cargo.toml\nCOPY crates/local-deployment/Cargo.toml crates/local-deployment/Cargo.toml\nCOPY crates/deployment/Cargo.toml crates/deployment/Cargo.toml\nCOPY crates/review/Cargo.toml crates/review/Cargo.toml\nCOPY crates/api-types crates/api-types\nCOPY crates/remote crates/remote\nCOPY crates/utils crates/utils\nCOPY assets assets\n\nRUN mkdir -p /app/bin\n\n# When building without FEATURES (self-hosted), strip the private billing dependency\n# and its feature flag from Cargo.toml so cargo doesn't try to fetch the private git repo.\n# Also remove the Cargo.lock which references the private repo.\nRUN if [ -z \"${FEATURES}\" ]; then \\\n      sed -i '/^billing = {.*vibe-kanban-private.*/d' crates/remote/Cargo.toml; \\\n      sed -i '/^# private crate for billing/d' crates/remote/Cargo.toml; \\\n      sed -i 's/^vk-billing = \\[\"dep:billing\"\\]/vk-billing = []/' crates/remote/Cargo.toml; \\\n      rm -f crates/remote/Cargo.lock; \\\n    fi\n\nRUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \\\n    --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \\\n    --mount=type=cache,id=remote-target,target=/app/target \\\n    --mount=type=ssh \\\n    cargo build ${FEATURES:+--locked} --release --manifest-path crates/remote/Cargo.toml ${FEATURES:+--features ${FEATURES}} \\\n && cp /app/target/release/${APP_NAME} /app/bin/${APP_NAME}\n\nFROM debian:bookworm-slim AS runtime\nARG APP_NAME\n\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends ca-certificates libssl3 wget git \\\n  && rm -rf /var/lib/apt/lists/* \\\n  && useradd --system --create-home --uid 10001 appuser\n\nWORKDIR /srv\n\nCOPY --from=builder /app/bin/${APP_NAME} /usr/local/bin/${APP_NAME}\nCOPY --from=fe-builder /repo/packages/remote-web/dist /srv/static\n\nUSER appuser\n\nENV SERVER_LISTEN_ADDR=0.0.0.0:8081 \\\n    RUST_LOG=info\n\nEXPOSE 8081\n\nHEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\\n  CMD [\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:8081/v1/health\"]\n\nENTRYPOINT [\"/usr/local/bin/remote\"]\n"
  },
  {
    "path": "crates/remote/README.md",
    "content": "# Remote service\n\nThe `remote` crate contains the implementation of the Vibe Kanban hosted API.\n\n## Prerequisites\n\nCreate a `.env.remote` file in `crates/remote/` (this matches `pnpm run remote:dev`):\n\n```env\n# Required — generate with: openssl rand -base64 48\nVIBEKANBAN_REMOTE_JWT_SECRET=your_base64_encoded_secret\n\n# Required — password for the electric_sync database role used by ElectricSQL\nELECTRIC_ROLE_PASSWORD=your_secure_password\n\n# OAuth — at least one provider (GitHub or Google) must be configured\nGITHUB_OAUTH_CLIENT_ID=your_github_web_app_client_id\nGITHUB_OAUTH_CLIENT_SECRET=your_github_web_app_client_secret\nGOOGLE_OAUTH_CLIENT_ID=\nGOOGLE_OAUTH_CLIENT_SECRET=\n\n# Relay (required for tunnel/relay features)\n# For local HTTPS via Caddy on :3001:\nVITE_RELAY_API_BASE_URL=https://relay.localhost:3001\n\n# Optional — enables Virtuoso Message List license for remote web UI\nVITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY=\n\n# Optional — leave empty to disable invitation emails\nLOOPS_EMAIL_API_KEY=\n```\n\nGenerate `VIBEKANBAN_REMOTE_JWT_SECRET` once using `openssl rand -base64 48` and copy the value into `.env.remote`.\n\n## Run the stack locally\n\nFrom the repo root:\n\n```bash\npnpm run remote:dev\n```\n\nEquivalent manual command:\n\n```bash\ncd crates/remote\ndocker compose --env-file .env.remote -f docker-compose.yml up --build\n```\n\nThis starts PostgreSQL, ElectricSQL, the Remote Server, and the Relay Server.\n\n- Remote web UI/API: `https://localhost:3001` (via Caddy) or `http://localhost:3000` (direct)\n- Relay API: `http://localhost:8082`\n- Postgres: `postgres://remote:remote@localhost:5433/remote`\n\n## Run Vibe Kanban\n\nTo connect the desktop client to your local remote server (without relay/tunnel):\n\n```bash\nexport VK_SHARED_API_BASE=https://localhost:3001\n\npnpm run dev\n```\n\n## Local HTTPS with Caddy\n\nThe stack defaults to `https://localhost:3001` as its public URL. Use [Caddy](https://caddyserver.com) as a reverse proxy to terminate TLS — it automatically provisions a locally-trusted certificate for `localhost`.\n\n### 1. Install Caddy\n\n```bash\n# macOS\nbrew install caddy\n\n# Debian/Ubuntu\nsudo apt install caddy\n```\n\n### 2. Create a Caddyfile\n\nCreate a `Caddyfile` in the repository root:\n\n```text\nlocalhost:3001, relay.localhost:3001, *.relay.localhost:3001 {\n    tls internal\n\n    @relay host relay.localhost *.relay.localhost\n    handle @relay {\n        reverse_proxy 127.0.0.1:8082\n    }\n\n    @app expression `{http.request.host} == \"localhost:3001\" || {http.request.host} == \"localhost\"`\n    handle @app {\n        reverse_proxy 127.0.0.1:3000\n    }\n\n    respond \"not found\" 404\n}\n```\n\n### 3. Update OAuth callback URLs\n\nUpdate your OAuth application to use `https://localhost:3001`:\n\n- **GitHub**: `https://localhost:3001/v1/oauth/github/callback`\n- **Google**: `https://localhost:3001/v1/oauth/google/callback`\n\n### 4. Start everything\n\nStart Docker services as usual, then start Caddy in a separate terminal:\n\n```bash\n# Terminal 1 — start the stack\npnpm run remote:dev\n\n# Terminal 2 — start Caddy (from repo root)\ncaddy run --config Caddyfile\n```\n\nThe first time Caddy runs it installs a local CA certificate — you may be prompted for your password.\n\nOpen **https://localhost:3001** in your browser.\n\n> **Tip:** To use plain HTTP instead (no Caddy), set `PUBLIC_BASE_URL=http://localhost:3000` in your `.env.remote`.\n\n## Run desktop with relay tunnel (optional)\n\nTo test relay/tunnel mode end-to-end:\n\n```bash\nexport VK_SHARED_API_BASE=https://localhost:3001\nexport VK_SHARED_RELAY_API_BASE=https://relay.localhost:3001\n\npnpm run dev\n```\n\nQuick checks:\n\n```bash\ncurl -sk https://localhost:3001/v1/health\ncurl -sk https://relay.localhost:3001/health\n```\n\nIf `https://relay.localhost:3001/health` returns the remote frontend HTML instead of `{\"status\":\"ok\"}`, your Caddy host routing is incorrect.\n"
  },
  {
    "path": "crates/remote/docker-compose.yml",
    "content": "# Self-hosting: set PUBLIC_BASE_URL to your public URL (e.g. https://kanban.example.com)\n# and REMOTE_SERVER_PORTS=0.0.0.0:3000:8081 so the server is reachable from other hosts.\nservices:\n  remote-db:\n    image: postgres:16-alpine\n    command: [ \"postgres\", \"-c\", \"wal_level=logical\" ]\n    environment:\n      POSTGRES_DB: remote\n      POSTGRES_USER: remote\n      POSTGRES_PASSWORD: remote\n    volumes:\n      - remote-db-data:/var/lib/postgresql/data\n    healthcheck:\n      test: [ \"CMD-SHELL\", \"pg_isready -U remote -d remote\" ]\n      interval: 5s\n      timeout: 5s\n      retries: 5\n      start_period: 5s\n    ports:\n      - \"5433:5432\"\n\n  azurite:\n    image: mcr.microsoft.com/azure-storage/azurite:latest\n    command: \"azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --loose --skipApiVersionCheck\"\n    ports:\n      - \"10000:10000\"\n    volumes:\n      - azurite-data:/data\n    healthcheck:\n      test: nc 127.0.0.1 10000 -z\n      interval: 1s\n      retries: 30\n\n  azurite-init:\n    image: mcr.microsoft.com/azure-cli:latest\n    depends_on:\n      azurite:\n        condition: service_healthy\n    environment:\n      AZURE_STORAGE_CONNECTION_STRING: \"DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;\"\n    entrypoint: /bin/sh\n    command:\n      - -c\n      - |\n        set -e\n        az storage container create --name issue-attachments 2>/dev/null || true\n        az storage cors add --services b --methods GET PUT POST DELETE OPTIONS --origins '*' --allowed-headers '*' --exposed-headers '*' --max-age 3600\n        echo \"Azurite CORS configured\"\n\n  electric:\n    image: electricsql/electric:1.4.13\n    working_dir: /app\n    restart: on-failure\n    environment:\n      DATABASE_URL: postgresql://electric_sync:${ELECTRIC_ROLE_PASSWORD:-remote}@remote-db:5432/remote?sslmode=disable\n      PG_PROXY_PORT: 65432\n      LOGICAL_PUBLISHER_HOST: electric\n      AUTH_MODE: insecure\n      ELECTRIC_INSECURE: true\n      ELECTRIC_MANUAL_TABLE_PUBLISHING: true\n      ELECTRIC_USAGE_REPORTING: false\n      ELECTRIC_FEATURE_FLAGS: allow_subqueries,tagged_subqueries\n    volumes:\n      - electric-data:/app/persistent\n    depends_on:\n      remote-db:\n        condition: service_healthy\n      remote-server:\n        condition: service_healthy\n\n  remote-server:\n    build:\n      context: ../..\n      dockerfile: crates/remote/Dockerfile\n      args:\n        FEATURES: ${FEATURES:-}\n        POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}\n        POSTHOG_API_ENDPOINT: ${POSTHOG_API_ENDPOINT:-}\n        SENTRY_DSN_REMOTE: ${SENTRY_DSN_REMOTE:-}\n        VITE_RELAY_API_BASE_URL: ${VITE_RELAY_API_BASE_URL:-http://localhost:8082}\n        VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY: ${VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY:-}\n      ssh:\n        - default\n    depends_on:\n      remote-db:\n        condition: service_healthy\n      azurite-init:\n        condition: service_completed_successfully\n    environment:\n      RUST_LOG: info,remote=info\n\n      SERVER_DATABASE_URL: postgres://remote:remote@remote-db:5432/remote\n      SERVER_LISTEN_ADDR: 0.0.0.0:8081\n      ELECTRIC_URL: http://electric:3000\n      # OAuth — configure at least one provider (GitHub or Google)\n      GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}\n      GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}\n      GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}\n      GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}\n      VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET:?set in .env.remote}\n      # Optional — leave empty to disable invitation emails\n      LOOPS_EMAIL_API_KEY: ${LOOPS_EMAIL_API_KEY:-}\n      DIGEST_ENABLED: ${DIGEST_ENABLED:-false}\n      SERVER_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:3000}\n      ELECTRIC_ROLE_PASSWORD: ${ELECTRIC_ROLE_PASSWORD:-remote}\n      R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-}\n      R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}\n      R2_REVIEW_ENDPOINT: ${R2_REVIEW_ENDPOINT:-}\n      R2_REVIEW_BUCKET: ${R2_REVIEW_BUCKET:-}\n      REVIEW_WORKER_BASE_URL: ${REVIEW_WORKER_BASE_URL:-}\n      GITHUB_APP_ID: ${GITHUB_APP_ID:-}\n      GITHUB_APP_PRIVATE_KEY: ${GITHUB_APP_PRIVATE_KEY:-}\n      GITHUB_APP_WEBHOOK_SECRET: ${GITHUB_APP_WEBHOOK_SECRET:-}\n      GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}\n\n      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}\n      STRIPE_TEAM_SEAT_PRICE_ID: ${STRIPE_TEAM_SEAT_PRICE_ID:-}\n      STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}\n      STRIPE_FREE_SEAT_LIMIT: ${STRIPE_FREE_SEAT_LIMIT:-1}\n\n      AZURE_STORAGE_ACCOUNT_NAME: ${AZURE_STORAGE_ACCOUNT_NAME:-devstoreaccount1}\n      AZURE_STORAGE_ACCOUNT_KEY: ${AZURE_STORAGE_ACCOUNT_KEY:-Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==}\n      AZURE_STORAGE_CONTAINER_NAME: ${AZURE_STORAGE_CONTAINER_NAME:-issue-attachments}\n      AZURE_STORAGE_ENDPOINT_URL: ${AZURE_STORAGE_ENDPOINT_URL:-http://azurite:10000/devstoreaccount1}\n      AZURE_STORAGE_PUBLIC_ENDPOINT_URL: ${AZURE_STORAGE_PUBLIC_ENDPOINT_URL:-http://localhost:10000/devstoreaccount1}\n\n    ports:\n      - \"${REMOTE_SERVER_PORTS:-127.0.0.1:3000:8081}\"\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://127.0.0.1:8081/v1/health\"]\n      interval: 5s\n      timeout: 5s\n      retries: 10\n      start_period: 10s\n\n  relay-server:\n    build:\n      context: ../..\n      dockerfile: crates/relay-tunnel/Dockerfile\n    depends_on:\n      remote-db:\n        condition: service_healthy\n    environment:\n      RUST_LOG: info\n      SERVER_DATABASE_URL: postgres://remote:remote@remote-db:5432/remote\n      RELAY_LISTEN_ADDR: 0.0.0.0:8082\n      VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET:?set in .env.remote}\n    ports:\n      - \"127.0.0.1:8082:8082\"\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://127.0.0.1:8082/health\"]\n      interval: 5s\n      timeout: 5s\n      retries: 10\n      start_period: 10s\n\nvolumes:\n  remote-db-data:\n  electric-data:\n  azurite-data:\n"
  },
  {
    "path": "crates/remote/migrations/20251001000000_shared_tasks_activity.sql",
    "content": "CREATE EXTENSION IF NOT EXISTS pgcrypto;\n\nCREATE OR REPLACE FUNCTION set_updated_at()\nRETURNS TRIGGER\nLANGUAGE plpgsql\nAS $$\nBEGIN\n    NEW.updated_at = NOW();\n    RETURN NEW;\nEND;\n$$;\n\nCREATE TABLE IF NOT EXISTS organizations (\n    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    name       TEXT NOT NULL,\n    slug       TEXT NOT NULL UNIQUE,\n    is_personal BOOLEAN NOT NULL DEFAULT FALSE,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE TABLE IF NOT EXISTS users (\n    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    email        TEXT NOT NULL UNIQUE,\n    first_name   TEXT,\n    last_name    TEXT,\n    username     TEXT,\n    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nDO $$\nBEGIN\n    CREATE TYPE member_role AS ENUM ('admin', 'member');\nEXCEPTION\n    WHEN duplicate_object THEN NULL;\nEND\n$$;\n\nCREATE TABLE IF NOT EXISTS organization_member_metadata (\n        organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\n        user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n        role            member_role NOT NULL DEFAULT 'member',\n        joined_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n        last_seen_at    TIMESTAMPTZ,\n        PRIMARY KEY (organization_id, user_id)\n    );\n\nCREATE INDEX IF NOT EXISTS idx_member_metadata_user\n    ON organization_member_metadata (user_id);\n\nCREATE INDEX IF NOT EXISTS idx_member_metadata_org_role\n    ON organization_member_metadata (organization_id, role);\n\nDO $$\nBEGIN\n    CREATE TYPE task_status AS ENUM ('todo', 'in-progress', 'in-review', 'done', 'cancelled');\nEXCEPTION\n    WHEN duplicate_object THEN NULL;\nEND\n$$;\n\nCREATE TABLE IF NOT EXISTS projects (\n    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\n    name            TEXT NOT NULL,\n    metadata        JSONB NOT NULL DEFAULT '{}'::jsonb,\n    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_projects_org_name\n    ON projects (organization_id, name);\n\nCREATE TABLE IF NOT EXISTS project_activity_counters (\n    project_id UUID PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,\n    last_seq BIGINT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS shared_tasks (\n    id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    organization_id    UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\n    project_id         UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    creator_user_id    UUID REFERENCES users(id) ON DELETE SET NULL,\n    assignee_user_id   UUID REFERENCES users(id) ON DELETE SET NULL,\n    deleted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,\n    title              TEXT NOT NULL,\n    description        TEXT,\n    status             task_status NOT NULL DEFAULT 'todo'::task_status,\n    version            BIGINT NOT NULL DEFAULT 1,\n    deleted_at         TIMESTAMPTZ,\n    shared_at          TIMESTAMPTZ DEFAULT NOW(),\n    created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_tasks_org_status\n    ON shared_tasks (organization_id, status);\n\nCREATE INDEX IF NOT EXISTS idx_tasks_org_assignee\n    ON shared_tasks (organization_id, assignee_user_id);\n\nCREATE INDEX IF NOT EXISTS idx_tasks_project\n    ON shared_tasks (project_id);\n\nCREATE INDEX IF NOT EXISTS idx_shared_tasks_org_deleted_at\n    ON shared_tasks (organization_id, deleted_at)\n    WHERE deleted_at IS NOT NULL;\n\n-- Partitioned activity feed (24-hour range partitions on created_at).\nCREATE TABLE activity (\n    seq               BIGINT NOT NULL,\n    event_id          UUID NOT NULL DEFAULT gen_random_uuid(),\n    project_id        UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    assignee_user_id  UUID REFERENCES users(id) ON DELETE SET NULL,\n    event_type        TEXT NOT NULL,\n    payload           JSONB NOT NULL,\n    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    PRIMARY KEY (created_at, project_id, seq),\n    UNIQUE (created_at, event_id)\n) PARTITION BY RANGE (created_at);\n\nCREATE INDEX IF NOT EXISTS idx_activity_project_seq\n    ON activity (project_id, seq DESC);\n\n-- Create partitions on demand for the 24-hour window that contains target_ts.\nCREATE FUNCTION ensure_activity_partition(target_ts TIMESTAMPTZ)\nRETURNS VOID\nLANGUAGE plpgsql\nAS $$\nDECLARE\n    bucket_seconds CONSTANT INTEGER := 24 * 60 * 60;\n    bucket_start   TIMESTAMPTZ;\n    bucket_end     TIMESTAMPTZ;\n    partition_name TEXT;\nBEGIN\n    bucket_start := to_timestamp(\n        floor(EXTRACT(EPOCH FROM target_ts) / bucket_seconds) * bucket_seconds\n    );\n    bucket_end := bucket_start + INTERVAL '24 hours';\n    partition_name := format(\n        'activity_p_%s',\n        to_char(bucket_start AT TIME ZONE 'UTC', 'YYYYMMDD')\n    );\n\n    BEGIN\n        EXECUTE format(\n            'CREATE TABLE IF NOT EXISTS %I PARTITION OF activity FOR VALUES FROM (%L) TO (%L)',\n            partition_name,\n            bucket_start,\n            bucket_end\n        );\n    EXCEPTION\n        WHEN duplicate_table THEN\n            NULL;\n    END;\nEND;\n$$;\n\n-- Seed partitions for the current and next 2 days (48 hours) for safety.\n-- This ensures partitions exist even if cron job fails temporarily.\nSELECT ensure_activity_partition(NOW());\nSELECT ensure_activity_partition(NOW() + INTERVAL '24 hours');\nSELECT ensure_activity_partition(NOW() + INTERVAL '48 hours');\n\nDO $$\nBEGIN\n    DROP TRIGGER IF EXISTS trg_activity_notify ON activity;\nEXCEPTION\n    WHEN undefined_object THEN NULL;\nEND\n$$;\n\nDO $$\nBEGIN\n    DROP FUNCTION IF EXISTS activity_notify();\nEXCEPTION\n    WHEN undefined_function THEN NULL;\nEND\n$$;\n\nCREATE FUNCTION activity_notify() RETURNS trigger AS $$\nBEGIN\n    PERFORM pg_notify(\n        'activity',\n        json_build_object(\n            'seq', NEW.seq,\n            'event_id', NEW.event_id,\n            'project_id', NEW.project_id,\n            'event_type', NEW.event_type,\n            'created_at', NEW.created_at\n        )::text\n    );\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql SECURITY DEFINER;\n\nCREATE TRIGGER trg_activity_notify\n    AFTER INSERT ON activity\n    FOR EACH ROW\n    EXECUTE FUNCTION activity_notify();\n\nDO $$\nBEGIN\n    CREATE TYPE invitation_status AS ENUM ('pending', 'accepted', 'declined', 'expired');\nEXCEPTION\n    WHEN duplicate_object THEN NULL;\nEND\n$$;\n\nCREATE TABLE IF NOT EXISTS organization_invitations (\n    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    organization_id     UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\n    invited_by_user_id  UUID REFERENCES users(id) ON DELETE SET NULL,\n    email               TEXT NOT NULL,\n    role                member_role NOT NULL DEFAULT 'member',\n    status              invitation_status NOT NULL DEFAULT 'pending',\n    token               TEXT NOT NULL UNIQUE,\n    expires_at          TIMESTAMPTZ NOT NULL,\n    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_org_invites_org\n    ON organization_invitations (organization_id);\n\nCREATE INDEX IF NOT EXISTS idx_org_invites_status_expires\n    ON organization_invitations (status, expires_at);\n\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_pending_invite_per_email_per_org\n    ON organization_invitations (organization_id, lower(email))\n    WHERE status = 'pending';\n\nCREATE TABLE IF NOT EXISTS auth_sessions (\n    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    user_id             UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    session_secret_hash TEXT,\n    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    last_used_at        TIMESTAMPTZ,\n    revoked_at          TIMESTAMPTZ\n);\n\nCREATE INDEX IF NOT EXISTS idx_auth_sessions_user\n    ON auth_sessions (user_id);\n\nCREATE TABLE IF NOT EXISTS oauth_accounts (\n    id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    user_id           UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    provider          TEXT NOT NULL,\n    provider_user_id  TEXT NOT NULL,\n    email             TEXT,\n    username          TEXT,\n    display_name      TEXT,\n    avatar_url        TEXT,\n    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    UNIQUE (provider, provider_user_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_oauth_accounts_user\n    ON oauth_accounts (user_id);\n\nCREATE INDEX IF NOT EXISTS idx_oauth_accounts_provider_user\n    ON oauth_accounts (provider, provider_user_id);\n\nCREATE TABLE IF NOT EXISTS oauth_handoffs (\n    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    provider        TEXT NOT NULL,\n    state           TEXT NOT NULL,\n    return_to       TEXT NOT NULL,\n    app_challenge   TEXT NOT NULL,\n    app_code_hash   TEXT,\n    status          TEXT NOT NULL DEFAULT 'pending',\n    error_code      TEXT,\n    expires_at      TIMESTAMPTZ NOT NULL,\n    authorized_at   TIMESTAMPTZ,\n    redeemed_at     TIMESTAMPTZ,\n    user_id         UUID REFERENCES users(id),\n    session_id      UUID REFERENCES auth_sessions(id) ON DELETE SET NULL,\n    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_oauth_handoffs_status\n    ON oauth_handoffs (status);\n\nCREATE INDEX IF NOT EXISTS idx_oauth_handoffs_user\n    ON oauth_handoffs (user_id);\n\nCREATE TRIGGER trg_organizations_updated_at\n    BEFORE UPDATE ON organizations\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n\nCREATE TRIGGER trg_users_updated_at\n    BEFORE UPDATE ON users\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n\nCREATE TRIGGER trg_shared_tasks_updated_at\n    BEFORE UPDATE ON shared_tasks\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n\nCREATE TRIGGER trg_org_invites_updated_at\n    BEFORE UPDATE ON organization_invitations\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n\nCREATE TRIGGER trg_oauth_accounts_updated_at\n    BEFORE UPDATE ON oauth_accounts\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n\nCREATE TRIGGER trg_oauth_handoffs_updated_at\n    BEFORE UPDATE ON oauth_handoffs\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n\nCREATE OR REPLACE FUNCTION set_last_used_at()\nRETURNS TRIGGER\nLANGUAGE plpgsql\nAS $$\nBEGIN\n    NEW.last_used_at = NOW();\n    RETURN NEW;\nEND;\n$$;\n\nCREATE TRIGGER trg_auth_sessions_last_used_at\nBEFORE UPDATE ON auth_sessions\nFOR EACH ROW\nEXECUTE FUNCTION set_last_used_at();\n"
  },
  {
    "path": "crates/remote/migrations/20251117000000_jwt_refresh_tokens.sql",
    "content": "ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS refresh_token_id UUID;\nALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS refresh_token_issued_at TIMESTAMPTZ;\n\nCREATE INDEX IF NOT EXISTS idx_auth_sessions_refresh_id\n    ON auth_sessions (refresh_token_id);\n\nCREATE TABLE IF NOT EXISTS revoked_refresh_tokens (\n    token_id UUID PRIMARY KEY,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    revoked_reason TEXT NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_revoked_tokens_user\n    ON revoked_refresh_tokens (user_id);\n"
  },
  {
    "path": "crates/remote/migrations/20251120121307_oauth_handoff_tokens.sql",
    "content": "ALTER TABLE oauth_handoffs\nADD COLUMN IF NOT EXISTS encrypted_provider_tokens TEXT;\n"
  },
  {
    "path": "crates/remote/migrations/20251127000000_electric_support.sql",
    "content": "CREATE ROLE electric_sync WITH LOGIN REPLICATION;\n\nGRANT CONNECT ON DATABASE remote TO electric_sync;\nGRANT USAGE ON SCHEMA public TO electric_sync;\n\nCREATE PUBLICATION electric_publication_default;\n\nCREATE OR REPLACE FUNCTION electric_sync_table(p_schema text, p_table text)\nRETURNS void\nLANGUAGE plpgsql\nAS $$\nDECLARE\n    qualified text := format('%I.%I', p_schema, p_table);\nBEGIN\n    EXECUTE format('ALTER TABLE %s REPLICA IDENTITY FULL', qualified);\n    EXECUTE format('GRANT SELECT ON TABLE %s TO electric_sync', qualified);\n    EXECUTE format('ALTER PUBLICATION %I ADD TABLE %s', 'electric_publication_default', qualified);\nEND;\n$$;\n\nSELECT electric_sync_table('public', 'shared_tasks');\n"
  },
  {
    "path": "crates/remote/migrations/20251201000000_drop_unused_activity_and_columns.sql",
    "content": "-- Drop activity feed tables and functions\nDROP TABLE IF EXISTS activity CASCADE;\nDROP TABLE IF EXISTS project_activity_counters;\nDROP FUNCTION IF EXISTS ensure_activity_partition;\nDROP FUNCTION IF EXISTS activity_notify;\n\n-- Drop unused columns from shared_tasks\nALTER TABLE shared_tasks DROP COLUMN IF EXISTS version;\nALTER TABLE shared_tasks DROP COLUMN IF EXISTS last_event_seq;\n"
  },
  {
    "path": "crates/remote/migrations/20251201010000_unify_task_status_enums.sql",
    "content": "ALTER TYPE task_status RENAME VALUE 'in-progress' TO 'inprogress';\nALTER TYPE task_status RENAME VALUE 'in-review' TO 'inreview';\n"
  },
  {
    "path": "crates/remote/migrations/20251212000000_create_reviews_table.sql",
    "content": "CREATE TABLE IF NOT EXISTS reviews (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    gh_pr_url TEXT NOT NULL,\n    claude_code_session_id TEXT,\n    ip_address INET NOT NULL,\n    review_cache JSONB,\n    last_viewed_at TIMESTAMPTZ,\n    r2_path TEXT NOT NULL,\n    deleted_at TIMESTAMPTZ,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    email TEXT NOT NULL,\n    pr_title TEXT NOT NULL,\n    status TEXT NOT NULL DEFAULT 'pending'\n);\n\n-- Index for rate limiting queries (IP + time range)\nCREATE INDEX IF NOT EXISTS idx_reviews_ip_created ON reviews (ip_address, created_at);\n"
  },
  {
    "path": "crates/remote/migrations/20251215000000_github_app_installations.sql",
    "content": "-- GitHub App installations linked to organizations\nCREATE TABLE github_app_installations (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\n    github_installation_id BIGINT NOT NULL UNIQUE,\n    github_account_login TEXT NOT NULL,\n    github_account_type TEXT NOT NULL,  -- 'Organization' or 'User'\n    repository_selection TEXT NOT NULL, -- 'all' or 'selected'\n    installed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,\n    suspended_at TIMESTAMPTZ,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_github_app_installations_org ON github_app_installations(organization_id);\n\n-- Repositories accessible via an installation\nCREATE TABLE github_app_repositories (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    installation_id UUID NOT NULL REFERENCES github_app_installations(id) ON DELETE CASCADE,\n    github_repo_id BIGINT NOT NULL,\n    repo_full_name TEXT NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    UNIQUE(installation_id, github_repo_id)\n);\n\nCREATE INDEX idx_github_app_repos_installation ON github_app_repositories(installation_id);\n\n-- Track pending installations (before callback completes)\nCREATE TABLE github_app_pending_installations (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    state_token TEXT NOT NULL UNIQUE,\n    expires_at TIMESTAMPTZ NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_pending_installations_state ON github_app_pending_installations(state_token);\nCREATE INDEX idx_pending_installations_expires ON github_app_pending_installations(expires_at);\n"
  },
  {
    "path": "crates/remote/migrations/20251216000000_add_webhook_fields_to_reviews.sql",
    "content": "-- Make email and ip_address nullable for webhook-triggered reviews\nALTER TABLE reviews\nALTER COLUMN email DROP NOT NULL,\nALTER COLUMN ip_address DROP NOT NULL;\n\n-- Add webhook-specific columns\nALTER TABLE reviews\nADD COLUMN github_installation_id BIGINT,\nADD COLUMN pr_owner TEXT,\nADD COLUMN pr_repo TEXT,\nADD COLUMN pr_number INTEGER;\n\n-- Index for webhook reviews\nCREATE INDEX idx_reviews_webhook ON reviews (github_installation_id)\nWHERE github_installation_id IS NOT NULL;\n"
  },
  {
    "path": "crates/remote/migrations/20251216100000_add_review_enabled_to_repos.sql",
    "content": "-- Add review_enabled column to allow users to toggle which repos are reviewed\nALTER TABLE github_app_repositories\nADD COLUMN review_enabled BOOLEAN NOT NULL DEFAULT true;\n\n-- Index for efficient filtering during webhook processing\nCREATE INDEX idx_github_app_repos_review_enabled\nON github_app_repositories(installation_id, review_enabled)\nWHERE review_enabled = true;\n"
  },
  {
    "path": "crates/remote/migrations/20260112000000_remote-projects.sql",
    "content": "-- 0. DROP SHARED TASKS\n-- Remove the old shared_tasks table and related objects\nDROP TABLE IF EXISTS shared_tasks CASCADE;\nDROP TYPE IF EXISTS task_status;\n\n-- 1. ENUMS\n-- We define enums for fields with a fixed set of options\nCREATE TYPE issue_priority AS ENUM ('urgent', 'high', 'medium', 'low');\n\n-- 2. MODIFY EXISTING ORGANIZATIONS TABLE\n-- Add issue_prefix for simple IDs (e.g., \"BLO\" from \"Bloop\")\nALTER TABLE organizations ADD COLUMN IF NOT EXISTS issue_prefix VARCHAR(10) NOT NULL DEFAULT 'ISS';\n\n-- 3. MODIFY EXISTING PROJECTS TABLE\n-- Add color and updated_at columns, drop unused metadata column\nALTER TABLE projects ADD COLUMN IF NOT EXISTS color VARCHAR(20) NOT NULL DEFAULT '0 0% 0%';\nALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();\nALTER TABLE projects DROP COLUMN IF EXISTS metadata;\n-- Add issue_counter for sequential issue numbering per project\nALTER TABLE projects ADD COLUMN IF NOT EXISTS issue_counter INTEGER NOT NULL DEFAULT 0;\n\n-- Add updated_at trigger for projects\nCREATE TRIGGER trg_projects_updated_at\n    BEFORE UPDATE ON projects\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n\n-- 4. PROJECT STATUSES\n-- Configurable statuses per project (Backlog, Todo, etc.)\nCREATE TABLE project_statuses (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    name VARCHAR(50) NOT NULL,\n    color VARCHAR(20) NOT NULL,\n    sort_order INTEGER NOT NULL DEFAULT 0,\n    hidden BOOLEAN NOT NULL DEFAULT FALSE,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\n\n-- 6. PROJECT NOTIFICATION PREFERENCES\nCREATE TABLE project_notification_preferences (\n    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\n    notify_on_issue_created BOOLEAN NOT NULL DEFAULT TRUE,\n    notify_on_issue_assigned BOOLEAN NOT NULL DEFAULT TRUE,\n\n    PRIMARY KEY (project_id, user_id)\n);\n\n-- 6. ISSUES\nCREATE TABLE issues (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n\n    -- Simple ID fields (e.g., \"BLO-5\")\n    issue_number INTEGER NOT NULL,\n    simple_id VARCHAR(20) NOT NULL,\n\n    -- Status inherits from project_statuses\n    status_id UUID NOT NULL REFERENCES project_statuses(id),\n\n    title VARCHAR(255) NOT NULL,\n    description TEXT,\n    priority issue_priority,\n\n    start_date TIMESTAMPTZ,\n    target_date TIMESTAMPTZ,\n\n    -- Completion status\n    completed_at TIMESTAMPTZ, -- NULL means not completed\n\n    -- Ordering in lists/kanban\n    sort_order DOUBLE PRECISION NOT NULL DEFAULT 0,\n\n    -- Parent Issue (Self-referential)\n    parent_issue_id UUID REFERENCES issues(id) ON DELETE SET NULL,\n    parent_issue_sort_order DOUBLE PRECISION,\n\n    -- Extension Metadata (JSONB for flexibility)\n    extension_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,\n\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n    -- Ensure unique issue numbers per project\n    CONSTRAINT issues_project_issue_number_uniq UNIQUE (project_id, issue_number)\n);\n\n-- Trigger function to auto-generate issue_number and simple_id\nCREATE OR REPLACE FUNCTION set_issue_simple_id()\nRETURNS TRIGGER AS $$\nDECLARE\n    v_issue_number INTEGER;\n    v_issue_prefix VARCHAR(10);\nBEGIN\n    -- Atomically increment the project's issue_counter and get the new number\n    UPDATE projects\n    SET issue_counter = issue_counter + 1\n    WHERE id = NEW.project_id\n    RETURNING issue_counter INTO v_issue_number;\n\n    -- Get the organization's issue_prefix\n    SELECT o.issue_prefix INTO v_issue_prefix\n    FROM projects p\n    JOIN organizations o ON o.id = p.organization_id\n    WHERE p.id = NEW.project_id;\n\n    -- Set the issue_number and simple_id\n    NEW.issue_number := v_issue_number;\n    NEW.simple_id := v_issue_prefix || '-' || v_issue_number;\n\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER trg_issues_simple_id\n    BEFORE INSERT ON issues\n    FOR EACH ROW\n    EXECUTE FUNCTION set_issue_simple_id();\n\n-- 9. ISSUE ASSIGNEES (Team members)\nCREATE TABLE issue_assignees (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    UNIQUE (issue_id, user_id)\n);\n\n-- 10. ISSUE FOLLOWERS\nCREATE TABLE issue_followers (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    UNIQUE (issue_id, user_id)\n);\n\n-- 11. ISSUE RELATIONSHIPS\n-- Links issues with different relationship types (blocking, related, duplicate)\nCREATE TYPE issue_relationship_type AS ENUM ('blocking', 'related', 'has_duplicate');\n\nCREATE TABLE issue_relationships (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    related_issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    relationship_type issue_relationship_type NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    UNIQUE (issue_id, related_issue_id, relationship_type),\n    CHECK (issue_id != related_issue_id)\n);\n\n-- 12. TAGS\nCREATE TABLE tags (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    name VARCHAR(50) NOT NULL,\n    color VARCHAR(20) NOT NULL,\n\n    UNIQUE (project_id, name)\n);\n\nCREATE TABLE issue_tags (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,\n    UNIQUE (issue_id, tag_id)\n);\n\n-- 13. COMMENTS\nCREATE TABLE issue_comments (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    author_id UUID REFERENCES users(id) ON DELETE SET NULL,\n    parent_id UUID REFERENCES issue_comments(id) ON DELETE SET NULL,\n\n    message TEXT NOT NULL,\n\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\n-- 14. COMMENT REACTIONS\nCREATE TABLE issue_comment_reactions (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    comment_id UUID NOT NULL REFERENCES issue_comments(id) ON DELETE CASCADE,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\n    emoji VARCHAR(32) NOT NULL, -- Store the emoji character or shortcode\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n    -- One reaction type per user per comment\n    UNIQUE (comment_id, user_id, emoji)\n);\n\n-- 15. NOTIFICATIONS\nCREATE TYPE notification_type AS ENUM (\n    'issue_comment_added',\n    'issue_status_changed',\n    'issue_assignee_changed',\n    'issue_deleted'\n);\n\nCREATE TABLE notifications (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\n    notification_type notification_type NOT NULL,\n    payload JSONB NOT NULL DEFAULT '{}',\n\n    issue_id UUID REFERENCES issues(id) ON DELETE SET NULL,\n    comment_id UUID REFERENCES issue_comments(id) ON DELETE SET NULL,\n\n    seen BOOLEAN NOT NULL DEFAULT FALSE,\n    dismissed_at TIMESTAMPTZ,\n\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\n-- Indexes for common lookups\nCREATE INDEX idx_issues_project_id ON issues(project_id);\nCREATE INDEX idx_issues_status_id ON issues(status_id);\nCREATE INDEX idx_issues_parent_issue_id ON issues(parent_issue_id);\nCREATE INDEX idx_issues_simple_id ON issues(simple_id);\nCREATE INDEX idx_issue_comments_issue_id ON issue_comments(issue_id);\nCREATE INDEX idx_issue_comments_parent_id ON issue_comments(parent_id);\n\nCREATE INDEX idx_notifications_user_unseen\n    ON notifications (user_id, seen)\n    WHERE dismissed_at IS NULL;\n\nCREATE INDEX idx_notifications_user_created\n    ON notifications (user_id, created_at DESC);\n\nCREATE INDEX idx_notifications_org\n    ON notifications (organization_id);\n\n-- 16. WORKSPACES\n-- Workspace metadata pushed from local clients\nCREATE TABLE workspaces (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    issue_id UUID REFERENCES issues(id) ON DELETE SET NULL,\n    local_workspace_id UUID UNIQUE,\n    name TEXT,\n    archived BOOLEAN NOT NULL DEFAULT FALSE,\n    files_changed INTEGER,\n    lines_added INTEGER,\n    lines_removed INTEGER,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_workspaces_project_id ON workspaces(project_id);\nCREATE INDEX idx_workspaces_owner_user_id ON workspaces(owner_user_id);\nCREATE INDEX idx_workspaces_issue_id ON workspaces(issue_id) WHERE issue_id IS NOT NULL;\nCREATE INDEX idx_workspaces_local_workspace_id ON workspaces(local_workspace_id);\n\n-- 17. PULL REQUESTS\n-- Direct PR tracking linked to issues (tasks)\nCREATE TYPE pull_request_status AS ENUM ('open', 'merged', 'closed');\n\nCREATE TABLE pull_requests (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    url TEXT NOT NULL,\n    number INTEGER NOT NULL,\n    status pull_request_status NOT NULL DEFAULT 'open',\n    merged_at TIMESTAMPTZ,\n    merge_commit_sha VARCHAR(40),\n    target_branch_name TEXT NOT NULL,\n    issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n    UNIQUE (url)\n);\n\nCREATE INDEX idx_pull_requests_issue_id ON pull_requests(issue_id);\nCREATE INDEX idx_pull_requests_workspace_id ON pull_requests(workspace_id) WHERE workspace_id IS NOT NULL;\nCREATE INDEX idx_pull_requests_status ON pull_requests(status);\n"
  },
  {
    "path": "crates/remote/migrations/20260114000000_electric_sync_tables.sql",
    "content": "-- Add tables to Electric publication for sync\n-- These tables need REPLICA IDENTITY FULL for Electric to track changes\n\nSELECT electric_sync_table('public', 'users');\nSELECT electric_sync_table('public', 'projects');\nSELECT electric_sync_table('public', 'project_statuses');\nSELECT electric_sync_table('public', 'tags');\nSELECT electric_sync_table('public', 'issues');\nSELECT electric_sync_table('public', 'issue_assignees');\nSELECT electric_sync_table('public', 'issue_followers');\nSELECT electric_sync_table('public', 'issue_tags');\nSELECT electric_sync_table('public', 'issue_comments');\nSELECT electric_sync_table('public', 'issue_relationships');\nSELECT electric_sync_table('public', 'issue_comment_reactions');\nSELECT electric_sync_table('public', 'notifications');\nSELECT electric_sync_table('public', 'organization_member_metadata');\nSELECT electric_sync_table('public', 'workspaces');\nSELECT electric_sync_table('public', 'pull_requests');\n\n-- Add indexes for subquery performance\nCREATE INDEX IF NOT EXISTS idx_projects_organization_id ON projects(organization_id);\nCREATE INDEX IF NOT EXISTS idx_project_statuses_project_id ON project_statuses(project_id);\nCREATE INDEX IF NOT EXISTS idx_tags_project_id ON tags(project_id);\nCREATE INDEX IF NOT EXISTS idx_issue_assignees_issue_id ON issue_assignees(issue_id);\nCREATE INDEX IF NOT EXISTS idx_issue_followers_issue_id ON issue_followers(issue_id);\nCREATE INDEX IF NOT EXISTS idx_issue_tags_issue_id ON issue_tags(issue_id);\nCREATE INDEX IF NOT EXISTS idx_issue_relationships_issue_id ON issue_relationships(issue_id);\nCREATE INDEX IF NOT EXISTS idx_issue_comment_reactions_comment_id ON issue_comment_reactions(comment_id);\n"
  },
  {
    "path": "crates/remote/migrations/20260115000000_billing.sql",
    "content": "-- Organization billing records for Stripe subscriptions\nCREATE TABLE organization_billing (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    organization_id UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE,\n\n    -- Stripe identifiers\n    stripe_customer_id TEXT,\n    stripe_subscription_id TEXT UNIQUE,\n    stripe_subscription_item_id TEXT,\n\n    -- Subscription status: 'active', 'past_due', 'canceled', 'incomplete', etc.\n    subscription_status TEXT,\n\n    -- Billing period\n    current_period_start TIMESTAMPTZ,\n    current_period_end TIMESTAMPTZ,\n    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,\n\n    -- Number of seats in subscription\n    quantity INTEGER,\n\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\n-- Index for looking up by Stripe customer\nCREATE INDEX idx_org_billing_stripe_customer ON organization_billing(stripe_customer_id)\n    WHERE stripe_customer_id IS NOT NULL;\n\n-- Index for looking up by Stripe subscription\nCREATE INDEX idx_org_billing_stripe_subscription ON organization_billing(stripe_subscription_id)\n    WHERE stripe_subscription_id IS NOT NULL;\n\n-- Trigger to update updated_at on modification\nCREATE TRIGGER trg_organization_billing_updated_at\n    BEFORE UPDATE ON organization_billing\n    FOR EACH ROW\n    EXECUTE FUNCTION set_updated_at();\n"
  },
  {
    "path": "crates/remote/migrations/20260204000000_issue_attachments.sql",
    "content": "-- Blobs: actual file storage metadata (one per unique file)\n-- Supports deduplication: same file content (hash) can be shared across attachments\nCREATE TABLE blobs (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    blob_path TEXT NOT NULL UNIQUE,           -- Azure blob path for original\n    thumbnail_blob_path TEXT,                 -- Azure blob path for thumbnail (null if not an image)\n    original_name TEXT NOT NULL,              -- User-provided filename\n    mime_type TEXT,                           -- Content type\n    size_bytes BIGINT NOT NULL,\n    hash TEXT NOT NULL,                       -- SHA256 for deduplication\n    width INT,                                -- Image width in pixels (null for non-images)\n    height INT,                               -- Image height in pixels (null for non-images)\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_blobs_project_id ON blobs(project_id);\nCREATE INDEX idx_blobs_hash ON blobs(hash);\n\n-- Attachments: links blobs to issues or comments (junction table)\n-- Supports staging (issue_id = NULL, comment_id = NULL) for uploads before creation\nCREATE TABLE attachments (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    blob_id UUID NOT NULL REFERENCES blobs(id) ON DELETE CASCADE,\n    issue_id UUID REFERENCES issues(id) ON DELETE CASCADE,\n    comment_id UUID REFERENCES issue_comments(id) ON DELETE CASCADE,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    expires_at TIMESTAMPTZ,                   -- For cleanup of abandoned staged attachments\n    -- Only one target can be set (or neither for staging)\n    CONSTRAINT attachments_single_target CHECK (NOT (issue_id IS NOT NULL AND comment_id IS NOT NULL))\n);\n\nCREATE INDEX idx_attachments_blob_id ON attachments(blob_id);\nCREATE INDEX idx_attachments_issue_id ON attachments(issue_id) WHERE issue_id IS NOT NULL;\nCREATE INDEX idx_attachments_comment_id ON attachments(comment_id) WHERE comment_id IS NOT NULL;\nCREATE INDEX idx_attachments_expires_at ON attachments(expires_at) WHERE expires_at IS NOT NULL;\n\n-- Enable Electric sync for real-time updates\nSELECT electric_sync_table('public', 'blobs');\nSELECT electric_sync_table('public', 'attachments');\n"
  },
  {
    "path": "crates/remote/migrations/20260205000000_add_issue_creator.sql",
    "content": "-- Add creator_user_id to issues table to track who created each issue\nALTER TABLE issues\nADD COLUMN creator_user_id UUID REFERENCES users(id) ON DELETE SET NULL;\n\n-- Index for efficient queries filtering by creator\nCREATE INDEX idx_issues_creator_user_id ON issues(creator_user_id) WHERE creator_user_id IS NOT NULL;\n"
  },
  {
    "path": "crates/remote/migrations/20260213000000_pending_uploads.sql",
    "content": "CREATE TABLE pending_uploads (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    blob_path TEXT NOT NULL,\n    hash TEXT NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    expires_at TIMESTAMPTZ NOT NULL\n);\n\nCREATE INDEX idx_pending_uploads_expires_at ON pending_uploads(expires_at);\n"
  },
  {
    "path": "crates/remote/migrations/20260216000000_remove_attachment_electric_sync.sql",
    "content": "-- Remove Electric sync for blobs and attachments (shapes are no longer used)\nALTER PUBLICATION electric_publication_default DROP TABLE public.blobs;\nREVOKE SELECT ON TABLE public.blobs FROM electric_sync;\nALTER TABLE public.blobs REPLICA IDENTITY DEFAULT;\n\nALTER PUBLICATION electric_publication_default DROP TABLE public.attachments;\nREVOKE SELECT ON TABLE public.attachments FROM electric_sync;\nALTER TABLE public.attachments REPLICA IDENTITY DEFAULT;\n"
  },
  {
    "path": "crates/remote/migrations/20260217000000_add_project_sort_order.sql",
    "content": "ALTER TABLE projects\nADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;\n\nCREATE INDEX IF NOT EXISTS idx_projects_organization_sort_order\nON projects (organization_id, sort_order);\n"
  },
  {
    "path": "crates/remote/migrations/20260226000000_add_encrypted_provider_tokens_to_oauth_accounts.sql",
    "content": "ALTER TABLE oauth_accounts\nADD COLUMN IF NOT EXISTS encrypted_provider_tokens TEXT;\n"
  },
  {
    "path": "crates/remote/migrations/20260226100000_relay_hosts_and_sessions.sql",
    "content": "CREATE TABLE hosts (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    shared_with_organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL,\n    machine_id TEXT NOT NULL,\n    name TEXT NOT NULL,\n    status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('offline', 'online')),\n    last_seen_at TIMESTAMPTZ,\n    agent_version TEXT,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_hosts_owner_user_id ON hosts(owner_user_id);\nCREATE UNIQUE INDEX idx_hosts_owner_user_id_machine_id ON hosts(owner_user_id, machine_id);\nCREATE INDEX idx_hosts_shared_with_organization_id ON hosts(shared_with_organization_id);\nCREATE INDEX idx_hosts_last_seen_at ON hosts(last_seen_at DESC);\n\nCREATE TABLE relay_sessions (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,\n    request_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    state TEXT NOT NULL CHECK (state IN ('requested', 'active', 'expired')),\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    expires_at TIMESTAMPTZ NOT NULL,\n    claimed_at TIMESTAMPTZ,\n    ended_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_relay_sessions_host_id ON relay_sessions(host_id);\nCREATE INDEX idx_relay_sessions_request_user_id ON relay_sessions(request_user_id);\nCREATE INDEX idx_relay_sessions_state_expires_at ON relay_sessions(state, expires_at);\n\nCREATE TABLE relay_browser_sessions (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    auth_session_id UUID NOT NULL REFERENCES auth_sessions(id) ON DELETE CASCADE,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    last_used_at TIMESTAMPTZ,\n    revoked_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_relay_browser_sessions_host_id\n    ON relay_browser_sessions(host_id);\nCREATE INDEX idx_relay_browser_sessions_user_id\n    ON relay_browser_sessions(user_id);\nCREATE INDEX idx_relay_browser_sessions_auth_session_id\n    ON relay_browser_sessions(auth_session_id);\nCREATE INDEX idx_relay_browser_sessions_active\n    ON relay_browser_sessions(host_id, user_id)\n    WHERE revoked_at IS NULL;\n\nCREATE TABLE relay_auth_codes (\n    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n    code_hash TEXT NOT NULL UNIQUE,\n    host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,\n    relay_cookie_value TEXT NOT NULL,\n    expires_at TIMESTAMPTZ NOT NULL,\n    consumed_at TIMESTAMPTZ,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_relay_auth_codes_host_id ON relay_auth_codes(host_id);\nCREATE INDEX idx_relay_auth_codes_expires_at ON relay_auth_codes(expires_at);\n"
  },
  {
    "path": "crates/remote/migrations/20260310000000_add_title_description_notification_types.sql",
    "content": "ALTER TYPE notification_type ADD VALUE 'issue_title_changed';\nALTER TYPE notification_type ADD VALUE 'issue_description_changed';\nALTER TYPE notification_type ADD VALUE 'issue_priority_changed';\nALTER TYPE notification_type ADD VALUE 'issue_unassigned';\nALTER TYPE notification_type ADD VALUE 'issue_comment_reaction';\n"
  },
  {
    "path": "crates/remote/migrations/20260311000000_notification_digest.sql",
    "content": "CREATE TABLE notification_digest_deliveries (\n    notification_id UUID PRIMARY KEY REFERENCES notifications(id) ON DELETE CASCADE,\n    sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n"
  },
  {
    "path": "crates/remote/migrations/20260313000000_fix-short-id-counter.sql",
    "content": "-- Fix short IDs to be unique per org, not per project.\n-- Moves issue_counter from projects -> organizations so that issues\n-- across all projects in an org share a single incrementing counter.\n-- e.g., Project A issue 1 gets ORG-1, Project B issue 1 gets ORG-2.\n-- Uniqueness is enforced by the trigger (atomic counter increment), not a constraint.\n\n-- 1. Add org-level counter\nALTER TABLE organizations\n    ADD COLUMN IF NOT EXISTS issue_counter INTEGER NOT NULL DEFAULT 0;\n\n-- 2. Renumber all existing issues with org-wide sequential numbers.\n--    Drop the old per-project uniqueness constraint first: the bulk UPDATE can\n--    otherwise hit transient (project_id, issue_number) collisions mid-statement\n--    before every row has been reassigned.\nALTER TABLE issues\n    DROP CONSTRAINT IF EXISTS issues_project_issue_number_uniq;\n\n-- 3. Renumber all existing issues with org-wide sequential numbers.\n--    Under the old schema, issue_number was per-project (each project starts at 1),\n--    so multiple projects in the same org have overlapping numbers and duplicate\n--    simple_ids (e.g. both Project A and Project B show ORG-1). Reassign sequential\n--    numbers ordered by created_at (id as tiebreaker) and update simple_id to match.\nWITH renumbered AS (\n    SELECT\n        i.id,\n        ROW_NUMBER() OVER (\n            PARTITION BY p.organization_id\n            ORDER BY i.created_at, i.id\n        ) AS new_issue_number,\n        o.issue_prefix\n    FROM issues i\n    JOIN projects p ON p.id = i.project_id\n    JOIN organizations o ON o.id = p.organization_id\n)\nUPDATE issues i\nSET\n    issue_number = r.new_issue_number,\n    simple_id    = r.issue_prefix || '-' || r.new_issue_number\nFROM renumbered r\nWHERE i.id = r.id;\n\n-- 4. Backfill denormalized notification payloads that store issue_simple_id.\nUPDATE notifications n\nSET payload = jsonb_set(n.payload, '{issue_simple_id}', to_jsonb(i.simple_id), true)\nFROM issues i\nWHERE n.issue_id = i.id\n  AND n.payload ? 'issue_simple_id';\n\n-- 5. Set org counters to the maximum issue_number now assigned.\nUPDATE organizations o\nSET issue_counter = COALESCE(\n    (\n        SELECT MAX(i.issue_number)\n        FROM issues i\n        JOIN projects p ON p.id = i.project_id\n        WHERE p.organization_id = o.id\n    ),\n    0\n);\n\n-- 6. Update the trigger function to increment the org counter instead of project counter.\n--    The trigger trg_issues_simple_id itself does not need to be recreated.\n--    Uniqueness is guaranteed by the atomic UPDATE ... RETURNING on the org row,\n--    which serializes concurrent inserts via row-level locking.\nCREATE OR REPLACE FUNCTION set_issue_simple_id()\nRETURNS TRIGGER AS $$\nDECLARE\n    v_issue_number    INTEGER;\n    v_issue_prefix    VARCHAR(10);\n    v_organization_id UUID;\nBEGIN\n    -- Resolve organization and its prefix from the project\n    SELECT p.organization_id, o.issue_prefix\n    INTO v_organization_id, v_issue_prefix\n    FROM projects p\n    JOIN organizations o ON o.id = p.organization_id\n    WHERE p.id = NEW.project_id;\n\n    -- Atomically increment the organization's counter and capture the new value\n    UPDATE organizations\n    SET issue_counter = issue_counter + 1\n    WHERE id = v_organization_id\n    RETURNING issue_counter INTO v_issue_number;\n\n    -- Assign auto-generated fields\n    NEW.issue_number := v_issue_number;\n    NEW.simple_id    := v_issue_prefix || '-' || v_issue_number;\n\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\n-- 7. Remove the now-unused per-project issue counter\nALTER TABLE projects\n    DROP COLUMN IF EXISTS issue_counter;\n"
  },
  {
    "path": "crates/remote/scripts/prepare-db.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nREMOTE_DIR=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\n\n# Parse arguments\nBILLING_MANIFEST_PATH=\"\"\nCHECK_MODE=\"\"\n\nfor arg in \"$@\"; do\n  case \"$arg\" in\n    --check) CHECK_MODE=\"--check\" ;;\n    *) BILLING_MANIFEST_PATH=\"$arg\" ;;\n  esac\ndone\n\n# Convert relative paths to absolute (relative to repo root, since pnpm runs from there)\nBILLING_DIR=\"\"\nif [ -n \"$BILLING_MANIFEST_PATH\" ]; then\n  if [[ \"$BILLING_MANIFEST_PATH\" != /* ]]; then\n    REPO_ROOT=\"$(cd \"$REMOTE_DIR/../..\" && pwd)\"\n    BILLING_MANIFEST_PATH=\"$REPO_ROOT/$BILLING_MANIFEST_PATH\"\n  fi\n\n  if [ -f \"$BILLING_MANIFEST_PATH\" ]; then\n    BILLING_DIR=\"$(cd \"$(dirname \"$BILLING_MANIFEST_PATH\")\" && pwd)\"\n  else\n    echo \"⚠️  Billing manifest not found: $BILLING_MANIFEST_PATH (skipping billing)\" >&2\n  fi\nfi\n\n# For --check mode, run offline without database (just verify .sqlx cache)\nif [ \"$CHECK_MODE\" = \"--check\" ]; then\n  if [ -n \"$BILLING_DIR\" ]; then\n    echo \"➤ Checking SQLx data for billing (offline mode)...\"\n    (cd \"$BILLING_DIR\" && SQLX_OFFLINE=true cargo sqlx prepare --check)\n  fi\n  echo \"➤ Checking SQLx data for remote (offline mode)...\"\n  SQLX_OFFLINE=true cargo sqlx prepare --check\n\n  RELAY_TUNNEL_DIR=\"$(cd \"$REMOTE_DIR/../../crates/relay-tunnel\" && pwd)\"\n  echo \"➤ Checking SQLx data for relay-tunnel (offline mode)...\"\n  (cd \"$RELAY_TUNNEL_DIR\" && SQLX_OFFLINE=true cargo sqlx prepare --check -- --features server)\n\n  echo \"✅ sqlx check complete\"\n  exit 0\nfi\n\n# For prepare mode, need a running PostgreSQL instance\nDATA_DIR=\"$(mktemp -d /tmp/sqlxpg.XXXXXX)\"\nPORT=54329\n\necho \"Killing existing Postgres instance on port $PORT\"\npids=$(lsof -t -i :\"$PORT\" 2>/dev/null || true)\n[ -n \"$pids\" ] && kill $pids 2>/dev/null || true\nsleep 1\n\necho \"➤ Initializing temporary Postgres cluster...\"\ninitdb -D \"$DATA_DIR\" > /dev/null\n\necho \"➤ Starting Postgres on port $PORT...\"\npg_ctl -D \"$DATA_DIR\" -o \"-p $PORT\" -w start > /dev/null\n\necho \"➤ Creating 'remote' database...\"\ncreatedb -p $PORT remote\n\n# Connection string\nexport DATABASE_URL=\"postgres://localhost:$PORT/remote\"\n\necho \"➤ Running migrations...\"\nsqlx migrate run\n\nif [ -n \"$BILLING_DIR\" ]; then\n  echo \"➤ Preparing SQLx data for billing...\"\n  (cd \"$BILLING_DIR\" && cargo sqlx prepare)\nfi\n\necho \"➤ Preparing SQLx data for remote...\"\ncargo sqlx prepare\n\nRELAY_TUNNEL_DIR=\"$(cd \"$REMOTE_DIR/../../crates/relay-tunnel\" && pwd)\"\necho \"➤ Preparing SQLx data for relay-tunnel...\"\n(cd \"$RELAY_TUNNEL_DIR\" && cargo sqlx prepare -- --features server)\n\necho \"➤ Stopping Postgres...\"\npg_ctl -D \"$DATA_DIR\" -m fast -w stop > /dev/null\n\necho \"➤ Cleaning up...\"\nrm -rf \"$DATA_DIR\"\n\necho \"Killing existing Postgres instance on port $PORT\"\npids=$(lsof -t -i :\"$PORT\" 2>/dev/null || true)\n[ -n \"$pids\" ] && kill $pids 2>/dev/null || true\nsleep 1\n\necho \"✅ sqlx prepare complete\"\n"
  },
  {
    "path": "crates/remote/src/analytics.rs",
    "content": "use std::time::Duration;\n\nuse serde_json::{Value, json};\nuse uuid::Uuid;\n\n#[derive(Debug, Clone)]\npub struct AnalyticsConfig {\n    pub posthog_api_key: String,\n    pub posthog_api_endpoint: String,\n}\n\nimpl AnalyticsConfig {\n    pub fn from_env() -> Option<Self> {\n        Self::from_values(\n            option_env!(\"POSTHOG_API_KEY\"),\n            option_env!(\"POSTHOG_API_ENDPOINT\"),\n        )\n    }\n\n    fn from_values(api_key: Option<&str>, api_endpoint: Option<&str>) -> Option<Self> {\n        let api_key = api_key?.trim();\n        let api_endpoint = api_endpoint?.trim();\n\n        if api_key.is_empty() || api_endpoint.is_empty() {\n            return None;\n        }\n\n        Some(Self {\n            posthog_api_key: api_key.to_string(),\n            posthog_api_endpoint: api_endpoint.to_string(),\n        })\n    }\n}\n\n#[derive(Clone, Debug)]\npub struct AnalyticsService {\n    config: AnalyticsConfig,\n    client: reqwest::Client,\n}\n\nimpl AnalyticsService {\n    pub fn new(config: AnalyticsConfig) -> Self {\n        let client = reqwest::Client::builder()\n            .timeout(Duration::from_secs(30))\n            .build()\n            .expect(\"failed to build analytics HTTP client\");\n        Self { config, client }\n    }\n\n    pub fn track(&self, user_id: Uuid, event_name: &str, properties: Value) {\n        let endpoint = format!(\n            \"{}/capture/\",\n            self.config.posthog_api_endpoint.trim_end_matches('/')\n        );\n\n        let payload = if event_name == \"$identify\" {\n            json!({\n                \"api_key\": self.config.posthog_api_key,\n                \"event\": event_name,\n                \"distinct_id\": user_id.to_string(),\n                \"$set\": properties,\n            })\n        } else {\n            let mut event_properties = properties;\n            if let Some(props) = event_properties.as_object_mut() {\n                props.insert(\n                    \"timestamp\".to_string(),\n                    json!(chrono::Utc::now().to_rfc3339()),\n                );\n                props.insert(\"version\".to_string(), json!(env!(\"CARGO_PKG_VERSION\")));\n                props.insert(\"source\".to_string(), json!(\"remote\"));\n            }\n            json!({\n                \"api_key\": self.config.posthog_api_key,\n                \"event\": event_name,\n                \"distinct_id\": user_id.to_string(),\n                \"properties\": event_properties,\n            })\n        };\n\n        let client = self.client.clone();\n        let event_name = event_name.to_string();\n\n        tokio::spawn(async move {\n            match client\n                .post(&endpoint)\n                .header(\"Content-Type\", \"application/json\")\n                .json(&payload)\n                .send()\n                .await\n            {\n                Ok(response) if !response.status().is_success() => {\n                    tracing::warn!(\n                        event = %event_name,\n                        status = %response.status(),\n                        \"analytics event failed\"\n                    );\n                }\n                Err(e) => {\n                    tracing::warn!(event = %event_name, error = ?e, \"analytics request failed\");\n                }\n                _ => {}\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/app.rs",
    "content": "use std::{net::SocketAddr, sync::Arc};\n\nuse anyhow::{Context, bail};\nuse secrecy::ExposeSecret;\nuse tracing::instrument;\n\nuse crate::{\n    AppState,\n    analytics::{AnalyticsConfig, AnalyticsService},\n    attachments::cleanup::spawn_cleanup_task,\n    auth::{\n        GitHubOAuthProvider, GoogleOAuthProvider, JwtService, OAuthHandoffService,\n        OAuthTokenValidator, ProviderRegistry,\n    },\n    azure_blob::AzureBlobService,\n    billing::BillingService,\n    config::RemoteServerConfig,\n    db, digest,\n    github_app::GitHubAppService,\n    mail::{LoopsMailer, Mailer, NoopMailer},\n    r2::R2Service,\n    routes,\n};\n\npub struct Server;\n\nimpl Server {\n    #[instrument(\n        name = \"remote_server\",\n        skip(config, billing),\n        fields(listen_addr = %config.listen_addr)\n    )]\n    pub async fn run(config: RemoteServerConfig, billing: BillingService) -> anyhow::Result<()> {\n        let pool = db::create_pool(&config.database_url)\n            .await\n            .context(\"failed to create postgres pool\")?;\n\n        db::migrate(&pool)\n            .await\n            .context(\"failed to run database migrations\")?;\n\n        if let Some(password) = config.electric_role_password.as_ref() {\n            db::ensure_electric_role_password(&pool, password.expose_secret())\n                .await\n                .context(\"failed to set electric role password\")?;\n        }\n\n        if !config.electric_publication_names.is_empty() {\n            db::electric_publications::ensure_electric_publications(\n                &pool,\n                &config.electric_publication_names,\n            )\n            .await\n            .context(\"failed to sync Electric publications\")?;\n        }\n\n        let auth_config = config.auth.clone();\n        let jwt = Arc::new(JwtService::new(auth_config.jwt_secret().clone()));\n\n        let mut registry = ProviderRegistry::new();\n\n        if let Some(github) = auth_config.github() {\n            registry.register(GitHubOAuthProvider::new(\n                github.client_id().to_string(),\n                github.client_secret().clone(),\n            )?);\n        }\n\n        if let Some(google) = auth_config.google() {\n            registry.register(GoogleOAuthProvider::new(\n                google.client_id().to_string(),\n                google.client_secret().clone(),\n            )?);\n        }\n\n        if registry.is_empty() {\n            bail!(\"no OAuth providers configured\");\n        }\n\n        let registry = Arc::new(registry);\n\n        let handoff_service = Arc::new(OAuthHandoffService::new(\n            pool.clone(),\n            registry.clone(),\n            jwt.clone(),\n            auth_config.public_base_url().to_string(),\n        ));\n\n        let oauth_token_validator = Arc::new(OAuthTokenValidator::new(\n            pool.clone(),\n            registry.clone(),\n            jwt.clone(),\n        ));\n\n        let loops_email_api_key = std::env::var(\"LOOPS_EMAIL_API_KEY\")\n            .ok()\n            .filter(|api_key| !api_key.is_empty());\n\n        let mailer: Arc<dyn Mailer> = match loops_email_api_key.clone() {\n            Some(api_key) => {\n                tracing::info!(\"Email service (Loops) configured\");\n                Arc::new(LoopsMailer::new(api_key))\n            }\n            _ => {\n                tracing::info!(\n                    \"LOOPS_EMAIL_API_KEY not set. Email notifications (invitations, review updates) will be disabled.\"\n                );\n                Arc::new(NoopMailer)\n            }\n        };\n\n        let server_public_base_url = config.server_public_base_url.clone().ok_or_else(|| {\n            anyhow::anyhow!(\n                \"SERVER_PUBLIC_BASE_URL is not set. Please set it in your .env.remote file.\"\n            )\n        })?;\n\n        let r2 = config.r2.as_ref().map(R2Service::new);\n        if r2.is_some() {\n            tracing::info!(\"R2 storage service initialized\");\n        } else {\n            tracing::warn!(\n                \"R2 storage service not configured. Set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_REVIEW_ENDPOINT, and R2_REVIEW_BUCKET to enable.\"\n            );\n        }\n\n        let azure_blob = config.azure_blob.as_ref().map(AzureBlobService::new);\n        if azure_blob.is_some() {\n            tracing::info!(\"Azure Blob storage service initialized\");\n        } else {\n            tracing::info!(\n                \"Azure Blob storage not configured. Set AZURE_STORAGE_ACCOUNT_NAME and AZURE_STORAGE_ACCOUNT_KEY to enable issue attachments.\"\n            );\n        }\n\n        let http_client = reqwest::Client::builder()\n            .user_agent(\"VibeKanbanRemote/1.0\")\n            .build()\n            .context(\"failed to create HTTP client\")?;\n\n        let github_app = match &config.github_app {\n            Some(github_config) => {\n                match GitHubAppService::new(github_config, http_client.clone()) {\n                    Ok(service) => {\n                        tracing::info!(\n                            app_slug = %github_config.app_slug,\n                            \"GitHub App service initialized\"\n                        );\n                        Some(Arc::new(service))\n                    }\n                    Err(e) => {\n                        tracing::error!(?e, \"Failed to initialize GitHub App service\");\n                        None\n                    }\n                }\n            }\n            None => {\n                tracing::info!(\n                    \"GitHub App not configured. Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET, and GITHUB_APP_SLUG to enable.\"\n                );\n                None\n            }\n        };\n\n        if billing.is_configured() {\n            tracing::info!(\"Billing provider configured\");\n        } else {\n            tracing::info!(\"Billing provider not configured\");\n        }\n\n        let analytics = match AnalyticsConfig::from_env() {\n            Some(analytics_config) => {\n                tracing::info!(\"PostHog analytics configured\");\n                Some(AnalyticsService::new(analytics_config))\n            }\n            None => {\n                tracing::info!(\n                    \"PostHog analytics not configured (POSTHOG_API_KEY and/or POSTHOG_API_ENDPOINT not set)\"\n                );\n                None\n            }\n        };\n\n        if let Some(ref azure_blob_service) = azure_blob {\n            spawn_cleanup_task(pool.clone(), azure_blob_service.clone());\n        }\n\n        let digest_enabled = std::env::var(\"DIGEST_ENABLED\")\n            .map(|v| matches!(v.as_str(), \"true\" | \"1\"))\n            .unwrap_or(false);\n\n        if loops_email_api_key.is_some() && digest_enabled {\n            digest::task::spawn_digest_task(\n                pool.clone(),\n                mailer.clone(),\n                server_public_base_url.clone(),\n            );\n        } else if !digest_enabled {\n            tracing::info!(\"Notification digest disabled (feature flag)\");\n        } else {\n            tracing::info!(\"Notification digest disabled (no email provider configured)\");\n        }\n\n        let state = AppState::new(\n            pool.clone(),\n            config.clone(),\n            jwt,\n            handoff_service,\n            oauth_token_validator,\n            mailer,\n            server_public_base_url,\n            http_client,\n            r2,\n            azure_blob,\n            github_app,\n            billing,\n            analytics,\n        );\n\n        let router = routes::router(state);\n        let addr: SocketAddr = config\n            .listen_addr\n            .parse()\n            .context(\"listen address is invalid\")?;\n        let tcp_listener = tokio::net::TcpListener::bind(addr)\n            .await\n            .context(\"failed to bind tcp listener\")?;\n\n        tracing::info!(%addr, \"shared sync server listening\");\n\n        let make_service = router.into_make_service();\n\n        axum::serve(tcp_listener, make_service)\n            .await\n            .context(\"shared sync server failure\")?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/attachments/cleanup.rs",
    "content": "use std::time::Duration;\n\nuse sqlx::PgPool;\nuse tokio::task::JoinHandle;\nuse tracing::{info, instrument, warn};\n\nuse crate::{\n    azure_blob::AzureBlobService,\n    db::{\n        attachments::AttachmentRepository, blobs::BlobRepository,\n        pending_uploads::PendingUploadRepository,\n    },\n};\n\nconst EXPIRED_BATCH_SIZE: i64 = 100;\nconst DEFAULT_INTERVAL: Duration = Duration::from_secs(3600);\n\n/// Spawns a background task that periodically cleans up orphan attachments and\n/// expired pending uploads. Call once during server startup.\npub fn spawn_cleanup_task(pool: PgPool, azure: AzureBlobService) -> JoinHandle<()> {\n    let interval = std::env::var(\"ATTACHMENT_CLEANUP_INTERVAL_SECS\")\n        .ok()\n        .and_then(|v| v.parse::<u64>().ok())\n        .map(Duration::from_secs)\n        .unwrap_or(DEFAULT_INTERVAL);\n\n    info!(\n        interval_secs = interval.as_secs(),\n        \"Starting attachment cleanup background task\"\n    );\n\n    tokio::spawn(async move {\n        let mut ticker = tokio::time::interval(interval);\n        // Skip the immediate first tick so the server can finish starting up.\n        ticker.tick().await;\n\n        loop {\n            ticker.tick().await;\n            run_sweep(&pool, &azure).await;\n        }\n    })\n}\n\n#[instrument(name = \"attachment_cleanup.sweep\", skip_all)]\nasync fn run_sweep(pool: &PgPool, azure: &AzureBlobService) {\n    info!(\"Starting attachment cleanup sweep\");\n\n    let (expired, pending) = tokio::join!(\n        cleanup_expired_attachments(pool, azure),\n        cleanup_expired_pending_uploads(pool, azure),\n    );\n\n    match expired {\n        Ok(count) => info!(deleted = count, \"Expired attachment cleanup complete\"),\n        Err(e) => warn!(error = %e, \"Expired attachment cleanup failed\"),\n    }\n\n    match pending {\n        Ok(count) => info!(deleted = count, \"Expired pending uploads cleanup complete\"),\n        Err(e) => warn!(error = %e, \"Expired pending uploads cleanup failed\"),\n    }\n}\n\nasync fn cleanup_expired_attachments(\n    pool: &PgPool,\n    azure: &AzureBlobService,\n) -> anyhow::Result<u32> {\n    let expired = AttachmentRepository::find_expired(pool, EXPIRED_BATCH_SIZE).await?;\n    let mut deleted_count: u32 = 0;\n\n    for attachment in expired {\n        let attachment_id = attachment.id;\n        let blob_id = attachment.blob_id;\n\n        if let Err(e) = AttachmentRepository::delete(pool, attachment_id).await {\n            warn!(%attachment_id, error = %e, \"Failed to delete expired attachment\");\n            continue;\n        }\n\n        match AttachmentRepository::count_by_blob_id(pool, blob_id).await {\n            Ok(0) => {\n                if let Ok(Some(blob)) = BlobRepository::delete(pool, blob_id).await {\n                    if let Err(e) = azure.delete_blob(&blob.blob_path).await {\n                        warn!(blob_path = %blob.blob_path, error = %e, \"Failed to delete Azure blob\");\n                    }\n                    if let Some(thumb_path) = &blob.thumbnail_blob_path\n                        && let Err(e) = azure.delete_blob(thumb_path).await\n                    {\n                        warn!(blob_path = %thumb_path, error = %e, \"Failed to delete Azure thumbnail\");\n                    }\n                }\n            }\n            Ok(_) => {} // blob still referenced by other attachments\n            Err(e) => {\n                warn!(%blob_id, error = %e, \"Failed to count blob references\");\n            }\n        }\n\n        deleted_count += 1;\n    }\n\n    Ok(deleted_count)\n}\n\nasync fn cleanup_expired_pending_uploads(\n    pool: &PgPool,\n    azure: &AzureBlobService,\n) -> anyhow::Result<u32> {\n    let expired = PendingUploadRepository::delete_expired(pool).await?;\n    let mut deleted_count: u32 = 0;\n\n    for pending in expired {\n        if let Err(e) = azure.delete_blob(&pending.blob_path).await {\n            warn!(blob_path = %pending.blob_path, error = %e, \"Failed to delete Azure blob for expired pending upload\");\n        }\n        deleted_count += 1;\n    }\n\n    Ok(deleted_count)\n}\n"
  },
  {
    "path": "crates/remote/src/attachments/mod.rs",
    "content": "pub(crate) mod cleanup;\npub mod thumbnail;\n"
  },
  {
    "path": "crates/remote/src/attachments/thumbnail.rs",
    "content": "use image::{DynamicImage, imageops::FilterType};\n\nconst THUMBNAIL_MAX_WIDTH: u32 = 200;\nconst THUMBNAIL_MAX_HEIGHT: u32 = 150;\nconst THUMBNAIL_JPEG_QUALITY: u8 = 80;\n\n#[derive(Debug)]\npub struct ThumbnailResult {\n    pub bytes: Vec<u8>,\n    pub width: u32,\n    pub height: u32,\n    pub original_width: u32,\n    pub original_height: u32,\n    pub mime_type: String,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum ThumbnailError {\n    #[error(\"unsupported image format\")]\n    UnsupportedFormat,\n    #[error(\"image decode error: {0}\")]\n    DecodeError(String),\n    #[error(\"image encode error: {0}\")]\n    EncodeError(String),\n}\n\npub struct ThumbnailService;\n\nimpl ThumbnailService {\n    /// Generate a thumbnail from image bytes.\n    /// Returns None for non-image MIME types.\n    pub fn generate(\n        data: &[u8],\n        mime_type: Option<&str>,\n    ) -> Result<Option<ThumbnailResult>, ThumbnailError> {\n        // Check if it's an image MIME type we support\n        let is_supported_image = mime_type\n            .map(|m| {\n                matches!(\n                    m.to_lowercase().as_str(),\n                    \"image/png\" | \"image/jpeg\" | \"image/jpg\" | \"image/gif\" | \"image/webp\"\n                )\n            })\n            .unwrap_or(false);\n\n        if !is_supported_image {\n            return Ok(None);\n        }\n\n        // Decode the image\n        let img = image::load_from_memory(data)\n            .map_err(|e| ThumbnailError::DecodeError(e.to_string()))?;\n\n        let original_width = img.width();\n        let original_height = img.height();\n\n        // Calculate thumbnail dimensions preserving aspect ratio\n        let (thumb_width, thumb_height) =\n            calculate_thumbnail_dimensions(original_width, original_height);\n\n        let thumbnail = img.resize(thumb_width, thumb_height, FilterType::Lanczos3);\n        let jpeg_bytes = encode_jpeg(&thumbnail, THUMBNAIL_JPEG_QUALITY)?;\n\n        Ok(Some(ThumbnailResult {\n            bytes: jpeg_bytes,\n            width: thumb_width,\n            height: thumb_height,\n            original_width,\n            original_height,\n            mime_type: \"image/jpeg\".to_string(),\n        }))\n    }\n}\n\n/// Calculate thumbnail dimensions preserving aspect ratio.\nfn calculate_thumbnail_dimensions(width: u32, height: u32) -> (u32, u32) {\n    if width <= THUMBNAIL_MAX_WIDTH && height <= THUMBNAIL_MAX_HEIGHT {\n        return (width, height);\n    }\n\n    let width_ratio = THUMBNAIL_MAX_WIDTH as f64 / width as f64;\n    let height_ratio = THUMBNAIL_MAX_HEIGHT as f64 / height as f64;\n    let ratio = width_ratio.min(height_ratio);\n\n    let new_width = (width as f64 * ratio).round() as u32;\n    let new_height = (height as f64 * ratio).round() as u32;\n\n    (new_width.max(1), new_height.max(1))\n}\n\n/// Encode a DynamicImage as JPEG with specified quality.\nfn encode_jpeg(img: &DynamicImage, quality: u8) -> Result<Vec<u8>, ThumbnailError> {\n    let rgb = img.to_rgb8();\n    let mut output = Vec::new();\n    let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality);\n    encoder\n        .encode_image(&rgb)\n        .map_err(|e| ThumbnailError::EncodeError(e.to_string()))?;\n    Ok(output)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_calculate_dimensions_smaller_than_max() {\n        let (w, h) = calculate_thumbnail_dimensions(100, 80);\n        assert_eq!((w, h), (100, 80));\n    }\n\n    #[test]\n    fn test_calculate_dimensions_landscape() {\n        let (w, h) = calculate_thumbnail_dimensions(800, 600);\n        // 800x600 aspect ratio = 4:3\n        // Max width 200, height would be 150\n        assert_eq!((w, h), (200, 150));\n    }\n\n    #[test]\n    fn test_calculate_dimensions_portrait() {\n        let (w, h) = calculate_thumbnail_dimensions(600, 800);\n        // 600x800 aspect ratio = 3:4\n        // Max height 150, width would be 112\n        assert_eq!((w, h), (112, 150));\n    }\n\n    #[test]\n    fn test_unsupported_mime_type() {\n        let result = ThumbnailService::generate(b\"not an image\", Some(\"application/pdf\")).unwrap();\n        assert!(result.is_none());\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/audit/mod.rs",
    "content": "use uuid::Uuid;\n\nuse crate::auth::RequestContext;\n\n#[derive(Debug, Clone, Copy)]\npub enum AuditAction {\n    AuthLogin,\n    AuthLogout,\n    AuthTokenRefresh,\n    AuthTokenReuseDetected,\n    AuthSessionRevoked,\n\n    MemberInvite,\n    MemberAcceptInvite,\n    MemberRevokeInvite,\n    MemberRemove,\n    MemberRoleChange,\n}\n\nimpl AuditAction {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            Self::AuthLogin => \"auth.login\",\n            Self::AuthLogout => \"auth.logout\",\n            Self::AuthTokenRefresh => \"auth.token_refresh\",\n            Self::AuthTokenReuseDetected => \"auth.token_reuse_detected\",\n            Self::AuthSessionRevoked => \"auth.session_revoked\",\n            Self::MemberInvite => \"member.invite\",\n            Self::MemberAcceptInvite => \"member.accept_invite\",\n            Self::MemberRevokeInvite => \"member.revoke_invite\",\n            Self::MemberRemove => \"member.remove\",\n            Self::MemberRoleChange => \"member.role_change\",\n        }\n    }\n}\n\n/// A single audit log event.\n#[derive(Debug, Clone)]\npub struct AuditEvent {\n    pub action: AuditAction,\n    pub user_id: Option<Uuid>,\n    pub session_id: Option<Uuid>,\n    pub resource_type: Option<&'static str>,\n    pub resource_id: Option<Uuid>,\n    pub organization_id: Option<Uuid>,\n    pub http_method: Option<String>,\n    pub http_path: Option<String>,\n    pub http_status: Option<u16>,\n    pub description: Option<String>,\n}\n\nimpl AuditEvent {\n    /// Create an event populated from a request context (user, session).\n    pub fn from_request(ctx: &RequestContext, action: AuditAction) -> Self {\n        Self {\n            action,\n            user_id: Some(ctx.user.id),\n            session_id: Some(ctx.session_id),\n            resource_type: None,\n            resource_id: None,\n            organization_id: None,\n            http_method: None,\n            http_path: None,\n            http_status: None,\n            description: None,\n        }\n    }\n\n    /// Create a system-level event with no request context.\n    pub fn system(action: AuditAction) -> Self {\n        Self {\n            action,\n            user_id: None,\n            session_id: None,\n            resource_type: None,\n            resource_id: None,\n            organization_id: None,\n            http_method: None,\n            http_path: None,\n            http_status: None,\n            description: None,\n        }\n    }\n\n    pub fn resource(mut self, resource_type: &'static str, resource_id: Option<Uuid>) -> Self {\n        self.resource_type = Some(resource_type);\n        self.resource_id = resource_id;\n        self\n    }\n\n    pub fn organization(mut self, id: Uuid) -> Self {\n        self.organization_id = Some(id);\n        self\n    }\n\n    pub fn http(mut self, method: &str, path: impl Into<String>, status: u16) -> Self {\n        self.http_method = Some(method.into());\n        self.http_path = Some(path.into());\n        self.http_status = Some(status);\n        self\n    }\n\n    pub fn description(mut self, desc: impl Into<String>) -> Self {\n        self.description = Some(desc.into());\n        self\n    }\n\n    pub fn user(mut self, user_id: Uuid, session_id: Option<Uuid>) -> Self {\n        self.user_id = Some(user_id);\n        self.session_id = session_id;\n        self\n    }\n}\n\n/// Emit an audit event as a structured tracing log.\n/// Uses `target: \"audit\"` for filtering in the backend.\npub fn emit(event: AuditEvent) {\n    tracing::info!(\n        target: \"audit\",\n        audit_action = event.action.as_str(),\n        audit_user_id = event.user_id.map(|u| u.to_string()).unwrap_or_default(),\n        audit_session_id = event.session_id.map(|s| s.to_string()).unwrap_or_default(),\n        audit_resource_type = event.resource_type.unwrap_or(\"\"),\n        audit_resource_id = event.resource_id.map(|r| r.to_string()).unwrap_or_default(),\n        audit_organization_id = event.organization_id.map(|o| o.to_string()).unwrap_or_default(),\n        audit_http_method = event.http_method.as_deref().unwrap_or(\"\"),\n        audit_http_path = event.http_path.as_deref().unwrap_or(\"\"),\n        audit_http_status = event.http_status.unwrap_or(0),\n        audit_description = event.description.as_deref().unwrap_or(\"\"),\n        \"audit_event\"\n    );\n}\n"
  },
  {
    "path": "crates/remote/src/auth/handoff.rs",
    "content": "use std::{fmt::Write, sync::Arc};\n\nuse anyhow::Error as AnyhowError;\nuse chrono::{DateTime, Duration, Utc};\nuse rand::{Rng, distr::Alphanumeric};\nuse reqwest::StatusCode;\nuse secrecy::ExposeSecret;\nuse sha2::{Digest, Sha256};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse url::Url;\nuse uuid::Uuid;\n\nuse super::{\n    ProviderRegistry,\n    jwt::{JwtError, JwtService},\n    provider::{AuthorizationGrant, AuthorizationProvider, ProviderUser},\n};\nuse crate::{\n    configure_user_scope,\n    db::{\n        auth::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION},\n        identity_errors::IdentityError,\n        oauth::{\n            AuthorizationStatus, CreateOAuthHandoff, OAuthHandoff, OAuthHandoffError,\n            OAuthHandoffRepository,\n        },\n        oauth_accounts::{OAuthAccountError, OAuthAccountInsert, OAuthAccountRepository},\n        organizations::OrganizationRepository,\n        users::{UpsertUser, UserRepository},\n    },\n};\n\nconst STATE_LENGTH: usize = 48;\nconst APP_CODE_LENGTH: usize = 48;\nconst HANDOFF_TTL: i64 = 10; // minutes\nconst USER_FETCH_MAX_ATTEMPTS: usize = 5;\nconst USER_FETCH_RETRY_DELAY_MS: u64 = 500;\n\n#[derive(Debug, Error)]\npub enum HandoffError {\n    #[error(\"unsupported provider `{0}`\")]\n    UnsupportedProvider(String),\n    #[error(\"invalid return url `{0}`\")]\n    InvalidReturnUrl(String),\n    #[error(\"invalid app verifier challenge\")]\n    InvalidChallenge,\n    #[error(\"oauth handoff not found\")]\n    NotFound,\n    #[error(\"oauth handoff expired\")]\n    Expired,\n    #[error(\"oauth authorization denied\")]\n    Denied,\n    #[error(\"oauth authorization failed: {0}\")]\n    Failed(String),\n    #[error(transparent)]\n    Provider(#[from] AnyhowError),\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(transparent)]\n    Identity(#[from] IdentityError),\n    #[error(transparent)]\n    OAuthAccount(#[from] OAuthAccountError),\n    #[error(transparent)]\n    Session(#[from] AuthSessionError),\n    #[error(transparent)]\n    Jwt(#[from] JwtError),\n    #[error(transparent)]\n    Authorization(#[from] OAuthHandoffError),\n}\n\n#[derive(Debug, Clone)]\npub struct HandoffInitResponse {\n    pub handoff_id: Uuid,\n    pub authorize_url: String,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone)]\npub enum CallbackResult {\n    Success {\n        handoff_id: Uuid,\n        return_to: String,\n        app_code: String,\n    },\n    Error {\n        handoff_id: Option<Uuid>,\n        return_to: Option<String>,\n        error: String,\n    },\n}\n\n#[derive(Debug, Clone)]\npub struct RedeemResponse {\n    pub access_token: String,\n    pub refresh_token: String,\n    pub user_id: Uuid,\n    pub email: String,\n}\n\npub struct OAuthHandoffService {\n    pool: PgPool,\n    providers: Arc<ProviderRegistry>,\n    jwt: Arc<JwtService>,\n    public_origin: String,\n}\n\nimpl OAuthHandoffService {\n    pub fn new(\n        pool: PgPool,\n        providers: Arc<ProviderRegistry>,\n        jwt: Arc<JwtService>,\n        public_origin: String,\n    ) -> Self {\n        let trimmed_origin = public_origin.trim_end_matches('/').to_string();\n        Self {\n            pool,\n            providers,\n            jwt,\n            public_origin: trimmed_origin,\n        }\n    }\n\n    pub fn providers(&self) -> Arc<ProviderRegistry> {\n        Arc::clone(&self.providers)\n    }\n\n    pub async fn initiate(\n        &self,\n        provider: &str,\n        return_to: &str,\n        app_challenge: &str,\n    ) -> Result<HandoffInitResponse, HandoffError> {\n        let provider = self\n            .providers\n            .get(provider)\n            .ok_or_else(|| HandoffError::UnsupportedProvider(provider.to_string()))?;\n\n        let return_to_url =\n            Url::parse(return_to).map_err(|_| HandoffError::InvalidReturnUrl(return_to.into()))?;\n        if !is_allowed_return_to(&return_to_url, &self.public_origin) {\n            return Err(HandoffError::InvalidReturnUrl(return_to.into()));\n        }\n\n        if !is_valid_challenge(app_challenge) {\n            return Err(HandoffError::InvalidChallenge);\n        }\n\n        let state = generate_state();\n        let expires_at = Utc::now() + Duration::minutes(HANDOFF_TTL);\n        let repo = OAuthHandoffRepository::new(&self.pool);\n        let record = repo\n            .create(CreateOAuthHandoff {\n                provider: provider.name(),\n                state: &state,\n                return_to: return_to_url.as_str(),\n                app_challenge,\n                expires_at,\n            })\n            .await?;\n\n        let authorize_url = format!(\n            \"{}/v1/oauth/{}/start?handoff_id={}\",\n            self.public_origin,\n            provider.name(),\n            record.id\n        );\n\n        Ok(HandoffInitResponse {\n            handoff_id: record.id,\n            authorize_url,\n            expires_at: record.expires_at,\n        })\n    }\n\n    pub async fn authorize_url(\n        &self,\n        provider: &str,\n        handoff_id: Uuid,\n    ) -> Result<String, HandoffError> {\n        let provider = self\n            .providers\n            .get(provider)\n            .ok_or_else(|| HandoffError::UnsupportedProvider(provider.to_string()))?;\n\n        let repo = OAuthHandoffRepository::new(&self.pool);\n        let record = repo.get(handoff_id).await?;\n\n        if record.provider != provider.name() {\n            return Err(HandoffError::UnsupportedProvider(record.provider));\n        }\n\n        if is_expired(&record) {\n            repo.set_status(record.id, AuthorizationStatus::Expired, Some(\"expired\"))\n                .await?;\n            return Err(HandoffError::Expired);\n        }\n\n        if record.status() != Some(AuthorizationStatus::Pending) {\n            return Err(HandoffError::Failed(\"invalid_state\".into()));\n        }\n\n        let redirect_uri = format!(\n            \"{}/v1/oauth/{}/callback\",\n            self.public_origin,\n            provider.name()\n        );\n\n        provider\n            .authorize_url(&record.state, &redirect_uri)\n            .map(|url| url.into())\n            .map_err(HandoffError::Provider)\n    }\n\n    pub async fn handle_callback(\n        &self,\n        provider_name: &str,\n        state: Option<&str>,\n        code: Option<&str>,\n        error: Option<&str>,\n    ) -> Result<CallbackResult, HandoffError> {\n        let provider = self\n            .providers\n            .get(provider_name)\n            .ok_or_else(|| HandoffError::UnsupportedProvider(provider_name.to_string()))?;\n\n        let Some(state_value) = state else {\n            return Ok(CallbackResult::Error {\n                handoff_id: None,\n                return_to: None,\n                error: \"missing_state\".into(),\n            });\n        };\n\n        let repo = OAuthHandoffRepository::new(&self.pool);\n        let record = repo.get_by_state(state_value).await?;\n\n        if record.provider != provider.name() {\n            return Err(HandoffError::UnsupportedProvider(record.provider));\n        }\n\n        if is_expired(&record) {\n            repo.set_status(record.id, AuthorizationStatus::Expired, Some(\"expired\"))\n                .await?;\n            return Err(HandoffError::Expired);\n        }\n\n        if let Some(err_code) = error {\n            repo.set_status(record.id, AuthorizationStatus::Error, Some(err_code))\n                .await?;\n            return Ok(CallbackResult::Error {\n                handoff_id: Some(record.id),\n                return_to: Some(record.return_to.clone()),\n                error: err_code.to_string(),\n            });\n        }\n\n        let code = code.ok_or_else(|| HandoffError::Failed(\"missing_code\".into()))?;\n\n        let redirect_uri = format!(\n            \"{}/v1/oauth/{}/callback\",\n            self.public_origin,\n            provider.name()\n        );\n\n        let grant = provider\n            .exchange_code(code, &redirect_uri)\n            .await\n            .map_err(HandoffError::Provider)?;\n\n        let provider_token_details = crate::auth::ProviderTokenDetails {\n            provider: provider.name().to_string(),\n            access_token: grant.access_token.expose_secret().to_string(),\n            refresh_token: grant\n                .refresh_token\n                .as_ref()\n                .map(|t| t.expose_secret().to_string()),\n            expires_at: grant.expires_in.map(|d| (Utc::now() + d).timestamp()),\n        };\n\n        let encrypted_tokens = self\n            .jwt\n            .encrypt_provider_tokens(&provider_token_details)\n            .map_err(|e| HandoffError::Failed(format!(\"Failed to encrypt provider token: {e}\")))?;\n\n        let user_profile = self.fetch_user_with_retries(&provider, &grant).await?;\n\n        let user = self\n            .upsert_identity(&provider, &user_profile, Some(encrypted_tokens.as_str()))\n            .await?;\n\n        let session_repo = AuthSessionRepository::new(&self.pool);\n        let session_record = session_repo.create(user.id, None).await?;\n\n        let app_code = generate_app_code();\n        let app_code_hash = hash_sha256_hex(&app_code);\n\n        repo.mark_authorized(\n            record.id,\n            user.id,\n            session_record.id,\n            &app_code_hash,\n            Some(encrypted_tokens),\n        )\n        .await?;\n\n        configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str()));\n\n        Ok(CallbackResult::Success {\n            handoff_id: record.id,\n            return_to: record.return_to,\n            app_code,\n        })\n    }\n\n    pub async fn redeem(\n        &self,\n        handoff_id: Uuid,\n        app_code: &str,\n        app_verifier: &str,\n    ) -> Result<RedeemResponse, HandoffError> {\n        let repo = OAuthHandoffRepository::new(&self.pool);\n        repo.ensure_redeemable(handoff_id).await?;\n\n        let record = repo.get(handoff_id).await?;\n\n        if is_expired(&record) {\n            repo.set_status(record.id, AuthorizationStatus::Expired, Some(\"expired\"))\n                .await?;\n            return Err(HandoffError::Expired);\n        }\n\n        let expected_code_hash = record\n            .app_code_hash\n            .ok_or_else(|| HandoffError::Failed(\"missing_app_code\".into()))?;\n        let provided_hash = hash_sha256_hex(app_code);\n        if provided_hash != expected_code_hash {\n            return Err(HandoffError::Failed(\"invalid_app_code\".into()));\n        }\n\n        let expected_challenge = record.app_challenge;\n        let provided_challenge = hash_sha256_hex(app_verifier);\n        if provided_challenge != expected_challenge {\n            return Err(HandoffError::Failed(\"invalid_app_verifier\".into()));\n        }\n\n        let session_id = record\n            .session_id\n            .ok_or_else(|| HandoffError::Failed(\"missing_session\".into()))?;\n        let user_id = record\n            .user_id\n            .ok_or_else(|| HandoffError::Failed(\"missing_user\".into()))?;\n        let provider = record.provider.clone();\n\n        let session_repo = AuthSessionRepository::new(&self.pool);\n        let session = session_repo.get(session_id).await?;\n        if session.revoked_at.is_some() {\n            return Err(HandoffError::Denied);\n        }\n\n        if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION {\n            session_repo.revoke(session.id).await?;\n            return Err(HandoffError::Denied);\n        }\n\n        let user_repo = UserRepository::new(&self.pool);\n        let user = user_repo.fetch_user(user_id).await?;\n        let org_repo = OrganizationRepository::new(&self.pool);\n        let _organization = org_repo\n            .ensure_personal_org_and_admin_membership(user.id, user.username.as_deref())\n            .await?;\n\n        let tokens = self.jwt.generate_tokens(&session, &user, &provider)?;\n\n        session_repo\n            .set_current_refresh_token(session.id, tokens.refresh_token_id)\n            .await?;\n\n        session_repo.touch(session.id).await?;\n        repo.mark_redeemed(record.id).await?;\n\n        configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str()));\n\n        Ok(RedeemResponse {\n            access_token: tokens.access_token,\n            refresh_token: tokens.refresh_token,\n            user_id: user.id,\n            email: user.email,\n        })\n    }\n\n    async fn fetch_user_with_retries(\n        &self,\n        provider: &Arc<dyn AuthorizationProvider>,\n        grant: &AuthorizationGrant,\n    ) -> Result<ProviderUser, HandoffError> {\n        let mut last_error: Option<AnyhowError> = None;\n        for attempt in 1..=USER_FETCH_MAX_ATTEMPTS {\n            match provider.fetch_user(&grant.access_token).await {\n                Ok(user) => return Ok(user),\n                Err(err) => {\n                    let retryable = attempt < USER_FETCH_MAX_ATTEMPTS && is_forbidden_error(&err);\n                    last_error = Some(err);\n                    if retryable {\n                        tokio::time::sleep(std::time::Duration::from_millis(\n                            USER_FETCH_RETRY_DELAY_MS,\n                        ))\n                        .await;\n                        continue;\n                    }\n                    break;\n                }\n            }\n        }\n\n        if let Some(err) = last_error {\n            Err(HandoffError::Provider(err))\n        } else {\n            Err(HandoffError::Failed(\"user_fetch_failed\".into()))\n        }\n    }\n\n    async fn upsert_identity(\n        &self,\n        provider: &Arc<dyn AuthorizationProvider>,\n        profile: &ProviderUser,\n        encrypted_provider_tokens: Option<&str>,\n    ) -> Result<IdentityUser, HandoffError> {\n        let account_repo = OAuthAccountRepository::new(&self.pool);\n        let user_repo = UserRepository::new(&self.pool);\n        let org_repo = OrganizationRepository::new(&self.pool);\n\n        let email = ensure_email(provider.name(), profile);\n        let username = derive_username(provider.name(), profile);\n        let display_name = derive_display_name(profile);\n\n        let existing_account = account_repo\n            .get_by_provider_user(provider.name(), &profile.id)\n            .await?;\n\n        let user_id = match existing_account {\n            Some(account) => account.user_id,\n            None => Uuid::new_v4(),\n        };\n\n        let (first_name, last_name) = split_name(profile.name.as_deref());\n\n        let user = user_repo\n            .upsert_user(UpsertUser {\n                id: user_id,\n                email: &email,\n                first_name: first_name.as_deref(),\n                last_name: last_name.as_deref(),\n                username: username.as_deref(),\n            })\n            .await?;\n\n        org_repo\n            .ensure_personal_org_and_admin_membership(user.id, username.as_deref())\n            .await?;\n\n        account_repo\n            .upsert(OAuthAccountInsert {\n                user_id: user.id,\n                provider: provider.name(),\n                provider_user_id: &profile.id,\n                email: Some(email.as_str()),\n                username: username.as_deref(),\n                display_name: display_name.as_deref(),\n                avatar_url: profile.avatar_url.as_deref(),\n                encrypted_provider_tokens,\n            })\n            .await?;\n\n        Ok(user)\n    }\n}\n\ntype IdentityUser = api_types::User;\n\nfn is_expired(record: &OAuthHandoff) -> bool {\n    record.expires_at <= Utc::now()\n}\n\nfn is_valid_challenge(challenge: &str) -> bool {\n    !challenge.is_empty()\n        && challenge.len() == 64\n        && challenge.chars().all(|ch| ch.is_ascii_hexdigit())\n}\n\nfn is_allowed_return_to(url: &Url, public_origin: &str) -> bool {\n    if url.scheme() == \"http\" && matches!(url.host_str(), Some(\"127.0.0.1\" | \"localhost\" | \"[::1]\"))\n    {\n        return true;\n    }\n\n    if url.scheme() == \"https\"\n        && Url::parse(public_origin).ok().is_some_and(|public_url| {\n            public_url.scheme() == \"https\"\n                && public_url.host_str().is_some()\n                && url.host_str() == public_url.host_str()\n        })\n    {\n        return true;\n    }\n\n    // Log and allow web-hosted clients. Rely on PKCE for security.\n    tracing::info!(%url, \"allowing external redirect URL\");\n    true\n}\n\nfn hash_sha256_hex(input: &str) -> String {\n    let digest = Sha256::digest(input.as_bytes());\n    let mut output = String::with_capacity(digest.len() * 2);\n    for byte in digest {\n        let _ = write!(output, \"{byte:02x}\");\n    }\n    output\n}\n\nfn generate_state() -> String {\n    rand::rng()\n        .sample_iter(&Alphanumeric)\n        .take(STATE_LENGTH)\n        .map(char::from)\n        .collect()\n}\n\nfn generate_app_code() -> String {\n    rand::rng()\n        .sample_iter(&Alphanumeric)\n        .take(APP_CODE_LENGTH)\n        .map(char::from)\n        .collect()\n}\n\nfn ensure_email(provider: &str, profile: &ProviderUser) -> String {\n    if let Some(email) = profile.email.clone() {\n        return email;\n    }\n    match provider {\n        \"github\" => format!(\"{}@users.noreply.github.com\", profile.id),\n        \"google\" => format!(\"{}@users.noreply.google.com\", profile.id),\n        _ => format!(\"{}@oauth.local\", profile.id),\n    }\n}\n\nfn derive_username(provider: &str, profile: &ProviderUser) -> Option<String> {\n    if let Some(login) = profile.login.clone() {\n        return Some(login);\n    }\n    if let Some(email) = profile.email.as_deref() {\n        return email.split('@').next().map(|part| part.to_owned());\n    }\n    Some(format!(\"{}-{}\", provider, profile.id))\n}\n\nfn derive_display_name(profile: &ProviderUser) -> Option<String> {\n    profile.name.clone()\n}\n\nfn split_name(name: Option<&str>) -> (Option<String>, Option<String>) {\n    match name {\n        Some(value) => {\n            let mut iter = value.split_whitespace();\n            let first = iter.next().map(|s| s.to_string());\n            let remainder: Vec<&str> = iter.collect();\n            let last = if remainder.is_empty() {\n                None\n            } else {\n                Some(remainder.join(\" \"))\n            };\n            (first, last)\n        }\n        None => (None, None),\n    }\n}\n\nfn is_forbidden_error(err: &AnyhowError) -> bool {\n    err.chain().any(|cause| {\n        cause\n            .downcast_ref::<reqwest::Error>()\n            .and_then(|req_err| req_err.status())\n            .map(|status| status == StatusCode::FORBIDDEN)\n            .unwrap_or(false)\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn hashes_match_hex_length() {\n        let output = hash_sha256_hex(\"example\");\n        assert_eq!(output.len(), 64);\n    }\n\n    #[test]\n    fn challenge_validation() {\n        assert!(is_valid_challenge(\n            \"0d44b13d0112ff7c94f27f66a701d89f5cb9184160a95cace0bbd10b191ed257\"\n        ));\n        assert!(!is_valid_challenge(\"not-hex\"));\n        assert!(!is_valid_challenge(\"\"));\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/auth/jwt.rs",
    "content": "use std::{collections::HashSet, sync::Arc};\n\nuse aes_gcm::{\n    Aes256Gcm, Key, Nonce,\n    aead::{Aead, AeadCore, KeyInit, OsRng},\n};\nuse api_types::User;\nuse base64::{\n    Engine as _,\n    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},\n};\nuse chrono::{DateTime, Duration as ChronoDuration, Utc};\nuse jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};\nuse secrecy::{ExposeSecret, SecretString};\nuse serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse crate::{auth::provider::ProviderTokenDetails, db::auth::AuthSession};\n\npub const ACCESS_TOKEN_TTL_SECONDS: i64 = 120;\npub const REFRESH_TOKEN_TTL_DAYS: i64 = 365;\nconst DEFAULT_JWT_LEEWAY_SECONDS: u64 = 60;\n\n#[derive(Debug, Error)]\npub enum JwtError {\n    #[error(\"invalid token\")]\n    InvalidToken,\n    #[error(\"invalid jwt secret\")]\n    InvalidSecret,\n    #[error(\"token expired\")]\n    TokenExpired,\n    #[error(\"refresh token reused - possible theft detected\")]\n    TokenReuseDetected,\n    #[error(\"session revoked\")]\n    SessionRevoked,\n    #[error(\"token type mismatch\")]\n    InvalidTokenType,\n    #[error(\"encryption error\")]\n    EncryptionError,\n    #[error(\"serialization error\")]\n    SerializationError,\n    #[error(transparent)]\n    Jwt(#[from] jsonwebtoken::errors::Error),\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AccessTokenClaims {\n    pub sub: Uuid,\n    pub session_id: Uuid,\n    pub iat: i64,\n    pub exp: i64,\n    pub aud: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RefreshTokenClaims {\n    pub sub: Uuid,\n    pub session_id: Uuid,\n    pub jti: Uuid,\n    pub iat: i64,\n    pub exp: i64,\n    pub aud: String,\n    pub provider: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub provider_tokens_blob: Option<String>, // Legacy claim for older refresh tokens\n}\n\n#[derive(Debug, Clone)]\npub struct AccessTokenDetails {\n    pub user_id: Uuid,\n    pub session_id: Uuid,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone)]\npub struct RefreshTokenDetails {\n    pub user_id: Uuid,\n    pub session_id: Uuid,\n    pub refresh_token_id: Uuid,\n    pub provider: String,\n    pub legacy_provider_token_details: Option<ProviderTokenDetails>,\n}\n\n#[derive(Clone)]\npub struct JwtService {\n    pub secret: Arc<SecretString>,\n}\n\n#[derive(Debug, Clone)]\npub struct Tokens {\n    pub access_token: String,\n    pub refresh_token: String,\n    pub refresh_token_id: Uuid,\n}\n\nimpl JwtService {\n    pub fn new(secret: SecretString) -> Self {\n        Self {\n            secret: Arc::new(secret),\n        }\n    }\n\n    pub fn generate_tokens(\n        &self,\n        session: &AuthSession,\n        user: &User,\n        provider: &str,\n    ) -> Result<Tokens, JwtError> {\n        let now = Utc::now();\n        let refresh_token_id = Uuid::new_v4();\n\n        // Access token, short-lived (~2 minutes)\n        let access_exp = now + ChronoDuration::seconds(ACCESS_TOKEN_TTL_SECONDS);\n        let access_claims = AccessTokenClaims {\n            sub: user.id,\n            session_id: session.id,\n            iat: now.timestamp(),\n            exp: access_exp.timestamp(),\n            aud: \"access\".to_string(),\n        };\n\n        // Refresh token, long-lived (~1 year)\n        let refresh_exp = now + ChronoDuration::days(REFRESH_TOKEN_TTL_DAYS);\n        let refresh_claims = RefreshTokenClaims {\n            sub: user.id,\n            session_id: session.id,\n            jti: refresh_token_id,\n            iat: now.timestamp(),\n            exp: refresh_exp.timestamp(),\n            aud: \"refresh\".to_string(),\n            provider: Some(provider.to_string()),\n            provider_tokens_blob: None,\n        };\n\n        let encoding_key = EncodingKey::from_base64_secret(self.secret.expose_secret())?;\n\n        let access_token = encode(\n            &Header::new(Algorithm::HS256),\n            &access_claims,\n            &encoding_key,\n        )?;\n\n        let refresh_token = encode(\n            &Header::new(Algorithm::HS256),\n            &refresh_claims,\n            &encoding_key,\n        )?;\n\n        Ok(Tokens {\n            access_token,\n            refresh_token,\n            refresh_token_id,\n        })\n    }\n\n    pub fn generate_access_token(\n        &self,\n        user_id: Uuid,\n        session_id: Uuid,\n    ) -> Result<String, JwtError> {\n        let now = Utc::now();\n        let access_exp = now + ChronoDuration::seconds(ACCESS_TOKEN_TTL_SECONDS);\n        let claims = AccessTokenClaims {\n            sub: user_id,\n            session_id,\n            iat: now.timestamp(),\n            exp: access_exp.timestamp(),\n            aud: \"access\".to_string(),\n        };\n\n        let encoding_key = EncodingKey::from_base64_secret(self.secret.expose_secret())?;\n        Ok(encode(\n            &Header::new(Algorithm::HS256),\n            &claims,\n            &encoding_key,\n        )?)\n    }\n\n    pub fn decode_access_token(&self, token: &str) -> Result<AccessTokenDetails, JwtError> {\n        self.decode_access_token_with_leeway(token, DEFAULT_JWT_LEEWAY_SECONDS)\n    }\n\n    pub fn decode_access_token_with_leeway(\n        &self,\n        token: &str,\n        leeway_seconds: u64,\n    ) -> Result<AccessTokenDetails, JwtError> {\n        if token.trim().is_empty() {\n            return Err(JwtError::InvalidToken);\n        }\n\n        let mut validation = Validation::new(Algorithm::HS256);\n        validation.validate_exp = true;\n        validation.validate_nbf = false;\n        validation.set_audience(&[\"access\"]);\n        validation.required_spec_claims =\n            HashSet::from([\"sub\".to_string(), \"exp\".to_string(), \"aud\".to_string()]);\n        validation.leeway = leeway_seconds;\n\n        let decoding_key = DecodingKey::from_base64_secret(self.secret.expose_secret())?;\n        let data = decode::<AccessTokenClaims>(token, &decoding_key, &validation)?;\n        let claims = data.claims;\n        let expires_at = DateTime::from_timestamp(claims.exp, 0).ok_or(JwtError::InvalidToken)?;\n\n        Ok(AccessTokenDetails {\n            user_id: claims.sub,\n            session_id: claims.session_id,\n            expires_at,\n        })\n    }\n\n    pub fn decode_refresh_token(&self, token: &str) -> Result<RefreshTokenDetails, JwtError> {\n        if token.trim().is_empty() {\n            return Err(JwtError::InvalidToken);\n        }\n\n        let mut validation = Validation::new(Algorithm::HS256);\n        validation.validate_exp = true;\n        validation.validate_nbf = false;\n        validation.set_audience(&[\"refresh\"]);\n        validation.required_spec_claims = HashSet::from([\n            \"sub\".to_string(),\n            \"exp\".to_string(),\n            \"aud\".to_string(),\n            \"jti\".to_string(),\n        ]);\n        validation.leeway = DEFAULT_JWT_LEEWAY_SECONDS;\n\n        let decoding_key = DecodingKey::from_base64_secret(self.secret.expose_secret())?;\n        let data = decode::<RefreshTokenClaims>(token, &decoding_key, &validation)?;\n        let claims = data.claims;\n\n        let (provider, legacy_provider_token_details) =\n            if let Some(provider) = claims.provider.as_ref().filter(|p| !p.trim().is_empty()) {\n                (provider.to_string(), None)\n            } else if let Some(provider_tokens_blob) = claims.provider_tokens_blob.as_deref() {\n                let provider_token_details = self.decrypt_provider_tokens(provider_tokens_blob)?;\n                (\n                    provider_token_details.provider.clone(),\n                    Some(provider_token_details),\n                )\n            } else {\n                return Err(JwtError::InvalidToken);\n            };\n\n        Ok(RefreshTokenDetails {\n            user_id: claims.sub,\n            session_id: claims.session_id,\n            refresh_token_id: claims.jti,\n            provider,\n            legacy_provider_token_details,\n        })\n    }\n\n    pub fn decrypt_provider_tokens(\n        &self,\n        provider_tokens_blob: &str,\n    ) -> Result<ProviderTokenDetails, JwtError> {\n        let decrypted = self.decrypt_data(provider_tokens_blob)?;\n        let decrypted_str = String::from_utf8_lossy(&decrypted);\n        serde_json::from_str(&decrypted_str).map_err(|_| JwtError::InvalidToken)\n    }\n\n    pub fn encrypt_provider_tokens(\n        &self,\n        provider_tokens: &ProviderTokenDetails,\n    ) -> Result<String, JwtError> {\n        let json =\n            serde_json::to_string(provider_tokens).map_err(|_| JwtError::SerializationError)?;\n        self.encrypt_data(json.as_bytes())\n    }\n\n    fn encrypt_data(&self, data: &[u8]) -> Result<String, JwtError> {\n        let key_bytes = self.derive_key()?;\n        let key = Key::<Aes256Gcm>::from(key_bytes);\n        let cipher = Aes256Gcm::new(&key);\n        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);\n        let ciphertext = cipher\n            .encrypt(&nonce, data)\n            .map_err(|_| JwtError::EncryptionError)?;\n\n        let mut combined = nonce.to_vec();\n        combined.extend_from_slice(&ciphertext);\n\n        Ok(URL_SAFE_NO_PAD.encode(combined))\n    }\n\n    fn decrypt_data(&self, encrypted: &str) -> Result<Vec<u8>, JwtError> {\n        let decoded = URL_SAFE_NO_PAD\n            .decode(encrypted)\n            .map_err(|_| JwtError::InvalidToken)?;\n\n        const NONCE_SIZE: usize = 12; // 96 bits for AES-256-GCM\n        if decoded.len() < NONCE_SIZE {\n            return Err(JwtError::InvalidToken);\n        }\n\n        let key_bytes = self.derive_key()?;\n        let key = Key::<Aes256Gcm>::from(key_bytes);\n        let cipher = Aes256Gcm::new(&key);\n        let nonce_bytes: [u8; NONCE_SIZE] = decoded[..NONCE_SIZE]\n            .try_into()\n            .map_err(|_| JwtError::InvalidToken)?;\n        let nonce = Nonce::from(nonce_bytes);\n        let ciphertext = &decoded[NONCE_SIZE..];\n\n        cipher\n            .decrypt(&nonce, ciphertext)\n            .map_err(|_| JwtError::EncryptionError)\n    }\n\n    fn derive_key(&self) -> Result<[u8; 32], JwtError> {\n        let secret_bytes = STANDARD\n            .decode(self.secret.expose_secret())\n            .map_err(|_| JwtError::InvalidSecret)?;\n\n        let mut hasher = Sha256::new();\n        hasher.update(&secret_bytes);\n        Ok(hasher.finalize().into())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/auth/middleware.rs",
    "content": "use api_types::User;\nuse axum::{\n    body::Body,\n    extract::State,\n    http::{Request, StatusCode},\n    middleware::Next,\n    response::{IntoResponse, Response},\n};\nuse axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer};\nuse chrono::{DateTime, Utc};\nuse tower_http::request_id::RequestId;\nuse tracing::{Span, warn};\nuse uuid::Uuid;\n\nuse crate::{\n    AppState, audit,\n    audit::{AuditAction, AuditEvent},\n    configure_user_scope,\n    db::{\n        self,\n        auth::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION},\n        identity_errors::IdentityError,\n        users::UserRepository,\n    },\n};\n\n#[derive(Clone)]\npub struct RequestContext {\n    pub user: User,\n    pub session_id: Uuid,\n    #[allow(dead_code)]\n    pub access_token_expires_at: DateTime<Utc>,\n}\n\npub async fn require_session(\n    State(state): State<AppState>,\n    mut req: Request<Body>,\n    next: Next,\n) -> Response {\n    let bearer = match req.headers().typed_get::<Authorization<Bearer>>() {\n        Some(Authorization(token)) => token.token().to_owned(),\n        None => return StatusCode::UNAUTHORIZED.into_response(),\n    };\n\n    let ctx = match request_context_from_access_token(&state, &bearer).await {\n        Ok(ctx) => ctx,\n        Err(response) => return response,\n    };\n\n    Span::current().record(\"user_id\", tracing::field::display(ctx.user.id));\n\n    let request_id = req\n        .extensions()\n        .get::<RequestId>()\n        .and_then(|id| id.header_value().to_str().ok())\n        .unwrap_or(\"\")\n        .to_owned();\n\n    let tx_ctx = db::TxContext {\n        user_id: ctx.user.id,\n        request_id,\n    };\n\n    req.extensions_mut().insert(ctx);\n    db::TX_CONTEXT.scope(Some(tx_ctx), next.run(req)).await\n}\n\npub async fn request_context_from_access_token(\n    state: &AppState,\n    access_token: &str,\n) -> Result<RequestContext, Response> {\n    let jwt = state.jwt();\n    let identity = match jwt.decode_access_token(access_token) {\n        Ok(details) => details,\n        Err(error) => {\n            warn!(?error, \"failed to decode access token\");\n            return Err(StatusCode::UNAUTHORIZED.into_response());\n        }\n    };\n\n    let mut ctx = request_context_from_auth_session_id(state, identity.session_id).await?;\n    if ctx.user.id != identity.user_id {\n        warn!(\n            token_user_id = %identity.user_id,\n            session_user_id = %ctx.user.id,\n            session_id = %identity.session_id,\n            \"access token user does not match session user\"\n        );\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    ctx.access_token_expires_at = identity.expires_at;\n    Ok(ctx)\n}\n\npub async fn request_context_from_auth_session_id(\n    state: &AppState,\n    session_id: Uuid,\n) -> Result<RequestContext, Response> {\n    let pool = state.pool();\n    let session_repo = AuthSessionRepository::new(pool);\n    let session = match session_repo.get(session_id).await {\n        Ok(session) => session,\n        Err(AuthSessionError::NotFound) => {\n            warn!(\"session `{}` not found\", session_id);\n            return Err(StatusCode::UNAUTHORIZED.into_response());\n        }\n        Err(AuthSessionError::Database(error)) => {\n            warn!(?error, \"failed to load session\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n        Err(_) => {\n            warn!(\"failed to load session for unknown reason\");\n            return Err(StatusCode::UNAUTHORIZED.into_response());\n        }\n    };\n\n    if session.revoked_at.is_some() {\n        warn!(\"session `{}` rejected (revoked)\", session.id);\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION {\n        warn!(\n            \"session `{}` expired due to inactivity; revoking\",\n            session.id\n        );\n        if let Err(error) = session_repo.revoke(session.id).await {\n            warn!(?error, \"failed to revoke inactive session\");\n        }\n        audit::emit(\n            AuditEvent::system(AuditAction::AuthSessionRevoked)\n                .user(session.user_id, Some(session.id))\n                .resource(\"auth_session\", Some(session.id))\n                .http(\"\", \"\", 401)\n                .description(\"Session revoked due to inactivity\"),\n        );\n        return Err(StatusCode::UNAUTHORIZED.into_response());\n    }\n\n    let user_repo = UserRepository::new(pool);\n    let user = match user_repo.fetch_user(session.user_id).await {\n        Ok(user) => user,\n        Err(IdentityError::NotFound) => {\n            warn!(\"user `{}` missing\", session.user_id);\n            return Err(StatusCode::UNAUTHORIZED.into_response());\n        }\n        Err(IdentityError::Database(error)) => {\n            warn!(?error, \"failed to load user\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n        Err(_) => {\n            warn!(\"unexpected error loading user\");\n            return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());\n        }\n    };\n\n    configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str()));\n\n    let ctx = RequestContext {\n        user,\n        session_id: session.id,\n        access_token_expires_at: Utc::now(),\n    };\n\n    match session_repo.touch(session.id).await {\n        Ok(_) => {}\n        Err(error) => warn!(?error, \"failed to update session last-used timestamp\"),\n    }\n\n    Ok(ctx)\n}\n"
  },
  {
    "path": "crates/remote/src/auth/mod.rs",
    "content": "mod handoff;\nmod jwt;\nmod middleware;\nmod oauth_token_validator;\nmod provider;\n\npub use handoff::{CallbackResult, HandoffError, OAuthHandoffService};\npub use jwt::{JwtError, JwtService};\npub use middleware::{RequestContext, require_session};\npub use oauth_token_validator::{OAuthTokenValidationError, OAuthTokenValidator};\npub use provider::{\n    GitHubOAuthProvider, GoogleOAuthProvider, ProviderRegistry, ProviderTokenDetails,\n};\n"
  },
  {
    "path": "crates/remote/src/auth/oauth_token_validator.rs",
    "content": "use std::sync::Arc;\n\nuse sqlx::PgPool;\nuse tracing::{error, info, warn};\nuse uuid::Uuid;\n\nuse crate::{\n    audit::{self, AuditAction, AuditEvent},\n    auth::{\n        JwtService, ProviderTokenDetails,\n        provider::{ProviderRegistry, TokenValidationError, VALIDATE_TOKEN_MAX_RETRIES},\n    },\n    db::{\n        auth::AuthSessionRepository,\n        oauth_accounts::{OAuthAccountError, OAuthAccountRepository},\n    },\n};\n\n#[derive(Debug, thiserror::Error)]\npub enum OAuthTokenValidationError {\n    #[error(\"failed to fetch OAuth accounts for user\")]\n    FetchAccountsFailed(OAuthAccountError),\n    #[error(\"provider account no longer linked to user\")]\n    ProviderAccountNotLinked,\n    #[error(\"OAuth provider token validation failed\")]\n    ProviderTokenValidationFailed,\n    #[error(\"temporary failure validating provider token: {0}\")]\n    ValidationUnavailable(String),\n}\n\npub struct OAuthTokenValidator {\n    pool: PgPool,\n    provider_registry: Arc<ProviderRegistry>,\n    jwt: Arc<JwtService>,\n}\n\nimpl OAuthTokenValidator {\n    pub fn new(\n        pool: PgPool,\n        provider_registry: Arc<ProviderRegistry>,\n        jwt: Arc<JwtService>,\n    ) -> Self {\n        Self {\n            pool,\n            provider_registry,\n            jwt,\n        }\n    }\n\n    // Check if the OAuth provider token is still valid, refresh if possible\n    // Revoke all sessions if provider has revoked the OAuth token\n    pub async fn validate(\n        &self,\n        provider: &str,\n        user_id: Uuid,\n        session_id: Uuid,\n    ) -> Result<(), OAuthTokenValidationError> {\n        match self.verify_inner(provider, user_id, session_id).await {\n            Ok(()) => Ok(()),\n            Err(err) => {\n                match &err {\n                    OAuthTokenValidationError::ProviderAccountNotLinked\n                    | OAuthTokenValidationError::ProviderTokenValidationFailed\n                    | OAuthTokenValidationError::FetchAccountsFailed(_) => {\n                        let session_repo = AuthSessionRepository::new(&self.pool);\n                        if let Err(e) = session_repo.revoke_all_user_sessions(user_id).await {\n                            error!(\n                                user_id = %user_id,\n                                error = %e,\n                                \"Failed to revoke all user sessions after OAuth token validation failure\"\n                            );\n                        }\n                        audit::emit(\n                            AuditEvent::system(AuditAction::AuthSessionRevoked)\n                                .user(user_id, Some(session_id))\n                                .resource(\"auth_session\", None)\n                                .description(\"All sessions revoked: OAuth provider token invalid\"),\n                        );\n                    }\n                    OAuthTokenValidationError::ValidationUnavailable(_) => (),\n                };\n                Err(err)\n            }\n        }\n    }\n\n    async fn verify_inner(\n        &self,\n        provider_name: &str,\n        user_id: Uuid,\n        session_id: Uuid,\n    ) -> Result<(), OAuthTokenValidationError> {\n        let oauth_account_repo = OAuthAccountRepository::new(&self.pool);\n        let account = match oauth_account_repo\n            .get_by_user_provider(user_id, provider_name)\n            .await\n        {\n            Ok(account) => account,\n            Err(err) => {\n                error!(\n                    user_id = %user_id,\n                    error = %err,\n                    provider = %provider_name,\n                    \"Failed to fetch OAuth account for user\"\n                );\n                return Err(OAuthTokenValidationError::FetchAccountsFailed(err));\n            }\n        };\n\n        let Some(account) = account else {\n            warn!(\n                user_id = %user_id,\n                provider = %provider_name,\n                \"Provider account no longer linked to user, revoking sessions\"\n            );\n            return Err(OAuthTokenValidationError::ProviderAccountNotLinked);\n        };\n\n        let Some(encrypted_tokens) = account.encrypted_provider_tokens.as_deref() else {\n            error!(\n                user_id = %user_id,\n                provider = %provider_name,\n                session_id = %session_id,\n                \"OAuth account is missing provider token\"\n            );\n            return Err(OAuthTokenValidationError::ProviderTokenValidationFailed);\n        };\n\n        let mut provider_token_details = match self.jwt.decrypt_provider_tokens(encrypted_tokens) {\n            Ok(details) => details,\n            Err(err) => {\n                error!(\n                    user_id = %user_id,\n                    provider = %provider_name,\n                    session_id = %session_id,\n                    error = %err,\n                    \"Failed to decrypt provider token from oauth account\"\n                );\n                return Err(OAuthTokenValidationError::ProviderTokenValidationFailed);\n            }\n        };\n\n        if provider_token_details.provider != provider_name {\n            error!(\n                user_id = %user_id,\n                provider = %provider_name,\n                session_id = %session_id,\n                \"Provider token details did not match linked provider account\"\n            );\n            return Err(OAuthTokenValidationError::ProviderTokenValidationFailed);\n        }\n\n        let Some(provider) = self.provider_registry.get(provider_name) else {\n            error!(\n                user_id = %user_id,\n                provider = %provider_name,\n                \"OAuth provider not found in registry, revoking all sessions\"\n            );\n            return Err(OAuthTokenValidationError::ProviderTokenValidationFailed);\n        };\n\n        match provider\n            .validate_token(&provider_token_details, VALIDATE_TOKEN_MAX_RETRIES)\n            .await\n        {\n            Ok(Some(updated_token_details)) => {\n                provider_token_details = updated_token_details;\n                self.persist_provider_tokens(\n                    &oauth_account_repo,\n                    user_id,\n                    provider_name,\n                    &provider_token_details,\n                )\n                .await?;\n            }\n            Ok(None) => {}\n            Err(TokenValidationError::InvalidOrRevoked) => {\n                info!(\n                    user_id = %user_id,\n                    provider = %provider_name,\n                    session_id = %session_id,\n                    \"OAuth provider reported token as invalid or revoked\"\n                );\n                return Err(OAuthTokenValidationError::ProviderTokenValidationFailed);\n            }\n            Err(TokenValidationError::Temporary(reason)) => {\n                warn!(\n                    user_id = %user_id,\n                    provider = %provider_name,\n                    session_id = %session_id,\n                    error = %reason,\n                    \"OAuth provider validation temporarily unavailable\"\n                );\n                return Err(OAuthTokenValidationError::ValidationUnavailable(reason));\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn persist_provider_tokens(\n        &self,\n        oauth_account_repo: &OAuthAccountRepository<'_>,\n        user_id: Uuid,\n        provider: &str,\n        provider_token_details: &ProviderTokenDetails,\n    ) -> Result<(), OAuthTokenValidationError> {\n        let encrypted_provider_tokens = self\n            .jwt\n            .encrypt_provider_tokens(provider_token_details)\n            .map_err(|err| {\n                error!(\n                    user_id = %user_id,\n                    provider = %provider,\n                    error = %err,\n                    \"Failed to encrypt provider token for persistence\"\n                );\n                OAuthTokenValidationError::ValidationUnavailable(\n                    \"failed to encrypt provider token\".to_string(),\n                )\n            })?;\n\n        oauth_account_repo\n            .update_encrypted_provider_tokens(user_id, provider, &encrypted_provider_tokens)\n            .await\n            .map_err(|err| {\n                error!(\n                    user_id = %user_id,\n                    provider = %provider,\n                    error = %err,\n                    \"Failed to persist provider token on oauth account\"\n                );\n                OAuthTokenValidationError::ValidationUnavailable(\n                    \"failed to persist provider token\".to_string(),\n                )\n            })?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/auth/provider.rs",
    "content": "use std::{collections::HashMap, sync::Arc};\n\nuse anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse chrono::Duration;\nuse reqwest::Client;\nuse secrecy::{ExposeSecret, SecretString};\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse tracing::info;\nuse url::Url;\n\nconst USER_AGENT: &str = \"VibeKanbanRemote/1.0\";\n\nconst TOKEN_EXPIRATION_LEEWAY_SECONDS: i64 = 20;\npub const VALIDATE_TOKEN_MAX_RETRIES: u32 = 3;\nconst RETRY_INTERVAL_SECONDS: u64 = 2;\n\n#[derive(Debug, Clone)]\npub struct AuthorizationGrant {\n    pub access_token: SecretString,\n    pub token_type: String,\n    pub scopes: Vec<String>,\n    pub refresh_token: Option<SecretString>,\n    pub expires_in: Option<Duration>,\n    pub id_token: Option<SecretString>,\n}\n\n#[derive(Debug)]\npub struct ProviderUser {\n    pub id: String,\n    pub login: Option<String>,\n    pub email: Option<String>,\n    pub name: Option<String>,\n    pub avatar_url: Option<String>,\n}\n\n#[derive(Debug, Error)]\npub enum TokenValidationError {\n    #[error(\"provider token invalid or revoked\")]\n    InvalidOrRevoked,\n    #[error(\"provider validation temporarily unavailable: {0}\")]\n    Temporary(String),\n}\n\nimpl TokenValidationError {\n    fn temporary(message: impl Into<String>) -> Self {\n        Self::Temporary(message.into())\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderTokenDetails {\n    pub provider: String,\n    pub access_token: String,\n    pub refresh_token: Option<String>,\n    pub expires_at: Option<i64>,\n}\n\n#[async_trait]\npub trait AuthorizationProvider: Send + Sync {\n    fn name(&self) -> &'static str;\n    fn scopes(&self) -> &[&str];\n    fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result<Url>;\n    async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<AuthorizationGrant>;\n    async fn fetch_user(&self, access_token: &SecretString) -> Result<ProviderUser>;\n    async fn validate_token(\n        &self,\n        token_details: &ProviderTokenDetails,\n        max_retries: u32,\n    ) -> Result<Option<ProviderTokenDetails>, TokenValidationError>;\n}\n\n#[derive(Default)]\npub struct ProviderRegistry {\n    providers: HashMap<String, Arc<dyn AuthorizationProvider>>,\n}\n\nimpl ProviderRegistry {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn register<P>(&mut self, provider: P)\n    where\n        P: AuthorizationProvider + 'static,\n    {\n        let key = provider.name().to_lowercase();\n        self.providers.insert(key, Arc::new(provider));\n    }\n\n    pub fn get(&self, provider: &str) -> Option<Arc<dyn AuthorizationProvider>> {\n        let key = provider.to_lowercase();\n        self.providers.get(&key).cloned()\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.providers.is_empty()\n    }\n}\n\npub struct GitHubOAuthProvider {\n    client: Client,\n    client_id: String,\n    client_secret: SecretString,\n}\n\nimpl GitHubOAuthProvider {\n    pub fn new(client_id: String, client_secret: SecretString) -> Result<Self> {\n        let client = Client::builder().user_agent(USER_AGENT).build()?;\n        Ok(Self {\n            client,\n            client_id,\n            client_secret,\n        })\n    }\n\n    fn parse_scopes(scope: Option<String>) -> Vec<String> {\n        scope\n            .unwrap_or_default()\n            .split(',')\n            .filter_map(|value| {\n                let trimmed = value.trim();\n                (!trimmed.is_empty()).then_some(trimmed.to_string())\n            })\n            .collect()\n    }\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(untagged)]\nenum GitHubTokenResponse {\n    Success {\n        access_token: String,\n        scope: Option<String>,\n        token_type: String,\n    },\n    Error {\n        error: String,\n        error_description: Option<String>,\n    },\n}\n\n#[derive(Debug, Deserialize)]\nstruct GitHubUser {\n    id: i64,\n    login: String,\n    email: Option<String>,\n    name: Option<String>,\n    avatar_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GitHubEmail {\n    email: String,\n    primary: bool,\n    verified: bool,\n}\n\n#[async_trait]\nimpl AuthorizationProvider for GitHubOAuthProvider {\n    fn name(&self) -> &'static str {\n        \"github\"\n    }\n\n    fn scopes(&self) -> &[&str] {\n        &[\"read:user\", \"user:email\"]\n    }\n\n    fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result<Url> {\n        let mut url = Url::parse(\"https://github.com/login/oauth/authorize\")?;\n        {\n            let mut qp = url.query_pairs_mut();\n            qp.append_pair(\"client_id\", &self.client_id);\n            qp.append_pair(\"state\", state);\n            qp.append_pair(\"redirect_uri\", redirect_uri);\n            qp.append_pair(\"allow_signup\", \"false\");\n            qp.append_pair(\"scope\", &self.scopes().join(\" \"));\n        }\n        Ok(url)\n    }\n\n    async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<AuthorizationGrant> {\n        let response = self\n            .client\n            .post(\"https://github.com/login/oauth/access_token\")\n            .header(\"Accept\", \"application/json\")\n            .form(&[\n                (\"client_id\", self.client_id.as_str()),\n                (\"client_secret\", self.client_secret.expose_secret()),\n                (\"code\", code),\n                (\"redirect_uri\", redirect_uri),\n            ])\n            .send()\n            .await?\n            .error_for_status()?;\n\n        match response.json::<GitHubTokenResponse>().await? {\n            GitHubTokenResponse::Success {\n                access_token,\n                scope,\n                token_type,\n            } => Ok(AuthorizationGrant {\n                access_token: SecretString::new(access_token.into()),\n                token_type,\n                scopes: Self::parse_scopes(scope),\n                refresh_token: None,\n                expires_in: None,\n                id_token: None,\n            }),\n            GitHubTokenResponse::Error {\n                error,\n                error_description,\n            } => {\n                let detail = error_description.unwrap_or_else(|| error.clone());\n                anyhow::bail!(\"github token exchange failed: {detail}\")\n            }\n        }\n    }\n\n    async fn fetch_user(&self, access_token: &SecretString) -> Result<ProviderUser> {\n        let bearer = format!(\"Bearer {}\", access_token.expose_secret());\n\n        let user: GitHubUser = self\n            .client\n            .get(\"https://api.github.com/user\")\n            .header(\"Accept\", \"application/vnd.github+json\")\n            .header(\"Authorization\", &bearer)\n            .send()\n            .await?\n            .error_for_status()?\n            .json()\n            .await?;\n\n        let email = if user.email.is_some() {\n            user.email\n        } else {\n            let response = self\n                .client\n                .get(\"https://api.github.com/user/emails\")\n                .header(\"Accept\", \"application/vnd.github+json\")\n                .header(\"Authorization\", bearer)\n                .send()\n                .await?;\n\n            if response.status().is_success() {\n                let emails: Vec<GitHubEmail> = response\n                    .json()\n                    .await\n                    .context(\"failed to parse GitHub email response\")?;\n                emails\n                    .into_iter()\n                    .find(|entry| entry.primary && entry.verified)\n                    .map(|entry| entry.email)\n            } else {\n                None\n            }\n        };\n\n        Ok(ProviderUser {\n            id: user.id.to_string(),\n            login: Some(user.login),\n            email,\n            name: user.name,\n            avatar_url: user.avatar_url,\n        })\n    }\n\n    async fn validate_token(\n        &self,\n        token_details: &ProviderTokenDetails,\n        max_retries: u32,\n    ) -> Result<Option<ProviderTokenDetails>, TokenValidationError> {\n        let mut attempt = 0;\n        let access_token = SecretString::new(token_details.access_token.clone().into_boxed_str());\n\n        loop {\n            attempt += 1;\n\n            let response = match self\n                .client\n                .get(\"https://api.github.com/rate_limit\")\n                .header(\n                    \"Authorization\",\n                    format!(\"Bearer {}\", access_token.expose_secret()),\n                )\n                .header(\"Accept\", \"application/vnd.github+json\")\n                .send()\n                .await\n            {\n                Ok(resp) => resp,\n                Err(err) => {\n                    if attempt >= max_retries {\n                        return Err(TokenValidationError::temporary(format!(\n                            \"request failed: {err}\"\n                        )));\n                    }\n                    tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS))\n                        .await;\n                    continue;\n                }\n            };\n\n            match response.status() {\n                reqwest::StatusCode::OK => {\n                    // GitHub tokens don't expire\n                    return Ok(None);\n                }\n                reqwest::StatusCode::UNAUTHORIZED => {\n                    return Err(TokenValidationError::InvalidOrRevoked);\n                }\n                reqwest::StatusCode::FORBIDDEN => {\n                    // Check if rate limited\n                    let rate_limit_remaining = response\n                        .headers()\n                        .get(\"x-ratelimit-remaining\")\n                        .and_then(|v| v.to_str().ok())\n                        .and_then(|v| v.parse::<i32>().ok())\n                        .unwrap_or(1);\n\n                    if rate_limit_remaining == 0 {\n                        if attempt <= max_retries {\n                            // Get reset time and wait\n                            if let Some(reset_str) = response\n                                .headers()\n                                .get(\"x-ratelimit-reset\")\n                                .and_then(|v| v.to_str().ok())\n                                && let Ok(reset_time) = reset_str.parse::<i64>()\n                            {\n                                let now = chrono::Utc::now().timestamp();\n                                let wait_seconds = (reset_time - now).clamp(0, 5);\n                                tokio::time::sleep(tokio::time::Duration::from_secs(\n                                    wait_seconds as u64,\n                                ))\n                                .await;\n                                continue;\n                            }\n                        }\n                        return Err(TokenValidationError::temporary(\"rate limited by GitHub\"));\n                    } else {\n                        return Err(TokenValidationError::temporary(\n                            \"access forbidden during validation\",\n                        ));\n                    }\n                }\n                status => {\n                    if status.is_server_error() && attempt <= max_retries {\n                        tokio::time::sleep(tokio::time::Duration::from_secs(\n                            RETRY_INTERVAL_SECONDS,\n                        ))\n                        .await;\n                        continue;\n                    }\n                    return Err(TokenValidationError::temporary(format!(\n                        \"unexpected validation status: {status}\"\n                    )));\n                }\n            }\n        }\n    }\n}\n\npub struct GoogleOAuthProvider {\n    client: Client,\n    client_id: String,\n    client_secret: SecretString,\n}\n\nimpl GoogleOAuthProvider {\n    pub fn new(client_id: String, client_secret: SecretString) -> Result<Self> {\n        let client = Client::builder().user_agent(USER_AGENT).build()?;\n        Ok(Self {\n            client,\n            client_id,\n            client_secret,\n        })\n    }\n\n    async fn try_refresh_access_token(\n        &self,\n        refresh_token: &str,\n    ) -> Result<ProviderTokenDetails, TokenValidationError> {\n        let response = match self\n            .client\n            .post(\"https://oauth2.googleapis.com/token\")\n            .form(&[\n                (\"client_id\", self.client_id.as_str()),\n                (\"client_secret\", self.client_secret.expose_secret()),\n                (\"refresh_token\", refresh_token),\n                (\"grant_type\", \"refresh_token\"),\n            ])\n            .send()\n            .await\n        {\n            Ok(resp) => resp,\n            Err(err) => {\n                return Err(TokenValidationError::temporary(format!(\n                    \"refresh request failed: {err}\"\n                )));\n            }\n        };\n\n        match response.status() {\n            reqwest::StatusCode::OK => {\n                #[derive(Debug, Deserialize)]\n                struct RefreshResponse {\n                    access_token: String,\n                    expires_in: i64,\n                    #[serde(default)]\n                    refresh_token: Option<String>,\n                }\n\n                let refresh_data: RefreshResponse = response\n                    .json()\n                    .await\n                    .map_err(|err| TokenValidationError::temporary(format!(\"{err}\")))?;\n                let expires_at = chrono::Utc::now().timestamp() + refresh_data.expires_in;\n\n                let new_refresh_token = refresh_data\n                    .refresh_token\n                    .unwrap_or_else(|| refresh_token.to_string());\n\n                Ok(ProviderTokenDetails {\n                    provider: self.name().to_string(),\n                    access_token: refresh_data.access_token,\n                    refresh_token: Some(new_refresh_token),\n                    expires_at: Some(expires_at),\n                })\n            }\n            reqwest::StatusCode::BAD_REQUEST => Err(TokenValidationError::InvalidOrRevoked),\n            status if status.is_server_error() => Err(TokenValidationError::temporary(format!(\n                \"token refresh server error: {status}\"\n            ))),\n            status => Err(TokenValidationError::temporary(format!(\n                \"unexpected token refresh status: {status}\"\n            ))),\n        }\n    }\n\n    async fn refresh_token(\n        &self,\n        refresh_token: &str,\n        max_retries: u32,\n    ) -> Result<ProviderTokenDetails, TokenValidationError> {\n        let mut attempt = 0;\n        loop {\n            attempt += 1;\n\n            match self.try_refresh_access_token(refresh_token).await {\n                Ok(new_token_details) => return Ok(new_token_details),\n                Err(TokenValidationError::InvalidOrRevoked) => {\n                    return Err(TokenValidationError::InvalidOrRevoked);\n                }\n                Err(TokenValidationError::Temporary(err)) => {\n                    if attempt >= max_retries {\n                        return Err(TokenValidationError::Temporary(err));\n                    }\n                    tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS))\n                        .await;\n                }\n            }\n        }\n    }\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(untagged)]\nenum GoogleTokenResponse {\n    Success {\n        access_token: String,\n        token_type: String,\n        scope: Option<String>,\n        expires_in: Option<i64>,\n        refresh_token: Option<String>,\n        id_token: Option<String>,\n    },\n    Error {\n        error: String,\n        error_description: Option<String>,\n    },\n}\n\n#[derive(Debug, Deserialize)]\nstruct GoogleUser {\n    sub: String,\n    email: Option<String>,\n    name: Option<String>,\n    given_name: Option<String>,\n    family_name: Option<String>,\n    picture: Option<String>,\n}\n\n#[async_trait]\nimpl AuthorizationProvider for GoogleOAuthProvider {\n    fn name(&self) -> &'static str {\n        \"google\"\n    }\n\n    fn scopes(&self) -> &[&str] {\n        &[\"openid\", \"email\", \"profile\"]\n    }\n\n    fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result<Url> {\n        let mut url = Url::parse(\"https://accounts.google.com/o/oauth2/v2/auth\")?;\n        {\n            let mut qp = url.query_pairs_mut();\n            qp.append_pair(\"client_id\", &self.client_id);\n            qp.append_pair(\"redirect_uri\", redirect_uri);\n            qp.append_pair(\"response_type\", \"code\");\n            qp.append_pair(\"scope\", &self.scopes().join(\" \"));\n            qp.append_pair(\"state\", state);\n            qp.append_pair(\"access_type\", \"offline\");\n            qp.append_pair(\"prompt\", \"consent\");\n        }\n        Ok(url)\n    }\n\n    async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<AuthorizationGrant> {\n        let response = self\n            .client\n            .post(\"https://oauth2.googleapis.com/token\")\n            .form(&[\n                (\"client_id\", self.client_id.as_str()),\n                (\"client_secret\", self.client_secret.expose_secret()),\n                (\"code\", code),\n                (\"grant_type\", \"authorization_code\"),\n                (\"redirect_uri\", redirect_uri),\n            ])\n            .send()\n            .await?\n            .error_for_status()?;\n\n        match response.json::<GoogleTokenResponse>().await? {\n            GoogleTokenResponse::Success {\n                access_token,\n                token_type,\n                scope,\n                expires_in,\n                refresh_token,\n                id_token,\n            } => {\n                let scopes = scope\n                    .unwrap_or_default()\n                    .split_whitespace()\n                    .filter_map(|value| {\n                        let trimmed = value.trim();\n                        (!trimmed.is_empty()).then_some(trimmed.to_string())\n                    })\n                    .collect();\n\n                Ok(AuthorizationGrant {\n                    access_token: SecretString::new(access_token.into()),\n                    token_type,\n                    scopes,\n                    refresh_token: refresh_token.map(|v| SecretString::new(v.into())),\n                    expires_in: expires_in.map(Duration::seconds),\n                    id_token: id_token.map(|v| SecretString::new(v.into())),\n                })\n            }\n            GoogleTokenResponse::Error {\n                error,\n                error_description,\n            } => {\n                let detail = error_description.unwrap_or_else(|| error.clone());\n                anyhow::bail!(\"google token exchange failed: {detail}\")\n            }\n        }\n    }\n\n    async fn fetch_user(&self, access_token: &SecretString) -> Result<ProviderUser> {\n        let bearer = format!(\"Bearer {}\", access_token.expose_secret());\n\n        let profile: GoogleUser = self\n            .client\n            .get(\"https://openidconnect.googleapis.com/v1/userinfo\")\n            .header(\"Authorization\", bearer)\n            .send()\n            .await?\n            .error_for_status()?\n            .json()\n            .await?;\n\n        let login = profile.email.clone();\n        let name = profile\n            .name\n            .or_else(|| match (profile.given_name, profile.family_name) {\n                (Some(first), Some(last)) => Some(format!(\"{first} {last}\")),\n                (Some(first), None) => Some(first),\n                (None, Some(last)) => Some(last),\n                (None, None) => None,\n            });\n\n        Ok(ProviderUser {\n            id: profile.sub,\n            login,\n            email: profile.email,\n            name,\n            avatar_url: profile.picture,\n        })\n    }\n\n    async fn validate_token(\n        &self,\n        token_details: &ProviderTokenDetails,\n        max_retries: u32,\n    ) -> Result<Option<ProviderTokenDetails>, TokenValidationError> {\n        let mut attempt = 0;\n        let access_token = SecretString::new(token_details.access_token.clone().into_boxed_str());\n\n        loop {\n            attempt += 1;\n\n            if let Some(expires_at) = token_details.expires_at\n                && let now = chrono::Utc::now().timestamp()\n                && now >= expires_at - TOKEN_EXPIRATION_LEEWAY_SECONDS\n            {\n                let Some(refresh_token) = &token_details.refresh_token else {\n                    return Err(TokenValidationError::InvalidOrRevoked);\n                };\n\n                info!(\"Token expired, attempting refresh for Google OAuth\");\n                return self\n                    .refresh_token(refresh_token, max_retries)\n                    .await\n                    .map(Some);\n            }\n\n            let response = match self\n                .client\n                .get(\"https://www.googleapis.com/oauth2/v2/tokeninfo\")\n                .query(&[(\"access_token\", access_token.expose_secret())])\n                .send()\n                .await\n            {\n                Ok(resp) => resp,\n                Err(err) => {\n                    if attempt >= max_retries {\n                        return Err(TokenValidationError::temporary(format!(\n                            \"tokeninfo request failed: {err}\"\n                        )));\n                    }\n                    tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS))\n                        .await;\n                    continue;\n                }\n            };\n\n            match response.status() {\n                reqwest::StatusCode::OK => {\n                    return Ok(None);\n                }\n                reqwest::StatusCode::BAD_REQUEST => {\n                    let Some(refresh_token) = &token_details.refresh_token else {\n                        return Err(TokenValidationError::InvalidOrRevoked);\n                    };\n                    info!(\"Token expired during validation, attempting refresh\");\n                    return self\n                        .refresh_token(refresh_token, max_retries)\n                        .await\n                        .map(Some);\n                }\n                reqwest::StatusCode::TOO_MANY_REQUESTS => {\n                    if attempt >= max_retries {\n                        return Err(TokenValidationError::temporary(\n                            \"rate limited by Google\".to_string(),\n                        ));\n                    }\n                    tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS))\n                        .await;\n                }\n                status if status.is_server_error() => {\n                    if attempt >= max_retries {\n                        return Err(TokenValidationError::temporary(format!(\n                            \"google tokeninfo server error: {status}\"\n                        )));\n                    }\n                    tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS))\n                        .await;\n                }\n                status => {\n                    if attempt >= max_retries {\n                        return Err(TokenValidationError::temporary(format!(\n                            \"unexpected tokeninfo status: {status}\"\n                        )));\n                    }\n                    tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS))\n                        .await;\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/azure_blob.rs",
    "content": "use std::{fmt, sync::Arc, time::Duration};\n\nuse azure_core::{\n    credentials::Secret,\n    http::{ClientOptions, RequestContent},\n};\nuse azure_identity::{ManagedIdentityCredential, ManagedIdentityCredentialOptions, UserAssignedId};\nuse azure_storage_blob::{\n    BlobClient, BlobContainerClient, BlobServiceClient, BlobServiceClientOptions,\n    models::{BlobClientGetPropertiesResultHeaders, BlockBlobClientUploadOptions},\n};\nuse base64::prelude::*;\nuse chrono::{DateTime, Utc};\nuse hmac::{Hmac, Mac};\nuse secrecy::ExposeSecret;\nuse sha2::Sha256;\nuse time::OffsetDateTime;\nuse url::form_urlencoded;\n\nuse crate::{\n    config::{AzureAuthMode, AzureBlobConfig},\n    shared_key_auth::SharedKeyAuthorizationPolicy,\n};\n\n#[derive(Clone)]\npub struct AzureBlobService {\n    service_client: Arc<BlobServiceClient>,\n    account_name: String,\n    account_key: String,\n    container_name: String,\n    endpoint_url: Option<String>,\n    public_endpoint_url: Option<String>,\n    presign_expiry: Duration,\n}\n\n#[derive(Debug)]\npub struct PresignedUpload {\n    pub upload_url: String,\n    pub blob_path: String,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug)]\npub struct BlobProperties {\n    pub content_length: i64,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum AzureBlobError {\n    #[error(\"azure storage error: {0}\")]\n    Storage(String),\n    #[error(\"blob not found: {0}\")]\n    NotFound(String),\n    #[error(\"SAS token error: {0}\")]\n    SasToken(String),\n}\n\nimpl AzureBlobService {\n    pub fn new(config: &AzureBlobConfig) -> Self {\n        let account_name = config.account_name.clone();\n        let account_key = config.account_key.expose_secret().to_string();\n        let container_name = config.container_name.clone();\n        let endpoint_url = config.endpoint_url.clone();\n        let public_endpoint_url = config.public_endpoint_url.clone();\n        let presign_expiry = Duration::from_secs(config.presign_expiry_secs);\n\n        let endpoint = match &endpoint_url {\n            Some(url) => url.clone(),\n            None => format!(\"https://{}.blob.core.windows.net\", account_name),\n        };\n\n        let service_client = match &config.auth_mode {\n            AzureAuthMode::EntraId { client_id } => {\n                let credential =\n                    ManagedIdentityCredential::new(Some(ManagedIdentityCredentialOptions {\n                        user_assigned_id: Some(UserAssignedId::ClientId(client_id.clone())),\n                        ..Default::default()\n                    }))\n                    .expect(\"failed to create ManagedIdentityCredential\");\n\n                Arc::new(\n                    BlobServiceClient::new(&endpoint, Some(credential), None)\n                        .expect(\"failed to create BlobServiceClient with managed identity\"),\n                )\n            }\n            AzureAuthMode::SharedKey => {\n                let policy = Arc::new(SharedKeyAuthorizationPolicy {\n                    account: account_name.clone(),\n                    key: Secret::new(account_key.clone()),\n                });\n\n                Arc::new(\n                    BlobServiceClient::new(\n                        &endpoint,\n                        None,\n                        Some(BlobServiceClientOptions {\n                            client_options: ClientOptions {\n                                per_try_policies: vec![policy],\n                                ..Default::default()\n                            },\n                            ..Default::default()\n                        }),\n                    )\n                    .expect(\"failed to create BlobServiceClient with shared key\"),\n                )\n            }\n        };\n\n        Self {\n            service_client,\n            account_name,\n            account_key,\n            container_name,\n            endpoint_url,\n            public_endpoint_url,\n            presign_expiry,\n        }\n    }\n\n    fn container_client(&self) -> BlobContainerClient {\n        self.service_client\n            .blob_container_client(&self.container_name)\n    }\n\n    fn blob_client(&self, blob_path: &str) -> BlobClient {\n        self.container_client().blob_client(blob_path)\n    }\n\n    pub fn create_upload_url(&self, blob_path: &str) -> Result<PresignedUpload, AzureBlobError> {\n        let expiry_chrono = Utc::now()\n            + chrono::Duration::from_std(self.presign_expiry).unwrap_or(chrono::Duration::hours(1));\n\n        let permissions = BlobSasPermissions {\n            create: true,\n            write: true,\n            ..Default::default()\n        };\n\n        let sas_url = self.generate_sas_url(blob_path, permissions, expiry_chrono)?;\n\n        Ok(PresignedUpload {\n            upload_url: sas_url,\n            blob_path: blob_path.to_string(),\n            expires_at: expiry_chrono,\n        })\n    }\n\n    pub fn create_read_url(&self, blob_path: &str) -> Result<String, AzureBlobError> {\n        let expiry = Utc::now() + chrono::Duration::minutes(5);\n\n        let permissions = BlobSasPermissions {\n            read: true,\n            ..Default::default()\n        };\n\n        self.generate_sas_url(blob_path, permissions, expiry)\n    }\n\n    pub async fn get_blob_properties(\n        &self,\n        blob_path: &str,\n    ) -> Result<BlobProperties, AzureBlobError> {\n        let response = self\n            .blob_client(blob_path)\n            .get_properties(None)\n            .await\n            .map_err(|e| AzureBlobError::Storage(e.to_string()))?;\n\n        let content_length = response\n            .content_length()\n            .map_err(|e| AzureBlobError::Storage(e.to_string()))?\n            .unwrap_or(0) as i64;\n\n        Ok(BlobProperties { content_length })\n    }\n\n    pub async fn download_blob(&self, blob_path: &str) -> Result<Vec<u8>, AzureBlobError> {\n        let response = self\n            .blob_client(blob_path)\n            .download(None)\n            .await\n            .map_err(|e| AzureBlobError::Storage(e.to_string()))?;\n\n        let bytes = response\n            .into_body()\n            .collect()\n            .await\n            .map_err(|e| AzureBlobError::Storage(e.to_string()))?;\n\n        if bytes.is_empty() {\n            return Err(AzureBlobError::NotFound(blob_path.to_string()));\n        }\n\n        Ok(bytes.to_vec())\n    }\n\n    pub async fn upload_blob(\n        &self,\n        blob_path: &str,\n        data: Vec<u8>,\n        content_type: String,\n    ) -> Result<(), AzureBlobError> {\n        let len = data.len() as u64;\n\n        self.blob_client(blob_path)\n            .upload(\n                RequestContent::from(data),\n                true,\n                len,\n                Some(BlockBlobClientUploadOptions {\n                    blob_content_type: Some(content_type),\n                    ..Default::default()\n                }),\n            )\n            .await\n            .map_err(|e| AzureBlobError::Storage(e.to_string()))?;\n\n        Ok(())\n    }\n\n    pub async fn delete_blob(&self, blob_path: &str) -> Result<(), AzureBlobError> {\n        self.blob_client(blob_path)\n            .delete(None)\n            .await\n            .map_err(|e| AzureBlobError::Storage(e.to_string()))?;\n\n        Ok(())\n    }\n\n    fn generate_sas_url(\n        &self,\n        blob_path: &str,\n        permissions: BlobSasPermissions,\n        expiry: DateTime<Utc>,\n    ) -> Result<String, AzureBlobError> {\n        let expiry_time = OffsetDateTime::from_unix_timestamp(expiry.timestamp())\n            .map_err(|e| AzureBlobError::SasToken(e.to_string()))?;\n\n        let canonicalized_resource = format!(\n            \"/blob/{}/{}/{}\",\n            self.account_name, self.container_name, blob_path\n        );\n\n        let protocol = match &self.endpoint_url {\n            Some(url) if url.starts_with(\"http://\") => SasProtocol::HttpHttps,\n            _ => SasProtocol::Https,\n        };\n\n        let sas = BlobSharedAccessSignature::new(\n            Secret::new(self.account_key.clone()),\n            canonicalized_resource,\n            permissions,\n            expiry_time,\n            BlobSignedResource::Blob,\n        )\n        .protocol(protocol);\n\n        let token = sas\n            .token()\n            .map_err(|e| AzureBlobError::SasToken(e.to_string()))?;\n\n        let base_url = match (&self.public_endpoint_url, &self.endpoint_url) {\n            (Some(public), _) => public.trim_end_matches('/').to_string(),\n            (None, Some(endpoint)) => endpoint.trim_end_matches('/').to_string(),\n            (None, None) => format!(\"https://{}.blob.core.windows.net\", self.account_name),\n        };\n\n        Ok(format!(\n            \"{}/{}/{}?{}\",\n            base_url, self.container_name, blob_path, token\n        ))\n    }\n}\n\n// ── SAS token generation (ported from azure_storage 0.21) ────────────────────\n//\n// https://github.com/Azure/azure-sdk-for-rust/blob/legacy/sdk/storage/src/shared_access_signature/mod.rs\n//\n// This crate has been deprecated by Azure, but SAS token generation has yet to be implemented in\n// the new azure_storage_blob crate, so we port the relevant code here for now.\n//\n// See: https://github.com/Azure/azure-sdk-for-rust/issues/3330\n\nconst SERVICE_SAS_VERSION: &str = \"2022-11-02\";\n\n#[derive(Copy, Clone, PartialEq, Eq, Debug)]\npub enum SasProtocol {\n    Https,\n    HttpHttps,\n}\n\nimpl fmt::Display for SasProtocol {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        match self {\n            SasProtocol::Https => write!(f, \"https\"),\n            SasProtocol::HttpHttps => write!(f, \"http,https\"),\n        }\n    }\n}\n\npub enum BlobSignedResource {\n    Blob,\n    BlobVersion,\n    BlobSnapshot,\n    Container,\n    Directory,\n}\n\nimpl fmt::Display for BlobSignedResource {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        match self {\n            Self::Blob => write!(f, \"b\"),\n            Self::BlobVersion => write!(f, \"bv\"),\n            Self::BlobSnapshot => write!(f, \"bs\"),\n            Self::Container => write!(f, \"c\"),\n            Self::Directory => write!(f, \"d\"),\n        }\n    }\n}\n\n#[allow(clippy::struct_excessive_bools)]\n#[derive(Default)]\npub struct BlobSasPermissions {\n    pub read: bool,\n    pub add: bool,\n    pub create: bool,\n    pub write: bool,\n    pub delete: bool,\n    pub delete_version: bool,\n    pub permanent_delete: bool,\n    pub list: bool,\n    pub tags: bool,\n    pub move_: bool,\n    pub execute: bool,\n    pub ownership: bool,\n    pub permissions: bool,\n}\n\nimpl fmt::Display for BlobSasPermissions {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        if self.read {\n            write!(f, \"r\")?;\n        }\n        if self.add {\n            write!(f, \"a\")?;\n        }\n        if self.create {\n            write!(f, \"c\")?;\n        }\n        if self.write {\n            write!(f, \"w\")?;\n        }\n        if self.delete {\n            write!(f, \"d\")?;\n        }\n        if self.delete_version {\n            write!(f, \"x\")?;\n        }\n        if self.permanent_delete {\n            write!(f, \"y\")?;\n        }\n        if self.list {\n            write!(f, \"l\")?;\n        }\n        if self.tags {\n            write!(f, \"t\")?;\n        }\n        if self.move_ {\n            write!(f, \"m\")?;\n        }\n        if self.execute {\n            write!(f, \"e\")?;\n        }\n        if self.ownership {\n            write!(f, \"o\")?;\n        }\n        if self.permissions {\n            write!(f, \"p\")?;\n        }\n        Ok(())\n    }\n}\n\npub struct BlobSharedAccessSignature {\n    key: Secret,\n    canonicalized_resource: String,\n    resource: BlobSignedResource,\n    permissions: BlobSasPermissions,\n    expiry: OffsetDateTime,\n    protocol: Option<SasProtocol>,\n}\n\nimpl BlobSharedAccessSignature {\n    pub fn new(\n        key: Secret,\n        canonicalized_resource: String,\n        permissions: BlobSasPermissions,\n        expiry: OffsetDateTime,\n        resource: BlobSignedResource,\n    ) -> Self {\n        Self {\n            key,\n            canonicalized_resource,\n            resource,\n            permissions,\n            expiry,\n            protocol: None,\n        }\n    }\n\n    pub fn protocol(mut self, protocol: SasProtocol) -> Self {\n        self.protocol = Some(protocol);\n        self\n    }\n\n    fn sign(&self) -> String {\n        let content = [\n            self.permissions.to_string(),\n            String::new(), // start time\n            format_sas_date(self.expiry),\n            self.canonicalized_resource.clone(),\n            String::new(), // identifier\n            String::new(), // ip\n            self.protocol.map(|x| x.to_string()).unwrap_or_default(),\n            SERVICE_SAS_VERSION.to_string(),\n            self.resource.to_string(),\n            String::new(), // snapshot time\n            String::new(), // signed encryption scope\n            String::new(), // signed cache control\n            String::new(), // signed content disposition\n            String::new(), // signed content encoding\n            String::new(), // signed content language\n            String::new(), // signed content type\n        ];\n\n        sas_hmac_sha256(&content.join(\"\\n\"), &self.key)\n    }\n\n    pub fn token(&self) -> Result<String, AzureBlobError> {\n        let mut form = form_urlencoded::Serializer::new(String::new());\n\n        form.extend_pairs(&[\n            (\"sv\", SERVICE_SAS_VERSION),\n            (\"sp\", &self.permissions.to_string()),\n            (\"sr\", &self.resource.to_string()),\n            (\"se\", &format_sas_date(self.expiry)),\n        ]);\n\n        if let Some(protocol) = &self.protocol {\n            form.append_pair(\"spr\", &protocol.to_string());\n        }\n\n        let sig = self.sign();\n        form.append_pair(\"sig\", &sig);\n        Ok(form.finish())\n    }\n}\n\nfn format_sas_date(d: OffsetDateTime) -> String {\n    // Truncate nanoseconds to match Azure's canonicalization.\n    let d = d.replace_nanosecond(0).unwrap();\n    d.format(&time::format_description::well_known::Rfc3339)\n        .unwrap()\n}\n\nfn sas_hmac_sha256(data: &str, key: &Secret) -> String {\n    let key_bytes = BASE64_STANDARD.decode(key.secret()).unwrap();\n    let mut hmac = Hmac::<Sha256>::new_from_slice(&key_bytes).unwrap();\n    hmac.update(data.as_bytes());\n    BASE64_STANDARD.encode(hmac.finalize().into_bytes())\n}\n\n#[cfg(test)]\nmod tests {\n    use time::Duration;\n\n    use super::*;\n\n    const MOCK_SECRET_KEY: &str = \"RZfi3m1W7eyQ5zD4ymSmGANVdJ2SDQmg4sE89SW104s=\";\n    const MOCK_CANONICALIZED_RESOURCE: &str = \"/blob/STORAGE_ACCOUNT_NAME/CONTAINER_NAME/\";\n\n    #[test]\n    fn test_blob_scoped_sas_token() {\n        let permissions = BlobSasPermissions {\n            read: true,\n            ..Default::default()\n        };\n        let signed_token = BlobSharedAccessSignature::new(\n            Secret::new(MOCK_SECRET_KEY),\n            String::from(MOCK_CANONICALIZED_RESOURCE),\n            permissions,\n            OffsetDateTime::UNIX_EPOCH + Duration::days(7),\n            BlobSignedResource::Blob,\n        )\n        .token()\n        .unwrap();\n\n        assert_eq!(\n            signed_token,\n            \"sv=2022-11-02&sp=r&sr=b&se=1970-01-08T00%3A00%3A00Z&sig=VRZjVZ1c%2FLz7IXCp17Sdx9%2BR9JDrnJdzE3NW56DMjNs%3D\"\n        );\n\n        let parsed = url::form_urlencoded::parse(signed_token.as_bytes());\n        assert!(parsed.clone().any(|(k, v)| k == \"sr\" && v == \"b\"));\n        assert!(!parsed.clone().any(|(k, _)| k == \"sdd\"));\n    }\n\n    #[test]\n    fn test_directory_scoped_sas_token() {\n        let permissions = BlobSasPermissions {\n            read: true,\n            ..Default::default()\n        };\n        let signed_token = BlobSharedAccessSignature::new(\n            Secret::new(MOCK_SECRET_KEY),\n            String::from(MOCK_CANONICALIZED_RESOURCE),\n            permissions,\n            OffsetDateTime::UNIX_EPOCH + Duration::days(7),\n            BlobSignedResource::Directory,\n        )\n        .token()\n        .unwrap();\n\n        // The directory test from the original just checks sr=d is present\n        let parsed = url::form_urlencoded::parse(signed_token.as_bytes());\n        assert!(parsed.clone().any(|(k, v)| k == \"sr\" && v == \"d\"));\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/billing.rs",
    "content": "#[cfg(feature = \"vk-billing\")]\nuse std::sync::Arc;\n\n#[cfg(feature = \"vk-billing\")]\npub use billing::{\n    BillingError, BillingProvider, BillingStatus, BillingStatusResponse, CreateCheckoutRequest,\n    CreatePortalRequest,\n};\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub struct BillingService {\n    #[cfg(feature = \"vk-billing\")]\n    provider: Option<Arc<dyn BillingProvider>>,\n}\n\nimpl BillingService {\n    #[cfg(feature = \"vk-billing\")]\n    pub fn new(provider: Option<Arc<dyn BillingProvider>>) -> Self {\n        Self { provider }\n    }\n\n    #[cfg(not(feature = \"vk-billing\"))]\n    pub fn new() -> Self {\n        Self {}\n    }\n\n    /// Returns Ok(()) if billing allows adding a member, or if billing is disabled/not configured.\n    pub async fn can_add_member(&self, _org_id: Uuid) -> Result<(), BillingCheckError> {\n        #[cfg(feature = \"vk-billing\")]\n        if let Some(provider) = &self.provider {\n            provider\n                .can_add_member(_org_id)\n                .await\n                .map_err(BillingCheckError::Billing)?;\n        }\n        Ok(())\n    }\n\n    /// Notifies billing of member count changes. No-op if billing disabled.\n    pub async fn on_member_count_changed(&self, _org_id: Uuid) {\n        #[cfg(feature = \"vk-billing\")]\n        if let Some(provider) = &self.provider {\n            if let Err(e) = provider.on_member_count_changed(_org_id).await {\n                tracing::warn!(?e, %_org_id, \"Failed to notify billing of member count change\");\n            }\n        }\n    }\n\n    pub fn is_configured(&self) -> bool {\n        #[cfg(feature = \"vk-billing\")]\n        {\n            self.provider.is_some()\n        }\n        #[cfg(not(feature = \"vk-billing\"))]\n        {\n            false\n        }\n    }\n\n    /// Returns the billing provider if configured.\n    #[cfg(feature = \"vk-billing\")]\n    pub fn provider(&self) -> Option<Arc<dyn BillingProvider>> {\n        self.provider.clone()\n    }\n\n    /// Returns None when billing feature is disabled.\n    #[cfg(not(feature = \"vk-billing\"))]\n    pub fn provider(&self) -> Option<std::convert::Infallible> {\n        None\n    }\n}\n\n#[cfg(not(feature = \"vk-billing\"))]\nimpl Default for BillingService {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[derive(Debug)]\npub enum BillingCheckError {\n    #[cfg(feature = \"vk-billing\")]\n    Billing(BillingError),\n}\n\nimpl std::fmt::Display for BillingCheckError {\n    fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        #[cfg(feature = \"vk-billing\")]\n        match self {\n            Self::Billing(e) => write!(_f, \"{}\", e),\n        }\n        #[cfg(not(feature = \"vk-billing\"))]\n        {\n            match *self {}\n        }\n    }\n}\n\nimpl std::error::Error for BillingCheckError {}\n\nimpl BillingCheckError {\n    pub fn to_error_response(&self, _context: &str) -> crate::routes::error::ErrorResponse {\n        #[cfg(feature = \"vk-billing\")]\n        {\n            use axum::http::StatusCode;\n\n            use crate::routes::error::ErrorResponse;\n\n            match self {\n                Self::Billing(e) => match e {\n                    BillingError::SubscriptionRequired(_) | BillingError::SubscriptionInactive => {\n                        ErrorResponse::new(\n                            StatusCode::PAYMENT_REQUIRED,\n                            format!(\"{}: {}. Subscribe to add more members.\", _context, e),\n                        )\n                    }\n                    BillingError::Stripe(msg) => {\n                        tracing::error!(?msg, \"Stripe error\");\n                        ErrorResponse::new(StatusCode::BAD_GATEWAY, \"Payment provider error\")\n                    }\n                    BillingError::Database(db_err) => {\n                        tracing::error!(?db_err, \"Database error in billing check\");\n                        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Internal error\")\n                    }\n                    BillingError::NotConfigured => ErrorResponse::new(\n                        StatusCode::SERVICE_UNAVAILABLE,\n                        \"Billing not configured\",\n                    ),\n                    BillingError::OrganizationNotFound => {\n                        ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n                    }\n                },\n            }\n        }\n        #[cfg(not(feature = \"vk-billing\"))]\n        {\n            match *self {}\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/bin/generate_types.rs",
    "content": "use std::{env, fs, path::Path};\n\nuse api_types::{\n    Attachment, AttachmentUrlResponse, AttachmentWithBlob, Blob, CreateIssueAssigneeRequest,\n    CreateIssueCommentReactionRequest, CreateIssueCommentRequest, CreateIssueFollowerRequest,\n    CreateIssueRelationshipRequest, CreateIssueRequest, CreateIssueTagRequest,\n    CreateProjectRequest, CreateProjectStatusRequest, CreateTagRequest, Issue, IssueAssignee,\n    IssueComment, IssueCommentReaction, IssueFollower, IssuePriority, IssueRelationship,\n    IssueRelationshipType, IssueSortField, IssueTag, ListIssuesQuery, ListIssuesResponse,\n    ListRelayHostsResponse, MemberRole, Notification, NotificationGroupKind, NotificationPayload,\n    NotificationType, OrganizationMember, Project, ProjectStatus, PullRequest, PullRequestStatus,\n    RelayHost, RelaySession, RelaySessionAuthCodeResponse, SearchIssuesRequest, SortDirection, Tag,\n    UpdateIssueCommentReactionRequest, UpdateIssueCommentRequest, UpdateIssueRequest,\n    UpdateNotificationRequest, UpdateProjectRequest, UpdateProjectStatusRequest, UpdateTagRequest,\n    User, UserData, Workspace,\n};\nuse remote::{\n    routes::{\n        all_mutation_definitions,\n        attachments::{\n            CommitAttachmentsRequest, CommitAttachmentsResponse, ConfirmUploadRequest,\n            InitUploadRequest, InitUploadResponse,\n        },\n        hosts::CreateRelaySessionResponse,\n    },\n    shape_routes::all_shape_routes,\n};\nuse ts_rs::TS;\n\nfn main() {\n    let args: Vec<String> = env::args().collect();\n    let check_mode = args.iter().any(|arg| arg == \"--check\");\n\n    let typescript = export_shapes();\n\n    // Path to shared/remote-types.ts relative to workspace root\n    let output_path = Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n        .parent() // crates/\n        .unwrap()\n        .parent() // workspace root\n        .unwrap()\n        .join(\"shared/remote-types.ts\");\n\n    if check_mode {\n        let current = fs::read_to_string(&output_path).unwrap_or_default();\n        if current == typescript {\n            println!(\"✅ shared/remote-types.ts is up to date.\");\n            std::process::exit(0);\n        } else {\n            eprintln!(\"❌ shared/remote-types.ts is not up to date.\");\n            eprintln!(\"Please run 'pnpm run remote:generate-types' and commit the changes.\");\n            std::process::exit(1);\n        }\n    } else {\n        fs::write(&output_path, &typescript).expect(\"Failed to write remote-types.ts\");\n        println!(\n            \"✅ Generated remote types and shapes to {}\",\n            output_path.display()\n        );\n    }\n}\n\nfn export_shapes() -> String {\n    let routes = all_shape_routes();\n\n    let mut output = String::new();\n\n    // Header\n    output.push_str(\"// This file was auto-generated by generate_types in the remote crate.\\n\");\n    output.push_str(\"// Do not edit manually.\\n\\n\");\n\n    // Generate type declarations for all Electric types\n    output.push_str(\"// Electric row types\\n\");\n    let type_decls = vec![\n        serde_json::Value::decl(),\n        Project::decl(),\n        Notification::decl(),\n        NotificationGroupKind::decl(),\n        NotificationPayload::decl(),\n        NotificationType::decl(),\n        Workspace::decl(),\n        ProjectStatus::decl(),\n        Tag::decl(),\n        Issue::decl(),\n        IssueAssignee::decl(),\n        Blob::decl(),\n        Attachment::decl(),\n        AttachmentWithBlob::decl(),\n        IssueFollower::decl(),\n        IssueTag::decl(),\n        IssueRelationship::decl(),\n        IssueRelationshipType::decl(),\n        IssueComment::decl(),\n        IssueCommentReaction::decl(),\n        IssuePriority::decl(),\n        IssueSortField::decl(),\n        ListIssuesQuery::decl(),\n        SearchIssuesRequest::decl(),\n        ListIssuesResponse::decl(),\n        PullRequestStatus::decl(),\n        PullRequest::decl(),\n        SortDirection::decl(),\n        UserData::decl(),\n        User::decl(),\n        RelayHost::decl(),\n        ListRelayHostsResponse::decl(),\n        RelaySession::decl(),\n        CreateRelaySessionResponse::decl(),\n        RelaySessionAuthCodeResponse::decl(),\n        MemberRole::decl(),\n        OrganizationMember::decl(),\n        // Mutation request types\n        CreateProjectRequest::decl(),\n        UpdateProjectRequest::decl(),\n        UpdateNotificationRequest::decl(),\n        CreateTagRequest::decl(),\n        UpdateTagRequest::decl(),\n        CreateProjectStatusRequest::decl(),\n        UpdateProjectStatusRequest::decl(),\n        CreateIssueRequest::decl(),\n        UpdateIssueRequest::decl(),\n        CreateIssueAssigneeRequest::decl(),\n        CreateIssueFollowerRequest::decl(),\n        CreateIssueTagRequest::decl(),\n        CreateIssueRelationshipRequest::decl(),\n        CreateIssueCommentRequest::decl(),\n        UpdateIssueCommentRequest::decl(),\n        CreateIssueCommentReactionRequest::decl(),\n        UpdateIssueCommentReactionRequest::decl(),\n        // Attachment API request/response types\n        InitUploadRequest::decl(),\n        InitUploadResponse::decl(),\n        ConfirmUploadRequest::decl(),\n        CommitAttachmentsRequest::decl(),\n        CommitAttachmentsResponse::decl(),\n        AttachmentUrlResponse::decl(),\n    ];\n\n    for decl in type_decls {\n        let trimmed = decl.trim_start();\n        if trimmed.starts_with(\"export\") {\n            output.push_str(&decl);\n        } else {\n            output.push_str(\"export \");\n            output.push_str(trimmed);\n        }\n        output.push_str(\"\\n\\n\");\n    }\n\n    // ShapeDefinition interface\n    output.push_str(\"// Shape definition interface\\n\");\n    output.push_str(\"export interface ShapeDefinition<T> {\\n\");\n    output.push_str(\"  readonly table: string;\\n\");\n    output.push_str(\"  readonly params: readonly string[];\\n\");\n    output.push_str(\"  readonly url: string;\\n\");\n    output.push_str(\"  readonly fallbackUrl: string;\\n\");\n    output.push_str(\n        \"  readonly _type: T;  // Phantom field for type inference (not present at runtime)\\n\",\n    );\n    output.push_str(\"}\\n\\n\");\n\n    // Helper function\n    output.push_str(\"// Helper to create type-safe shape definitions\\n\");\n    output.push_str(\"function defineShape<T>(\\n\");\n    output.push_str(\"  table: string,\\n\");\n    output.push_str(\"  params: readonly string[],\\n\");\n    output.push_str(\"  url: string,\\n\");\n    output.push_str(\"  fallbackUrl: string\\n\");\n    output.push_str(\"): ShapeDefinition<T> {\\n\");\n    output.push_str(\"  return { table, params, url, fallbackUrl } as ShapeDefinition<T>;\\n\");\n    output.push_str(\"}\\n\\n\");\n\n    // Generate individual shape definitions\n    output.push_str(\"// Individual shape definitions with embedded types\\n\");\n    for route in &routes {\n        let shape = route.shape;\n        let name = shape.name();\n\n        let params_str = shape\n            .params()\n            .iter()\n            .map(|p| format!(\"'{}'\", p))\n            .collect::<Vec<_>>()\n            .join(\", \");\n\n        output.push_str(&format!(\n            \"export const {} = defineShape<{}>(\\n  '{}',\\n  [{}] as const,\\n  '/v1{}',\\n  '/v1{}'\\n);\\n\\n\",\n            name,\n            shape.ts_type_name(),\n            shape.table(),\n            params_str,\n            shape.url(),\n            route.fallback_url,\n        ));\n    }\n\n    output.push_str(\n        \"// =============================================================================\\n\",\n    );\n    output.push_str(\"// Mutation Definitions\\n\");\n    output.push_str(\n        \"// =============================================================================\\n\\n\",\n    );\n\n    // MutationDefinition interface\n    output.push_str(\"// Mutation definition interface\\n\");\n    output.push_str(\n        \"export interface MutationDefinition<TRow, TCreate = unknown, TUpdate = unknown> {\\n\",\n    );\n    output.push_str(\"  readonly name: string;\\n\");\n    output.push_str(\"  readonly url: string;\\n\");\n    output.push_str(\n        \"  readonly _rowType: TRow;  // Phantom field for type inference (not present at runtime)\\n\",\n    );\n    output.push_str(\"  readonly _createType: TCreate;  // Phantom field for type inference (not present at runtime)\\n\");\n    output.push_str(\"  readonly _updateType: TUpdate;  // Phantom field for type inference (not present at runtime)\\n\");\n    output.push_str(\"}\\n\\n\");\n\n    // Helper function\n    output.push_str(\"// Helper to create type-safe mutation definitions\\n\");\n    output.push_str(\"function defineMutation<TRow, TCreate, TUpdate>(\\n\");\n    output.push_str(\"  name: string,\\n\");\n    output.push_str(\"  url: string\\n\");\n    output.push_str(\"): MutationDefinition<TRow, TCreate, TUpdate> {\\n\");\n    output.push_str(\"  return { name, url } as MutationDefinition<TRow, TCreate, TUpdate>;\\n\");\n    output.push_str(\"}\\n\\n\");\n\n    // Generate individual mutation definitions\n    output.push_str(\"// Individual mutation definitions\\n\");\n    for mutation in all_mutation_definitions() {\n        let ts_type = &mutation.row_type;\n        let const_name = to_screaming_snake_case(ts_type);\n        let create_type = mutation.create_type.as_deref().unwrap_or(\"unknown\");\n        let update_type = mutation.update_type.as_deref().unwrap_or(\"unknown\");\n\n        output.push_str(&format!(\n            \"export const {}_MUTATION = defineMutation<{}, {}, {}>(\\n  '{}',\\n  '/v1/{}'\\n);\\n\\n\",\n            const_name, ts_type, create_type, update_type, ts_type, mutation.table,\n        ));\n    }\n\n    // Type helpers\n    output.push_str(\"// Type helpers to extract types from a mutation definition\\n\");\n    output.push_str(\"export type MutationRowType<M extends MutationDefinition<unknown>> = M extends MutationDefinition<infer R> ? R : never;\\n\");\n    output.push_str(\"export type MutationCreateType<M extends MutationDefinition<unknown, unknown>> = M extends MutationDefinition<unknown, infer C> ? C : never;\\n\");\n    output.push_str(\"export type MutationUpdateType<M extends MutationDefinition<unknown, unknown, unknown>> = M extends MutationDefinition<unknown, unknown, infer U> ? U : never;\\n\");\n\n    output\n}\n\n/// Convert PascalCase to SCREAMING_SNAKE_CASE\nfn to_screaming_snake_case(s: &str) -> String {\n    let mut result = String::new();\n    for (i, c) in s.chars().enumerate() {\n        if c.is_uppercase() && i > 0 {\n            result.push('_');\n        }\n        result.push(c.to_ascii_uppercase());\n    }\n    result\n}\n"
  },
  {
    "path": "crates/remote/src/config.rs",
    "content": "use std::env;\n\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse secrecy::SecretString;\nuse thiserror::Error;\n\n#[derive(Debug, Clone)]\npub struct RemoteServerConfig {\n    pub database_url: String,\n    pub listen_addr: String,\n    pub server_public_base_url: Option<String>,\n    pub auth: AuthConfig,\n    pub electric_url: String,\n    pub electric_secret: Option<SecretString>,\n    pub electric_role_password: Option<SecretString>,\n    pub electric_publication_names: Vec<String>,\n    pub r2: Option<R2Config>,\n    pub azure_blob: Option<AzureBlobConfig>,\n    pub review_worker_base_url: Option<String>,\n    pub review_disabled: bool,\n    pub github_app: Option<GitHubAppConfig>,\n}\n\n#[derive(Debug, Clone)]\npub struct R2Config {\n    pub access_key_id: String,\n    pub secret_access_key: SecretString,\n    pub endpoint: String,\n    pub bucket: String,\n    pub presign_expiry_secs: u64,\n}\n\nimpl R2Config {\n    pub fn from_env() -> Result<Option<Self>, ConfigError> {\n        let access_key_id = match env::var(\"R2_ACCESS_KEY_ID\") {\n            Ok(v) if !v.is_empty() => v,\n            _ => {\n                tracing::info!(\"R2_ACCESS_KEY_ID not set, R2 storage disabled\");\n                return Ok(None);\n            }\n        };\n\n        tracing::info!(\"R2_ACCESS_KEY_ID is set, checking other R2 env vars\");\n\n        let secret_access_key = env::var(\"R2_SECRET_ACCESS_KEY\")\n            .map_err(|_| ConfigError::MissingVar(\"R2_SECRET_ACCESS_KEY\"))?;\n\n        let endpoint = env::var(\"R2_REVIEW_ENDPOINT\")\n            .map_err(|_| ConfigError::MissingVar(\"R2_REVIEW_ENDPOINT\"))?;\n\n        let bucket = env::var(\"R2_REVIEW_BUCKET\")\n            .map_err(|_| ConfigError::MissingVar(\"R2_REVIEW_BUCKET\"))?;\n\n        let presign_expiry_secs = env::var(\"R2_PRESIGN_EXPIRY_SECS\")\n            .ok()\n            .and_then(|v| v.parse().ok())\n            .unwrap_or(3600);\n\n        tracing::info!(endpoint = %endpoint, bucket = %bucket, \"R2 config loaded successfully\");\n\n        Ok(Some(Self {\n            access_key_id,\n            secret_access_key: SecretString::new(secret_access_key.into()),\n            endpoint,\n            bucket,\n            presign_expiry_secs,\n        }))\n    }\n}\n\n#[derive(Debug, Clone)]\npub enum AzureAuthMode {\n    /// Entra ID via user-assigned managed identity (production).\n    EntraId { client_id: String },\n    /// Shared Key via custom HMAC policy (local Azurite).\n    SharedKey,\n}\n\n#[derive(Debug, Clone)]\npub struct AzureBlobConfig {\n    pub account_name: String,\n    /// Account key is always required for SAS token generation.\n    pub account_key: SecretString,\n    pub container_name: String,\n    pub endpoint_url: Option<String>,\n    pub public_endpoint_url: Option<String>,\n    pub presign_expiry_secs: u64,\n    pub auth_mode: AzureAuthMode,\n}\n\nimpl AzureBlobConfig {\n    pub fn from_env() -> Result<Option<Self>, ConfigError> {\n        let account_name = match env::var(\"AZURE_STORAGE_ACCOUNT_NAME\") {\n            Ok(v) => v,\n            Err(_) => {\n                tracing::info!(\"AZURE_STORAGE_ACCOUNT_NAME not set, Azure Blob storage disabled\");\n                return Ok(None);\n            }\n        };\n\n        tracing::info!(\"AZURE_STORAGE_ACCOUNT_NAME is set, checking other Azure Blob env vars\");\n\n        let account_key = env::var(\"AZURE_STORAGE_ACCOUNT_KEY\")\n            .map_err(|_| ConfigError::MissingVar(\"AZURE_STORAGE_ACCOUNT_KEY\"))?;\n\n        let container_name = env::var(\"AZURE_STORAGE_CONTAINER_NAME\")\n            .unwrap_or_else(|_| \"issue-attachments\".to_string());\n\n        let endpoint_url = env::var(\"AZURE_STORAGE_ENDPOINT_URL\").ok();\n        let public_endpoint_url = env::var(\"AZURE_STORAGE_PUBLIC_ENDPOINT_URL\").ok();\n\n        let auth_mode = match env::var(\"AZURE_MANAGED_IDENTITY_CLIENT_ID\") {\n            Ok(client_id) => AzureAuthMode::EntraId { client_id },\n            Err(_) => AzureAuthMode::SharedKey,\n        };\n\n        let presign_expiry_secs = env::var(\"AZURE_BLOB_PRESIGN_EXPIRY_SECS\")\n            .ok()\n            .and_then(|v| v.parse().ok())\n            .unwrap_or(3600);\n\n        tracing::info!(\n            account_name = %account_name,\n            container_name = %container_name,\n            endpoint_url = ?endpoint_url,\n            auth_mode = ?auth_mode,\n            \"Azure Blob config loaded successfully\"\n        );\n\n        Ok(Some(Self {\n            account_name,\n            account_key: SecretString::new(account_key.into()),\n            container_name,\n            endpoint_url,\n            public_endpoint_url,\n            presign_expiry_secs,\n            auth_mode,\n        }))\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct GitHubAppConfig {\n    pub app_id: u64,\n    pub private_key: SecretString, // Base64-encoded PEM\n    pub webhook_secret: SecretString,\n    pub app_slug: String,\n}\n\nimpl GitHubAppConfig {\n    pub fn from_env() -> Result<Option<Self>, ConfigError> {\n        let app_id = match env::var(\"GITHUB_APP_ID\") {\n            Ok(v) if !v.is_empty() => v,\n            _ => {\n                tracing::info!(\"GITHUB_APP_ID not set, GitHub App integration disabled\");\n                return Ok(None);\n            }\n        };\n\n        let app_id: u64 = app_id\n            .parse()\n            .map_err(|_| ConfigError::InvalidVar(\"GITHUB_APP_ID\"))?;\n\n        tracing::info!(\"GITHUB_APP_ID is set, checking other GitHub App env vars\");\n\n        let private_key = env::var(\"GITHUB_APP_PRIVATE_KEY\")\n            .map_err(|_| ConfigError::MissingVar(\"GITHUB_APP_PRIVATE_KEY\"))?;\n\n        // Validate that the private key is valid base64\n        BASE64_STANDARD\n            .decode(private_key.as_bytes())\n            .map_err(|_| ConfigError::InvalidVar(\"GITHUB_APP_PRIVATE_KEY\"))?;\n\n        let webhook_secret = env::var(\"GITHUB_APP_WEBHOOK_SECRET\")\n            .map_err(|_| ConfigError::MissingVar(\"GITHUB_APP_WEBHOOK_SECRET\"))?;\n\n        let app_slug =\n            env::var(\"GITHUB_APP_SLUG\").map_err(|_| ConfigError::MissingVar(\"GITHUB_APP_SLUG\"))?;\n\n        tracing::info!(app_id = %app_id, app_slug = %app_slug, \"GitHub App config loaded successfully\");\n\n        Ok(Some(Self {\n            app_id,\n            private_key: SecretString::new(private_key.into()),\n            webhook_secret: SecretString::new(webhook_secret.into()),\n            app_slug,\n        }))\n    }\n}\n\n#[derive(Debug, Error)]\npub enum ConfigError {\n    #[error(\"environment variable `{0}` is not set\")]\n    MissingVar(&'static str),\n    #[error(\"invalid value for environment variable `{0}`\")]\n    InvalidVar(&'static str),\n    #[error(\"no OAuth providers configured\")]\n    NoOAuthProviders,\n}\n\nimpl RemoteServerConfig {\n    pub fn from_env() -> Result<Self, ConfigError> {\n        let database_url = env::var(\"SERVER_DATABASE_URL\")\n            .or_else(|_| env::var(\"DATABASE_URL\"))\n            .map_err(|_| ConfigError::MissingVar(\"SERVER_DATABASE_URL\"))?;\n\n        let listen_addr =\n            env::var(\"SERVER_LISTEN_ADDR\").unwrap_or_else(|_| \"0.0.0.0:8081\".to_string());\n\n        let server_public_base_url = env::var(\"SERVER_PUBLIC_BASE_URL\").ok();\n\n        let auth = AuthConfig::from_env()?;\n\n        let electric_url =\n            env::var(\"ELECTRIC_URL\").map_err(|_| ConfigError::MissingVar(\"ELECTRIC_URL\"))?;\n\n        let electric_secret = env::var(\"ELECTRIC_SECRET\")\n            .map(|s| SecretString::new(s.into()))\n            .ok();\n\n        let electric_role_password = env::var(\"ELECTRIC_ROLE_PASSWORD\")\n            .ok()\n            .map(|s| SecretString::new(s.into()));\n        let electric_publication_names = match env::var(\"ELECTRIC_PUBLICATION_NAMES\") {\n            Ok(value) => parse_publication_names(&value)?,\n            Err(_) => Vec::new(),\n        };\n\n        let r2 = R2Config::from_env()?;\n        let azure_blob = AzureBlobConfig::from_env()?;\n\n        let review_worker_base_url = env::var(\"REVIEW_WORKER_BASE_URL\").ok();\n\n        let review_disabled = env::var(\"REVIEW_DISABLED\")\n            .map(|v| v == \"1\" || v.eq_ignore_ascii_case(\"true\"))\n            .unwrap_or(false);\n\n        let github_app = GitHubAppConfig::from_env()?;\n\n        Ok(Self {\n            database_url,\n            listen_addr,\n            server_public_base_url,\n            auth,\n            electric_url,\n            electric_secret,\n            electric_role_password,\n            electric_publication_names,\n            r2,\n            azure_blob,\n            review_worker_base_url,\n            review_disabled,\n            github_app,\n        })\n    }\n}\n\nfn parse_publication_names(value: &str) -> Result<Vec<String>, ConfigError> {\n    let mut names = Vec::new();\n\n    for raw in value.split(',') {\n        let name = raw.trim();\n        if name.is_empty() {\n            continue;\n        }\n        if !is_valid_identifier(name) {\n            return Err(ConfigError::InvalidVar(\"ELECTRIC_PUBLICATION_NAMES\"));\n        }\n        names.push(name.to_string());\n    }\n\n    Ok(names)\n}\n\nfn is_valid_identifier(value: &str) -> bool {\n    let mut chars = value.chars();\n    let Some(first) = chars.next() else {\n        return false;\n    };\n    if !(first.is_ascii_alphabetic() || first == '_') {\n        return false;\n    }\n    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')\n}\n\n#[derive(Debug, Clone)]\npub struct OAuthProviderConfig {\n    client_id: String,\n    client_secret: SecretString,\n}\n\nimpl OAuthProviderConfig {\n    fn new(client_id: String, client_secret: SecretString) -> Self {\n        Self {\n            client_id,\n            client_secret,\n        }\n    }\n\n    pub fn client_id(&self) -> &str {\n        &self.client_id\n    }\n\n    pub fn client_secret(&self) -> &SecretString {\n        &self.client_secret\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct AuthConfig {\n    github: Option<OAuthProviderConfig>,\n    google: Option<OAuthProviderConfig>,\n    jwt_secret: SecretString,\n    public_base_url: String,\n}\n\nimpl AuthConfig {\n    fn from_env() -> Result<Self, ConfigError> {\n        let jwt_secret = env::var(\"VIBEKANBAN_REMOTE_JWT_SECRET\")\n            .map_err(|_| ConfigError::MissingVar(\"VIBEKANBAN_REMOTE_JWT_SECRET\"))?;\n        validate_jwt_secret(&jwt_secret)?;\n        let jwt_secret = SecretString::new(jwt_secret.into());\n\n        let github = match env::var(\"GITHUB_OAUTH_CLIENT_ID\") {\n            Ok(client_id) if !client_id.is_empty() => {\n                let client_secret = env::var(\"GITHUB_OAUTH_CLIENT_SECRET\")\n                    .map_err(|_| ConfigError::MissingVar(\"GITHUB_OAUTH_CLIENT_SECRET\"))?;\n                Some(OAuthProviderConfig::new(\n                    client_id,\n                    SecretString::new(client_secret.into()),\n                ))\n            }\n            _ => None,\n        };\n\n        let google = match env::var(\"GOOGLE_OAUTH_CLIENT_ID\") {\n            Ok(client_id) if !client_id.is_empty() => {\n                let client_secret = env::var(\"GOOGLE_OAUTH_CLIENT_SECRET\")\n                    .map_err(|_| ConfigError::MissingVar(\"GOOGLE_OAUTH_CLIENT_SECRET\"))?;\n                Some(OAuthProviderConfig::new(\n                    client_id,\n                    SecretString::new(client_secret.into()),\n                ))\n            }\n            _ => None,\n        };\n\n        if github.is_none() && google.is_none() {\n            return Err(ConfigError::NoOAuthProviders);\n        }\n\n        let public_base_url =\n            env::var(\"SERVER_PUBLIC_BASE_URL\").unwrap_or_else(|_| \"http://localhost:8081\".into());\n\n        Ok(Self {\n            github,\n            google,\n            jwt_secret,\n            public_base_url,\n        })\n    }\n\n    pub fn github(&self) -> Option<&OAuthProviderConfig> {\n        self.github.as_ref()\n    }\n\n    pub fn google(&self) -> Option<&OAuthProviderConfig> {\n        self.google.as_ref()\n    }\n\n    pub fn jwt_secret(&self) -> &SecretString {\n        &self.jwt_secret\n    }\n\n    pub fn public_base_url(&self) -> &str {\n        &self.public_base_url\n    }\n}\n\nfn validate_jwt_secret(secret: &str) -> Result<(), ConfigError> {\n    let decoded = BASE64_STANDARD\n        .decode(secret.as_bytes())\n        .map_err(|_| ConfigError::InvalidVar(\"VIBEKANBAN_REMOTE_JWT_SECRET\"))?;\n\n    if decoded.len() < 32 {\n        return Err(ConfigError::InvalidVar(\"VIBEKANBAN_REMOTE_JWT_SECRET\"));\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/remote/src/db/attachments.rs",
    "content": "use api_types::{Attachment, AttachmentWithBlob, Blob};\nuse chrono::{DateTime, Utc};\nuse sqlx::{Executor, PgPool, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum AttachmentError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct AttachmentRepository;\n\nimpl AttachmentRepository {\n    pub async fn find_by_id<'e, E>(\n        executor: E,\n        id: Uuid,\n    ) -> Result<Option<Attachment>, AttachmentError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            Attachment,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                blob_id     AS \"blob_id!: Uuid\",\n                issue_id    AS \"issue_id?: Uuid\",\n                comment_id  AS \"comment_id?: Uuid\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                expires_at  AS \"expires_at?: DateTime<Utc>\"\n            FROM attachments\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn find_by_id_with_blob<'e, E>(\n        executor: E,\n        id: Uuid,\n    ) -> Result<Option<AttachmentWithBlob>, AttachmentError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            AttachmentWithBlob,\n            r#\"\n            SELECT\n                a.id                    AS \"id!: Uuid\",\n                a.blob_id               AS \"blob_id!: Uuid\",\n                a.issue_id              AS \"issue_id?: Uuid\",\n                a.comment_id            AS \"comment_id?: Uuid\",\n                a.created_at            AS \"created_at!: DateTime<Utc>\",\n                a.expires_at            AS \"expires_at?: DateTime<Utc>\",\n                b.blob_path             AS \"blob_path!\",\n                b.thumbnail_blob_path   AS \"thumbnail_blob_path?\",\n                b.original_name         AS \"original_name!\",\n                b.mime_type             AS \"mime_type?\",\n                b.size_bytes            AS \"size_bytes!\",\n                b.hash                  AS \"hash!\",\n                b.width                 AS \"width?\",\n                b.height                AS \"height?\"\n            FROM attachments a\n            INNER JOIN blobs b ON b.id = a.blob_id\n            WHERE a.id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn find_by_issue_id(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<AttachmentWithBlob>, AttachmentError> {\n        let records = sqlx::query_as!(\n            AttachmentWithBlob,\n            r#\"\n            SELECT\n                a.id                    AS \"id!: Uuid\",\n                a.blob_id               AS \"blob_id!: Uuid\",\n                a.issue_id              AS \"issue_id?: Uuid\",\n                a.comment_id            AS \"comment_id?: Uuid\",\n                a.created_at            AS \"created_at!: DateTime<Utc>\",\n                a.expires_at            AS \"expires_at?: DateTime<Utc>\",\n                b.blob_path             AS \"blob_path!\",\n                b.thumbnail_blob_path   AS \"thumbnail_blob_path?\",\n                b.original_name         AS \"original_name!\",\n                b.mime_type             AS \"mime_type?\",\n                b.size_bytes            AS \"size_bytes!\",\n                b.hash                  AS \"hash!\",\n                b.width                 AS \"width?\",\n                b.height                AS \"height?\"\n            FROM attachments a\n            INNER JOIN blobs b ON b.id = a.blob_id\n            WHERE a.issue_id = $1\n            ORDER BY a.created_at ASC\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn find_by_comment_id(\n        pool: &PgPool,\n        comment_id: Uuid,\n    ) -> Result<Vec<AttachmentWithBlob>, AttachmentError> {\n        let records = sqlx::query_as!(\n            AttachmentWithBlob,\n            r#\"\n            SELECT\n                a.id                    AS \"id!: Uuid\",\n                a.blob_id               AS \"blob_id!: Uuid\",\n                a.issue_id              AS \"issue_id?: Uuid\",\n                a.comment_id            AS \"comment_id?: Uuid\",\n                a.created_at            AS \"created_at!: DateTime<Utc>\",\n                a.expires_at            AS \"expires_at?: DateTime<Utc>\",\n                b.blob_path             AS \"blob_path!\",\n                b.thumbnail_blob_path   AS \"thumbnail_blob_path?\",\n                b.original_name         AS \"original_name!\",\n                b.mime_type             AS \"mime_type?\",\n                b.size_bytes            AS \"size_bytes!\",\n                b.hash                  AS \"hash!\",\n                b.width                 AS \"width?\",\n                b.height                AS \"height?\"\n            FROM attachments a\n            INNER JOIN blobs b ON b.id = a.blob_id\n            WHERE a.comment_id = $1\n            ORDER BY a.created_at ASC\n            \"#,\n            comment_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        blob_id: Uuid,\n        issue_id: Option<Uuid>,\n        comment_id: Option<Uuid>,\n        expires_at: Option<DateTime<Utc>>,\n    ) -> Result<Attachment, AttachmentError> {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n\n        let data = sqlx::query_as!(\n            Attachment,\n            r#\"\n            INSERT INTO attachments (id, blob_id, issue_id, comment_id, expires_at)\n            VALUES ($1, $2, $3, $4, $5)\n            RETURNING\n                id          AS \"id!: Uuid\",\n                blob_id     AS \"blob_id!: Uuid\",\n                issue_id    AS \"issue_id?: Uuid\",\n                comment_id  AS \"comment_id?: Uuid\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                expires_at  AS \"expires_at?: DateTime<Utc>\"\n            \"#,\n            id,\n            blob_id,\n            issue_id,\n            comment_id,\n            expires_at\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(data)\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Option<Attachment>, AttachmentError> {\n        let record = sqlx::query_as!(\n            Attachment,\n            r#\"\n            DELETE FROM attachments\n            WHERE id = $1\n            RETURNING\n                id          AS \"id!: Uuid\",\n                blob_id     AS \"blob_id!: Uuid\",\n                issue_id    AS \"issue_id?: Uuid\",\n                comment_id  AS \"comment_id?: Uuid\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                expires_at  AS \"expires_at?: DateTime<Utc>\"\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    /// Count how many attachments reference a specific blob.\n    pub async fn count_by_blob_id(pool: &PgPool, blob_id: Uuid) -> Result<i64, AttachmentError> {\n        let count = sqlx::query_scalar!(\n            r#\"\n            SELECT COUNT(*) AS \"count!\"\n            FROM attachments\n            WHERE blob_id = $1\n            \"#,\n            blob_id\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(count)\n    }\n\n    /// Commit staged attachments to an issue (sets issue_id and clears expires_at).\n    pub async fn commit_to_issue(\n        pool: &PgPool,\n        attachment_ids: &[Uuid],\n        issue_id: Uuid,\n    ) -> Result<Vec<AttachmentWithBlob>, AttachmentError> {\n        let records = sqlx::query_as!(\n            AttachmentWithBlob,\n            r#\"\n            UPDATE attachments a\n            SET issue_id = $1, expires_at = NULL\n            FROM blobs b\n            WHERE a.blob_id = b.id\n              AND a.id = ANY($2)\n              AND a.issue_id IS NULL\n              AND a.comment_id IS NULL\n            RETURNING\n                a.id                    AS \"id!: Uuid\",\n                a.blob_id               AS \"blob_id!: Uuid\",\n                a.issue_id              AS \"issue_id?: Uuid\",\n                a.comment_id            AS \"comment_id?: Uuid\",\n                a.created_at            AS \"created_at!: DateTime<Utc>\",\n                a.expires_at            AS \"expires_at?: DateTime<Utc>\",\n                b.blob_path             AS \"blob_path!\",\n                b.thumbnail_blob_path   AS \"thumbnail_blob_path?\",\n                b.original_name         AS \"original_name!\",\n                b.mime_type             AS \"mime_type?\",\n                b.size_bytes            AS \"size_bytes!\",\n                b.hash                  AS \"hash!\",\n                b.width                 AS \"width?\",\n                b.height                AS \"height?\"\n            \"#,\n            issue_id,\n            attachment_ids\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    /// Commit staged attachments to a comment (sets comment_id and clears expires_at).\n    pub async fn commit_to_comment(\n        pool: &PgPool,\n        attachment_ids: &[Uuid],\n        comment_id: Uuid,\n    ) -> Result<Vec<AttachmentWithBlob>, AttachmentError> {\n        let records = sqlx::query_as!(\n            AttachmentWithBlob,\n            r#\"\n            UPDATE attachments a\n            SET comment_id = $1, expires_at = NULL\n            FROM blobs b\n            WHERE a.blob_id = b.id\n              AND a.id = ANY($2)\n              AND a.issue_id IS NULL\n              AND a.comment_id IS NULL\n            RETURNING\n                a.id                    AS \"id!: Uuid\",\n                a.blob_id               AS \"blob_id!: Uuid\",\n                a.issue_id              AS \"issue_id?: Uuid\",\n                a.comment_id            AS \"comment_id?: Uuid\",\n                a.created_at            AS \"created_at!: DateTime<Utc>\",\n                a.expires_at            AS \"expires_at?: DateTime<Utc>\",\n                b.blob_path             AS \"blob_path!\",\n                b.thumbnail_blob_path   AS \"thumbnail_blob_path?\",\n                b.original_name         AS \"original_name!\",\n                b.mime_type             AS \"mime_type?\",\n                b.size_bytes            AS \"size_bytes!\",\n                b.hash                  AS \"hash!\",\n                b.width                 AS \"width?\",\n                b.height                AS \"height?\"\n            \"#,\n            comment_id,\n            attachment_ids\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    /// Get the project_id for an attachment via its blob.\n    pub async fn project_id(\n        pool: &PgPool,\n        attachment_id: Uuid,\n    ) -> Result<Option<Uuid>, AttachmentError> {\n        let record = sqlx::query_scalar!(\n            r#\"\n            SELECT b.project_id\n            FROM attachments a\n            INNER JOIN blobs b ON b.id = a.blob_id\n            WHERE a.id = $1\n            \"#,\n            attachment_id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    /// Get the blob data for an attachment.\n    pub async fn get_blob(\n        pool: &PgPool,\n        attachment_id: Uuid,\n    ) -> Result<Option<Blob>, AttachmentError> {\n        let record = sqlx::query_as!(\n            Blob,\n            r#\"\n            SELECT\n                b.id                  AS \"id!: Uuid\",\n                b.project_id          AS \"project_id!: Uuid\",\n                b.blob_path           AS \"blob_path!\",\n                b.thumbnail_blob_path AS \"thumbnail_blob_path?\",\n                b.original_name       AS \"original_name!\",\n                b.mime_type           AS \"mime_type?\",\n                b.size_bytes          AS \"size_bytes!\",\n                b.hash                AS \"hash!\",\n                b.width               AS \"width?\",\n                b.height              AS \"height?\",\n                b.created_at          AS \"created_at!: DateTime<Utc>\",\n                b.updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM attachments a\n            INNER JOIN blobs b ON b.id = a.blob_id\n            WHERE a.id = $1\n            \"#,\n            attachment_id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    /// Find attachments whose `expires_at` is in the past (abandoned staged uploads).\n    /// Returns up to `limit` results, oldest expired first.\n    pub async fn find_expired(\n        pool: &PgPool,\n        limit: i64,\n    ) -> Result<Vec<Attachment>, AttachmentError> {\n        let records = sqlx::query_as!(\n            Attachment,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                blob_id     AS \"blob_id!: Uuid\",\n                issue_id    AS \"issue_id?: Uuid\",\n                comment_id  AS \"comment_id?: Uuid\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                expires_at  AS \"expires_at?: DateTime<Utc>\"\n            FROM attachments\n            WHERE expires_at IS NOT NULL AND expires_at < NOW()\n            ORDER BY expires_at ASC\n            LIMIT $1\n            \"#,\n            limit\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/auth.rs",
    "content": "pub use api_types::AuthSession;\nuse chrono::Duration;\nuse sqlx::{PgPool, query_as};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum AuthSessionError {\n    #[error(\"auth session not found\")]\n    NotFound,\n    #[error(\"refresh token reused - possible theft detected\")]\n    TokenReuseDetected,\n    #[error(\"token has been revoked\")]\n    TokenRevoked,\n    #[error(\"token has expired\")]\n    TokenExpired,\n    #[error(\"invalid token\")]\n    InvalidToken,\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\npub const MAX_SESSION_INACTIVITY_DURATION: Duration = Duration::days(365);\n\npub struct AuthSessionRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> AuthSessionRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn create(\n        &self,\n        user_id: Uuid,\n        refresh_token_id: Option<Uuid>,\n    ) -> Result<AuthSession, AuthSessionError> {\n        query_as!(\n            AuthSession,\n            r#\"\n            INSERT INTO auth_sessions (user_id, refresh_token_id)\n            VALUES ($1, $2)\n            RETURNING\n                id                          AS \"id!\",\n                user_id                     AS \"user_id!: Uuid\",\n                created_at                  AS \"created_at!\",\n                last_used_at                AS \"last_used_at?\",\n                revoked_at                  AS \"revoked_at?\",\n                refresh_token_id           AS \"refresh_token_id?\",\n                refresh_token_issued_at     AS \"refresh_token_issued_at?\"\n            \"#,\n            user_id,\n            refresh_token_id\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(AuthSessionError::from)\n    }\n\n    pub async fn get(&self, session_id: Uuid) -> Result<AuthSession, AuthSessionError> {\n        query_as!(\n            AuthSession,\n            r#\"\n            SELECT\n                id                          AS \"id!\",\n                user_id                     AS \"user_id!: Uuid\",\n                created_at                  AS \"created_at!\",\n                last_used_at                AS \"last_used_at?\",\n                revoked_at                  AS \"revoked_at?\",\n                refresh_token_id           AS \"refresh_token_id?\",\n                refresh_token_issued_at     AS \"refresh_token_issued_at?\"\n            FROM auth_sessions\n            WHERE id = $1\n            \"#,\n            session_id\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(AuthSessionError::NotFound)\n    }\n\n    pub async fn touch(&self, session_id: Uuid) -> Result<(), AuthSessionError> {\n        sqlx::query!(\n            r#\"\n            UPDATE auth_sessions\n            SET last_used_at = date_trunc('day', NOW())\n            WHERE id = $1\n              AND (\n                last_used_at IS NULL\n                OR last_used_at < date_trunc('day', NOW())\n              )\n            \"#,\n            session_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn rotate_tokens(\n        &self,\n        session_id: Uuid,\n        old_refresh_token_id: Uuid,\n        new_refresh_token_id: Uuid,\n    ) -> Result<(), AuthSessionError> {\n        let mut tx = super::begin_tx(self.pool)\n            .await\n            .map_err(AuthSessionError::from)?;\n\n        let updated = sqlx::query!(\n            r#\"\n            UPDATE auth_sessions\n            SET refresh_token_id = $3,\n                refresh_token_issued_at = NOW()\n            WHERE id = $1\n              AND refresh_token_id = $2\n            RETURNING user_id\n            \"#,\n            session_id,\n            old_refresh_token_id,\n            new_refresh_token_id\n        )\n        .fetch_optional(&mut *tx)\n        .await\n        .map_err(AuthSessionError::from)?;\n\n        let Some(row) = updated else {\n            tx.rollback().await.map_err(AuthSessionError::from)?;\n            return Err(AuthSessionError::TokenReuseDetected);\n        };\n\n        // Revoke the old refresh token\n        sqlx::query!(\n            r#\"\n            INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason)\n            VALUES ($1, $2, 'token_rotation')\n            ON CONFLICT (token_id) DO NOTHING\n            \"#,\n            old_refresh_token_id,\n            row.user_id\n        )\n        .execute(&mut *tx)\n        .await\n        .map_err(AuthSessionError::from)?;\n\n        tx.commit().await.map_err(AuthSessionError::from)?;\n        Ok(())\n    }\n\n    pub async fn set_current_refresh_token(\n        &self,\n        session_id: Uuid,\n        refresh_token_id: Uuid,\n    ) -> Result<(), AuthSessionError> {\n        sqlx::query!(\n            r#\"\n            UPDATE auth_sessions\n            SET refresh_token_id = $2,\n                refresh_token_issued_at = NOW()\n            WHERE id = $1\n            \"#,\n            session_id,\n            refresh_token_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn revoke_all_user_sessions(&self, user_id: Uuid) -> Result<i64, AuthSessionError> {\n        let mut tx = super::begin_tx(self.pool)\n            .await\n            .map_err(AuthSessionError::from)?;\n\n        sqlx::query!(\n            r#\"\n            INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason)\n            SELECT refresh_token_id, user_id, 'reuse_of_revoked_token'\n            FROM auth_sessions\n            WHERE user_id = $1\n              AND refresh_token_id IS NOT NULL\n            ON CONFLICT (token_id) DO NOTHING\n            \"#,\n            user_id\n        )\n        .execute(&mut *tx)\n        .await\n        .map_err(AuthSessionError::from)?;\n\n        let update_result = sqlx::query!(\n            r#\"\n            UPDATE auth_sessions\n            SET revoked_at = NOW()\n            WHERE user_id = $1\n              AND revoked_at IS NULL\n            \"#,\n            user_id\n        )\n        .execute(&mut *tx)\n        .await\n        .map_err(AuthSessionError::from)?;\n\n        tx.commit().await.map_err(AuthSessionError::from)?;\n\n        Ok(update_result.rows_affected() as i64)\n    }\n\n    pub async fn is_refresh_token_revoked(&self, token_id: Uuid) -> Result<bool, AuthSessionError> {\n        let result = sqlx::query!(\n            r#\"\n            SELECT EXISTS(\n                SELECT 1 FROM revoked_refresh_tokens WHERE token_id = $1\n            ) as is_revoked\n            \"#,\n            token_id\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(AuthSessionError::from)?;\n\n        Ok(result.is_revoked.unwrap_or(false))\n    }\n\n    pub async fn revoke(&self, session_id: Uuid) -> Result<(), AuthSessionError> {\n        sqlx::query!(\n            r#\"\n            UPDATE auth_sessions\n            SET revoked_at = NOW()\n            WHERE id = $1\n            \"#,\n            session_id\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/blobs.rs",
    "content": "use api_types::Blob;\nuse chrono::{DateTime, Utc};\nuse sqlx::{Executor, PgPool, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum BlobError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct BlobRepository;\n\nimpl BlobRepository {\n    pub async fn find_by_id<'e, E>(executor: E, id: Uuid) -> Result<Option<Blob>, BlobError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            Blob,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                blob_path           AS \"blob_path!\",\n                thumbnail_blob_path AS \"thumbnail_blob_path?\",\n                original_name       AS \"original_name!\",\n                mime_type           AS \"mime_type?\",\n                size_bytes          AS \"size_bytes!\",\n                hash                AS \"hash!\",\n                width               AS \"width?\",\n                height              AS \"height?\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM blobs\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    /// Find a blob by its content hash within a project.\n    pub async fn find_by_hash(\n        pool: &PgPool,\n        project_id: Uuid,\n        hash: &str,\n    ) -> Result<Option<Blob>, BlobError> {\n        let record = sqlx::query_as!(\n            Blob,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                blob_path           AS \"blob_path!\",\n                thumbnail_blob_path AS \"thumbnail_blob_path?\",\n                original_name       AS \"original_name!\",\n                mime_type           AS \"mime_type?\",\n                size_bytes          AS \"size_bytes!\",\n                hash                AS \"hash!\",\n                width               AS \"width?\",\n                height              AS \"height?\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM blobs\n            WHERE project_id = $1 AND hash = $2\n            LIMIT 1\n            \"#,\n            project_id,\n            hash\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        project_id: Uuid,\n        blob_path: String,\n        thumbnail_blob_path: Option<String>,\n        original_name: String,\n        mime_type: Option<String>,\n        size_bytes: i64,\n        hash: String,\n        width: Option<i32>,\n        height: Option<i32>,\n    ) -> Result<Blob, BlobError> {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n\n        let data = sqlx::query_as!(\n            Blob,\n            r#\"\n            INSERT INTO blobs (\n                id, project_id, blob_path, thumbnail_blob_path, original_name,\n                mime_type, size_bytes, hash, width, height\n            )\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n            ON CONFLICT (blob_path) DO UPDATE SET\n                updated_at = NOW()\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                blob_path           AS \"blob_path!\",\n                thumbnail_blob_path AS \"thumbnail_blob_path?\",\n                original_name       AS \"original_name!\",\n                mime_type           AS \"mime_type?\",\n                size_bytes          AS \"size_bytes!\",\n                hash                AS \"hash!\",\n                width               AS \"width?\",\n                height              AS \"height?\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            project_id,\n            blob_path,\n            thumbnail_blob_path,\n            original_name,\n            mime_type,\n            size_bytes,\n            hash,\n            width,\n            height,\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(data)\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Option<Blob>, BlobError> {\n        let record = sqlx::query_as!(\n            Blob,\n            r#\"\n            DELETE FROM blobs\n            WHERE id = $1\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                blob_path           AS \"blob_path!\",\n                thumbnail_blob_path AS \"thumbnail_blob_path?\",\n                original_name       AS \"original_name!\",\n                mime_type           AS \"mime_type?\",\n                size_bytes          AS \"size_bytes!\",\n                hash                AS \"hash!\",\n                width               AS \"width?\",\n                height              AS \"height?\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    /// Get the organization_id for a blob via its project.\n    pub async fn organization_id(pool: &PgPool, blob_id: Uuid) -> Result<Option<Uuid>, BlobError> {\n        let record = sqlx::query_scalar!(\n            r#\"\n            SELECT p.organization_id\n            FROM blobs b\n            INNER JOIN projects p ON p.id = b.project_id\n            WHERE b.id = $1\n            \"#,\n            blob_id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/digest.rs",
    "content": "use api_types::{NotificationPayload, NotificationType};\nuse chrono::{DateTime, Utc};\nuse sqlx::{PgPool, Postgres, pool::PoolConnection};\nuse uuid::Uuid;\n\nuse crate::digest::DigestUser;\n\n#[derive(Debug, Clone)]\npub struct NotificationDigestRow {\n    pub id: Uuid,\n    pub notification_type: NotificationType,\n    pub payload: sqlx::types::Json<NotificationPayload>,\n    pub issue_id: Option<Uuid>,\n    pub created_at: DateTime<Utc>,\n    pub actor_name: String,\n}\n\npub struct DigestRepository;\n\nconst DIGEST_ADVISORY_LOCK_ID: i64 = 3_447_201_001;\n\npub struct DigestRunLock {\n    connection: PoolConnection<Postgres>,\n}\n\nimpl DigestRepository {\n    pub async fn try_acquire_run_lock(pool: &PgPool) -> Result<Option<DigestRunLock>, sqlx::Error> {\n        let mut connection = pool.acquire().await?;\n        let acquired: bool = sqlx::query_scalar(\"SELECT pg_try_advisory_lock($1)\")\n            .bind(DIGEST_ADVISORY_LOCK_ID)\n            .fetch_one(&mut *connection)\n            .await?;\n\n        if acquired {\n            Ok(Some(DigestRunLock { connection }))\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub async fn fetch_users_with_pending_notifications(\n        pool: &PgPool,\n        window_start: DateTime<Utc>,\n        window_end: DateTime<Utc>,\n    ) -> Result<Vec<DigestUser>, sqlx::Error> {\n        sqlx::query_as!(\n            DigestUser,\n            r#\"\n            SELECT DISTINCT\n                u.id AS \"id!: Uuid\",\n                u.email AS \"email!\",\n                u.first_name,\n                u.last_name,\n                u.username\n            FROM notifications n\n            JOIN users u ON u.id = n.user_id\n            WHERE n.created_at >= $1\n              AND n.created_at < $2\n              AND n.dismissed_at IS NULL\n              AND n.seen = FALSE\n              AND NOT EXISTS (\n                  SELECT 1\n                  FROM notification_digest_deliveries d\n                  WHERE d.notification_id = n.id\n              )\n            ORDER BY u.id\n            \"#,\n            window_start,\n            window_end\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn fetch_notifications_for_user(\n        pool: &PgPool,\n        user_id: Uuid,\n        window_start: DateTime<Utc>,\n        window_end: DateTime<Utc>,\n    ) -> Result<Vec<NotificationDigestRow>, sqlx::Error> {\n        sqlx::query_as!(\n            NotificationDigestRow,\n            r#\"\n            SELECT\n                n.id AS \"id!: Uuid\",\n                n.notification_type AS \"notification_type!: NotificationType\",\n                n.payload AS \"payload!: sqlx::types::Json<NotificationPayload>\",\n                n.issue_id AS \"issue_id?: Uuid\",\n                n.created_at AS \"created_at!\",\n                COALESCE(NULLIF(actor.first_name, ''), NULLIF(actor.username, ''), 'Someone') AS \"actor_name!\"\n            FROM notifications n\n            LEFT JOIN users actor\n                ON actor.id = NULLIF(n.payload->>'actor_user_id', '')::uuid\n            WHERE n.user_id = $1\n              AND n.created_at >= $2\n              AND n.created_at < $3\n              AND n.dismissed_at IS NULL\n              AND n.seen = FALSE\n              AND NOT EXISTS (\n                  SELECT 1\n                  FROM notification_digest_deliveries d\n                  WHERE d.notification_id = n.id\n              )\n            ORDER BY n.created_at DESC\n            \"#,\n            user_id,\n            window_start,\n            window_end\n        )\n        .fetch_all(pool)\n        .await\n    }\n\n    pub async fn record_notifications_delivered(\n        pool: &PgPool,\n        notification_ids: &[Uuid],\n    ) -> Result<(), sqlx::Error> {\n        if notification_ids.is_empty() {\n            return Ok(());\n        }\n\n        sqlx::query!(\n            r#\"\n            INSERT INTO notification_digest_deliveries (notification_id)\n            SELECT notification_id\n            FROM UNNEST($1::uuid[]) AS delivered(notification_id)\n            ON CONFLICT (notification_id) DO NOTHING\n            \"#,\n            notification_ids,\n        )\n        .execute(pool)\n        .await?;\n\n        Ok(())\n    }\n}\n\nimpl DigestRunLock {\n    pub async fn release(mut self) -> Result<(), sqlx::Error> {\n        sqlx::query(\"SELECT pg_advisory_unlock($1)\")\n            .bind(DIGEST_ADVISORY_LOCK_ID)\n            .execute(&mut *self.connection)\n            .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/electric_publications.rs",
    "content": "use std::collections::HashSet;\n\nuse sqlx::PgPool;\n\n#[derive(Debug)]\nstruct PublicationTable {\n    schema_name: String,\n    table_name: String,\n}\n\n#[derive(Debug, Hash, PartialEq, Eq)]\nstruct PublicationTableRef {\n    pubname: String,\n    schema_name: String,\n    table_name: String,\n}\n\npub(crate) async fn ensure_electric_publications(\n    pool: &PgPool,\n    publication_names: &[String],\n) -> Result<(), sqlx::Error> {\n    if publication_names.is_empty() {\n        return Ok(());\n    }\n\n    tracing::info!(\n        publication_count = publication_names.len(),\n        publications = ?publication_names,\n        \"Electric publication sync starting\"\n    );\n\n    let mut tx = super::begin_tx(pool).await?;\n\n    sqlx::query(r#\"SELECT pg_advisory_xact_lock(hashtext('electric_publication_sync'))\"#)\n        .execute(&mut *tx)\n        .await?;\n\n    let existing_publications = sqlx::query_scalar!(\n        r#\"SELECT pubname FROM pg_publication WHERE pubname = ANY($1)\"#,\n        publication_names\n    )\n    .fetch_all(&mut *tx)\n    .await?;\n    let existing_publications: HashSet<String> = existing_publications.into_iter().collect();\n\n    let mut created_publications = Vec::new();\n    let mut skipped_publications = Vec::new();\n\n    for publication in publication_names {\n        if !existing_publications.contains(publication) {\n            let sql = format!(\"CREATE PUBLICATION {}\", quote_ident(publication));\n            sqlx::query(&sql).execute(&mut *tx).await?;\n            created_publications.push(publication.clone());\n        } else {\n            skipped_publications.push(publication.clone());\n        }\n    }\n\n    if !created_publications.is_empty() {\n        tracing::info!(publications = ?created_publications, \"Created missing Electric publications\");\n    }\n    if !skipped_publications.is_empty() {\n        tracing::info!(publications = ?skipped_publications, \"Electric publications already exist (skipped)\");\n    }\n\n    let tables = sqlx::query_as!(\n        PublicationTable,\n        r#\"SELECT n.nspname AS schema_name, c.relname AS table_name\n           FROM pg_publication_rel pr\n           JOIN pg_publication p ON pr.prpubid = p.oid\n           JOIN pg_class c ON pr.prrelid = c.oid\n           JOIN pg_namespace n ON c.relnamespace = n.oid\n           WHERE p.pubname = 'electric_publication_default'\"#\n    )\n    .fetch_all(&mut *tx)\n    .await?;\n\n    tracing::info!(\n        default_table_count = tables.len(),\n        \"Loaded tables from electric_publication_default\"\n    );\n\n    let existing_pairs = sqlx::query_as!(\n        PublicationTableRef,\n        r#\"SELECT p.pubname AS pubname, n.nspname AS schema_name, c.relname AS table_name\n           FROM pg_publication_rel pr\n           JOIN pg_publication p ON pr.prpubid = p.oid\n           JOIN pg_class c ON pr.prrelid = c.oid\n           JOIN pg_namespace n ON c.relnamespace = n.oid\n           WHERE p.pubname = ANY($1)\"#,\n        publication_names\n    )\n    .fetch_all(&mut *tx)\n    .await?;\n    let existing_pairs: HashSet<PublicationTableRef> = existing_pairs.into_iter().collect();\n\n    let mut missing_pairs = Vec::new();\n    for table in &tables {\n        for publication in publication_names {\n            let key = PublicationTableRef {\n                pubname: publication.clone(),\n                schema_name: table.schema_name.clone(),\n                table_name: table.table_name.clone(),\n            };\n            if !existing_pairs.contains(&key) {\n                missing_pairs.push(key);\n            }\n        }\n    }\n\n    tracing::info!(\n        missing_pair_count = missing_pairs.len(),\n        \"Computed missing publication/table mappings\"\n    );\n\n    for entry in &missing_pairs {\n        let sql = format!(\n            \"ALTER PUBLICATION {} ADD TABLE {}.{}\",\n            quote_ident(&entry.pubname),\n            quote_ident(&entry.schema_name),\n            quote_ident(&entry.table_name)\n        );\n        sqlx::query(&sql).execute(&mut *tx).await?;\n    }\n\n    if !missing_pairs.is_empty() {\n        tracing::info!(\n            added_pair_count = missing_pairs.len(),\n            \"Added missing tables to Electric publications\"\n        );\n    } else {\n        tracing::info!(\"No missing tables to add to Electric publications\");\n    }\n\n    tx.commit().await?;\n\n    tracing::info!(\"Electric publication sync completed\");\n\n    Ok(())\n}\n\nfn quote_ident(ident: &str) -> String {\n    format!(\"\\\"{}\\\"\", ident.replace('\\\"', \"\\\"\\\"\"))\n}\n"
  },
  {
    "path": "crates/remote/src/db/github_app.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sqlx::{FromRow, PgPool};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum GitHubAppDbError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n    #[error(\"installation not found\")]\n    NotFound,\n    #[error(\"pending installation not found or expired\")]\n    PendingNotFound,\n}\n\n/// A GitHub App installation linked to an organization\n#[derive(Debug, Clone, FromRow)]\npub struct GitHubAppInstallation {\n    pub id: Uuid,\n    pub organization_id: Uuid,\n    pub github_installation_id: i64,\n    pub github_account_login: String,\n    pub github_account_type: String,\n    pub repository_selection: String,\n    pub installed_by_user_id: Option<Uuid>,\n    pub suspended_at: Option<DateTime<Utc>>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n/// A repository accessible via an installation\n#[derive(Debug, Clone, FromRow)]\npub struct GitHubAppRepository {\n    pub id: Uuid,\n    pub installation_id: Uuid,\n    pub github_repo_id: i64,\n    pub repo_full_name: String,\n    pub review_enabled: bool,\n    pub created_at: DateTime<Utc>,\n}\n\n/// A pending installation waiting for callback\n#[derive(Debug, Clone, FromRow)]\npub struct PendingInstallation {\n    pub id: Uuid,\n    pub organization_id: Uuid,\n    pub user_id: Uuid,\n    pub state_token: String,\n    pub expires_at: DateTime<Utc>,\n    pub created_at: DateTime<Utc>,\n}\n\npub struct GitHubAppRepository2<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> GitHubAppRepository2<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    // ========== Installations ==========\n\n    pub async fn create_installation(\n        &self,\n        organization_id: Uuid,\n        github_installation_id: i64,\n        github_account_login: &str,\n        github_account_type: &str,\n        repository_selection: &str,\n        installed_by_user_id: Uuid,\n    ) -> Result<GitHubAppInstallation, GitHubAppDbError> {\n        let installation = sqlx::query_as!(\n            GitHubAppInstallation,\n            r#\"\n            INSERT INTO github_app_installations (\n                organization_id,\n                github_installation_id,\n                github_account_login,\n                github_account_type,\n                repository_selection,\n                installed_by_user_id\n            )\n            VALUES ($1, $2, $3, $4, $5, $6)\n            ON CONFLICT (github_installation_id) DO UPDATE SET\n                organization_id = EXCLUDED.organization_id,\n                github_account_login = EXCLUDED.github_account_login,\n                github_account_type = EXCLUDED.github_account_type,\n                repository_selection = EXCLUDED.repository_selection,\n                installed_by_user_id = EXCLUDED.installed_by_user_id,\n                suspended_at = NULL,\n                updated_at = NOW()\n            RETURNING\n                id,\n                organization_id,\n                github_installation_id,\n                github_account_login,\n                github_account_type,\n                repository_selection,\n                installed_by_user_id,\n                suspended_at,\n                created_at,\n                updated_at\n            \"#,\n            organization_id,\n            github_installation_id,\n            github_account_login,\n            github_account_type,\n            repository_selection,\n            installed_by_user_id\n        )\n        .fetch_one(self.pool)\n        .await?;\n\n        Ok(installation)\n    }\n\n    pub async fn get_by_github_id(\n        &self,\n        github_installation_id: i64,\n    ) -> Result<Option<GitHubAppInstallation>, GitHubAppDbError> {\n        let installation = sqlx::query_as!(\n            GitHubAppInstallation,\n            r#\"\n            SELECT\n                id,\n                organization_id,\n                github_installation_id,\n                github_account_login,\n                github_account_type,\n                repository_selection,\n                installed_by_user_id,\n                suspended_at,\n                created_at,\n                updated_at\n            FROM github_app_installations\n            WHERE github_installation_id = $1\n            \"#,\n            github_installation_id\n        )\n        .fetch_optional(self.pool)\n        .await?;\n\n        Ok(installation)\n    }\n\n    /// Find an installation by the GitHub account login (owner name)\n    pub async fn get_by_account_login(\n        &self,\n        account_login: &str,\n    ) -> Result<Option<GitHubAppInstallation>, GitHubAppDbError> {\n        let installation = sqlx::query_as!(\n            GitHubAppInstallation,\n            r#\"\n            SELECT\n                id,\n                organization_id,\n                github_installation_id,\n                github_account_login,\n                github_account_type,\n                repository_selection,\n                installed_by_user_id,\n                suspended_at,\n                created_at,\n                updated_at\n            FROM github_app_installations\n            WHERE github_account_login = $1\n            \"#,\n            account_login\n        )\n        .fetch_optional(self.pool)\n        .await?;\n\n        Ok(installation)\n    }\n\n    pub async fn get_by_organization(\n        &self,\n        organization_id: Uuid,\n    ) -> Result<Option<GitHubAppInstallation>, GitHubAppDbError> {\n        let installation = sqlx::query_as!(\n            GitHubAppInstallation,\n            r#\"\n            SELECT\n                id,\n                organization_id,\n                github_installation_id,\n                github_account_login,\n                github_account_type,\n                repository_selection,\n                installed_by_user_id,\n                suspended_at,\n                created_at,\n                updated_at\n            FROM github_app_installations\n            WHERE organization_id = $1\n            \"#,\n            organization_id\n        )\n        .fetch_optional(self.pool)\n        .await?;\n\n        Ok(installation)\n    }\n\n    pub async fn delete_by_github_id(\n        &self,\n        github_installation_id: i64,\n    ) -> Result<(), GitHubAppDbError> {\n        sqlx::query!(\n            r#\"\n            DELETE FROM github_app_installations\n            WHERE github_installation_id = $1\n            \"#,\n            github_installation_id\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub async fn delete_by_organization(\n        &self,\n        organization_id: Uuid,\n    ) -> Result<(), GitHubAppDbError> {\n        sqlx::query!(\n            r#\"\n            DELETE FROM github_app_installations\n            WHERE organization_id = $1\n            \"#,\n            organization_id\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub async fn suspend(&self, github_installation_id: i64) -> Result<(), GitHubAppDbError> {\n        sqlx::query!(\n            r#\"\n            UPDATE github_app_installations\n            SET suspended_at = NOW(), updated_at = NOW()\n            WHERE github_installation_id = $1\n            \"#,\n            github_installation_id\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub async fn unsuspend(&self, github_installation_id: i64) -> Result<(), GitHubAppDbError> {\n        sqlx::query!(\n            r#\"\n            UPDATE github_app_installations\n            SET suspended_at = NULL, updated_at = NOW()\n            WHERE github_installation_id = $1\n            \"#,\n            github_installation_id\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub async fn update_repository_selection(\n        &self,\n        github_installation_id: i64,\n        repository_selection: &str,\n    ) -> Result<(), GitHubAppDbError> {\n        sqlx::query!(\n            r#\"\n            UPDATE github_app_installations\n            SET repository_selection = $2, updated_at = NOW()\n            WHERE github_installation_id = $1\n            \"#,\n            github_installation_id,\n            repository_selection\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    // ========== Repositories ==========\n\n    pub async fn sync_repositories(\n        &self,\n        installation_id: Uuid,\n        repos: &[(i64, String)], // (github_repo_id, repo_full_name)\n    ) -> Result<(), GitHubAppDbError> {\n        // Get current repo IDs to preserve review_enabled settings\n        let current_repo_ids: Vec<i64> = repos.iter().map(|(id, _)| *id).collect();\n\n        // Delete repos that are no longer in the list\n        sqlx::query!(\n            r#\"\n            DELETE FROM github_app_repositories\n            WHERE installation_id = $1 AND NOT (github_repo_id = ANY($2))\n            \"#,\n            installation_id,\n            &current_repo_ids\n        )\n        .execute(self.pool)\n        .await?;\n\n        // Upsert repos, preserving review_enabled for existing ones\n        for (github_repo_id, repo_full_name) in repos {\n            sqlx::query!(\n                r#\"\n                INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name, review_enabled)\n                VALUES ($1, $2, $3, true)\n                ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET\n                    repo_full_name = EXCLUDED.repo_full_name\n                \"#,\n                installation_id,\n                github_repo_id,\n                repo_full_name\n            )\n            .execute(self.pool)\n            .await?;\n        }\n\n        Ok(())\n    }\n\n    pub async fn get_repositories(\n        &self,\n        installation_id: Uuid,\n    ) -> Result<Vec<GitHubAppRepository>, GitHubAppDbError> {\n        let repos = sqlx::query_as!(\n            GitHubAppRepository,\n            r#\"\n            SELECT\n                id,\n                installation_id,\n                github_repo_id,\n                repo_full_name,\n                review_enabled,\n                created_at\n            FROM github_app_repositories\n            WHERE installation_id = $1\n            ORDER BY repo_full_name\n            \"#,\n            installation_id\n        )\n        .fetch_all(self.pool)\n        .await?;\n\n        Ok(repos)\n    }\n\n    pub async fn add_repositories(\n        &self,\n        installation_id: Uuid,\n        repos: &[(i64, String)],\n    ) -> Result<(), GitHubAppDbError> {\n        for (github_repo_id, repo_full_name) in repos {\n            sqlx::query!(\n                r#\"\n                INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name)\n                VALUES ($1, $2, $3)\n                ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET\n                    repo_full_name = EXCLUDED.repo_full_name\n                \"#,\n                installation_id,\n                github_repo_id,\n                repo_full_name\n            )\n            .execute(self.pool)\n            .await?;\n        }\n\n        Ok(())\n    }\n\n    pub async fn remove_repositories(\n        &self,\n        installation_id: Uuid,\n        github_repo_ids: &[i64],\n    ) -> Result<(), GitHubAppDbError> {\n        sqlx::query!(\n            r#\"\n            DELETE FROM github_app_repositories\n            WHERE installation_id = $1 AND github_repo_id = ANY($2)\n            \"#,\n            installation_id,\n            github_repo_ids\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    /// Update the review_enabled flag for a repository\n    pub async fn update_repository_review_enabled(\n        &self,\n        repo_id: Uuid,\n        installation_id: Uuid,\n        enabled: bool,\n    ) -> Result<GitHubAppRepository, GitHubAppDbError> {\n        let repo = sqlx::query_as!(\n            GitHubAppRepository,\n            r#\"\n            UPDATE github_app_repositories\n            SET review_enabled = $3\n            WHERE id = $1 AND installation_id = $2\n            RETURNING\n                id,\n                installation_id,\n                github_repo_id,\n                repo_full_name,\n                review_enabled,\n                created_at\n            \"#,\n            repo_id,\n            installation_id,\n            enabled\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(GitHubAppDbError::NotFound)?;\n\n        Ok(repo)\n    }\n\n    /// Check if a repository has reviews enabled (for webhook filtering)\n    pub async fn is_repository_review_enabled(\n        &self,\n        installation_id: Uuid,\n        github_repo_id: i64,\n    ) -> Result<bool, GitHubAppDbError> {\n        let result = sqlx::query_scalar!(\n            r#\"\n            SELECT review_enabled\n            FROM github_app_repositories\n            WHERE installation_id = $1 AND github_repo_id = $2\n            \"#,\n            installation_id,\n            github_repo_id\n        )\n        .fetch_optional(self.pool)\n        .await?;\n\n        // If repo not found, default to true (for \"all repos\" mode where repo might not be in DB yet)\n        Ok(result.unwrap_or(true))\n    }\n\n    /// Bulk update review_enabled for all repositories in an installation\n    pub async fn set_all_repositories_review_enabled(\n        &self,\n        installation_id: Uuid,\n        enabled: bool,\n    ) -> Result<u64, GitHubAppDbError> {\n        let result = sqlx::query!(\n            r#\"\n            UPDATE github_app_repositories\n            SET review_enabled = $2\n            WHERE installation_id = $1\n            \"#,\n            installation_id,\n            enabled\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(result.rows_affected())\n    }\n\n    // ========== Pending Installations ==========\n\n    pub async fn create_pending(\n        &self,\n        organization_id: Uuid,\n        user_id: Uuid,\n        state_token: &str,\n        expires_at: DateTime<Utc>,\n    ) -> Result<PendingInstallation, GitHubAppDbError> {\n        // Delete any existing pending installation for this org\n        sqlx::query!(\n            r#\"\n            DELETE FROM github_app_pending_installations\n            WHERE organization_id = $1\n            \"#,\n            organization_id\n        )\n        .execute(self.pool)\n        .await?;\n\n        let pending = sqlx::query_as!(\n            PendingInstallation,\n            r#\"\n            INSERT INTO github_app_pending_installations (organization_id, user_id, state_token, expires_at)\n            VALUES ($1, $2, $3, $4)\n            RETURNING\n                id,\n                organization_id,\n                user_id,\n                state_token,\n                expires_at,\n                created_at\n            \"#,\n            organization_id,\n            user_id,\n            state_token,\n            expires_at\n        )\n        .fetch_one(self.pool)\n        .await?;\n\n        Ok(pending)\n    }\n\n    pub async fn get_pending_by_state(\n        &self,\n        state_token: &str,\n    ) -> Result<Option<PendingInstallation>, GitHubAppDbError> {\n        let pending = sqlx::query_as!(\n            PendingInstallation,\n            r#\"\n            SELECT\n                id,\n                organization_id,\n                user_id,\n                state_token,\n                expires_at,\n                created_at\n            FROM github_app_pending_installations\n            WHERE state_token = $1 AND expires_at > NOW()\n            \"#,\n            state_token\n        )\n        .fetch_optional(self.pool)\n        .await?;\n\n        Ok(pending)\n    }\n\n    pub async fn delete_pending(&self, state_token: &str) -> Result<(), GitHubAppDbError> {\n        sqlx::query!(\n            r#\"\n            DELETE FROM github_app_pending_installations\n            WHERE state_token = $1\n            \"#,\n            state_token\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub async fn cleanup_expired_pending(&self) -> Result<u64, GitHubAppDbError> {\n        let result = sqlx::query!(\n            r#\"\n            DELETE FROM github_app_pending_installations\n            WHERE expires_at < NOW()\n            \"#\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(result.rows_affected())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/hosts.rs",
    "content": "use api_types::{RelayHost, RelaySession};\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\nuse super::identity_errors::IdentityError;\n\npub struct HostRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> HostRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn assert_host_access(\n        &self,\n        host_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<(), IdentityError> {\n        let row = sqlx::query!(\n            r#\"\n            SELECT EXISTS (\n                SELECT 1\n                FROM hosts h\n                LEFT JOIN organization_member_metadata om\n                    ON om.organization_id = h.shared_with_organization_id\n                    AND om.user_id = $2\n                WHERE h.id = $1\n                  AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL)\n            ) AS \"allowed!\"\n            \"#,\n            host_id,\n            user_id\n        )\n        .fetch_one(self.pool)\n        .await?;\n\n        if row.allowed {\n            Ok(())\n        } else {\n            Err(IdentityError::PermissionDenied)\n        }\n    }\n\n    pub async fn create_session(\n        &self,\n        host_id: Uuid,\n        request_user_id: Uuid,\n        expires_at: DateTime<Utc>,\n    ) -> Result<RelaySession, sqlx::Error> {\n        sqlx::query_as!(\n            RelaySession,\n            r#\"\n            INSERT INTO relay_sessions (host_id, request_user_id, state, expires_at)\n            VALUES ($1, $2, 'requested', $3)\n            RETURNING\n                id              AS \"id!: Uuid\",\n                host_id         AS \"host_id!: Uuid\",\n                request_user_id AS \"request_user_id!: Uuid\",\n                state,\n                created_at,\n                expires_at,\n                claimed_at,\n                ended_at\n            \"#,\n            host_id,\n            request_user_id,\n            expires_at\n        )\n        .fetch_one(self.pool)\n        .await\n    }\n\n    pub async fn list_accessible_hosts(\n        &self,\n        user_id: Uuid,\n    ) -> Result<Vec<RelayHost>, sqlx::Error> {\n        sqlx::query_as::<_, RelayHost>(\n            r#\"\n            SELECT\n                h.id,\n                h.owner_user_id,\n                h.name,\n                h.status,\n                h.last_seen_at,\n                h.agent_version,\n                h.created_at,\n                h.updated_at,\n                CASE\n                    WHEN h.owner_user_id = $1 THEN 'owner'\n                    ELSE 'member'\n                END AS access_role\n            FROM hosts h\n            LEFT JOIN organization_member_metadata om\n                ON om.organization_id = h.shared_with_organization_id\n                AND om.user_id = $1\n            WHERE h.owner_user_id = $1 OR om.user_id IS NOT NULL\n            ORDER BY h.updated_at DESC\n            \"#,\n        )\n        .bind(user_id)\n        .fetch_all(self.pool)\n        .await\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/identity_errors.rs",
    "content": "use thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum IdentityError {\n    #[error(\"identity record not found\")]\n    NotFound,\n    #[error(\"permission denied: admin access required\")]\n    PermissionDenied,\n    #[error(\"invitation error: {0}\")]\n    InvitationError(String),\n    #[error(\"cannot delete organization: {0}\")]\n    CannotDeleteOrganization(String),\n    #[error(\"organization conflict: {0}\")]\n    OrganizationConflict(String),\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[cfg(feature = \"vk-billing\")]\n    #[error(\"billing error: {0}\")]\n    Billing(crate::billing::BillingCheckError),\n}\n\n#[cfg(feature = \"vk-billing\")]\nimpl From<crate::billing::BillingCheckError> for IdentityError {\n    fn from(err: crate::billing::BillingCheckError) -> Self {\n        Self::Billing(err)\n    }\n}\n\n#[cfg(not(feature = \"vk-billing\"))]\nimpl From<crate::billing::BillingCheckError> for IdentityError {\n    fn from(err: crate::billing::BillingCheckError) -> Self {\n        match err {}\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/invitations.rs",
    "content": "pub use api_types::InvitationStatus;\nuse api_types::MemberRole;\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\nuse super::{\n    identity_errors::IdentityError,\n    organization_members::{add_member, assert_admin},\n    organizations::{Organization, OrganizationRepository, is_personal_org},\n};\nuse crate::{billing::BillingService, db::organization_members::is_member};\n\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]\npub struct Invitation {\n    pub id: Uuid,\n    pub organization_id: Uuid,\n    pub invited_by_user_id: Option<Uuid>,\n    pub email: String,\n    pub role: MemberRole,\n    pub status: InvitationStatus,\n    pub token: String,\n    pub expires_at: DateTime<Utc>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\npub struct InvitationRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> InvitationRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn create_invitation(\n        &self,\n        organization_id: Uuid,\n        invited_by_user_id: Uuid,\n        email: &str,\n        role: MemberRole,\n        expires_at: DateTime<Utc>,\n        token: &str,\n    ) -> Result<Invitation, IdentityError> {\n        assert_admin(self.pool, organization_id, invited_by_user_id).await?;\n\n        if OrganizationRepository::new(self.pool)\n            .is_personal(organization_id)\n            .await?\n        {\n            return Err(IdentityError::InvitationError(\n                \"Cannot invite members to a personal organization\".to_string(),\n            ));\n        }\n\n        let invitation = sqlx::query_as!(\n            Invitation,\n            r#\"\n            INSERT INTO organization_invitations (\n                organization_id, invited_by_user_id, email, role, token, expires_at\n            )\n            VALUES ($1, $2, $3, $4, $5, $6)\n            RETURNING\n                id AS \"id!\",\n                organization_id AS \"organization_id!: Uuid\",\n                invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n                email AS \"email!\",\n                role AS \"role!: MemberRole\",\n                status AS \"status!: InvitationStatus\",\n                token AS \"token!\",\n                expires_at AS \"expires_at!\",\n                created_at AS \"created_at!\",\n                updated_at AS \"updated_at!\"\n            \"#,\n            organization_id,\n            invited_by_user_id,\n            email,\n            role as MemberRole,\n            token,\n            expires_at\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(|e| {\n            if let Some(db_err) = e.as_database_error()\n                && db_err.is_unique_violation()\n            {\n                return IdentityError::InvitationError(\n                    \"A pending invitation already exists for this email\".to_string(),\n                );\n            }\n            IdentityError::from(e)\n        })?;\n\n        Ok(invitation)\n    }\n\n    pub async fn list_invitations(\n        &self,\n        organization_id: Uuid,\n        requesting_user_id: Uuid,\n    ) -> Result<Vec<Invitation>, IdentityError> {\n        assert_admin(self.pool, organization_id, requesting_user_id).await?;\n\n        if OrganizationRepository::new(self.pool)\n            .is_personal(organization_id)\n            .await?\n        {\n            return Err(IdentityError::InvitationError(\n                \"Personal organizations do not support invitations\".to_string(),\n            ));\n        }\n\n        let invitations = sqlx::query_as!(\n            Invitation,\n            r#\"\n            SELECT\n                id AS \"id!\",\n                organization_id AS \"organization_id!: Uuid\",\n                invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n                email AS \"email!\",\n                role AS \"role!: MemberRole\",\n                status AS \"status!: InvitationStatus\",\n                token AS \"token!\",\n                expires_at AS \"expires_at!\",\n                created_at AS \"created_at!\",\n                updated_at AS \"updated_at!\"\n            FROM organization_invitations\n            WHERE organization_id = $1\n            ORDER BY created_at DESC\n            \"#,\n            organization_id\n        )\n        .fetch_all(self.pool)\n        .await?;\n\n        Ok(invitations)\n    }\n\n    pub async fn get_invitation_by_token(&self, token: &str) -> Result<Invitation, IdentityError> {\n        sqlx::query_as!(\n            Invitation,\n            r#\"\n            SELECT\n                id AS \"id!\",\n                organization_id AS \"organization_id!: Uuid\",\n                invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n                email AS \"email!\",\n                role AS \"role!: MemberRole\",\n                status AS \"status!: InvitationStatus\",\n                token AS \"token!\",\n                expires_at AS \"expires_at!\",\n                created_at AS \"created_at!\",\n                updated_at AS \"updated_at!\"\n            FROM organization_invitations\n            WHERE token = $1\n            \"#,\n            token\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(IdentityError::NotFound)\n    }\n\n    pub async fn revoke_invitation(\n        &self,\n        organization_id: Uuid,\n        invitation_id: Uuid,\n        requesting_user_id: Uuid,\n    ) -> Result<(), IdentityError> {\n        assert_admin(self.pool, organization_id, requesting_user_id).await?;\n\n        let result = sqlx::query!(\n            r#\"\n            DELETE FROM organization_invitations\n            WHERE id = $1 AND organization_id = $2\n            \"#,\n            invitation_id,\n            organization_id\n        )\n        .execute(self.pool)\n        .await?;\n\n        if result.rows_affected() == 0 {\n            return Err(IdentityError::NotFound);\n        }\n\n        Ok(())\n    }\n\n    pub async fn accept_invitation(\n        &self,\n        token: &str,\n        user_id: Uuid,\n        billing: &BillingService,\n    ) -> Result<(Organization, MemberRole), IdentityError> {\n        let mut tx = super::begin_tx(self.pool).await?;\n\n        let invitation = sqlx::query_as!(\n            Invitation,\n            r#\"\n            SELECT\n                id AS \"id!\",\n                organization_id AS \"organization_id!: Uuid\",\n                invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n                email AS \"email!\",\n                role AS \"role!: MemberRole\",\n                status AS \"status!: InvitationStatus\",\n                token AS \"token!\",\n                expires_at AS \"expires_at!\",\n                created_at AS \"created_at!\",\n                updated_at AS \"updated_at!\"\n            FROM organization_invitations\n            WHERE token = $1 AND status = 'pending'\n            FOR UPDATE\n            \"#,\n            token\n        )\n        .fetch_optional(&mut *tx)\n        .await?\n        .ok_or_else(|| {\n            IdentityError::InvitationError(\"Invitation not found or already used\".to_string())\n        })?;\n\n        if is_personal_org(&mut *tx, invitation.organization_id).await? {\n            tx.rollback().await?;\n            return Err(IdentityError::InvitationError(\n                \"Cannot accept invitations for a personal organization\".to_string(),\n            ));\n        }\n\n        if invitation.expires_at < Utc::now() {\n            sqlx::query!(\n                r#\"\n                UPDATE organization_invitations\n                SET status = 'expired'\n                WHERE id = $1\n                \"#,\n                invitation.id\n            )\n            .execute(&mut *tx)\n            .await?;\n\n            tx.commit().await?;\n            return Err(IdentityError::InvitationError(\n                \"Invitation has expired\".to_string(),\n            ));\n        }\n\n        if is_member(&mut *tx, invitation.organization_id, user_id).await? {\n            tx.rollback().await?;\n            return Err(IdentityError::InvitationError(\n                \"You are already a member of the organization\".to_string(),\n            ));\n        }\n\n        billing.can_add_member(invitation.organization_id).await?;\n\n        add_member(\n            &mut *tx,\n            invitation.organization_id,\n            user_id,\n            invitation.role,\n        )\n        .await?;\n\n        sqlx::query!(\n            r#\"\n            UPDATE organization_invitations\n            SET status = 'accepted'\n            WHERE id = $1\n            \"#,\n            invitation.id\n        )\n        .execute(&mut *tx)\n        .await?;\n\n        tx.commit().await?;\n\n        billing\n            .on_member_count_changed(invitation.organization_id)\n            .await;\n\n        let organization = OrganizationRepository::new(self.pool)\n            .fetch_organization(invitation.organization_id)\n            .await?;\n\n        Ok((organization, invitation.role))\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/issue_assignees.rs",
    "content": "use api_types::{DeleteResponse, IssueAssignee, MutationResponse};\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n#[derive(Debug, Error)]\npub enum IssueAssigneeError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct IssueAssigneeRepository;\n\nimpl IssueAssigneeRepository {\n    pub async fn find_by_id(\n        pool: &PgPool,\n        id: Uuid,\n    ) -> Result<Option<IssueAssignee>, IssueAssigneeError> {\n        let record = sqlx::query_as!(\n            IssueAssignee,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                assigned_at AS \"assigned_at!: DateTime<Utc>\"\n            FROM issue_assignees\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn list_by_issue(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<IssueAssignee>, IssueAssigneeError> {\n        let records = sqlx::query_as!(\n            IssueAssignee,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                assigned_at AS \"assigned_at!: DateTime<Utc>\"\n            FROM issue_assignees\n            WHERE issue_id = $1\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn list_by_project(\n        pool: &PgPool,\n        project_id: Uuid,\n    ) -> Result<Vec<IssueAssignee>, IssueAssigneeError> {\n        let records = sqlx::query_as!(\n            IssueAssignee,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                assigned_at AS \"assigned_at!: DateTime<Utc>\"\n            FROM issue_assignees\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n            \"#,\n            project_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        issue_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<MutationResponse<IssueAssignee>, IssueAssigneeError> {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            IssueAssignee,\n            r#\"\n            INSERT INTO issue_assignees (id, issue_id, user_id)\n            VALUES ($1, $2, $3)\n            RETURNING\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                assigned_at AS \"assigned_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            issue_id,\n            user_id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, IssueAssigneeError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM issue_assignees WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/issue_comment_reactions.rs",
    "content": "use api_types::{DeleteResponse, IssueCommentReaction, MutationResponse};\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n#[derive(Debug, Error)]\npub enum IssueCommentReactionError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct IssueCommentReactionRepository;\n\nimpl IssueCommentReactionRepository {\n    pub async fn find_by_id(\n        pool: &PgPool,\n        id: Uuid,\n    ) -> Result<Option<IssueCommentReaction>, IssueCommentReactionError> {\n        let record = sqlx::query_as!(\n            IssueCommentReaction,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                comment_id  AS \"comment_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                emoji       AS \"emoji!\",\n                created_at  AS \"created_at!: DateTime<Utc>\"\n            FROM issue_comment_reactions\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn list_by_issue(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<IssueCommentReaction>, IssueCommentReactionError> {\n        let records = sqlx::query_as!(\n            IssueCommentReaction,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                comment_id  AS \"comment_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                emoji       AS \"emoji!\",\n                created_at  AS \"created_at!: DateTime<Utc>\"\n            FROM issue_comment_reactions\n            WHERE comment_id IN (SELECT id FROM issue_comments WHERE issue_id = $1)\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        comment_id: Uuid,\n        user_id: Uuid,\n        emoji: String,\n    ) -> Result<MutationResponse<IssueCommentReaction>, IssueCommentReactionError> {\n        let mut tx = super::begin_tx(pool).await?;\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let created_at = Utc::now();\n        let data = sqlx::query_as!(\n            IssueCommentReaction,\n            r#\"\n            INSERT INTO issue_comment_reactions (id, comment_id, user_id, emoji, created_at)\n            VALUES ($1, $2, $3, $4, $5)\n            RETURNING\n                id          AS \"id!: Uuid\",\n                comment_id  AS \"comment_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                emoji       AS \"emoji!\",\n                created_at  AS \"created_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            comment_id,\n            user_id,\n            emoji,\n            created_at\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    /// Update an issue comment reaction with partial fields. Uses COALESCE to preserve existing values\n    /// when None is provided.\n    pub async fn update(\n        pool: &PgPool,\n        id: Uuid,\n        emoji: Option<String>,\n    ) -> Result<MutationResponse<IssueCommentReaction>, IssueCommentReactionError> {\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            IssueCommentReaction,\n            r#\"\n            UPDATE issue_comment_reactions\n            SET\n                emoji = COALESCE($1, emoji)\n            WHERE id = $2\n            RETURNING\n                id          AS \"id!: Uuid\",\n                comment_id  AS \"comment_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                emoji       AS \"emoji!\",\n                created_at  AS \"created_at!: DateTime<Utc>\"\n            \"#,\n            emoji,\n            id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(\n        pool: &PgPool,\n        id: Uuid,\n    ) -> Result<DeleteResponse, IssueCommentReactionError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM issue_comment_reactions WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n\n    pub async fn list_by_comment(\n        pool: &PgPool,\n        comment_id: Uuid,\n    ) -> Result<Vec<IssueCommentReaction>, IssueCommentReactionError> {\n        let records = sqlx::query_as!(\n            IssueCommentReaction,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                comment_id  AS \"comment_id!: Uuid\",\n                user_id     AS \"user_id!: Uuid\",\n                emoji       AS \"emoji!\",\n                created_at  AS \"created_at!: DateTime<Utc>\"\n            FROM issue_comment_reactions\n            WHERE comment_id = $1\n            \"#,\n            comment_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/issue_comments.rs",
    "content": "use api_types::{DeleteResponse, IssueComment, MutationResponse};\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n#[derive(Debug, Error)]\npub enum IssueCommentError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct IssueCommentRepository;\n\nimpl IssueCommentRepository {\n    pub async fn find_by_id(\n        pool: &PgPool,\n        id: Uuid,\n    ) -> Result<Option<IssueComment>, IssueCommentError> {\n        let record = sqlx::query_as!(\n            IssueComment,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                author_id   AS \"author_id: Uuid\",\n                parent_id   AS \"parent_id: Uuid\",\n                message     AS \"message!\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                updated_at  AS \"updated_at!: DateTime<Utc>\"\n            FROM issue_comments\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        issue_id: Uuid,\n        author_id: Uuid,\n        parent_id: Option<Uuid>,\n        message: String,\n    ) -> Result<MutationResponse<IssueComment>, IssueCommentError> {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let now = Utc::now();\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            IssueComment,\n            r#\"\n            INSERT INTO issue_comments (id, issue_id, author_id, parent_id, message, created_at, updated_at)\n            VALUES ($1, $2, $3, $4, $5, $6, $7)\n            RETURNING\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                author_id   AS \"author_id: Uuid\",\n                parent_id   AS \"parent_id: Uuid\",\n                message     AS \"message!\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                updated_at  AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            issue_id,\n            author_id,\n            parent_id,\n            message,\n            now,\n            now\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    /// Update an issue comment with partial fields. Uses COALESCE to preserve existing values\n    /// when None is provided.\n    pub async fn update(\n        pool: &PgPool,\n        id: Uuid,\n        message: Option<String>,\n    ) -> Result<MutationResponse<IssueComment>, IssueCommentError> {\n        let updated_at = Utc::now();\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            IssueComment,\n            r#\"\n            UPDATE issue_comments\n            SET\n                message = COALESCE($1, message),\n                updated_at = $2\n            WHERE id = $3\n            RETURNING\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                author_id   AS \"author_id: Uuid\",\n                parent_id   AS \"parent_id: Uuid\",\n                message     AS \"message!\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                updated_at  AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            message,\n            updated_at,\n            id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, IssueCommentError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM issue_comments WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n\n    pub async fn list_by_issue(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<IssueComment>, IssueCommentError> {\n        let records = sqlx::query_as!(\n            IssueComment,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                issue_id    AS \"issue_id!: Uuid\",\n                author_id   AS \"author_id: Uuid\",\n                parent_id   AS \"parent_id: Uuid\",\n                message     AS \"message!\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                updated_at  AS \"updated_at!: DateTime<Utc>\"\n            FROM issue_comments\n            WHERE issue_id = $1\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/issue_followers.rs",
    "content": "use api_types::{DeleteResponse, IssueFollower, MutationResponse};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n#[derive(Debug, Error)]\npub enum IssueFollowerError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct IssueFollowerRepository;\n\nimpl IssueFollowerRepository {\n    pub async fn find_by_id(\n        pool: &PgPool,\n        id: Uuid,\n    ) -> Result<Option<IssueFollower>, IssueFollowerError> {\n        let record = sqlx::query_as!(\n            IssueFollower,\n            r#\"\n            SELECT\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                user_id  AS \"user_id!: Uuid\"\n            FROM issue_followers\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn list_by_issue(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<IssueFollower>, IssueFollowerError> {\n        let records = sqlx::query_as!(\n            IssueFollower,\n            r#\"\n            SELECT\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                user_id  AS \"user_id!: Uuid\"\n            FROM issue_followers\n            WHERE issue_id = $1\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn list_by_project(\n        pool: &PgPool,\n        project_id: Uuid,\n    ) -> Result<Vec<IssueFollower>, IssueFollowerError> {\n        let records = sqlx::query_as!(\n            IssueFollower,\n            r#\"\n            SELECT\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                user_id  AS \"user_id!: Uuid\"\n            FROM issue_followers\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n            \"#,\n            project_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        issue_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<MutationResponse<IssueFollower>, IssueFollowerError> {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            IssueFollower,\n            r#\"\n            INSERT INTO issue_followers (id, issue_id, user_id)\n            VALUES ($1, $2, $3)\n            RETURNING\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                user_id  AS \"user_id!: Uuid\"\n            \"#,\n            id,\n            issue_id,\n            user_id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, IssueFollowerError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM issue_followers WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/issue_relationships.rs",
    "content": "use api_types::{DeleteResponse, IssueRelationship, IssueRelationshipType, MutationResponse};\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n#[derive(Debug, Error)]\npub enum IssueRelationshipError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct IssueRelationshipRepository;\n\nimpl IssueRelationshipRepository {\n    pub async fn find_by_id(\n        pool: &PgPool,\n        id: Uuid,\n    ) -> Result<Option<IssueRelationship>, IssueRelationshipError> {\n        let record = sqlx::query_as!(\n            IssueRelationship,\n            r#\"\n            SELECT\n                id                AS \"id!: Uuid\",\n                issue_id          AS \"issue_id!: Uuid\",\n                related_issue_id  AS \"related_issue_id!: Uuid\",\n                relationship_type AS \"relationship_type!: IssueRelationshipType\",\n                created_at        AS \"created_at!: DateTime<Utc>\"\n            FROM issue_relationships\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn list_by_issue(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<IssueRelationship>, IssueRelationshipError> {\n        let records = sqlx::query_as!(\n            IssueRelationship,\n            r#\"\n            SELECT\n                id                AS \"id!: Uuid\",\n                issue_id          AS \"issue_id!: Uuid\",\n                related_issue_id  AS \"related_issue_id!: Uuid\",\n                relationship_type AS \"relationship_type!: IssueRelationshipType\",\n                created_at        AS \"created_at!: DateTime<Utc>\"\n            FROM issue_relationships\n            WHERE issue_id = $1\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn list_by_project(\n        pool: &PgPool,\n        project_id: Uuid,\n    ) -> Result<Vec<IssueRelationship>, IssueRelationshipError> {\n        let records = sqlx::query_as!(\n            IssueRelationship,\n            r#\"\n            SELECT\n                id                AS \"id!: Uuid\",\n                issue_id          AS \"issue_id!: Uuid\",\n                related_issue_id  AS \"related_issue_id!: Uuid\",\n                relationship_type AS \"relationship_type!: IssueRelationshipType\",\n                created_at        AS \"created_at!: DateTime<Utc>\"\n            FROM issue_relationships\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n            \"#,\n            project_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        issue_id: Uuid,\n        related_issue_id: Uuid,\n        relationship_type: IssueRelationshipType,\n    ) -> Result<MutationResponse<IssueRelationship>, IssueRelationshipError> {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            IssueRelationship,\n            r#\"\n            INSERT INTO issue_relationships (id, issue_id, related_issue_id, relationship_type)\n            VALUES ($1, $2, $3, $4)\n            RETURNING\n                id                AS \"id!: Uuid\",\n                issue_id          AS \"issue_id!: Uuid\",\n                related_issue_id  AS \"related_issue_id!: Uuid\",\n                relationship_type AS \"relationship_type!: IssueRelationshipType\",\n                created_at        AS \"created_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            issue_id,\n            related_issue_id,\n            relationship_type as IssueRelationshipType\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, IssueRelationshipError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM issue_relationships WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/issue_tags.rs",
    "content": "use api_types::{DeleteResponse, IssueTag, MutationResponse};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n#[derive(Debug, Error)]\npub enum IssueTagError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct IssueTagRepository;\n\nimpl IssueTagRepository {\n    pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<IssueTag>, IssueTagError> {\n        let record = sqlx::query_as!(\n            IssueTag,\n            r#\"\n            SELECT\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                tag_id   AS \"tag_id!: Uuid\"\n            FROM issue_tags\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn list_by_issue(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<IssueTag>, IssueTagError> {\n        let records = sqlx::query_as!(\n            IssueTag,\n            r#\"\n            SELECT\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                tag_id   AS \"tag_id!: Uuid\"\n            FROM issue_tags\n            WHERE issue_id = $1\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn list_by_project(\n        pool: &PgPool,\n        project_id: Uuid,\n    ) -> Result<Vec<IssueTag>, IssueTagError> {\n        let records = sqlx::query_as!(\n            IssueTag,\n            r#\"\n            SELECT\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                tag_id   AS \"tag_id!: Uuid\"\n            FROM issue_tags\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n            \"#,\n            project_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        issue_id: Uuid,\n        tag_id: Uuid,\n    ) -> Result<MutationResponse<IssueTag>, IssueTagError> {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            IssueTag,\n            r#\"\n            INSERT INTO issue_tags (id, issue_id, tag_id)\n            VALUES ($1, $2, $3)\n            RETURNING\n                id       AS \"id!: Uuid\",\n                issue_id AS \"issue_id!: Uuid\",\n                tag_id   AS \"tag_id!: Uuid\"\n            \"#,\n            id,\n            issue_id,\n            tag_id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, IssueTagError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM issue_tags WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/issues.rs",
    "content": "use api_types::{\n    DeleteResponse, Issue, IssuePriority, IssueSortField, ListIssuesResponse, MutationResponse,\n    PullRequestStatus, SearchIssuesRequest, SortDirection,\n};\nuse chrono::{DateTime, Utc};\nuse serde_json::Value;\nuse sqlx::{Executor, PgPool, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::{\n    get_txid, issue_assignees::IssueAssigneeRepository, project_statuses::ProjectStatusRepository,\n    pull_requests::PullRequestRepository, workspaces::WorkspaceRepository,\n};\n\n#[derive(Debug, Error)]\npub enum IssueError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n    #[error(\"pull request error: {0}\")]\n    PullRequest(#[from] super::pull_requests::PullRequestError),\n    #[error(\"project status error: {0}\")]\n    ProjectStatus(#[from] super::project_statuses::ProjectStatusError),\n    #[error(\"workspace error: {0}\")]\n    Workspace(#[from] super::workspaces::WorkspaceError),\n    #[error(\"issue assignee error: {0}\")]\n    IssueAssignee(#[from] super::issue_assignees::IssueAssigneeError),\n}\n\npub struct IssueRepository;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum IssueWorkflowSignal {\n    ReviewStarted,\n    WorkMerged,\n}\n\nimpl IssueRepository {\n    fn sort_field_key(sort_field: IssueSortField) -> &'static str {\n        match sort_field {\n            IssueSortField::SortOrder => \"sort_order\",\n            IssueSortField::Priority => \"priority\",\n            IssueSortField::CreatedAt => \"created_at\",\n            IssueSortField::UpdatedAt => \"updated_at\",\n            IssueSortField::Title => \"title\",\n        }\n    }\n\n    fn sort_direction_key(sort_direction: SortDirection) -> &'static str {\n        match sort_direction {\n            SortDirection::Asc => \"asc\",\n            SortDirection::Desc => \"desc\",\n        }\n    }\n\n    fn escape_like_pattern(value: &str) -> String {\n        value\n            .replace('\\\\', r\"\\\\\")\n            .replace('%', r\"\\%\")\n            .replace('_', r\"\\_\")\n    }\n\n    pub async fn search(\n        pool: &PgPool,\n        query: &SearchIssuesRequest,\n    ) -> Result<ListIssuesResponse, IssueError> {\n        let status_ids = query.status_ids.as_deref();\n        let search_pattern = query\n            .search\n            .as_deref()\n            .map(Self::escape_like_pattern)\n            .map(|search| format!(\"%{search}%\"));\n        let simple_id = query.simple_id.as_deref().map(Self::escape_like_pattern);\n        let tag_ids = query.tag_ids.as_deref();\n        let sort_field =\n            Self::sort_field_key(query.sort_field.unwrap_or(IssueSortField::SortOrder));\n        let sort_direction =\n            Self::sort_direction_key(query.sort_direction.unwrap_or(SortDirection::Asc));\n        let offset = query.offset.unwrap_or(0).max(0) as usize;\n        let query_limit = query\n            .limit\n            .map(|value| value.max(0) as i64)\n            .unwrap_or(i64::MAX);\n\n        let total_count = sqlx::query_scalar!(\n            r#\"\n            SELECT COUNT(*)::BIGINT\n            FROM issues i\n            WHERE i.project_id = $1\n              AND ($2::uuid IS NULL OR i.status_id = $2)\n              AND ($3::uuid[] IS NULL OR i.status_id = ANY($3))\n              AND ($4::issue_priority IS NULL OR i.priority = $4)\n              AND ($5::uuid IS NULL OR i.parent_issue_id = $5)\n              AND (\n                  $6::text IS NULL\n                  OR i.title ILIKE $6 ESCAPE '\\'\n                  OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\\'\n              )\n              AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\\')\n              AND (\n                  $8::uuid IS NULL\n                  OR EXISTS (\n                      SELECT 1\n                      FROM issue_assignees ia\n                      WHERE ia.issue_id = i.id AND ia.user_id = $8\n                  )\n              )\n              AND (\n                  $9::uuid IS NULL\n                  OR EXISTS (\n                      SELECT 1\n                      FROM issue_tags it\n                      WHERE it.issue_id = i.id AND it.tag_id = $9\n                  )\n              )\n              AND (\n                  $10::uuid[] IS NULL\n                  OR EXISTS (\n                      SELECT 1\n                      FROM issue_tags it\n                      WHERE it.issue_id = i.id AND it.tag_id = ANY($10)\n                  )\n              )\n            \"#,\n            query.project_id,\n            query.status_id,\n            status_ids,\n            query.priority as Option<IssuePriority>,\n            query.parent_issue_id,\n            search_pattern.as_deref(),\n            simple_id.as_deref(),\n            query.assignee_user_id,\n            query.tag_id,\n            tag_ids,\n        )\n        .fetch_one(pool)\n        .await?\n        .unwrap_or(0) as usize;\n\n        let issues = sqlx::query_as!(\n            Issue,\n            r#\"\n            SELECT\n                i.id                  AS \"id!: Uuid\",\n                i.project_id          AS \"project_id!: Uuid\",\n                i.issue_number        AS \"issue_number!\",\n                i.simple_id           AS \"simple_id!\",\n                i.status_id           AS \"status_id!: Uuid\",\n                i.title               AS \"title!\",\n                i.description         AS \"description?\",\n                i.priority            AS \"priority: IssuePriority\",\n                i.start_date          AS \"start_date?: DateTime<Utc>\",\n                i.target_date         AS \"target_date?: DateTime<Utc>\",\n                i.completed_at        AS \"completed_at?: DateTime<Utc>\",\n                i.sort_order          AS \"sort_order!\",\n                i.parent_issue_id     AS \"parent_issue_id?: Uuid\",\n                i.parent_issue_sort_order AS \"parent_issue_sort_order?\",\n                i.extension_metadata  AS \"extension_metadata!: Value\",\n                i.creator_user_id     AS \"creator_user_id?: Uuid\",\n                i.created_at          AS \"created_at!: DateTime<Utc>\",\n                i.updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM issues i\n            LEFT JOIN project_statuses ps ON ps.id = i.status_id\n            WHERE i.project_id = $1\n              AND ($2::uuid IS NULL OR i.status_id = $2)\n              AND ($3::uuid[] IS NULL OR i.status_id = ANY($3))\n              AND ($4::issue_priority IS NULL OR i.priority = $4)\n              AND ($5::uuid IS NULL OR i.parent_issue_id = $5)\n              AND (\n                  $6::text IS NULL\n                  OR i.title ILIKE $6 ESCAPE '\\'\n                  OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\\'\n              )\n              AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\\')\n              AND (\n                  $8::uuid IS NULL\n                  OR EXISTS (\n                      SELECT 1\n                      FROM issue_assignees ia\n                      WHERE ia.issue_id = i.id AND ia.user_id = $8\n                  )\n              )\n              AND (\n                  $9::uuid IS NULL\n                  OR EXISTS (\n                      SELECT 1\n                      FROM issue_tags it\n                      WHERE it.issue_id = i.id AND it.tag_id = $9\n                  )\n              )\n              AND (\n                  $10::uuid[] IS NULL\n                  OR EXISTS (\n                      SELECT 1\n                      FROM issue_tags it\n                      WHERE it.issue_id = i.id AND it.tag_id = ANY($10)\n                  )\n              )\n            ORDER BY\n                CASE\n                    WHEN $11 = 'sort_order' AND $12 = 'asc' THEN ps.sort_order\n                END ASC NULLS LAST,\n                CASE\n                    WHEN $11 = 'sort_order' AND $12 = 'desc' THEN ps.sort_order\n                END DESC NULLS LAST,\n                CASE\n                    WHEN $11 = 'sort_order' AND $12 = 'asc' THEN i.sort_order\n                END ASC NULLS LAST,\n                CASE\n                    WHEN $11 = 'sort_order' AND $12 = 'desc' THEN i.sort_order\n                END DESC NULLS LAST,\n                CASE\n                    WHEN $11 = 'priority' AND $12 = 'asc' THEN i.priority\n                END ASC NULLS LAST,\n                CASE\n                    WHEN $11 = 'priority' AND $12 = 'desc' THEN i.priority\n                END DESC NULLS FIRST,\n                CASE\n                    WHEN $11 = 'created_at' AND $12 = 'asc' THEN i.created_at\n                END ASC NULLS LAST,\n                CASE\n                    WHEN $11 = 'created_at' AND $12 = 'desc' THEN i.created_at\n                END DESC NULLS LAST,\n                CASE\n                    WHEN $11 = 'updated_at' AND $12 = 'asc' THEN i.updated_at\n                END ASC NULLS LAST,\n                CASE\n                    WHEN $11 = 'updated_at' AND $12 = 'desc' THEN i.updated_at\n                END DESC NULLS LAST,\n                CASE\n                    WHEN $11 = 'title' AND $12 = 'asc' THEN i.title\n                END ASC NULLS LAST,\n                CASE\n                    WHEN $11 = 'title' AND $12 = 'desc' THEN i.title\n                END DESC NULLS LAST,\n                i.issue_number ASC\n            LIMIT $13\n            OFFSET $14\n            \"#,\n            query.project_id,\n            query.status_id,\n            status_ids,\n            query.priority as Option<IssuePriority>,\n            query.parent_issue_id,\n            search_pattern.as_deref(),\n            simple_id.as_deref(),\n            query.assignee_user_id,\n            query.tag_id,\n            tag_ids,\n            sort_field,\n            sort_direction,\n            query_limit,\n            offset as i64,\n        )\n        .fetch_all(pool)\n        .await?;\n\n        let limit = query.limit.unwrap_or(issues.len() as i32).max(0) as usize;\n\n        Ok(ListIssuesResponse {\n            issues,\n            total_count,\n            limit,\n            offset,\n        })\n    }\n\n    pub async fn find_by_id<'e, E>(executor: E, id: Uuid) -> Result<Option<Issue>, IssueError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            Issue,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                issue_number        AS \"issue_number!\",\n                simple_id           AS \"simple_id!\",\n                status_id           AS \"status_id!: Uuid\",\n                title               AS \"title!\",\n                description         AS \"description?\",\n                priority            AS \"priority: IssuePriority\",\n                start_date          AS \"start_date?: DateTime<Utc>\",\n                target_date         AS \"target_date?: DateTime<Utc>\",\n                completed_at        AS \"completed_at?: DateTime<Utc>\",\n                sort_order          AS \"sort_order!\",\n                parent_issue_id     AS \"parent_issue_id?: Uuid\",\n                parent_issue_sort_order AS \"parent_issue_sort_order?\",\n                extension_metadata  AS \"extension_metadata!: Value\",\n                creator_user_id     AS \"creator_user_id?: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM issues\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn organization_id(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Option<Uuid>, IssueError> {\n        let record = sqlx::query_scalar!(\n            r#\"\n            SELECT p.organization_id\n            FROM issues i\n            INNER JOIN projects p ON p.id = i.project_id\n            WHERE i.id = $1\n            \"#,\n            issue_id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        project_id: Uuid,\n        status_id: Uuid,\n        title: String,\n        description: Option<String>,\n        priority: Option<IssuePriority>,\n        start_date: Option<DateTime<Utc>>,\n        target_date: Option<DateTime<Utc>>,\n        completed_at: Option<DateTime<Utc>>,\n        sort_order: f64,\n        parent_issue_id: Option<Uuid>,\n        parent_issue_sort_order: Option<f64>,\n        extension_metadata: Value,\n        creator_user_id: Uuid,\n    ) -> Result<MutationResponse<Issue>, IssueError> {\n        let mut tx = super::begin_tx(pool).await?;\n\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        // Note: issue_number and simple_id are auto-generated by the DB trigger\n        let data = sqlx::query_as!(\n            Issue,\n            r#\"\n            INSERT INTO issues (\n                id, project_id, status_id, title, description, priority,\n                start_date, target_date, completed_at, sort_order,\n                parent_issue_id, parent_issue_sort_order, extension_metadata,\n                creator_user_id\n            )\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                issue_number        AS \"issue_number!\",\n                simple_id           AS \"simple_id!\",\n                status_id           AS \"status_id!: Uuid\",\n                title               AS \"title!\",\n                description         AS \"description?\",\n                priority            AS \"priority: IssuePriority\",\n                start_date          AS \"start_date?: DateTime<Utc>\",\n                target_date         AS \"target_date?: DateTime<Utc>\",\n                completed_at        AS \"completed_at?: DateTime<Utc>\",\n                sort_order          AS \"sort_order!\",\n                parent_issue_id     AS \"parent_issue_id?: Uuid\",\n                parent_issue_sort_order AS \"parent_issue_sort_order?\",\n                extension_metadata  AS \"extension_metadata!: Value\",\n                creator_user_id     AS \"creator_user_id?: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            project_id,\n            status_id,\n            title,\n            description,\n            priority as Option<IssuePriority>,\n            start_date,\n            target_date,\n            completed_at,\n            sort_order,\n            parent_issue_id,\n            parent_issue_sort_order,\n            extension_metadata,\n            creator_user_id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    /// Update an issue with partial fields.\n    ///\n    /// For non-nullable fields, uses COALESCE to preserve existing values when None is provided.\n    /// For nullable fields (Option<Option<T>>), uses CASE to distinguish between:\n    /// - None: don't update the field\n    /// - Some(None): set the field to NULL\n    /// - Some(Some(value)): set the field to the value\n    #[allow(clippy::too_many_arguments)]\n    pub async fn update<'e, E>(\n        executor: E,\n        id: Uuid,\n        status_id: Option<Uuid>,\n        title: Option<String>,\n        description: Option<Option<String>>,\n        priority: Option<Option<IssuePriority>>,\n        start_date: Option<Option<DateTime<Utc>>>,\n        target_date: Option<Option<DateTime<Utc>>>,\n        completed_at: Option<Option<DateTime<Utc>>>,\n        sort_order: Option<f64>,\n        parent_issue_id: Option<Option<Uuid>>,\n        parent_issue_sort_order: Option<Option<f64>>,\n        extension_metadata: Option<Value>,\n    ) -> Result<Issue, IssueError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        // For nullable fields, extract boolean flags and flattened values\n        // This preserves the distinction between \"don't update\" and \"set to NULL\"\n        let update_description = description.is_some();\n        let description_value = description.flatten();\n        let update_priority = priority.is_some();\n        let priority_value = priority.flatten();\n        let update_start_date = start_date.is_some();\n        let start_date_value = start_date.flatten();\n        let update_target_date = target_date.is_some();\n        let target_date_value = target_date.flatten();\n        let update_completed_at = completed_at.is_some();\n        let completed_at_value = completed_at.flatten();\n        let update_parent_issue_id = parent_issue_id.is_some();\n        let parent_issue_id_value = parent_issue_id.flatten();\n        let update_parent_issue_sort_order = parent_issue_sort_order.is_some();\n        let parent_issue_sort_order_value = parent_issue_sort_order.flatten();\n\n        let data = sqlx::query_as!(\n            Issue,\n            r#\"\n            UPDATE issues\n            SET\n                status_id = COALESCE($1, status_id),\n                title = COALESCE($2, title),\n                description = CASE WHEN $3 THEN $4 ELSE description END,\n                priority = CASE WHEN $5 THEN $6 ELSE priority END,\n                start_date = CASE WHEN $7 THEN $8 ELSE start_date END,\n                target_date = CASE WHEN $9 THEN $10 ELSE target_date END,\n                completed_at = CASE WHEN $11 THEN $12 ELSE completed_at END,\n                sort_order = COALESCE($13, sort_order),\n                parent_issue_id = CASE WHEN $14 THEN $15 ELSE parent_issue_id END,\n                parent_issue_sort_order = CASE WHEN $16 THEN $17 ELSE parent_issue_sort_order END,\n                extension_metadata = COALESCE($18, extension_metadata),\n                updated_at = NOW()\n            WHERE id = $19\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                issue_number        AS \"issue_number!\",\n                simple_id           AS \"simple_id!\",\n                status_id           AS \"status_id!: Uuid\",\n                title               AS \"title!\",\n                description         AS \"description?\",\n                priority            AS \"priority: IssuePriority\",\n                start_date          AS \"start_date?: DateTime<Utc>\",\n                target_date         AS \"target_date?: DateTime<Utc>\",\n                completed_at        AS \"completed_at?: DateTime<Utc>\",\n                sort_order          AS \"sort_order!\",\n                parent_issue_id     AS \"parent_issue_id?: Uuid\",\n                parent_issue_sort_order AS \"parent_issue_sort_order?\",\n                extension_metadata  AS \"extension_metadata!: Value\",\n                creator_user_id     AS \"creator_user_id?: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            status_id,\n            title,\n            update_description,\n            description_value,\n            update_priority,\n            priority_value as Option<IssuePriority>,\n            update_start_date,\n            start_date_value,\n            update_target_date,\n            target_date_value,\n            update_completed_at,\n            completed_at_value,\n            sort_order,\n            update_parent_issue_id,\n            parent_issue_id_value,\n            update_parent_issue_sort_order,\n            parent_issue_sort_order_value,\n            extension_metadata,\n            id\n        )\n        .fetch_one(executor)\n        .await?;\n\n        Ok(data)\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, IssueError> {\n        let mut tx = super::begin_tx(pool).await?;\n\n        sqlx::query!(\"DELETE FROM issues WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(DeleteResponse { txid })\n    }\n\n    /// Syncs issue status based on a workflow signal.\n    /// - `ReviewStarted` → move issue to \"In review\"\n    /// - `WorkMerged` → if all linked PRs are merged, move issue to \"Done\"\n    async fn sync_status_from_workflow_signal(\n        pool: &PgPool,\n        issue_id: Uuid,\n        signal: IssueWorkflowSignal,\n    ) -> Result<(), IssueError> {\n        let Some(issue) = Self::find_by_id(pool, issue_id).await? else {\n            return Ok(());\n        };\n\n        let target_status_name = match signal {\n            IssueWorkflowSignal::ReviewStarted => \"In review\",\n            IssueWorkflowSignal::WorkMerged => {\n                let prs = PullRequestRepository::list_by_issue(pool, issue_id).await?;\n                let all_merged = prs.iter().all(|pr| pr.status == PullRequestStatus::Merged);\n                if all_merged {\n                    \"Done\"\n                } else {\n                    return Ok(());\n                }\n            }\n        };\n\n        let Some(target_status) =\n            ProjectStatusRepository::find_by_name(pool, issue.project_id, target_status_name)\n                .await?\n        else {\n            return Ok(());\n        };\n\n        if issue.status_id == target_status.id {\n            return Ok(());\n        }\n\n        Self::update(\n            pool,\n            issue_id,\n            Some(target_status.id),\n            None,\n            None,\n            None,\n            None,\n            None,\n            None,\n            None,\n            None,\n            None,\n            None,\n        )\n        .await?;\n\n        Ok(())\n    }\n\n    /// Syncs issue status based on the current pull-request status.\n    /// - Open PR => move issue to \"In review\"\n    /// - Merged/closed PR => if all linked PRs are merged, move issue to \"Done\"\n    pub async fn sync_status_from_pull_request(\n        pool: &PgPool,\n        issue_id: Uuid,\n        pr_status: PullRequestStatus,\n    ) -> Result<(), IssueError> {\n        let signal = if pr_status == PullRequestStatus::Open {\n            IssueWorkflowSignal::ReviewStarted\n        } else {\n            IssueWorkflowSignal::WorkMerged\n        };\n        Self::sync_status_from_workflow_signal(pool, issue_id, signal).await\n    }\n\n    /// Syncs issue status when a workspace is merged locally without a PR.\n    pub async fn sync_status_from_local_workspace_merge(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<(), IssueError> {\n        Self::sync_status_from_workflow_signal(pool, issue_id, IssueWorkflowSignal::WorkMerged)\n            .await\n    }\n\n    /// Moves an issue to the given target status if its current status is \"Backlog\" or \"To do\".\n    async fn move_to_status_if_pending(\n        pool: &PgPool,\n        issue_id: Uuid,\n        current_status_id: Uuid,\n        target_status_id: Uuid,\n    ) -> Result<(), IssueError> {\n        let Some(current_status) =\n            ProjectStatusRepository::find_by_id(pool, current_status_id).await?\n        else {\n            return Ok(());\n        };\n\n        let name = current_status.name.to_lowercase();\n        if name == \"backlog\" || name == \"to do\" {\n            Self::update(\n                pool,\n                issue_id,\n                Some(target_status_id),\n                None,\n                None,\n                None,\n                None,\n                None,\n                None,\n                None,\n                None,\n                None,\n                None,\n            )\n            .await?;\n        }\n\n        Ok(())\n    }\n\n    /// Syncs issue state when a workspace is created:\n    /// - If this is the first workspace and the issue is in \"Backlog\" or \"To do\", moves to \"In progress\"\n    /// - If sub-issue, also moves parent issue to \"In progress\" if pending\n    /// - If the issue has no assignees, adds the workspace creator as an assignee\n    pub async fn sync_issue_from_workspace_created(\n        pool: &PgPool,\n        issue_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<(), IssueError> {\n        // Status sync: only on first workspace\n        let workspace_count = WorkspaceRepository::count_by_issue_id(pool, issue_id).await?;\n        if workspace_count == 1 {\n            let Some(issue) = Self::find_by_id(pool, issue_id).await? else {\n                return Ok(());\n            };\n\n            let Some(in_progress_status) =\n                ProjectStatusRepository::find_by_name(pool, issue.project_id, \"In progress\")\n                    .await?\n            else {\n                return Ok(());\n            };\n\n            Self::move_to_status_if_pending(pool, issue_id, issue.status_id, in_progress_status.id)\n                .await?;\n\n            // If sub-issue, also move parent issue to \"In progress\"\n            if let Some(parent_issue_id) = issue.parent_issue_id\n                && let Some(parent_issue) = Self::find_by_id(pool, parent_issue_id).await?\n            {\n                Self::move_to_status_if_pending(\n                    pool,\n                    parent_issue_id,\n                    parent_issue.status_id,\n                    in_progress_status.id,\n                )\n                .await?;\n            }\n        }\n\n        // Assignee sync: add creator if no assignees exist\n        let assignees = IssueAssigneeRepository::list_by_issue(pool, issue_id).await?;\n        if assignees.is_empty() {\n            IssueAssigneeRepository::create(pool, None, issue_id, user_id).await?;\n        }\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::IssueRepository;\n\n    #[test]\n    fn escapes_like_pattern_special_characters() {\n        assert_eq!(\n            IssueRepository::escape_like_pattern(r\"100%_done\\ish\"),\n            r\"100\\%\\_done\\\\ish\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/migration.rs",
    "content": "use api_types::{\n    MigrateIssueRequest, MigrateProjectRequest, MigratePullRequestRequest, MigrateWorkspaceRequest,\n    PullRequestStatus,\n};\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::{project_statuses::ProjectStatusRepository, tags::TagRepository};\n\n#[derive(Debug, Error)]\npub enum MigrationError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(\"project status error: {0}\")]\n    ProjectStatus(#[from] super::project_statuses::ProjectStatusError),\n    #[error(\"tag error: {0}\")]\n    Tag(#[from] super::tags::TagError),\n}\n\npub struct MigrationRepository;\n\nimpl MigrationRepository {\n    pub async fn bulk_create_projects(\n        pool: &PgPool,\n        inputs: Vec<MigrateProjectRequest>,\n    ) -> Result<Vec<Uuid>, MigrationError> {\n        if inputs.is_empty() {\n            return Ok(vec![]);\n        }\n\n        let mut tx = super::begin_tx(pool).await?;\n\n        let org_ids: Vec<Uuid> = inputs.iter().map(|i| i.organization_id).collect();\n        let names: Vec<String> = inputs.iter().map(|i| i.name.clone()).collect();\n        let colors: Vec<String> = inputs.iter().map(|i| i.color.clone()).collect();\n        let created_ats: Vec<DateTime<Utc>> = inputs.iter().map(|i| i.created_at).collect();\n\n        let ids = sqlx::query_scalar!(\n            r#\"\n            INSERT INTO projects (id, organization_id, name, color, created_at, updated_at)\n            SELECT gen_random_uuid(), organization_id, name, color, created_at, NOW()\n            FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::timestamptz[])\n                AS t(organization_id, name, color, created_at)\n            RETURNING id\n            \"#,\n            &org_ids,\n            &names,\n            &colors,\n            &created_ats,\n        )\n        .fetch_all(&mut *tx)\n        .await?;\n\n        for id in &ids {\n            TagRepository::create_default_tags(&mut *tx, *id).await?;\n            ProjectStatusRepository::create_default_statuses(&mut *tx, *id).await?;\n        }\n\n        tx.commit().await?;\n\n        Ok(ids)\n    }\n\n    pub async fn bulk_create_issues(\n        pool: &PgPool,\n        inputs: Vec<MigrateIssueRequest>,\n    ) -> Result<Vec<Uuid>, MigrationError> {\n        if inputs.is_empty() {\n            return Ok(vec![]);\n        }\n\n        let project_ids: Vec<Uuid> = inputs.iter().map(|i| i.project_id).collect();\n        let status_names: Vec<String> = inputs.iter().map(|i| i.status_name.clone()).collect();\n        let titles: Vec<String> = inputs.iter().map(|i| i.title.clone()).collect();\n        let descriptions: Vec<Option<String>> =\n            inputs.iter().map(|i| i.description.clone()).collect();\n        let created_ats: Vec<DateTime<Utc>> = inputs.iter().map(|i| i.created_at).collect();\n\n        let ids = sqlx::query_scalar!(\n            r#\"\n            INSERT INTO issues (id, project_id, status_id, title, description, priority, sort_order, extension_metadata, created_at)\n            SELECT\n                gen_random_uuid(),\n                t.project_id,\n                (SELECT id FROM project_statuses ps WHERE ps.project_id = t.project_id AND LOWER(ps.name) = LOWER(t.status_name)),\n                t.title,\n                t.description,\n                NULL,\n                0.0,\n                '{}'::jsonb,\n                t.created_at\n            FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::text[], $5::timestamptz[])\n                AS t(project_id, status_name, title, description, created_at)\n            RETURNING id\n            \"#,\n            &project_ids,\n            &status_names,\n            &titles,\n            &descriptions as &[Option<String>],\n            &created_ats\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(ids)\n    }\n\n    pub async fn bulk_create_pull_requests(\n        pool: &PgPool,\n        inputs: Vec<MigratePullRequestRequest>,\n    ) -> Result<Vec<Uuid>, MigrationError> {\n        if inputs.is_empty() {\n            return Ok(vec![]);\n        }\n\n        let urls: Vec<String> = inputs.iter().map(|i| i.url.clone()).collect();\n        let numbers: Vec<i32> = inputs.iter().map(|i| i.number).collect();\n        let statuses: Vec<PullRequestStatus> =\n            inputs.iter().map(|i| parse_pr_status(&i.status)).collect();\n        let merged_ats: Vec<Option<DateTime<Utc>>> = inputs.iter().map(|i| i.merged_at).collect();\n        let merge_commit_shas: Vec<Option<String>> =\n            inputs.iter().map(|i| i.merge_commit_sha.clone()).collect();\n        let target_branch_names: Vec<String> = inputs\n            .iter()\n            .map(|i| i.target_branch_name.clone())\n            .collect();\n        let issue_ids: Vec<Uuid> = inputs.iter().map(|i| i.issue_id).collect();\n\n        let ids = sqlx::query_scalar!(\n            r#\"\n            INSERT INTO pull_requests (id, url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id)\n            SELECT gen_random_uuid(), url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id\n            FROM UNNEST($1::text[], $2::int[], $3::pull_request_status[], $4::timestamptz[], $5::text[], $6::text[], $7::uuid[])\n                AS t(url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id)\n            RETURNING id\n            \"#,\n            &urls,\n            &numbers,\n            &statuses as &[PullRequestStatus],\n            &merged_ats as &[Option<DateTime<Utc>>],\n            &merge_commit_shas as &[Option<String>],\n            &target_branch_names,\n            &issue_ids\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(ids)\n    }\n\n    pub async fn bulk_create_workspaces(\n        pool: &PgPool,\n        owner_user_id: Uuid,\n        inputs: Vec<MigrateWorkspaceRequest>,\n    ) -> Result<Vec<Uuid>, MigrationError> {\n        if inputs.is_empty() {\n            return Ok(vec![]);\n        }\n\n        let project_ids: Vec<Uuid> = inputs.iter().map(|i| i.project_id).collect();\n        let issue_ids: Vec<Option<Uuid>> = inputs.iter().map(|i| i.issue_id).collect();\n        let local_workspace_ids: Vec<Uuid> = inputs.iter().map(|i| i.local_workspace_id).collect();\n        let archived_values: Vec<bool> = inputs.iter().map(|i| i.archived).collect();\n        let created_ats: Vec<DateTime<Utc>> = inputs.iter().map(|i| i.created_at).collect();\n        let owner_ids: Vec<Uuid> = vec![owner_user_id; inputs.len()];\n\n        let ids = sqlx::query_scalar!(\n            r#\"\n            INSERT INTO workspaces (project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at)\n            SELECT project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at\n            FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[], $4::uuid[], $5::boolean[], $6::timestamptz[])\n                AS t(project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at)\n            RETURNING id\n            \"#,\n            &project_ids,\n            &owner_ids,\n            &issue_ids as &[Option<Uuid>],\n            &local_workspace_ids,\n            &archived_values,\n            &created_ats,\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(ids)\n    }\n}\n\nfn parse_pr_status(s: &str) -> PullRequestStatus {\n    match s.to_lowercase().as_str() {\n        \"merged\" => PullRequestStatus::Merged,\n        \"closed\" => PullRequestStatus::Closed,\n        _ => PullRequestStatus::Open,\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/mod.rs",
    "content": "pub mod attachments;\npub mod auth;\npub mod blobs;\npub mod digest;\npub mod electric_publications;\npub mod github_app;\npub mod hosts;\npub mod identity_errors;\npub mod invitations;\npub mod issue_assignees;\npub mod issue_comment_reactions;\npub mod issue_comments;\npub mod issue_followers;\npub mod issue_relationships;\npub mod issue_tags;\npub mod issues;\npub mod migration;\npub mod notifications;\npub mod oauth;\npub mod oauth_accounts;\npub mod organization_members;\npub mod organizations;\npub mod pending_uploads;\npub mod project_notification_preferences;\npub mod project_statuses;\npub mod projects;\npub mod pull_requests;\npub mod reviews;\npub mod tags;\npub mod types;\npub mod users;\npub mod workspaces;\n\nuse sqlx::{\n    Executor, PgPool, Postgres, Transaction,\n    migrate::MigrateError,\n    postgres::{PgConnectOptions, PgPoolOptions},\n};\nuse uuid::Uuid;\n\npub(crate) type Tx<'a> = Transaction<'a, Postgres>;\n\n/// Per-request context propagated to database transactions via a tokio task-local.\n/// The auth middleware initialises the scope; `begin_tx` reads it.\n#[derive(Clone)]\npub struct TxContext {\n    pub user_id: Uuid,\n    pub request_id: String,\n}\n\ntokio::task_local! {\n    pub static TX_CONTEXT: Option<TxContext>;\n}\n\n/// Begin a transaction and tag it with the current request's request ID.\n/// If no context is set (e.g. background jobs), the transaction is untagged.\npub async fn begin_tx(pool: &PgPool) -> Result<Tx<'_>, sqlx::Error> {\n    let mut tx = pool.begin().await?;\n    let ctx = TX_CONTEXT.try_with(|c| c.clone()).ok().flatten();\n    if let Some(ctx) = ctx {\n        let name = format!(\"vk r:{}\", ctx.request_id.replace('-', \"\"));\n        sqlx::query(\"SELECT set_config('application_name', $1, true)\")\n            .bind(&name)\n            .execute(&mut *tx)\n            .await?;\n    }\n    Ok(tx)\n}\n\n/// Get the current transaction ID from Postgres.\n/// Must be called within an active transaction.\n/// Uses text conversion to avoid xid8->bigint cast issues in some PG versions.\npub async fn get_txid<'e, E>(executor: E) -> Result<i64, sqlx::Error>\nwhere\n    E: Executor<'e, Database = Postgres>,\n{\n    let row: (i64,) = sqlx::query_as(\"SELECT pg_current_xact_id()::text::bigint\")\n        .fetch_one(executor)\n        .await?;\n    Ok(row.0)\n}\n\npub(crate) async fn migrate(pool: &PgPool) -> Result<(), MigrateError> {\n    sqlx::migrate!(\"./migrations\").run(pool).await\n}\n\npub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {\n    let options: PgConnectOptions = database_url\n        .parse::<PgConnectOptions>()?\n        .application_name(\"vibe-kanban-remote\");\n\n    PgPoolOptions::new()\n        .max_connections(10)\n        .connect_with(options)\n        .await\n}\n\npub(crate) async fn ensure_electric_role_password(\n    pool: &PgPool,\n    password: &str,\n) -> Result<(), sqlx::Error> {\n    if password.is_empty() {\n        return Ok(());\n    }\n\n    // PostgreSQL doesn't support parameter binding for ALTER ROLE PASSWORD\n    // We need to escape the password properly and embed it directly in the SQL\n    let escaped_password = password.replace(\"'\", \"''\");\n    let sql = format!(\"ALTER ROLE electric_sync WITH PASSWORD '{escaped_password}'\");\n\n    sqlx::query(&sql).execute(pool).await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/remote/src/db/notifications.rs",
    "content": "use api_types::{Notification, NotificationPayload, NotificationType};\nuse chrono::{DateTime, Utc};\nuse sqlx::{Executor, FromRow, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum NotificationError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\n#[derive(Debug, FromRow)]\nstruct NotificationRow {\n    id: Uuid,\n    organization_id: Uuid,\n    user_id: Uuid,\n    notification_type: NotificationType,\n    payload: sqlx::types::Json<NotificationPayload>,\n    issue_id: Option<Uuid>,\n    comment_id: Option<Uuid>,\n    seen: bool,\n    dismissed_at: Option<DateTime<Utc>>,\n    created_at: DateTime<Utc>,\n}\n\nimpl From<NotificationRow> for Notification {\n    fn from(row: NotificationRow) -> Self {\n        Self {\n            id: row.id,\n            organization_id: row.organization_id,\n            user_id: row.user_id,\n            notification_type: row.notification_type,\n            payload: row.payload.0,\n            issue_id: row.issue_id,\n            comment_id: row.comment_id,\n            seen: row.seen,\n            dismissed_at: row.dismissed_at,\n            created_at: row.created_at,\n        }\n    }\n}\n\npub struct NotificationRepository;\n\nimpl NotificationRepository {\n    pub async fn find_by_id<'e, E>(\n        executor: E,\n        id: Uuid,\n    ) -> Result<Option<Notification>, NotificationError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            NotificationRow,\n            r#\"\n            SELECT\n                id,\n                organization_id,\n                user_id,\n                notification_type as \"notification_type!: NotificationType\",\n                payload as \"payload!: sqlx::types::Json<NotificationPayload>\",\n                issue_id,\n                comment_id,\n                seen,\n                dismissed_at,\n                created_at\n            FROM notifications\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record.map(Into::into))\n    }\n\n    pub async fn create<'e, E>(\n        executor: E,\n        organization_id: Uuid,\n        user_id: Uuid,\n        notification_type: NotificationType,\n        payload: NotificationPayload,\n        issue_id: Option<Uuid>,\n        comment_id: Option<Uuid>,\n    ) -> Result<Notification, NotificationError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let id = Uuid::new_v4();\n        let now = Utc::now();\n        let payload = sqlx::types::Json(payload);\n        let record = sqlx::query_as!(\n            NotificationRow,\n            r#\"\n            INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at)\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n            RETURNING\n                id,\n                organization_id,\n                user_id,\n                notification_type as \"notification_type!: NotificationType\",\n                payload as \"payload!: sqlx::types::Json<NotificationPayload>\",\n                issue_id,\n                comment_id,\n                seen,\n                dismissed_at,\n                created_at\n            \"#,\n            id,\n            organization_id,\n            user_id,\n            notification_type as NotificationType,\n            payload as sqlx::types::Json<NotificationPayload>,\n            issue_id,\n            comment_id,\n            now\n        )\n        .fetch_one(executor)\n        .await?;\n\n        Ok(record.into())\n    }\n\n    pub async fn list_by_user<'e, E>(\n        executor: E,\n        user_id: Uuid,\n        include_dismissed: bool,\n    ) -> Result<Vec<Notification>, NotificationError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let records = if include_dismissed {\n            sqlx::query_as!(\n                NotificationRow,\n                r#\"\n                SELECT\n                    id,\n                    organization_id,\n                    user_id,\n                    notification_type as \"notification_type!: NotificationType\",\n                    payload as \"payload!: sqlx::types::Json<NotificationPayload>\",\n                    issue_id,\n                    comment_id,\n                    seen,\n                    dismissed_at,\n                    created_at\n                FROM notifications\n                WHERE user_id = $1\n                ORDER BY created_at DESC\n                \"#,\n                user_id\n            )\n            .fetch_all(executor)\n            .await?\n        } else {\n            sqlx::query_as!(\n                NotificationRow,\n                r#\"\n                SELECT\n                    id,\n                    organization_id,\n                    user_id,\n                    notification_type as \"notification_type!: NotificationType\",\n                    payload as \"payload!: sqlx::types::Json<NotificationPayload>\",\n                    issue_id,\n                    comment_id,\n                    seen,\n                    dismissed_at,\n                    created_at\n                FROM notifications\n                WHERE user_id = $1 AND dismissed_at IS NULL\n                ORDER BY created_at DESC\n                \"#,\n                user_id\n            )\n            .fetch_all(executor)\n            .await?\n        };\n\n        Ok(records.into_iter().map(Into::into).collect())\n    }\n\n    pub async fn update<'e, E>(\n        executor: E,\n        id: Uuid,\n        seen: Option<bool>,\n    ) -> Result<Notification, NotificationError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            NotificationRow,\n            r#\"\n            UPDATE notifications\n            SET seen = COALESCE($1, seen),\n                dismissed_at = CASE\n                    WHEN $1 = true AND dismissed_at IS NULL THEN NOW()\n                    ELSE dismissed_at\n                END\n            WHERE id = $2\n            RETURNING\n                id,\n                organization_id,\n                user_id,\n                notification_type as \"notification_type!: NotificationType\",\n                payload as \"payload!: sqlx::types::Json<NotificationPayload>\",\n                issue_id,\n                comment_id,\n                seen,\n                dismissed_at,\n                created_at\n            \"#,\n            seen,\n            id\n        )\n        .fetch_one(executor)\n        .await?;\n\n        Ok(record.into())\n    }\n\n    pub async fn upsert_recent<'e, E>(\n        executor: E,\n        organization_id: Uuid,\n        user_id: Uuid,\n        notification_type: NotificationType,\n        payload: NotificationPayload,\n        issue_id: Option<Uuid>,\n        comment_id: Option<Uuid>,\n    ) -> Result<Notification, NotificationError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let id = Uuid::new_v4();\n        let now = Utc::now();\n        let payload = sqlx::types::Json(payload);\n        let record: NotificationRow = sqlx::query_as!(\n            NotificationRow,\n            r#\"\n            WITH existing AS (\n                SELECT id FROM notifications\n                WHERE user_id = $3\n                  AND notification_type = $4\n                  AND issue_id IS NOT DISTINCT FROM $6\n                  AND comment_id IS NOT DISTINCT FROM $7\n                  AND created_at > NOW() - INTERVAL '1 minute'\n                ORDER BY created_at DESC\n                LIMIT 1\n            ),\n            updated AS (\n                UPDATE notifications\n                SET payload = $5,\n                    seen = FALSE,\n                    dismissed_at = NULL,\n                    created_at = $8\n                WHERE id = (SELECT id FROM existing)\n                RETURNING\n                    id,\n                    organization_id,\n                    user_id,\n                    notification_type,\n                    payload,\n                    issue_id,\n                    comment_id,\n                    seen,\n                    dismissed_at,\n                    created_at\n            ),\n            inserted AS (\n                INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at)\n                SELECT $1, $2, $3, $4, $5, $6, $7, $8\n                WHERE NOT EXISTS (SELECT 1 FROM existing)\n                RETURNING\n                    id,\n                    organization_id,\n                    user_id,\n                    notification_type,\n                    payload,\n                    issue_id,\n                    comment_id,\n                    seen,\n                    dismissed_at,\n                    created_at\n            )\n            SELECT\n                id as \"id!\",\n                organization_id as \"organization_id!\",\n                user_id as \"user_id!\",\n                notification_type as \"notification_type!: NotificationType\",\n                payload as \"payload!: sqlx::types::Json<NotificationPayload>\",\n                issue_id,\n                comment_id,\n                seen as \"seen!\",\n                dismissed_at,\n                created_at as \"created_at!\"\n            FROM updated\n            UNION ALL\n            SELECT\n                id as \"id!\",\n                organization_id as \"organization_id!\",\n                user_id as \"user_id!\",\n                notification_type as \"notification_type!: NotificationType\",\n                payload as \"payload!: sqlx::types::Json<NotificationPayload>\",\n                issue_id,\n                comment_id,\n                seen as \"seen!\",\n                dismissed_at,\n                created_at as \"created_at!\"\n            FROM inserted\n            \"#,\n            id,\n            organization_id,\n            user_id,\n            notification_type as NotificationType,\n            payload as sqlx::types::Json<NotificationPayload>,\n            issue_id,\n            comment_id,\n            now\n        )\n        .fetch_one(executor)\n        .await?;\n\n        Ok(record.into())\n    }\n\n    pub async fn delete<'e, E>(executor: E, id: Uuid) -> Result<(), NotificationError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        sqlx::query!(\"DELETE FROM notifications WHERE id = $1\", id)\n            .execute(executor)\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/oauth.rs",
    "content": "use std::str::FromStr;\n\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum AuthorizationStatus {\n    Pending,\n    Authorized,\n    Redeemed,\n    Error,\n    Expired,\n}\n\nimpl AuthorizationStatus {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            Self::Pending => \"pending\",\n            Self::Authorized => \"authorized\",\n            Self::Redeemed => \"redeemed\",\n            Self::Error => \"error\",\n            Self::Expired => \"expired\",\n        }\n    }\n}\n\nimpl FromStr for AuthorizationStatus {\n    type Err = ();\n\n    fn from_str(input: &str) -> Result<Self, Self::Err> {\n        match input {\n            \"pending\" => Ok(Self::Pending),\n            \"authorized\" => Ok(Self::Authorized),\n            \"redeemed\" => Ok(Self::Redeemed),\n            \"error\" => Ok(Self::Error),\n            \"expired\" => Ok(Self::Expired),\n            _ => Err(()),\n        }\n    }\n}\n\n#[derive(Debug, Error)]\npub enum OAuthHandoffError {\n    #[error(\"oauth handoff not found\")]\n    NotFound,\n    #[error(\"oauth handoff is not authorized\")]\n    NotAuthorized,\n    #[error(\"oauth handoff already redeemed or not in authorized state\")]\n    AlreadyRedeemed,\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct OAuthHandoff {\n    pub id: Uuid,\n    pub provider: String,\n    pub state: String,\n    pub return_to: String,\n    pub app_challenge: String,\n    pub app_code_hash: Option<String>,\n    pub status: String,\n    pub error_code: Option<String>,\n    pub expires_at: DateTime<Utc>,\n    pub authorized_at: Option<DateTime<Utc>>,\n    pub redeemed_at: Option<DateTime<Utc>>,\n    pub user_id: Option<Uuid>,\n    pub session_id: Option<Uuid>,\n    pub encrypted_provider_tokens: Option<String>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\nimpl OAuthHandoff {\n    pub fn status(&self) -> Option<AuthorizationStatus> {\n        AuthorizationStatus::from_str(&self.status).ok()\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct CreateOAuthHandoff<'a> {\n    pub provider: &'a str,\n    pub state: &'a str,\n    pub return_to: &'a str,\n    pub app_challenge: &'a str,\n    pub expires_at: DateTime<Utc>,\n}\n\npub struct OAuthHandoffRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> OAuthHandoffRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn create(\n        &self,\n        data: CreateOAuthHandoff<'_>,\n    ) -> Result<OAuthHandoff, OAuthHandoffError> {\n        sqlx::query_as!(\n            OAuthHandoff,\n            r#\"\n            INSERT INTO oauth_handoffs (\n                provider,\n                state,\n                return_to,\n                app_challenge,\n                expires_at\n            )\n            VALUES ($1, $2, $3, $4, $5)\n            RETURNING\n                id                          AS \"id!\",\n                provider                    AS \"provider!\",\n                state                       AS \"state!\",\n                return_to                   AS \"return_to!\",\n                app_challenge               AS \"app_challenge!\",\n                app_code_hash               AS \"app_code_hash?\",\n                status                      AS \"status!\",\n                error_code                  AS \"error_code?\",\n                expires_at                  AS \"expires_at!\",\n                authorized_at               AS \"authorized_at?\",\n                redeemed_at                 AS \"redeemed_at?\",\n                user_id                     AS \"user_id?\",\n                session_id                  AS \"session_id?\",\n                encrypted_provider_tokens   AS \"encrypted_provider_tokens?\",\n                created_at                  AS \"created_at!\",\n                updated_at                  AS \"updated_at!\"\n            \"#,\n            data.provider,\n            data.state,\n            data.return_to,\n            data.app_challenge,\n            data.expires_at,\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(OAuthHandoffError::from)\n    }\n\n    pub async fn get(&self, id: Uuid) -> Result<OAuthHandoff, OAuthHandoffError> {\n        sqlx::query_as!(\n            OAuthHandoff,\n            r#\"\n            SELECT\n                id              AS \"id!\",\n                provider        AS \"provider!\",\n                state           AS \"state!\",\n                return_to       AS \"return_to!\",\n                app_challenge   AS \"app_challenge!\",\n                app_code_hash   AS \"app_code_hash?\",\n                status          AS \"status!\",\n                error_code      AS \"error_code?\",\n                expires_at      AS \"expires_at!\",\n                authorized_at   AS \"authorized_at?\",\n                redeemed_at     AS \"redeemed_at?\",\n                user_id         AS \"user_id?\",\n                session_id                  AS \"session_id?\",\n                encrypted_provider_tokens   AS \"encrypted_provider_tokens?\",\n                created_at      AS \"created_at!\",\n                updated_at      AS \"updated_at!\"\n            FROM oauth_handoffs\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(OAuthHandoffError::NotFound)\n    }\n\n    pub async fn get_by_state(&self, state: &str) -> Result<OAuthHandoff, OAuthHandoffError> {\n        sqlx::query_as!(\n            OAuthHandoff,\n            r#\"\n            SELECT\n                id              AS \"id!\",\n                provider        AS \"provider!\",\n                state           AS \"state!\",\n                return_to       AS \"return_to!\",\n                app_challenge   AS \"app_challenge!\",\n                app_code_hash   AS \"app_code_hash?\",\n                status          AS \"status!\",\n                error_code      AS \"error_code?\",\n                expires_at      AS \"expires_at!\",\n                authorized_at   AS \"authorized_at?\",\n                redeemed_at     AS \"redeemed_at?\",\n                user_id         AS \"user_id?\",\n                session_id                  AS \"session_id?\",\n                encrypted_provider_tokens   AS \"encrypted_provider_tokens?\",\n                created_at      AS \"created_at!\",\n                updated_at      AS \"updated_at!\"\n            FROM oauth_handoffs\n            WHERE state = $1\n            \"#,\n            state\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(OAuthHandoffError::NotFound)\n    }\n\n    pub async fn set_status(\n        &self,\n        id: Uuid,\n        status: AuthorizationStatus,\n        error_code: Option<&str>,\n    ) -> Result<(), OAuthHandoffError> {\n        sqlx::query!(\n            r#\"\n            UPDATE oauth_handoffs\n            SET\n                status = $2,\n                error_code = $3\n            WHERE id = $1\n            \"#,\n            id,\n            status.as_str(),\n            error_code\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn mark_authorized(\n        &self,\n        id: Uuid,\n        user_id: Uuid,\n        session_id: Uuid,\n        app_code_hash: &str,\n        encrypted_provider_tokens: Option<String>,\n    ) -> Result<(), OAuthHandoffError> {\n        sqlx::query!(\n            r#\"\n            UPDATE oauth_handoffs\n            SET\n                status = 'authorized',\n                error_code = NULL,\n                user_id = $2,\n                session_id = $3,\n                app_code_hash = $4,\n                encrypted_provider_tokens = $5,\n                authorized_at = NOW()\n            WHERE id = $1\n            \"#,\n            id,\n            user_id,\n            session_id,\n            app_code_hash,\n            encrypted_provider_tokens\n        )\n        .execute(self.pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn mark_redeemed(&self, id: Uuid) -> Result<(), OAuthHandoffError> {\n        let result = sqlx::query!(\n            r#\"\n            UPDATE oauth_handoffs\n            SET\n                status = 'redeemed',\n                encrypted_provider_tokens = NULL,\n                redeemed_at = NOW()\n            WHERE id = $1\n              AND status = 'authorized'\n            \"#,\n            id\n        )\n        .execute(self.pool)\n        .await?;\n\n        if result.rows_affected() == 0 {\n            return Err(OAuthHandoffError::AlreadyRedeemed);\n        }\n\n        Ok(())\n    }\n\n    pub async fn ensure_redeemable(&self, id: Uuid) -> Result<(), OAuthHandoffError> {\n        let handoff = self.get(id).await?;\n\n        match handoff.status() {\n            Some(AuthorizationStatus::Authorized) => Ok(()),\n            Some(AuthorizationStatus::Pending) => Err(OAuthHandoffError::NotAuthorized),\n            _ => Err(OAuthHandoffError::AlreadyRedeemed),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/oauth_accounts.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum OAuthAccountError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct OAuthAccount {\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub provider: String,\n    pub provider_user_id: String,\n    pub email: Option<String>,\n    pub username: Option<String>,\n    pub display_name: Option<String>,\n    pub avatar_url: Option<String>,\n    pub encrypted_provider_tokens: Option<String>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone)]\npub struct OAuthAccountInsert<'a> {\n    pub user_id: Uuid,\n    pub provider: &'a str,\n    pub provider_user_id: &'a str,\n    pub email: Option<&'a str>,\n    pub username: Option<&'a str>,\n    pub display_name: Option<&'a str>,\n    pub avatar_url: Option<&'a str>,\n    pub encrypted_provider_tokens: Option<&'a str>,\n}\n\npub struct OAuthAccountRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> OAuthAccountRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn get_by_provider_user(\n        &self,\n        provider: &str,\n        provider_user_id: &str,\n    ) -> Result<Option<OAuthAccount>, OAuthAccountError> {\n        sqlx::query_as!(\n            OAuthAccount,\n            r#\"\n            SELECT\n                id                AS \"id!: Uuid\",\n                user_id           AS \"user_id!: Uuid\",\n                provider          AS \"provider!\",\n                provider_user_id  AS \"provider_user_id!\",\n                email             AS \"email?\",\n                username          AS \"username?\",\n                display_name      AS \"display_name?\",\n                avatar_url        AS \"avatar_url?\",\n                encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n                created_at        AS \"created_at!\",\n                updated_at        AS \"updated_at!\"\n            FROM oauth_accounts\n            WHERE provider = $1\n              AND provider_user_id = $2\n            \"#,\n            provider,\n            provider_user_id\n        )\n        .fetch_optional(self.pool)\n        .await\n        .map_err(OAuthAccountError::from)\n    }\n\n    pub async fn get_by_user_provider(\n        &self,\n        user_id: Uuid,\n        provider: &str,\n    ) -> Result<Option<OAuthAccount>, OAuthAccountError> {\n        sqlx::query_as!(\n            OAuthAccount,\n            r#\"\n            SELECT\n                id                AS \"id!: Uuid\",\n                user_id           AS \"user_id!: Uuid\",\n                provider          AS \"provider!\",\n                provider_user_id  AS \"provider_user_id!\",\n                email             AS \"email?\",\n                username          AS \"username?\",\n                display_name      AS \"display_name?\",\n                avatar_url        AS \"avatar_url?\",\n                encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n                created_at        AS \"created_at!\",\n                updated_at        AS \"updated_at!\"\n            FROM oauth_accounts\n            WHERE user_id = $1\n              AND provider = $2\n            LIMIT 1\n            \"#,\n            user_id,\n            provider,\n        )\n        .fetch_optional(self.pool)\n        .await\n        .map_err(OAuthAccountError::from)\n    }\n\n    pub async fn list_by_user(\n        &self,\n        user_id: Uuid,\n    ) -> Result<Vec<OAuthAccount>, OAuthAccountError> {\n        sqlx::query_as!(\n            OAuthAccount,\n            r#\"\n            SELECT\n                id                AS \"id!: Uuid\",\n                user_id           AS \"user_id!: Uuid\",\n                provider          AS \"provider!\",\n                provider_user_id  AS \"provider_user_id!\",\n                email             AS \"email?\",\n                username          AS \"username?\",\n                display_name      AS \"display_name?\",\n                avatar_url        AS \"avatar_url?\",\n                encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n                created_at        AS \"created_at!\",\n                updated_at        AS \"updated_at!\"\n            FROM oauth_accounts\n            WHERE user_id = $1\n            ORDER BY provider\n            \"#,\n            user_id\n        )\n        .fetch_all(self.pool)\n        .await\n        .map_err(OAuthAccountError::from)\n    }\n\n    pub async fn upsert(\n        &self,\n        account: OAuthAccountInsert<'_>,\n    ) -> Result<OAuthAccount, OAuthAccountError> {\n        sqlx::query_as!(\n            OAuthAccount,\n            r#\"\n            INSERT INTO oauth_accounts (\n                user_id,\n                provider,\n                provider_user_id,\n                email,\n                username,\n                display_name,\n                avatar_url,\n                encrypted_provider_tokens\n            )\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n            ON CONFLICT (provider, provider_user_id) DO UPDATE\n            SET\n                email = EXCLUDED.email,\n                username = EXCLUDED.username,\n                display_name = EXCLUDED.display_name,\n                avatar_url = EXCLUDED.avatar_url,\n                encrypted_provider_tokens = COALESCE(\n                    EXCLUDED.encrypted_provider_tokens,\n                    oauth_accounts.encrypted_provider_tokens\n                )\n            RETURNING\n                id                AS \"id!: Uuid\",\n                user_id           AS \"user_id!: Uuid\",\n                provider          AS \"provider!\",\n                provider_user_id  AS \"provider_user_id!\",\n                email             AS \"email?\",\n                username          AS \"username?\",\n                display_name      AS \"display_name?\",\n                avatar_url        AS \"avatar_url?\",\n                encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n                created_at        AS \"created_at!\",\n                updated_at        AS \"updated_at!\"\n            \"#,\n            account.user_id,\n            account.provider,\n            account.provider_user_id,\n            account.email,\n            account.username,\n            account.display_name,\n            account.avatar_url,\n            account.encrypted_provider_tokens\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(OAuthAccountError::from)\n    }\n\n    pub async fn update_encrypted_provider_tokens(\n        &self,\n        user_id: Uuid,\n        provider: &str,\n        encrypted_provider_tokens: &str,\n    ) -> Result<(), OAuthAccountError> {\n        sqlx::query!(\n            r#\"\n            UPDATE oauth_accounts\n            SET encrypted_provider_tokens = $3\n            WHERE user_id = $1\n              AND provider = $2\n            \"#,\n            user_id,\n            provider,\n            encrypted_provider_tokens,\n        )\n        .execute(self.pool)\n        .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/organization_members.rs",
    "content": "use api_types::MemberRole;\nuse sqlx::{Executor, PgPool, Postgres};\nuse uuid::Uuid;\n\nuse super::identity_errors::IdentityError;\n\npub(super) async fn add_member<'a, E>(\n    executor: E,\n    organization_id: Uuid,\n    user_id: Uuid,\n    role: MemberRole,\n) -> Result<(), sqlx::Error>\nwhere\n    E: Executor<'a, Database = Postgres>,\n{\n    sqlx::query!(\n        r#\"\n        INSERT INTO organization_member_metadata (organization_id, user_id, role)\n        VALUES ($1, $2, $3)\n        ON CONFLICT (organization_id, user_id) DO UPDATE\n        SET role = EXCLUDED.role\n        \"#,\n        organization_id,\n        user_id,\n        role as MemberRole\n    )\n    .execute(executor)\n    .await?;\n\n    Ok(())\n}\n\npub(crate) async fn check_user_role(\n    pool: &PgPool,\n    organization_id: Uuid,\n    user_id: Uuid,\n) -> Result<Option<MemberRole>, IdentityError> {\n    let result = sqlx::query!(\n        r#\"\n        SELECT role AS \"role!: MemberRole\"\n        FROM organization_member_metadata\n        WHERE organization_id = $1 AND user_id = $2\n        \"#,\n        organization_id,\n        user_id\n    )\n    .fetch_optional(pool)\n    .await?;\n\n    Ok(result.map(|r| r.role))\n}\n\npub async fn is_member<'a, E>(\n    executor: E,\n    organization_id: Uuid,\n    user_id: Uuid,\n) -> Result<bool, IdentityError>\nwhere\n    E: Executor<'a, Database = Postgres>,\n{\n    let exists = sqlx::query_scalar!(\n        r#\"\n        SELECT EXISTS(\n            SELECT 1\n            FROM organization_member_metadata\n            WHERE organization_id = $1 AND user_id = $2\n        ) AS \"exists!\"\n        \"#,\n        organization_id,\n        user_id\n    )\n    .fetch_one(executor)\n    .await?;\n\n    Ok(exists)\n}\n\npub(crate) async fn assert_membership(\n    pool: &PgPool,\n    organization_id: Uuid,\n    user_id: Uuid,\n) -> Result<(), IdentityError> {\n    let exists = is_member(pool, organization_id, user_id).await?;\n\n    if exists {\n        Ok(())\n    } else {\n        Err(IdentityError::NotFound)\n    }\n}\n\npub(crate) async fn assert_issue_access(\n    pool: &PgPool,\n    issue_id: Uuid,\n    user_id: Uuid,\n) -> Result<(), IdentityError> {\n    let org_id = sqlx::query_scalar!(\n        r#\"\n        SELECT p.organization_id\n        FROM issues i\n        JOIN projects p ON i.project_id = p.id\n        WHERE i.id = $1\n        \"#,\n        issue_id\n    )\n    .fetch_optional(pool)\n    .await?\n    .ok_or(IdentityError::NotFound)?;\n\n    assert_membership(pool, org_id, user_id).await\n}\n\npub(crate) async fn assert_project_access(\n    pool: &PgPool,\n    project_id: Uuid,\n    user_id: Uuid,\n) -> Result<(), IdentityError> {\n    let org_id = sqlx::query_scalar!(\n        r#\"SELECT organization_id FROM projects WHERE id = $1\"#,\n        project_id\n    )\n    .fetch_optional(pool)\n    .await?\n    .ok_or(IdentityError::NotFound)?;\n\n    assert_membership(pool, org_id, user_id).await\n}\n\npub(crate) async fn list_by_organization(\n    pool: &PgPool,\n    organization_id: Uuid,\n) -> Result<Vec<api_types::OrganizationMember>, sqlx::Error> {\n    sqlx::query_as!(\n        api_types::OrganizationMember,\n        r#\"\n        SELECT\n            organization_id AS \"organization_id!: Uuid\",\n            user_id         AS \"user_id!: Uuid\",\n            role            AS \"role!: MemberRole\",\n            joined_at       AS \"joined_at!\",\n            last_seen_at\n        FROM organization_member_metadata\n        WHERE organization_id = $1\n        \"#,\n        organization_id\n    )\n    .fetch_all(pool)\n    .await\n}\n\npub(crate) async fn list_users_by_organization(\n    pool: &PgPool,\n    organization_id: Uuid,\n) -> Result<Vec<api_types::User>, sqlx::Error> {\n    sqlx::query_as!(\n        api_types::User,\n        r#\"\n        SELECT\n            id           AS \"id!: Uuid\",\n            email        AS \"email!\",\n            first_name   AS \"first_name?\",\n            last_name    AS \"last_name?\",\n            username     AS \"username?\",\n            created_at   AS \"created_at!\",\n            updated_at   AS \"updated_at!\"\n        FROM users\n        WHERE id IN (SELECT user_id FROM organization_member_metadata WHERE organization_id = $1)\n        \"#,\n        organization_id\n    )\n    .fetch_all(pool)\n    .await\n}\n\npub(super) async fn assert_admin(\n    pool: &PgPool,\n    organization_id: Uuid,\n    user_id: Uuid,\n) -> Result<(), IdentityError> {\n    let role = check_user_role(pool, organization_id, user_id).await?;\n    match role {\n        Some(MemberRole::Admin) => Ok(()),\n        _ => Err(IdentityError::PermissionDenied),\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/organizations.rs",
    "content": "pub use api_types::{MemberRole, Organization, OrganizationWithRole};\nuse sqlx::{Executor, PgPool, Postgres, query_as};\nuse uuid::Uuid;\n\nuse super::{\n    identity_errors::IdentityError,\n    organization_members::{\n        add_member, assert_admin as check_admin, assert_membership as check_membership,\n        check_user_role as get_user_role,\n    },\n    projects::ProjectRepository,\n};\n\npub struct OrganizationRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> OrganizationRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn assert_membership(\n        &self,\n        organization_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<(), IdentityError> {\n        check_membership(self.pool, organization_id, user_id).await\n    }\n\n    pub async fn fetch_organization(\n        &self,\n        organization_id: Uuid,\n    ) -> Result<Organization, IdentityError> {\n        query_as!(\n            Organization,\n            r#\"\n            SELECT\n                id           AS \"id!: Uuid\",\n                name         AS \"name!\",\n                slug         AS \"slug!\",\n                is_personal  AS \"is_personal!\",\n                issue_prefix AS \"issue_prefix!\",\n                created_at   AS \"created_at!\",\n                updated_at   AS \"updated_at!\"\n            FROM organizations\n            WHERE id = $1\n            \"#,\n            organization_id\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(IdentityError::NotFound)\n    }\n\n    pub async fn is_personal(&self, organization_id: Uuid) -> Result<bool, IdentityError> {\n        is_personal_org(self.pool, organization_id).await\n    }\n\n    pub async fn ensure_personal_org_and_admin_membership(\n        &self,\n        user_id: Uuid,\n        display_name_hint: Option<&str>,\n    ) -> Result<Organization, IdentityError> {\n        let name = personal_org_name(display_name_hint, user_id);\n        let slug = personal_org_slug(user_id);\n\n        // Try to find existing personal org by slug\n        let existing_org = find_organization_by_slug(self.pool, &slug).await?;\n\n        let org = match existing_org {\n            Some(org) => org,\n            None => {\n                // Create new personal org WITH initial project in a transaction\n                let mut tx = super::begin_tx(self.pool).await?;\n\n                let org = create_personal_org_tx(&mut *tx, &name, &slug).await?;\n\n                // Create initial project with default tags and statuses\n                ProjectRepository::create_initial_project_tx(&mut tx, org.id)\n                    .await\n                    .map_err(|e| {\n                        IdentityError::Database(sqlx::Error::Protocol(format!(\n                            \"Failed to create initial project: {e}\"\n                        )))\n                    })?;\n\n                tx.commit().await?;\n                org\n            }\n        };\n\n        add_member(self.pool, org.id, user_id, MemberRole::Admin).await?;\n        Ok(org)\n    }\n\n    pub async fn check_user_role(\n        &self,\n        organization_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<Option<MemberRole>, IdentityError> {\n        get_user_role(self.pool, organization_id, user_id).await\n    }\n\n    pub async fn assert_admin(\n        &self,\n        organization_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<(), IdentityError> {\n        check_admin(self.pool, organization_id, user_id).await\n    }\n\n    pub async fn create_organization(\n        &self,\n        name: &str,\n        slug: &str,\n        creator_user_id: Uuid,\n    ) -> Result<OrganizationWithRole, IdentityError> {\n        let mut tx = super::begin_tx(self.pool).await?;\n\n        let issue_prefix = derive_issue_prefix(name);\n        let org = sqlx::query_as!(\n            Organization,\n            r#\"\n            INSERT INTO organizations (name, slug, issue_prefix)\n            VALUES ($1, $2, $3)\n            RETURNING\n                id           AS \"id!: Uuid\",\n                name         AS \"name!\",\n                slug         AS \"slug!\",\n                is_personal  AS \"is_personal!\",\n                issue_prefix AS \"issue_prefix!\",\n                created_at   AS \"created_at!\",\n                updated_at   AS \"updated_at!\"\n            \"#,\n            name,\n            slug,\n            issue_prefix\n        )\n        .fetch_one(&mut *tx)\n        .await\n        .map_err(|e| {\n            if let Some(db_err) = e.as_database_error()\n                && db_err.is_unique_violation()\n            {\n                return IdentityError::OrganizationConflict(\n                    \"An organization with this slug already exists\".to_string(),\n                );\n            }\n            IdentityError::from(e)\n        })?;\n\n        // Create initial project with default tags and statuses\n        ProjectRepository::create_initial_project_tx(&mut tx, org.id)\n            .await\n            .map_err(|e| {\n                IdentityError::Database(sqlx::Error::Protocol(format!(\n                    \"Failed to create initial project: {e}\"\n                )))\n            })?;\n\n        add_member(&mut *tx, org.id, creator_user_id, MemberRole::Admin).await?;\n\n        tx.commit().await?;\n\n        Ok(OrganizationWithRole {\n            id: org.id,\n            name: org.name,\n            slug: org.slug,\n            is_personal: org.is_personal,\n            issue_prefix: org.issue_prefix,\n            created_at: org.created_at,\n            updated_at: org.updated_at,\n            user_role: MemberRole::Admin,\n        })\n    }\n\n    pub async fn list_user_organizations(\n        &self,\n        user_id: Uuid,\n    ) -> Result<Vec<OrganizationWithRole>, IdentityError> {\n        let orgs = sqlx::query_as!(\n            OrganizationWithRole,\n            r#\"\n            SELECT\n                o.id           AS \"id!: Uuid\",\n                o.name         AS \"name!\",\n                o.slug         AS \"slug!\",\n                o.is_personal  AS \"is_personal!\",\n                o.issue_prefix AS \"issue_prefix!\",\n                o.created_at   AS \"created_at!\",\n                o.updated_at   AS \"updated_at!\",\n                m.role         AS \"user_role!: MemberRole\"\n            FROM organizations o\n            JOIN organization_member_metadata m ON m.organization_id = o.id\n            WHERE m.user_id = $1\n            ORDER BY o.created_at DESC\n            \"#,\n            user_id\n        )\n        .fetch_all(self.pool)\n        .await?;\n\n        Ok(orgs)\n    }\n\n    pub async fn update_organization_name(\n        &self,\n        org_id: Uuid,\n        user_id: Uuid,\n        new_name: &str,\n    ) -> Result<Organization, IdentityError> {\n        self.assert_admin(org_id, user_id).await?;\n\n        let org = sqlx::query_as!(\n            Organization,\n            r#\"\n            UPDATE organizations\n            SET name = $2\n            WHERE id = $1\n            RETURNING\n                id           AS \"id!: Uuid\",\n                name         AS \"name!\",\n                slug         AS \"slug!\",\n                is_personal  AS \"is_personal!\",\n                issue_prefix AS \"issue_prefix!\",\n                created_at   AS \"created_at!\",\n                updated_at   AS \"updated_at!\"\n            \"#,\n            org_id,\n            new_name\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(IdentityError::NotFound)?;\n\n        Ok(org)\n    }\n\n    pub async fn delete_organization(\n        &self,\n        org_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<(), IdentityError> {\n        // First fetch the org to check if it's a personal org\n        let org = self.fetch_organization(org_id).await?;\n\n        // Check if this is a personal org by flag\n        if org.is_personal {\n            return Err(IdentityError::CannotDeleteOrganization(\n                \"Cannot delete personal organizations\".to_string(),\n            ));\n        }\n\n        let result = sqlx::query!(\n            r#\"\n            WITH s AS (\n                SELECT\n                    BOOL_OR(user_id = $2 AND role = 'admin') AS is_admin\n                FROM organization_member_metadata\n                WHERE organization_id = $1\n            )\n            DELETE FROM organizations o\n            USING s\n            WHERE o.id = $1\n              AND s.is_admin = true\n            RETURNING o.id\n            \"#,\n            org_id,\n            user_id\n        )\n        .fetch_optional(self.pool)\n        .await?;\n\n        if result.is_none() {\n            return Err(IdentityError::PermissionDenied);\n        }\n\n        Ok(())\n    }\n}\n\npub async fn is_personal_org<'e, E>(\n    executor: E,\n    organization_id: Uuid,\n) -> Result<bool, IdentityError>\nwhere\n    E: Executor<'e, Database = Postgres>,\n{\n    let result: Option<bool> = sqlx::query_scalar!(\n        r#\"SELECT is_personal FROM organizations WHERE id = $1\"#,\n        organization_id\n    )\n    .fetch_optional(executor)\n    .await?;\n\n    result.ok_or(IdentityError::NotFound)\n}\n\nasync fn find_organization_by_slug(\n    pool: &PgPool,\n    slug: &str,\n) -> Result<Option<Organization>, sqlx::Error> {\n    query_as!(\n        Organization,\n        r#\"\n        SELECT\n            id           AS \"id!: Uuid\",\n            name         AS \"name!\",\n            slug         AS \"slug!\",\n            is_personal  AS \"is_personal!\",\n            issue_prefix AS \"issue_prefix!\",\n            created_at   AS \"created_at!\",\n            updated_at   AS \"updated_at!\"\n        FROM organizations\n        WHERE slug = $1\n        \"#,\n        slug\n    )\n    .fetch_optional(pool)\n    .await\n}\n\nasync fn create_personal_org_tx<'e, E>(\n    executor: E,\n    name: &str,\n    slug: &str,\n) -> Result<Organization, sqlx::Error>\nwhere\n    E: Executor<'e, Database = Postgres>,\n{\n    let issue_prefix = derive_issue_prefix(name);\n    query_as!(\n        Organization,\n        r#\"\n        INSERT INTO organizations (name, slug, is_personal, issue_prefix)\n        VALUES ($1, $2, TRUE, $3)\n        RETURNING\n            id           AS \"id!: Uuid\",\n            name         AS \"name!\",\n            slug         AS \"slug!\",\n            is_personal  AS \"is_personal!\",\n            issue_prefix AS \"issue_prefix!\",\n            created_at   AS \"created_at!\",\n            updated_at   AS \"updated_at!\"\n        \"#,\n        name,\n        slug,\n        issue_prefix\n    )\n    .fetch_one(executor)\n    .await\n}\n\nfn personal_org_name(hint: Option<&str>, user_id: Uuid) -> String {\n    let user_id_str = user_id.to_string();\n    let display_name = hint.unwrap_or(&user_id_str);\n    format!(\"{display_name}'s Org\")\n}\n\nfn personal_org_slug(user_id: Uuid) -> String {\n    // Use a deterministic slug pattern so we can find personal orgs\n    format!(\"personal-{user_id}\")\n}\n\n/// Derive an issue prefix from an organization name.\n/// Takes the first 3 uppercase letters from the name.\n/// Examples: \"Bloop\" -> \"BLO\", \"My Project\" -> \"MYP\"\nfn derive_issue_prefix(name: &str) -> String {\n    let letters: String = name.chars().filter(|c| c.is_ascii_alphabetic()).collect();\n    let prefix: String = letters.chars().take(3).collect::<String>().to_uppercase();\n    if prefix.is_empty() {\n        \"ISS\".to_string()\n    } else {\n        prefix\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/pending_uploads.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone)]\npub struct PendingUpload {\n    pub id: Uuid,\n    pub project_id: Uuid,\n    pub blob_path: String,\n    pub hash: String,\n    pub created_at: DateTime<Utc>,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Error)]\npub enum PendingUploadError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct PendingUploadRepository;\n\nimpl PendingUploadRepository {\n    pub async fn create(\n        pool: &PgPool,\n        project_id: Uuid,\n        blob_path: String,\n        hash: String,\n        expires_at: DateTime<Utc>,\n    ) -> Result<PendingUpload, PendingUploadError> {\n        let record = sqlx::query_as!(\n            PendingUpload,\n            r#\"\n            INSERT INTO pending_uploads (project_id, blob_path, hash, expires_at)\n            VALUES ($1, $2, $3, $4)\n            RETURNING\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                blob_path   AS \"blob_path!\",\n                hash        AS \"hash!\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                expires_at  AS \"expires_at!: DateTime<Utc>\"\n            \"#,\n            project_id,\n            blob_path,\n            hash,\n            expires_at,\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn find_by_id(\n        pool: &PgPool,\n        id: Uuid,\n    ) -> Result<Option<PendingUpload>, PendingUploadError> {\n        let record = sqlx::query_as!(\n            PendingUpload,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                blob_path   AS \"blob_path!\",\n                hash        AS \"hash!\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                expires_at  AS \"expires_at!: DateTime<Utc>\"\n            FROM pending_uploads\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), PendingUploadError> {\n        sqlx::query!(\"DELETE FROM pending_uploads WHERE id = $1\", id)\n            .execute(pool)\n            .await?;\n        Ok(())\n    }\n\n    pub async fn delete_expired(pool: &PgPool) -> Result<Vec<PendingUpload>, PendingUploadError> {\n        let records = sqlx::query_as!(\n            PendingUpload,\n            r#\"\n            DELETE FROM pending_uploads\n            WHERE expires_at < NOW()\n            RETURNING\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                blob_path   AS \"blob_path!\",\n                hash        AS \"hash!\",\n                created_at  AS \"created_at!: DateTime<Utc>\",\n                expires_at  AS \"expires_at!: DateTime<Utc>\"\n            \"#,\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/project_notification_preferences.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse sqlx::{Executor, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProjectNotificationPreference {\n    pub project_id: Uuid,\n    pub user_id: Uuid,\n    pub notify_on_issue_created: bool,\n    pub notify_on_issue_assigned: bool,\n}\n\n#[derive(Debug, Error)]\npub enum ProjectNotificationPreferenceError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\npub struct ProjectNotificationPreferenceRepository;\n\nimpl ProjectNotificationPreferenceRepository {\n    pub async fn find<'e, E>(\n        executor: E,\n        project_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<Option<ProjectNotificationPreference>, ProjectNotificationPreferenceError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            ProjectNotificationPreference,\n            r#\"\n            SELECT\n                project_id               AS \"project_id!: Uuid\",\n                user_id                  AS \"user_id!: Uuid\",\n                notify_on_issue_created  AS \"notify_on_issue_created!\",\n                notify_on_issue_assigned AS \"notify_on_issue_assigned!\"\n            FROM project_notification_preferences\n            WHERE project_id = $1 AND user_id = $2\n            \"#,\n            project_id,\n            user_id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/project_statuses.rs",
    "content": "use api_types::{DeleteResponse, MutationResponse, ProjectStatus};\nuse chrono::{DateTime, Utc};\nuse sqlx::{Executor, PgPool, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n/// Default statuses that are created for each new project (name, color, sort_order, hidden)\n/// Colors are in HSL format: \"H S% L%\"\npub const DEFAULT_STATUSES: &[(&str, &str, i32, bool)] = &[\n    (\"Backlog\", \"220 9% 46%\", 0, true),\n    (\"To do\", \"217 91% 60%\", 1, false),\n    (\"In progress\", \"38 92% 50%\", 2, false),\n    (\"In review\", \"258 90% 66%\", 3, false),\n    (\"Done\", \"142 71% 45%\", 4, false),\n    (\"Cancelled\", \"0 84% 60%\", 5, true),\n];\n\n#[derive(Debug, Error)]\npub enum ProjectStatusError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct ProjectStatusRepository;\n\nimpl ProjectStatusRepository {\n    pub async fn find_by_id<'e, E>(\n        executor: E,\n        id: Uuid,\n    ) -> Result<Option<ProjectStatus>, ProjectStatusError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            ProjectStatus,\n            r#\"\n            SELECT\n                id              AS \"id!: Uuid\",\n                project_id      AS \"project_id!: Uuid\",\n                name            AS \"name!\",\n                color           AS \"color!\",\n                sort_order      AS \"sort_order!\",\n                hidden          AS \"hidden!\",\n                created_at      AS \"created_at!: DateTime<Utc>\"\n            FROM project_statuses\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn find_by_name<'e, E>(\n        executor: E,\n        project_id: Uuid,\n        name: &str,\n    ) -> Result<Option<ProjectStatus>, ProjectStatusError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            ProjectStatus,\n            r#\"\n            SELECT\n                id              AS \"id!: Uuid\",\n                project_id      AS \"project_id!: Uuid\",\n                name            AS \"name!\",\n                color           AS \"color!\",\n                sort_order      AS \"sort_order!\",\n                hidden          AS \"hidden!\",\n                created_at      AS \"created_at!: DateTime<Utc>\"\n            FROM project_statuses\n            WHERE project_id = $1 AND LOWER(name) = LOWER($2)\n            \"#,\n            project_id,\n            name\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        project_id: Uuid,\n        name: String,\n        color: String,\n        sort_order: i32,\n        hidden: bool,\n    ) -> Result<MutationResponse<ProjectStatus>, ProjectStatusError> {\n        let mut tx = super::begin_tx(pool).await?;\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let created_at = Utc::now();\n        let data = sqlx::query_as!(\n            ProjectStatus,\n            r#\"\n            INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at)\n            VALUES ($1, $2, $3, $4, $5, $6, $7)\n            RETURNING\n                id              AS \"id!: Uuid\",\n                project_id      AS \"project_id!: Uuid\",\n                name            AS \"name!\",\n                color           AS \"color!\",\n                sort_order      AS \"sort_order!\",\n                hidden          AS \"hidden!\",\n                created_at      AS \"created_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            project_id,\n            name,\n            color,\n            sort_order,\n            hidden,\n            created_at\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(MutationResponse { data, txid })\n    }\n\n    /// Update a project status with partial fields. Uses COALESCE to preserve existing values\n    /// when None is provided.\n    pub async fn update(\n        pool: &PgPool,\n        id: Uuid,\n        name: Option<String>,\n        color: Option<String>,\n        sort_order: Option<i32>,\n        hidden: Option<bool>,\n    ) -> Result<MutationResponse<ProjectStatus>, ProjectStatusError> {\n        let mut tx = super::begin_tx(pool).await?;\n        let data = sqlx::query_as!(\n            ProjectStatus,\n            r#\"\n            UPDATE project_statuses\n            SET\n                name = COALESCE($1, name),\n                color = COALESCE($2, color),\n                sort_order = COALESCE($3, sort_order),\n                hidden = COALESCE($4, hidden)\n            WHERE id = $5\n            RETURNING\n                id              AS \"id!: Uuid\",\n                project_id      AS \"project_id!: Uuid\",\n                name            AS \"name!\",\n                color           AS \"color!\",\n                sort_order      AS \"sort_order!\",\n                hidden          AS \"hidden!\",\n                created_at      AS \"created_at!: DateTime<Utc>\"\n            \"#,\n            name,\n            color,\n            sort_order,\n            hidden,\n            id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, ProjectStatusError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM project_statuses WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n\n    pub async fn list_by_project<'e, E>(\n        executor: E,\n        project_id: Uuid,\n    ) -> Result<Vec<ProjectStatus>, ProjectStatusError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let records = sqlx::query_as!(\n            ProjectStatus,\n            r#\"\n            SELECT\n                id              AS \"id!: Uuid\",\n                project_id      AS \"project_id!: Uuid\",\n                name            AS \"name!\",\n                color           AS \"color!\",\n                sort_order      AS \"sort_order!\",\n                hidden          AS \"hidden!\",\n                created_at      AS \"created_at!: DateTime<Utc>\"\n            FROM project_statuses\n            WHERE project_id = $1\n            \"#,\n            project_id\n        )\n        .fetch_all(executor)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn create_default_statuses<'e, E>(\n        executor: E,\n        project_id: Uuid,\n    ) -> Result<Vec<ProjectStatus>, ProjectStatusError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let names: Vec<String> = DEFAULT_STATUSES\n            .iter()\n            .map(|(n, _, _, _)| (*n).to_string())\n            .collect();\n        let colors: Vec<String> = DEFAULT_STATUSES\n            .iter()\n            .map(|(_, c, _, _)| (*c).to_string())\n            .collect();\n        let sort_orders: Vec<i32> = DEFAULT_STATUSES.iter().map(|(_, _, s, _)| *s).collect();\n        let hiddens: Vec<bool> = DEFAULT_STATUSES.iter().map(|(_, _, _, h)| *h).collect();\n\n        let statuses = sqlx::query_as!(\n            ProjectStatus,\n            r#\"\n            INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at)\n            SELECT gen_random_uuid(), $1, name, color, sort_order, hidden, NOW()\n            FROM UNNEST($2::text[], $3::text[], $4::int[], $5::bool[]) AS t(name, color, sort_order, hidden)\n            RETURNING\n                id              AS \"id!: Uuid\",\n                project_id      AS \"project_id!: Uuid\",\n                name            AS \"name!\",\n                color           AS \"color!\",\n                sort_order      AS \"sort_order!\",\n                hidden          AS \"hidden!\",\n                created_at      AS \"created_at!: DateTime<Utc>\"\n            \"#,\n            project_id,\n            &names,\n            &colors,\n            &sort_orders,\n            &hiddens\n        )\n        .fetch_all(executor)\n        .await?;\n\n        Ok(statuses)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/projects.rs",
    "content": "use api_types::{DeleteResponse, MutationResponse, Project};\nuse chrono::{DateTime, Utc};\nuse sqlx::{Executor, PgPool, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::{get_txid, project_statuses::ProjectStatusRepository, tags::TagRepository};\n\n/// Default color for the initial project created with personal organizations\n/// HSL format: \"H S% L%\" (blue - matches \"To do\" status)\npub const INITIAL_PROJECT_COLOR: &str = \"217 91% 60%\";\n\n/// Default name for the initial project\npub const INITIAL_PROJECT_NAME: &str = \"Initial Project\";\n\n#[derive(Debug, Error)]\npub enum ProjectError {\n    #[error(\"project conflict: {0}\")]\n    Conflict(String),\n    #[error(\"failed to create default tags: {0}\")]\n    DefaultTagsFailed(String),\n    #[error(\"failed to create default statuses: {0}\")]\n    DefaultStatusesFailed(String),\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct ProjectRepository;\n\nimpl ProjectRepository {\n    pub async fn find_by_id<'e, E>(executor: E, id: Uuid) -> Result<Option<Project>, ProjectError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let record = sqlx::query_as!(\n            Project,\n            r#\"\n            SELECT\n                id               AS \"id!: Uuid\",\n                organization_id  AS \"organization_id!: Uuid\",\n                name             AS \"name!\",\n                color            AS \"color!\",\n                sort_order       AS \"sort_order!\",\n                created_at       AS \"created_at!: DateTime<Utc>\",\n                updated_at       AS \"updated_at!: DateTime<Utc>\"\n            FROM projects\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn create<'e, E>(\n        executor: E,\n        id: Option<Uuid>,\n        organization_id: Uuid,\n        name: String,\n        color: String,\n    ) -> Result<Project, ProjectError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let now = Utc::now();\n        let record = sqlx::query_as!(\n            Project,\n            r#\"\n            INSERT INTO projects (\n                id, organization_id, name, color, sort_order,\n                created_at, updated_at\n            )\n            VALUES (\n                $1,\n                $2,\n                $3,\n                $4,\n                COALESCE(\n                    (SELECT MAX(sort_order) + 1 FROM projects WHERE organization_id = $2),\n                    0\n                ),\n                $5,\n                $6\n            )\n            RETURNING\n                id               AS \"id!: Uuid\",\n                organization_id  AS \"organization_id!: Uuid\",\n                name             AS \"name!\",\n                color            AS \"color!\",\n                sort_order       AS \"sort_order!\",\n                created_at       AS \"created_at!: DateTime<Utc>\",\n                updated_at       AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            organization_id,\n            name,\n            color,\n            now,\n            now\n        )\n        .fetch_one(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn list_by_organization<'e, E>(\n        executor: E,\n        organization_id: Uuid,\n    ) -> Result<Vec<Project>, ProjectError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let records = sqlx::query_as!(\n            Project,\n            r#\"\n            SELECT\n                id               AS \"id!: Uuid\",\n                organization_id  AS \"organization_id!: Uuid\",\n                name             AS \"name!\",\n                color            AS \"color!\",\n                sort_order       AS \"sort_order!\",\n                created_at       AS \"created_at!: DateTime<Utc>\",\n                updated_at       AS \"updated_at!: DateTime<Utc>\"\n            FROM projects\n            WHERE organization_id = $1\n            ORDER BY sort_order ASC, created_at DESC\n            \"#,\n            organization_id\n        )\n        .fetch_all(executor)\n        .await?;\n\n        Ok(records)\n    }\n\n    /// Update a project with partial fields. Uses COALESCE to preserve existing values\n    /// when None is provided.\n    pub async fn update(\n        pool: &PgPool,\n        id: Uuid,\n        name: Option<String>,\n        color: Option<String>,\n        sort_order: Option<i32>,\n    ) -> Result<MutationResponse<Project>, ProjectError> {\n        let mut tx = super::begin_tx(pool).await?;\n        let data = Self::update_partial(&mut *tx, id, name, color, sort_order).await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(MutationResponse { data, txid })\n    }\n\n    /// Updates project fields using a provided executor (used by bulk update transactions).\n    pub async fn update_partial<'e, E>(\n        executor: E,\n        id: Uuid,\n        name: Option<String>,\n        color: Option<String>,\n        sort_order: Option<i32>,\n    ) -> Result<Project, ProjectError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let updated_at = Utc::now();\n        let record = sqlx::query_as!(\n            Project,\n            r#\"\n            UPDATE projects\n            SET\n                name = COALESCE($1, name),\n                color = COALESCE($2, color),\n                sort_order = COALESCE($3, sort_order),\n                updated_at = $4\n            WHERE id = $5\n            RETURNING\n                id               AS \"id!: Uuid\",\n                organization_id  AS \"organization_id!: Uuid\",\n                name             AS \"name!\",\n                color            AS \"color!\",\n                sort_order       AS \"sort_order!\",\n                created_at       AS \"created_at!: DateTime<Utc>\",\n                updated_at       AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            name,\n            color,\n            sort_order,\n            updated_at,\n            id\n        )\n        .fetch_one(executor)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, ProjectError> {\n        let mut tx = super::begin_tx(pool).await?;\n        sqlx::query!(\"DELETE FROM projects WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(DeleteResponse { txid })\n    }\n\n    pub async fn organization_id<'e, E>(\n        executor: E,\n        project_id: Uuid,\n    ) -> Result<Option<Uuid>, ProjectError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        sqlx::query_scalar!(\n            r#\"\n            SELECT organization_id\n            FROM projects\n            WHERE id = $1\n            \"#,\n            project_id\n        )\n        .fetch_optional(executor)\n        .await\n        .map_err(ProjectError::from)\n    }\n\n    /// Creates the initial project for a newly created personal organization.\n    /// Includes default tags and statuses. Designed for use within transactions.\n    pub async fn create_initial_project_tx(\n        tx: &mut sqlx::Transaction<'_, Postgres>,\n        organization_id: Uuid,\n    ) -> Result<Project, ProjectError> {\n        let project = Self::create(\n            &mut **tx,\n            None,\n            organization_id,\n            INITIAL_PROJECT_NAME.to_string(),\n            INITIAL_PROJECT_COLOR.to_string(),\n        )\n        .await?;\n\n        TagRepository::create_default_tags(&mut **tx, project.id)\n            .await\n            .map_err(|e| ProjectError::DefaultTagsFailed(e.to_string()))?;\n\n        ProjectStatusRepository::create_default_statuses(&mut **tx, project.id)\n            .await\n            .map_err(|e| ProjectError::DefaultStatusesFailed(e.to_string()))?;\n\n        Ok(project)\n    }\n\n    /// Creates a project along with default tags and statuses in a single transaction.\n    pub async fn create_with_defaults(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        organization_id: Uuid,\n        name: String,\n        color: String,\n    ) -> Result<MutationResponse<Project>, ProjectError> {\n        let mut tx = super::begin_tx(pool).await?;\n\n        let project = Self::create(&mut *tx, id, organization_id, name, color).await?;\n\n        TagRepository::create_default_tags(&mut *tx, project.id)\n            .await\n            .map_err(|e| ProjectError::DefaultTagsFailed(e.to_string()))?;\n\n        ProjectStatusRepository::create_default_statuses(&mut *tx, project.id)\n            .await\n            .map_err(|e| ProjectError::DefaultStatusesFailed(e.to_string()))?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n        Ok(MutationResponse {\n            data: project,\n            txid,\n        })\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/pull_requests.rs",
    "content": "use api_types::{PullRequest, PullRequestStatus};\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum PullRequestError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct PullRequestRepository;\n\nimpl PullRequestRepository {\n    pub async fn list_by_issue(\n        pool: &PgPool,\n        issue_id: Uuid,\n    ) -> Result<Vec<PullRequest>, PullRequestError> {\n        let records = sqlx::query_as!(\n            PullRequest,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                url                 AS \"url!: String\",\n                number              AS \"number!: i32\",\n                status              AS \"status!: PullRequestStatus\",\n                merged_at           AS \"merged_at: DateTime<Utc>\",\n                merge_commit_sha    AS \"merge_commit_sha: String\",\n                target_branch_name  AS \"target_branch_name!: String\",\n                issue_id            AS \"issue_id!: Uuid\",\n                workspace_id        AS \"workspace_id: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM pull_requests\n            WHERE issue_id = $1\n            \"#,\n            issue_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn list_by_project(\n        pool: &PgPool,\n        project_id: Uuid,\n    ) -> Result<Vec<PullRequest>, PullRequestError> {\n        let records = sqlx::query_as!(\n            PullRequest,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                url                 AS \"url!: String\",\n                number              AS \"number!: i32\",\n                status              AS \"status!: PullRequestStatus\",\n                merged_at           AS \"merged_at: DateTime<Utc>\",\n                merge_commit_sha    AS \"merge_commit_sha: String\",\n                target_branch_name  AS \"target_branch_name!: String\",\n                issue_id            AS \"issue_id!: Uuid\",\n                workspace_id        AS \"workspace_id: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM pull_requests\n            WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n            \"#,\n            project_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn find_by_url(\n        pool: &PgPool,\n        url: &str,\n    ) -> Result<Option<PullRequest>, PullRequestError> {\n        let record = sqlx::query_as!(\n            PullRequest,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                url                 AS \"url!: String\",\n                number              AS \"number!: i32\",\n                status              AS \"status!: PullRequestStatus\",\n                merged_at           AS \"merged_at: DateTime<Utc>\",\n                merge_commit_sha    AS \"merge_commit_sha: String\",\n                target_branch_name  AS \"target_branch_name!: String\",\n                issue_id            AS \"issue_id!: Uuid\",\n                workspace_id        AS \"workspace_id: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM pull_requests\n            WHERE url = $1\n            \"#,\n            url\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub async fn create(\n        pool: &PgPool,\n        url: String,\n        number: i32,\n        status: PullRequestStatus,\n        merged_at: Option<DateTime<Utc>>,\n        merge_commit_sha: Option<String>,\n        target_branch_name: String,\n        issue_id: Uuid,\n        workspace_id: Option<Uuid>,\n    ) -> Result<PullRequest, PullRequestError> {\n        let id = Uuid::new_v4();\n        let record = sqlx::query_as!(\n            PullRequest,\n            r#\"\n            INSERT INTO pull_requests (\n                id, url, number, status, merged_at, merge_commit_sha,\n                target_branch_name, issue_id, workspace_id\n            )\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                url                 AS \"url!: String\",\n                number              AS \"number!: i32\",\n                status              AS \"status!: PullRequestStatus\",\n                merged_at           AS \"merged_at: DateTime<Utc>\",\n                merge_commit_sha    AS \"merge_commit_sha: String\",\n                target_branch_name  AS \"target_branch_name!: String\",\n                issue_id            AS \"issue_id!: Uuid\",\n                workspace_id        AS \"workspace_id: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            id,\n            url,\n            number,\n            status as PullRequestStatus,\n            merged_at,\n            merge_commit_sha,\n            target_branch_name,\n            issue_id,\n            workspace_id\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn update(\n        pool: &PgPool,\n        id: Uuid,\n        status: Option<PullRequestStatus>,\n        merged_at: Option<Option<DateTime<Utc>>>,\n        merge_commit_sha: Option<Option<String>>,\n    ) -> Result<PullRequest, PullRequestError> {\n        let update_status = status.is_some();\n        let status_value = status.unwrap_or(PullRequestStatus::Open);\n\n        let update_merged_at = merged_at.is_some();\n        let merged_at_value = merged_at.flatten();\n\n        let update_merge_commit_sha = merge_commit_sha.is_some();\n        let merge_commit_sha_value = merge_commit_sha.flatten();\n\n        let record = sqlx::query_as!(\n            PullRequest,\n            r#\"\n            UPDATE pull_requests SET\n                status = CASE WHEN $1 THEN $2 ELSE status END,\n                merged_at = CASE WHEN $3 THEN $4 ELSE merged_at END,\n                merge_commit_sha = CASE WHEN $5 THEN $6 ELSE merge_commit_sha END,\n                updated_at = NOW()\n            WHERE id = $7\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                url                 AS \"url!: String\",\n                number              AS \"number!: i32\",\n                status              AS \"status!: PullRequestStatus\",\n                merged_at           AS \"merged_at: DateTime<Utc>\",\n                merge_commit_sha    AS \"merge_commit_sha: String\",\n                target_branch_name  AS \"target_branch_name!: String\",\n                issue_id            AS \"issue_id!: Uuid\",\n                workspace_id        AS \"workspace_id: Uuid\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            update_status,\n            status_value as PullRequestStatus,\n            update_merged_at,\n            merged_at_value,\n            update_merge_commit_sha,\n            merge_commit_sha_value,\n            id\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(record)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/reviews.rs",
    "content": "use std::net::IpAddr;\n\nuse chrono::{DateTime, Utc};\nuse ipnetwork::IpNetwork;\nuse serde::Serialize;\nuse sqlx::{PgPool, query_as};\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum ReviewError {\n    #[error(\"review not found\")]\n    NotFound,\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n}\n\n#[derive(Debug, Clone, sqlx::FromRow, Serialize)]\npub struct Review {\n    pub id: Uuid,\n    pub gh_pr_url: String,\n    pub claude_code_session_id: Option<String>,\n    pub ip_address: Option<IpNetwork>,\n    pub review_cache: Option<serde_json::Value>,\n    pub last_viewed_at: Option<DateTime<Utc>>,\n    pub r2_path: String,\n    pub deleted_at: Option<DateTime<Utc>>,\n    pub created_at: DateTime<Utc>,\n    pub email: Option<String>,\n    pub pr_title: String,\n    pub status: String,\n    // Webhook-specific fields\n    pub github_installation_id: Option<i64>,\n    pub pr_owner: Option<String>,\n    pub pr_repo: Option<String>,\n    pub pr_number: Option<i32>,\n}\n\nimpl Review {\n    /// Returns true if this review was triggered by a GitHub webhook\n    pub fn is_webhook_review(&self) -> bool {\n        self.github_installation_id.is_some()\n    }\n}\n\n/// Parameters for creating a new review (CLI-triggered)\npub struct CreateReviewParams<'a> {\n    pub id: Uuid,\n    pub gh_pr_url: &'a str,\n    pub claude_code_session_id: Option<&'a str>,\n    pub ip_address: IpAddr,\n    pub r2_path: &'a str,\n    pub email: &'a str,\n    pub pr_title: &'a str,\n}\n\n/// Parameters for creating a webhook-triggered review\npub struct CreateWebhookReviewParams<'a> {\n    pub id: Uuid,\n    pub gh_pr_url: &'a str,\n    pub r2_path: &'a str,\n    pub pr_title: &'a str,\n    pub github_installation_id: i64,\n    pub pr_owner: &'a str,\n    pub pr_repo: &'a str,\n    pub pr_number: i32,\n}\n\npub struct ReviewRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> ReviewRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn create(&self, params: CreateReviewParams<'_>) -> Result<Review, ReviewError> {\n        let ip_network = IpNetwork::from(params.ip_address);\n\n        query_as!(\n            Review,\n            r#\"\n            INSERT INTO reviews (id, gh_pr_url, claude_code_session_id, ip_address, r2_path, email, pr_title)\n            VALUES ($1, $2, $3, $4, $5, $6, $7)\n            RETURNING\n                id,\n                gh_pr_url,\n                claude_code_session_id,\n                ip_address AS \"ip_address: IpNetwork\",\n                review_cache,\n                last_viewed_at,\n                r2_path,\n                deleted_at,\n                created_at,\n                email,\n                pr_title,\n                status,\n                github_installation_id,\n                pr_owner,\n                pr_repo,\n                pr_number\n            \"#,\n            params.id,\n            params.gh_pr_url,\n            params.claude_code_session_id,\n            ip_network,\n            params.r2_path,\n            params.email,\n            params.pr_title\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(ReviewError::from)\n    }\n\n    /// Create a webhook-triggered review (no email/IP)\n    pub async fn create_webhook_review(\n        &self,\n        params: CreateWebhookReviewParams<'_>,\n    ) -> Result<Review, ReviewError> {\n        query_as!(\n            Review,\n            r#\"\n            INSERT INTO reviews (id, gh_pr_url, r2_path, pr_title, github_installation_id, pr_owner, pr_repo, pr_number)\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n            RETURNING\n                id,\n                gh_pr_url,\n                claude_code_session_id,\n                ip_address AS \"ip_address: IpNetwork\",\n                review_cache,\n                last_viewed_at,\n                r2_path,\n                deleted_at,\n                created_at,\n                email,\n                pr_title,\n                status,\n                github_installation_id,\n                pr_owner,\n                pr_repo,\n                pr_number\n            \"#,\n            params.id,\n            params.gh_pr_url,\n            params.r2_path,\n            params.pr_title,\n            params.github_installation_id,\n            params.pr_owner,\n            params.pr_repo,\n            params.pr_number\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(ReviewError::from)\n    }\n\n    /// Get a review by its ID.\n    /// Returns NotFound if the review doesn't exist or has been deleted.\n    pub async fn get_by_id(&self, id: Uuid) -> Result<Review, ReviewError> {\n        query_as!(\n            Review,\n            r#\"\n            SELECT\n                id,\n                gh_pr_url,\n                claude_code_session_id,\n                ip_address AS \"ip_address: IpNetwork\",\n                review_cache,\n                last_viewed_at,\n                r2_path,\n                deleted_at,\n                created_at,\n                email,\n                pr_title,\n                status,\n                github_installation_id,\n                pr_owner,\n                pr_repo,\n                pr_number\n            FROM reviews\n            WHERE id = $1 AND deleted_at IS NULL\n            \"#,\n            id\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(ReviewError::NotFound)\n    }\n\n    /// Count reviews from an IP address since a given timestamp.\n    /// Used for rate limiting.\n    pub async fn count_since(\n        &self,\n        ip_address: IpAddr,\n        since: DateTime<Utc>,\n    ) -> Result<i64, ReviewError> {\n        let ip_network = IpNetwork::from(ip_address);\n\n        let result = sqlx::query!(\n            r#\"\n            SELECT COUNT(*) as \"count!\"\n            FROM reviews\n            WHERE ip_address = $1\n              AND created_at > $2\n              AND deleted_at IS NULL\n            \"#,\n            ip_network,\n            since\n        )\n        .fetch_one(self.pool)\n        .await\n        .map_err(ReviewError::from)?;\n\n        Ok(result.count)\n    }\n\n    /// Mark a review as completed\n    pub async fn mark_completed(&self, id: Uuid) -> Result<(), ReviewError> {\n        sqlx::query!(\n            r#\"\n            UPDATE reviews\n            SET status = 'completed'\n            WHERE id = $1 AND deleted_at IS NULL\n            \"#,\n            id\n        )\n        .execute(self.pool)\n        .await\n        .map_err(ReviewError::from)?;\n\n        Ok(())\n    }\n\n    /// Mark a review as failed\n    pub async fn mark_failed(&self, id: Uuid) -> Result<(), ReviewError> {\n        sqlx::query!(\n            r#\"\n            UPDATE reviews\n            SET status = 'failed'\n            WHERE id = $1 AND deleted_at IS NULL\n            \"#,\n            id\n        )\n        .execute(self.pool)\n        .await\n        .map_err(ReviewError::from)?;\n\n        Ok(())\n    }\n\n    /// Check if there's a pending review for a specific PR\n    pub async fn has_pending_review_for_pr(\n        &self,\n        pr_owner: &str,\n        pr_repo: &str,\n        pr_number: i32,\n    ) -> Result<bool, ReviewError> {\n        let result = sqlx::query!(\n            r#\"\n            SELECT EXISTS(\n                SELECT 1 FROM reviews\n                WHERE pr_owner = $1\n                  AND pr_repo = $2\n                  AND pr_number = $3\n                  AND status = 'pending'\n                  AND deleted_at IS NULL\n            ) as \"exists!\"\n            \"#,\n            pr_owner,\n            pr_repo,\n            pr_number\n        )\n        .fetch_one(self.pool)\n        .await?;\n\n        Ok(result.exists)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/tags.rs",
    "content": "use api_types::{DeleteResponse, MutationResponse, Tag};\nuse sqlx::{Executor, PgPool, Postgres};\nuse thiserror::Error;\nuse uuid::Uuid;\n\nuse super::get_txid;\n\n#[derive(Debug, Error)]\npub enum TagError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\n/// Default tags that are created for each new project\n/// Colors are in HSL format: \"H S% L%\"\npub const DEFAULT_TAGS: &[(&str, &str)] = &[\n    (\"bug\", \"355 65% 53%\"),\n    (\"feature\", \"124 82% 30%\"),\n    (\"documentation\", \"205 100% 40%\"),\n    (\"enhancement\", \"181 72% 78%\"),\n];\n\npub struct TagRepository;\n\nimpl TagRepository {\n    pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Tag>, TagError> {\n        let record = sqlx::query_as!(\n            Tag,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                name        AS \"name!\",\n                color       AS \"color!\"\n            FROM tags\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        id: Option<Uuid>,\n        project_id: Uuid,\n        name: String,\n        color: String,\n    ) -> Result<MutationResponse<Tag>, TagError> {\n        let mut tx = super::begin_tx(pool).await?;\n\n        let id = id.unwrap_or_else(Uuid::new_v4);\n        let data = sqlx::query_as!(\n            Tag,\n            r#\"\n            INSERT INTO tags (id, project_id, name, color)\n            VALUES ($1, $2, $3, $4)\n            RETURNING\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                name        AS \"name!\",\n                color       AS \"color!\"\n            \"#,\n            id,\n            project_id,\n            name,\n            color\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    /// Update a tag with partial fields. Uses COALESCE to preserve existing values\n    /// when None is provided.\n    pub async fn update(\n        pool: &PgPool,\n        id: Uuid,\n        name: Option<String>,\n        color: Option<String>,\n    ) -> Result<MutationResponse<Tag>, TagError> {\n        let mut tx = super::begin_tx(pool).await?;\n\n        let data = sqlx::query_as!(\n            Tag,\n            r#\"\n            UPDATE tags\n            SET\n                name = COALESCE($1, name),\n                color = COALESCE($2, color)\n            WHERE id = $3\n            RETURNING\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                name        AS \"name!\",\n                color       AS \"color!\"\n            \"#,\n            name,\n            color,\n            id\n        )\n        .fetch_one(&mut *tx)\n        .await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(MutationResponse { data, txid })\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<DeleteResponse, TagError> {\n        let mut tx = super::begin_tx(pool).await?;\n\n        sqlx::query!(\"DELETE FROM tags WHERE id = $1\", id)\n            .execute(&mut *tx)\n            .await?;\n\n        let txid = get_txid(&mut *tx).await?;\n        tx.commit().await?;\n\n        Ok(DeleteResponse { txid })\n    }\n\n    pub async fn list_by_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<Tag>, TagError> {\n        let records = sqlx::query_as!(\n            Tag,\n            r#\"\n            SELECT\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                name        AS \"name!\",\n                color       AS \"color!\"\n            FROM tags\n            WHERE project_id = $1\n            \"#,\n            project_id\n        )\n        .fetch_all(pool)\n        .await?;\n\n        Ok(records)\n    }\n\n    pub async fn create_default_tags<'e, E>(\n        executor: E,\n        project_id: Uuid,\n    ) -> Result<Vec<Tag>, TagError>\n    where\n        E: Executor<'e, Database = Postgres>,\n    {\n        let names: Vec<String> = DEFAULT_TAGS.iter().map(|(n, _)| (*n).to_string()).collect();\n        let colors: Vec<String> = DEFAULT_TAGS.iter().map(|(_, c)| (*c).to_string()).collect();\n\n        let tags = sqlx::query_as!(\n            Tag,\n            r#\"\n            INSERT INTO tags (id, project_id, name, color)\n            SELECT gen_random_uuid(), $1, name, color\n            FROM UNNEST($2::text[], $3::text[]) AS t(name, color)\n            RETURNING\n                id          AS \"id!: Uuid\",\n                project_id  AS \"project_id!: Uuid\",\n                name        AS \"name!\",\n                color       AS \"color!\"\n            \"#,\n            project_id,\n            &names,\n            &colors\n        )\n        .fetch_all(executor)\n        .await?;\n\n        Ok(tags)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/types.rs",
    "content": "/// Validates that a string is in HSL format: \"H S% L%\"\n/// where H is 0-360, S is 0-100%, L is 0-100%\npub fn is_valid_hsl_color(color: &str) -> bool {\n    let parts: Vec<&str> = color.split(' ').collect();\n    if parts.len() != 3 {\n        return false;\n    }\n\n    // Parse hue (0-360)\n    let Some(h) = parts[0].parse::<u16>().ok() else {\n        return false;\n    };\n    if h > 360 {\n        return false;\n    }\n\n    // Parse saturation (0-100%)\n    let Some(s_str) = parts[1].strip_suffix('%') else {\n        return false;\n    };\n    let Some(s) = s_str.parse::<u8>().ok() else {\n        return false;\n    };\n    if s > 100 {\n        return false;\n    }\n\n    // Parse lightness (0-100%)\n    let Some(l_str) = parts[2].strip_suffix('%') else {\n        return false;\n    };\n    let Some(l) = l_str.parse::<u8>().ok() else {\n        return false;\n    };\n    if l > 100 {\n        return false;\n    }\n\n    true\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_valid_hsl_colors() {\n        assert!(is_valid_hsl_color(\"0 0% 0%\"));\n        assert!(is_valid_hsl_color(\"360 100% 100%\"));\n        assert!(is_valid_hsl_color(\"217 91% 60%\"));\n        assert!(is_valid_hsl_color(\"355 65% 53%\"));\n        assert!(is_valid_hsl_color(\"220 9% 46%\"));\n    }\n\n    #[test]\n    fn test_invalid_hsl_colors() {\n        assert!(!is_valid_hsl_color(\"#ff0000\")); // HEX format\n        assert!(!is_valid_hsl_color(\"361 50% 50%\")); // Hue out of range\n        assert!(!is_valid_hsl_color(\"180 101% 50%\")); // Saturation out of range\n        assert!(!is_valid_hsl_color(\"180 50% 101%\")); // Lightness out of range\n        assert!(!is_valid_hsl_color(\"180 50 50%\")); // Missing % on saturation\n        assert!(!is_valid_hsl_color(\"180 50% 50\")); // Missing % on lightness\n        assert!(!is_valid_hsl_color(\"hsl(180, 50%, 50%)\")); // Wrong format\n        assert!(!is_valid_hsl_color(\"180, 50%, 50%\")); // Wrong separator\n        assert!(!is_valid_hsl_color(\"\")); // Empty\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/db/users.rs",
    "content": "use api_types::{User, UserData};\nuse sqlx::{PgPool, query_as};\nuse uuid::Uuid;\n\nuse super::{Tx, identity_errors::IdentityError};\n\n#[derive(Debug, Clone)]\npub struct UpsertUser<'a> {\n    pub id: Uuid,\n    pub email: &'a str,\n    pub first_name: Option<&'a str>,\n    pub last_name: Option<&'a str>,\n    pub username: Option<&'a str>,\n}\n\npub struct UserRepository<'a> {\n    pool: &'a PgPool,\n}\n\nimpl<'a> UserRepository<'a> {\n    pub fn new(pool: &'a PgPool) -> Self {\n        Self { pool }\n    }\n\n    pub async fn upsert_user(&self, user: UpsertUser<'_>) -> Result<User, IdentityError> {\n        upsert_user(self.pool, &user)\n            .await\n            .map_err(IdentityError::from)\n    }\n\n    pub async fn fetch_user(&self, user_id: Uuid) -> Result<User, IdentityError> {\n        query_as!(\n            User,\n            r#\"\n            SELECT\n                id           AS \"id!: Uuid\",\n                email        AS \"email!\",\n                first_name   AS \"first_name?\",\n                last_name    AS \"last_name?\",\n                username     AS \"username?\",\n                created_at   AS \"created_at!\",\n                updated_at   AS \"updated_at!\"\n            FROM users\n            WHERE id = $1\n            \"#,\n            user_id\n        )\n        .fetch_optional(self.pool)\n        .await?\n        .ok_or(IdentityError::NotFound)\n    }\n}\n\nasync fn upsert_user(pool: &PgPool, user: &UpsertUser<'_>) -> Result<User, sqlx::Error> {\n    query_as!(\n        User,\n        r#\"\n        INSERT INTO users (id, email, first_name, last_name, username)\n        VALUES ($1, $2, $3, $4, $5)\n        ON CONFLICT (id) DO UPDATE\n        SET email = EXCLUDED.email,\n            first_name = EXCLUDED.first_name,\n            last_name = EXCLUDED.last_name,\n            username = EXCLUDED.username\n        RETURNING\n            id           AS \"id!: Uuid\",\n            email        AS \"email!\",\n            first_name   AS \"first_name?\",\n            last_name    AS \"last_name?\",\n            username     AS \"username?\",\n            created_at   AS \"created_at!\",\n            updated_at   AS \"updated_at!\"\n        \"#,\n        user.id,\n        user.email,\n        user.first_name,\n        user.last_name,\n        user.username\n    )\n    .fetch_one(pool)\n    .await\n}\n\npub async fn fetch_user(tx: &mut Tx<'_>, user_id: Uuid) -> Result<Option<UserData>, IdentityError> {\n    sqlx::query!(\n        r#\"\n        SELECT\n            id         AS \"id!: Uuid\",\n            first_name AS \"first_name?\",\n            last_name  AS \"last_name?\",\n            username   AS \"username?\"\n        FROM users\n        WHERE id = $1\n        \"#,\n        user_id\n    )\n    .fetch_optional(&mut **tx)\n    .await\n    .map_err(IdentityError::from)\n    .map(|row_opt| {\n        row_opt.map(|row| UserData {\n            user_id: row.id,\n            first_name: row.first_name,\n            last_name: row.last_name,\n            username: row.username,\n        })\n    })\n}\n"
  },
  {
    "path": "crates/remote/src/db/workspaces.rs",
    "content": "use api_types::Workspace;\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum WorkspaceError {\n    #[error(\"database error: {0}\")]\n    Database(#[from] sqlx::Error),\n}\n\npub struct CreateWorkspaceParams {\n    pub project_id: Uuid,\n    pub owner_user_id: Uuid,\n    pub local_workspace_id: Option<Uuid>,\n    pub issue_id: Option<Uuid>,\n    pub name: Option<String>,\n    pub archived: Option<bool>,\n    pub files_changed: Option<i32>,\n    pub lines_added: Option<i32>,\n    pub lines_removed: Option<i32>,\n}\n\npub struct WorkspaceRepository;\n\nimpl WorkspaceRepository {\n    pub async fn list_by_owner(\n        pool: &PgPool,\n        owner_user_id: Uuid,\n    ) -> Result<Vec<Workspace>, WorkspaceError> {\n        let records = sqlx::query_as!(\n            Workspace,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                owner_user_id       AS \"owner_user_id!: Uuid\",\n                issue_id            AS \"issue_id: Uuid\",\n                local_workspace_id  AS \"local_workspace_id: Uuid\",\n                name                AS \"name: String\",\n                archived            AS \"archived!: bool\",\n                files_changed       AS \"files_changed: i32\",\n                lines_added         AS \"lines_added: i32\",\n                lines_removed       AS \"lines_removed: i32\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM workspaces\n            WHERE owner_user_id = $1\n            \"#,\n            owner_user_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn list_by_project(\n        pool: &PgPool,\n        project_id: Uuid,\n    ) -> Result<Vec<Workspace>, WorkspaceError> {\n        let records = sqlx::query_as!(\n            Workspace,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                owner_user_id       AS \"owner_user_id!: Uuid\",\n                issue_id            AS \"issue_id: Uuid\",\n                local_workspace_id  AS \"local_workspace_id: Uuid\",\n                name                AS \"name: String\",\n                archived            AS \"archived!: bool\",\n                files_changed       AS \"files_changed: i32\",\n                lines_added         AS \"lines_added: i32\",\n                lines_removed       AS \"lines_removed: i32\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM workspaces\n            WHERE project_id = $1\n            \"#,\n            project_id\n        )\n        .fetch_all(pool)\n        .await?;\n        Ok(records)\n    }\n\n    pub async fn create(\n        pool: &PgPool,\n        params: CreateWorkspaceParams,\n    ) -> Result<Workspace, WorkspaceError> {\n        let CreateWorkspaceParams {\n            project_id,\n            owner_user_id,\n            local_workspace_id,\n            issue_id,\n            name,\n            archived,\n            files_changed,\n            lines_added,\n            lines_removed,\n        } = params;\n        let archived = archived.unwrap_or(false);\n        let record = sqlx::query_as!(\n            Workspace,\n            r#\"\n            INSERT INTO workspaces (project_id, owner_user_id, local_workspace_id, issue_id, name, archived, files_changed, lines_added, lines_removed)\n            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                owner_user_id       AS \"owner_user_id!: Uuid\",\n                issue_id            AS \"issue_id: Uuid\",\n                local_workspace_id  AS \"local_workspace_id: Uuid\",\n                name                AS \"name: String\",\n                archived            AS \"archived!: bool\",\n                files_changed       AS \"files_changed: i32\",\n                lines_added         AS \"lines_added: i32\",\n                lines_removed       AS \"lines_removed: i32\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            project_id,\n            owner_user_id,\n            local_workspace_id,\n            issue_id,\n            name,\n            archived,\n            files_changed,\n            lines_added,\n            lines_removed\n        )\n        .fetch_one(pool)\n        .await?;\n        Ok(record)\n    }\n\n    pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Workspace>, WorkspaceError> {\n        let record = sqlx::query_as!(\n            Workspace,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                owner_user_id       AS \"owner_user_id!: Uuid\",\n                issue_id            AS \"issue_id: Uuid\",\n                local_workspace_id  AS \"local_workspace_id: Uuid\",\n                name                AS \"name: String\",\n                archived            AS \"archived!: bool\",\n                files_changed       AS \"files_changed: i32\",\n                lines_added         AS \"lines_added: i32\",\n                lines_removed       AS \"lines_removed: i32\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM workspaces\n            WHERE id = $1\n            \"#,\n            id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn find_by_local_id(\n        pool: &PgPool,\n        local_workspace_id: Uuid,\n    ) -> Result<Option<Workspace>, WorkspaceError> {\n        let record = sqlx::query_as!(\n            Workspace,\n            r#\"\n            SELECT\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                owner_user_id       AS \"owner_user_id!: Uuid\",\n                issue_id            AS \"issue_id: Uuid\",\n                local_workspace_id  AS \"local_workspace_id: Uuid\",\n                name                AS \"name: String\",\n                archived            AS \"archived!: bool\",\n                files_changed       AS \"files_changed: i32\",\n                lines_added         AS \"lines_added: i32\",\n                lines_removed       AS \"lines_removed: i32\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            FROM workspaces\n            WHERE local_workspace_id = $1\n            \"#,\n            local_workspace_id\n        )\n        .fetch_optional(pool)\n        .await?;\n\n        Ok(record)\n    }\n\n    pub async fn exists_by_local_id(\n        pool: &PgPool,\n        local_workspace_id: Uuid,\n    ) -> Result<bool, WorkspaceError> {\n        let exists = sqlx::query_scalar!(\n            r#\"SELECT EXISTS(SELECT 1 FROM workspaces WHERE local_workspace_id = $1) AS \"exists!\"\"#,\n            local_workspace_id\n        )\n        .fetch_one(pool)\n        .await?;\n        Ok(exists)\n    }\n\n    pub async fn delete_by_local_id(\n        pool: &PgPool,\n        local_workspace_id: Uuid,\n    ) -> Result<(), WorkspaceError> {\n        sqlx::query!(\n            \"DELETE FROM workspaces WHERE local_workspace_id = $1\",\n            local_workspace_id\n        )\n        .execute(pool)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), WorkspaceError> {\n        sqlx::query!(\"DELETE FROM workspaces WHERE id = $1\", id)\n            .execute(pool)\n            .await?;\n        Ok(())\n    }\n\n    pub async fn count_by_issue_id(pool: &PgPool, issue_id: Uuid) -> Result<i64, WorkspaceError> {\n        let count = sqlx::query_scalar!(\n            r#\"SELECT COUNT(*) AS \"count!\" FROM workspaces WHERE issue_id = $1\"#,\n            issue_id\n        )\n        .fetch_one(pool)\n        .await?;\n        Ok(count)\n    }\n\n    pub async fn update(\n        pool: &PgPool,\n        id: Uuid,\n        name: Option<Option<String>>,\n        archived: Option<bool>,\n        files_changed: Option<Option<i32>>,\n        lines_added: Option<Option<i32>>,\n        lines_removed: Option<Option<i32>>,\n    ) -> Result<Workspace, WorkspaceError> {\n        let update_name = name.is_some();\n        let name_value = name.flatten();\n\n        let update_archived = archived.is_some();\n        let archived_value = archived.unwrap_or(false);\n\n        let update_files_changed = files_changed.is_some();\n        let files_changed_value = files_changed.flatten();\n\n        let update_lines_added = lines_added.is_some();\n        let lines_added_value = lines_added.flatten();\n\n        let update_lines_removed = lines_removed.is_some();\n        let lines_removed_value = lines_removed.flatten();\n\n        let record = sqlx::query_as!(\n            Workspace,\n            r#\"\n            UPDATE workspaces SET\n                name = CASE WHEN $1 THEN $2 ELSE name END,\n                archived = CASE WHEN $3 THEN $4 ELSE archived END,\n                files_changed = CASE WHEN $5 THEN $6 ELSE files_changed END,\n                lines_added = CASE WHEN $7 THEN $8 ELSE lines_added END,\n                lines_removed = CASE WHEN $9 THEN $10 ELSE lines_removed END,\n                updated_at = NOW()\n            WHERE id = $11\n            RETURNING\n                id                  AS \"id!: Uuid\",\n                project_id          AS \"project_id!: Uuid\",\n                owner_user_id       AS \"owner_user_id!: Uuid\",\n                issue_id            AS \"issue_id: Uuid\",\n                local_workspace_id  AS \"local_workspace_id: Uuid\",\n                name                AS \"name: String\",\n                archived            AS \"archived!: bool\",\n                files_changed       AS \"files_changed: i32\",\n                lines_added         AS \"lines_added: i32\",\n                lines_removed       AS \"lines_removed: i32\",\n                created_at          AS \"created_at!: DateTime<Utc>\",\n                updated_at          AS \"updated_at!: DateTime<Utc>\"\n            \"#,\n            update_name,\n            name_value,\n            update_archived,\n            archived_value,\n            update_files_changed,\n            files_changed_value,\n            update_lines_added,\n            lines_added_value,\n            update_lines_removed,\n            lines_removed_value,\n            id\n        )\n        .fetch_one(pool)\n        .await?;\n\n        Ok(record)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/digest/email.rs",
    "content": "use std::collections::{HashMap, VecDeque};\n\nuse api_types::{NotificationPayload, NotificationType};\nuse uuid::Uuid;\n\nuse crate::{\n    db::digest::NotificationDigestRow,\n    mail::{DIGEST_PREVIEW_COUNT, DigestNotificationItem},\n};\n\npub fn build_digest_items(\n    rows: &[NotificationDigestRow],\n    base_url: &str,\n) -> Vec<DigestNotificationItem> {\n    let preview_rows = select_preview_rows(rows);\n\n    preview_rows\n        .iter()\n        .map(|row| {\n            let payload = &row.payload.0;\n            let deeplink = absolute_url(base_url, payload.deeplink_path.as_deref().unwrap_or(\"\"));\n            let copy = build_digest_copy(row);\n\n            DigestNotificationItem {\n                title: copy.title,\n                body: copy.body.unwrap_or_default(),\n                url: deeplink,\n            }\n        })\n        .collect()\n}\n\npub fn notifications_url(base_url: &str) -> String {\n    absolute_url(base_url, \"/notifications\")\n}\n\nfn select_preview_rows(rows: &[NotificationDigestRow]) -> Vec<&NotificationDigestRow> {\n    let mut groups = build_preview_groups(rows);\n    let mut selected = Vec::with_capacity(DIGEST_PREVIEW_COUNT.min(rows.len()));\n\n    while selected.len() < DIGEST_PREVIEW_COUNT {\n        let mut added_in_pass = false;\n\n        for group in &mut groups {\n            if let Some(row) = group.rows.pop_front() {\n                selected.push(row);\n                added_in_pass = true;\n\n                if selected.len() == DIGEST_PREVIEW_COUNT {\n                    break;\n                }\n            }\n        }\n\n        if !added_in_pass {\n            break;\n        }\n    }\n\n    selected\n}\n\nstruct PreviewGroup<'a> {\n    rows: VecDeque<&'a NotificationDigestRow>,\n}\n\nfn build_preview_groups(rows: &[NotificationDigestRow]) -> Vec<PreviewGroup<'_>> {\n    let mut groups: Vec<PreviewGroup<'_>> = Vec::new();\n    let mut issue_group_indexes: HashMap<Uuid, usize> = HashMap::new();\n\n    for row in rows {\n        if let Some(issue_id) = preview_issue_id(row) {\n            if let Some(index) = issue_group_indexes.get(&issue_id).copied() {\n                groups[index].rows.push_back(row);\n            } else {\n                let index = groups.len();\n                groups.push(PreviewGroup {\n                    rows: VecDeque::from([row]),\n                });\n                issue_group_indexes.insert(issue_id, index);\n            }\n        } else {\n            groups.push(PreviewGroup {\n                rows: VecDeque::from([row]),\n            });\n        }\n    }\n\n    groups\n}\n\nfn preview_issue_id(row: &NotificationDigestRow) -> Option<Uuid> {\n    row.payload.0.issue_id.or(row.issue_id)\n}\n\nstruct DigestCopy {\n    title: String,\n    body: Option<String>,\n}\n\nfn build_digest_copy(row: &NotificationDigestRow) -> DigestCopy {\n    let payload = &row.payload.0;\n    let actor_name = &row.actor_name;\n    let issue_label = issue_label(payload);\n\n    let (title, body) = match row.notification_type {\n        NotificationType::IssueCommentAdded => (\n            format!(\"{actor_name} commented on {issue_label}\"),\n            payload\n                .comment_preview\n                .as_deref()\n                .map(clean_preview_text)\n                .filter(|value| !value.is_empty())\n                .map(|value| format!(\"\\\"{}\\\"\", truncate_text(&value, 177)))\n                .or_else(|| issue_context(payload)),\n        ),\n        NotificationType::IssueStatusChanged => {\n            let old_status = clean_optional_text(payload.old_status_name.as_deref());\n            let new_status = clean_optional_text(payload.new_status_name.as_deref());\n\n            let title = match (&old_status, &new_status) {\n                (Some(old_status), Some(new_status)) => format!(\n                    \"{actor_name} changed status of {issue_label} from {old_status} to {new_status}\"\n                ),\n                (_, Some(new_status)) => {\n                    format!(\"{actor_name} changed status of {issue_label} to {new_status}\")\n                }\n                _ => format!(\"{actor_name} changed status of {issue_label}\"),\n            };\n\n            (title, issue_context(payload))\n        }\n        NotificationType::IssueAssigneeChanged => (\n            format!(\"You were assigned to {issue_label} by {actor_name}\"),\n            issue_context(payload),\n        ),\n        NotificationType::IssuePriorityChanged => {\n            let old_priority = payload.old_priority.map(priority_label);\n            let new_priority = payload.new_priority.map(priority_label);\n\n            let title = match (&old_priority, &new_priority) {\n                (Some(old_priority), Some(new_priority)) => format!(\n                    \"{actor_name} changed the priority of {issue_label} from {old_priority} to {new_priority}\"\n                ),\n                (None, Some(new_priority)) => {\n                    format!(\"{actor_name} changed the priority of {issue_label} to {new_priority}\")\n                }\n                _ => format!(\"{actor_name} changed the priority of {issue_label}\"),\n            };\n\n            let body = match (old_priority, new_priority) {\n                (Some(old_priority), Some(new_priority)) => Some(format!(\n                    \"Priority changed from {old_priority} to {new_priority}.\"\n                )),\n                (_, Some(new_priority)) => Some(format!(\"Priority changed to {new_priority}.\")),\n                _ => None,\n            };\n\n            (title, body)\n        }\n        NotificationType::IssueUnassigned => (\n            format!(\"{actor_name} unassigned you from {issue_label}\"),\n            issue_context(payload),\n        ),\n        NotificationType::IssueCommentReaction => {\n            let emoji = clean_optional_text(payload.emoji.as_deref());\n            let title = match &emoji {\n                Some(emoji) => {\n                    format!(\"{actor_name} reacted {emoji} to your comment on {issue_label}\")\n                }\n                None => format!(\"{actor_name} reacted to your comment on {issue_label}\"),\n            };\n            let body = emoji.map(|emoji| format!(\"Reacted with {emoji} to your comment.\"));\n            (title, body)\n        }\n        NotificationType::IssueDeleted => (\n            format!(\"{actor_name} deleted {issue_label}\"),\n            issue_context(payload),\n        ),\n        NotificationType::IssueTitleChanged => {\n            let new_title = clean_optional_text(payload.new_title.as_deref());\n            let title = new_title\n                .as_ref()\n                .map(|value| format!(\"{actor_name} changed the title of {issue_label} to {value}\"))\n                .unwrap_or_else(|| format!(\"{actor_name} changed the title of {issue_label}\"));\n            let body = new_title\n                .map(|new_title| format!(\"New title: {new_title}\"))\n                .or_else(|| issue_context(payload));\n            (title, body)\n        }\n        NotificationType::IssueDescriptionChanged => (\n            format!(\"{actor_name} changed the description on {issue_label}\"),\n            issue_context(payload).map(|issue| format!(\"Updated the description on {issue}.\")),\n        ),\n    };\n\n    DigestCopy {\n        title,\n        body: body.map(|value| truncate_text(&value, 180)),\n    }\n}\n\nfn issue_context(payload: &NotificationPayload) -> Option<String> {\n    clean_optional_text(payload.issue_title.as_deref())\n        .or_else(|| clean_optional_text(payload.issue_simple_id.as_deref()))\n}\n\nfn issue_label(payload: &NotificationPayload) -> String {\n    clean_optional_text(payload.issue_simple_id.as_deref()).unwrap_or_else(|| \"issue\".to_string())\n}\n\nfn priority_label(priority: api_types::IssuePriority) -> &'static str {\n    match priority {\n        api_types::IssuePriority::Urgent => \"Urgent\",\n        api_types::IssuePriority::High => \"High\",\n        api_types::IssuePriority::Medium => \"Medium\",\n        api_types::IssuePriority::Low => \"Low\",\n    }\n}\n\nfn clean_optional_text(value: Option<&str>) -> Option<String> {\n    value\n        .map(str::trim)\n        .filter(|value| !value.is_empty())\n        .map(ToOwned::to_owned)\n}\n\nfn clean_preview_text(value: &str) -> String {\n    value.split_whitespace().collect::<Vec<_>>().join(\" \")\n}\n\nfn truncate_text(value: &str, max_chars: usize) -> String {\n    let trimmed = value.trim();\n    let char_count = trimmed.chars().count();\n    if char_count <= max_chars {\n        return trimmed.to_string();\n    }\n\n    let truncated = trimmed.chars().take(max_chars).collect::<String>();\n    format!(\"{}...\", truncated.trim_end())\n}\n\nfn absolute_url(base_url: &str, deeplink_path: &str) -> String {\n    let base_url = base_url.trim_end_matches('/');\n    let deeplink_path = deeplink_path.trim_start_matches('/');\n    format!(\"{base_url}/{deeplink_path}\")\n}\n"
  },
  {
    "path": "crates/remote/src/digest/index.mjml",
    "content": "<mjml>\n  <mj-head>\n    <mj-attributes>\n      <mj-all font-family=\"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif\" />\n      <mj-text font-size=\"14px\" line-height=\"1.5\" color=\"#262626\" />\n      <mj-section padding=\"0\" />\n    </mj-attributes>\n    <mj-style>\n      a { color: #D07A2F; text-decoration: none; }\n      .notification-title a { font-weight: 700; font-size: 14px; line-height: 1.4; color: #D07A2F; text-decoration: none; }\n      .notification-body { margin-top: 8px; color: #262626; font-size: 14px; line-height: 1.5; }\n    </mj-style>\n  </mj-head>\n\n  <mj-body background-color=\"#F9FAFB\">\n    <mj-section padding=\"20px 0 0\" />\n\n    <mj-section padding=\"32px 24px 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-text font-size=\"22px\" font-weight=\"700\" line-height=\"1.3\" color=\"#262626\">\n          Hey {firstName}, you have {EVENT_PROPERTY:notificationCount} new notifications\n        </mj-text>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-text css-class=\"notification-title\">\n          <a href=\"{EVENT_PROPERTY:notification0Url}\">{EVENT_PROPERTY:notification0Title}</a>\n        </mj-text>\n        <mj-text css-class=\"notification-body\" padding-top=\"4px\">\n          {EVENT_PROPERTY:notification0Body}\n        </mj-text>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-divider border-color=\"#E5E7EB\" border-width=\"1px\" padding=\"0 0 24px\" />\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-text css-class=\"notification-title\">\n          <a href=\"{EVENT_PROPERTY:notification1Url}\">{EVENT_PROPERTY:notification1Title}</a>\n        </mj-text>\n        <mj-text css-class=\"notification-body\" padding-top=\"4px\">\n          {EVENT_PROPERTY:notification1Body}\n        </mj-text>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-divider border-color=\"#E5E7EB\" border-width=\"1px\" padding=\"0 0 24px\" />\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-text css-class=\"notification-title\">\n          <a href=\"{EVENT_PROPERTY:notification2Url}\">{EVENT_PROPERTY:notification2Title}</a>\n        </mj-text>\n        <mj-text css-class=\"notification-body\" padding-top=\"4px\">\n          {EVENT_PROPERTY:notification2Body}\n        </mj-text>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-divider border-color=\"#E5E7EB\" border-width=\"1px\" padding=\"0 0 24px\" />\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-text css-class=\"notification-title\">\n          <a href=\"{EVENT_PROPERTY:notification3Url}\">{EVENT_PROPERTY:notification3Title}</a>\n        </mj-text>\n        <mj-text css-class=\"notification-body\" padding-top=\"4px\">\n          {EVENT_PROPERTY:notification3Body}\n        </mj-text>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-divider border-color=\"#E5E7EB\" border-width=\"1px\" padding=\"0 0 24px\" />\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 24px 24px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-text css-class=\"notification-title\">\n          <a href=\"{EVENT_PROPERTY:notification4Url}\">{EVENT_PROPERTY:notification4Title}</a>\n        </mj-text>\n        <mj-text css-class=\"notification-body\" padding-top=\"4px\">\n          {EVENT_PROPERTY:notification4Body}\n        </mj-text>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"8px 24px 32px\" background-color=\"#FFFFFF\">\n      <mj-column>\n        <mj-button background-color=\"#D07A2F\" color=\"#FFFFFF\" font-size=\"14px\" font-weight=\"600\" border-radius=\"6px\" inner-padding=\"12px 24px\" href=\"{EVENT_PROPERTY:notificationsUrl}\">\n          View all notifications\n        </mj-button>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"24px\">\n      <mj-column>\n        <mj-text font-size=\"12px\" color=\"#9CA3AF\" align=\"center\">\n          You're receiving this because you have unread notifications on Vibe Kanban.\n        </mj-text>\n        <mj-text font-size=\"12px\" color=\"#9CA3AF\" align=\"center\" padding-top=\"4px\">\n          <a href=\"{unsubscribe_link}\" style=\"color: #9CA3AF; text-decoration: underline;\">Unsubscribe</a>\n        </mj-text>\n      </mj-column>\n    </mj-section>\n\n    <mj-section padding=\"0 0 20px\" />\n  </mj-body>\n</mjml>\n"
  },
  {
    "path": "crates/remote/src/digest/mod.rs",
    "content": "pub mod email;\npub mod task;\n\nuse std::time::Duration;\n\nuse chrono::{DateTime, Utc};\nuse sqlx::PgPool;\nuse thiserror::Error;\nuse tracing::{info, warn};\n\nuse crate::{\n    db::digest::DigestRepository,\n    mail::{DIGEST_PREVIEW_COUNT, DigestContact, Mailer},\n};\n\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct DigestUser {\n    pub id: uuid::Uuid,\n    pub email: String,\n    pub first_name: Option<String>,\n    pub last_name: Option<String>,\n    pub username: Option<String>,\n}\n\n#[derive(Debug, Default)]\npub struct DigestStats {\n    pub users_processed: u32,\n    pub emails_sent: u32,\n    pub errors: u32,\n}\n\n#[derive(Debug, Error)]\npub enum DigestError {\n    #[error(\"digest database error: {0}\")]\n    Database(#[from] sqlx::Error),\n    #[error(\"loops event failed for digest: status={status}, body={body}\")]\n    LoopsSendFailed {\n        status: reqwest::StatusCode,\n        body: String,\n    },\n    #[error(\"loops request error for digest: {0}\")]\n    LoopsRequest(#[from] reqwest::Error),\n    #[error(\"invalid digest window duration\")]\n    InvalidWindowDuration,\n}\n\npub async fn run_email_digest(\n    pool: &PgPool,\n    mailer: &dyn Mailer,\n    base_url: &str,\n    now: DateTime<Utc>,\n    window: Duration,\n    send_delay: Duration,\n) -> Result<DigestStats, DigestError> {\n    let (window_start, window_end) = digest_window(now, window)?;\n    let mut stats = DigestStats::default();\n\n    let users =\n        DigestRepository::fetch_users_with_pending_notifications(pool, window_start, window_end)\n            .await?;\n\n    info!(\n        window_start = %window_start,\n        window_end = %window_end,\n        user_count = users.len(),\n        \"Digest: found users with pending notifications\"\n    );\n\n    for user in &users {\n        stats.users_processed += 1;\n\n        match process_user_digest(pool, mailer, base_url, user, window_start, window_end).await {\n            Ok(sent) => stats.emails_sent += sent,\n            Err(e) => {\n                warn!(user_id = %user.id, error = %e, \"Digest: failed to process user\");\n                stats.errors += 1;\n            }\n        }\n\n        if !send_delay.is_zero() {\n            tokio::time::sleep(send_delay).await;\n        }\n    }\n\n    Ok(stats)\n}\n\nasync fn process_user_digest(\n    pool: &PgPool,\n    mailer: &dyn Mailer,\n    base_url: &str,\n    user: &DigestUser,\n    window_start: DateTime<Utc>,\n    window_end: DateTime<Utc>,\n) -> Result<u32, DigestError> {\n    let notification_rows =\n        DigestRepository::fetch_notifications_for_user(pool, user.id, window_start, window_end)\n            .await?;\n\n    if notification_rows.len() < DIGEST_PREVIEW_COUNT {\n        return Ok(0);\n    }\n\n    let total_count = notification_rows.len() as i32;\n    let notification_ids = notification_rows\n        .iter()\n        .map(|row| row.id)\n        .collect::<Vec<_>>();\n\n    let items = email::build_digest_items(&notification_rows, base_url);\n    let notifications_url = email::notifications_url(base_url);\n    let contact = DigestContact {\n        email: &user.email,\n        user_id: &user.id.to_string(),\n        first_name: user.first_name.as_deref(),\n        last_name: user.last_name.as_deref(),\n    };\n\n    mailer\n        .send_digest_event(&contact, total_count, &items, &notifications_url)\n        .await?;\n\n    DigestRepository::record_notifications_delivered(pool, &notification_ids).await?;\n\n    Ok(1)\n}\n\nfn digest_window(\n    now: DateTime<Utc>,\n    window: Duration,\n) -> Result<(DateTime<Utc>, DateTime<Utc>), DigestError> {\n    let lookback =\n        chrono::Duration::from_std(window).map_err(|_| DigestError::InvalidWindowDuration)?;\n    let window_end = now;\n    let window_start = window_end - lookback;\n\n    Ok((window_start, window_end))\n}\n"
  },
  {
    "path": "crates/remote/src/digest/task.rs",
    "content": "use std::{panic::AssertUnwindSafe, sync::Arc, time::Duration};\n\nuse chrono::{DateTime, Days, Timelike, Utc};\nuse futures::FutureExt;\nuse sqlx::PgPool;\nuse tokio::task::JoinHandle;\nuse tracing::{error, info, warn};\n\nuse crate::{\n    db::digest::{DigestRepository, DigestRunLock},\n    digest::run_email_digest,\n    mail::Mailer,\n};\n\nconst DEFAULT_WINDOW: Duration = Duration::from_secs(86400);\nconst DEFAULT_RUN_HOUR_UTC: u32 = 8;\nconst DEFAULT_SEND_DELAY: Duration = Duration::from_millis(100);\n\npub fn spawn_digest_task(\n    pool: PgPool,\n    mailer: Arc<dyn Mailer>,\n    base_url: String,\n) -> JoinHandle<()> {\n    let interval_override = std::env::var(\"DIGEST_INTERVAL_SECS_OVERRIDE\")\n        .ok()\n        .and_then(|v| v.parse::<u64>().ok())\n        .map(Duration::from_secs);\n    let run_hour_utc = std::env::var(\"DIGEST_RUN_HOUR_UTC\")\n        .ok()\n        .and_then(|v| v.parse::<u32>().ok())\n        .filter(|hour| *hour < 24)\n        .unwrap_or(DEFAULT_RUN_HOUR_UTC);\n    let window = std::env::var(\"DIGEST_WINDOW_SECS_OVERRIDE\")\n        .ok()\n        .and_then(|v| v.parse::<u64>().ok())\n        .map(Duration::from_secs)\n        .unwrap_or(DEFAULT_WINDOW);\n    let send_delay = std::env::var(\"DIGEST_SEND_DELAY_MS\")\n        .ok()\n        .and_then(|v| v.parse::<u64>().ok())\n        .map(Duration::from_millis)\n        .unwrap_or(DEFAULT_SEND_DELAY);\n\n    match interval_override {\n        Some(interval) => info!(\n            interval_secs = interval.as_secs(),\n            window_secs = window.as_secs(),\n            \"Starting notification digest background task with interval override\"\n        ),\n        None => info!(\n            run_hour_utc,\n            window_secs = window.as_secs(),\n            \"Starting notification digest background task\"\n        ),\n    }\n\n    tokio::spawn(async move {\n        let result = AssertUnwindSafe(digest_loop(\n            &pool,\n            mailer.as_ref(),\n            &base_url,\n            interval_override,\n            run_hour_utc,\n            window,\n            send_delay,\n        ));\n\n        if let Err(panic) = result.catch_unwind().await {\n            let msg = panic\n                .downcast_ref::<&str>()\n                .map(|s| s.to_string())\n                .or_else(|| panic.downcast_ref::<String>().cloned())\n                .unwrap_or_else(|| \"unknown panic\".to_string());\n            error!(panic = %msg, \"Notification digest task died — digests will not be sent until next deploy\");\n        }\n    })\n}\n\nasync fn digest_loop(\n    pool: &PgPool,\n    mailer: &dyn Mailer,\n    base_url: &str,\n    interval_override: Option<Duration>,\n    run_hour_utc: u32,\n    window: Duration,\n    send_delay: Duration,\n) {\n    loop {\n        if let Some(interval) = interval_override {\n            tokio::time::sleep(interval).await;\n        } else {\n            let now = Utc::now();\n            let next_run = next_run_at(now, run_hour_utc);\n            let sleep_duration = (next_run - now)\n                .to_std()\n                .unwrap_or_else(|_| Duration::from_secs(0));\n\n            info!(next_run = %next_run, sleep_secs = sleep_duration.as_secs(), \"Next notification digest scheduled\");\n            tokio::time::sleep(sleep_duration).await;\n        }\n\n        let Some(lock) = acquire_run_lock(pool).await else {\n            continue;\n        };\n\n        match run_email_digest(pool, mailer, base_url, Utc::now(), window, send_delay).await {\n            Ok(stats) => {\n                info!(\n                    users_processed = stats.users_processed,\n                    emails_sent = stats.emails_sent,\n                    errors = stats.errors,\n                    \"Notification digest cycle complete\"\n                );\n            }\n            Err(e) => {\n                error!(error = %e, \"Notification digest cycle failed\");\n            }\n        }\n\n        if let Err(error) = lock.release().await {\n            warn!(error = %error, \"Failed to release notification digest lock\");\n        }\n    }\n}\n\nasync fn acquire_run_lock(pool: &PgPool) -> Option<DigestRunLock> {\n    match DigestRepository::try_acquire_run_lock(pool).await {\n        Ok(Some(lock)) => Some(lock),\n        Ok(None) => {\n            info!(\"Skipping notification digest cycle because another instance is running it\");\n            None\n        }\n        Err(error) => {\n            error!(error = %error, \"Failed to acquire notification digest lock\");\n            None\n        }\n    }\n}\n\nfn next_run_at(now: DateTime<Utc>, run_hour_utc: u32) -> DateTime<Utc> {\n    let today = now.date_naive();\n    let today_run = today\n        .and_hms_opt(run_hour_utc, 0, 0)\n        .expect(\"validated digest hour\");\n\n    let next_naive = if now.hour() < run_hour_utc {\n        today_run\n    } else {\n        today\n            .checked_add_days(Days::new(1))\n            .expect(\"date overflow for digest schedule\")\n            .and_hms_opt(run_hour_utc, 0, 0)\n            .expect(\"validated digest hour\")\n    };\n\n    DateTime::from_naive_utc_and_offset(next_naive, Utc)\n}\n"
  },
  {
    "path": "crates/remote/src/github_app/jwt.rs",
    "content": "use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse jsonwebtoken::{Algorithm, EncodingKey, Header, encode};\nuse secrecy::{ExposeSecret, SecretString};\nuse serde::Serialize;\nuse thiserror::Error;\n\n/// JWT generator for GitHub App authentication.\n/// GitHub Apps authenticate using RS256-signed JWTs with a 10-minute max TTL.\n#[derive(Clone)]\npub struct GitHubAppJwt {\n    app_id: u64,\n    private_key_pem: SecretString,\n}\n\n#[derive(Debug, Error)]\npub enum JwtError {\n    #[error(\"invalid private key: {0}\")]\n    InvalidPrivateKey(String),\n    #[error(\"failed to encode JWT: {0}\")]\n    EncodingError(#[from] jsonwebtoken::errors::Error),\n    #[error(\"invalid base64 encoding\")]\n    Base64Error,\n}\n\n#[derive(Debug, Serialize)]\nstruct GitHubAppClaims {\n    /// Issuer - the GitHub App ID\n    iss: String,\n    /// Issued at (Unix timestamp)\n    iat: i64,\n    /// Expiration (Unix timestamp) - max 10 minutes from iat\n    exp: i64,\n}\n\nimpl GitHubAppJwt {\n    /// Create a new JWT generator from base64-encoded PEM private key\n    pub fn new(app_id: u64, private_key_base64: SecretString) -> Result<Self, JwtError> {\n        // Decode base64 to get raw PEM\n        let pem_bytes = BASE64_STANDARD\n            .decode(private_key_base64.expose_secret().as_bytes())\n            .map_err(|_| JwtError::Base64Error)?;\n\n        let pem_string = String::from_utf8(pem_bytes)\n            .map_err(|_| JwtError::InvalidPrivateKey(\"PEM is not valid UTF-8\".to_string()))?;\n\n        // Validate we can parse this as an RSA key\n        EncodingKey::from_rsa_pem(pem_string.as_bytes())\n            .map_err(|e| JwtError::InvalidPrivateKey(e.to_string()))?;\n\n        Ok(Self {\n            app_id,\n            private_key_pem: SecretString::new(pem_string.into()),\n        })\n    }\n\n    /// Generate a JWT for authenticating as the GitHub App.\n    /// This JWT is used to get installation access tokens.\n    /// Max TTL is 10 minutes as per GitHub's requirements.\n    pub fn generate(&self) -> Result<String, JwtError> {\n        let now = chrono::Utc::now().timestamp();\n        // Subtract 60 seconds from iat to account for clock drift\n        let iat = now - 60;\n        // GitHub allows max 10 minutes, we use 9 to be safe\n        let exp = now + (9 * 60);\n\n        let claims = GitHubAppClaims {\n            iss: self.app_id.to_string(),\n            iat,\n            exp,\n        };\n\n        let header = Header::new(Algorithm::RS256);\n        let key = EncodingKey::from_rsa_pem(self.private_key_pem.expose_secret().as_bytes())?;\n\n        encode(&header, &claims, &key).map_err(JwtError::EncodingError)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    // Test with a dummy key - in real tests you'd use a proper test key\n    #[test]\n    fn test_invalid_base64_fails() {\n        let result = GitHubAppJwt::new(12345, SecretString::new(\"not-valid-base64!!!\".into()));\n        assert!(matches!(result, Err(JwtError::Base64Error)));\n    }\n\n    #[test]\n    fn test_invalid_pem_fails() {\n        // Valid base64, but not a valid PEM\n        let invalid_pem_b64 = BASE64_STANDARD.encode(\"not a real pem key\");\n        let result = GitHubAppJwt::new(12345, SecretString::new(invalid_pem_b64.into()));\n        assert!(matches!(result, Err(JwtError::InvalidPrivateKey(_))));\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/github_app/mod.rs",
    "content": "mod jwt;\nmod pr_review;\nmod service;\nmod webhook;\n\npub use jwt::GitHubAppJwt;\npub use pr_review::{PrReviewError, PrReviewParams, PrReviewService};\npub use service::{GitHubAppService, InstallationInfo, PrDetails, PrRef, Repository};\npub use webhook::verify_webhook_signature;\n"
  },
  {
    "path": "crates/remote/src/github_app/pr_review.rs",
    "content": "//! PR Review service for webhook-triggered code reviews.\n\nuse std::{fs::File, path::Path};\n\nuse flate2::{Compression, write::GzEncoder};\nuse reqwest::Client;\nuse sqlx::PgPool;\nuse tar::Builder;\nuse thiserror::Error;\nuse tracing::{debug, error, info};\nuse uuid::Uuid;\n\nuse super::service::{GitHubAppError, GitHubAppService};\nuse crate::{\n    db::reviews::{CreateWebhookReviewParams, ReviewError, ReviewRepository},\n    r2::{R2Error, R2Service},\n};\n\n/// Parameters extracted from webhook payload for PR review\n#[derive(Debug, Clone)]\npub struct PrReviewParams {\n    pub installation_id: i64,\n    pub owner: String,\n    pub repo: String,\n    pub pr_number: u64,\n    pub pr_title: String,\n    pub pr_body: String,\n    pub head_sha: String,\n    pub base_ref: String, // Branch name like \"main\" - used to calculate merge-base\n}\n\n#[derive(Debug, Error)]\npub enum PrReviewError {\n    #[error(\"GitHub error: {0}\")]\n    GitHub(#[from] GitHubAppError),\n    #[error(\"R2 error: {0}\")]\n    R2(#[from] R2Error),\n    #[error(\"Database error: {0}\")]\n    Database(#[from] ReviewError),\n    #[error(\"Archive error: {0}\")]\n    Archive(String),\n    #[error(\"Worker error: {0}\")]\n    Worker(String),\n}\n\n/// Service for processing webhook-triggered PR reviews\npub struct PrReviewService {\n    github_app: GitHubAppService,\n    r2: R2Service,\n    http_client: Client,\n    worker_base_url: String,\n    server_base_url: String,\n}\n\nimpl PrReviewService {\n    pub fn new(\n        github_app: GitHubAppService,\n        r2: R2Service,\n        http_client: Client,\n        worker_base_url: String,\n        server_base_url: String,\n    ) -> Self {\n        Self {\n            github_app,\n            r2,\n            http_client,\n            worker_base_url,\n            server_base_url,\n        }\n    }\n\n    /// Process a PR review from webhook.\n    ///\n    /// This will:\n    /// 1. Clone the repository at the PR head commit\n    /// 2. Create a tarball of the repository\n    /// 3. Upload the tarball to R2\n    /// 4. Create a review record in the database\n    /// 5. Start the review worker\n    ///\n    /// Returns the review ID on success.\n    pub async fn process_pr_review(\n        &self,\n        pool: &PgPool,\n        params: PrReviewParams,\n    ) -> Result<Uuid, PrReviewError> {\n        let review_id = Uuid::new_v4();\n\n        info!(\n            review_id = %review_id,\n            owner = %params.owner,\n            repo = %params.repo,\n            pr_number = params.pr_number,\n            \"Starting webhook PR review\"\n        );\n\n        // 1. Clone the repository\n        let temp_dir = self\n            .github_app\n            .clone_repo(\n                params.installation_id,\n                &params.owner,\n                &params.repo,\n                &params.head_sha,\n            )\n            .await?;\n\n        debug!(review_id = %review_id, \"Repository cloned\");\n\n        // 2. Calculate merge-base for accurate diff computation\n        let base_commit =\n            GitHubAppService::get_merge_base(temp_dir.path(), &params.base_ref).await?;\n        debug!(review_id = %review_id, base_commit = %base_commit, \"Merge-base calculated\");\n\n        // 3. Create tarball\n        let source_dir = temp_dir.path().to_path_buf();\n        let tarball = tokio::task::spawn_blocking(move || create_tarball(&source_dir))\n            .await\n            .map_err(|e| PrReviewError::Archive(format!(\"Tarball task failed: {e}\")))?\n            .map_err(|e| PrReviewError::Archive(e.to_string()))?;\n\n        let tarball_size_mb = tarball.len() as f64 / 1_048_576.0;\n        debug!(review_id = %review_id, size_mb = tarball_size_mb, \"Tarball created\");\n\n        // 4. Upload to R2\n        let r2_path = self.r2.upload_bytes(review_id, tarball).await?;\n        debug!(review_id = %review_id, r2_path = %r2_path, \"Uploaded to R2\");\n\n        // 5. Create review record in database\n        let gh_pr_url = format!(\n            \"https://github.com/{}/{}/pull/{}\",\n            params.owner, params.repo, params.pr_number\n        );\n\n        let repo = ReviewRepository::new(pool);\n        repo.create_webhook_review(CreateWebhookReviewParams {\n            id: review_id,\n            gh_pr_url: &gh_pr_url,\n            r2_path: &r2_path,\n            pr_title: &params.pr_title,\n            github_installation_id: params.installation_id,\n            pr_owner: &params.owner,\n            pr_repo: &params.repo,\n            pr_number: params.pr_number as i32,\n        })\n        .await?;\n\n        debug!(review_id = %review_id, \"Review record created\");\n\n        // 6. Start the review worker\n        let codebase_url = format!(\n            \"{}/reviews/{}/payload.tar.gz\",\n            self.r2_public_url(),\n            review_id\n        );\n        let callback_url = format!(\"{}/review/{}\", self.server_base_url, review_id);\n\n        let start_request = serde_json::json!({\n            \"id\": review_id.to_string(),\n            \"title\": params.pr_title,\n            \"description\": params.pr_body,\n            \"org\": params.owner,\n            \"repo\": params.repo,\n            \"codebaseUrl\": codebase_url,\n            \"baseCommit\": base_commit,\n            \"callbackUrl\": callback_url,\n        });\n\n        let response = self\n            .http_client\n            .post(format!(\"{}/review/start\", self.worker_base_url))\n            .json(&start_request)\n            .send()\n            .await\n            .map_err(|e| PrReviewError::Worker(format!(\"Failed to call worker: {e}\")))?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let body = response.text().await.unwrap_or_default();\n            error!(review_id = %review_id, status = %status, body = %body, \"Worker returned error\");\n            return Err(PrReviewError::Worker(format!(\n                \"Worker returned {}: {}\",\n                status, body\n            )));\n        }\n\n        info!(review_id = %review_id, \"Review worker started successfully\");\n\n        Ok(review_id)\n    }\n\n    /// Get the public URL for R2 (used to construct codebase URLs for the worker).\n    /// This assumes the R2 bucket has public read access configured.\n    fn r2_public_url(&self) -> &str {\n        // The worker needs to be able to fetch the tarball from R2.\n        // This is typically configured via a public bucket URL or CDN.\n        // For now, we'll use the worker base URL as a proxy assumption.\n        // In production, this should be configured separately.\n        &self.worker_base_url\n    }\n}\n\n/// Create a tar.gz archive from a directory\nfn create_tarball(source_dir: &Path) -> Result<Vec<u8>, std::io::Error> {\n    debug!(\"Creating tarball from {}\", source_dir.display());\n\n    let mut buffer = Vec::new();\n\n    {\n        let encoder = GzEncoder::new(&mut buffer, Compression::default());\n        let mut archive = Builder::new(encoder);\n\n        add_directory_to_archive(&mut archive, source_dir, source_dir)?;\n\n        let encoder = archive.into_inner()?;\n        encoder.finish()?;\n    }\n\n    debug!(\"Created tarball: {} bytes\", buffer.len());\n\n    Ok(buffer)\n}\n\nfn add_directory_to_archive<W: std::io::Write>(\n    archive: &mut Builder<W>,\n    base_dir: &Path,\n    current_dir: &Path,\n) -> Result<(), std::io::Error> {\n    let entries = std::fs::read_dir(current_dir)?;\n\n    for entry in entries {\n        let entry = entry?;\n        let path = entry.path();\n\n        let relative_path = path.strip_prefix(base_dir).map_err(std::io::Error::other)?;\n\n        let metadata = entry.metadata()?;\n\n        if metadata.is_dir() {\n            // Recursively add directory contents\n            add_directory_to_archive(archive, base_dir, &path)?;\n        } else if metadata.is_file() {\n            // Add file to archive\n            let mut file = File::open(&path)?;\n            archive.append_file(relative_path, &mut file)?;\n        }\n        // Skip symlinks and other special files\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/remote/src/github_app/service.rs",
    "content": "use reqwest::Client;\nuse secrecy::SecretString;\nuse serde::{Deserialize, Serialize};\nuse tempfile::TempDir;\nuse thiserror::Error;\nuse tokio::process::Command;\nuse tracing::{debug, info, warn};\n\nuse super::jwt::{GitHubAppJwt, JwtError};\nuse crate::config::GitHubAppConfig;\n\nconst USER_AGENT: &str = \"VibeKanbanRemote/1.0\";\nconst GITHUB_API_BASE: &str = \"https://api.github.com\";\n\n#[derive(Debug, Error)]\npub enum GitHubAppError {\n    #[error(\"JWT error: {0}\")]\n    Jwt(#[from] JwtError),\n    #[error(\"HTTP request failed: {0}\")]\n    Http(#[from] reqwest::Error),\n    #[error(\"GitHub API error: {status} - {message}\")]\n    Api { status: u16, message: String },\n    #[error(\"Installation not found\")]\n    InstallationNotFound,\n    #[error(\"Git operation failed: {0}\")]\n    GitOperation(String),\n}\n\n/// Information about a GitHub App installation\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct InstallationInfo {\n    pub id: i64,\n    pub account: InstallationAccount,\n    pub repository_selection: String, // \"all\" or \"selected\"\n    pub suspended_at: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct InstallationAccount {\n    pub login: String,\n    #[serde(rename = \"type\")]\n    pub account_type: String, // \"Organization\" or \"User\"\n    pub id: i64,\n}\n\n/// A repository accessible via an installation\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Repository {\n    pub id: i64,\n    pub full_name: String,\n    pub name: String,\n    pub private: bool,\n}\n\n#[derive(Debug, Deserialize)]\nstruct InstallationTokenResponse {\n    token: String,\n    expires_at: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct RepositoriesResponse {\n    repositories: Vec<Repository>,\n}\n\n/// Details about a pull request\n#[derive(Debug, Clone, Deserialize)]\npub struct PrDetails {\n    pub title: String,\n    pub body: Option<String>,\n    pub head: PrRef,\n    pub base: PrRef,\n}\n\n/// A git ref (branch/commit) in a PR\n#[derive(Debug, Clone, Deserialize)]\npub struct PrRef {\n    pub sha: String,\n    #[serde(rename = \"ref\")]\n    pub ref_name: String,\n}\n\n/// Service for interacting with the GitHub App API\n#[derive(Clone)]\npub struct GitHubAppService {\n    jwt_generator: GitHubAppJwt,\n    client: Client,\n    app_slug: String,\n    webhook_secret: SecretString,\n}\n\nimpl GitHubAppService {\n    pub fn new(config: &GitHubAppConfig, client: Client) -> Result<Self, GitHubAppError> {\n        let jwt_generator = GitHubAppJwt::new(config.app_id, config.private_key.clone())?;\n\n        Ok(Self {\n            jwt_generator,\n            client,\n            app_slug: config.app_slug.clone(),\n            webhook_secret: config.webhook_secret.clone(),\n        })\n    }\n\n    /// Get the app slug for constructing installation URLs\n    pub fn app_slug(&self) -> &str {\n        &self.app_slug\n    }\n\n    /// Get the webhook secret for signature verification\n    pub fn webhook_secret(&self) -> &SecretString {\n        &self.webhook_secret\n    }\n\n    /// Get an installation access token for making API calls on behalf of an installation\n    pub async fn get_installation_token(\n        &self,\n        installation_id: i64,\n    ) -> Result<String, GitHubAppError> {\n        let jwt = self.jwt_generator.generate()?;\n\n        let url = format!(\n            \"{}/app/installations/{}/access_tokens\",\n            GITHUB_API_BASE, installation_id\n        );\n\n        let response = self\n            .client\n            .post(&url)\n            .header(\"Authorization\", format!(\"Bearer {}\", jwt))\n            .header(\"Accept\", \"application/vnd.github+json\")\n            .header(\"User-Agent\", USER_AGENT)\n            .header(\"X-GitHub-Api-Version\", \"2022-11-28\")\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let status = response.status().as_u16();\n            let message = response.text().await.unwrap_or_default();\n            warn!(\n                installation_id,\n                status, message, \"Failed to get installation token\"\n            );\n            return Err(GitHubAppError::Api { status, message });\n        }\n\n        let token_response: InstallationTokenResponse = response.json().await?;\n        info!(\n            installation_id,\n            expires_at = %token_response.expires_at,\n            \"Got installation access token\"\n        );\n\n        Ok(token_response.token)\n    }\n\n    /// Get details about a specific installation\n    pub async fn get_installation(\n        &self,\n        installation_id: i64,\n    ) -> Result<InstallationInfo, GitHubAppError> {\n        let jwt = self.jwt_generator.generate()?;\n\n        let url = format!(\"{}/app/installations/{}\", GITHUB_API_BASE, installation_id);\n\n        let response = self\n            .client\n            .get(&url)\n            .header(\"Authorization\", format!(\"Bearer {}\", jwt))\n            .header(\"Accept\", \"application/vnd.github+json\")\n            .header(\"User-Agent\", USER_AGENT)\n            .header(\"X-GitHub-Api-Version\", \"2022-11-28\")\n            .send()\n            .await?;\n\n        if response.status() == reqwest::StatusCode::NOT_FOUND {\n            return Err(GitHubAppError::InstallationNotFound);\n        }\n\n        if !response.status().is_success() {\n            let status = response.status().as_u16();\n            let message = response.text().await.unwrap_or_default();\n            return Err(GitHubAppError::Api { status, message });\n        }\n\n        let installation: InstallationInfo = response.json().await?;\n        Ok(installation)\n    }\n\n    /// List repositories accessible to an installation (handles pagination for 100+ repos)\n    pub async fn list_installation_repos(\n        &self,\n        installation_id: i64,\n    ) -> Result<Vec<Repository>, GitHubAppError> {\n        let token = self.get_installation_token(installation_id).await?;\n        let url = format!(\"{}/installation/repositories\", GITHUB_API_BASE);\n\n        let mut all_repos = Vec::new();\n        let mut page = 1u32;\n\n        loop {\n            let response = self\n                .client\n                .get(&url)\n                .header(\"Authorization\", format!(\"Bearer {}\", token))\n                .header(\"Accept\", \"application/vnd.github+json\")\n                .header(\"User-Agent\", USER_AGENT)\n                .header(\"X-GitHub-Api-Version\", \"2022-11-28\")\n                .query(&[(\"per_page\", \"100\"), (\"page\", &page.to_string())])\n                .send()\n                .await?;\n\n            if !response.status().is_success() {\n                let status = response.status().as_u16();\n                let message = response.text().await.unwrap_or_default();\n                return Err(GitHubAppError::Api { status, message });\n            }\n\n            let repos_response: RepositoriesResponse = response.json().await?;\n            let count = repos_response.repositories.len();\n            all_repos.extend(repos_response.repositories);\n\n            // If we got fewer than 100, we've reached the last page\n            if count < 100 {\n                break;\n            }\n            page += 1;\n        }\n\n        Ok(all_repos)\n    }\n\n    /// Post a comment on a pull request\n    pub async fn post_pr_comment(\n        &self,\n        installation_id: i64,\n        owner: &str,\n        repo: &str,\n        pr_number: u64,\n        body: &str,\n    ) -> Result<(), GitHubAppError> {\n        let token = self.get_installation_token(installation_id).await?;\n\n        // Use the issues API to post comments (PRs are issues in GitHub)\n        let url = format!(\n            \"{}/repos/{}/{}/issues/{}/comments\",\n            GITHUB_API_BASE, owner, repo, pr_number\n        );\n\n        let response = self\n            .client\n            .post(&url)\n            .header(\"Authorization\", format!(\"Bearer {}\", token))\n            .header(\"Accept\", \"application/vnd.github+json\")\n            .header(\"User-Agent\", USER_AGENT)\n            .header(\"X-GitHub-Api-Version\", \"2022-11-28\")\n            .json(&serde_json::json!({ \"body\": body }))\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let status = response.status().as_u16();\n            let message = response.text().await.unwrap_or_default();\n            warn!(\n                owner,\n                repo, pr_number, status, message, \"Failed to post PR comment\"\n            );\n            return Err(GitHubAppError::Api { status, message });\n        }\n\n        info!(owner, repo, pr_number, \"Posted PR comment\");\n        Ok(())\n    }\n\n    /// Clone a repository using the installation token for authentication.\n    ///\n    /// Returns a TempDir containing the cloned repository at the specified commit.\n    /// The TempDir will be automatically cleaned up when dropped.\n    pub async fn clone_repo(\n        &self,\n        installation_id: i64,\n        owner: &str,\n        repo: &str,\n        head_sha: &str,\n    ) -> Result<TempDir, GitHubAppError> {\n        let token = self.get_installation_token(installation_id).await?;\n\n        // Create temp directory\n        let temp_dir = tempfile::tempdir()\n            .map_err(|e| GitHubAppError::GitOperation(format!(\"Failed to create temp dir: {e}\")))?;\n\n        let clone_url = format!(\n            \"https://x-access-token:{}@github.com/{}/{}.git\",\n            token, owner, repo\n        );\n\n        debug!(owner, repo, head_sha, \"Cloning repository\");\n\n        // Clone the repository with security flags to prevent code execution from untrusted repos\n        // Note: We do a full clone (not shallow) to ensure git history is available for merge-base calculation\n        let output = Command::new(\"git\")\n            .args([\n                \"-c\",\n                \"core.hooksPath=/dev/null\",\n                \"-c\",\n                \"core.autocrlf=false\",\n                \"-c\",\n                \"core.symlinks=false\",\n                \"clone\",\n                &clone_url,\n                \".\",\n            ])\n            .env(\"GIT_CONFIG_GLOBAL\", \"/dev/null\")\n            .env(\"GIT_CONFIG_SYSTEM\", \"/dev/null\")\n            .env(\"GIT_TERMINAL_PROMPT\", \"0\")\n            .current_dir(temp_dir.path())\n            .output()\n            .await\n            .map_err(|e| {\n                if e.kind() == std::io::ErrorKind::NotFound {\n                    GitHubAppError::GitOperation(\"git is not installed or not in PATH\".to_string())\n                } else {\n                    GitHubAppError::GitOperation(format!(\"Failed to run git clone: {e}\"))\n                }\n            })?;\n\n        if !output.status.success() {\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            // Redact the token from error messages\n            let redacted_stderr = stderr.replace(&token, \"[REDACTED]\");\n            return Err(GitHubAppError::GitOperation(format!(\n                \"git clone failed: {redacted_stderr}\"\n            )));\n        }\n\n        // Fetch the specific commit (in case it's not in the default branch)\n        let output = Command::new(\"git\")\n            .args([\n                \"-c\",\n                \"core.hooksPath=/dev/null\",\n                \"fetch\",\n                \"origin\",\n                head_sha,\n            ])\n            .env(\"GIT_CONFIG_GLOBAL\", \"/dev/null\")\n            .env(\"GIT_CONFIG_SYSTEM\", \"/dev/null\")\n            .env(\"GIT_TERMINAL_PROMPT\", \"0\")\n            .current_dir(temp_dir.path())\n            .output()\n            .await\n            .map_err(|e| {\n                if e.kind() == std::io::ErrorKind::NotFound {\n                    GitHubAppError::GitOperation(\"git is not installed or not in PATH\".to_string())\n                } else {\n                    GitHubAppError::GitOperation(format!(\"Failed to run git fetch: {e}\"))\n                }\n            })?;\n\n        if !output.status.success() {\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            let redacted_stderr = stderr.replace(&token, \"[REDACTED]\");\n            return Err(GitHubAppError::GitOperation(format!(\n                \"git fetch failed: {redacted_stderr}\"\n            )));\n        }\n\n        // Checkout the specific commit\n        let output = Command::new(\"git\")\n            .args([\"-c\", \"core.hooksPath=/dev/null\", \"checkout\", head_sha])\n            .env(\"GIT_CONFIG_GLOBAL\", \"/dev/null\")\n            .env(\"GIT_CONFIG_SYSTEM\", \"/dev/null\")\n            .env(\"GIT_TERMINAL_PROMPT\", \"0\")\n            .current_dir(temp_dir.path())\n            .output()\n            .await\n            .map_err(|e| {\n                if e.kind() == std::io::ErrorKind::NotFound {\n                    GitHubAppError::GitOperation(\"git is not installed or not in PATH\".to_string())\n                } else {\n                    GitHubAppError::GitOperation(format!(\"Failed to run git checkout: {e}\"))\n                }\n            })?;\n\n        if !output.status.success() {\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            return Err(GitHubAppError::GitOperation(format!(\n                \"git checkout failed: {stderr}\"\n            )));\n        }\n\n        info!(owner, repo, head_sha, \"Repository cloned successfully\");\n        Ok(temp_dir)\n    }\n\n    /// Calculate the merge-base between the current HEAD and the base branch.\n    /// This gives the correct base commit for computing diffs, even if the base branch has moved.\n    pub async fn get_merge_base(\n        repo_dir: &std::path::Path,\n        base_ref: &str,\n    ) -> Result<String, GitHubAppError> {\n        let output = Command::new(\"git\")\n            .args([\"merge-base\", &format!(\"origin/{}\", base_ref), \"HEAD\"])\n            .env(\"GIT_CONFIG_GLOBAL\", \"/dev/null\")\n            .env(\"GIT_CONFIG_SYSTEM\", \"/dev/null\")\n            .current_dir(repo_dir)\n            .output()\n            .await\n            .map_err(|e| GitHubAppError::GitOperation(format!(\"merge-base failed: {e}\")))?;\n\n        if !output.status.success() {\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            return Err(GitHubAppError::GitOperation(format!(\n                \"merge-base failed: {stderr}\"\n            )));\n        }\n\n        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())\n    }\n\n    /// Get details about a pull request\n    pub async fn get_pr_details(\n        &self,\n        installation_id: i64,\n        owner: &str,\n        repo: &str,\n        pr_number: u64,\n    ) -> Result<PrDetails, GitHubAppError> {\n        let token = self.get_installation_token(installation_id).await?;\n\n        let url = format!(\n            \"{}/repos/{}/{}/pulls/{}\",\n            GITHUB_API_BASE, owner, repo, pr_number\n        );\n\n        let response = self\n            .client\n            .get(&url)\n            .header(\"Authorization\", format!(\"Bearer {}\", token))\n            .header(\"Accept\", \"application/vnd.github+json\")\n            .header(\"User-Agent\", USER_AGENT)\n            .header(\"X-GitHub-Api-Version\", \"2022-11-28\")\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let status = response.status().as_u16();\n            let message = response.text().await.unwrap_or_default();\n            return Err(GitHubAppError::Api { status, message });\n        }\n\n        let pr: PrDetails = response.json().await?;\n        Ok(pr)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/github_app/webhook.rs",
    "content": "use hmac::{Hmac, Mac};\nuse sha2::Sha256;\nuse subtle::ConstantTimeEq;\n\ntype HmacSha256 = Hmac<Sha256>;\n\n/// Verify a GitHub webhook signature.\n///\n/// GitHub sends the HMAC-SHA256 signature in the `X-Hub-Signature-256` header\n/// in the format `sha256=<hex-signature>`.\n///\n/// Returns true if the signature is valid.\npub fn verify_webhook_signature(secret: &[u8], signature_header: &str, payload: &[u8]) -> bool {\n    // Extract the hex signature from the header\n    let Some(hex_signature) = signature_header.strip_prefix(\"sha256=\") else {\n        return false;\n    };\n\n    // Decode the hex signature\n    let Ok(expected_signature) = hex::decode(hex_signature) else {\n        return false;\n    };\n\n    // Compute HMAC-SHA256\n    let Ok(mut mac) = HmacSha256::new_from_slice(secret) else {\n        return false;\n    };\n    mac.update(payload);\n    let computed_signature = mac.finalize().into_bytes();\n\n    // Constant-time comparison to prevent timing attacks\n    computed_signature[..].ct_eq(&expected_signature).into()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_valid_signature() {\n        let secret = b\"test-secret\";\n        let payload = b\"test payload\";\n\n        // Compute expected signature\n        let mut mac = HmacSha256::new_from_slice(secret).unwrap();\n        mac.update(payload);\n        let signature = mac.finalize().into_bytes();\n        let signature_header = format!(\"sha256={}\", hex::encode(signature));\n\n        assert!(verify_webhook_signature(secret, &signature_header, payload));\n    }\n\n    #[test]\n    fn test_invalid_signature() {\n        let secret = b\"test-secret\";\n        let payload = b\"test payload\";\n        let wrong_signature =\n            \"sha256=0000000000000000000000000000000000000000000000000000000000000000\";\n\n        assert!(!verify_webhook_signature(secret, wrong_signature, payload));\n    }\n\n    #[test]\n    fn test_missing_prefix() {\n        let secret = b\"test-secret\";\n        let payload = b\"test payload\";\n        let no_prefix = \"0000000000000000000000000000000000000000000000000000000000000000\";\n\n        assert!(!verify_webhook_signature(secret, no_prefix, payload));\n    }\n\n    #[test]\n    fn test_invalid_hex() {\n        let secret = b\"test-secret\";\n        let payload = b\"test payload\";\n        let invalid_hex = \"sha256=not-valid-hex\";\n\n        assert!(!verify_webhook_signature(secret, invalid_hex, payload));\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/lib.rs",
    "content": "mod analytics;\nmod app;\npub mod attachments;\npub mod audit;\nmod auth;\npub mod azure_blob;\nmod billing;\npub mod config;\npub mod db;\npub mod digest;\npub mod github_app;\npub mod mail;\nmod middleware;\npub mod mutation_definition;\npub mod notifications;\npub mod r2;\npub mod routes;\npub mod shape_definition;\npub mod shape_route;\npub mod shape_routes;\npub mod shapes;\nmod shared_key_auth;\nmod state;\n\nuse std::env;\n\npub use app::Server;\npub use billing::{BillingCheckError, BillingService};\nuse opentelemetry::trace::TracerProvider as _;\npub use state::AppState;\nuse tracing_error::ErrorLayer;\nuse tracing_subscriber::{\n    Layer,\n    fmt::{self, format::FmtSpan},\n    layer::SubscriberExt,\n    util::SubscriberInitExt,\n};\npub use utils::sentry::{SentrySource, init_once as sentry_init_once};\n\nfn init_otel_layer<S>() -> Option<Box<dyn Layer<S> + Send + Sync>>\nwhere\n    S: tracing::Subscriber\n        + for<'span> tracing_subscriber::registry::LookupSpan<'span>\n        + Send\n        + Sync,\n{\n    let connection_string = env::var(\"APPLICATIONINSIGHTS_CONNECTION_STRING\").ok()?;\n    if connection_string.is_empty() {\n        return None;\n    }\n\n    // Create the background client using std::thread::spawn.\n    // https://github.com/frigus02/opentelemetry-application-insights/blob/6d3ac4505c0c47e448bb8de4ac67d904f8eacb76/src/lib.rs#L168\n    let http_client = std::thread::spawn(otel_reqwest::blocking::Client::new)\n        .join()\n        .ok()?;\n\n    let exporter = opentelemetry_application_insights::Exporter::new_from_connection_string(\n        &connection_string,\n        http_client,\n    )\n    .ok()?;\n\n    let service_name =\n        env::var(\"OTEL_SERVICE_NAME\").unwrap_or_else(|_| \"vibe-kanban-remote\".to_string());\n\n    let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()\n        .with_resource(\n            opentelemetry_sdk::Resource::builder()\n                .with_service_name(service_name)\n                .build(),\n        )\n        .with_batch_exporter(exporter)\n        .build();\n\n    // Register globally so the provider outlives this function.\n    // Without this, Drop shuts down the batch exporter and no spans export.\n    opentelemetry::global::set_tracer_provider(provider.clone());\n\n    let tracer = provider.tracer(\"vibe-kanban-remote\");\n    let layer = tracing_opentelemetry::OpenTelemetryLayer::new(tracer);\n    Some(layer.boxed())\n}\n\npub fn init_tracing() {\n    if tracing::dispatcher::has_been_set() {\n        return;\n    }\n\n    let env_filter = env::var(\"RUST_LOG\").unwrap_or_else(|_| \"info,sqlx=warn\".to_string());\n    let fmt_layer = fmt::layer()\n        .json()\n        .with_target(false)\n        .with_span_events(FmtSpan::CLOSE)\n        .boxed();\n\n    let otel_layer = init_otel_layer();\n    let otel_enabled = otel_layer.is_some();\n\n    tracing_subscriber::registry()\n        .with(tracing_subscriber::EnvFilter::new(env_filter))\n        .with(ErrorLayer::default())\n        .with(fmt_layer)\n        .with(otel_layer)\n        .with(utils::sentry::sentry_layer())\n        .init();\n\n    tracing::info!(\n        otel_enabled,\n        \"Tracing initialized ({})\",\n        if otel_enabled {\n            \"stdout + Application Insights\"\n        } else {\n            \"stdout only\"\n        }\n    );\n}\n\npub fn configure_user_scope(user_id: uuid::Uuid, username: Option<&str>, email: Option<&str>) {\n    utils::sentry::configure_user_scope(&user_id.to_string(), username, email);\n}\n"
  },
  {
    "path": "crates/remote/src/mail.rs",
    "content": "use std::time::Duration;\n\nuse api_types::MemberRole;\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse crate::digest::DigestError;\n\nconst LOOPS_INVITE_TEMPLATE_ID: &str = \"cmhvy2wgs3s13z70i1pxakij9\";\nconst LOOPS_REVIEW_READY_TEMPLATE_ID: &str = \"cmj47k5ge16990iylued9by17\";\nconst LOOPS_REVIEW_FAILED_TEMPLATE_ID: &str = \"cmj49ougk1c8s0iznavijdqpo\";\n\npub const DIGEST_PREVIEW_COUNT: usize = 5;\n\n#[derive(Debug, Clone)]\npub struct DigestContact<'a> {\n    pub email: &'a str,\n    pub user_id: &'a str,\n    pub first_name: Option<&'a str>,\n    pub last_name: Option<&'a str>,\n}\n\n#[derive(Debug, Clone)]\npub struct DigestNotificationItem {\n    pub title: String,\n    pub body: String,\n    pub url: String,\n}\n\n#[async_trait]\npub trait Mailer: Send + Sync {\n    async fn send_org_invitation(\n        &self,\n        org_name: &str,\n        email: &str,\n        accept_url: &str,\n        role: MemberRole,\n        invited_by: Option<&str>,\n    );\n\n    async fn send_review_ready(&self, email: &str, review_url: &str, pr_name: &str);\n\n    async fn send_review_failed(&self, email: &str, pr_name: &str, review_id: &str);\n\n    async fn send_digest_event(\n        &self,\n        contact: &DigestContact<'_>,\n        notification_count: i32,\n        items: &[DigestNotificationItem],\n        notifications_url: &str,\n    ) -> Result<(), DigestError>;\n}\n\n/// No-op mailer used when `LOOPS_EMAIL_API_KEY` is not configured.\npub struct NoopMailer;\n\n#[async_trait]\nimpl Mailer for NoopMailer {\n    async fn send_org_invitation(\n        &self,\n        org_name: &str,\n        email: &str,\n        _accept_url: &str,\n        _role: MemberRole,\n        _invited_by: Option<&str>,\n    ) {\n        tracing::warn!(\n            email = %email,\n            org_name = %org_name,\n            \"Email service not configured — skipping org invitation email. Set LOOPS_EMAIL_API_KEY to enable.\"\n        );\n    }\n\n    async fn send_review_ready(&self, email: &str, _review_url: &str, pr_name: &str) {\n        tracing::warn!(\n            email = %email,\n            pr_name = %pr_name,\n            \"Email service not configured — skipping review ready email. Set LOOPS_EMAIL_API_KEY to enable.\"\n        );\n    }\n\n    async fn send_review_failed(&self, email: &str, pr_name: &str, _review_id: &str) {\n        tracing::warn!(\n            email = %email,\n            pr_name = %pr_name,\n            \"Email service not configured — skipping review failed email. Set LOOPS_EMAIL_API_KEY to enable.\"\n        );\n    }\n\n    async fn send_digest_event(\n        &self,\n        contact: &DigestContact<'_>,\n        notification_count: i32,\n        _items: &[DigestNotificationItem],\n        _notifications_url: &str,\n    ) -> Result<(), DigestError> {\n        tracing::warn!(\n            email = %contact.email,\n            notification_count,\n            \"Email service not configured — skipping digest event. Set LOOPS_EMAIL_API_KEY to enable.\"\n        );\n\n        Ok(())\n    }\n}\n\npub struct LoopsMailer {\n    client: reqwest::Client,\n    api_key: String,\n}\n\nimpl LoopsMailer {\n    pub fn new(api_key: String) -> Self {\n        let client = reqwest::Client::builder()\n            .timeout(Duration::from_secs(5))\n            .build()\n            .expect(\"failed to build reqwest client\");\n\n        Self { client, api_key }\n    }\n}\n\n#[async_trait]\nimpl Mailer for LoopsMailer {\n    async fn send_org_invitation(\n        &self,\n        org_name: &str,\n        email: &str,\n        accept_url: &str,\n        role: MemberRole,\n        invited_by: Option<&str>,\n    ) {\n        let role_str = match role {\n            MemberRole::Admin => \"admin\",\n            MemberRole::Member => \"member\",\n        };\n        let inviter = invited_by.unwrap_or(\"someone\");\n\n        if cfg!(debug_assertions) {\n            tracing::info!(\n                \"Sending invitation email to {email}\\n\\\n                 Organization: {org_name}\\n\\\n                 Role: {role_str}\\n\\\n                 Invited by: {inviter}\\n\\\n                 Accept URL: {accept_url}\"\n            );\n        }\n\n        let payload = json!({\n            \"transactionalId\": LOOPS_INVITE_TEMPLATE_ID,\n            \"email\": email,\n            \"dataVariables\": {\n                \"org_name\": org_name,\n                \"accept_url\": accept_url,\n                \"invited_by\": inviter,\n            }\n        });\n\n        let res = self\n            .client\n            .post(\"https://app.loops.so/api/v1/transactional\")\n            .bearer_auth(&self.api_key)\n            .json(&payload)\n            .send()\n            .await;\n\n        match res {\n            Ok(resp) if resp.status().is_success() => {\n                tracing::debug!(\"Invitation email sent via Loops to {email}\");\n            }\n            Ok(resp) => {\n                let status = resp.status();\n                let body = resp.text().await.unwrap_or_default();\n                tracing::warn!(status = %status, body = %body, \"Loops send failed\");\n            }\n            Err(err) => {\n                tracing::error!(error = ?err, \"Loops request error\");\n            }\n        }\n    }\n\n    async fn send_review_ready(&self, email: &str, review_url: &str, pr_name: &str) {\n        if cfg!(debug_assertions) {\n            tracing::info!(\n                \"Sending review ready email to {email}\\n\\\n                 PR: {pr_name}\\n\\\n                 Review URL: {review_url}\"\n            );\n        }\n\n        let payload = json!({\n            \"transactionalId\": LOOPS_REVIEW_READY_TEMPLATE_ID,\n            \"email\": email,\n            \"dataVariables\": {\n                \"review_url\": review_url,\n                \"pr_name\": pr_name,\n            }\n        });\n\n        let res = self\n            .client\n            .post(\"https://app.loops.so/api/v1/transactional\")\n            .bearer_auth(&self.api_key)\n            .json(&payload)\n            .send()\n            .await;\n\n        match res {\n            Ok(resp) if resp.status().is_success() => {\n                tracing::debug!(\"Review ready email sent via Loops to {email}\");\n            }\n            Ok(resp) => {\n                let status = resp.status();\n                let body = resp.text().await.unwrap_or_default();\n                tracing::warn!(status = %status, body = %body, \"Loops send failed for review ready\");\n            }\n            Err(err) => {\n                tracing::error!(error = ?err, \"Loops request error for review ready\");\n            }\n        }\n    }\n\n    async fn send_review_failed(&self, email: &str, pr_name: &str, review_id: &str) {\n        if cfg!(debug_assertions) {\n            tracing::info!(\n                \"Sending review failed email to {email}\\n\\\n                 PR: {pr_name}\\n\\\n                 Review ID: {review_id}\"\n            );\n        }\n\n        let payload = json!({\n            \"transactionalId\": LOOPS_REVIEW_FAILED_TEMPLATE_ID,\n            \"email\": email,\n            \"dataVariables\": {\n                \"pr_name\": pr_name,\n                \"review_id\": review_id,\n            }\n        });\n\n        let res = self\n            .client\n            .post(\"https://app.loops.so/api/v1/transactional\")\n            .bearer_auth(&self.api_key)\n            .json(&payload)\n            .send()\n            .await;\n\n        match res {\n            Ok(resp) if resp.status().is_success() => {\n                tracing::debug!(\"Review failed email sent via Loops to {email}\");\n            }\n            Ok(resp) => {\n                let status = resp.status();\n                let body = resp.text().await.unwrap_or_default();\n                tracing::warn!(status = %status, body = %body, \"Loops send failed for review failed\");\n            }\n            Err(err) => {\n                tracing::error!(error = ?err, \"Loops request error for review failed\");\n            }\n        }\n    }\n\n    async fn send_digest_event(\n        &self,\n        contact: &DigestContact<'_>,\n        notification_count: i32,\n        items: &[DigestNotificationItem],\n        notifications_url: &str,\n    ) -> Result<(), DigestError> {\n        if cfg!(debug_assertions) {\n            tracing::info!(\n                \"Firing sendDigest event for {}\\n\\\n                 User ID: {}\\n\\\n                 First name: {:?}\\n\\\n                 Last name: {:?}\\n\\\n                 Total notifications: {notification_count}\\n\\\n                 Items: {}\\n\\\n                 Notifications URL: {notifications_url}\",\n                contact.email,\n                contact.user_id,\n                contact.first_name,\n                contact.last_name,\n                items.len()\n            );\n        }\n\n        let mut event_properties = serde_json::Map::new();\n        event_properties.insert(\"notificationCount\".into(), json!(notification_count));\n        event_properties.insert(\"notificationsUrl\".into(), json!(notifications_url));\n\n        for (i, item) in items.iter().take(DIGEST_PREVIEW_COUNT).enumerate() {\n            event_properties.insert(format!(\"notification{i}Title\"), json!(item.title));\n            event_properties.insert(format!(\"notification{i}Body\"), json!(item.body));\n            event_properties.insert(format!(\"notification{i}Url\"), json!(item.url));\n        }\n\n        let mut payload = json!({\n            \"email\": contact.email,\n            \"userId\": contact.user_id,\n            \"eventName\": \"sendDigest\",\n            \"eventProperties\": event_properties,\n        });\n\n        if let Some(first_name) = contact.first_name {\n            payload[\"firstName\"] = json!(first_name);\n        }\n        if let Some(last_name) = contact.last_name {\n            payload[\"lastName\"] = json!(last_name);\n        }\n\n        let res = self\n            .client\n            .post(\"https://app.loops.so/api/v1/events/send\")\n            .bearer_auth(&self.api_key)\n            .json(&payload)\n            .send()\n            .await;\n\n        match res {\n            Ok(resp) if resp.status().is_success() => {\n                tracing::debug!(\"Digest event fired via Loops for {}\", contact.email);\n                Ok(())\n            }\n            Ok(resp) => {\n                let status = resp.status();\n                let body = resp.text().await.unwrap_or_default();\n                Err(DigestError::LoopsSendFailed { status, body })\n            }\n            Err(err) => Err(DigestError::LoopsRequest(err)),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/main.rs",
    "content": "use remote::{\n    BillingService, SentrySource, Server, config::RemoteServerConfig, init_tracing,\n    sentry_init_once,\n};\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n    // Install rustls crypto provider before any TLS operations\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .expect(\"Failed to install rustls crypto provider\");\n\n    sentry_init_once(SentrySource::Remote);\n    init_tracing();\n\n    let config = RemoteServerConfig::from_env()?;\n\n    #[cfg(feature = \"vk-billing\")]\n    let billing = {\n        use std::sync::Arc;\n\n        use billing::{BillingConfig, BillingProvider, StripeBillingProvider};\n        use remote::db;\n\n        match BillingConfig::from_env()? {\n            Some(billing_config) => {\n                let pool = db::create_pool(&config.database_url).await?;\n                let provider: Arc<dyn BillingProvider> = Arc::new(StripeBillingProvider::new(\n                    pool,\n                    billing_config.stripe_secret_key,\n                    billing_config.stripe_price_id,\n                    billing_config.stripe_webhook_secret,\n                    Some(billing_config.free_seat_limit),\n                ));\n                BillingService::new(Some(provider))\n            }\n            None => BillingService::new(None),\n        }\n    };\n\n    #[cfg(not(feature = \"vk-billing\"))]\n    let billing = BillingService::new();\n\n    Server::run(config, billing).await\n}\n"
  },
  {
    "path": "crates/remote/src/middleware/mod.rs",
    "content": "pub mod version;\n"
  },
  {
    "path": "crates/remote/src/middleware/version.rs",
    "content": "use axum::{\n    body::Body,\n    http::{Request, header::HeaderValue},\n    middleware::Next,\n    response::Response,\n};\n\npub async fn add_version_headers(request: Request<Body>, next: Next) -> Response {\n    let mut response = next.run(request).await;\n\n    response.headers_mut().insert(\n        \"X-Server-Version\",\n        HeaderValue::from_static(env!(\"CARGO_PKG_VERSION\")),\n    );\n\n    response\n}\n"
  },
  {
    "path": "crates/remote/src/mutation_definition.rs",
    "content": "//! Mutation definition builder for type-safe route and metadata generation.\n//!\n//! This module provides `MutationBuilder`, a builder that:\n//! - Generates axum routers for CRUD mutation routes\n//! - Captures type information for TypeScript generation\n//! - Uses `HasJsonPayload` to ensure handler signatures match declared C/U types\n//!\n//! # Example\n//!\n//! ```ignore\n//! use crate::mutation_definition::MutationBuilder;\n//!\n//! pub fn mutation() -> MutationBuilder<Tag, CreateTagRequest, UpdateTagRequest> {\n//!     MutationBuilder::new(\"tags\")\n//!         .list(list_tags)\n//!         .get(get_tag)\n//!         .create(create_tag)\n//!         .update(update_tag)\n//!         .delete(delete_tag)\n//! }\n//!\n//! pub fn router() -> Router<AppState> {\n//!     mutation().router()\n//! }\n//! ```\n\nuse std::marker::PhantomData;\n\nuse axum::{Json, handler::Handler, routing::MethodRouter};\nuse ts_rs::TS;\n\nuse crate::AppState;\n\ntype MutationMarker<E, C, U> = fn() -> (E, C, U);\n\n// =============================================================================\n// HasJsonPayload - Structural trait linking handlers to their payload types\n// =============================================================================\n\n/// Marker trait implemented for extractor tuples that include `Json<T>` as payload.\n///\n/// This links MutationBuilder's `C`/`U` generic arguments to the actual handler payload\n/// type and prevents metadata drift from handler signatures.\npub trait HasJsonPayload<T> {}\n\nimpl<T> HasJsonPayload<T> for (Json<T>,) {}\nimpl<A, T> HasJsonPayload<T> for (A, Json<T>) {}\nimpl<A, B, T> HasJsonPayload<T> for (A, B, Json<T>) {}\nimpl<A, B, C, T> HasJsonPayload<T> for (A, B, C, Json<T>) {}\nimpl<A, B, C, D, T> HasJsonPayload<T> for (A, B, C, D, Json<T>) {}\nimpl<A, B, C, D, E0, T> HasJsonPayload<T> for (A, B, C, D, E0, Json<T>) {}\nimpl<A, B, C, D, E0, F, T> HasJsonPayload<T> for (A, B, C, D, E0, F, Json<T>) {}\nimpl<A, B, C, D, E0, F, G, T> HasJsonPayload<T> for (A, B, C, D, E0, F, G, Json<T>) {}\nimpl<A, B, C, D, E0, F, G, H, T> HasJsonPayload<T> for (A, B, C, D, E0, F, G, H, Json<T>) {}\n\n// =============================================================================\n// MutationDefinition - Metadata for TypeScript generation\n// =============================================================================\n\n/// Metadata extracted from a MutationBuilder for TypeScript code generation.\n#[derive(Debug)]\npub struct MutationDefinition {\n    pub table: &'static str,\n    pub row_type: String,\n    pub create_type: Option<String>,\n    pub update_type: Option<String>,\n}\n\n// =============================================================================\n// MutationBuilder Builder\n// =============================================================================\n\n/// Builder for mutation routes and metadata.\n///\n/// Type parameters:\n/// - `E`: The row type (e.g., `Tag`)\n/// - `C`: The create request type, or `NoCreate` if no create\n/// - `U`: The update request type, or `NoUpdate` if no update\npub struct MutationBuilder<E, C = (), U = ()> {\n    table: &'static str,\n    base_route: MethodRouter<AppState>,\n    id_route: MethodRouter<AppState>,\n    _phantom: PhantomData<MutationMarker<E, C, U>>,\n}\n\nimpl<E: TS + Send + Sync + 'static> MutationBuilder<E, NoCreate, NoUpdate> {\n    /// Create a new MutationBuilder for the given table.\n    pub fn new(table: &'static str) -> Self {\n        Self {\n            table,\n            base_route: MethodRouter::new(),\n            id_route: MethodRouter::new(),\n            _phantom: PhantomData,\n        }\n    }\n}\n\nimpl<E: TS, C, U> MutationBuilder<E, C, U> {\n    /// Add a list handler (GET /{table}).\n    pub fn list<H, T>(mut self, handler: H) -> Self\n    where\n        H: Handler<T, AppState> + Clone + Send + 'static,\n        T: 'static,\n    {\n        self.base_route = self.base_route.get(handler);\n        self\n    }\n\n    /// Add a get handler (GET /{table}/{id}).\n    pub fn get<H, T>(mut self, handler: H) -> Self\n    where\n        H: Handler<T, AppState> + Clone + Send + 'static,\n        T: 'static,\n    {\n        self.id_route = self.id_route.get(handler);\n        self\n    }\n\n    /// Add a delete handler (DELETE /{table}/{id}).\n    pub fn delete<H, T>(mut self, handler: H) -> Self\n    where\n        H: Handler<T, AppState> + Clone + Send + 'static,\n        T: 'static,\n    {\n        self.id_route = self.id_route.delete(handler);\n        self\n    }\n\n    /// Build the axum router from the registered handlers.\n    pub fn router(self) -> axum::Router<AppState> {\n        let base_path = format!(\"/{}\", self.table);\n        let id_path = format!(\"/{}/{{id}}\", self.table);\n\n        axum::Router::new()\n            .route(&base_path, self.base_route)\n            .route(&id_path, self.id_route)\n    }\n}\n\nimpl<E: TS, U> MutationBuilder<E, NoCreate, U> {\n    /// Add a create handler (POST /{table}).\n    ///\n    /// The handler's extractor tuple must contain `Json<C>`, ensuring the\n    /// declared create type matches what the handler actually accepts.\n    pub fn create<C, H, T>(self, handler: H) -> MutationBuilder<E, C, U>\n    where\n        C: TS,\n        H: Handler<T, AppState> + Clone + Send + 'static,\n        T: HasJsonPayload<C> + 'static,\n    {\n        MutationBuilder {\n            table: self.table,\n            base_route: self.base_route.post(handler),\n            id_route: self.id_route,\n            _phantom: PhantomData,\n        }\n    }\n}\n\nimpl<E: TS, C> MutationBuilder<E, C, NoUpdate> {\n    /// Add an update handler (PATCH /{table}/{id}).\n    ///\n    /// The handler's extractor tuple must contain `Json<U>`, ensuring the\n    /// declared update type matches what the handler actually accepts.\n    pub fn update<U, H, T>(self, handler: H) -> MutationBuilder<E, C, U>\n    where\n        U: TS,\n        H: Handler<T, AppState> + Clone + Send + 'static,\n        T: HasJsonPayload<U> + 'static,\n    {\n        MutationBuilder {\n            table: self.table,\n            base_route: self.base_route,\n            id_route: self.id_route.patch(handler),\n            _phantom: PhantomData,\n        }\n    }\n}\n\n/// Marker type for mutations without a create endpoint.\npub struct NoCreate;\n\n/// Marker type for mutations without an update endpoint.\npub struct NoUpdate;\n\n// Metadata extraction — one impl per combination of NoCreate/NoUpdate vs real types.\n\nimpl<E: TS, C: TS, U: TS> MutationBuilder<E, C, U> {\n    pub fn definition(&self) -> MutationDefinition {\n        MutationDefinition {\n            table: self.table,\n            row_type: E::name(),\n            create_type: Some(C::name()),\n            update_type: Some(U::name()),\n        }\n    }\n}\n\nimpl<E: TS, U: TS> MutationBuilder<E, NoCreate, U> {\n    pub fn definition(&self) -> MutationDefinition {\n        MutationDefinition {\n            table: self.table,\n            row_type: E::name(),\n            create_type: None,\n            update_type: Some(U::name()),\n        }\n    }\n}\n\nimpl<E: TS, C: TS> MutationBuilder<E, C, NoUpdate> {\n    pub fn definition(&self) -> MutationDefinition {\n        MutationDefinition {\n            table: self.table,\n            row_type: E::name(),\n            create_type: Some(C::name()),\n            update_type: None,\n        }\n    }\n}\n\nimpl<E: TS> MutationBuilder<E, NoCreate, NoUpdate> {\n    pub fn definition(&self) -> MutationDefinition {\n        MutationDefinition {\n            table: self.table,\n            row_type: E::name(),\n            create_type: None,\n            update_type: None,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/notifications.rs",
    "content": "use std::collections::HashSet;\n\nuse api_types::{Issue, NotificationPayload, NotificationType};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\nuse crate::db::{\n    issue_assignees::IssueAssigneeRepository, issue_followers::IssueFollowerRepository,\n    notifications::NotificationRepository, organization_members::is_member,\n};\n\npub async fn notify_issue_subscribers(\n    pool: &PgPool,\n    organization_id: Uuid,\n    actor_user_id: Uuid,\n    issue: &Issue,\n    notification_type: NotificationType,\n    extra_payload: NotificationPayload,\n    comment_id: Option<Uuid>,\n) {\n    let recipients = match collect_issue_recipients(pool, organization_id, issue.id, actor_user_id)\n        .await\n    {\n        Ok(r) => r,\n        Err(e) => {\n            tracing::warn!(?e, issue_id = %issue.id, \"failed to collect notification recipients\");\n            return;\n        }\n    };\n\n    send_issue_notifications(\n        pool,\n        organization_id,\n        actor_user_id,\n        &recipients,\n        issue,\n        notification_type,\n        extra_payload,\n        comment_id,\n        Some(issue.id),\n    )\n    .await;\n}\n\n/// Like `notify_issue_subscribers` but with pre-collected recipients.\n/// Use when recipients must be gathered before an operation (e.g. delete) but\n/// notifications should only be sent after it succeeds.\n#[allow(clippy::too_many_arguments)]\npub async fn send_issue_notifications(\n    pool: &PgPool,\n    organization_id: Uuid,\n    actor_user_id: Uuid,\n    recipients: &[Uuid],\n    issue: &Issue,\n    notification_type: NotificationType,\n    extra_payload: NotificationPayload,\n    comment_id: Option<Uuid>,\n    issue_id: Option<Uuid>,\n) {\n    if recipients.is_empty() {\n        return;\n    }\n\n    let payload = build_payload(issue, actor_user_id, notification_type, extra_payload);\n\n    for &recipient_id in recipients {\n        if let Err(e) = NotificationRepository::create(\n            pool,\n            organization_id,\n            recipient_id,\n            notification_type,\n            payload.clone(),\n            issue_id,\n            comment_id,\n        )\n        .await\n        {\n            tracing::warn!(?e, %recipient_id, issue_id = %issue.id, \"failed to create notification\");\n        }\n    }\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn send_debounced_issue_notifications(\n    pool: &PgPool,\n    organization_id: Uuid,\n    actor_user_id: Uuid,\n    recipients: &[Uuid],\n    issue: &Issue,\n    notification_type: NotificationType,\n    extra_payload: NotificationPayload,\n    comment_id: Option<Uuid>,\n    issue_id: Option<Uuid>,\n) {\n    if recipients.is_empty() {\n        return;\n    }\n\n    let payload = build_payload(issue, actor_user_id, notification_type, extra_payload);\n\n    for &recipient_id in recipients {\n        if let Err(e) = NotificationRepository::upsert_recent(\n            pool,\n            organization_id,\n            recipient_id,\n            notification_type,\n            payload.clone(),\n            issue_id,\n            comment_id,\n        )\n        .await\n        {\n            tracing::warn!(?e, %recipient_id, issue_id = %issue.id, \"failed to upsert notification\");\n        }\n    }\n}\n\npub async fn notify_user(\n    pool: &PgPool,\n    organization_id: Uuid,\n    actor_user_id: Uuid,\n    recipient_user_id: Uuid,\n    issue: &Issue,\n    notification_type: NotificationType,\n    extra_payload: NotificationPayload,\n) {\n    if !is_member(pool, organization_id, recipient_user_id)\n        .await\n        .unwrap_or(false)\n    {\n        return;\n    }\n\n    send_issue_notifications(\n        pool,\n        organization_id,\n        actor_user_id,\n        &[recipient_user_id],\n        issue,\n        notification_type,\n        extra_payload,\n        None,\n        Some(issue.id),\n    )\n    .await;\n}\n\npub async fn collect_issue_recipients(\n    pool: &PgPool,\n    organization_id: Uuid,\n    issue_id: Uuid,\n    exclude_user_id: Uuid,\n) -> Result<Vec<Uuid>, Box<dyn std::error::Error + Send + Sync>> {\n    let assignees = IssueAssigneeRepository::list_by_issue(pool, issue_id).await?;\n    let followers = IssueFollowerRepository::list_by_issue(pool, issue_id).await?;\n\n    let mut user_ids: HashSet<Uuid> = assignees.iter().map(|a| a.user_id).collect();\n    user_ids.extend(followers.iter().map(|f| f.user_id));\n    user_ids.remove(&exclude_user_id);\n\n    let mut recipients = Vec::with_capacity(user_ids.len());\n    for user_id in user_ids {\n        if is_member(pool, organization_id, user_id)\n            .await\n            .unwrap_or(false)\n        {\n            recipients.push(user_id);\n        }\n    }\n\n    Ok(recipients)\n}\n\nfn build_payload(\n    issue: &Issue,\n    actor_user_id: Uuid,\n    notification_type: NotificationType,\n    extra_payload: NotificationPayload,\n) -> NotificationPayload {\n    let deeplink_path = match notification_type {\n        NotificationType::IssueDeleted => format!(\"/projects/{}\", issue.project_id),\n        _ => format!(\"/projects/{}/issues/{}\", issue.project_id, issue.id),\n    };\n\n    NotificationPayload {\n        deeplink_path: Some(deeplink_path),\n        issue_id: Some(issue.id),\n        issue_simple_id: Some(issue.simple_id.clone()),\n        issue_title: Some(issue.title.clone()),\n        actor_user_id: Some(actor_user_id),\n        comment_preview: extra_payload.comment_preview,\n        old_status_id: extra_payload.old_status_id,\n        new_status_id: extra_payload.new_status_id,\n        old_status_name: extra_payload.old_status_name,\n        new_status_name: extra_payload.new_status_name,\n        new_title: extra_payload.new_title,\n        old_priority: extra_payload.old_priority,\n        new_priority: extra_payload.new_priority,\n        assignee_user_id: extra_payload.assignee_user_id,\n        emoji: extra_payload.emoji,\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/r2.rs",
    "content": "use std::time::Duration;\n\nuse aws_credential_types::Credentials;\nuse aws_sdk_s3::{\n    Client,\n    config::{Builder as S3ConfigBuilder, IdentityCache},\n    presigning::PresigningConfig,\n    primitives::ByteStream,\n};\nuse chrono::{DateTime, Utc};\nuse secrecy::ExposeSecret;\nuse uuid::Uuid;\n\nuse crate::config::R2Config;\n\n/// Well-known filename for the payload tarball stored in each review folder.\npub const PAYLOAD_FILENAME: &str = \"payload.tar.gz\";\n\n#[derive(Clone)]\npub struct R2Service {\n    client: Client,\n    bucket: String,\n    presign_expiry: Duration,\n}\n\n#[derive(Debug)]\npub struct PresignedUpload {\n    pub upload_url: String,\n    pub object_key: String,\n    /// Folder path in R2 (e.g., \"reviews/{review_id}\") - this is stored in the database.\n    pub folder_path: String,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum R2Error {\n    #[error(\"presign config error: {0}\")]\n    PresignConfig(String),\n    #[error(\"presign error: {0}\")]\n    Presign(String),\n    #[error(\"upload error: {0}\")]\n    Upload(String),\n}\n\nimpl R2Service {\n    pub fn new(config: &R2Config) -> Self {\n        let credentials = Credentials::new(\n            &config.access_key_id,\n            config.secret_access_key.expose_secret(),\n            None,\n            None,\n            \"r2-static\",\n        );\n\n        let s3_config =\n            S3ConfigBuilder::new()\n                .region(aws_sdk_s3::config::Region::new(\"auto\"))\n                .endpoint_url(&config.endpoint)\n                .credentials_provider(credentials)\n                .force_path_style(true)\n                .stalled_stream_protection(\n                    aws_sdk_s3::config::StalledStreamProtectionConfig::disabled(),\n                )\n                .identity_cache(IdentityCache::no_cache())\n                .build();\n\n        let client = Client::from_conf(s3_config);\n\n        Self {\n            client,\n            bucket: config.bucket.clone(),\n            presign_expiry: Duration::from_secs(config.presign_expiry_secs),\n        }\n    }\n\n    pub async fn create_presigned_upload(\n        &self,\n        review_id: Uuid,\n        content_type: Option<&str>,\n    ) -> Result<PresignedUpload, R2Error> {\n        let folder_path = format!(\"reviews/{review_id}\");\n        let object_key = format!(\"{folder_path}/{PAYLOAD_FILENAME}\");\n\n        let presigning_config = PresigningConfig::builder()\n            .expires_in(self.presign_expiry)\n            .build()\n            .map_err(|e| R2Error::PresignConfig(e.to_string()))?;\n\n        let mut request = self\n            .client\n            .put_object()\n            .bucket(&self.bucket)\n            .key(&object_key);\n\n        if let Some(ct) = content_type {\n            request = request.content_type(ct);\n        }\n\n        let presigned = request\n            .presigned(presigning_config)\n            .await\n            .map_err(|e| R2Error::Presign(e.to_string()))?;\n\n        let expires_at = Utc::now()\n            + chrono::Duration::from_std(self.presign_expiry).unwrap_or(chrono::Duration::hours(1));\n\n        Ok(PresignedUpload {\n            upload_url: presigned.uri().to_string(),\n            object_key,\n            folder_path,\n            expires_at,\n        })\n    }\n\n    /// Upload bytes directly to R2 (for server-side uploads).\n    ///\n    /// Returns the folder path (e.g., \"reviews/{review_id}\") to store in the database.\n    pub async fn upload_bytes(&self, review_id: Uuid, data: Vec<u8>) -> Result<String, R2Error> {\n        let folder_path = format!(\"reviews/{review_id}\");\n        let object_key = format!(\"{folder_path}/{PAYLOAD_FILENAME}\");\n\n        self.client\n            .put_object()\n            .bucket(&self.bucket)\n            .key(&object_key)\n            .body(ByteStream::from(data))\n            .content_type(\"application/gzip\")\n            .send()\n            .await\n            .map_err(|e| R2Error::Upload(e.to_string()))?;\n\n        Ok(folder_path)\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/routes/attachments.rs",
    "content": "use api_types::{\n    AttachmentUrlResponse, AttachmentWithBlob, AttachmentWithUrl, ListAttachmentsResponse,\n};\nuse axum::{\n    Json, Router,\n    extract::{Extension, Path, State},\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    routing::{delete, get, post},\n};\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse tracing::instrument;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse super::organization_members::{\n    ensure_comment_access, ensure_issue_access, ensure_project_access,\n};\nuse crate::{\n    AppState,\n    attachments::thumbnail::ThumbnailService,\n    auth::RequestContext,\n    azure_blob::AzureBlobError,\n    db::{\n        attachments::{AttachmentError, AttachmentRepository},\n        blobs::{BlobError, BlobRepository},\n        pending_uploads::{PendingUploadError, PendingUploadRepository},\n    },\n};\n\npub fn router() -> Router<AppState> {\n    Router::new()\n        .route(\"/attachments/init\", post(init_upload))\n        .route(\"/attachments/confirm\", post(confirm_upload))\n        .route(\"/attachments/{id}/file\", get(get_attachment_file))\n        .route(\"/attachments/{id}/thumbnail\", get(get_attachment_thumbnail))\n        .route(\"/attachments/{id}\", delete(delete_attachment))\n        .route(\n            \"/issues/{issue_id}/attachments\",\n            get(list_issue_attachments),\n        )\n        .route(\n            \"/issues/{issue_id}/attachments/commit\",\n            post(commit_issue_attachments),\n        )\n        .route(\n            \"/comments/{comment_id}/attachments\",\n            get(list_comment_attachments),\n        )\n        .route(\n            \"/comments/{comment_id}/attachments/commit\",\n            post(commit_comment_attachments),\n        )\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct InitUploadRequest {\n    pub project_id: Uuid,\n    pub filename: String,\n    #[ts(type = \"number\")]\n    pub size_bytes: i64,\n    pub hash: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct InitUploadResponse {\n    pub upload_url: String,\n    pub upload_id: Uuid,\n    pub expires_at: DateTime<Utc>,\n    pub skip_upload: bool,\n    pub existing_blob_id: Option<Uuid>,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct ConfirmUploadRequest {\n    pub project_id: Uuid,\n    pub upload_id: Uuid,\n    pub filename: String,\n    #[ts(optional)]\n    pub content_type: Option<String>,\n    #[ts(type = \"number\")]\n    pub size_bytes: i64,\n    pub hash: String,\n    #[ts(optional)]\n    pub issue_id: Option<Uuid>,\n    #[ts(optional)]\n    pub comment_id: Option<Uuid>,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CommitAttachmentsRequest {\n    pub attachment_ids: Vec<Uuid>,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CommitAttachmentsResponse {\n    pub attachments: Vec<AttachmentWithBlob>,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum RouteError {\n    #[error(\"Azure Blob storage not configured\")]\n    NotConfigured,\n    #[error(\"Azure Blob error: {0}\")]\n    AzureBlob(#[from] AzureBlobError),\n    #[error(\"attachment error: {0}\")]\n    Attachment(#[from] AttachmentError),\n    #[error(\"blob error: {0}\")]\n    Blob(#[from] BlobError),\n    #[error(\"attachment not found\")]\n    NotFound,\n    #[error(\"no thumbnail available\")]\n    NoThumbnail,\n    #[error(\"access denied\")]\n    AccessDenied,\n    #[error(\"file too large (max 20MB)\")]\n    FileTooLarge,\n    #[error(\"upload not found or expired\")]\n    UploadNotFound,\n    #[error(\"pending upload error: {0}\")]\n    PendingUpload(#[from] PendingUploadError),\n    #[error(\"thumbnail generation failed: {0}\")]\n    ThumbnailError(String),\n}\n\nimpl IntoResponse for RouteError {\n    fn into_response(self) -> Response {\n        let (status, message) = match &self {\n            RouteError::NotConfigured => (\n                StatusCode::SERVICE_UNAVAILABLE,\n                \"Attachment storage not available\",\n            ),\n            RouteError::AzureBlob(e) => {\n                tracing::error!(error = %e, \"Azure Blob error\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"Storage error\")\n            }\n            RouteError::Attachment(e) => {\n                tracing::error!(error = %e, \"Attachment error\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n            }\n            RouteError::Blob(e) => {\n                tracing::error!(error = %e, \"Blob error\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n            }\n            RouteError::NotFound => (StatusCode::NOT_FOUND, \"Attachment not found\"),\n            RouteError::NoThumbnail => (StatusCode::NOT_FOUND, \"No thumbnail available\"),\n            RouteError::AccessDenied => (StatusCode::FORBIDDEN, \"Access denied\"),\n            RouteError::FileTooLarge => {\n                (StatusCode::PAYLOAD_TOO_LARGE, \"File too large (max 20MB)\")\n            }\n            RouteError::UploadNotFound => (StatusCode::NOT_FOUND, \"Upload not found or expired\"),\n            RouteError::PendingUpload(e) => {\n                tracing::error!(error = %e, \"Pending upload error\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n            }\n            RouteError::ThumbnailError(e) => {\n                tracing::error!(error = %e, \"Thumbnail generation failed\");\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"Thumbnail generation failed\",\n                )\n            }\n        };\n\n        let body = serde_json::json!({ \"error\": message });\n        (status, Json(body)).into_response()\n    }\n}\n\nconst MAX_FILE_SIZE: i64 = 20 * 1024 * 1024;\n\n#[instrument(name = \"attachments.init_upload\", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id))]\nasync fn init_upload(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<InitUploadRequest>,\n) -> Result<Json<InitUploadResponse>, RouteError> {\n    ensure_project_access(state.pool(), ctx.user.id, payload.project_id)\n        .await\n        .map_err(|_| RouteError::AccessDenied)?;\n\n    if payload.size_bytes > MAX_FILE_SIZE {\n        return Err(RouteError::FileTooLarge);\n    }\n\n    if let Some(existing) =\n        BlobRepository::find_by_hash(state.pool(), payload.project_id, &payload.hash).await?\n    {\n        let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?;\n        let read_url = azure.create_read_url(&existing.blob_path)?;\n\n        return Ok(Json(InitUploadResponse {\n            upload_url: read_url,\n            upload_id: existing.id,\n            expires_at: Utc::now() + chrono::Duration::minutes(5),\n            skip_upload: true,\n            existing_blob_id: Some(existing.id),\n        }));\n    }\n\n    let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?;\n    let sanitized_filename = sanitize_filename(&payload.filename);\n    let blob_path = format!(\n        \"attachments/{}/{}_{}\",\n        payload.project_id,\n        Uuid::new_v4(),\n        sanitized_filename\n    );\n    let upload = azure.create_upload_url(&blob_path)?;\n\n    let pending = PendingUploadRepository::create(\n        state.pool(),\n        payload.project_id,\n        upload.blob_path,\n        payload.hash.clone(),\n        upload.expires_at,\n    )\n    .await?;\n\n    Ok(Json(InitUploadResponse {\n        upload_url: upload.upload_url,\n        upload_id: pending.id,\n        expires_at: upload.expires_at,\n        skip_upload: false,\n        existing_blob_id: None,\n    }))\n}\n\n#[instrument(name = \"attachments.confirm_upload\", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id))]\nasync fn confirm_upload(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<ConfirmUploadRequest>,\n) -> Result<Json<AttachmentWithBlob>, RouteError> {\n    ensure_project_access(state.pool(), ctx.user.id, payload.project_id)\n        .await\n        .map_err(|_| RouteError::AccessDenied)?;\n\n    if let Some(issue_id) = payload.issue_id {\n        ensure_issue_access(state.pool(), ctx.user.id, issue_id)\n            .await\n            .map_err(|_| RouteError::AccessDenied)?;\n    }\n    if let Some(comment_id) = payload.comment_id {\n        ensure_comment_access(state.pool(), ctx.user.id, comment_id)\n            .await\n            .map_err(|_| RouteError::AccessDenied)?;\n    }\n\n    let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?;\n\n    let blob = if let Some(existing) =\n        BlobRepository::find_by_hash(state.pool(), payload.project_id, &payload.hash).await?\n    {\n        existing\n    } else {\n        let pending = PendingUploadRepository::find_by_id(state.pool(), payload.upload_id)\n            .await?\n            .ok_or(RouteError::UploadNotFound)?;\n\n        let blob_path = &pending.blob_path;\n\n        let props = azure.get_blob_properties(blob_path).await?;\n        if props.content_length > MAX_FILE_SIZE {\n            let _ = azure.delete_blob(blob_path).await;\n            return Err(RouteError::FileTooLarge);\n        }\n\n        let blob_data = azure.download_blob(blob_path).await?;\n        let thumbnail_result =\n            ThumbnailService::generate(&blob_data, payload.content_type.as_deref())\n                .map_err(|e| RouteError::ThumbnailError(e.to_string()))?;\n\n        let _ = PendingUploadRepository::delete(state.pool(), pending.id).await;\n\n        let (thumbnail_blob_path, width, height) = match thumbnail_result {\n            Some(thumb) => {\n                let thumb_path = format!(\"thumbnails/{}\", blob_path);\n                azure\n                    .upload_blob(&thumb_path, thumb.bytes, thumb.mime_type)\n                    .await?;\n                (\n                    Some(thumb_path),\n                    Some(thumb.original_width as i32),\n                    Some(thumb.original_height as i32),\n                )\n            }\n            None => (None, None, None),\n        };\n\n        BlobRepository::create(\n            state.pool(),\n            None,\n            payload.project_id,\n            blob_path.clone(),\n            thumbnail_blob_path,\n            payload.filename.clone(),\n            payload.content_type.clone(),\n            payload.size_bytes,\n            payload.hash.clone(),\n            width,\n            height,\n        )\n        .await?\n    };\n\n    let expires_at = if payload.issue_id.is_some() || payload.comment_id.is_some() {\n        None\n    } else {\n        Some(Utc::now() + chrono::Duration::hours(24))\n    };\n\n    let attachment = AttachmentRepository::create(\n        state.pool(),\n        None,\n        blob.id,\n        payload.issue_id,\n        payload.comment_id,\n        expires_at,\n    )\n    .await?;\n\n    let result = AttachmentRepository::find_by_id_with_blob(state.pool(), attachment.id)\n        .await?\n        .ok_or(RouteError::NotFound)?;\n\n    Ok(Json(result))\n}\n\n#[instrument(name = \"attachments.commit_issue\", skip(state, ctx, payload), fields(issue_id = %issue_id, user_id = %ctx.user.id))]\nasync fn commit_issue_attachments(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_id): Path<Uuid>,\n    Json(payload): Json<CommitAttachmentsRequest>,\n) -> Result<Json<CommitAttachmentsResponse>, RouteError> {\n    ensure_issue_access(state.pool(), ctx.user.id, issue_id)\n        .await\n        .map_err(|_| RouteError::AccessDenied)?;\n\n    let attachments =\n        AttachmentRepository::commit_to_issue(state.pool(), &payload.attachment_ids, issue_id)\n            .await?;\n    Ok(Json(CommitAttachmentsResponse { attachments }))\n}\n\n#[instrument(name = \"attachments.commit_comment\", skip(state, ctx, payload), fields(comment_id = %comment_id, user_id = %ctx.user.id))]\nasync fn commit_comment_attachments(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(comment_id): Path<Uuid>,\n    Json(payload): Json<CommitAttachmentsRequest>,\n) -> Result<Json<CommitAttachmentsResponse>, RouteError> {\n    ensure_comment_access(state.pool(), ctx.user.id, comment_id)\n        .await\n        .map_err(|_| RouteError::AccessDenied)?;\n\n    let attachments =\n        AttachmentRepository::commit_to_comment(state.pool(), &payload.attachment_ids, comment_id)\n            .await?;\n    Ok(Json(CommitAttachmentsResponse { attachments }))\n}\n\n#[instrument(name = \"attachments.list_issue\", skip(state, ctx), fields(issue_id = %issue_id, user_id = %ctx.user.id))]\nasync fn list_issue_attachments(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_id): Path<Uuid>,\n) -> Result<Json<ListAttachmentsResponse>, RouteError> {\n    ensure_issue_access(state.pool(), ctx.user.id, issue_id)\n        .await\n        .map_err(|_| RouteError::AccessDenied)?;\n\n    let azure = state.azure_blob();\n    let attachments = AttachmentRepository::find_by_issue_id(state.pool(), issue_id)\n        .await?\n        .into_iter()\n        .map(|a| {\n            let file_url = azure.and_then(|az| az.create_read_url(&a.blob_path).ok());\n            AttachmentWithUrl {\n                attachment: a,\n                file_url,\n            }\n        })\n        .collect();\n    Ok(Json(ListAttachmentsResponse { attachments }))\n}\n\n#[instrument(name = \"attachments.list_comment\", skip(state, ctx), fields(comment_id = %comment_id, user_id = %ctx.user.id))]\nasync fn list_comment_attachments(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(comment_id): Path<Uuid>,\n) -> Result<Json<ListAttachmentsResponse>, RouteError> {\n    ensure_comment_access(state.pool(), ctx.user.id, comment_id)\n        .await\n        .map_err(|_| RouteError::AccessDenied)?;\n\n    let azure = state.azure_blob();\n    let attachments = AttachmentRepository::find_by_comment_id(state.pool(), comment_id)\n        .await?\n        .into_iter()\n        .map(|a| {\n            let file_url = azure.and_then(|az| az.create_read_url(&a.blob_path).ok());\n            AttachmentWithUrl {\n                attachment: a,\n                file_url,\n            }\n        })\n        .collect();\n    Ok(Json(ListAttachmentsResponse { attachments }))\n}\n\n#[instrument(name = \"attachments.get_file\", skip(state, ctx), fields(attachment_id = %id, user_id = %ctx.user.id))]\nasync fn get_attachment_file(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(id): Path<Uuid>,\n) -> Result<Json<AttachmentUrlResponse>, RouteError> {\n    let attachment = AttachmentRepository::find_by_id_with_blob(state.pool(), id)\n        .await?\n        .ok_or(RouteError::NotFound)?;\n\n    ensure_attachment_access(&state, ctx.user.id, &attachment).await?;\n\n    let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?;\n    let url = azure.create_read_url(&attachment.blob_path)?;\n    Ok(Json(AttachmentUrlResponse { url }))\n}\n\n#[instrument(name = \"attachments.get_thumbnail\", skip(state, ctx), fields(attachment_id = %id, user_id = %ctx.user.id))]\nasync fn get_attachment_thumbnail(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(id): Path<Uuid>,\n) -> Result<Json<AttachmentUrlResponse>, RouteError> {\n    let attachment = AttachmentRepository::find_by_id_with_blob(state.pool(), id)\n        .await?\n        .ok_or(RouteError::NotFound)?;\n\n    ensure_attachment_access(&state, ctx.user.id, &attachment).await?;\n\n    let thumbnail_path = attachment\n        .thumbnail_blob_path\n        .ok_or(RouteError::NoThumbnail)?;\n    let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?;\n    let url = azure.create_read_url(&thumbnail_path)?;\n    Ok(Json(AttachmentUrlResponse { url }))\n}\n\n#[instrument(name = \"attachments.delete\", skip(state, ctx), fields(attachment_id = %id, user_id = %ctx.user.id))]\nasync fn delete_attachment(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(id): Path<Uuid>,\n) -> Result<StatusCode, RouteError> {\n    let attachment = AttachmentRepository::find_by_id_with_blob(state.pool(), id)\n        .await?\n        .ok_or(RouteError::NotFound)?;\n\n    ensure_attachment_access(&state, ctx.user.id, &attachment).await?;\n\n    let blob_id = attachment.blob_id;\n    AttachmentRepository::delete(state.pool(), id).await?;\n\n    let remaining = AttachmentRepository::count_by_blob_id(state.pool(), blob_id).await?;\n    if remaining == 0\n        && let Some(blob) = BlobRepository::delete(state.pool(), blob_id).await?\n    {\n        let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?;\n        if let Err(e) = azure.delete_blob(&blob.blob_path).await {\n            tracing::warn!(error = %e, blob_path = %blob.blob_path, \"Failed to delete blob\");\n        }\n        if let Some(thumb_path) = blob.thumbnail_blob_path\n            && let Err(e) = azure.delete_blob(&thumb_path).await\n        {\n            tracing::warn!(error = %e, blob_path = %thumb_path, \"Failed to delete thumbnail\");\n        }\n    }\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\nasync fn ensure_attachment_access(\n    state: &AppState,\n    user_id: Uuid,\n    attachment: &AttachmentWithBlob,\n) -> Result<(), RouteError> {\n    if let Some(issue_id) = attachment.issue_id {\n        ensure_issue_access(state.pool(), user_id, issue_id)\n            .await\n            .map_err(|_| RouteError::AccessDenied)?;\n    } else if let Some(comment_id) = attachment.comment_id {\n        ensure_comment_access(state.pool(), user_id, comment_id)\n            .await\n            .map_err(|_| RouteError::AccessDenied)?;\n    } else if let Some(project_id) =\n        AttachmentRepository::project_id(state.pool(), attachment.id).await?\n    {\n        ensure_project_access(state.pool(), user_id, project_id)\n            .await\n            .map_err(|_| RouteError::AccessDenied)?;\n    } else {\n        return Err(RouteError::AccessDenied);\n    }\n    Ok(())\n}\n\nfn sanitize_filename(filename: &str) -> String {\n    filename\n        .chars()\n        .map(|c| {\n            if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' {\n                c\n            } else {\n                '_'\n            }\n        })\n        .take(100)\n        .collect()\n}\n"
  },
  {
    "path": "crates/remote/src/routes/billing.rs",
    "content": "use axum::{\n    Json, Router,\n    body::Bytes,\n    extract::{Extension, Path, State},\n    http::{HeaderMap, StatusCode},\n    response::IntoResponse,\n    routing::{get, post},\n};\nuse uuid::Uuid;\n\nuse super::{error::ErrorResponse, organization_members::ensure_admin_access};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    billing::{\n        BillingError, BillingStatus, BillingStatusResponse, CreateCheckoutRequest,\n        CreatePortalRequest,\n    },\n    db::organization_members,\n};\n\npub fn public_router() -> Router<AppState> {\n    Router::new().route(\"/billing/webhook\", post(handle_webhook))\n}\n\npub fn protected_router() -> Router<AppState> {\n    Router::new()\n        .route(\"/organizations/{org_id}/billing\", get(get_billing_status))\n        .route(\n            \"/organizations/{org_id}/billing/portal\",\n            post(create_portal_session),\n        )\n        .route(\n            \"/organizations/{org_id}/billing/checkout\",\n            post(create_checkout_session),\n        )\n}\n\npub async fn get_billing_status(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    organization_members::assert_membership(&state.pool, org_id, ctx.user.id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::FORBIDDEN, \"Access denied\"))?;\n\n    match state.billing().provider() {\n        Some(billing) => {\n            let status = billing\n                .get_billing_status(org_id)\n                .await\n                .map_err(billing_error)?;\n            Ok(Json(status))\n        }\n        None => Ok(Json(BillingStatusResponse {\n            status: BillingStatus::Free,\n            billing_enabled: false,\n            seat_info: None,\n        })),\n    }\n}\n\npub async fn create_portal_session(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n    Json(payload): Json<CreatePortalRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    ensure_admin_access(&state.pool, org_id, ctx.user.id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\"))?;\n\n    let billing = state.billing().provider().ok_or_else(|| {\n        ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, \"Billing not configured\")\n    })?;\n\n    let session = billing\n        .create_portal_session(org_id, &payload.return_url)\n        .await\n        .map_err(billing_error)?;\n\n    Ok(Json(session))\n}\n\npub async fn create_checkout_session(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n    Json(payload): Json<CreateCheckoutRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    ensure_admin_access(&state.pool, org_id, ctx.user.id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\"))?;\n\n    let billing = state.billing().provider().ok_or_else(|| {\n        ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, \"Billing not configured\")\n    })?;\n\n    let session = billing\n        .create_checkout_session(org_id, &payload.success_url, &payload.cancel_url)\n        .await\n        .map_err(billing_error)?;\n\n    Ok(Json(session))\n}\n\npub async fn handle_webhook(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    body: Bytes,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let billing = state.billing().provider().ok_or_else(|| {\n        ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, \"Billing not configured\")\n    })?;\n\n    let signature = headers\n        .get(\"stripe-signature\")\n        .and_then(|v| v.to_str().ok())\n        .unwrap_or(\"\");\n\n    billing\n        .handle_webhook(&body, signature)\n        .await\n        .map_err(billing_error)?;\n\n    Ok(StatusCode::OK)\n}\n\nfn billing_error(error: BillingError) -> ErrorResponse {\n    match error {\n        BillingError::NotConfigured => {\n            ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, \"Billing not configured\")\n        }\n        BillingError::SubscriptionRequired(msg) => {\n            ErrorResponse::new(StatusCode::PAYMENT_REQUIRED, msg)\n        }\n        BillingError::SubscriptionInactive => {\n            ErrorResponse::new(StatusCode::PAYMENT_REQUIRED, \"Subscription is inactive\")\n        }\n        BillingError::Stripe(msg) => {\n            tracing::error!(?msg, \"Stripe error\");\n            ErrorResponse::new(StatusCode::BAD_GATEWAY, \"Payment provider error\")\n        }\n        BillingError::Database(e) => {\n            tracing::error!(?e, \"Database error in billing\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n        }\n        BillingError::OrganizationNotFound => {\n            ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/routes/electric_proxy.rs",
    "content": "use std::collections::HashMap;\n\nuse axum::{\n    Router,\n    body::Body,\n    http::{HeaderMap, HeaderValue, StatusCode, header},\n    response::{IntoResponse, Response},\n};\nuse futures::TryStreamExt;\nuse secrecy::ExposeSecret;\nuse serde::Deserialize;\nuse tracing::error;\nuse uuid::Uuid;\n\nuse crate::{AppState, shape_definition::ShapeExport};\n\n#[derive(Deserialize)]\npub(crate) struct OrgShapeQuery {\n    pub organization_id: Uuid,\n    #[serde(flatten)]\n    pub params: HashMap<String, String>,\n}\n\n#[derive(Deserialize)]\npub(crate) struct ShapeQuery {\n    #[serde(flatten)]\n    pub params: HashMap<String, String>,\n}\n\nconst ELECTRIC_PARAMS: &[&str] = &[\"offset\", \"handle\", \"live\", \"cursor\", \"columns\"];\nconst ELECTRIC_STICKY_HEADER: &str = \"x-vk-electric-sticky\";\n\npub fn router() -> Router<AppState> {\n    let mut router = Router::new();\n    for route in crate::shape_routes::all_shape_routes() {\n        router = router.merge(route.router);\n    }\n    router\n}\n\n/// Proxy a Shape request to Electric for a specific table.\n///\n/// The table and where clause are set server-side (not from client params)\n/// to prevent unauthorized access to other tables or data.\npub(crate) async fn proxy_table(\n    state: &AppState,\n    shape: &dyn ShapeExport,\n    client_params: &HashMap<String, String>,\n    electric_params: &[String],\n    session_id: Uuid,\n) -> Result<Response, ProxyError> {\n    // Build the Electric URL\n    let mut origin_url = url::Url::parse(&state.config.electric_url)\n        .map_err(|e| ProxyError::InvalidConfig(format!(\"invalid electric_url: {e}\")))?;\n\n    origin_url.set_path(\"/v1/shape\");\n\n    // Set table server-side (security: client can't override)\n    origin_url\n        .query_pairs_mut()\n        .append_pair(\"table\", shape.table());\n\n    // Set WHERE clause with parameterized values\n    origin_url\n        .query_pairs_mut()\n        .append_pair(\"where\", shape.where_clause());\n\n    // Pass params for $1, $2, etc. placeholders\n    for (i, param) in electric_params.iter().enumerate() {\n        origin_url\n            .query_pairs_mut()\n            .append_pair(&format!(\"params[{}]\", i + 1), param);\n    }\n\n    // Forward safe client params\n    for (key, value) in client_params {\n        if ELECTRIC_PARAMS.contains(&key.as_str()) {\n            origin_url.query_pairs_mut().append_pair(key, value);\n        }\n    }\n\n    if let Some(secret) = &state.config.electric_secret {\n        origin_url\n            .query_pairs_mut()\n            .append_pair(\"secret\", secret.expose_secret());\n    }\n\n    let response = state\n        .http_client\n        .get(origin_url.as_str())\n        .header(ELECTRIC_STICKY_HEADER, session_id.to_string())\n        .send()\n        .await\n        .map_err(ProxyError::Connection)?;\n\n    let status = response.status();\n    let mut headers = HeaderMap::new();\n\n    // Copy headers from Electric response, but remove problematic ones\n    for (key, value) in response.headers() {\n        // Skip headers that interfere with browser handling\n        if key == header::CONTENT_ENCODING || key == header::CONTENT_LENGTH {\n            continue;\n        }\n        headers.insert(key.clone(), value.clone());\n    }\n\n    // Add Vary header for proper caching with auth\n    headers.insert(header::VARY, HeaderValue::from_static(\"Authorization\"));\n\n    // Stream the response body directly without buffering\n    let body_stream = response.bytes_stream().map_err(std::io::Error::other);\n    let body = Body::from_stream(body_stream);\n\n    Ok((status, headers, body).into_response())\n}\n\n#[derive(Debug)]\npub(crate) enum ProxyError {\n    Connection(reqwest::Error),\n    InvalidConfig(String),\n    Authorization(String),\n}\n\nimpl IntoResponse for ProxyError {\n    fn into_response(self) -> Response {\n        match self {\n            ProxyError::Connection(err) => {\n                error!(?err, \"failed to connect to Electric service\");\n                (\n                    StatusCode::BAD_GATEWAY,\n                    \"failed to connect to Electric service\",\n                )\n                    .into_response()\n            }\n            ProxyError::InvalidConfig(msg) => {\n                error!(%msg, \"invalid Electric proxy configuration\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\").into_response()\n            }\n            ProxyError::Authorization(msg) => {\n                error!(%msg, \"authorization failed for Electric proxy\");\n                (StatusCode::FORBIDDEN, \"forbidden\").into_response()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/routes/error.rs",
    "content": "use axum::{\n    Json,\n    http::StatusCode,\n    response::{IntoResponse, Response},\n};\nuse serde_json::json;\n\nuse crate::db::identity_errors::IdentityError;\n\n#[derive(Debug)]\npub struct ErrorResponse {\n    status: StatusCode,\n    message: String,\n}\n\nimpl ErrorResponse {\n    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {\n        Self {\n            status,\n            message: message.into(),\n        }\n    }\n}\n\nimpl IntoResponse for ErrorResponse {\n    fn into_response(self) -> Response {\n        (self.status, Json(json!({ \"error\": self.message }))).into_response()\n    }\n}\n\npub(crate) fn db_error(\n    error: impl std::error::Error + 'static,\n    fallback_message: &str,\n) -> ErrorResponse {\n    let error: &(dyn std::error::Error + 'static) = &error;\n    let mut current = Some(error);\n\n    while let Some(err) = current {\n        if let Some(sqlx_error) = err.downcast_ref::<sqlx::Error>() {\n            if let sqlx::Error::Database(db_err) = sqlx_error {\n                if db_err.is_unique_violation() {\n                    return ErrorResponse::new(StatusCode::CONFLICT, \"resource already exists\");\n                }\n                if db_err.is_foreign_key_violation() {\n                    return ErrorResponse::new(StatusCode::NOT_FOUND, \"related resource not found\");\n                }\n            }\n            break;\n        }\n        current = err.source();\n    }\n\n    ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, fallback_message)\n}\n\npub(crate) fn membership_error(error: IdentityError, forbidden_message: &str) -> ErrorResponse {\n    match error {\n        IdentityError::NotFound | IdentityError::PermissionDenied => {\n            ErrorResponse::new(StatusCode::FORBIDDEN, forbidden_message)\n        }\n        IdentityError::Database(_) => {\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n        }\n        other => {\n            tracing::warn!(?other, \"unexpected membership error\");\n            ErrorResponse::new(StatusCode::FORBIDDEN, forbidden_message)\n        }\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/routes/github_app.rs",
    "content": "use axum::{\n    Json, Router,\n    body::Bytes,\n    extract::{Path, Query, State},\n    http::{HeaderMap, StatusCode},\n    response::{IntoResponse, Redirect, Response},\n    routing::{delete, get, patch, post},\n};\nuse chrono::{Duration, Utc};\nuse secrecy::ExposeSecret;\nuse serde::{Deserialize, Serialize};\nuse tracing::{error, info, warn};\nuse uuid::Uuid;\n\nuse super::error::ErrorResponse;\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        github_app::GitHubAppRepository2, identity_errors::IdentityError,\n        organizations::OrganizationRepository, reviews::ReviewRepository,\n    },\n    github_app::{PrReviewParams, PrReviewService, verify_webhook_signature},\n};\n\n// ========== Public Routes ==========\n\npub fn public_router() -> Router<AppState> {\n    Router::new()\n        .route(\"/github/webhook\", post(handle_webhook))\n        .route(\"/github/app/callback\", get(handle_callback))\n}\n\n// ========== Protected Routes ==========\n\npub fn protected_router() -> Router<AppState> {\n    Router::new()\n        .route(\n            \"/organizations/{org_id}/github-app/install-url\",\n            get(get_install_url),\n        )\n        .route(\"/organizations/{org_id}/github-app/status\", get(get_status))\n        .route(\"/organizations/{org_id}/github-app\", delete(uninstall))\n        .route(\n            \"/organizations/{org_id}/github-app/repositories\",\n            get(fetch_repositories),\n        )\n        .route(\n            \"/organizations/{org_id}/github-app/repositories/review-enabled\",\n            patch(bulk_update_review_enabled),\n        )\n        .route(\n            \"/organizations/{org_id}/github-app/repositories/{repo_id}/review-enabled\",\n            patch(update_repo_review_enabled),\n        )\n        .route(\"/debug/pr-review/trigger\", post(trigger_pr_review))\n}\n\n// ========== Types ==========\n\n#[derive(Debug, Serialize)]\npub struct InstallUrlResponse {\n    pub install_url: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct GitHubAppStatusResponse {\n    pub installed: bool,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub installation: Option<InstallationDetails>,\n    pub repositories: Vec<RepositoryDetails>,\n}\n\n#[derive(Debug, Serialize)]\npub struct InstallationDetails {\n    pub id: String,\n    pub github_installation_id: i64,\n    pub github_account_login: String,\n    pub github_account_type: String,\n    pub repository_selection: String,\n    pub suspended_at: Option<String>,\n    pub created_at: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct RepositoryDetails {\n    pub id: String,\n    pub github_repo_id: i64,\n    pub repo_full_name: String,\n    pub review_enabled: bool,\n}\n\n#[derive(Debug, Deserialize)]\npub struct CallbackQuery {\n    pub installation_id: Option<i64>,\n    pub state: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\npub struct TriggerPrReviewRequest {\n    /// GitHub PR URL, e.g., \"https://github.com/owner/repo/pull/123\"\n    pub pr_url: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct TriggerPrReviewResponse {\n    pub review_id: Uuid,\n}\n\n#[derive(Debug, Deserialize)]\npub struct UpdateRepoReviewEnabledRequest {\n    pub enabled: bool,\n}\n\n#[derive(Debug, Serialize)]\npub struct BulkUpdateReviewEnabledResponse {\n    pub updated_count: u64,\n}\n\n// ========== Protected Route Handlers ==========\n\n/// GET /v1/organizations/:org_id/github-app/install-url\n/// Returns URL to install the GitHub App for this organization\npub async fn get_install_url(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    // Check GitHub App is configured\n    let github_app = state.github_app().ok_or_else(|| {\n        ErrorResponse::new(StatusCode::NOT_IMPLEMENTED, \"GitHub App not configured\")\n    })?;\n\n    // Check user is admin of organization\n    let org_repo = OrganizationRepository::new(state.pool());\n    org_repo\n        .assert_admin(org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    // Check not a personal org\n    let is_personal = org_repo\n        .is_personal(org_id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    if is_personal {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"GitHub App cannot be installed on personal organizations\",\n        ));\n    }\n\n    // Generate state token (simple format: org_id:user_id:timestamp)\n    // In production, you'd want to sign this with HMAC\n    let expires_at = Utc::now() + Duration::minutes(10);\n    let state_token = format!(\"{}:{}:{}\", org_id, ctx.user.id, expires_at.timestamp());\n\n    // Store pending installation\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n    gh_repo\n        .create_pending(org_id, ctx.user.id, &state_token, expires_at)\n        .await\n        .map_err(|e| {\n            error!(?e, \"Failed to create pending installation\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n        })?;\n\n    // Build installation URL\n    let install_url = format!(\n        \"https://github.com/apps/{}/installations/new?state={}\",\n        github_app.app_slug(),\n        urlencoding::encode(&state_token)\n    );\n\n    Ok(Json(InstallUrlResponse { install_url }))\n}\n\n/// GET /v1/organizations/:org_id/github-app/status\n/// Returns the GitHub App installation status for this organization\npub async fn get_status(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    // Check user is member of organization\n    let org_repo = OrganizationRepository::new(state.pool());\n    org_repo\n        .assert_membership(org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied | IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Access denied\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n\n    let installation = gh_repo.get_by_organization(org_id).await.map_err(|e| {\n        error!(?e, \"Failed to get GitHub App installation\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n    })?;\n\n    match installation {\n        Some(inst) => {\n            // Return cached repos from DB (fast) - use GET /repositories to fetch fresh data\n            let repositories = gh_repo.get_repositories(inst.id).await.map_err(|e| {\n                error!(?e, \"Failed to get repositories\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n            })?;\n\n            Ok(Json(GitHubAppStatusResponse {\n                installed: true,\n                installation: Some(InstallationDetails {\n                    id: inst.id.to_string(),\n                    github_installation_id: inst.github_installation_id,\n                    github_account_login: inst.github_account_login,\n                    github_account_type: inst.github_account_type,\n                    repository_selection: inst.repository_selection,\n                    suspended_at: inst.suspended_at.map(|t| t.to_rfc3339()),\n                    created_at: inst.created_at.to_rfc3339(),\n                }),\n                repositories: repositories\n                    .into_iter()\n                    .map(|r| RepositoryDetails {\n                        id: r.id.to_string(),\n                        github_repo_id: r.github_repo_id,\n                        repo_full_name: r.repo_full_name,\n                        review_enabled: r.review_enabled,\n                    })\n                    .collect(),\n            }))\n        }\n        None => Ok(Json(GitHubAppStatusResponse {\n            installed: false,\n            installation: None,\n            repositories: vec![],\n        })),\n    }\n}\n\n/// DELETE /v1/organizations/:org_id/github-app\n/// Removes the local installation record (does not uninstall from GitHub)\npub async fn uninstall(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    // Check user is admin of organization\n    let org_repo = OrganizationRepository::new(state.pool());\n    org_repo\n        .assert_admin(org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n    gh_repo.delete_by_organization(org_id).await.map_err(|e| {\n        error!(?e, \"Failed to delete GitHub App installation\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n    })?;\n\n    info!(org_id = %org_id, user_id = %ctx.user.id, \"GitHub App installation removed\");\n    Ok(StatusCode::NO_CONTENT)\n}\n\n/// PATCH /v1/organizations/:org_id/github-app/repositories/:repo_id/review-enabled\n/// Toggle whether a repository should trigger PR reviews\npub async fn update_repo_review_enabled(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path((org_id, repo_id)): Path<(Uuid, Uuid)>,\n    Json(payload): Json<UpdateRepoReviewEnabledRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    // Check user is admin of organization\n    let org_repo = OrganizationRepository::new(state.pool());\n    org_repo\n        .assert_admin(org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    // Get installation for this org\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n    let installation = gh_repo\n        .get_by_organization(org_id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"GitHub App not installed\"))?;\n\n    // Update the repository\n    let updated = gh_repo\n        .update_repository_review_enabled(repo_id, installation.id, payload.enabled)\n        .await\n        .map_err(|e| {\n            error!(?e, \"Failed to update repository review_enabled\");\n            match e {\n                crate::db::github_app::GitHubAppDbError::NotFound => {\n                    ErrorResponse::new(StatusCode::NOT_FOUND, \"Repository not found\")\n                }\n                _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n            }\n        })?;\n\n    info!(\n        org_id = %org_id,\n        repo_id = %repo_id,\n        review_enabled = payload.enabled,\n        \"Repository review_enabled updated\"\n    );\n\n    Ok(Json(RepositoryDetails {\n        id: updated.id.to_string(),\n        github_repo_id: updated.github_repo_id,\n        repo_full_name: updated.repo_full_name,\n        review_enabled: updated.review_enabled,\n    }))\n}\n\n/// GET /v1/organizations/:org_id/github-app/repositories\n/// Fetches repositories from GitHub API, syncs to DB, and returns the list\npub async fn fetch_repositories(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    // Check user is member of organization\n    let org_repo = OrganizationRepository::new(state.pool());\n    org_repo\n        .assert_membership(org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied | IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Access denied\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n\n    let installation = gh_repo\n        .get_by_organization(org_id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"GitHub App not installed\"))?;\n\n    // Fetch repos from GitHub API and sync to DB\n    let github_app = state.github_app().ok_or_else(|| {\n        ErrorResponse::new(StatusCode::NOT_IMPLEMENTED, \"GitHub App not configured\")\n    })?;\n\n    match github_app\n        .list_installation_repos(installation.github_installation_id)\n        .await\n    {\n        Ok(repos) => {\n            let repo_data: Vec<(i64, String)> =\n                repos.into_iter().map(|r| (r.id, r.full_name)).collect();\n            if let Err(e) = gh_repo.sync_repositories(installation.id, &repo_data).await {\n                warn!(?e, \"Failed to sync repositories from GitHub API\");\n            }\n        }\n        Err(e) => {\n            warn!(?e, \"Failed to fetch repositories from GitHub API\");\n            // Continue with cached data\n        }\n    }\n\n    // Return the (now updated) list from DB\n    let repositories = gh_repo\n        .get_repositories(installation.id)\n        .await\n        .map_err(|e| {\n            error!(?e, \"Failed to get repositories\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n        })?;\n\n    Ok(Json(\n        repositories\n            .into_iter()\n            .map(|r| RepositoryDetails {\n                id: r.id.to_string(),\n                github_repo_id: r.github_repo_id,\n                repo_full_name: r.repo_full_name,\n                review_enabled: r.review_enabled,\n            })\n            .collect::<Vec<_>>(),\n    ))\n}\n\n/// PATCH /v1/organizations/:org_id/github-app/repositories/review-enabled\n/// Bulk toggle review_enabled for all repositories\npub async fn bulk_update_review_enabled(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n    Json(payload): Json<UpdateRepoReviewEnabledRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    // Check user is admin of organization\n    let org_repo = OrganizationRepository::new(state.pool());\n    org_repo\n        .assert_admin(org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n    let installation = gh_repo\n        .get_by_organization(org_id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"GitHub App not installed\"))?;\n\n    let updated_count = gh_repo\n        .set_all_repositories_review_enabled(installation.id, payload.enabled)\n        .await\n        .map_err(|e| {\n            error!(?e, \"Failed to bulk update review_enabled\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n        })?;\n\n    info!(\n        org_id = %org_id,\n        review_enabled = payload.enabled,\n        updated_count,\n        \"Bulk updated repository review_enabled\"\n    );\n\n    Ok(Json(BulkUpdateReviewEnabledResponse { updated_count }))\n}\n\n// ========== Public Route Handlers ==========\n\n/// GET /v1/github/app/callback\n/// Handles redirect from GitHub after app installation\npub async fn handle_callback(\n    State(state): State<AppState>,\n    Query(query): Query<CallbackQuery>,\n) -> Response {\n    let frontend_base = state\n        .config\n        .server_public_base_url\n        .clone()\n        .unwrap_or_else(|| \"http://localhost:3000\".to_string());\n\n    // Helper to redirect with error\n    let redirect_error = |org_id: Option<Uuid>, error: &str| -> Response {\n        let url = match org_id {\n            Some(id) => format!(\n                \"{}/account/organizations/{}?github_app_error={}\",\n                frontend_base,\n                id,\n                urlencoding::encode(error)\n            ),\n            None => format!(\n                \"{}/account?github_app_error={}\",\n                frontend_base,\n                urlencoding::encode(error)\n            ),\n        };\n        Redirect::temporary(&url).into_response()\n    };\n\n    // Check GitHub App is configured\n    let Some(github_app) = state.github_app() else {\n        return redirect_error(None, \"GitHub App not configured\");\n    };\n\n    // Validate required params\n    let Some(installation_id) = query.installation_id else {\n        return redirect_error(None, \"Missing installation_id\");\n    };\n\n    let Some(state_token) = query.state else {\n        return redirect_error(None, \"Missing state parameter\");\n    };\n\n    // Parse state token: org_id:user_id:timestamp\n    let parts: Vec<&str> = state_token.split(':').collect();\n    if parts.len() != 3 {\n        return redirect_error(None, \"Invalid state token format\");\n    }\n\n    let Ok(org_id) = Uuid::parse_str(parts[0]) else {\n        return redirect_error(None, \"Invalid organization ID in state\");\n    };\n\n    let Ok(user_id) = Uuid::parse_str(parts[1]) else {\n        return redirect_error(Some(org_id), \"Invalid user ID in state\");\n    };\n\n    let Ok(timestamp) = parts[2].parse::<i64>() else {\n        return redirect_error(Some(org_id), \"Invalid timestamp in state\");\n    };\n\n    // Check expiry\n    if Utc::now().timestamp() > timestamp {\n        return redirect_error(Some(org_id), \"Installation link expired\");\n    }\n\n    // Verify pending installation exists\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n    let pending = match gh_repo.get_pending_by_state(&state_token).await {\n        Ok(Some(p)) => p,\n        Ok(None) => {\n            return redirect_error(Some(org_id), \"Installation not found or expired\");\n        }\n        Err(e) => {\n            error!(?e, \"Failed to get pending installation\");\n            return redirect_error(Some(org_id), \"Database error\");\n        }\n    };\n\n    // Fetch installation details from GitHub\n    let installation_info = match github_app.get_installation(installation_id).await {\n        Ok(info) => info,\n        Err(e) => {\n            error!(?e, \"Failed to get installation from GitHub\");\n            return redirect_error(Some(org_id), \"Failed to verify installation with GitHub\");\n        }\n    };\n\n    // Create installation record\n    if let Err(e) = gh_repo\n        .create_installation(\n            pending.organization_id,\n            installation_id,\n            &installation_info.account.login,\n            &installation_info.account.account_type,\n            &installation_info.repository_selection,\n            user_id,\n        )\n        .await\n    {\n        error!(?e, \"Failed to create installation record\");\n        return redirect_error(Some(org_id), \"Failed to save installation\");\n    }\n\n    // Delete pending record\n    if let Err(e) = gh_repo.delete_pending(&state_token).await {\n        warn!(?e, \"Failed to delete pending installation record\");\n    }\n\n    // Fetch and store repositories if selection is \"selected\"\n    if installation_info.repository_selection == \"selected\"\n        && let Ok(repos) = github_app.list_installation_repos(installation_id).await\n    {\n        let installation = gh_repo\n            .get_by_github_id(installation_id)\n            .await\n            .ok()\n            .flatten();\n        if let Some(inst) = installation {\n            let repo_data: Vec<(i64, String)> =\n                repos.into_iter().map(|r| (r.id, r.full_name)).collect();\n            if let Err(e) = gh_repo.sync_repositories(inst.id, &repo_data).await {\n                warn!(?e, \"Failed to sync repositories\");\n            }\n        }\n    }\n\n    info!(\n        org_id = %org_id,\n        installation_id = installation_id,\n        account = %installation_info.account.login,\n        \"GitHub App installed successfully\"\n    );\n\n    // Redirect to organization page with success\n    let url = format!(\n        \"{}/account/organizations/{}?github_app=installed\",\n        frontend_base, org_id\n    );\n    Redirect::temporary(&url).into_response()\n}\n\n/// POST /v1/github/webhook\n/// Handles webhook events from GitHub\npub async fn handle_webhook(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    body: Bytes,\n) -> Response {\n    // Check GitHub App is configured\n    let Some(github_app) = state.github_app() else {\n        warn!(\"Received webhook but GitHub App not configured\");\n        return StatusCode::NOT_IMPLEMENTED.into_response();\n    };\n\n    // Verify signature\n    let signature = headers\n        .get(\"X-Hub-Signature-256\")\n        .and_then(|v| v.to_str().ok())\n        .unwrap_or(\"\");\n\n    if !verify_webhook_signature(\n        github_app.webhook_secret().expose_secret().as_bytes(),\n        signature,\n        &body,\n    ) {\n        warn!(\"Invalid webhook signature\");\n        return StatusCode::UNAUTHORIZED.into_response();\n    }\n\n    // Get event type\n    let event_type = headers\n        .get(\"X-GitHub-Event\")\n        .and_then(|v| v.to_str().ok())\n        .unwrap_or(\"unknown\");\n\n    info!(event_type, \"Received GitHub webhook\");\n\n    // Parse payload\n    let payload: serde_json::Value = match serde_json::from_slice(&body) {\n        Ok(v) => v,\n        Err(e) => {\n            warn!(?e, \"Failed to parse webhook payload\");\n            return StatusCode::BAD_REQUEST.into_response();\n        }\n    };\n\n    // Handle different event types\n    match event_type {\n        \"installation\" => handle_installation_event(&state, &payload).await,\n        \"installation_repositories\" => handle_installation_repos_event(&state, &payload).await,\n        \"pull_request\" => handle_pull_request_event(&state, github_app, &payload).await,\n        \"issue_comment\" => handle_issue_comment_event(&state, github_app, &payload).await,\n        _ => {\n            info!(event_type, \"Ignoring unhandled webhook event\");\n            StatusCode::OK.into_response()\n        }\n    }\n}\n\n// ========== Webhook Event Handlers ==========\n\nasync fn handle_installation_event(state: &AppState, payload: &serde_json::Value) -> Response {\n    let action = payload[\"action\"].as_str().unwrap_or(\"\");\n    let installation_id = payload[\"installation\"][\"id\"].as_i64().unwrap_or(0);\n\n    info!(action, installation_id, \"Processing installation event\");\n\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n\n    match action {\n        \"deleted\" => {\n            if let Err(e) = gh_repo.delete_by_github_id(installation_id).await {\n                error!(?e, \"Failed to delete installation\");\n            } else {\n                info!(installation_id, \"Installation deleted\");\n            }\n        }\n        \"suspend\" => {\n            if let Err(e) = gh_repo.suspend(installation_id).await {\n                error!(?e, \"Failed to suspend installation\");\n            } else {\n                info!(installation_id, \"Installation suspended\");\n            }\n        }\n        \"unsuspend\" => {\n            if let Err(e) = gh_repo.unsuspend(installation_id).await {\n                error!(?e, \"Failed to unsuspend installation\");\n            } else {\n                info!(installation_id, \"Installation unsuspended\");\n            }\n        }\n        \"created\" => {\n            // Installation created via webhook (without going through our flow)\n            // This shouldn't happen if orphan installations are rejected\n            info!(\n                installation_id,\n                \"Installation created event received (orphan)\"\n            );\n        }\n        _ => {\n            info!(action, \"Ignoring installation action\");\n        }\n    }\n\n    StatusCode::OK.into_response()\n}\n\nasync fn handle_installation_repos_event(\n    state: &AppState,\n    payload: &serde_json::Value,\n) -> Response {\n    let action = payload[\"action\"].as_str().unwrap_or(\"\");\n    let installation_id = payload[\"installation\"][\"id\"].as_i64().unwrap_or(0);\n\n    info!(\n        action,\n        installation_id, \"Processing installation_repositories event\"\n    );\n\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n\n    // Get our installation record\n    let installation = match gh_repo.get_by_github_id(installation_id).await {\n        Ok(Some(inst)) => inst,\n        Ok(None) => {\n            info!(installation_id, \"Installation not found, ignoring\");\n            return StatusCode::OK.into_response();\n        }\n        Err(e) => {\n            error!(?e, \"Failed to get installation\");\n            return StatusCode::OK.into_response();\n        }\n    };\n\n    match action {\n        \"added\" => {\n            let repos: Vec<(i64, String)> = payload[\"repositories_added\"]\n                .as_array()\n                .unwrap_or(&vec![])\n                .iter()\n                .filter_map(|r| {\n                    let id = r[\"id\"].as_i64()?;\n                    let name = r[\"full_name\"].as_str()?;\n                    Some((id, name.to_string()))\n                })\n                .collect();\n\n            if let Err(e) = gh_repo.add_repositories(installation.id, &repos).await {\n                error!(?e, \"Failed to add repositories\");\n            } else {\n                info!(installation_id, count = repos.len(), \"Repositories added\");\n            }\n        }\n        \"removed\" => {\n            let repo_ids: Vec<i64> = payload[\"repositories_removed\"]\n                .as_array()\n                .unwrap_or(&vec![])\n                .iter()\n                .filter_map(|r| r[\"id\"].as_i64())\n                .collect();\n\n            if let Err(e) = gh_repo\n                .remove_repositories(installation.id, &repo_ids)\n                .await\n            {\n                error!(?e, \"Failed to remove repositories\");\n            } else {\n                info!(\n                    installation_id,\n                    count = repo_ids.len(),\n                    \"Repositories removed\"\n                );\n            }\n        }\n        _ => {\n            info!(action, \"Ignoring repositories action\");\n        }\n    }\n\n    // Update repository selection if changed\n    let new_selection = payload[\"repository_selection\"].as_str().unwrap_or(\"\");\n    if !new_selection.is_empty()\n        && new_selection != installation.repository_selection\n        && let Err(e) = gh_repo\n            .update_repository_selection(installation_id, new_selection)\n            .await\n    {\n        error!(?e, \"Failed to update repository selection\");\n    }\n\n    StatusCode::OK.into_response()\n}\n\n// ========== Shared PR Review Trigger Logic ==========\n\n/// Parameters for triggering a PR review from webhook events\nstruct TriggerReviewContext<'a> {\n    installation_id: i64,\n    github_repo_id: i64,\n    repo_owner: &'a str,\n    repo_name: &'a str,\n    pr_number: u64,\n    /// PR metadata - if None, will be fetched from GitHub API\n    pr_metadata: Option<PrMetadata>,\n}\n\nstruct PrMetadata {\n    title: String,\n    body: String,\n    head_sha: String,\n    base_ref: String,\n}\n\n/// Shared logic to validate and trigger a PR review.\n/// Returns Ok(()) if review was triggered, Err with reason if skipped.\nasync fn try_trigger_pr_review(\n    state: &AppState,\n    github_app: &crate::github_app::GitHubAppService,\n    ctx: TriggerReviewContext<'_>,\n    check_pending: bool,\n) -> Result<(), &'static str> {\n    if state.config.review_disabled {\n        return Err(\"Review feature is disabled\");\n    }\n\n    // Check if we have this installation\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n    let installation = gh_repo\n        .get_by_github_id(ctx.installation_id)\n        .await\n        .map_err(|_| \"Failed to get installation\")?\n        .ok_or(\"Installation not found\")?;\n\n    // Check if installation is suspended\n    if installation.suspended_at.is_some() {\n        return Err(\"Installation is suspended\");\n    }\n\n    // Check if repository has reviews enabled\n    let is_review_enabled = gh_repo\n        .is_repository_review_enabled(installation.id, ctx.github_repo_id)\n        .await\n        .unwrap_or(true);\n\n    if !is_review_enabled {\n        return Err(\"Repository has reviews disabled\");\n    }\n\n    // Optionally check for pending review\n    if check_pending {\n        let review_repo = ReviewRepository::new(state.pool());\n        if review_repo\n            .has_pending_review_for_pr(ctx.repo_owner, ctx.repo_name, ctx.pr_number as i32)\n            .await\n            .unwrap_or(false)\n        {\n            return Err(\"Review already pending\");\n        }\n    }\n\n    // Check if R2 and review worker are configured\n    let r2 = state.r2().ok_or(\"R2 not configured\")?;\n    let worker_base_url = state\n        .config\n        .review_worker_base_url\n        .as_ref()\n        .ok_or(\"Review worker not configured\")?;\n\n    // Get PR metadata (from payload or fetch from API)\n    let (pr_title, pr_body, head_sha, base_ref) = match ctx.pr_metadata {\n        Some(meta) => (meta.title, meta.body, meta.head_sha, meta.base_ref),\n        None => {\n            let pr_details = github_app\n                .get_pr_details(\n                    ctx.installation_id,\n                    ctx.repo_owner,\n                    ctx.repo_name,\n                    ctx.pr_number,\n                )\n                .await\n                .map_err(|_| \"Failed to fetch PR details\")?;\n            (\n                pr_details.title,\n                pr_details.body.unwrap_or_default(),\n                pr_details.head.sha,\n                pr_details.base.ref_name,\n            )\n        }\n    };\n\n    // Spawn async task to process PR review\n    let github_app_clone = github_app.clone();\n    let r2_clone = r2.clone();\n    let http_client = state.http_client.clone();\n    let worker_url = worker_base_url.clone();\n    let server_url = state.server_public_base_url.clone();\n    let pool = state.pool.clone();\n    let installation_id = ctx.installation_id;\n    let pr_number = ctx.pr_number;\n    let repo_owner = ctx.repo_owner.to_string();\n    let repo_name = ctx.repo_name.to_string();\n\n    tokio::spawn(async move {\n        let service = PrReviewService::new(\n            github_app_clone,\n            r2_clone,\n            http_client,\n            worker_url,\n            server_url,\n        );\n\n        let params = PrReviewParams {\n            installation_id,\n            owner: repo_owner.clone(),\n            repo: repo_name.clone(),\n            pr_number,\n            pr_title,\n            pr_body,\n            head_sha,\n            base_ref,\n        };\n\n        if let Err(e) = service.process_pr_review(&pool, params).await {\n            error!(\n                ?e,\n                installation_id, pr_number, repo_owner, repo_name, \"Failed to start PR review\"\n            );\n        }\n    });\n\n    Ok(())\n}\n\nasync fn handle_pull_request_event(\n    state: &AppState,\n    github_app: &crate::github_app::GitHubAppService,\n    payload: &serde_json::Value,\n) -> Response {\n    let action = payload[\"action\"].as_str().unwrap_or(\"\");\n\n    if action != \"opened\" {\n        return StatusCode::OK.into_response();\n    }\n\n    let ctx = TriggerReviewContext {\n        installation_id: payload[\"installation\"][\"id\"].as_i64().unwrap_or(0),\n        github_repo_id: payload[\"repository\"][\"id\"].as_i64().unwrap_or(0),\n        repo_owner: payload[\"repository\"][\"owner\"][\"login\"]\n            .as_str()\n            .unwrap_or(\"\"),\n        repo_name: payload[\"repository\"][\"name\"].as_str().unwrap_or(\"\"),\n        pr_number: payload[\"pull_request\"][\"number\"].as_u64().unwrap_or(0),\n        pr_metadata: Some(PrMetadata {\n            title: payload[\"pull_request\"][\"title\"]\n                .as_str()\n                .unwrap_or(\"Untitled PR\")\n                .to_string(),\n            body: payload[\"pull_request\"][\"body\"]\n                .as_str()\n                .unwrap_or(\"\")\n                .to_string(),\n            head_sha: payload[\"pull_request\"][\"head\"][\"sha\"]\n                .as_str()\n                .unwrap_or(\"\")\n                .to_string(),\n            base_ref: payload[\"pull_request\"][\"base\"][\"ref\"]\n                .as_str()\n                .unwrap_or(\"main\")\n                .to_string(),\n        }),\n    };\n\n    info!(\n        installation_id = ctx.installation_id,\n        pr_number = ctx.pr_number,\n        repo_owner = ctx.repo_owner,\n        repo_name = ctx.repo_name,\n        \"Processing pull_request.opened event\"\n    );\n\n    if let Err(reason) = try_trigger_pr_review(state, github_app, ctx, false).await {\n        info!(reason, \"Skipping PR review\");\n    }\n\n    StatusCode::OK.into_response()\n}\n\nasync fn handle_issue_comment_event(\n    state: &AppState,\n    github_app: &crate::github_app::GitHubAppService,\n    payload: &serde_json::Value,\n) -> Response {\n    let action = payload[\"action\"].as_str().unwrap_or(\"\");\n\n    // Only handle new comments\n    if action != \"created\" {\n        return StatusCode::OK.into_response();\n    }\n\n    // Check if comment is on a PR (issues don't have pull_request field)\n    if payload[\"issue\"][\"pull_request\"].is_null() {\n        return StatusCode::OK.into_response();\n    }\n\n    // Check for exact \"!reviewfast\" trigger\n    let comment_body = payload[\"comment\"][\"body\"].as_str().unwrap_or(\"\").trim();\n    if comment_body != \"!reviewfast\" {\n        return StatusCode::OK.into_response();\n    }\n\n    // Ignore bot comments to prevent loops\n    let user_type = payload[\"comment\"][\"user\"][\"type\"].as_str().unwrap_or(\"\");\n    if user_type == \"Bot\" {\n        info!(\"Ignoring !reviewfast from bot user\");\n        return StatusCode::OK.into_response();\n    }\n\n    let ctx = TriggerReviewContext {\n        installation_id: payload[\"installation\"][\"id\"].as_i64().unwrap_or(0),\n        github_repo_id: payload[\"repository\"][\"id\"].as_i64().unwrap_or(0),\n        repo_owner: payload[\"repository\"][\"owner\"][\"login\"]\n            .as_str()\n            .unwrap_or(\"\"),\n        repo_name: payload[\"repository\"][\"name\"].as_str().unwrap_or(\"\"),\n        pr_number: payload[\"issue\"][\"number\"].as_u64().unwrap_or(0),\n        pr_metadata: None, // Will fetch from GitHub API\n    };\n\n    info!(\n        installation_id = ctx.installation_id,\n        pr_number = ctx.pr_number,\n        repo_owner = ctx.repo_owner,\n        repo_name = ctx.repo_name,\n        \"Processing !reviewfast comment\"\n    );\n\n    // Pass check_pending=true to skip if review already in progress\n    if let Err(reason) = try_trigger_pr_review(state, github_app, ctx, true).await {\n        info!(reason, \"Skipping PR review from !reviewfast\");\n    }\n\n    StatusCode::OK.into_response()\n}\n\n// ========== Debug Endpoint ==========\n\n/// Parse a GitHub PR URL into (owner, repo, pr_number)\nfn parse_pr_url(url: &str) -> Option<(String, String, u64)> {\n    // Parse URLs like: https://github.com/owner/repo/pull/123\n    let url = url.trim_end_matches('/');\n    let parts: Vec<&str> = url.split('/').collect();\n\n    // Find \"github.com\" and get owner/repo/pull/number\n    let github_idx = parts.iter().position(|&p| p == \"github.com\")?;\n\n    if parts.len() < github_idx + 5 {\n        return None;\n    }\n\n    let owner = parts[github_idx + 1].to_string();\n    let repo = parts[github_idx + 2].to_string();\n\n    if parts[github_idx + 3] != \"pull\" {\n        return None;\n    }\n\n    let pr_number: u64 = parts[github_idx + 4].parse().ok()?;\n\n    Some((owner, repo, pr_number))\n}\n\n/// POST /v1/debug/pr-review/trigger\n/// Manually trigger a PR review for debugging purposes\npub async fn trigger_pr_review(\n    State(state): State<AppState>,\n    Json(payload): Json<TriggerPrReviewRequest>,\n) -> Result<Json<TriggerPrReviewResponse>, ErrorResponse> {\n    if state.config.review_disabled {\n        return Err(ErrorResponse::new(\n            StatusCode::SERVICE_UNAVAILABLE,\n            \"Review feature is disabled\",\n        ));\n    }\n\n    // 1. Parse PR URL\n    let (owner, repo, pr_number) = parse_pr_url(&payload.pr_url)\n        .ok_or_else(|| ErrorResponse::new(StatusCode::BAD_REQUEST, \"Invalid PR URL format\"))?;\n\n    // 2. Validate services are configured\n    let github_app = state.github_app().ok_or_else(|| {\n        ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, \"GitHub App not configured\")\n    })?;\n    let r2 = state\n        .r2()\n        .ok_or_else(|| ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, \"R2 not configured\"))?;\n    let worker_base_url = state\n        .config\n        .review_worker_base_url\n        .as_ref()\n        .ok_or_else(|| {\n            ErrorResponse::new(\n                StatusCode::SERVICE_UNAVAILABLE,\n                \"Review worker not configured\",\n            )\n        })?;\n\n    // 3. Look up installation by owner\n    let gh_repo = GitHubAppRepository2::new(state.pool());\n    let installation = gh_repo\n        .get_by_account_login(&owner)\n        .await\n        .map_err(|e| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?\n        .ok_or_else(|| {\n            ErrorResponse::new(\n                StatusCode::NOT_FOUND,\n                format!(\"No installation found for {}\", owner),\n            )\n        })?;\n\n    // 4. Fetch PR details from GitHub API\n    let pr_details = github_app\n        .get_pr_details(\n            installation.github_installation_id,\n            &owner,\n            &repo,\n            pr_number,\n        )\n        .await\n        .map_err(|e| ErrorResponse::new(StatusCode::BAD_GATEWAY, e.to_string()))?;\n\n    // 5. Create service and process review\n    let service = PrReviewService::new(\n        github_app.clone(),\n        r2.clone(),\n        state.http_client.clone(),\n        worker_base_url.clone(),\n        state.server_public_base_url.clone(),\n    );\n\n    let params = PrReviewParams {\n        installation_id: installation.github_installation_id,\n        owner,\n        repo,\n        pr_number,\n        pr_title: pr_details.title,\n        pr_body: pr_details.body.unwrap_or_default(),\n        head_sha: pr_details.head.sha,\n        base_ref: pr_details.base.ref_name,\n    };\n\n    let review_id = service\n        .process_pr_review(state.pool(), params)\n        .await\n        .map_err(|e| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;\n\n    info!(\n        review_id = %review_id,\n        pr_url = %payload.pr_url,\n        \"Manual PR review triggered\"\n    );\n\n    Ok(Json(TriggerPrReviewResponse { review_id }))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/hosts.rs",
    "content": "use api_types::{ListRelayHostsResponse, RelaySession};\nuse axum::{\n    Json, Router,\n    extract::{Extension, Path, State},\n    http::StatusCode,\n    routing::{get, post},\n};\nuse chrono::{Duration, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse super::error::ErrorResponse;\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{hosts::HostRepository, identity_errors::IdentityError},\n};\n\nconst RELAY_SESSION_TTL_SECS: i64 = 120;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct CreateRelaySessionResponse {\n    pub session: RelaySession,\n}\n\npub fn router() -> Router<AppState> {\n    Router::new()\n        .route(\"/hosts\", get(list_hosts))\n        .route(\"/hosts/{host_id}/sessions\", post(create_relay_session))\n}\n\nasync fn list_hosts(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n) -> Result<Json<ListRelayHostsResponse>, ErrorResponse> {\n    let repo = HostRepository::new(state.pool());\n    let hosts = repo\n        .list_accessible_hosts(ctx.user.id)\n        .await\n        .map_err(|error| {\n            tracing::warn!(?error, \"failed to list relay hosts\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Failed to list hosts\")\n        })?;\n\n    Ok(Json(ListRelayHostsResponse { hosts }))\n}\n\nasync fn create_relay_session(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(host_id): Path<Uuid>,\n) -> Result<Json<CreateRelaySessionResponse>, ErrorResponse> {\n    let repo = HostRepository::new(state.pool());\n\n    repo.assert_host_access(host_id, ctx.user.id)\n        .await\n        .map_err(|error| match error {\n            IdentityError::Database(_) => {\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\")\n            }\n            IdentityError::PermissionDenied | IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Access denied\")\n            }\n            _ => ErrorResponse::new(StatusCode::FORBIDDEN, \"Access denied\"),\n        })?;\n\n    let expires_at = Utc::now() + Duration::seconds(RELAY_SESSION_TTL_SECS);\n    let session = repo\n        .create_session(host_id, ctx.user.id, expires_at)\n        .await\n        .map_err(|_| {\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"Failed to create session\",\n            )\n        })?;\n\n    if let Some(analytics) = state.analytics() {\n        analytics.track(\n            ctx.user.id,\n            \"relay_host_session_created\",\n            serde_json::json!({\n                \"host_id\": host_id,\n                \"relay_session_id\": session.id,\n            }),\n        );\n    }\n\n    Ok(Json(CreateRelaySessionResponse { session }))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/identity.rs",
    "content": "use axum::{Extension, Json, Router, routing::get};\nuse serde::{Deserialize, Serialize};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse crate::{AppState, auth::RequestContext};\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct IdentityResponse {\n    pub user_id: Uuid,\n    pub username: Option<String>,\n    pub email: String,\n}\n\npub fn router() -> Router<AppState> {\n    Router::new().route(\"/identity\", get(get_identity))\n}\n\n#[instrument(name = \"identity.get_identity\", skip(ctx), fields(user_id = %ctx.user.id))]\npub async fn get_identity(Extension(ctx): Extension<RequestContext>) -> Json<IdentityResponse> {\n    let user = ctx.user;\n    Json(IdentityResponse {\n        user_id: user.id,\n        username: user.username,\n        email: user.email,\n    })\n}\n"
  },
  {
    "path": "crates/remote/src/routes/issue_assignees.rs",
    "content": "use api_types::{\n    CreateIssueAssigneeRequest, DeleteResponse, IssueAssignee, ListIssueAssigneesQuery,\n    ListIssueAssigneesResponse, MutationResponse, NotificationPayload, NotificationType,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_issue_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{issue_assignees::IssueAssigneeRepository, issues::IssueRepository},\n    mutation_definition::{MutationBuilder, NoUpdate},\n    notifications::notify_user,\n};\n\n/// Mutation definition for IssueAssignee - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<IssueAssignee, CreateIssueAssigneeRequest, NoUpdate> {\n    MutationBuilder::new(\"issue_assignees\")\n        .list(list_issue_assignees)\n        .get(get_issue_assignee)\n        .create(create_issue_assignee)\n        .delete(delete_issue_assignee)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation().router()\n}\n\n#[instrument(\n    name = \"issue_assignees.list_issue_assignees\",\n    skip(state, ctx),\n    fields(issue_id = %query.issue_id, user_id = %ctx.user.id)\n)]\nasync fn list_issue_assignees(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListIssueAssigneesQuery>,\n) -> Result<Json<ListIssueAssigneesResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let issue_assignees = IssueAssigneeRepository::list_by_issue(state.pool(), query.issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, issue_id = %query.issue_id, \"failed to list issue assignees\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list issue assignees\",\n            )\n        })?;\n\n    Ok(Json(ListIssueAssigneesResponse { issue_assignees }))\n}\n\n#[instrument(\n    name = \"issue_assignees.get_issue_assignee\",\n    skip(state, ctx),\n    fields(issue_assignee_id = %issue_assignee_id, user_id = %ctx.user.id)\n)]\nasync fn get_issue_assignee(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_assignee_id): Path<Uuid>,\n) -> Result<Json<IssueAssignee>, ErrorResponse> {\n    let assignee = IssueAssigneeRepository::find_by_id(state.pool(), issue_assignee_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_assignee_id, \"failed to load issue assignee\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue assignee\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue assignee not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, assignee.issue_id).await?;\n\n    Ok(Json(assignee))\n}\n\n#[instrument(\n    name = \"issue_assignees.create_issue_assignee\",\n    skip(state, ctx, payload),\n    fields(issue_id = %payload.issue_id, user_id = %ctx.user.id)\n)]\nasync fn create_issue_assignee(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateIssueAssigneeRequest>,\n) -> Result<Json<MutationResponse<IssueAssignee>>, ErrorResponse> {\n    let organization_id = ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?;\n\n    let response = IssueAssigneeRepository::create(\n        state.pool(),\n        payload.id,\n        payload.issue_id,\n        payload.user_id,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create issue assignee\");\n        db_error(error, \"failed to create issue assignee\")\n    })?;\n\n    if payload.user_id != ctx.user.id\n        && let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), payload.issue_id).await\n    {\n        notify_user(\n            state.pool(),\n            organization_id,\n            ctx.user.id,\n            payload.user_id,\n            &issue,\n            NotificationType::IssueAssigneeChanged,\n            NotificationPayload {\n                assignee_user_id: Some(payload.user_id),\n                ..Default::default()\n            },\n        )\n        .await;\n    }\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_assignees.delete_issue_assignee\",\n    skip(state, ctx),\n    fields(issue_assignee_id = %issue_assignee_id, user_id = %ctx.user.id)\n)]\nasync fn delete_issue_assignee(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_assignee_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let assignee = IssueAssigneeRepository::find_by_id(state.pool(), issue_assignee_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_assignee_id, \"failed to load issue assignee\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue assignee\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue assignee not found\"))?;\n\n    let organization_id = ensure_issue_access(state.pool(), ctx.user.id, assignee.issue_id).await?;\n\n    let response = IssueAssigneeRepository::delete(state.pool(), issue_assignee_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete issue assignee\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    if assignee.user_id != ctx.user.id\n        && let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), assignee.issue_id).await\n    {\n        notify_user(\n            state.pool(),\n            organization_id,\n            ctx.user.id,\n            assignee.user_id,\n            &issue,\n            NotificationType::IssueUnassigned,\n            NotificationPayload {\n                assignee_user_id: Some(assignee.user_id),\n                ..Default::default()\n            },\n        )\n        .await;\n    }\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/issue_comment_reactions.rs",
    "content": "use api_types::{\n    CreateIssueCommentReactionRequest, DeleteResponse, IssueComment, IssueCommentReaction,\n    ListIssueCommentReactionsQuery, ListIssueCommentReactionsResponse, MutationResponse,\n    NotificationPayload, NotificationType, UpdateIssueCommentReactionRequest,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_issue_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        issue_comment_reactions::IssueCommentReactionRepository,\n        issue_comments::IssueCommentRepository, issues::IssueRepository,\n        organization_members::is_member,\n    },\n    mutation_definition::MutationBuilder,\n    notifications::send_issue_notifications,\n};\n\n/// Mutation definition for IssueCommentReaction - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<\n    IssueCommentReaction,\n    CreateIssueCommentReactionRequest,\n    UpdateIssueCommentReactionRequest,\n> {\n    MutationBuilder::new(\"issue_comment_reactions\")\n        .list(list_issue_comment_reactions)\n        .get(get_issue_comment_reaction)\n        .create(create_issue_comment_reaction)\n        .update(update_issue_comment_reaction)\n        .delete(delete_issue_comment_reaction)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation().router()\n}\n\nasync fn notify_comment_author_about_reaction(\n    state: &AppState,\n    organization_id: Uuid,\n    actor_user_id: Uuid,\n    comment: &IssueComment,\n    emoji: &str,\n) {\n    let Some(comment_author_id) = comment.author_id else {\n        return;\n    };\n\n    if comment_author_id == actor_user_id\n        || !is_member(state.pool(), organization_id, comment_author_id)\n            .await\n            .unwrap_or(false)\n    {\n        return;\n    }\n\n    let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), comment.issue_id).await else {\n        return;\n    };\n\n    send_issue_notifications(\n        state.pool(),\n        organization_id,\n        actor_user_id,\n        &[comment_author_id],\n        &issue,\n        NotificationType::IssueCommentReaction,\n        NotificationPayload {\n            comment_preview: Some(comment.message.chars().take(100).collect::<String>()),\n            emoji: Some(emoji.to_owned()),\n            ..Default::default()\n        },\n        Some(comment.id),\n        Some(issue.id),\n    )\n    .await;\n}\n\n#[instrument(\n    name = \"issue_comment_reactions.list_issue_comment_reactions\",\n    skip(state, ctx),\n    fields(comment_id = %query.comment_id, user_id = %ctx.user.id)\n)]\nasync fn list_issue_comment_reactions(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListIssueCommentReactionsQuery>,\n) -> Result<Json<ListIssueCommentReactionsResponse>, ErrorResponse> {\n    let comment = IssueCommentRepository::find_by_id(state.pool(), query.comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, comment_id = %query.comment_id, \"failed to load comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load comment\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"comment not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    let issue_comment_reactions =\n        IssueCommentReactionRepository::list_by_comment(state.pool(), query.comment_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, comment_id = %query.comment_id, \"failed to list reactions\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list reactions\",\n                )\n            })?;\n\n    Ok(Json(ListIssueCommentReactionsResponse {\n        issue_comment_reactions,\n    }))\n}\n\n#[instrument(\n    name = \"issue_comment_reactions.get_issue_comment_reaction\",\n    skip(state, ctx),\n    fields(issue_comment_reaction_id = %issue_comment_reaction_id, user_id = %ctx.user.id)\n)]\nasync fn get_issue_comment_reaction(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_comment_reaction_id): Path<Uuid>,\n) -> Result<Json<IssueCommentReaction>, ErrorResponse> {\n    let reaction =\n        IssueCommentReactionRepository::find_by_id(state.pool(), issue_comment_reaction_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, %issue_comment_reaction_id, \"failed to load reaction\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load reaction\")\n            })?\n            .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"reaction not found\"))?;\n\n    let comment = IssueCommentRepository::find_by_id(state.pool(), reaction.comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, comment_id = %reaction.comment_id, \"failed to load comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load comment\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"comment not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    Ok(Json(reaction))\n}\n\n#[instrument(\n    name = \"issue_comment_reactions.create_issue_comment_reaction\",\n    skip(state, ctx, payload),\n    fields(comment_id = %payload.comment_id, user_id = %ctx.user.id)\n)]\nasync fn create_issue_comment_reaction(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateIssueCommentReactionRequest>,\n) -> Result<Json<MutationResponse<IssueCommentReaction>>, ErrorResponse> {\n    let comment = IssueCommentRepository::find_by_id(state.pool(), payload.comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, comment_id = %payload.comment_id, \"failed to load comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load comment\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"comment not found\"))?;\n\n    let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    let response = IssueCommentReactionRepository::create(\n        state.pool(),\n        payload.id,\n        payload.comment_id,\n        ctx.user.id,\n        payload.emoji,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create reaction\");\n        db_error(error, \"failed to create reaction\")\n    })?;\n\n    notify_comment_author_about_reaction(\n        &state,\n        organization_id,\n        ctx.user.id,\n        &comment,\n        &response.data.emoji,\n    )\n    .await;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_comment_reactions.update_issue_comment_reaction\",\n    skip(state, ctx, payload),\n    fields(issue_comment_reaction_id = %issue_comment_reaction_id, user_id = %ctx.user.id)\n)]\nasync fn update_issue_comment_reaction(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_comment_reaction_id): Path<Uuid>,\n    Json(payload): Json<UpdateIssueCommentReactionRequest>,\n) -> Result<Json<MutationResponse<IssueCommentReaction>>, ErrorResponse> {\n    let reaction =\n        IssueCommentReactionRepository::find_by_id(state.pool(), issue_comment_reaction_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, %issue_comment_reaction_id, \"failed to load reaction\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load reaction\")\n            })?\n            .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"reaction not found\"))?;\n\n    if reaction.user_id != ctx.user.id {\n        return Err(ErrorResponse::new(\n            StatusCode::FORBIDDEN,\n            \"you are not the author of this reaction\",\n        ));\n    }\n\n    let comment = IssueCommentRepository::find_by_id(state.pool(), reaction.comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, comment_id = %reaction.comment_id, \"failed to load comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load comment\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"comment not found\"))?;\n\n    let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    let response = IssueCommentReactionRepository::update(\n        state.pool(),\n        issue_comment_reaction_id,\n        payload.emoji,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to update reaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    notify_comment_author_about_reaction(\n        &state,\n        organization_id,\n        ctx.user.id,\n        &comment,\n        &response.data.emoji,\n    )\n    .await;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_comment_reactions.delete_issue_comment_reaction\",\n    skip(state, ctx),\n    fields(issue_comment_reaction_id = %issue_comment_reaction_id, user_id = %ctx.user.id)\n)]\nasync fn delete_issue_comment_reaction(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_comment_reaction_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let reaction =\n        IssueCommentReactionRepository::find_by_id(state.pool(), issue_comment_reaction_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, %issue_comment_reaction_id, \"failed to load reaction\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load reaction\")\n            })?\n            .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"reaction not found\"))?;\n\n    if reaction.user_id != ctx.user.id {\n        return Err(ErrorResponse::new(\n            StatusCode::FORBIDDEN,\n            \"you are not the author of this reaction\",\n        ));\n    }\n\n    let comment = IssueCommentRepository::find_by_id(state.pool(), reaction.comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, comment_id = %reaction.comment_id, \"failed to load comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load comment\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"comment not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    let response = IssueCommentReactionRepository::delete(state.pool(), issue_comment_reaction_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete reaction\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/issue_comments.rs",
    "content": "use api_types::{\n    CreateIssueCommentRequest, DeleteResponse, IssueComment, ListIssueCommentsQuery,\n    ListIssueCommentsResponse, MemberRole, MutationResponse, NotificationPayload, NotificationType,\n    UpdateIssueCommentRequest,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_issue_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        issue_comments::IssueCommentRepository, issues::IssueRepository,\n        organization_members::check_user_role,\n    },\n    mutation_definition::MutationBuilder,\n    notifications::notify_issue_subscribers,\n};\n\n/// Mutation definition for IssueComment - provides both router and TypeScript metadata.\npub fn mutation()\n-> MutationBuilder<IssueComment, CreateIssueCommentRequest, UpdateIssueCommentRequest> {\n    MutationBuilder::new(\"issue_comments\")\n        .list(list_issue_comments)\n        .get(get_issue_comment)\n        .create(create_issue_comment)\n        .update(update_issue_comment)\n        .delete(delete_issue_comment)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation().router()\n}\n\n#[instrument(\n    name = \"issue_comments.list_issue_comments\",\n    skip(state, ctx),\n    fields(issue_id = %query.issue_id, user_id = %ctx.user.id)\n)]\nasync fn list_issue_comments(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListIssueCommentsQuery>,\n) -> Result<Json<ListIssueCommentsResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let issue_comments = IssueCommentRepository::list_by_issue(state.pool(), query.issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, issue_id = %query.issue_id, \"failed to list issue comments\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list issue comments\",\n            )\n        })?;\n\n    Ok(Json(ListIssueCommentsResponse { issue_comments }))\n}\n\n#[instrument(\n    name = \"issue_comments.get_issue_comment\",\n    skip(state, ctx),\n    fields(issue_comment_id = %issue_comment_id, user_id = %ctx.user.id)\n)]\nasync fn get_issue_comment(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_comment_id): Path<Uuid>,\n) -> Result<Json<IssueComment>, ErrorResponse> {\n    let comment = IssueCommentRepository::find_by_id(state.pool(), issue_comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_comment_id, \"failed to load issue comment\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue comment\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue comment not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    Ok(Json(comment))\n}\n\n#[instrument(\n    name = \"issue_comments.create_issue_comment\",\n    skip(state, ctx, payload),\n    fields(issue_id = %payload.issue_id, user_id = %ctx.user.id)\n)]\nasync fn create_issue_comment(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateIssueCommentRequest>,\n) -> Result<Json<MutationResponse<IssueComment>>, ErrorResponse> {\n    let organization_id = ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?;\n\n    let is_reply = payload.parent_id.is_some();\n\n    let response = IssueCommentRepository::create(\n        state.pool(),\n        payload.id,\n        payload.issue_id,\n        ctx.user.id,\n        payload.parent_id,\n        payload.message,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create issue comment\");\n        db_error(error, \"failed to create issue comment\")\n    })?;\n\n    if let Some(analytics) = state.analytics() {\n        analytics.track(\n            ctx.user.id,\n            \"issue_comment_created\",\n            serde_json::json!({\n                \"comment_id\": response.data.id,\n                \"issue_id\": response.data.issue_id,\n                \"organization_id\": organization_id,\n                \"is_reply\": is_reply,\n            }),\n        );\n    }\n\n    if let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), response.data.issue_id).await\n    {\n        let comment_preview = response.data.message.chars().take(100).collect::<String>();\n        notify_issue_subscribers(\n            state.pool(),\n            organization_id,\n            ctx.user.id,\n            &issue,\n            NotificationType::IssueCommentAdded,\n            NotificationPayload {\n                comment_preview: Some(comment_preview),\n                ..Default::default()\n            },\n            Some(response.data.id),\n        )\n        .await;\n    }\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_comments.update_issue_comment\",\n    skip(state, ctx, payload),\n    fields(issue_comment_id = %issue_comment_id, user_id = %ctx.user.id)\n)]\nasync fn update_issue_comment(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_comment_id): Path<Uuid>,\n    Json(payload): Json<UpdateIssueCommentRequest>,\n) -> Result<Json<MutationResponse<IssueComment>>, ErrorResponse> {\n    let comment = IssueCommentRepository::find_by_id(state.pool(), issue_comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_comment_id, \"failed to load issue comment\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue comment\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue comment not found\"))?;\n\n    let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    let is_author = comment\n        .author_id\n        .map(|id| id == ctx.user.id)\n        .unwrap_or(false);\n    let is_admin = check_user_role(state.pool(), organization_id, ctx.user.id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to check user role\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?\n        .map(|role| role == MemberRole::Admin)\n        .unwrap_or(false);\n\n    if !is_author && !is_admin {\n        return Err(ErrorResponse::new(\n            StatusCode::FORBIDDEN,\n            \"you do not have permission to edit this comment\",\n        ));\n    }\n\n    let response = IssueCommentRepository::update(state.pool(), issue_comment_id, payload.message)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to update issue comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_comments.delete_issue_comment\",\n    skip(state, ctx),\n    fields(issue_comment_id = %issue_comment_id, user_id = %ctx.user.id)\n)]\nasync fn delete_issue_comment(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_comment_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let comment = IssueCommentRepository::find_by_id(state.pool(), issue_comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_comment_id, \"failed to load issue comment\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue comment\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue comment not found\"))?;\n\n    let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?;\n\n    let is_author = comment\n        .author_id\n        .map(|id| id == ctx.user.id)\n        .unwrap_or(false);\n    let is_admin = check_user_role(state.pool(), organization_id, ctx.user.id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to check user role\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?\n        .map(|role| role == MemberRole::Admin)\n        .unwrap_or(false);\n\n    if !is_author && !is_admin {\n        return Err(ErrorResponse::new(\n            StatusCode::FORBIDDEN,\n            \"you do not have permission to delete this comment\",\n        ));\n    }\n\n    let response = IssueCommentRepository::delete(state.pool(), issue_comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete issue comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/issue_followers.rs",
    "content": "use api_types::{\n    CreateIssueFollowerRequest, DeleteResponse, IssueFollower, ListIssueFollowersQuery,\n    ListIssueFollowersResponse, MutationResponse,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_issue_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::issue_followers::IssueFollowerRepository,\n    mutation_definition::{MutationBuilder, NoUpdate},\n};\n\n/// Mutation definition for IssueFollower - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<IssueFollower, CreateIssueFollowerRequest, NoUpdate> {\n    MutationBuilder::new(\"issue_followers\")\n        .list(list_issue_followers)\n        .get(get_issue_follower)\n        .create(create_issue_follower)\n        .delete(delete_issue_follower)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation().router()\n}\n\n#[instrument(\n    name = \"issue_followers.list_issue_followers\",\n    skip(state, ctx),\n    fields(issue_id = %query.issue_id, user_id = %ctx.user.id)\n)]\nasync fn list_issue_followers(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListIssueFollowersQuery>,\n) -> Result<Json<ListIssueFollowersResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let issue_followers = IssueFollowerRepository::list_by_issue(state.pool(), query.issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, issue_id = %query.issue_id, \"failed to list issue followers\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list issue followers\",\n            )\n        })?;\n\n    Ok(Json(ListIssueFollowersResponse { issue_followers }))\n}\n\n#[instrument(\n    name = \"issue_followers.get_issue_follower\",\n    skip(state, ctx),\n    fields(issue_follower_id = %issue_follower_id, user_id = %ctx.user.id)\n)]\nasync fn get_issue_follower(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_follower_id): Path<Uuid>,\n) -> Result<Json<IssueFollower>, ErrorResponse> {\n    let follower = IssueFollowerRepository::find_by_id(state.pool(), issue_follower_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_follower_id, \"failed to load issue follower\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue follower\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue follower not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, follower.issue_id).await?;\n\n    Ok(Json(follower))\n}\n\n#[instrument(\n    name = \"issue_followers.create_issue_follower\",\n    skip(state, ctx, payload),\n    fields(issue_id = %payload.issue_id, user_id = %ctx.user.id)\n)]\nasync fn create_issue_follower(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateIssueFollowerRequest>,\n) -> Result<Json<MutationResponse<IssueFollower>>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?;\n\n    let response = IssueFollowerRepository::create(\n        state.pool(),\n        payload.id,\n        payload.issue_id,\n        payload.user_id,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create issue follower\");\n        db_error(error, \"failed to create issue follower\")\n    })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_followers.delete_issue_follower\",\n    skip(state, ctx),\n    fields(issue_follower_id = %issue_follower_id, user_id = %ctx.user.id)\n)]\nasync fn delete_issue_follower(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_follower_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let follower = IssueFollowerRepository::find_by_id(state.pool(), issue_follower_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_follower_id, \"failed to load issue follower\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue follower\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue follower not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, follower.issue_id).await?;\n\n    let response = IssueFollowerRepository::delete(state.pool(), issue_follower_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete issue follower\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/issue_relationships.rs",
    "content": "use api_types::{\n    CreateIssueRelationshipRequest, DeleteResponse, IssueRelationship, ListIssueRelationshipsQuery,\n    ListIssueRelationshipsResponse, MutationResponse,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_issue_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::issue_relationships::IssueRelationshipRepository,\n    mutation_definition::{MutationBuilder, NoUpdate},\n};\n\n/// Mutation definition for IssueRelationship - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<IssueRelationship, CreateIssueRelationshipRequest, NoUpdate> {\n    MutationBuilder::new(\"issue_relationships\")\n        .list(list_issue_relationships)\n        .get(get_issue_relationship)\n        .create(create_issue_relationship)\n        .delete(delete_issue_relationship)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation().router()\n}\n\n#[instrument(\n    name = \"issue_relationships.list_issue_relationships\",\n    skip(state, ctx),\n    fields(issue_id = %query.issue_id, user_id = %ctx.user.id)\n)]\nasync fn list_issue_relationships(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListIssueRelationshipsQuery>,\n) -> Result<Json<ListIssueRelationshipsResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let issue_relationships = IssueRelationshipRepository::list_by_issue(\n        state.pool(),\n        query.issue_id,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, issue_id = %query.issue_id, \"failed to list issue relationships\");\n        ErrorResponse::new(\n            StatusCode::INTERNAL_SERVER_ERROR,\n            \"failed to list issue relationships\",\n        )\n    })?;\n\n    Ok(Json(ListIssueRelationshipsResponse {\n        issue_relationships,\n    }))\n}\n\n#[instrument(\n    name = \"issue_relationships.get_issue_relationship\",\n    skip(state, ctx),\n    fields(issue_relationship_id = %issue_relationship_id, user_id = %ctx.user.id)\n)]\nasync fn get_issue_relationship(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_relationship_id): Path<Uuid>,\n) -> Result<Json<IssueRelationship>, ErrorResponse> {\n    let relationship = IssueRelationshipRepository::find_by_id(state.pool(), issue_relationship_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_relationship_id, \"failed to load issue relationship\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue relationship\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue relationship not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, relationship.issue_id).await?;\n\n    Ok(Json(relationship))\n}\n\n#[instrument(\n    name = \"issue_relationships.create_issue_relationship\",\n    skip(state, ctx, payload),\n    fields(issue_id = %payload.issue_id, user_id = %ctx.user.id)\n)]\nasync fn create_issue_relationship(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateIssueRelationshipRequest>,\n) -> Result<Json<MutationResponse<IssueRelationship>>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?;\n\n    let response = IssueRelationshipRepository::create(\n        state.pool(),\n        payload.id,\n        payload.issue_id,\n        payload.related_issue_id,\n        payload.relationship_type,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create issue relationship\");\n        db_error(error, \"failed to create issue relationship\")\n    })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_relationships.delete_issue_relationship\",\n    skip(state, ctx),\n    fields(issue_relationship_id = %issue_relationship_id, user_id = %ctx.user.id)\n)]\nasync fn delete_issue_relationship(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_relationship_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let relationship = IssueRelationshipRepository::find_by_id(state.pool(), issue_relationship_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_relationship_id, \"failed to load issue relationship\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue relationship\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue relationship not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, relationship.issue_id).await?;\n\n    let response = IssueRelationshipRepository::delete(state.pool(), issue_relationship_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete issue relationship\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/issue_tags.rs",
    "content": "use api_types::{\n    CreateIssueTagRequest, DeleteResponse, IssueTag, ListIssueTagsQuery, ListIssueTagsResponse,\n    MutationResponse,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_issue_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::issue_tags::IssueTagRepository,\n    mutation_definition::{MutationBuilder, NoUpdate},\n};\n\n/// Mutation definition for IssueTag - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<IssueTag, CreateIssueTagRequest, NoUpdate> {\n    MutationBuilder::new(\"issue_tags\")\n        .list(list_issue_tags)\n        .get(get_issue_tag)\n        .create(create_issue_tag)\n        .delete(delete_issue_tag)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation().router()\n}\n\n#[instrument(\n    name = \"issue_tags.list_issue_tags\",\n    skip(state, ctx),\n    fields(issue_id = %query.issue_id, user_id = %ctx.user.id)\n)]\nasync fn list_issue_tags(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListIssueTagsQuery>,\n) -> Result<Json<ListIssueTagsResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let issue_tags = IssueTagRepository::list_by_issue(state.pool(), query.issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, issue_id = %query.issue_id, \"failed to list issue tags\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list issue tags\",\n            )\n        })?;\n\n    Ok(Json(ListIssueTagsResponse { issue_tags }))\n}\n\n#[instrument(\n    name = \"issue_tags.get_issue_tag\",\n    skip(state, ctx),\n    fields(issue_tag_id = %issue_tag_id, user_id = %ctx.user.id)\n)]\nasync fn get_issue_tag(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_tag_id): Path<Uuid>,\n) -> Result<Json<IssueTag>, ErrorResponse> {\n    let issue_tag = IssueTagRepository::find_by_id(state.pool(), issue_tag_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_tag_id, \"failed to load issue tag\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue tag\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue tag not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, issue_tag.issue_id).await?;\n\n    Ok(Json(issue_tag))\n}\n\n#[instrument(\n    name = \"issue_tags.create_issue_tag\",\n    skip(state, ctx, payload),\n    fields(issue_id = %payload.issue_id, user_id = %ctx.user.id)\n)]\nasync fn create_issue_tag(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateIssueTagRequest>,\n) -> Result<Json<MutationResponse<IssueTag>>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?;\n\n    let response =\n        IssueTagRepository::create(state.pool(), payload.id, payload.issue_id, payload.tag_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, \"failed to create issue tag\");\n                db_error(error, \"failed to create issue tag\")\n            })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issue_tags.delete_issue_tag\",\n    skip(state, ctx),\n    fields(issue_tag_id = %issue_tag_id, user_id = %ctx.user.id)\n)]\nasync fn delete_issue_tag(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_tag_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let issue_tag = IssueTagRepository::find_by_id(state.pool(), issue_tag_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_tag_id, \"failed to load issue tag\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load issue tag\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue tag not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, issue_tag.issue_id).await?;\n\n    let response = IssueTagRepository::delete(state.pool(), issue_tag_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete issue tag\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/issues.rs",
    "content": "use api_types::{\n    CreateIssueRequest, DeleteResponse, Issue, ListIssuesQuery, ListIssuesResponse,\n    MutationResponse, NotificationPayload, NotificationType, SearchIssuesRequest,\n    UpdateIssueRequest,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n    routing::post,\n};\nuse serde::{Deserialize, Serialize};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_project_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        get_txid, issue_followers::IssueFollowerRepository, issues::IssueRepository,\n        project_statuses::ProjectStatusRepository,\n    },\n    mutation_definition::MutationBuilder,\n    notifications::{\n        collect_issue_recipients, send_debounced_issue_notifications, send_issue_notifications,\n    },\n};\n\n/// Mutation definition for Issue - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<Issue, CreateIssueRequest, UpdateIssueRequest> {\n    MutationBuilder::new(\"issues\")\n        .list(list_issues)\n        .get(get_issue)\n        .create(create_issue)\n        .update(update_issue)\n        .delete(delete_issue)\n}\n\n/// Router for issue endpoints including bulk update\npub fn router() -> axum::Router<AppState> {\n    mutation()\n        .router()\n        .route(\"/issues/search\", post(search_issues))\n        .route(\"/issues/bulk\", post(bulk_update_issues))\n}\n\nasync fn notify_issue_update_changes(\n    state: &AppState,\n    organization_id: Uuid,\n    actor_user_id: Uuid,\n    old_issue: &Issue,\n    new_issue: &Issue,\n) {\n    let status_changed = old_issue.status_id != new_issue.status_id;\n    let title_changed = old_issue.title != new_issue.title;\n    let description_changed = old_issue.description != new_issue.description;\n    let priority_changed = old_issue.priority != new_issue.priority;\n\n    let needs_notification =\n        status_changed || title_changed || description_changed || priority_changed;\n    if !needs_notification {\n        return;\n    }\n\n    let recipients =\n        match collect_issue_recipients(state.pool(), organization_id, new_issue.id, actor_user_id)\n            .await\n        {\n            Ok(recipients) => recipients,\n            Err(error) => {\n                tracing::warn!(\n                    ?error,\n                    issue_id = %new_issue.id,\n                    \"failed to collect notification recipients\"\n                );\n                vec![]\n            }\n        };\n\n    if recipients.is_empty() {\n        return;\n    }\n\n    if status_changed {\n        let old_status_name =\n            ProjectStatusRepository::find_by_id(state.pool(), old_issue.status_id)\n                .await\n                .ok()\n                .flatten()\n                .map(|s| s.name);\n        let new_status_name =\n            ProjectStatusRepository::find_by_id(state.pool(), new_issue.status_id)\n                .await\n                .ok()\n                .flatten()\n                .map(|s| s.name);\n\n        send_issue_notifications(\n            state.pool(),\n            organization_id,\n            actor_user_id,\n            &recipients,\n            new_issue,\n            NotificationType::IssueStatusChanged,\n            NotificationPayload {\n                old_status_id: Some(old_issue.status_id),\n                new_status_id: Some(new_issue.status_id),\n                old_status_name,\n                new_status_name,\n                ..Default::default()\n            },\n            None,\n            Some(new_issue.id),\n        )\n        .await;\n    }\n\n    if title_changed {\n        send_debounced_issue_notifications(\n            state.pool(),\n            organization_id,\n            actor_user_id,\n            &recipients,\n            new_issue,\n            NotificationType::IssueTitleChanged,\n            NotificationPayload {\n                new_title: Some(new_issue.title.clone()),\n                ..Default::default()\n            },\n            None,\n            Some(new_issue.id),\n        )\n        .await;\n    }\n\n    if description_changed {\n        send_debounced_issue_notifications(\n            state.pool(),\n            organization_id,\n            actor_user_id,\n            &recipients,\n            new_issue,\n            NotificationType::IssueDescriptionChanged,\n            NotificationPayload::default(),\n            None,\n            Some(new_issue.id),\n        )\n        .await;\n    }\n\n    if priority_changed {\n        send_debounced_issue_notifications(\n            state.pool(),\n            organization_id,\n            actor_user_id,\n            &recipients,\n            new_issue,\n            NotificationType::IssuePriorityChanged,\n            NotificationPayload {\n                old_priority: old_issue.priority,\n                new_priority: new_issue.priority,\n                ..Default::default()\n            },\n            None,\n            Some(new_issue.id),\n        )\n        .await;\n    }\n}\n\n#[instrument(\n    name = \"issues.list_issues\",\n    skip(state, ctx),\n    fields(project_id = %query.project_id, user_id = %ctx.user.id)\n)]\nasync fn list_issues(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListIssuesQuery>,\n) -> Result<Json<ListIssuesResponse>, ErrorResponse> {\n    let project_id = query.project_id;\n    ensure_project_access(state.pool(), ctx.user.id, project_id).await?;\n    let request = SearchIssuesRequest {\n        project_id,\n        status_id: None,\n        status_ids: None,\n        priority: None,\n        parent_issue_id: None,\n        search: None,\n        simple_id: None,\n        assignee_user_id: None,\n        tag_id: None,\n        tag_ids: None,\n        sort_field: None,\n        sort_direction: None,\n        limit: None,\n        offset: None,\n    };\n\n    let response = IssueRepository::search(state.pool(), &request)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %project_id, \"failed to list issues\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list issues\")\n        })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issues.search_issues\",\n    skip(state, ctx, payload),\n    fields(project_id = %payload.project_id, user_id = %ctx.user.id)\n)]\nasync fn search_issues(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<SearchIssuesRequest>,\n) -> Result<Json<ListIssuesResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?;\n\n    let response = IssueRepository::search(state.pool(), &payload)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %payload.project_id, \"failed to search issues\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to search issues\")\n        })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issues.get_issue\",\n    skip(state, ctx),\n    fields(issue_id = %issue_id, user_id = %ctx.user.id)\n)]\nasync fn get_issue(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_id): Path<Uuid>,\n) -> Result<Json<Issue>, ErrorResponse> {\n    let issue = IssueRepository::find_by_id(state.pool(), issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_id, \"failed to load issue\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load issue\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, issue.project_id).await?;\n\n    Ok(Json(issue))\n}\n\n#[instrument(\n    name = \"issues.create_issue\",\n    skip(state, ctx, payload),\n    fields(project_id = %payload.project_id, user_id = %ctx.user.id)\n)]\nasync fn create_issue(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateIssueRequest>,\n) -> Result<Json<MutationResponse<Issue>>, ErrorResponse> {\n    let organization_id =\n        ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?;\n\n    let has_parent = payload.parent_issue_id.is_some();\n    let has_description = payload.description.is_some();\n    let priority = payload.priority;\n    let parent_issue_id = payload.parent_issue_id;\n\n    let response = IssueRepository::create(\n        state.pool(),\n        payload.id,\n        payload.project_id,\n        payload.status_id,\n        payload.title,\n        payload.description,\n        payload.priority,\n        payload.start_date,\n        payload.target_date,\n        payload.completed_at,\n        payload.sort_order,\n        payload.parent_issue_id,\n        payload.parent_issue_sort_order,\n        payload.extension_metadata,\n        ctx.user.id,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create issue\");\n        db_error(error, \"failed to create issue\")\n    })?;\n\n    // Auto-follow: the creator should receive notifications for all activity on this issue.\n    if let Err(e) =\n        IssueFollowerRepository::create(state.pool(), None, response.data.id, ctx.user.id).await\n    {\n        tracing::warn!(?e, issue_id = %response.data.id, \"failed to auto-follow issue for creator\");\n    }\n\n    if let Some(analytics) = state.analytics() {\n        analytics.track(\n            ctx.user.id,\n            \"issue_created\",\n            serde_json::json!({\n                \"issue_id\": response.data.id,\n                \"project_id\": response.data.project_id,\n                \"organization_id\": organization_id,\n                \"has_description\": has_description,\n                \"has_parent\": has_parent,\n                \"priority\": format!(\"{:?}\", priority),\n            }),\n        );\n\n        if let Some(parent_id) = parent_issue_id {\n            analytics.track(\n                ctx.user.id,\n                \"subtask_created\",\n                serde_json::json!({\n                    \"issue_id\": response.data.id,\n                    \"parent_issue_id\": parent_id,\n                    \"project_id\": response.data.project_id,\n                    \"organization_id\": organization_id,\n                }),\n            );\n        }\n    }\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"issues.update_issue\",\n    skip(state, ctx, payload),\n    fields(issue_id = %issue_id, user_id = %ctx.user.id)\n)]\nasync fn update_issue(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_id): Path<Uuid>,\n    Json(payload): Json<UpdateIssueRequest>,\n) -> Result<Json<MutationResponse<Issue>>, ErrorResponse> {\n    let issue = IssueRepository::find_by_id(state.pool(), issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_id, \"failed to load issue\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load issue\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue not found\"))?;\n\n    let organization_id =\n        ensure_project_access(state.pool(), ctx.user.id, issue.project_id).await?;\n\n    let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| {\n        tracing::error!(?error, \"failed to begin transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let data = IssueRepository::update(\n        &mut *tx,\n        issue_id,\n        payload.status_id,\n        payload.title,\n        payload.description,\n        payload.priority,\n        payload.start_date,\n        payload.target_date,\n        payload.completed_at,\n        payload.sort_order,\n        payload.parent_issue_id,\n        payload.parent_issue_sort_order,\n        payload.extension_metadata,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to update issue\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let txid = get_txid(&mut *tx).await.map_err(|error| {\n        tracing::error!(?error, \"failed to get txid\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    tx.commit().await.map_err(|error| {\n        tracing::error!(?error, \"failed to commit transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    notify_issue_update_changes(&state, organization_id, ctx.user.id, &issue, &data).await;\n\n    Ok(Json(MutationResponse { data, txid }))\n}\n\n#[instrument(\n    name = \"issues.delete_issue\",\n    skip(state, ctx),\n    fields(issue_id = %issue_id, user_id = %ctx.user.id)\n)]\nasync fn delete_issue(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(issue_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let issue = IssueRepository::find_by_id(state.pool(), issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_id, \"failed to load issue\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load issue\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue not found\"))?;\n\n    let organization_id =\n        ensure_project_access(state.pool(), ctx.user.id, issue.project_id).await?;\n\n    let recipients = match collect_issue_recipients(\n        state.pool(),\n        organization_id,\n        issue.id,\n        ctx.user.id,\n    )\n    .await\n    {\n        Ok(recipients) => recipients,\n        Err(error) => {\n            tracing::warn!(\n                ?error,\n                issue_id = %issue.id,\n                \"failed to collect notification recipients\"\n            );\n            vec![]\n        }\n    };\n\n    let response = IssueRepository::delete(state.pool(), issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete issue\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    send_issue_notifications(\n        state.pool(),\n        organization_id,\n        ctx.user.id,\n        &recipients,\n        &issue,\n        NotificationType::IssueDeleted,\n        NotificationPayload::default(),\n        None,\n        None,\n    )\n    .await;\n\n    Ok(Json(response))\n}\n\n// =============================================================================\n// Bulk Update\n// =============================================================================\n\n#[derive(Debug, Deserialize)]\npub struct BulkUpdateIssueItem {\n    pub id: Uuid,\n    #[serde(flatten)]\n    pub changes: UpdateIssueRequest,\n}\n\n#[derive(Debug, Deserialize)]\npub struct BulkUpdateIssuesRequest {\n    pub updates: Vec<BulkUpdateIssueItem>,\n}\n\n#[derive(Debug, Serialize)]\npub struct BulkUpdateIssuesResponse {\n    pub data: Vec<Issue>,\n    pub txid: i64,\n}\n\n#[instrument(\n    name = \"issues.bulk_update\",\n    skip(state, ctx, payload),\n    fields(user_id = %ctx.user.id, count = payload.updates.len())\n)]\nasync fn bulk_update_issues(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkUpdateIssuesRequest>,\n) -> Result<Json<BulkUpdateIssuesResponse>, ErrorResponse> {\n    if payload.updates.is_empty() {\n        return Ok(Json(BulkUpdateIssuesResponse {\n            data: vec![],\n            txid: 0,\n        }));\n    }\n\n    // Get first issue to determine project_id for access check\n    let first_issue = IssueRepository::find_by_id(state.pool(), payload.updates[0].id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to find first issue\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find issue\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue not found\"))?;\n\n    let project_id = first_issue.project_id;\n    let organization_id = ensure_project_access(state.pool(), ctx.user.id, project_id).await?;\n\n    let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| {\n        tracing::error!(?error, \"failed to begin transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let mut results = Vec::with_capacity(payload.updates.len());\n    let mut notification_pairs = Vec::with_capacity(payload.updates.len());\n\n    for item in payload.updates {\n        // Verify issue belongs to the same project\n        let issue = IssueRepository::find_by_id(&mut *tx, item.id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, issue_id = %item.id, \"failed to find issue\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find issue\")\n            })?\n            .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"issue not found\"))?;\n\n        if issue.project_id != project_id {\n            return Err(ErrorResponse::new(\n                StatusCode::BAD_REQUEST,\n                \"all issues must belong to the same project\",\n            ));\n        }\n\n        // Update the issue\n        let updated = IssueRepository::update(\n            &mut *tx,\n            item.id,\n            item.changes.status_id,\n            item.changes.title,\n            item.changes.description,\n            item.changes.priority,\n            item.changes.start_date,\n            item.changes.target_date,\n            item.changes.completed_at,\n            item.changes.sort_order,\n            item.changes.parent_issue_id,\n            item.changes.parent_issue_sort_order,\n            item.changes.extension_metadata,\n        )\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, issue_id = %item.id, \"failed to update issue\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to update issue\")\n        })?;\n\n        notification_pairs.push((issue, updated.clone()));\n        results.push(updated);\n    }\n\n    let txid = get_txid(&mut *tx).await.map_err(|error| {\n        tracing::error!(?error, \"failed to get txid\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n    tx.commit().await.map_err(|error| {\n        tracing::error!(?error, \"failed to commit transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    for (old_issue, new_issue) in &notification_pairs {\n        notify_issue_update_changes(&state, organization_id, ctx.user.id, old_issue, new_issue)\n            .await;\n    }\n\n    Ok(Json(BulkUpdateIssuesResponse {\n        data: results,\n        txid,\n    }))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/migration.rs",
    "content": "use std::collections::HashSet;\n\nuse api_types::{\n    BulkMigrateRequest, BulkMigrateResponse, MigrateIssueRequest, MigrateProjectRequest,\n    MigratePullRequestRequest, MigrateWorkspaceRequest,\n};\nuse axum::{\n    Json, Router,\n    extract::{Extension, State},\n    http::StatusCode,\n    routing::post,\n};\nuse tracing::instrument;\n\nuse super::{\n    error::ErrorResponse,\n    organization_members::{ensure_issue_access, ensure_member_access, ensure_project_access},\n};\nuse crate::{AppState, auth::RequestContext, db::migration::MigrationRepository};\n\npub fn router() -> Router<AppState> {\n    Router::new()\n        .route(\"/migration/projects\", post(migrate_projects))\n        .route(\"/migration/issues\", post(migrate_issues))\n        .route(\"/migration/pull_requests\", post(migrate_pull_requests))\n        .route(\"/migration/workspaces\", post(migrate_workspaces))\n}\n\n#[instrument(name = \"migration.projects\", skip(state, ctx, payload))]\nasync fn migrate_projects(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkMigrateRequest<MigrateProjectRequest>>,\n) -> Result<Json<BulkMigrateResponse>, ErrorResponse> {\n    let org_ids: HashSet<_> = payload\n        .items\n        .iter()\n        .map(|item| item.organization_id)\n        .collect();\n    for org_id in org_ids {\n        ensure_member_access(state.pool(), org_id, ctx.user.id).await?;\n    }\n\n    let ids = MigrationRepository::bulk_create_projects(state.pool(), payload.items)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to migrate projects\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())\n        })?;\n\n    Ok(Json(BulkMigrateResponse { ids }))\n}\n\n#[instrument(name = \"migration.issues\", skip(state, ctx, payload))]\nasync fn migrate_issues(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkMigrateRequest<MigrateIssueRequest>>,\n) -> Result<Json<BulkMigrateResponse>, ErrorResponse> {\n    let project_ids: HashSet<_> = payload.items.iter().map(|item| item.project_id).collect();\n    for project_id in project_ids {\n        ensure_project_access(state.pool(), ctx.user.id, project_id).await?;\n    }\n\n    let ids = MigrationRepository::bulk_create_issues(state.pool(), payload.items)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to migrate issues\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())\n        })?;\n\n    Ok(Json(BulkMigrateResponse { ids }))\n}\n\n#[instrument(name = \"migration.pull_requests\", skip(state, ctx, payload))]\nasync fn migrate_pull_requests(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkMigrateRequest<MigratePullRequestRequest>>,\n) -> Result<Json<BulkMigrateResponse>, ErrorResponse> {\n    let issue_ids: HashSet<_> = payload.items.iter().map(|item| item.issue_id).collect();\n    for issue_id in issue_ids {\n        ensure_issue_access(state.pool(), ctx.user.id, issue_id).await?;\n    }\n\n    let ids = MigrationRepository::bulk_create_pull_requests(state.pool(), payload.items)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to migrate pull requests\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())\n        })?;\n\n    Ok(Json(BulkMigrateResponse { ids }))\n}\n\n#[instrument(name = \"migration.workspaces\", skip(state, ctx, payload))]\nasync fn migrate_workspaces(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkMigrateRequest<MigrateWorkspaceRequest>>,\n) -> Result<Json<BulkMigrateResponse>, ErrorResponse> {\n    let project_ids: HashSet<_> = payload.items.iter().map(|item| item.project_id).collect();\n    for project_id in project_ids {\n        ensure_project_access(state.pool(), ctx.user.id, project_id).await?;\n    }\n\n    let ids = MigrationRepository::bulk_create_workspaces(state.pool(), ctx.user.id, payload.items)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to migrate workspaces\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())\n        })?;\n\n    Ok(Json(BulkMigrateResponse { ids }))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/mod.rs",
    "content": "use axum::{Json, Router, http::header::HeaderName, middleware, routing::get};\nuse serde::Serialize;\nuse tower_http::{\n    compression::CompressionLayer,\n    cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer},\n    request_id::{MakeRequestUuid, PropagateRequestIdLayer, RequestId, SetRequestIdLayer},\n    services::{ServeDir, ServeFile},\n    trace::{DefaultOnFailure, TraceLayer},\n};\nuse tracing::{Level, Span, field};\n\nuse crate::{AppState, auth::require_session};\n\n#[cfg(feature = \"vk-billing\")]\nmod billing;\n#[cfg(not(feature = \"vk-billing\"))]\nmod billing {\n    use axum::Router;\n\n    use crate::AppState;\n    pub fn public_router() -> Router<AppState> {\n        Router::new()\n    }\n    pub fn protected_router() -> Router<AppState> {\n        Router::new()\n    }\n}\npub mod attachments;\npub(crate) mod electric_proxy;\npub(crate) mod error;\nmod github_app;\npub mod hosts;\nmod identity;\npub mod issue_assignees;\npub mod issue_comment_reactions;\npub mod issue_comments;\npub mod issue_followers;\npub mod issue_relationships;\npub mod issue_tags;\npub mod issues;\nmod migration;\npub mod notifications;\nmod oauth;\npub(crate) mod organization_members;\nmod organizations;\npub mod project_statuses;\npub mod projects;\nmod pull_requests;\nmod review;\npub mod tags;\nmod tokens;\nmod workspaces;\n\npub fn router(state: AppState) -> Router {\n    let trace_layer = TraceLayer::new_for_http()\n        .make_span_with(|request: &axum::http::Request<_>| {\n            let request_id = request\n                .extensions()\n                .get::<RequestId>()\n                .and_then(|id| id.header_value().to_str().ok());\n            let is_health = request.uri().path() == \"/health\";\n            let span = if is_health {\n                tracing::trace_span!(\n                    \"http_request\",\n                    method = %request.method(),\n                    uri = %request.uri(),\n                    request_id = field::Empty,\n                    user_id = field::Empty\n                )\n            } else {\n                tracing::debug_span!(\n                    \"http_request\",\n                    method = %request.method(),\n                    uri = %request.uri(),\n                    request_id = field::Empty,\n                    user_id = field::Empty\n                )\n            };\n            if let Some(request_id) = request_id {\n                span.record(\"request_id\", field::display(request_id));\n            }\n            span\n        })\n        .on_response(\n            |response: &axum::http::Response<_>, latency: std::time::Duration, span: &Span| {\n                if span.is_disabled() {\n                    return;\n                }\n                let status = response.status().as_u16();\n                let latency_ms = latency.as_millis();\n                if status >= 500 {\n                    tracing::error!(status, latency_ms, \"server error\");\n                } else if status >= 400 {\n                    tracing::warn!(status, latency_ms, \"client error\");\n                } else {\n                    tracing::debug!(status, latency_ms, \"request completed\");\n                }\n            },\n        )\n        .on_failure(DefaultOnFailure::new().level(Level::ERROR));\n\n    let v1_public = Router::<AppState>::new()\n        .route(\"/health\", get(health))\n        .merge(oauth::public_router())\n        .merge(organization_members::public_router())\n        .merge(tokens::public_router())\n        .merge(review::public_router())\n        .merge(github_app::public_router())\n        .merge(billing::public_router());\n\n    let v1_protected = Router::<AppState>::new()\n        .merge(identity::router())\n        .merge(hosts::router())\n        .merge(projects::router())\n        .merge(organizations::router())\n        .merge(organization_members::protected_router())\n        .merge(oauth::protected_router())\n        .merge(electric_proxy::router())\n        .merge(github_app::protected_router())\n        .merge(project_statuses::router())\n        .merge(tags::router())\n        .merge(issue_comments::router())\n        .merge(issue_comment_reactions::router())\n        .merge(issues::router())\n        .merge(issue_assignees::router())\n        .merge(attachments::router())\n        .merge(issue_followers::router())\n        .merge(issue_tags::router())\n        .merge(issue_relationships::router())\n        .merge(pull_requests::router())\n        .merge(notifications::router())\n        .merge(workspaces::router())\n        .merge(billing::protected_router())\n        .merge(migration::router())\n        .layer(middleware::from_fn_with_state(\n            state.clone(),\n            require_session,\n        ));\n\n    let static_dir = \"/srv/static\";\n    let spa =\n        ServeDir::new(static_dir).fallback(ServeFile::new(format!(\"{static_dir}/index.html\")));\n\n    Router::<AppState>::new()\n        .nest(\"/v1\", v1_public)\n        .nest(\"/v1\", v1_protected)\n        .fallback_service(spa)\n        .layer(CompressionLayer::new())\n        .layer(middleware::from_fn(\n            crate::middleware::version::add_version_headers,\n        ))\n        .layer(\n            CorsLayer::new()\n                .allow_origin(AllowOrigin::mirror_request())\n                .allow_methods(AllowMethods::mirror_request())\n                .allow_headers(AllowHeaders::mirror_request())\n                .allow_credentials(true),\n        )\n        .layer(trace_layer)\n        .layer(PropagateRequestIdLayer::new(HeaderName::from_static(\n            \"x-request-id\",\n        )))\n        .layer(SetRequestIdLayer::new(\n            HeaderName::from_static(\"x-request-id\"),\n            MakeRequestUuid {},\n        ))\n        .with_state(state)\n}\n\n#[derive(Serialize)]\nstruct HealthResponse {\n    status: &'static str,\n    version: &'static str,\n}\n\nasync fn health() -> Json<HealthResponse> {\n    Json(HealthResponse {\n        status: \"ok\",\n        version: env!(\"CARGO_PKG_VERSION\"),\n    })\n}\n\n/// Collect all mutation definitions for TypeScript generation.\npub fn all_mutation_definitions() -> Vec<crate::mutation_definition::MutationDefinition> {\n    vec![\n        projects::mutation().definition(),\n        notifications::mutation().definition(),\n        tags::mutation().definition(),\n        project_statuses::mutation().definition(),\n        issues::mutation().definition(),\n        issue_assignees::mutation().definition(),\n        issue_followers::mutation().definition(),\n        issue_tags::mutation().definition(),\n        issue_relationships::mutation().definition(),\n        issue_comments::mutation().definition(),\n        issue_comment_reactions::mutation().definition(),\n    ]\n}\n"
  },
  {
    "path": "crates/remote/src/routes/notifications.rs",
    "content": "use api_types::{DeleteResponse, MutationResponse, Notification, UpdateNotificationRequest};\nuse axum::{\n    Json, Router,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n    routing::post,\n};\nuse serde::{Deserialize, Serialize};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::error::ErrorResponse;\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{get_txid, notifications::NotificationRepository},\n    mutation_definition::{MutationBuilder, NoCreate},\n};\n\n#[derive(Debug, Serialize)]\npub struct ListNotificationsResponse {\n    pub notifications: Vec<Notification>,\n}\n\n#[derive(Debug, Deserialize)]\npub struct ListNotificationsQuery {\n    #[serde(default)]\n    pub include_dismissed: bool,\n}\n\n#[derive(Debug, Deserialize)]\npub struct BulkUpdateNotificationItem {\n    pub id: Uuid,\n    #[serde(flatten)]\n    pub changes: UpdateNotificationRequest,\n}\n\n#[derive(Debug, Deserialize)]\npub struct BulkUpdateNotificationsRequest {\n    pub updates: Vec<BulkUpdateNotificationItem>,\n}\n\n#[derive(Debug, Serialize)]\npub struct BulkUpdateNotificationsResponse {\n    pub data: Vec<Notification>,\n    pub txid: i64,\n}\n\npub fn mutation() -> MutationBuilder<Notification, NoCreate, UpdateNotificationRequest> {\n    MutationBuilder::new(\"notifications\")\n        .list(list_notifications)\n        .get(get_notification)\n        .update(update_notification)\n        .delete(delete_notification)\n}\n\npub fn router() -> Router<AppState> {\n    mutation()\n        .router()\n        .route(\"/notifications/bulk\", post(bulk_update_notifications))\n}\n\n#[instrument(\n    name = \"notifications.list\",\n    skip(state, ctx),\n    fields(user_id = %ctx.user.id)\n)]\nasync fn list_notifications(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListNotificationsQuery>,\n) -> Result<Json<ListNotificationsResponse>, ErrorResponse> {\n    let notifications =\n        NotificationRepository::list_by_user(state.pool(), ctx.user.id, query.include_dismissed)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, \"failed to list notifications\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list notifications\",\n                )\n            })?;\n\n    Ok(Json(ListNotificationsResponse { notifications }))\n}\n\n#[instrument(\n    name = \"notifications.get\",\n    skip(state, ctx),\n    fields(notification_id = %notification_id, user_id = %ctx.user.id)\n)]\nasync fn get_notification(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(notification_id): Path<Uuid>,\n) -> Result<Json<Notification>, ErrorResponse> {\n    let notification = NotificationRepository::find_by_id(state.pool(), notification_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %notification_id, \"failed to load notification\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load notification\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"notification not found\"))?;\n\n    if notification.user_id != ctx.user.id {\n        return Err(ErrorResponse::new(\n            StatusCode::NOT_FOUND,\n            \"notification not found\",\n        ));\n    }\n\n    Ok(Json(notification))\n}\n\n#[instrument(\n    name = \"notifications.update\",\n    skip(state, ctx, payload),\n    fields(notification_id = %notification_id, user_id = %ctx.user.id)\n)]\nasync fn update_notification(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(notification_id): Path<Uuid>,\n    Json(payload): Json<UpdateNotificationRequest>,\n) -> Result<Json<MutationResponse<Notification>>, ErrorResponse> {\n    let mut tx = state.pool().begin().await.map_err(|error| {\n        tracing::error!(?error, \"failed to begin transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let existing = NotificationRepository::find_by_id(&mut *tx, notification_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %notification_id, \"failed to load notification\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load notification\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"notification not found\"))?;\n\n    if existing.user_id != ctx.user.id {\n        return Err(ErrorResponse::new(\n            StatusCode::NOT_FOUND,\n            \"notification not found\",\n        ));\n    }\n\n    let data = NotificationRepository::update(&mut *tx, notification_id, payload.seen)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to update notification\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    let txid = get_txid(&mut *tx).await.map_err(|error| {\n        tracing::error!(?error, \"failed to get txid\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    tx.commit().await.map_err(|error| {\n        tracing::error!(?error, \"failed to commit transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    Ok(Json(MutationResponse { data, txid }))\n}\n\n#[instrument(\n    name = \"notifications.delete\",\n    skip(state, ctx),\n    fields(notification_id = %notification_id, user_id = %ctx.user.id)\n)]\nasync fn delete_notification(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(notification_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let mut tx = state.pool().begin().await.map_err(|error| {\n        tracing::error!(?error, \"failed to begin transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let notification = NotificationRepository::find_by_id(&mut *tx, notification_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %notification_id, \"failed to load notification\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load notification\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"notification not found\"))?;\n\n    if notification.user_id != ctx.user.id {\n        return Err(ErrorResponse::new(\n            StatusCode::NOT_FOUND,\n            \"notification not found\",\n        ));\n    }\n\n    NotificationRepository::delete(&mut *tx, notification_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete notification\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    let txid = get_txid(&mut *tx).await.map_err(|error| {\n        tracing::error!(?error, \"failed to get txid\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    tx.commit().await.map_err(|error| {\n        tracing::error!(?error, \"failed to commit transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    Ok(Json(DeleteResponse { txid }))\n}\n\n#[instrument(\n    name = \"notifications.bulk_update\",\n    skip(state, ctx, payload),\n    fields(user_id = %ctx.user.id, count = payload.updates.len())\n)]\nasync fn bulk_update_notifications(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkUpdateNotificationsRequest>,\n) -> Result<Json<BulkUpdateNotificationsResponse>, ErrorResponse> {\n    if payload.updates.is_empty() {\n        return Ok(Json(BulkUpdateNotificationsResponse {\n            data: vec![],\n            txid: 0,\n        }));\n    }\n\n    let mut tx = state.pool().begin().await.map_err(|error| {\n        tracing::error!(?error, \"failed to begin transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let first_notification = NotificationRepository::find_by_id(&mut *tx, payload.updates[0].id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to find first notification\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to find notification\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"notification not found\"))?;\n\n    let user_id = first_notification.user_id;\n    if user_id != ctx.user.id {\n        return Err(ErrorResponse::new(\n            StatusCode::NOT_FOUND,\n            \"notification not found\",\n        ));\n    }\n\n    let mut results = Vec::with_capacity(payload.updates.len());\n\n    for item in payload.updates {\n        let existing = NotificationRepository::find_by_id(&mut *tx, item.id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, notification_id = %item.id, \"failed to find notification\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to find notification\",\n                )\n            })?\n            .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"notification not found\"))?;\n\n        if existing.user_id != user_id {\n            return Err(ErrorResponse::new(\n                StatusCode::BAD_REQUEST,\n                \"all notifications must belong to the same user\",\n            ));\n        }\n\n        let updated = NotificationRepository::update(&mut *tx, item.id, item.changes.seen)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, notification_id = %item.id, \"failed to update notification\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n            })?;\n\n        results.push(updated);\n    }\n\n    let txid = get_txid(&mut *tx).await.map_err(|error| {\n        tracing::error!(?error, \"failed to get txid\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    tx.commit().await.map_err(|error| {\n        tracing::error!(?error, \"failed to commit transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    Ok(Json(BulkUpdateNotificationsResponse {\n        data: results,\n        txid,\n    }))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/oauth.rs",
    "content": "use std::borrow::Cow;\n\nuse api_types::{\n    HandoffInitRequest, HandoffInitResponse, HandoffRedeemRequest, HandoffRedeemResponse,\n    ProfileResponse, ProviderProfile,\n};\nuse axum::{\n    Json, Router,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n    response::{IntoResponse, Redirect, Response},\n    routing::{get, post},\n};\nuse serde::Deserialize;\nuse tracing::warn;\nuse url::Url;\nuse uuid::Uuid;\n\nuse crate::{\n    AppState,\n    audit::{self, AuditAction, AuditEvent},\n    auth::{CallbackResult, HandoffError, RequestContext},\n    db::{oauth::OAuthHandoffError, oauth_accounts::OAuthAccountRepository},\n};\n\npub fn public_router() -> Router<AppState> {\n    Router::new()\n        .route(\"/oauth/web/init\", post(web_init))\n        .route(\"/oauth/web/redeem\", post(web_redeem))\n        .route(\"/oauth/{provider}/start\", get(authorize_start))\n        .route(\"/oauth/{provider}/callback\", get(authorize_callback))\n}\n\npub fn protected_router() -> Router<AppState> {\n    Router::new()\n        .route(\"/profile\", get(profile))\n        .route(\"/oauth/logout\", post(logout))\n}\n\npub async fn web_init(\n    State(state): State<AppState>,\n    Json(payload): Json<HandoffInitRequest>,\n) -> Response {\n    let handoff = state.handoff();\n\n    match handoff\n        .initiate(\n            &payload.provider,\n            &payload.return_to,\n            &payload.app_challenge,\n        )\n        .await\n    {\n        Ok(result) => (\n            StatusCode::OK,\n            Json(HandoffInitResponse {\n                handoff_id: result.handoff_id,\n                authorize_url: result.authorize_url,\n            }),\n        )\n            .into_response(),\n        Err(error) => init_error_response(error),\n    }\n}\n\npub async fn web_redeem(\n    State(state): State<AppState>,\n    Json(payload): Json<HandoffRedeemRequest>,\n) -> Response {\n    let handoff = state.handoff();\n    match handoff\n        .redeem(payload.handoff_id, &payload.app_code, &payload.app_verifier)\n        .await\n    {\n        Ok(result) => {\n            if let Some(analytics) = state.analytics() {\n                analytics.track(\n                    result.user_id,\n                    \"$identify\",\n                    serde_json::json!({ \"email\": result.email }),\n                );\n            }\n\n            audit::emit(\n                AuditEvent::system(AuditAction::AuthLogin)\n                    .user(result.user_id, None)\n                    .resource(\"auth_session\", None)\n                    .http(\"POST\", \"/v1/oauth/web/redeem\", 200)\n                    .description(\"User logged in via OAuth\"),\n            );\n\n            (\n                StatusCode::OK,\n                Json(HandoffRedeemResponse {\n                    access_token: result.access_token,\n                    refresh_token: result.refresh_token,\n                }),\n            )\n                .into_response()\n        }\n        Err(error) => redeem_error_response(error),\n    }\n}\n\n#[derive(Debug, Deserialize)]\npub struct StartQuery {\n    handoff_id: Uuid,\n}\n\npub async fn authorize_start(\n    State(state): State<AppState>,\n    Path(provider): Path<String>,\n    Query(query): Query<StartQuery>,\n) -> Response {\n    let handoff = state.handoff();\n\n    match handoff.authorize_url(&provider, query.handoff_id).await {\n        Ok(url) => Redirect::temporary(&url).into_response(),\n        Err(error) => {\n            let (status, message) = classify_handoff_error(&error);\n            (\n                status,\n                format!(\"OAuth authorization failed: {}\", message.into_owned()),\n            )\n                .into_response()\n        }\n    }\n}\n\n#[derive(Debug, Deserialize)]\npub struct CallbackQuery {\n    state: Option<String>,\n    code: Option<String>,\n    error: Option<String>,\n}\n\npub async fn authorize_callback(\n    State(state): State<AppState>,\n    Path(provider): Path<String>,\n    Query(query): Query<CallbackQuery>,\n) -> Response {\n    let handoff = state.handoff();\n\n    match handoff\n        .handle_callback(\n            &provider,\n            query.state.as_deref(),\n            query.code.as_deref(),\n            query.error.as_deref(),\n        )\n        .await\n    {\n        Ok(CallbackResult::Success {\n            handoff_id,\n            return_to,\n            app_code,\n        }) => match append_query_params(&return_to, Some(handoff_id), Some(&app_code), None) {\n            Ok(url) => Redirect::temporary(url.as_str()).into_response(),\n            Err(err) => (\n                StatusCode::BAD_REQUEST,\n                format!(\"Invalid return_to URL: {err}\"),\n            )\n                .into_response(),\n        },\n        Ok(CallbackResult::Error {\n            handoff_id,\n            return_to,\n            error,\n        }) => {\n            if let Some(url) = return_to {\n                match append_query_params(&url, handoff_id, None, Some(&error)) {\n                    Ok(url) => Redirect::temporary(url.as_str()).into_response(),\n                    Err(err) => (\n                        StatusCode::BAD_REQUEST,\n                        format!(\"Invalid return_to URL: {err}\"),\n                    )\n                        .into_response(),\n                }\n            } else {\n                (\n                    StatusCode::BAD_REQUEST,\n                    format!(\"OAuth authorization failed: {error}\"),\n                )\n                    .into_response()\n            }\n        }\n        Err(error) => {\n            let (status, message) = classify_handoff_error(&error);\n            (\n                status,\n                format!(\"OAuth authorization failed: {}\", message.into_owned()),\n            )\n                .into_response()\n        }\n    }\n}\n\npub async fn profile(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n) -> Json<ProfileResponse> {\n    let repo = OAuthAccountRepository::new(state.pool());\n    let providers = repo\n        .list_by_user(ctx.user.id)\n        .await\n        .unwrap_or_default()\n        .into_iter()\n        .map(|account| ProviderProfile {\n            provider: account.provider,\n            username: account.username,\n            display_name: account.display_name,\n            email: account.email,\n            avatar_url: account.avatar_url,\n        })\n        .collect();\n\n    Json(ProfileResponse {\n        user_id: ctx.user.id,\n        username: ctx.user.username.clone(),\n        email: ctx.user.email.clone(),\n        providers,\n    })\n}\n\npub async fn logout(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n) -> Response {\n    use crate::db::auth::{AuthSessionError, AuthSessionRepository};\n\n    let repo = AuthSessionRepository::new(state.pool());\n\n    let (response, status) = match repo.revoke(ctx.session_id).await {\n        Ok(_) | Err(AuthSessionError::NotFound) => (StatusCode::NO_CONTENT.into_response(), 204u16),\n        Err(AuthSessionError::Database(error)) => {\n            warn!(?error, session_id = %ctx.session_id, \"failed to revoke auth session\");\n            (StatusCode::INTERNAL_SERVER_ERROR.into_response(), 500u16)\n        }\n        Err(error) => {\n            warn!(?error, session_id = %ctx.session_id, \"failed to revoke auth session\");\n            (StatusCode::INTERNAL_SERVER_ERROR.into_response(), 500u16)\n        }\n    };\n\n    audit::emit(\n        AuditEvent::from_request(&ctx, AuditAction::AuthLogout)\n            .resource(\"auth_session\", Some(ctx.session_id))\n            .http(\"POST\", \"/v1/oauth/logout\", status)\n            .description(\"User logged out\"),\n    );\n\n    response\n}\n\nfn init_error_response(error: HandoffError) -> Response {\n    match &error {\n        HandoffError::Provider(err) => warn!(?err, \"provider error during oauth init\"),\n        HandoffError::Database(err) => warn!(?err, \"database error during oauth init\"),\n        HandoffError::Authorization(err) => warn!(?err, \"authorization error during oauth init\"),\n        HandoffError::Identity(err) => warn!(?err, \"identity error during oauth init\"),\n        HandoffError::OAuthAccount(err) => warn!(?err, \"account error during oauth init\"),\n        _ => {}\n    }\n\n    let (status, code) = classify_handoff_error(&error);\n    let code = code.into_owned();\n    (status, Json(serde_json::json!({ \"error\": code }))).into_response()\n}\n\nfn redeem_error_response(error: HandoffError) -> Response {\n    match &error {\n        HandoffError::Provider(err) => warn!(?err, \"provider error during oauth redeem\"),\n        HandoffError::Database(err) => warn!(?err, \"database error during oauth redeem\"),\n        HandoffError::Authorization(err) => warn!(?err, \"authorization error during oauth redeem\"),\n        HandoffError::Identity(err) => warn!(?err, \"identity error during oauth redeem\"),\n        HandoffError::OAuthAccount(err) => warn!(?err, \"account error during oauth redeem\"),\n        HandoffError::Session(err) => warn!(?err, \"session error during oauth redeem\"),\n        HandoffError::Jwt(err) => warn!(?err, \"jwt error during oauth redeem\"),\n        _ => {}\n    }\n\n    let (status, code) = classify_handoff_error(&error);\n    let code = code.into_owned();\n\n    (status, Json(serde_json::json!({ \"error\": code }))).into_response()\n}\n\nfn classify_handoff_error(error: &HandoffError) -> (StatusCode, Cow<'_, str>) {\n    match error {\n        HandoffError::UnsupportedProvider(_) => (\n            StatusCode::BAD_REQUEST,\n            Cow::Borrowed(\"unsupported_provider\"),\n        ),\n        HandoffError::InvalidReturnUrl(_) => {\n            (StatusCode::BAD_REQUEST, Cow::Borrowed(\"invalid_return_url\"))\n        }\n        HandoffError::InvalidChallenge => {\n            (StatusCode::BAD_REQUEST, Cow::Borrowed(\"invalid_challenge\"))\n        }\n        HandoffError::NotFound => (StatusCode::NOT_FOUND, Cow::Borrowed(\"not_found\")),\n        HandoffError::Expired => (StatusCode::GONE, Cow::Borrowed(\"expired\")),\n        HandoffError::Denied => (StatusCode::FORBIDDEN, Cow::Borrowed(\"access_denied\")),\n        HandoffError::Failed(reason) => (StatusCode::BAD_REQUEST, Cow::Owned(reason.clone())),\n        HandoffError::Provider(_) => (StatusCode::BAD_GATEWAY, Cow::Borrowed(\"provider_error\")),\n        HandoffError::Database(_)\n        | HandoffError::Identity(_)\n        | HandoffError::OAuthAccount(_)\n        | HandoffError::Session(_)\n        | HandoffError::Jwt(_) => (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Cow::Borrowed(\"internal_error\"),\n        ),\n        HandoffError::Authorization(auth_err) => match auth_err {\n            OAuthHandoffError::NotAuthorized => (StatusCode::GONE, Cow::Borrowed(\"not_authorized\")),\n            OAuthHandoffError::AlreadyRedeemed => {\n                (StatusCode::GONE, Cow::Borrowed(\"already_redeemed\"))\n            }\n            OAuthHandoffError::NotFound => (StatusCode::NOT_FOUND, Cow::Borrowed(\"not_found\")),\n            OAuthHandoffError::Database(_) => (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Cow::Borrowed(\"internal_error\"),\n            ),\n        },\n    }\n}\n\nfn append_query_params(\n    base: &str,\n    handoff_id: Option<Uuid>,\n    app_code: Option<&str>,\n    error: Option<&str>,\n) -> Result<Url, url::ParseError> {\n    let mut url = Url::parse(base)?;\n    {\n        let mut qp = url.query_pairs_mut();\n        if let Some(id) = handoff_id {\n            qp.append_pair(\"handoff_id\", &id.to_string());\n        }\n        if let Some(code) = app_code {\n            qp.append_pair(\"app_code\", code);\n        }\n        if let Some(error) = error {\n            qp.append_pair(\"error\", error);\n        }\n    }\n    Ok(url)\n}\n"
  },
  {
    "path": "crates/remote/src/routes/organization_members.rs",
    "content": "use api_types::{\n    ListMembersResponse, MemberRole, OrganizationMemberWithProfile, RevokeInvitationRequest,\n    UpdateMemberRoleRequest, UpdateMemberRoleResponse,\n};\nuse axum::{\n    Json, Router,\n    extract::{Path, State},\n    http::StatusCode,\n    response::IntoResponse,\n    routing::{delete, get, patch, post},\n};\nuse chrono::{Duration, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::PgPool;\nuse tracing::warn;\nuse uuid::Uuid;\n\nuse super::error::{ErrorResponse, membership_error};\nuse crate::{\n    AppState,\n    audit::{self, AuditAction, AuditEvent},\n    auth::RequestContext,\n    db::{\n        identity_errors::IdentityError,\n        invitations::{Invitation, InvitationRepository},\n        issue_comments::IssueCommentRepository,\n        issues::IssueRepository,\n        organization_members,\n        organizations::OrganizationRepository,\n        projects::ProjectRepository,\n    },\n};\n\npub fn public_router() -> Router<AppState> {\n    Router::new().route(\"/invitations/{token}\", get(get_invitation))\n}\n\npub fn protected_router() -> Router<AppState> {\n    Router::new()\n        .route(\n            \"/organizations/{org_id}/invitations\",\n            post(create_invitation),\n        )\n        .route(\"/organizations/{org_id}/invitations\", get(list_invitations))\n        .route(\n            \"/organizations/{org_id}/invitations/revoke\",\n            post(revoke_invitation),\n        )\n        .route(\"/invitations/{token}/accept\", post(accept_invitation))\n        .route(\"/organizations/{org_id}/members\", get(list_members))\n        .route(\n            \"/organizations/{org_id}/members/{user_id}\",\n            delete(remove_member),\n        )\n        .route(\n            \"/organizations/{org_id}/members/{user_id}/role\",\n            patch(update_member_role),\n        )\n}\n\n#[derive(Debug, Deserialize)]\npub struct CreateInvitationRequest {\n    pub email: String,\n    pub role: MemberRole,\n}\n\n#[derive(Debug, Serialize)]\npub struct CreateInvitationResponse {\n    pub invitation: Invitation,\n}\n\n#[derive(Debug, Serialize)]\npub struct ListInvitationsResponse {\n    pub invitations: Vec<Invitation>,\n}\n\n#[derive(Debug, Serialize)]\npub struct GetInvitationResponse {\n    pub id: Uuid,\n    pub organization_slug: String,\n    pub organization_name: String,\n    pub role: MemberRole,\n    pub expires_at: chrono::DateTime<Utc>,\n}\n\n#[derive(Debug, Serialize)]\npub struct AcceptInvitationResponse {\n    pub organization_id: String,\n    pub organization_slug: String,\n    pub role: MemberRole,\n}\n\npub async fn create_invitation(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n    Json(payload): Json<CreateInvitationRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let session_id = ctx.session_id;\n\n    let user = ctx.user;\n    let org_repo = OrganizationRepository::new(&state.pool);\n    let invitation_repo = InvitationRepository::new(&state.pool);\n\n    ensure_admin_access(&state.pool, org_id, user.id).await?;\n\n    state\n        .billing()\n        .can_add_member(org_id)\n        .await\n        .map_err(|e| e.to_error_response(\"Cannot invite more members\"))?;\n\n    let token = Uuid::new_v4().to_string();\n    let expires_at = Utc::now() + Duration::days(7);\n\n    let invitation = invitation_repo\n        .create_invitation(\n            org_id,\n            user.id,\n            &payload.email,\n            payload.role,\n            expires_at,\n            &token,\n        )\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg),\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    let organization = org_repo.fetch_organization(org_id).await.map_err(|_| {\n        ErrorResponse::new(\n            StatusCode::INTERNAL_SERVER_ERROR,\n            \"Failed to fetch organization\",\n        )\n    })?;\n\n    let accept_url = format!(\n        \"{}/invitations/{}/accept\",\n        state.server_public_base_url, token\n    );\n    state\n        .mailer\n        .send_org_invitation(\n            &organization.name,\n            &payload.email,\n            &accept_url,\n            payload.role,\n            user.username.as_deref(),\n        )\n        .await;\n\n    audit::emit(\n        AuditEvent::system(AuditAction::MemberInvite)\n            .user(user.id, Some(session_id))\n            .resource(\"invitation\", Some(invitation.id))\n            .organization(org_id)\n            .http(\n                \"POST\",\n                format!(\"/v1/organizations/{org_id}/invitations\"),\n                201,\n            )\n            .description(format!(\"Invited member with role {:?}\", payload.role)),\n    );\n\n    if let Some(analytics) = state.analytics() {\n        analytics.track(\n            user.id,\n            \"invitation_created\",\n            serde_json::json!({\n                \"invitation_id\": invitation.id,\n                \"organization_id\": org_id,\n                \"role\": format!(\"{:?}\", payload.role),\n            }),\n        );\n    }\n\n    Ok((\n        StatusCode::CREATED,\n        Json(CreateInvitationResponse { invitation }),\n    ))\n}\n\npub async fn list_invitations(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let user = ctx.user;\n    let invitation_repo = InvitationRepository::new(&state.pool);\n\n    ensure_admin_access(&state.pool, org_id, user.id).await?;\n\n    let invitations = invitation_repo\n        .list_invitations(org_id, user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg),\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    Ok(Json(ListInvitationsResponse { invitations }))\n}\n\npub async fn get_invitation(\n    State(state): State<AppState>,\n    Path(token): Path<String>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let invitation_repo = InvitationRepository::new(&state.pool);\n\n    let invitation = invitation_repo\n        .get_invitation_by_token(&token)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, \"Invitation not found\"))?;\n\n    let org_repo = OrganizationRepository::new(&state.pool);\n    let org = org_repo\n        .fetch_organization(invitation.organization_id)\n        .await\n        .map_err(|_| {\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"Failed to fetch organization\",\n            )\n        })?;\n\n    Ok(Json(GetInvitationResponse {\n        id: invitation.id,\n        organization_slug: org.slug,\n        organization_name: org.name,\n        role: invitation.role,\n        expires_at: invitation.expires_at,\n    }))\n}\n\npub async fn revoke_invitation(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n    Json(payload): Json<RevokeInvitationRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let invitation_repo = InvitationRepository::new(&state.pool);\n\n    ensure_admin_access(&state.pool, org_id, ctx.user.id).await?;\n\n    invitation_repo\n        .revoke_invitation(org_id, payload.invitation_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Invitation not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    audit::emit(\n        AuditEvent::from_request(&ctx, AuditAction::MemberRevokeInvite)\n            .resource(\"invitation\", Some(payload.invitation_id))\n            .organization(org_id)\n            .http(\n                \"POST\",\n                format!(\"/v1/organizations/{org_id}/invitations/revoke\"),\n                204,\n            )\n            .description(\"Revoked invitation\"),\n    );\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\npub async fn accept_invitation(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(token): Path<String>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let session_id = ctx.session_id;\n\n    let user = ctx.user;\n    let invitation_repo = InvitationRepository::new(&state.pool);\n\n    let (org, role) = invitation_repo\n        .accept_invitation(&token, user.id, state.billing())\n        .await\n        .map_err(|e| match e {\n            IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg),\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Invitation not found\")\n            }\n            #[cfg(feature = \"vk-billing\")]\n            IdentityError::Billing(billing_err) => {\n                billing_err.to_error_response(\"Cannot accept invitation\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    audit::emit(\n        AuditEvent::system(AuditAction::MemberAcceptInvite)\n            .user(user.id, Some(session_id))\n            .resource(\"organization_member\", None)\n            .organization(org.id)\n            .http(\"POST\", format!(\"/v1/invitations/{token}/accept\"), 200)\n            .description(format!(\"Accepted invitation with role {role:?}\")),\n    );\n\n    if let Some(analytics) = state.analytics() {\n        analytics.track(\n            user.id,\n            \"invitation_accepted\",\n            serde_json::json!({\n                \"organization_id\": org.id,\n                \"role\": format!(\"{:?}\", role),\n            }),\n        );\n    }\n\n    Ok(Json(AcceptInvitationResponse {\n        organization_id: org.id.to_string(),\n        organization_slug: org.slug,\n        role,\n    }))\n}\n\npub async fn list_members(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let user = ctx.user;\n    ensure_member_access(&state.pool, org_id, user.id).await?;\n\n    let members = sqlx::query_as!(\n        OrganizationMemberWithProfile,\n        r#\"\n        SELECT\n            omm.user_id AS \"user_id!: Uuid\",\n            omm.role AS \"role!: MemberRole\",\n            omm.joined_at AS \"joined_at!\",\n            u.first_name AS \"first_name?\",\n            u.last_name AS \"last_name?\",\n            u.username AS \"username?\",\n            u.email AS \"email?\",\n            oa.avatar_url AS \"avatar_url?\"\n        FROM organization_member_metadata omm\n        INNER JOIN users u ON omm.user_id = u.id\n        LEFT JOIN LATERAL (\n            SELECT avatar_url\n            FROM oauth_accounts\n            WHERE user_id = omm.user_id\n            ORDER BY created_at ASC\n            LIMIT 1\n        ) oa ON true\n        WHERE omm.organization_id = $1\n        ORDER BY omm.joined_at ASC\n        \"#,\n        org_id\n    )\n    .fetch_all(&state.pool)\n    .await\n    .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    Ok(Json(ListMembersResponse { members }))\n}\n\npub async fn remove_member(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path((org_id, user_id)): Path<(Uuid, Uuid)>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let session_id = ctx.session_id;\n\n    let user = ctx.user;\n    if user.id == user_id {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Cannot remove yourself\",\n        ));\n    }\n\n    let org_repo = OrganizationRepository::new(&state.pool);\n    if org_repo\n        .is_personal(org_id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\"))?\n    {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Cannot modify members of a personal organization\",\n        ));\n    }\n\n    ensure_admin_access(&state.pool, org_id, user.id).await?;\n\n    let mut tx = crate::db::begin_tx(&state.pool)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    let target = sqlx::query!(\n        r#\"\n        SELECT role AS \"role!: MemberRole\"\n        FROM organization_member_metadata\n        WHERE organization_id = $1 AND user_id = $2\n        FOR UPDATE\n        \"#,\n        org_id,\n        user_id\n    )\n    .fetch_optional(&mut *tx)\n    .await\n    .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?\n    .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"Member not found\"))?;\n\n    if target.role == MemberRole::Admin {\n        let admin_ids = sqlx::query_scalar!(\n            r#\"\n            SELECT user_id\n            FROM organization_member_metadata\n            WHERE organization_id = $1 AND role = 'admin'\n            FOR UPDATE\n            \"#,\n            org_id\n        )\n        .fetch_all(&mut *tx)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n        if admin_ids.len() == 1 && admin_ids[0] == user_id {\n            return Err(ErrorResponse::new(\n                StatusCode::CONFLICT,\n                \"Cannot remove the last admin\",\n            ));\n        }\n    }\n\n    sqlx::query!(\n        r#\"\n        DELETE FROM organization_member_metadata\n        WHERE organization_id = $1 AND user_id = $2\n        \"#,\n        org_id,\n        user_id\n    )\n    .execute(&mut *tx)\n    .await\n    .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    tx.commit()\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    state.billing().on_member_count_changed(org_id).await;\n\n    audit::emit(\n        AuditEvent::system(AuditAction::MemberRemove)\n            .user(user.id, Some(session_id))\n            .resource(\"organization_member\", Some(user_id))\n            .organization(org_id)\n            .http(\n                \"DELETE\",\n                format!(\"/v1/organizations/{org_id}/members/{user_id}\"),\n                204,\n            )\n            .description(\"Removed member from organization\"),\n    );\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\npub async fn update_member_role(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path((org_id, user_id)): Path<(Uuid, Uuid)>,\n    Json(payload): Json<UpdateMemberRoleRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let session_id = ctx.session_id;\n\n    let user = ctx.user;\n    if user.id == user_id && payload.role == MemberRole::Member {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Cannot demote yourself\",\n        ));\n    }\n\n    let org_repo = OrganizationRepository::new(&state.pool);\n    if org_repo\n        .is_personal(org_id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\"))?\n    {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Cannot modify members of a personal organization\",\n        ));\n    }\n\n    ensure_admin_access(&state.pool, org_id, user.id).await?;\n\n    let mut tx = crate::db::begin_tx(&state.pool)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    let target = sqlx::query!(\n        r#\"\n        SELECT role AS \"role!: MemberRole\"\n        FROM organization_member_metadata\n        WHERE organization_id = $1 AND user_id = $2\n        FOR UPDATE\n        \"#,\n        org_id,\n        user_id\n    )\n    .fetch_optional(&mut *tx)\n    .await\n    .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?\n    .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"Member not found\"))?;\n\n    if target.role == payload.role {\n        return Ok(Json(UpdateMemberRoleResponse {\n            user_id,\n            role: payload.role,\n        }));\n    }\n\n    if target.role == MemberRole::Admin && payload.role == MemberRole::Member {\n        let admin_ids = sqlx::query_scalar!(\n            r#\"\n            SELECT user_id\n            FROM organization_member_metadata\n            WHERE organization_id = $1 AND role = 'admin'\n            FOR UPDATE\n            \"#,\n            org_id\n        )\n        .fetch_all(&mut *tx)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n        if admin_ids.len() == 1 && admin_ids[0] == user_id {\n            return Err(ErrorResponse::new(\n                StatusCode::CONFLICT,\n                \"Cannot demote the last admin\",\n            ));\n        }\n    }\n\n    sqlx::query!(\n        r#\"\n        UPDATE organization_member_metadata\n        SET role = $3\n        WHERE organization_id = $1 AND user_id = $2\n        \"#,\n        org_id,\n        user_id,\n        payload.role as MemberRole\n    )\n    .execute(&mut *tx)\n    .await\n    .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    tx.commit()\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    audit::emit(\n        AuditEvent::system(AuditAction::MemberRoleChange)\n            .user(user.id, Some(session_id))\n            .resource(\"organization_member\", Some(user_id))\n            .organization(org_id)\n            .http(\n                \"PATCH\",\n                format!(\"/v1/organizations/{org_id}/members/{user_id}/role\"),\n                200,\n            )\n            .description(format!(\n                \"Changed member role to {role:?}\",\n                role = payload.role\n            )),\n    );\n\n    Ok(Json(UpdateMemberRoleResponse {\n        user_id,\n        role: payload.role,\n    }))\n}\n\npub(crate) async fn ensure_member_access(\n    pool: &PgPool,\n    organization_id: Uuid,\n    user_id: Uuid,\n) -> Result<(), ErrorResponse> {\n    organization_members::assert_membership(pool, organization_id, user_id)\n        .await\n        .map_err(|err| membership_error(err, \"Not a member of organization\"))\n}\n\npub(crate) async fn ensure_admin_access(\n    pool: &PgPool,\n    organization_id: Uuid,\n    user_id: Uuid,\n) -> Result<(), ErrorResponse> {\n    OrganizationRepository::new(pool)\n        .assert_admin(organization_id, user_id)\n        .await\n        .map_err(|err| membership_error(err, \"Admin access required\"))\n}\n\npub(crate) async fn ensure_project_access(\n    pool: &PgPool,\n    user_id: Uuid,\n    project_id: Uuid,\n) -> Result<Uuid, ErrorResponse> {\n    let organization_id = ProjectRepository::organization_id(pool, project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %project_id, \"failed to load project\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?\n        .ok_or_else(|| {\n            warn!(\n                %project_id,\n                %user_id,\n                \"project not found for access check\"\n            );\n            ErrorResponse::new(StatusCode::NOT_FOUND, \"project not found\")\n        })?;\n\n    organization_members::assert_membership(pool, organization_id, user_id)\n        .await\n        .map_err(|err| {\n            if let IdentityError::Database(error) = &err {\n                tracing::error!(\n                    ?error,\n                    %organization_id,\n                    %project_id,\n                    \"failed to authorize project membership\"\n                );\n            } else {\n                warn!(\n                    ?err,\n                    %organization_id,\n                    %project_id,\n                    %user_id,\n                    \"project access denied\"\n                );\n            }\n            membership_error(err, \"project not accessible\")\n        })?;\n\n    Ok(organization_id)\n}\n\npub(crate) async fn ensure_issue_access(\n    pool: &PgPool,\n    user_id: Uuid,\n    issue_id: Uuid,\n) -> Result<Uuid, ErrorResponse> {\n    let organization_id = IssueRepository::organization_id(pool, issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %issue_id, \"failed to load issue\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?\n        .ok_or_else(|| {\n            warn!(\n                %issue_id,\n                %user_id,\n                \"issue not found for access check\"\n            );\n            ErrorResponse::new(StatusCode::NOT_FOUND, \"issue not found\")\n        })?;\n\n    organization_members::assert_membership(pool, organization_id, user_id)\n        .await\n        .map_err(|err| {\n            if let IdentityError::Database(error) = &err {\n                tracing::error!(\n                    ?error,\n                    %organization_id,\n                    %issue_id,\n                    \"failed to authorize issue access\"\n                );\n            } else {\n                warn!(\n                    ?err,\n                    %organization_id,\n                    %issue_id,\n                    %user_id,\n                    \"issue access denied\"\n                );\n            }\n            membership_error(err, \"issue not accessible\")\n        })?;\n\n    Ok(organization_id)\n}\n\npub(crate) async fn ensure_comment_access(\n    pool: &PgPool,\n    user_id: Uuid,\n    comment_id: Uuid,\n) -> Result<Uuid, ErrorResponse> {\n    let comment = IssueCommentRepository::find_by_id(pool, comment_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %comment_id, \"failed to load comment\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?\n        .ok_or_else(|| {\n            warn!(\n                %comment_id,\n                %user_id,\n                \"comment not found for access check\"\n            );\n            ErrorResponse::new(StatusCode::NOT_FOUND, \"comment not found\")\n        })?;\n\n    ensure_issue_access(pool, user_id, comment.issue_id).await\n}\n"
  },
  {
    "path": "crates/remote/src/routes/organizations.rs",
    "content": "use api_types::{\n    CreateOrganizationRequest, CreateOrganizationResponse, GetOrganizationResponse,\n    ListOrganizationsResponse, MemberRole, UpdateOrganizationRequest,\n};\nuse axum::{\n    Json, Router,\n    extract::{Path, State},\n    http::StatusCode,\n    response::IntoResponse,\n    routing::{delete, get, patch, post},\n};\nuse uuid::Uuid;\n\nuse super::error::ErrorResponse;\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        identity_errors::IdentityError, organization_members, organizations::OrganizationRepository,\n    },\n};\n\npub fn router() -> Router<AppState> {\n    Router::new()\n        .route(\"/organizations\", post(create_organization))\n        .route(\"/organizations\", get(list_organizations))\n        .route(\"/organizations/{org_id}\", get(get_organization))\n        .route(\"/organizations/{org_id}\", patch(update_organization))\n        .route(\"/organizations/{org_id}\", delete(delete_organization))\n}\n\npub async fn create_organization(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Json(payload): Json<CreateOrganizationRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let name = payload.name.trim();\n    let slug = payload.slug.trim().to_lowercase();\n\n    if name.is_empty() || name.len() > 100 {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Organization name must be between 1 and 100 characters\",\n        ));\n    }\n\n    if slug.len() < 3 || slug.len() > 63 {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Organization slug must be between 3 and 63 characters\",\n        ));\n    }\n\n    if !slug\n        .chars()\n        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')\n    {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Organization slug can only contain lowercase letters, numbers, hyphens, and underscores\",\n        ));\n    }\n\n    let org_repo = OrganizationRepository::new(&state.pool);\n\n    let organization = org_repo\n        .create_organization(name, &slug, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::OrganizationConflict(msg) => {\n                ErrorResponse::new(StatusCode::CONFLICT, msg)\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    if let Some(analytics) = state.analytics() {\n        analytics.track(\n            ctx.user.id,\n            \"organization_created\",\n            serde_json::json!({\n                \"organization_id\": organization.id,\n            }),\n        );\n    }\n\n    Ok((\n        StatusCode::CREATED,\n        Json(CreateOrganizationResponse { organization }),\n    ))\n}\n\npub async fn list_organizations(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let org_repo = OrganizationRepository::new(&state.pool);\n\n    let organizations = org_repo\n        .list_user_organizations(ctx.user.id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?;\n\n    Ok(Json(ListOrganizationsResponse { organizations }))\n}\n\npub async fn get_organization(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let org_repo = OrganizationRepository::new(&state.pool);\n\n    organization_members::assert_membership(&state.pool, org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::FORBIDDEN, \"Access denied\"),\n        })?;\n\n    let organization = org_repo.fetch_organization(org_id).await.map_err(|_| {\n        ErrorResponse::new(\n            StatusCode::INTERNAL_SERVER_ERROR,\n            \"Failed to fetch organization\",\n        )\n    })?;\n\n    let role = org_repo\n        .check_user_role(org_id, ctx.user.id)\n        .await\n        .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"))?\n        .unwrap_or(MemberRole::Member);\n\n    let user_role = match role {\n        MemberRole::Admin => \"ADMIN\",\n        MemberRole::Member => \"MEMBER\",\n    }\n    .to_string();\n\n    Ok(Json(GetOrganizationResponse {\n        organization,\n        user_role,\n    }))\n}\n\npub async fn update_organization(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n    Json(payload): Json<UpdateOrganizationRequest>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let name = payload.name.trim();\n\n    if name.is_empty() || name.len() > 100 {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Organization name must be between 1 and 100 characters\",\n        ));\n    }\n\n    let org_repo = OrganizationRepository::new(&state.pool);\n\n    let organization = org_repo\n        .update_organization_name(org_id, ctx.user.id, name)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    Ok(Json(organization))\n}\n\npub async fn delete_organization(\n    State(state): State<AppState>,\n    axum::extract::Extension(ctx): axum::extract::Extension<RequestContext>,\n    Path(org_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ErrorResponse> {\n    let org_repo = OrganizationRepository::new(&state.pool);\n\n    org_repo\n        .delete_organization(org_id, ctx.user.id)\n        .await\n        .map_err(|e| match e {\n            IdentityError::PermissionDenied => {\n                ErrorResponse::new(StatusCode::FORBIDDEN, \"Admin access required\")\n            }\n            IdentityError::CannotDeleteOrganization(msg) => {\n                ErrorResponse::new(StatusCode::CONFLICT, msg)\n            }\n            IdentityError::NotFound => {\n                ErrorResponse::new(StatusCode::NOT_FOUND, \"Organization not found\")\n            }\n            _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"Database error\"),\n        })?;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n"
  },
  {
    "path": "crates/remote/src/routes/project_statuses.rs",
    "content": "use api_types::{\n    CreateProjectStatusRequest, DeleteResponse, ListProjectStatusesQuery,\n    ListProjectStatusesResponse, MutationResponse, ProjectStatus, UpdateProjectStatusRequest,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n    routing::post,\n};\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_project_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{get_txid, project_statuses::ProjectStatusRepository, types::is_valid_hsl_color},\n    mutation_definition::MutationBuilder,\n};\n\n/// Mutation definition for ProjectStatus - provides both router and TypeScript metadata.\npub fn mutation()\n-> MutationBuilder<ProjectStatus, CreateProjectStatusRequest, UpdateProjectStatusRequest> {\n    MutationBuilder::new(\"project_statuses\")\n        .list(list_project_statuses)\n        .get(get_project_status)\n        .create(create_project_status)\n        .update(update_project_status)\n        .delete(delete_project_status)\n}\n\n/// Router for project status endpoints including bulk update\npub fn router() -> axum::Router<AppState> {\n    mutation()\n        .router()\n        .route(\"/project_statuses/bulk\", post(bulk_update_project_statuses))\n}\n\n#[instrument(\n    name = \"project_statuses.list_project_statuses\",\n    skip(state, ctx),\n    fields(project_id = %query.project_id, user_id = %ctx.user.id)\n)]\nasync fn list_project_statuses(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListProjectStatusesQuery>,\n) -> Result<Json<ListProjectStatusesResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let project_statuses = ProjectStatusRepository::list_by_project(state.pool(), query.project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %query.project_id, \"failed to list project statuses\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list project statuses\",\n            )\n        })?;\n\n    Ok(Json(ListProjectStatusesResponse { project_statuses }))\n}\n\n#[instrument(\n    name = \"project_statuses.get_project_status\",\n    skip(state, ctx),\n    fields(project_status_id = %project_status_id, user_id = %ctx.user.id)\n)]\nasync fn get_project_status(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(project_status_id): Path<Uuid>,\n) -> Result<Json<ProjectStatus>, ErrorResponse> {\n    let status = ProjectStatusRepository::find_by_id(state.pool(), project_status_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %project_status_id, \"failed to load project status\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load project status\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project status not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, status.project_id).await?;\n\n    Ok(Json(status))\n}\n\n#[instrument(\n    name = \"project_statuses.create_project_status\",\n    skip(state, ctx, payload),\n    fields(project_id = %payload.project_id, user_id = %ctx.user.id)\n)]\nasync fn create_project_status(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateProjectStatusRequest>,\n) -> Result<Json<MutationResponse<ProjectStatus>>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?;\n\n    if !is_valid_hsl_color(&payload.color) {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Invalid color format. Expected HSL format: 'H S% L%'\",\n        ));\n    }\n\n    let response = ProjectStatusRepository::create(\n        state.pool(),\n        payload.id,\n        payload.project_id,\n        payload.name,\n        payload.color,\n        payload.sort_order,\n        payload.hidden,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create project status\");\n        db_error(error, \"failed to create project status\")\n    })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"project_statuses.update_project_status\",\n    skip(state, ctx, payload),\n    fields(project_status_id = %project_status_id, user_id = %ctx.user.id)\n)]\nasync fn update_project_status(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(project_status_id): Path<Uuid>,\n    Json(payload): Json<UpdateProjectStatusRequest>,\n) -> Result<Json<MutationResponse<ProjectStatus>>, ErrorResponse> {\n    let status = ProjectStatusRepository::find_by_id(state.pool(), project_status_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %project_status_id, \"failed to load project status\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load project status\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project status not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, status.project_id).await?;\n\n    if let Some(ref color) = payload.color\n        && !is_valid_hsl_color(color)\n    {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Invalid color format. Expected HSL format: 'H S% L%'\",\n        ));\n    }\n\n    let response = ProjectStatusRepository::update(\n        state.pool(),\n        project_status_id,\n        payload.name,\n        payload.color,\n        payload.sort_order,\n        payload.hidden,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to update project status\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"project_statuses.delete_project_status\",\n    skip(state, ctx),\n    fields(project_status_id = %project_status_id, user_id = %ctx.user.id)\n)]\nasync fn delete_project_status(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(project_status_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let status = ProjectStatusRepository::find_by_id(state.pool(), project_status_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %project_status_id, \"failed to load project status\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load project status\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project status not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, status.project_id).await?;\n\n    let response = ProjectStatusRepository::delete(state.pool(), project_status_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete project status\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n\n#[derive(Debug, Deserialize)]\npub struct BulkUpdateProjectStatusItem {\n    pub id: Uuid,\n    #[serde(flatten)]\n    pub changes: UpdateProjectStatusRequest,\n}\n\n#[derive(Debug, Deserialize)]\npub struct BulkUpdateProjectStatusesRequest {\n    pub updates: Vec<BulkUpdateProjectStatusItem>,\n}\n\n#[derive(Debug, Serialize)]\npub struct BulkUpdateProjectStatusesResponse {\n    pub data: Vec<ProjectStatus>,\n    pub txid: i64,\n}\n\n#[instrument(\n    name = \"project_statuses.bulk_update\",\n    skip(state, ctx, payload),\n    fields(user_id = %ctx.user.id, count = payload.updates.len())\n)]\nasync fn bulk_update_project_statuses(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkUpdateProjectStatusesRequest>,\n) -> Result<Json<BulkUpdateProjectStatusesResponse>, ErrorResponse> {\n    if payload.updates.is_empty() {\n        return Ok(Json(BulkUpdateProjectStatusesResponse {\n            data: vec![],\n            txid: 0,\n        }));\n    }\n\n    // Get first status to determine project_id for access check\n    let first_status = ProjectStatusRepository::find_by_id(state.pool(), payload.updates[0].id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to find first project status\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find status\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project status not found\"))?;\n\n    let project_id = first_status.project_id;\n    ensure_project_access(state.pool(), ctx.user.id, project_id).await?;\n\n    let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| {\n        tracing::error!(?error, \"failed to begin transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let mut results = Vec::with_capacity(payload.updates.len());\n\n    for item in payload.updates {\n        // Verify status belongs to the same project\n        let status = ProjectStatusRepository::find_by_id(state.pool(), item.id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, status_id = %item.id, \"failed to find project status\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find status\")\n            })?\n            .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project status not found\"))?;\n\n        if status.project_id != project_id {\n            return Err(ErrorResponse::new(\n                StatusCode::BAD_REQUEST,\n                \"all statuses must belong to the same project\",\n            ));\n        }\n\n        // Validate color if provided\n        if let Some(ref color) = item.changes.color\n            && !is_valid_hsl_color(color)\n        {\n            return Err(ErrorResponse::new(\n                StatusCode::BAD_REQUEST,\n                \"Invalid color format. Expected HSL format: 'H S% L%'\",\n            ));\n        }\n\n        // Update the status within the transaction\n        let updated = sqlx::query_as!(\n            ProjectStatus,\n            r#\"\n            UPDATE project_statuses\n            SET\n                name = COALESCE($1, name),\n                color = COALESCE($2, color),\n                sort_order = COALESCE($3, sort_order),\n                hidden = COALESCE($4, hidden)\n            WHERE id = $5\n            RETURNING\n                id              AS \"id!: Uuid\",\n                project_id      AS \"project_id!: Uuid\",\n                name            AS \"name!\",\n                color           AS \"color!\",\n                sort_order      AS \"sort_order!\",\n                hidden          AS \"hidden!\",\n                created_at      AS \"created_at!: DateTime<Utc>\"\n            \"#,\n            item.changes.name,\n            item.changes.color,\n            item.changes.sort_order,\n            item.changes.hidden,\n            item.id\n        )\n        .fetch_one(&mut *tx)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, status_id = %item.id, \"failed to update project status\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to update project status\",\n            )\n        })?;\n\n        results.push(updated);\n    }\n\n    let txid = get_txid(&mut *tx).await.map_err(|error| {\n        tracing::error!(?error, \"failed to get txid\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n    tx.commit().await.map_err(|error| {\n        tracing::error!(?error, \"failed to commit transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    Ok(Json(BulkUpdateProjectStatusesResponse {\n        data: results,\n        txid,\n    }))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/projects.rs",
    "content": "use api_types::{\n    BulkUpdateProjectsRequest, BulkUpdateProjectsResponse, CreateProjectRequest, DeleteResponse,\n    ListProjectsQuery, ListProjectsResponse, MutationResponse, Project, UpdateProjectRequest,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n    routing::post,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_member_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{get_txid, projects::ProjectRepository, types::is_valid_hsl_color},\n    mutation_definition::MutationBuilder,\n};\n\n/// Mutation definition for Projects - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<Project, CreateProjectRequest, UpdateProjectRequest> {\n    MutationBuilder::new(\"projects\")\n        .list(list_projects)\n        .get(get_project)\n        .create(create_project)\n        .update(update_project)\n        .delete(delete_project)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation()\n        .router()\n        .route(\"/projects/bulk\", post(bulk_update_projects))\n}\n\n#[instrument(\n    name = \"projects.list_projects\",\n    skip(state, ctx),\n    fields(organization_id = %query.organization_id, user_id = %ctx.user.id)\n)]\nasync fn list_projects(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListProjectsQuery>,\n) -> Result<Json<ListProjectsResponse>, ErrorResponse> {\n    ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?;\n\n    let projects = ProjectRepository::list_by_organization(state.pool(), query.organization_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, organization_id = %query.organization_id, \"failed to list projects\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list projects\")\n        })?;\n\n    Ok(Json(ListProjectsResponse { projects }))\n}\n\n#[instrument(\n    name = \"projects.get_project\",\n    skip(state, ctx),\n    fields(project_id = %project_id, user_id = %ctx.user.id)\n)]\nasync fn get_project(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(project_id): Path<Uuid>,\n) -> Result<Json<Project>, ErrorResponse> {\n    let project = ProjectRepository::find_by_id(state.pool(), project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %project_id, \"failed to load project\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load project\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project not found\"))?;\n\n    ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?;\n\n    Ok(Json(project))\n}\n\n#[instrument(\n    name = \"projects.create_project\",\n    skip(state, ctx, payload),\n    fields(organization_id = %payload.organization_id, user_id = %ctx.user.id)\n)]\nasync fn create_project(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateProjectRequest>,\n) -> Result<Json<MutationResponse<Project>>, ErrorResponse> {\n    ensure_member_access(state.pool(), payload.organization_id, ctx.user.id).await?;\n\n    if !is_valid_hsl_color(&payload.color) {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Invalid color format. Expected HSL format: 'H S% L%'\",\n        ));\n    }\n\n    let response = ProjectRepository::create_with_defaults(\n        state.pool(),\n        payload.id,\n        payload.organization_id,\n        payload.name,\n        payload.color,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create project\");\n        db_error(error, \"failed to create project\")\n    })?;\n\n    if let Some(analytics) = state.analytics() {\n        analytics.track(\n            ctx.user.id,\n            \"project_created\",\n            serde_json::json!({\n                \"project_id\": response.data.id,\n                \"organization_id\": response.data.organization_id,\n            }),\n        );\n    }\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"projects.update_project\",\n    skip(state, ctx, payload),\n    fields(project_id = %project_id, user_id = %ctx.user.id)\n)]\nasync fn update_project(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(project_id): Path<Uuid>,\n    Json(payload): Json<UpdateProjectRequest>,\n) -> Result<Json<MutationResponse<Project>>, ErrorResponse> {\n    let existing = ProjectRepository::find_by_id(state.pool(), project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %project_id, \"failed to load project\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load project\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project not found\"))?;\n\n    ensure_member_access(state.pool(), existing.organization_id, ctx.user.id).await?;\n\n    if let Some(ref color) = payload.color\n        && !is_valid_hsl_color(color)\n    {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Invalid color format. Expected HSL format: 'H S% L%'\",\n        ));\n    }\n\n    let response = ProjectRepository::update(\n        state.pool(),\n        project_id,\n        payload.name,\n        payload.color,\n        payload.sort_order,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to update project\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"projects.bulk_update\",\n    skip(state, ctx, payload),\n    fields(user_id = %ctx.user.id, count = payload.updates.len())\n)]\nasync fn bulk_update_projects(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<BulkUpdateProjectsRequest>,\n) -> Result<Json<BulkUpdateProjectsResponse>, ErrorResponse> {\n    if payload.updates.is_empty() {\n        return Ok(Json(BulkUpdateProjectsResponse {\n            data: vec![],\n            txid: 0,\n        }));\n    }\n\n    let first_project = ProjectRepository::find_by_id(state.pool(), payload.updates[0].id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to find first project\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find project\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project not found\"))?;\n\n    let organization_id = first_project.organization_id;\n    ensure_member_access(state.pool(), organization_id, ctx.user.id).await?;\n\n    let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| {\n        tracing::error!(?error, \"failed to begin transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    let mut results = Vec::with_capacity(payload.updates.len());\n\n    for item in payload.updates {\n        let project = ProjectRepository::find_by_id(&mut *tx, item.id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, project_id = %item.id, \"failed to find project\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find project\")\n            })?\n            .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project not found\"))?;\n\n        if project.organization_id != organization_id {\n            return Err(ErrorResponse::new(\n                StatusCode::BAD_REQUEST,\n                \"all projects must belong to the same organization\",\n            ));\n        }\n\n        if let Some(ref color) = item.changes.color\n            && !is_valid_hsl_color(color)\n        {\n            return Err(ErrorResponse::new(\n                StatusCode::BAD_REQUEST,\n                \"Invalid color format. Expected HSL format: 'H S% L%'\",\n            ));\n        }\n\n        let updated = ProjectRepository::update_partial(\n            &mut *tx,\n            item.id,\n            item.changes.name,\n            item.changes.color,\n            item.changes.sort_order,\n        )\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %item.id, \"failed to update project\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to update project\",\n            )\n        })?;\n\n        results.push(updated);\n    }\n\n    let txid = get_txid(&mut *tx).await.map_err(|error| {\n        tracing::error!(?error, \"failed to get txid\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n    tx.commit().await.map_err(|error| {\n        tracing::error!(?error, \"failed to commit transaction\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    Ok(Json(BulkUpdateProjectsResponse {\n        data: results,\n        txid,\n    }))\n}\n\n#[instrument(\n    name = \"projects.delete_project\",\n    skip(state, ctx),\n    fields(project_id = %project_id, user_id = %ctx.user.id)\n)]\nasync fn delete_project(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(project_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let project = ProjectRepository::find_by_id(state.pool(), project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %project_id, \"failed to load project\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load project\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"project not found\"))?;\n\n    ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?;\n\n    let response = ProjectRepository::delete(state.pool(), project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete project\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/pull_requests.rs",
    "content": "use api_types::{\n    ListPullRequestsQuery, ListPullRequestsResponse, PullRequest, PullRequestStatus,\n    UpsertPullRequestRequest,\n};\nuse axum::{\n    Json, Router,\n    extract::{Extension, Query, State},\n    http::StatusCode,\n    routing::get,\n};\nuse chrono::{DateTime, Utc};\nuse serde::Deserialize;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_issue_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        issues::IssueRepository, pull_requests::PullRequestRepository,\n        workspaces::WorkspaceRepository,\n    },\n};\n\n#[derive(Debug, Deserialize)]\npub struct CreatePullRequestRequest {\n    pub url: String,\n    pub number: i32,\n    pub status: PullRequestStatus,\n    pub merged_at: Option<DateTime<Utc>>,\n    pub merge_commit_sha: Option<String>,\n    pub target_branch_name: String,\n    pub issue_id: Uuid,\n    pub local_workspace_id: Option<Uuid>,\n}\n\n#[derive(Debug, Deserialize)]\npub struct UpdatePullRequestRequest {\n    pub url: String,\n    pub status: Option<PullRequestStatus>,\n    pub merged_at: Option<Option<DateTime<Utc>>>,\n    pub merge_commit_sha: Option<Option<String>>,\n}\n\npub fn router() -> Router<AppState> {\n    Router::new().route(\n        \"/pull_requests\",\n        get(list_pull_requests)\n            .post(create_pull_request)\n            .patch(update_pull_request)\n            .put(upsert_pull_request),\n    )\n}\n\n#[instrument(\n    name = \"pull_requests.list_pull_requests\",\n    skip(state, ctx),\n    fields(issue_id = %query.issue_id, user_id = %ctx.user.id)\n)]\nasync fn list_pull_requests(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListPullRequestsQuery>,\n) -> Result<Json<ListPullRequestsResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let pull_requests = PullRequestRepository::list_by_issue(state.pool(), query.issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to list pull requests\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list pull requests\",\n            )\n        })?;\n\n    Ok(Json(ListPullRequestsResponse { pull_requests }))\n}\n\n#[instrument(\n    name = \"pull_requests.create_pull_request\",\n    skip(state, ctx, payload),\n    fields(issue_id = %payload.issue_id, local_workspace_id = ?payload.local_workspace_id, user_id = %ctx.user.id)\n)]\nasync fn create_pull_request(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreatePullRequestRequest>,\n) -> Result<Json<PullRequest>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?;\n\n    // Resolve local_workspace_id to remote workspace_id\n    let workspace_id = match payload.local_workspace_id {\n        Some(local_id) => {\n            let workspace = WorkspaceRepository::find_by_local_id(state.pool(), local_id)\n                .await\n                .map_err(|error| {\n                    tracing::error!(?error, local_workspace_id = %local_id, \"failed to find workspace\");\n                    ErrorResponse::new(\n                        StatusCode::INTERNAL_SERVER_ERROR,\n                        \"failed to find workspace\",\n                    )\n                })?\n                .ok_or_else(|| {\n                    tracing::warn!(local_workspace_id = %local_id, \"workspace not found\");\n                    ErrorResponse::new(StatusCode::NOT_FOUND, \"workspace not found\")\n                })?;\n            Some(workspace.id)\n        }\n        None => None,\n    };\n\n    let pr = PullRequestRepository::create(\n        state.pool(),\n        payload.url,\n        payload.number,\n        payload.status,\n        payload.merged_at,\n        payload.merge_commit_sha,\n        payload.target_branch_name,\n        payload.issue_id,\n        workspace_id,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create pull request\");\n        db_error(error, \"failed to create pull request\")\n    })?;\n\n    IssueRepository::sync_status_from_pull_request(state.pool(), pr.issue_id, pr.status)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to sync issue status\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(pr))\n}\n\n#[instrument(\n    name = \"pull_requests.update_pull_request\",\n    skip(state, ctx, payload),\n    fields(url = %payload.url, user_id = %ctx.user.id)\n)]\nasync fn update_pull_request(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<UpdatePullRequestRequest>,\n) -> Result<Json<PullRequest>, ErrorResponse> {\n    let pull_request = PullRequestRepository::find_by_url(state.pool(), &payload.url)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, url = %payload.url, \"failed to load pull request\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to load pull request\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"pull request not found\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, pull_request.issue_id).await?;\n\n    let pr = PullRequestRepository::update(\n        state.pool(),\n        pull_request.id,\n        payload.status,\n        payload.merged_at,\n        payload.merge_commit_sha,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to update pull request\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n    })?;\n\n    IssueRepository::sync_status_from_pull_request(state.pool(), pr.issue_id, pr.status)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to sync issue status\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(pr))\n}\n\n#[instrument(\n    name = \"pull_requests.upsert_pull_request\",\n    skip(state, ctx, payload),\n    fields(url = %payload.url, local_workspace_id = %payload.local_workspace_id, user_id = %ctx.user.id)\n)]\nasync fn upsert_pull_request(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<UpsertPullRequestRequest>,\n) -> Result<Json<PullRequest>, ErrorResponse> {\n    // Resolve local_workspace_id to workspace and get issue_id\n    let workspace = WorkspaceRepository::find_by_local_id(state.pool(), payload.local_workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, local_workspace_id = %payload.local_workspace_id, \"failed to find workspace\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to find workspace\",\n            )\n        })?\n        .ok_or_else(|| {\n            tracing::info!(local_workspace_id = %payload.local_workspace_id, \"workspace not found\");\n            ErrorResponse::new(StatusCode::NOT_FOUND, \"workspace not found\")\n        })?;\n\n    let issue_id = workspace\n        .issue_id\n        .ok_or_else(|| ErrorResponse::new(StatusCode::BAD_REQUEST, \"workspace has no issue\"))?;\n\n    ensure_issue_access(state.pool(), ctx.user.id, issue_id).await?;\n\n    // Try to find existing PR by URL\n    let existing_pr = PullRequestRepository::find_by_url(state.pool(), &payload.url)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, url = %payload.url, \"failed to check for existing PR\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    let pr = if let Some(existing) = existing_pr {\n        if existing.issue_id != issue_id {\n            return Err(ErrorResponse::new(\n                StatusCode::FORBIDDEN,\n                \"PR URL belongs to a different issue\",\n            ));\n        }\n        PullRequestRepository::update(\n            state.pool(),\n            existing.id,\n            Some(payload.status),\n            Some(payload.merged_at),\n            Some(payload.merge_commit_sha),\n        )\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to update pull request\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?\n    } else {\n        // Create new PR\n        PullRequestRepository::create(\n            state.pool(),\n            payload.url,\n            payload.number,\n            payload.status,\n            payload.merged_at,\n            payload.merge_commit_sha,\n            payload.target_branch_name,\n            issue_id,\n            Some(workspace.id),\n        )\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to create pull request\");\n            db_error(error, \"failed to create pull request\")\n        })?\n    };\n\n    IssueRepository::sync_status_from_pull_request(state.pool(), pr.issue_id, pr.status)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to sync issue status\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(pr))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/review.rs",
    "content": "use std::net::IpAddr;\n\nuse axum::{\n    Json, Router,\n    body::Body,\n    extract::{Path, State},\n    http::{HeaderMap, StatusCode},\n    response::{IntoResponse, Response},\n    routing::{get, post},\n};\nuse chrono::{DateTime, Duration, Utc};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse crate::{\n    AppState,\n    db::reviews::{CreateReviewParams, ReviewRepository},\n    r2::R2Error,\n};\n\npub fn public_router() -> Router<AppState> {\n    Router::new()\n        .route(\"/review/init\", post(init_review_upload))\n        .route(\"/review/start\", post(start_review))\n        .route(\"/review/{id}/status\", get(get_review_status))\n        .route(\"/review/{id}\", get(get_review))\n        .route(\"/review/{id}/metadata\", get(get_review_metadata))\n        .route(\"/review/{id}/file/{file_hash}\", get(get_review_file))\n        .route(\"/review/{id}/diff\", get(get_review_diff))\n        .route(\"/review/{id}/success\", post(review_success))\n        .route(\"/review/{id}/failed\", post(review_failed))\n}\n\n#[derive(Debug, Deserialize)]\npub struct InitReviewRequest {\n    pub gh_pr_url: String,\n    pub email: String,\n    pub pr_title: String,\n    #[serde(default)]\n    pub claude_code_session_id: Option<String>,\n    #[serde(default)]\n    pub content_type: Option<String>,\n}\n\n#[derive(Debug, Serialize)]\npub struct InitReviewResponse {\n    pub review_id: Uuid,\n    pub upload_url: String,\n    pub object_key: String,\n    pub expires_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Serialize)]\npub struct ReviewMetadataResponse {\n    pub gh_pr_url: String,\n    pub pr_title: String,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum ReviewError {\n    #[error(\"Review feature is disabled\")]\n    Disabled,\n    #[error(\"R2 storage not configured\")]\n    NotConfigured,\n    #[error(\"failed to generate upload URL: {0}\")]\n    R2Error(#[from] R2Error),\n    #[error(\"rate limit exceeded\")]\n    RateLimited,\n    #[error(\"unable to determine client IP\")]\n    MissingClientIp,\n    #[error(\"database error: {0}\")]\n    Database(#[from] crate::db::reviews::ReviewError),\n    #[error(\"review worker not configured\")]\n    WorkerNotConfigured,\n    #[error(\"review worker request failed: {0}\")]\n    WorkerError(#[from] reqwest::Error),\n    #[error(\"invalid review ID\")]\n    InvalidReviewId,\n}\n\nimpl IntoResponse for ReviewError {\n    fn into_response(self) -> Response {\n        let (status, message) = match &self {\n            ReviewError::Disabled => (\n                StatusCode::SERVICE_UNAVAILABLE,\n                \"Review feature is disabled\",\n            ),\n            ReviewError::NotConfigured => (\n                StatusCode::SERVICE_UNAVAILABLE,\n                \"Review upload service not available\",\n            ),\n            ReviewError::R2Error(e) => {\n                tracing::error!(error = %e, \"R2 presign failed\");\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"Failed to generate upload URL\",\n                )\n            }\n            ReviewError::RateLimited => (\n                StatusCode::TOO_MANY_REQUESTS,\n                \"Rate limit exceeded. Try again later.\",\n            ),\n            ReviewError::MissingClientIp => {\n                (StatusCode::BAD_REQUEST, \"Unable to determine client IP\")\n            }\n            ReviewError::Database(crate::db::reviews::ReviewError::NotFound) => {\n                (StatusCode::NOT_FOUND, \"Review not found\")\n            }\n            ReviewError::Database(e) => {\n                tracing::error!(error = %e, \"Database error in review\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"Internal server error\")\n            }\n            ReviewError::WorkerNotConfigured => (\n                StatusCode::SERVICE_UNAVAILABLE,\n                \"Review worker service not available\",\n            ),\n            ReviewError::WorkerError(e) => {\n                tracing::error!(error = %e, \"Review worker request failed\");\n                (\n                    StatusCode::BAD_GATEWAY,\n                    \"Failed to fetch review from worker\",\n                )\n            }\n            ReviewError::InvalidReviewId => (StatusCode::BAD_REQUEST, \"Invalid review ID\"),\n        };\n\n        let body = serde_json::json!({\n            \"error\": message\n        });\n\n        (status, Json(body)).into_response()\n    }\n}\n\n/// Ensures the GitHub URL has the https:// protocol prefix\nfn normalize_github_url(url: &str) -> String {\n    let url = url.trim();\n    if url.starts_with(\"https://\") || url.starts_with(\"http://\") {\n        url.to_string()\n    } else {\n        format!(\"https://{}\", url)\n    }\n}\n\n/// Extract client IP from headers, with fallbacks for local development\nfn extract_client_ip(headers: &HeaderMap) -> Option<IpAddr> {\n    // Try Cloudflare header first (production)\n    if let Some(ip) = headers\n        .get(\"CF-Connecting-IP\")\n        .and_then(|v| v.to_str().ok())\n        .and_then(|s| s.parse().ok())\n    {\n        return Some(ip);\n    }\n\n    // Fallback to X-Forwarded-For (common proxy header)\n    if let Some(ip) = headers\n        .get(\"X-Forwarded-For\")\n        .and_then(|v| v.to_str().ok())\n        .and_then(|s| s.split(',').next()) // Take first IP in chain\n        .and_then(|s| s.trim().parse().ok())\n    {\n        return Some(ip);\n    }\n\n    // Fallback to X-Real-IP\n    if let Some(ip) = headers\n        .get(\"X-Real-IP\")\n        .and_then(|v| v.to_str().ok())\n        .and_then(|s| s.parse().ok())\n    {\n        return Some(ip);\n    }\n\n    // For local development, use localhost\n    Some(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))\n}\n\n/// Check rate limits for the given IP address.\n/// Limits: 2 reviews per minute, 20 reviews per hour.\nasync fn check_rate_limit(repo: &ReviewRepository<'_>, ip: IpAddr) -> Result<(), ReviewError> {\n    let now = Utc::now();\n\n    // Check minute limit (2 per minute)\n    let minute_ago = now - Duration::minutes(1);\n    let minute_count = repo.count_since(ip, minute_ago).await?;\n    if minute_count >= 2 {\n        return Err(ReviewError::RateLimited);\n    }\n\n    // Check hour limit (20 per hour)\n    let hour_ago = now - Duration::hours(1);\n    let hour_count = repo.count_since(ip, hour_ago).await?;\n    if hour_count >= 20 {\n        return Err(ReviewError::RateLimited);\n    }\n\n    Ok(())\n}\n\npub async fn init_review_upload(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    Json(payload): Json<InitReviewRequest>,\n) -> Result<Json<InitReviewResponse>, ReviewError> {\n    if state.config.review_disabled {\n        return Err(ReviewError::Disabled);\n    }\n\n    // 1. Generate the review ID upfront (used in both R2 path and DB record)\n    let review_id = Uuid::new_v4();\n\n    // 2. Extract IP (required for rate limiting)\n    let ip = extract_client_ip(&headers).ok_or(ReviewError::MissingClientIp)?;\n\n    // 3. Check rate limits\n    let repo = ReviewRepository::new(state.pool());\n    check_rate_limit(&repo, ip).await?;\n\n    // 4. Get R2 service\n    let r2 = state.r2().ok_or(ReviewError::NotConfigured)?;\n\n    // 5. Generate presigned URL with review ID in path\n    let content_type = payload.content_type.as_deref();\n    let upload = r2.create_presigned_upload(review_id, content_type).await?;\n\n    // 6. Normalize the GitHub PR URL to ensure it has https:// prefix\n    let normalized_url = normalize_github_url(&payload.gh_pr_url);\n\n    // 7. Insert DB record with the same review ID, storing folder path\n    let review = repo\n        .create(CreateReviewParams {\n            id: review_id,\n            gh_pr_url: &normalized_url,\n            claude_code_session_id: payload.claude_code_session_id.as_deref(),\n            ip_address: ip,\n            r2_path: &upload.folder_path,\n            email: &payload.email,\n            pr_title: &payload.pr_title,\n        })\n        .await?;\n\n    // 8. Return response with review_id\n    Ok(Json(InitReviewResponse {\n        review_id: review.id,\n        upload_url: upload.upload_url,\n        object_key: upload.object_key,\n        expires_at: upload.expires_at,\n    }))\n}\n\n/// Proxy a request to the review worker and return the response.\nasync fn proxy_to_worker(state: &AppState, path: &str) -> Result<Response, ReviewError> {\n    let base_url = state\n        .config\n        .review_worker_base_url\n        .as_ref()\n        .ok_or(ReviewError::WorkerNotConfigured)?;\n\n    let url = format!(\"{}{}\", base_url.trim_end_matches('/'), path);\n\n    let response = state.http_client.get(&url).send().await?;\n\n    let status = response.status();\n    let headers = response.headers().clone();\n    let bytes = response.bytes().await?;\n\n    let mut builder = Response::builder().status(status);\n\n    // Copy relevant headers from the worker response\n    if let Some(content_type) = headers.get(\"content-type\") {\n        builder = builder.header(\"content-type\", content_type);\n    }\n\n    Ok(builder.body(Body::from(bytes)).unwrap())\n}\n\n/// Proxy a POST request with JSON body to the review worker\nasync fn proxy_post_to_worker(\n    state: &AppState,\n    path: &str,\n    body: serde_json::Value,\n) -> Result<Response, ReviewError> {\n    let base_url = state\n        .config\n        .review_worker_base_url\n        .as_ref()\n        .ok_or(ReviewError::WorkerNotConfigured)?;\n\n    let url = format!(\"{}{}\", base_url.trim_end_matches('/'), path);\n\n    let response = state.http_client.post(&url).json(&body).send().await?;\n\n    let status = response.status();\n    let headers = response.headers().clone();\n    let bytes = response.bytes().await?;\n\n    let mut builder = Response::builder().status(status);\n\n    if let Some(content_type) = headers.get(\"content-type\") {\n        builder = builder.header(\"content-type\", content_type);\n    }\n\n    Ok(builder.body(Body::from(bytes)).unwrap())\n}\n\n/// POST /review/start - Start review processing on worker\npub async fn start_review(\n    State(state): State<AppState>,\n    Json(body): Json<serde_json::Value>,\n) -> Result<Response, ReviewError> {\n    if state.config.review_disabled {\n        return Err(ReviewError::Disabled);\n    }\n\n    proxy_post_to_worker(&state, \"/review/start\", body).await\n}\n\n/// GET /review/:id/status - Get review status from worker\npub async fn get_review_status(\n    State(state): State<AppState>,\n    Path(id): Path<String>,\n) -> Result<Response, ReviewError> {\n    let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?;\n\n    // Verify review exists in our database\n    let repo = ReviewRepository::new(state.pool());\n    let _review = repo.get_by_id(review_id).await?;\n\n    // Proxy to worker\n    proxy_to_worker(&state, &format!(\"/review/{}/status\", review_id)).await\n}\n\n/// GET /review/:id/metadata - Get PR metadata from database\npub async fn get_review_metadata(\n    State(state): State<AppState>,\n    Path(id): Path<String>,\n) -> Result<Json<ReviewMetadataResponse>, ReviewError> {\n    let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?;\n\n    let repo = ReviewRepository::new(state.pool());\n    let review = repo.get_by_id(review_id).await?;\n\n    Ok(Json(ReviewMetadataResponse {\n        gh_pr_url: review.gh_pr_url,\n        pr_title: review.pr_title,\n    }))\n}\n\n/// GET /review/:id - Get complete review result from worker\npub async fn get_review(\n    State(state): State<AppState>,\n    Path(id): Path<String>,\n) -> Result<Response, ReviewError> {\n    let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?;\n\n    // Verify review exists in our database\n    let repo = ReviewRepository::new(state.pool());\n    let _review = repo.get_by_id(review_id).await?;\n\n    // Proxy to worker\n    proxy_to_worker(&state, &format!(\"/review/{}\", review_id)).await\n}\n\n/// GET /review/:id/file/:file_hash - Get file content from worker\npub async fn get_review_file(\n    State(state): State<AppState>,\n    Path((id, file_hash)): Path<(String, String)>,\n) -> Result<Response, ReviewError> {\n    let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?;\n\n    // Verify review exists in our database\n    let repo = ReviewRepository::new(state.pool());\n    let _review = repo.get_by_id(review_id).await?;\n\n    // Proxy to worker\n    proxy_to_worker(&state, &format!(\"/review/{}/file/{}\", review_id, file_hash)).await\n}\n\n/// GET /review/:id/diff - Get diff for review from worker\npub async fn get_review_diff(\n    State(state): State<AppState>,\n    Path(id): Path<String>,\n) -> Result<Response, ReviewError> {\n    let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?;\n\n    // Verify review exists in our database\n    let repo = ReviewRepository::new(state.pool());\n    let _review = repo.get_by_id(review_id).await?;\n\n    // Proxy to worker\n    proxy_to_worker(&state, &format!(\"/review/{}/diff\", review_id)).await\n}\n\n/// POST /review/:id/success - Called by worker when review completes successfully\n/// Sends success notification email to the user, or posts PR comment for webhook reviews\npub async fn review_success(\n    State(state): State<AppState>,\n    Path(id): Path<String>,\n) -> Result<StatusCode, ReviewError> {\n    let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?;\n\n    // Fetch review from database to get email and PR title\n    let repo = ReviewRepository::new(state.pool());\n    let review = repo.get_by_id(review_id).await?;\n\n    // Mark review as completed\n    repo.mark_completed(review_id).await?;\n\n    // Build review URL\n    let review_url = format!(\"{}/review/{}\", state.server_public_base_url, review_id);\n\n    // Check if this is a webhook-triggered review\n    if review.is_webhook_review() {\n        // Post PR comment instead of sending email\n        if let Some(github_app) = state.github_app() {\n            let comment = format!(\n                \"## Review Complete\\n\\n\\\n                Your review story is ready!\\n\\n\\\n                **[View Story]({})**\\n\\n\\\n                Comment **!reviewfast** on this PR to re-generate the story.\",\n                review_url\n            );\n\n            let installation_id = review.github_installation_id.unwrap_or(0);\n            let pr_owner = review.pr_owner.as_deref().unwrap_or(\"\");\n            let pr_repo = review.pr_repo.as_deref().unwrap_or(\"\");\n            let pr_number = review.pr_number.unwrap_or(0) as u64;\n\n            if let Err(e) = github_app\n                .post_pr_comment(installation_id, pr_owner, pr_repo, pr_number, &comment)\n                .await\n            {\n                tracing::error!(\n                    ?e,\n                    review_id = %review_id,\n                    \"Failed to post success comment to PR\"\n                );\n            }\n        }\n    } else if let Some(email) = &review.email {\n        // CLI review - send email notification\n        state\n            .mailer\n            .send_review_ready(email, &review_url, &review.pr_title)\n            .await;\n    }\n\n    Ok(StatusCode::OK)\n}\n\n/// POST /review/:id/failed - Called by worker when review fails\n/// Sends failure notification email to the user, or posts PR comment for webhook reviews\npub async fn review_failed(\n    State(state): State<AppState>,\n    Path(id): Path<String>,\n) -> Result<StatusCode, ReviewError> {\n    let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?;\n\n    // Fetch review from database to get email and PR title\n    let repo = ReviewRepository::new(state.pool());\n    let review = repo.get_by_id(review_id).await?;\n\n    // Mark review as failed\n    repo.mark_failed(review_id).await?;\n\n    // Check if this is a webhook-triggered review\n    if review.is_webhook_review() {\n        // Post PR comment instead of sending email\n        if let Some(github_app) = state.github_app() {\n            let comment = format!(\n                \"## Vibe Kanban Review Failed\\n\\n\\\n                Unfortunately, the code review could not be completed.\\n\\n\\\n                Review ID: `{}`\",\n                review_id\n            );\n\n            let installation_id = review.github_installation_id.unwrap_or(0);\n            let pr_owner = review.pr_owner.as_deref().unwrap_or(\"\");\n            let pr_repo = review.pr_repo.as_deref().unwrap_or(\"\");\n            let pr_number = review.pr_number.unwrap_or(0) as u64;\n\n            if let Err(e) = github_app\n                .post_pr_comment(installation_id, pr_owner, pr_repo, pr_number, &comment)\n                .await\n            {\n                tracing::error!(\n                    ?e,\n                    review_id = %review_id,\n                    \"Failed to post failure comment to PR\"\n                );\n            }\n        }\n    } else if let Some(email) = &review.email {\n        // CLI review - send email notification\n        state\n            .mailer\n            .send_review_failed(email, &review.pr_title, &review_id.to_string())\n            .await;\n    }\n\n    Ok(StatusCode::OK)\n}\n"
  },
  {
    "path": "crates/remote/src/routes/tags.rs",
    "content": "use api_types::{\n    CreateTagRequest, DeleteResponse, ListTagsQuery, ListTagsResponse, MutationResponse, Tag,\n    UpdateTagRequest,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Path, Query, State},\n    http::StatusCode,\n};\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_project_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{tags::TagRepository, types::is_valid_hsl_color},\n    mutation_definition::MutationBuilder,\n};\n\n/// Mutation definition for Tags - provides both router and TypeScript metadata.\npub fn mutation() -> MutationBuilder<Tag, CreateTagRequest, UpdateTagRequest> {\n    MutationBuilder::new(\"tags\")\n        .list(list_tags)\n        .get(get_tag)\n        .create(create_tag)\n        .update(update_tag)\n        .delete(delete_tag)\n}\n\npub fn router() -> axum::Router<AppState> {\n    mutation().router()\n}\n\n#[instrument(\n    name = \"tags.list_tags\",\n    skip(state, ctx),\n    fields(project_id = %query.project_id, user_id = %ctx.user.id)\n)]\nasync fn list_tags(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ListTagsQuery>,\n) -> Result<Json<ListTagsResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let tags = TagRepository::list_by_project(state.pool(), query.project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %query.project_id, \"failed to list tags\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list tags\")\n        })?;\n\n    Ok(Json(ListTagsResponse { tags }))\n}\n\n#[instrument(\n    name = \"tags.get_tag\",\n    skip(state, ctx),\n    fields(tag_id = %tag_id, user_id = %ctx.user.id)\n)]\nasync fn get_tag(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(tag_id): Path<Uuid>,\n) -> Result<Json<Tag>, ErrorResponse> {\n    let tag = TagRepository::find_by_id(state.pool(), tag_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %tag_id, \"failed to load tag\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load tag\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"tag not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, tag.project_id).await?;\n\n    Ok(Json(tag))\n}\n\n#[instrument(\n    name = \"tags.create_tag\",\n    skip(state, ctx, payload),\n    fields(project_id = %payload.project_id, user_id = %ctx.user.id)\n)]\nasync fn create_tag(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateTagRequest>,\n) -> Result<Json<MutationResponse<Tag>>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?;\n\n    if !is_valid_hsl_color(&payload.color) {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Invalid color format. Expected HSL format: 'H S% L%'\",\n        ));\n    }\n\n    let response = TagRepository::create(\n        state.pool(),\n        payload.id,\n        payload.project_id,\n        payload.name,\n        payload.color,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create tag\");\n        db_error(error, \"failed to create tag\")\n    })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"tags.update_tag\",\n    skip(state, ctx, payload),\n    fields(tag_id = %tag_id, user_id = %ctx.user.id)\n)]\nasync fn update_tag(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(tag_id): Path<Uuid>,\n    Json(payload): Json<UpdateTagRequest>,\n) -> Result<Json<MutationResponse<Tag>>, ErrorResponse> {\n    let tag = TagRepository::find_by_id(state.pool(), tag_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %tag_id, \"failed to load tag\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load tag\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"tag not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, tag.project_id).await?;\n\n    if let Some(ref color) = payload.color\n        && !is_valid_hsl_color(color)\n    {\n        return Err(ErrorResponse::new(\n            StatusCode::BAD_REQUEST,\n            \"Invalid color format. Expected HSL format: 'H S% L%'\",\n        ));\n    }\n\n    // Partial update - use existing values if not provided\n    let response = TagRepository::update(state.pool(), tag_id, payload.name, payload.color)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to update tag\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n\n#[instrument(\n    name = \"tags.delete_tag\",\n    skip(state, ctx),\n    fields(tag_id = %tag_id, user_id = %ctx.user.id)\n)]\nasync fn delete_tag(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(tag_id): Path<Uuid>,\n) -> Result<Json<DeleteResponse>, ErrorResponse> {\n    let tag = TagRepository::find_by_id(state.pool(), tag_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, %tag_id, \"failed to load tag\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to load tag\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"tag not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, tag.project_id).await?;\n\n    let response = TagRepository::delete(state.pool(), tag_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete tag\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(Json(response))\n}\n"
  },
  {
    "path": "crates/remote/src/routes/tokens.rs",
    "content": "use api_types::{TokenRefreshRequest, TokenRefreshResponse};\nuse axum::{\n    Json, Router,\n    extract::State,\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    routing::post,\n};\nuse tracing::{info, warn};\n\nuse crate::{\n    AppState,\n    audit::{self, AuditAction, AuditEvent},\n    auth::{JwtError, OAuthTokenValidationError},\n    db::{\n        auth::{AuthSessionError, AuthSessionRepository},\n        identity_errors::IdentityError,\n        oauth_accounts::{OAuthAccountError, OAuthAccountRepository},\n        users::UserRepository,\n    },\n};\n\npub fn public_router() -> Router<AppState> {\n    Router::new().route(\"/tokens/refresh\", post(refresh_token))\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum TokenRefreshError {\n    #[error(\"invalid refresh token\")]\n    InvalidToken,\n    #[error(\"session has been revoked\")]\n    SessionRevoked,\n    #[error(\"refresh token expired\")]\n    TokenExpired,\n    #[error(\"refresh token reused - possible token theft\")]\n    TokenReuseDetected,\n    #[error(\"provider token has been revoked\")]\n    ProviderTokenRevoked,\n    #[error(\"temporary failure validating provider token\")]\n    ProviderValidationUnavailable(String),\n    #[error(transparent)]\n    Jwt(#[from] JwtError),\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(transparent)]\n    SessionError(#[from] AuthSessionError),\n    #[error(transparent)]\n    Identity(#[from] IdentityError),\n}\n\nimpl From<OAuthTokenValidationError> for TokenRefreshError {\n    fn from(err: OAuthTokenValidationError) -> Self {\n        match err {\n            OAuthTokenValidationError::ProviderAccountNotLinked\n            | OAuthTokenValidationError::ProviderTokenValidationFailed => {\n                TokenRefreshError::ProviderTokenRevoked\n            }\n            OAuthTokenValidationError::FetchAccountsFailed(inner) => match inner {\n                OAuthAccountError::Database(db_err) => TokenRefreshError::Database(db_err),\n            },\n            OAuthTokenValidationError::ValidationUnavailable(reason) => {\n                TokenRefreshError::ProviderValidationUnavailable(reason)\n            }\n        }\n    }\n}\n\nimpl From<OAuthAccountError> for TokenRefreshError {\n    fn from(err: OAuthAccountError) -> Self {\n        match err {\n            OAuthAccountError::Database(db_err) => TokenRefreshError::Database(db_err),\n        }\n    }\n}\n\npub async fn refresh_token(\n    State(state): State<AppState>,\n    Json(payload): Json<TokenRefreshRequest>,\n) -> Result<Response, TokenRefreshError> {\n    let jwt_service = &state.jwt();\n    let session_repo = AuthSessionRepository::new(state.pool());\n\n    let token_details = match jwt_service.decode_refresh_token(&payload.refresh_token) {\n        Ok(details) => details,\n        Err(JwtError::TokenExpired) => return Err(TokenRefreshError::TokenExpired),\n        Err(_) => return Err(TokenRefreshError::InvalidToken),\n    };\n\n    let session = match session_repo.get(token_details.session_id).await {\n        Ok(session) => session,\n        Err(AuthSessionError::NotFound) => return Err(TokenRefreshError::SessionRevoked),\n        Err(error) => return Err(TokenRefreshError::SessionError(error)),\n    };\n\n    if session.revoked_at.is_some() {\n        return Err(TokenRefreshError::SessionRevoked);\n    }\n\n    if session.refresh_token_id != Some(token_details.refresh_token_id)\n        || session_repo\n            .is_refresh_token_revoked(token_details.refresh_token_id)\n            .await?\n    {\n        // Token was reused, revoke all user sessions as a security measure\n        let revoked_count = session_repo\n            .revoke_all_user_sessions(token_details.user_id)\n            .await?;\n        warn!(\n            user_id = %token_details.user_id,\n            session_id = %token_details.session_id,\n            revoked_sessions = revoked_count,\n            \"Refresh token reuse detected. Revoked all user sessions.\"\n        );\n        audit::emit(\n            AuditEvent::system(AuditAction::AuthTokenReuseDetected)\n                .user(token_details.user_id, Some(token_details.session_id))\n                .resource(\"auth_session\", Some(token_details.session_id))\n                .http(\"POST\", \"/v1/tokens/refresh\", 401)\n                .description(format!(\"{revoked_count} sessions revoked\")),\n        );\n        return Err(TokenRefreshError::TokenReuseDetected);\n    }\n\n    // Move encrypted_provider_tokens from legacy refresh token claim to the DB\n    if let Some(legacy_provider_token_details) =\n        token_details.legacy_provider_token_details.as_ref()\n        && let oauth_account_repo = OAuthAccountRepository::new(state.pool())\n        && oauth_account_repo\n            .get_by_user_provider(token_details.user_id, &token_details.provider)\n            .await?\n            .is_some_and(|account| account.encrypted_provider_tokens.is_none())\n    {\n        let encrypted_provider_tokens =\n            jwt_service.encrypt_provider_tokens(legacy_provider_token_details)?;\n        oauth_account_repo\n            .update_encrypted_provider_tokens(\n                token_details.user_id,\n                &token_details.provider,\n                &encrypted_provider_tokens,\n            )\n            .await?;\n        info!(\n            user_id = %token_details.user_id,\n            provider = %token_details.provider,\n            session_id = %token_details.session_id,\n            \"Backfilled DB provider token from legacy refresh token claim\"\n        );\n    }\n\n    state\n        .oauth_token_validator()\n        .validate(\n            &token_details.provider,\n            token_details.user_id,\n            token_details.session_id,\n        )\n        .await?;\n\n    let user_repo = UserRepository::new(state.pool());\n    let user = user_repo.fetch_user(token_details.user_id).await?;\n\n    let tokens = jwt_service.generate_tokens(&session, &user, &token_details.provider)?;\n\n    let old_token_id = token_details.refresh_token_id;\n    let new_token_id = tokens.refresh_token_id;\n\n    match session_repo\n        .rotate_tokens(session.id, old_token_id, new_token_id)\n        .await\n    {\n        Ok(_) => {}\n        Err(AuthSessionError::TokenReuseDetected) => {\n            let revoked_count = session_repo\n                .revoke_all_user_sessions(token_details.user_id)\n                .await?;\n            warn!(\n                user_id = %token_details.user_id,\n                session_id = %token_details.session_id,\n                revoked_sessions = revoked_count,\n                \"Detected concurrent refresh attempt; revoked all user sessions\"\n            );\n            audit::emit(\n                AuditEvent::system(AuditAction::AuthTokenReuseDetected)\n                    .user(token_details.user_id, Some(token_details.session_id))\n                    .resource(\"auth_session\", Some(token_details.session_id))\n                    .http(\"POST\", \"/v1/tokens/refresh\", 401)\n                    .description(format!(\n                        \"{revoked_count} sessions revoked (concurrent reuse)\"\n                    )),\n            );\n            return Err(TokenRefreshError::TokenReuseDetected);\n        }\n        Err(error) => return Err(TokenRefreshError::SessionError(error)),\n    }\n\n    audit::emit(\n        AuditEvent::system(AuditAction::AuthTokenRefresh)\n            .user(token_details.user_id, Some(token_details.session_id))\n            .resource(\"auth_session\", Some(token_details.session_id))\n            .http(\"POST\", \"/v1/tokens/refresh\", 200),\n    );\n\n    Ok(Json(TokenRefreshResponse {\n        access_token: tokens.access_token,\n        refresh_token: tokens.refresh_token,\n    })\n    .into_response())\n}\n\nimpl IntoResponse for TokenRefreshError {\n    fn into_response(self) -> Response {\n        let (status, error_code) = match self {\n            TokenRefreshError::InvalidToken => (StatusCode::UNAUTHORIZED, \"invalid_token\"),\n            TokenRefreshError::TokenExpired => (StatusCode::UNAUTHORIZED, \"token_expired\"),\n            TokenRefreshError::SessionRevoked => (StatusCode::UNAUTHORIZED, \"session_revoked\"),\n            TokenRefreshError::TokenReuseDetected => {\n                (StatusCode::UNAUTHORIZED, \"token_reuse_detected\")\n            }\n            TokenRefreshError::ProviderTokenRevoked => {\n                (StatusCode::UNAUTHORIZED, \"provider_token_revoked\")\n            }\n            TokenRefreshError::ProviderValidationUnavailable(ref reason) => {\n                warn!(\n                    reason = reason.as_str(),\n                    \"Provider validation temporarily unavailable during refresh\"\n                );\n                (\n                    StatusCode::SERVICE_UNAVAILABLE,\n                    \"provider_validation_unavailable\",\n                )\n            }\n            TokenRefreshError::Jwt(_) => (StatusCode::UNAUTHORIZED, \"invalid_token\"),\n            TokenRefreshError::Identity(_) => (StatusCode::UNAUTHORIZED, \"identity_error\"),\n            TokenRefreshError::Database(ref err) => {\n                tracing::error!(error = %err, \"Database error during token refresh\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"internal_error\")\n            }\n            TokenRefreshError::SessionError(ref err) => {\n                tracing::error!(error = %err, \"Session error during token refresh\");\n                (StatusCode::INTERNAL_SERVER_ERROR, \"internal_error\")\n            }\n        };\n\n        let body = serde_json::json!({\n            \"error\": error_code,\n            \"message\": self.to_string()\n        });\n\n        (status, Json(body)).into_response()\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/routes/workspaces.rs",
    "content": "use api_types::{DeleteWorkspaceRequest, UpdateWorkspaceRequest, Workspace};\nuse axum::{\n    Json, Router,\n    extract::{Extension, Path, State},\n    http::StatusCode,\n    routing::{delete, get, head, post},\n};\nuse serde::Deserialize;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::{\n    error::{ErrorResponse, db_error},\n    organization_members::ensure_project_access,\n};\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        issues::IssueRepository,\n        workspaces::{CreateWorkspaceParams, WorkspaceRepository},\n    },\n};\n\n#[derive(Debug, Deserialize)]\npub struct CreateWorkspaceRequest {\n    pub project_id: Uuid,\n    pub local_workspace_id: Option<Uuid>,\n    pub issue_id: Option<Uuid>,\n    pub name: Option<String>,\n    pub archived: Option<bool>,\n    pub files_changed: Option<i32>,\n    pub lines_added: Option<i32>,\n    pub lines_removed: Option<i32>,\n}\n\npub fn router() -> Router<AppState> {\n    Router::new()\n        .route(\n            \"/workspaces\",\n            post(create_workspace)\n                .patch(update_workspace)\n                .delete(delete_workspace),\n        )\n        .route(\"/workspaces/{workspace_id}\", delete(unlink_workspace))\n        .route(\n            \"/workspaces/{local_workspace_id}/sync_issue_status_from_local_merge\",\n            post(sync_issue_status_from_local_merge),\n        )\n        .route(\n            \"/workspaces/by-local-id/{local_workspace_id}\",\n            get(get_workspace_by_local_id),\n        )\n        .route(\n            \"/workspaces/exists/{local_workspace_id}\",\n            head(workspace_exists),\n        )\n}\n\n#[instrument(\n    name = \"workspaces.create_workspace\",\n    skip(state, ctx, payload),\n    fields(project_id = %payload.project_id, user_id = %ctx.user.id)\n)]\nasync fn create_workspace(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<CreateWorkspaceRequest>,\n) -> Result<Json<Workspace>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?;\n\n    let workspace = WorkspaceRepository::create(\n        state.pool(),\n        CreateWorkspaceParams {\n            project_id: payload.project_id,\n            owner_user_id: ctx.user.id,\n            local_workspace_id: payload.local_workspace_id,\n            issue_id: payload.issue_id,\n            name: payload.name,\n            archived: payload.archived,\n            files_changed: payload.files_changed,\n            lines_added: payload.lines_added,\n            lines_removed: payload.lines_removed,\n        },\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to create workspace\");\n        db_error(error, \"failed to create workspace\")\n    })?;\n\n    if let Some(issue_id) = payload.issue_id {\n        if let Err(error) =\n            IssueRepository::sync_issue_from_workspace_created(state.pool(), issue_id, ctx.user.id)\n                .await\n        {\n            tracing::warn!(?error, \"failed to sync issue from workspace creation\");\n        }\n\n        if let Some(analytics) = state.analytics() {\n            analytics.track(\n                ctx.user.id,\n                \"workspace_created_from_issue\",\n                serde_json::json!({\n                    \"workspace_id\": workspace.id,\n                    \"project_id\": workspace.project_id,\n                    \"issue_id\": issue_id,\n                }),\n            );\n        }\n    }\n\n    Ok(Json(workspace))\n}\n\n#[instrument(\n    name = \"workspaces.update_workspace\",\n    skip(state, ctx, payload),\n    fields(local_workspace_id = %payload.local_workspace_id, user_id = %ctx.user.id)\n)]\nasync fn update_workspace(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<UpdateWorkspaceRequest>,\n) -> Result<Json<Workspace>, ErrorResponse> {\n    let workspace = WorkspaceRepository::find_by_local_id(state.pool(), payload.local_workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, local_workspace_id = %payload.local_workspace_id, \"failed to find workspace\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find workspace\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"workspace not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?;\n\n    let updated = WorkspaceRepository::update(\n        state.pool(),\n        workspace.id,\n        payload.name,\n        payload.archived,\n        payload.files_changed,\n        payload.lines_added,\n        payload.lines_removed,\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, \"failed to update workspace\");\n        ErrorResponse::new(\n            StatusCode::INTERNAL_SERVER_ERROR,\n            \"failed to update workspace\",\n        )\n    })?;\n\n    Ok(Json(updated))\n}\n\n#[instrument(\n    name = \"workspaces.sync_issue_status_from_local_merge\",\n    skip(state, ctx),\n    fields(local_workspace_id = %local_workspace_id, user_id = %ctx.user.id)\n)]\nasync fn sync_issue_status_from_local_merge(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(local_workspace_id): Path<Uuid>,\n) -> Result<StatusCode, ErrorResponse> {\n    let workspace = WorkspaceRepository::find_by_local_id(state.pool(), local_workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, local_workspace_id = %local_workspace_id, \"failed to find workspace\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to find workspace\")\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"workspace not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?;\n\n    let Some(issue_id) = workspace.issue_id else {\n        return Ok(StatusCode::NO_CONTENT);\n    };\n\n    IssueRepository::sync_status_from_local_workspace_merge(state.pool(), issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, issue_id = %issue_id, \"failed to sync issue status\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"internal server error\")\n        })?;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\n#[instrument(\n    name = \"workspaces.delete_workspace\",\n    skip(state, ctx, payload),\n    fields(local_workspace_id = %payload.local_workspace_id, user_id = %ctx.user.id)\n)]\nasync fn delete_workspace(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Json(payload): Json<DeleteWorkspaceRequest>,\n) -> Result<StatusCode, ErrorResponse> {\n    let workspace = WorkspaceRepository::find_by_local_id(state.pool(), payload.local_workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to find workspace\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to find workspace\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"workspace not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?;\n\n    WorkspaceRepository::delete_by_local_id(state.pool(), payload.local_workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete workspace\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to delete workspace\",\n            )\n        })?;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\n#[instrument(\n    name = \"workspaces.unlink_workspace\",\n    skip(state, ctx),\n    fields(workspace_id = %workspace_id, user_id = %ctx.user.id)\n)]\nasync fn unlink_workspace(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(workspace_id): Path<Uuid>,\n) -> Result<StatusCode, ErrorResponse> {\n    let workspace = WorkspaceRepository::find_by_id(state.pool(), workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to find workspace\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to find workspace\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"workspace not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?;\n\n    WorkspaceRepository::delete(state.pool(), workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to delete workspace\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to delete workspace\",\n            )\n        })?;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\n#[instrument(\n    name = \"workspaces.get_workspace_by_local_id\",\n    skip(state, ctx),\n    fields(local_workspace_id = %local_workspace_id, user_id = %ctx.user.id)\n)]\nasync fn get_workspace_by_local_id(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Path(local_workspace_id): Path<Uuid>,\n) -> Result<Json<Workspace>, ErrorResponse> {\n    let workspace = WorkspaceRepository::find_by_local_id(state.pool(), local_workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to find workspace\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to find workspace\",\n            )\n        })?\n        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, \"workspace not found\"))?;\n\n    ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?;\n\n    Ok(Json(workspace))\n}\n\n#[instrument(\n    name = \"workspaces.workspace_exists\",\n    skip(state, _ctx),\n    fields(local_workspace_id = %local_workspace_id)\n)]\nasync fn workspace_exists(\n    State(state): State<AppState>,\n    Extension(_ctx): Extension<RequestContext>,\n    Path(local_workspace_id): Path<Uuid>,\n) -> Result<StatusCode, ErrorResponse> {\n    let exists = WorkspaceRepository::exists_by_local_id(state.pool(), local_workspace_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, \"failed to check workspace existence\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to check workspace\",\n            )\n        })?;\n\n    if exists {\n        Ok(StatusCode::OK)\n    } else {\n        Err(ErrorResponse::new(\n            StatusCode::NOT_FOUND,\n            \"workspace not found\",\n        ))\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/shape_definition.rs",
    "content": "//! Shape infrastructure: struct, trait, and macro.\n\nuse std::marker::PhantomData;\n\nuse ts_rs::TS;\n\n#[derive(Debug)]\npub struct ShapeDefinition<T: TS> {\n    pub name: &'static str,\n    pub table: &'static str,\n    pub where_clause: &'static str,\n    pub params: &'static [&'static str],\n    pub url: &'static str,\n    pub _phantom: PhantomData<T>,\n}\n\n/// Trait to allow heterogeneous collection of shapes for export.\n///\n/// This enables collecting `ShapeDefinition<T>` values with different `T`\n/// into a single `Vec<&dyn ShapeExport>`.\npub trait ShapeExport: Sync {\n    fn name(&self) -> &'static str;\n    fn table(&self) -> &'static str;\n    fn where_clause(&self) -> &'static str;\n    fn params(&self) -> &'static [&'static str];\n    fn url(&self) -> &'static str;\n    fn ts_type_name(&self) -> String;\n}\n\nimpl<T: TS + Sync> ShapeExport for ShapeDefinition<T> {\n    fn name(&self) -> &'static str {\n        self.name\n    }\n    fn table(&self) -> &'static str {\n        self.table\n    }\n    fn where_clause(&self) -> &'static str {\n        self.where_clause\n    }\n    fn params(&self) -> &'static [&'static str] {\n        self.params\n    }\n    fn url(&self) -> &'static str {\n        self.url\n    }\n    fn ts_type_name(&self) -> String {\n        T::name()\n    }\n}\n\n/// Macro to construct a `ShapeDefinition` with compile-time SQL validation.\n///\n/// Usage:\n/// ```ignore\n/// pub const PROJECTS_SHAPE: ShapeDefinition<Project> = define_shape!(\n///     table: \"projects\",\n///     where_clause: r#\"\"organization_id\" = $1\"#,\n///     url: \"/shape/projects\",\n///     params: [\"organization_id\"]\n/// );\n/// ```\n#[macro_export]\nmacro_rules! define_shape {\n    (\n        name: $name:literal,\n        table: $table:literal,\n        where_clause: $where:literal,\n        url: $url:expr,\n        params: [$($param:literal),* $(,)?] $(,)?\n    ) => {{\n        #[allow(dead_code)]\n        fn _validate() {\n            let _ = sqlx::query!(\n                \"SELECT 1 AS v FROM \" + $table + \" WHERE \" + $where\n                $(, { let _ = stringify!($param); uuid::Uuid::nil() })*\n            );\n        }\n\n        $crate::shape_definition::ShapeDefinition {\n            name: $name,\n            table: $table,\n            where_clause: $where,\n            params: &[$($param),*],\n            url: $url,\n            _phantom: std::marker::PhantomData,\n        }\n    }};\n}\n"
  },
  {
    "path": "crates/remote/src/shape_route.rs",
    "content": "//! Unified registration for Electric proxy + REST fallback routes.\n//!\n//! Each shape has exactly one proxy handler (GET on its URL) and a required\n//! REST fallback route. `ShapeRoute::new` pairs the shape with its\n//! authorization scope and fallback, then registers both routes in one call.\n//!\n//! # Example\n//!\n//! ```ignore\n//! use crate::shape_route_builder::{ShapeRoute, ShapeScope};\n//! use crate::shapes;\n//!\n//! let route = ShapeRoute::new(\n//!     &shapes::PROJECTS_SHAPE,\n//!     ShapeScope::Org,\n//!     \"/fallback/projects\",\n//!     fallback_list_projects,\n//! );\n//! ```\n\nuse axum::{\n    extract::{Extension, Path, Query, State},\n    handler::Handler,\n    routing::{MethodRouter, get},\n};\nuse serde::Deserialize;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::organization_members,\n    routes::electric_proxy::{OrgShapeQuery, ProxyError, ShapeQuery, proxy_table},\n    shape_definition::{ShapeDefinition, ShapeExport},\n};\n\n// =============================================================================\n// HasQueryParams — structural trait linking handlers to their query extractor\n// =============================================================================\n\n/// Marker trait implemented for extractor tuples that include `Query<Q>`.\n///\n/// This links a fallback handler's query extractor to the declared query type,\n/// ensuring the handler accepts the correct scope parameters.\n/// Same pattern as `HasJsonPayload` in `mutation_definition`.\npub trait HasQueryParams<Q> {}\n\nimpl<Q> HasQueryParams<Q> for (Query<Q>,) {}\nimpl<A, Q> HasQueryParams<Q> for (A, Query<Q>) {}\nimpl<A, B, Q> HasQueryParams<Q> for (A, B, Query<Q>) {}\nimpl<A, B, C, Q> HasQueryParams<Q> for (A, B, C, Query<Q>) {}\nimpl<A, B, C, D, Q> HasQueryParams<Q> for (A, B, C, D, Query<Q>) {}\n\n// =============================================================================\n// Fallback query types — one per scope pattern\n// =============================================================================\n\n/// Query params for org-scoped fallback handlers (Org, OrgWithUser).\n#[derive(Debug, Deserialize)]\npub struct OrgFallbackQuery {\n    pub organization_id: Uuid,\n}\n\n/// Query params for project-scoped fallback handlers.\n#[derive(Debug, Deserialize)]\npub struct ProjectFallbackQuery {\n    pub project_id: Uuid,\n}\n\n/// Query params for issue-scoped fallback handlers.\n#[derive(Debug, Deserialize)]\npub struct IssueFallbackQuery {\n    pub issue_id: Uuid,\n}\n\n/// Marker for fallback handlers that require no query parameters.\n/// Used for User-scoped shapes where the user ID comes from auth context.\n/// Analogous to `NoCreate` in `MutationBuilder`.\n#[derive(Debug, Deserialize)]\npub struct NoQueryParams {}\n\n// =============================================================================\n// ShapeScope — authorization patterns for Electric proxy routes\n// =============================================================================\n\n/// Authorization scope for an Electric proxy route.\n///\n/// Each variant maps to a distinct combination of extractor types,\n/// authorization check, and Electric parameter construction.\n#[derive(Debug, Clone, Copy)]\npub enum ShapeScope {\n    /// Org-scoped: `organization_id` from query.\n    /// Auth: `assert_membership(organization_id, user_id)`\n    /// Electric params: `[organization_id]`\n    Org,\n\n    /// Org-scoped with user injection: `organization_id` from query.\n    /// Auth: `assert_membership(organization_id, user_id)`\n    /// Electric params: `[organization_id, user_id]`\n    OrgWithUser,\n\n    /// Project-scoped: `{project_id}` from URL path.\n    /// Auth: `assert_project_access(project_id, user_id)`\n    /// Electric params: `[project_id]`\n    Project,\n\n    /// Issue-scoped: `{issue_id}` from URL path.\n    /// Auth: `assert_issue_access(issue_id, user_id)`\n    /// Electric params: `[issue_id]`\n    Issue,\n\n    /// User-scoped: no client-provided scope param.\n    /// Auth: none (implicit — user can only see their own data)\n    /// Electric params: `[user_id]`\n    User,\n}\n\n// =============================================================================\n// ShapeRoute\n// =============================================================================\n\n/// A shape route: router, shape metadata, and fallback URL.\npub struct ShapeRoute {\n    pub router: axum::Router<AppState>,\n    /// Type-erased shape metadata (table, params, url, ts_type_name).\n    pub shape: &'static dyn ShapeExport,\n    /// REST fallback URL, e.g. `\"/fallback/projects\"`.\n    pub fallback_url: &'static str,\n}\n\nimpl ShapeRoute {\n    /// Create a shape route: Electric proxy handler + REST fallback, type-erased.\n    ///\n    /// The fallback handler's extractor tuple must include `Query<Q>` (enforced by\n    /// `HasQueryParams`), ensuring the handler accepts the correct scope\n    /// parameters. Use `Query<NoQueryParams>` for handlers that don't need\n    /// query parameters (e.g. User-scoped shapes).\n    pub fn new<T, H, HT, Q>(\n        shape: &'static ShapeDefinition<T>,\n        scope: ShapeScope,\n        fallback_url: &'static str,\n        fallback_handler: H,\n    ) -> Self\n    where\n        T: TS + Sync + Send + 'static,\n        H: Handler<HT, AppState> + Clone + Send + 'static,\n        HT: HasQueryParams<Q> + 'static,\n    {\n        let proxy_handler = build_proxy_handler(shape, scope);\n        let router = axum::Router::new()\n            .route(shape.url(), proxy_handler)\n            .route(fallback_url, get(fallback_handler));\n\n        Self {\n            router,\n            shape,\n            fallback_url,\n        }\n    }\n}\n\n// =============================================================================\n// Handler construction\n// =============================================================================\n\n/// Build the appropriate GET handler for a shape based on its authorization scope.\nfn build_proxy_handler(\n    shape: &'static dyn ShapeExport,\n    scope: ShapeScope,\n) -> MethodRouter<AppState> {\n    match scope {\n        ShapeScope::Org => get(\n            move |State(state): State<AppState>,\n                  Extension(ctx): Extension<RequestContext>,\n                  Query(query): Query<OrgShapeQuery>| async move {\n                organization_members::assert_membership(\n                    state.pool(),\n                    query.organization_id,\n                    ctx.user.id,\n                )\n                .await\n                .map_err(|e| ProxyError::Authorization(e.to_string()))?;\n\n                proxy_table(\n                    &state,\n                    shape,\n                    &query.params,\n                    &[query.organization_id.to_string()],\n                    ctx.session_id,\n                )\n                .await\n            },\n        ),\n\n        ShapeScope::OrgWithUser => get(\n            move |State(state): State<AppState>,\n                  Extension(ctx): Extension<RequestContext>,\n                  Query(query): Query<OrgShapeQuery>| async move {\n                organization_members::assert_membership(\n                    state.pool(),\n                    query.organization_id,\n                    ctx.user.id,\n                )\n                .await\n                .map_err(|e| ProxyError::Authorization(e.to_string()))?;\n\n                proxy_table(\n                    &state,\n                    shape,\n                    &query.params,\n                    &[query.organization_id.to_string(), ctx.user.id.to_string()],\n                    ctx.session_id,\n                )\n                .await\n            },\n        ),\n\n        ShapeScope::Project => get(\n            move |State(state): State<AppState>,\n                  Extension(ctx): Extension<RequestContext>,\n                  Path(project_id): Path<Uuid>,\n                  Query(query): Query<ShapeQuery>| async move {\n                organization_members::assert_project_access(state.pool(), project_id, ctx.user.id)\n                    .await\n                    .map_err(|e| ProxyError::Authorization(e.to_string()))?;\n\n                proxy_table(\n                    &state,\n                    shape,\n                    &query.params,\n                    &[project_id.to_string()],\n                    ctx.session_id,\n                )\n                .await\n            },\n        ),\n\n        ShapeScope::Issue => get(\n            move |State(state): State<AppState>,\n                  Extension(ctx): Extension<RequestContext>,\n                  Path(issue_id): Path<Uuid>,\n                  Query(query): Query<ShapeQuery>| async move {\n                organization_members::assert_issue_access(state.pool(), issue_id, ctx.user.id)\n                    .await\n                    .map_err(|e| ProxyError::Authorization(e.to_string()))?;\n\n                proxy_table(\n                    &state,\n                    shape,\n                    &query.params,\n                    &[issue_id.to_string()],\n                    ctx.session_id,\n                )\n                .await\n            },\n        ),\n\n        ShapeScope::User => get(\n            move |State(state): State<AppState>,\n                  Extension(ctx): Extension<RequestContext>,\n                  Query(query): Query<ShapeQuery>| async move {\n                proxy_table(\n                    &state,\n                    shape,\n                    &query.params,\n                    &[ctx.user.id.to_string()],\n                    ctx.session_id,\n                )\n                .await\n            },\n        ),\n    }\n}\n"
  },
  {
    "path": "crates/remote/src/shape_routes.rs",
    "content": "//! All shape route declarations with authorization scope and REST fallback.\n\nuse api_types::{\n    ListIssueAssigneesResponse, ListIssueCommentReactionsResponse, ListIssueCommentsResponse,\n    ListIssueFollowersResponse, ListIssueRelationshipsResponse, ListIssueTagsResponse,\n    ListIssuesResponse, ListProjectStatusesResponse, ListProjectsResponse,\n    ListPullRequestsResponse, ListTagsResponse, Notification, OrganizationMember,\n    SearchIssuesRequest, User, Workspace,\n};\nuse axum::{\n    Json,\n    extract::{Extension, Query, State},\n    http::StatusCode,\n};\nuse serde::Serialize;\n\nuse crate::{\n    AppState,\n    auth::RequestContext,\n    db::{\n        issue_assignees::IssueAssigneeRepository,\n        issue_comment_reactions::IssueCommentReactionRepository,\n        issue_comments::IssueCommentRepository, issue_followers::IssueFollowerRepository,\n        issue_relationships::IssueRelationshipRepository, issue_tags::IssueTagRepository,\n        issues::IssueRepository, notifications::NotificationRepository, organization_members,\n        project_statuses::ProjectStatusRepository, projects::ProjectRepository,\n        pull_requests::PullRequestRepository, tags::TagRepository, workspaces::WorkspaceRepository,\n    },\n    routes::{\n        error::ErrorResponse,\n        organization_members::{ensure_issue_access, ensure_member_access, ensure_project_access},\n    },\n    shape_route::{\n        IssueFallbackQuery, NoQueryParams, OrgFallbackQuery, ProjectFallbackQuery, ShapeRoute,\n        ShapeScope,\n    },\n    shapes,\n};\n\n// =============================================================================\n// Response types not defined in api-types (field name must match shape table)\n// =============================================================================\n\n#[derive(Debug, Serialize)]\nstruct ListNotificationsResponse {\n    notifications: Vec<Notification>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ListOrganizationMembersResponse {\n    organization_member_metadata: Vec<OrganizationMember>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ListUsersResponse {\n    users: Vec<User>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ListWorkspacesResponse {\n    workspaces: Vec<Workspace>,\n}\n\n// =============================================================================\n// Shape route registration\n// =============================================================================\n\n/// All shape routes: built and type-erased.\n///\n/// This is the single source of truth for shape registration.\npub fn all_shape_routes() -> Vec<ShapeRoute> {\n    vec![\n        // Organization-scoped\n        ShapeRoute::new(\n            &shapes::PROJECTS_SHAPE,\n            ShapeScope::Org,\n            \"/fallback/projects\",\n            fallback_list_projects,\n        ),\n        ShapeRoute::new(\n            &shapes::NOTIFICATIONS_SHAPE,\n            ShapeScope::User,\n            \"/fallback/notifications\",\n            fallback_list_notifications,\n        ),\n        ShapeRoute::new(\n            &shapes::ORGANIZATION_MEMBERS_SHAPE,\n            ShapeScope::Org,\n            \"/fallback/organization_members\",\n            fallback_list_organization_members,\n        ),\n        ShapeRoute::new(\n            &shapes::USERS_SHAPE,\n            ShapeScope::Org,\n            \"/fallback/users\",\n            fallback_list_users,\n        ),\n        // Project-scoped\n        ShapeRoute::new(\n            &shapes::PROJECT_TAGS_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/tags\",\n            fallback_list_tags,\n        ),\n        ShapeRoute::new(\n            &shapes::PROJECT_PROJECT_STATUSES_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/project_statuses\",\n            fallback_list_project_statuses,\n        ),\n        ShapeRoute::new(\n            &shapes::PROJECT_ISSUES_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/issues\",\n            fallback_list_issues,\n        ),\n        ShapeRoute::new(\n            &shapes::USER_WORKSPACES_SHAPE,\n            ShapeScope::User,\n            \"/fallback/user_workspaces\",\n            fallback_list_user_workspaces,\n        ),\n        ShapeRoute::new(\n            &shapes::PROJECT_WORKSPACES_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/project_workspaces\",\n            fallback_list_project_workspaces,\n        ),\n        // Project-scoped issue-related\n        ShapeRoute::new(\n            &shapes::PROJECT_ISSUE_ASSIGNEES_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/issue_assignees\",\n            fallback_list_issue_assignees,\n        ),\n        ShapeRoute::new(\n            &shapes::PROJECT_ISSUE_FOLLOWERS_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/issue_followers\",\n            fallback_list_issue_followers,\n        ),\n        ShapeRoute::new(\n            &shapes::PROJECT_ISSUE_TAGS_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/issue_tags\",\n            fallback_list_issue_tags,\n        ),\n        ShapeRoute::new(\n            &shapes::PROJECT_ISSUE_RELATIONSHIPS_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/issue_relationships\",\n            fallback_list_issue_relationships,\n        ),\n        ShapeRoute::new(\n            &shapes::PROJECT_PULL_REQUESTS_SHAPE,\n            ShapeScope::Project,\n            \"/fallback/pull_requests\",\n            fallback_list_pull_requests,\n        ),\n        // Issue-scoped\n        ShapeRoute::new(\n            &shapes::ISSUE_COMMENTS_SHAPE,\n            ShapeScope::Issue,\n            \"/fallback/issue_comments\",\n            fallback_list_issue_comments,\n        ),\n        ShapeRoute::new(\n            &shapes::ISSUE_REACTIONS_SHAPE,\n            ShapeScope::Issue,\n            \"/fallback/issue_comment_reactions\",\n            fallback_list_issue_comment_reactions,\n        ),\n    ]\n}\n\n// =============================================================================\n// Org-scoped fallback handlers\n// =============================================================================\n\nasync fn fallback_list_projects(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<OrgFallbackQuery>,\n) -> Result<Json<ListProjectsResponse>, ErrorResponse> {\n    ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?;\n\n    let projects = ProjectRepository::list_by_organization(state.pool(), query.organization_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, organization_id = %query.organization_id, \"failed to list projects (fallback)\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list projects\")\n        })?;\n\n    Ok(Json(ListProjectsResponse { projects }))\n}\n\nasync fn fallback_list_notifications(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(_query): Query<NoQueryParams>,\n) -> Result<Json<ListNotificationsResponse>, ErrorResponse> {\n    let notifications = NotificationRepository::list_by_user(state.pool(), ctx.user.id, true)\n        .await\n        .map_err(|error| {\n            tracing::error!(\n                ?error,\n                user_id = %ctx.user.id,\n                \"failed to list notifications (fallback)\"\n            );\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list notifications\",\n            )\n        })?;\n\n    Ok(Json(ListNotificationsResponse { notifications }))\n}\n\nasync fn fallback_list_organization_members(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<OrgFallbackQuery>,\n) -> Result<Json<ListOrganizationMembersResponse>, ErrorResponse> {\n    ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?;\n\n    let organization_member_metadata =\n        organization_members::list_by_organization(state.pool(), query.organization_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, organization_id = %query.organization_id, \"failed to list organization members (fallback)\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list organization members\",\n                )\n            })?;\n\n    Ok(Json(ListOrganizationMembersResponse {\n        organization_member_metadata,\n    }))\n}\n\nasync fn fallback_list_users(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<OrgFallbackQuery>,\n) -> Result<Json<ListUsersResponse>, ErrorResponse> {\n    ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?;\n\n    let users =\n        organization_members::list_users_by_organization(state.pool(), query.organization_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, organization_id = %query.organization_id, \"failed to list users (fallback)\");\n                ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list users\")\n            })?;\n\n    Ok(Json(ListUsersResponse { users }))\n}\n\n// =============================================================================\n// Project-scoped fallback handlers\n// =============================================================================\n\nasync fn fallback_list_tags(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListTagsResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let tags = TagRepository::list_by_project(state.pool(), query.project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %query.project_id, \"failed to list tags (fallback)\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list tags\")\n        })?;\n\n    Ok(Json(ListTagsResponse { tags }))\n}\n\nasync fn fallback_list_project_statuses(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListProjectStatusesResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let project_statuses =\n        ProjectStatusRepository::list_by_project(state.pool(), query.project_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, project_id = %query.project_id, \"failed to list project statuses (fallback)\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list project statuses\",\n                )\n            })?;\n\n    Ok(Json(ListProjectStatusesResponse { project_statuses }))\n}\n\nasync fn fallback_list_issues(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListIssuesResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let response = IssueRepository::search(\n        state.pool(),\n        &SearchIssuesRequest {\n            project_id: query.project_id,\n            status_id: None,\n            status_ids: None,\n            priority: None,\n            parent_issue_id: None,\n            search: None,\n            simple_id: None,\n            assignee_user_id: None,\n            tag_id: None,\n            tag_ids: None,\n            sort_field: None,\n            sort_direction: None,\n            limit: None,\n            offset: None,\n        },\n    )\n    .await\n    .map_err(|error| {\n        tracing::error!(?error, project_id = %query.project_id, \"failed to list issues (fallback)\");\n        ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list issues\")\n    })?;\n\n    Ok(Json(response))\n}\n\nasync fn fallback_list_project_workspaces(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListWorkspacesResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let workspaces = WorkspaceRepository::list_by_project(state.pool(), query.project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %query.project_id, \"failed to list workspaces (fallback)\");\n            ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, \"failed to list workspaces\")\n        })?;\n\n    Ok(Json(ListWorkspacesResponse { workspaces }))\n}\n\nasync fn fallback_list_issue_assignees(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListIssueAssigneesResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let issue_assignees =\n        IssueAssigneeRepository::list_by_project(state.pool(), query.project_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, project_id = %query.project_id, \"failed to list issue assignees (fallback)\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list issue assignees\",\n                )\n            })?;\n\n    Ok(Json(ListIssueAssigneesResponse { issue_assignees }))\n}\n\nasync fn fallback_list_issue_followers(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListIssueFollowersResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let issue_followers =\n        IssueFollowerRepository::list_by_project(state.pool(), query.project_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, project_id = %query.project_id, \"failed to list issue followers (fallback)\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list issue followers\",\n                )\n            })?;\n\n    Ok(Json(ListIssueFollowersResponse { issue_followers }))\n}\n\nasync fn fallback_list_issue_tags(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListIssueTagsResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let issue_tags = IssueTagRepository::list_by_project(state.pool(), query.project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %query.project_id, \"failed to list issue tags (fallback)\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list issue tags\",\n            )\n        })?;\n\n    Ok(Json(ListIssueTagsResponse { issue_tags }))\n}\n\nasync fn fallback_list_issue_relationships(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListIssueRelationshipsResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let issue_relationships =\n        IssueRelationshipRepository::list_by_project(state.pool(), query.project_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, project_id = %query.project_id, \"failed to list issue relationships (fallback)\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list issue relationships\",\n                )\n            })?;\n\n    Ok(Json(ListIssueRelationshipsResponse {\n        issue_relationships,\n    }))\n}\n\nasync fn fallback_list_pull_requests(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<ProjectFallbackQuery>,\n) -> Result<Json<ListPullRequestsResponse>, ErrorResponse> {\n    ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?;\n\n    let pull_requests = PullRequestRepository::list_by_project(state.pool(), query.project_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, project_id = %query.project_id, \"failed to list pull requests (fallback)\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list pull requests\",\n            )\n        })?;\n\n    Ok(Json(ListPullRequestsResponse { pull_requests }))\n}\n\n// =============================================================================\n// User-scoped fallback handlers\n// =============================================================================\n\nasync fn fallback_list_user_workspaces(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(_): Query<NoQueryParams>,\n) -> Result<Json<ListWorkspacesResponse>, ErrorResponse> {\n    let workspaces = WorkspaceRepository::list_by_owner(state.pool(), ctx.user.id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, user_id = %ctx.user.id, \"failed to list user workspaces (fallback)\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list workspaces\",\n            )\n        })?;\n\n    Ok(Json(ListWorkspacesResponse { workspaces }))\n}\n\n// =============================================================================\n// Issue-scoped fallback handlers\n// =============================================================================\n\nasync fn fallback_list_issue_comments(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<IssueFallbackQuery>,\n) -> Result<Json<ListIssueCommentsResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let issue_comments = IssueCommentRepository::list_by_issue(state.pool(), query.issue_id)\n        .await\n        .map_err(|error| {\n            tracing::error!(?error, issue_id = %query.issue_id, \"failed to list issue comments (fallback)\");\n            ErrorResponse::new(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"failed to list issue comments\",\n            )\n        })?;\n\n    Ok(Json(ListIssueCommentsResponse { issue_comments }))\n}\n\nasync fn fallback_list_issue_comment_reactions(\n    State(state): State<AppState>,\n    Extension(ctx): Extension<RequestContext>,\n    Query(query): Query<IssueFallbackQuery>,\n) -> Result<Json<ListIssueCommentReactionsResponse>, ErrorResponse> {\n    ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?;\n\n    let issue_comment_reactions =\n        IssueCommentReactionRepository::list_by_issue(state.pool(), query.issue_id)\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, issue_id = %query.issue_id, \"failed to list issue comment reactions (fallback)\");\n                ErrorResponse::new(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"failed to list issue comment reactions\",\n                )\n            })?;\n\n    Ok(Json(ListIssueCommentReactionsResponse {\n        issue_comment_reactions,\n    }))\n}\n"
  },
  {
    "path": "crates/remote/src/shapes.rs",
    "content": "//! All shape constant instances for realtime streaming.\n\nuse api_types::{\n    Issue, IssueAssignee, IssueComment, IssueCommentReaction, IssueFollower, IssueRelationship,\n    IssueTag, Notification, OrganizationMember, Project, ProjectStatus, PullRequest, Tag, User,\n    Workspace,\n};\n\nuse crate::shape_definition::ShapeDefinition;\n\n// =============================================================================\n// Organization-scoped shapes\n// =============================================================================\n\npub const PROJECTS_SHAPE: ShapeDefinition<Project> = crate::define_shape!(\n    name: \"PROJECTS_SHAPE\",\n    table: \"projects\",\n    where_clause: r#\"\"organization_id\" = $1\"#,\n    url: \"/shape/projects\",\n    params: [\"organization_id\"],\n);\n\npub const NOTIFICATIONS_SHAPE: ShapeDefinition<Notification> = crate::define_shape!(\n    name: \"NOTIFICATIONS_SHAPE\",\n    table: \"notifications\",\n    where_clause: r#\"\"user_id\" = $1\"#,\n    url: \"/shape/notifications\",\n    params: [\"user_id\"],\n);\n\npub const ORGANIZATION_MEMBERS_SHAPE: ShapeDefinition<OrganizationMember> = crate::define_shape!(\n    name: \"ORGANIZATION_MEMBERS_SHAPE\",\n    table: \"organization_member_metadata\",\n    where_clause: r#\"\"organization_id\" = $1\"#,\n    url: \"/shape/organization_members\",\n    params: [\"organization_id\"],\n);\n\npub const USERS_SHAPE: ShapeDefinition<User> = crate::define_shape!(\n    name: \"USERS_SHAPE\",\n    table: \"users\",\n    where_clause: r#\"\"id\" IN (SELECT user_id FROM organization_member_metadata WHERE \"organization_id\" = $1)\"#,\n    url: \"/shape/users\",\n    params: [\"organization_id\"],\n);\n\n// =============================================================================\n// Project-scoped shapes\n// =============================================================================\n\npub const PROJECT_TAGS_SHAPE: ShapeDefinition<Tag> = crate::define_shape!(\n    name: \"PROJECT_TAGS_SHAPE\",\n    table: \"tags\",\n    where_clause: r#\"\"project_id\" = $1\"#,\n    url: \"/shape/project/{project_id}/tags\",\n    params: [\"project_id\"],\n);\n\npub const PROJECT_PROJECT_STATUSES_SHAPE: ShapeDefinition<ProjectStatus> = crate::define_shape!(\n    name: \"PROJECT_PROJECT_STATUSES_SHAPE\",\n    table: \"project_statuses\",\n    where_clause: r#\"\"project_id\" = $1\"#,\n    url: \"/shape/project/{project_id}/project_statuses\",\n    params: [\"project_id\"],\n);\n\npub const PROJECT_ISSUES_SHAPE: ShapeDefinition<Issue> = crate::define_shape!(\n    name: \"PROJECT_ISSUES_SHAPE\",\n    table: \"issues\",\n    where_clause: r#\"\"project_id\" = $1\"#,\n    url: \"/shape/project/{project_id}/issues\",\n    params: [\"project_id\"],\n);\n\npub const USER_WORKSPACES_SHAPE: ShapeDefinition<Workspace> = crate::define_shape!(\n    name: \"USER_WORKSPACES_SHAPE\",\n    table: \"workspaces\",\n    where_clause: r#\"\"owner_user_id\" = $1\"#,\n    url: \"/shape/user/workspaces\",\n    params: [\"owner_user_id\"],\n);\n\npub const PROJECT_WORKSPACES_SHAPE: ShapeDefinition<Workspace> = crate::define_shape!(\n    name: \"PROJECT_WORKSPACES_SHAPE\",\n    table: \"workspaces\",\n    where_clause: r#\"\"project_id\" = $1\"#,\n    url: \"/shape/project/{project_id}/workspaces\",\n    params: [\"project_id\"],\n);\n\n// =============================================================================\n// Issue-related shapes (streamed at project level)\n// =============================================================================\n\npub const PROJECT_ISSUE_ASSIGNEES_SHAPE: ShapeDefinition<IssueAssignee> = crate::define_shape!(\n    name: \"PROJECT_ISSUE_ASSIGNEES_SHAPE\",\n    table: \"issue_assignees\",\n    where_clause: r#\"\"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)\"#,\n    url: \"/shape/project/{project_id}/issue_assignees\",\n    params: [\"project_id\"],\n);\n\npub const PROJECT_ISSUE_FOLLOWERS_SHAPE: ShapeDefinition<IssueFollower> = crate::define_shape!(\n    name: \"PROJECT_ISSUE_FOLLOWERS_SHAPE\",\n    table: \"issue_followers\",\n    where_clause: r#\"\"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)\"#,\n    url: \"/shape/project/{project_id}/issue_followers\",\n    params: [\"project_id\"],\n);\n\npub const PROJECT_ISSUE_TAGS_SHAPE: ShapeDefinition<IssueTag> = crate::define_shape!(\n    name: \"PROJECT_ISSUE_TAGS_SHAPE\",\n    table: \"issue_tags\",\n    where_clause: r#\"\"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)\"#,\n    url: \"/shape/project/{project_id}/issue_tags\",\n    params: [\"project_id\"],\n);\n\npub const PROJECT_ISSUE_RELATIONSHIPS_SHAPE: ShapeDefinition<IssueRelationship> = crate::define_shape!(\n    name: \"PROJECT_ISSUE_RELATIONSHIPS_SHAPE\",\n    table: \"issue_relationships\",\n    where_clause: r#\"\"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)\"#,\n    url: \"/shape/project/{project_id}/issue_relationships\",\n    params: [\"project_id\"],\n);\n\npub const PROJECT_PULL_REQUESTS_SHAPE: ShapeDefinition<PullRequest> = crate::define_shape!(\n    name: \"PROJECT_PULL_REQUESTS_SHAPE\",\n    table: \"pull_requests\",\n    where_clause: r#\"\"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)\"#,\n    url: \"/shape/project/{project_id}/pull_requests\",\n    params: [\"project_id\"],\n);\n\n// =============================================================================\n// Issue-scoped shapes\n// =============================================================================\n\npub const ISSUE_COMMENTS_SHAPE: ShapeDefinition<IssueComment> = crate::define_shape!(\n    name: \"ISSUE_COMMENTS_SHAPE\",\n    table: \"issue_comments\",\n    where_clause: r#\"\"issue_id\" = $1\"#,\n    url: \"/shape/issue/{issue_id}/comments\",\n    params: [\"issue_id\"],\n);\n\npub const ISSUE_REACTIONS_SHAPE: ShapeDefinition<IssueCommentReaction> = crate::define_shape!(\n    name: \"ISSUE_REACTIONS_SHAPE\",\n    table: \"issue_comment_reactions\",\n    where_clause: r#\"\"comment_id\" IN (SELECT id FROM issue_comments WHERE \"issue_id\" = $1)\"#,\n    url: \"/shape/issue/{issue_id}/reactions\",\n    params: [\"issue_id\"],\n);\n"
  },
  {
    "path": "crates/remote/src/shared_key_auth.rs",
    "content": "// SharedKey authorization policy for connecting to Azurite (local Azure Storage emulator).\n// Only used for local development — production uses Entra ID.\n// Based on: https://github.com/Azure/azure-sdk-for-rust/issues/2975#issuecomment-3538764202\n\nuse std::{borrow::Cow, sync::Arc};\n\nuse async_trait::async_trait;\nuse azure_core::{\n    credentials::Secret,\n    http::{\n        Context, Method, Request, Url,\n        headers::{CONTENT_LENGTH, HeaderName, Headers},\n        policies::{Policy, PolicyResult},\n    },\n};\nuse base64::prelude::*;\nuse hmac::{Hmac, Mac};\nuse sha2::Sha256;\n\n#[derive(Debug)]\npub struct SharedKeyAuthorizationPolicy {\n    pub account: String,\n    pub key: Secret,\n}\n\n#[async_trait]\nimpl Policy for SharedKeyAuthorizationPolicy {\n    async fn send(\n        &self,\n        ctx: &Context,\n        request: &mut Request,\n        next: &[Arc<dyn Policy>],\n    ) -> PolicyResult {\n        let auth = generate_authorization(\n            request.headers(),\n            request.url(),\n            &request.method(),\n            &self.account,\n            &self.key,\n        );\n        request.insert_header(\"authorization\", auth);\n        next[0].send(ctx, request, &next[1..]).await\n    }\n}\n\nfn generate_authorization(\n    h: &Headers,\n    u: &Url,\n    method: &Method,\n    account: &str,\n    key: &Secret,\n) -> String {\n    let str_to_sign = string_to_sign(account, h, u, method);\n    let auth = hmac_sha256(&str_to_sign, key);\n    format!(\"SharedKey {account}:{auth}\")\n}\n\nfn string_to_sign(account: &str, h: &Headers, u: &Url, method: &Method) -> String {\n    let content_length = h\n        .get_optional_str(&CONTENT_LENGTH)\n        .filter(|&v| v != \"0\")\n        .unwrap_or_default();\n    format!(\n        \"{}\\n{}\\n{}\\n{}\\n{}\\n{}\\n{}\\n{}\\n{}\\n{}\\n{}\\n{}\\n{}{}\",\n        method.as_ref(),\n        add_if_exists(h, &HeaderName::from_static(\"content-encoding\")),\n        add_if_exists(h, &HeaderName::from_static(\"content-language\")),\n        content_length,\n        add_if_exists(h, &HeaderName::from_static(\"content-md5\")),\n        add_if_exists(h, &HeaderName::from_static(\"content-type\")),\n        add_if_exists(h, &HeaderName::from_static(\"date\")),\n        add_if_exists(h, &HeaderName::from_static(\"if-modified-since\")),\n        add_if_exists(h, &HeaderName::from_static(\"if-match\")),\n        add_if_exists(h, &HeaderName::from_static(\"if-none-match\")),\n        add_if_exists(h, &HeaderName::from_static(\"if-unmodified-since\")),\n        add_if_exists(h, &HeaderName::from_static(\"byte_range\")),\n        canonicalize_header(h),\n        canonicalized_resource(account, u),\n    )\n}\n\n#[inline]\nfn add_if_exists<'a>(h: &'a Headers, key: &HeaderName) -> &'a str {\n    h.get_optional_str(key).unwrap_or(\"\")\n}\n\nfn canonicalize_header(headers: &Headers) -> String {\n    let mut names: Vec<_> = headers\n        .iter()\n        .filter_map(|(k, _)| k.as_str().starts_with(\"x-ms\").then_some(k))\n        .collect();\n    names.sort_unstable();\n\n    let mut result = String::new();\n    for header_name in names {\n        let value = headers.get_optional_str(header_name).unwrap();\n        let name = header_name.as_str();\n        result = format!(\"{result}{name}:{value}\\n\");\n    }\n    result\n}\n\nfn lexy_sort<'a>(\n    pairs: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)>,\n    query_param: &str,\n) -> Vec<Cow<'a, str>> {\n    let mut values: Vec<_> = pairs\n        .filter(|(k, _)| *k == query_param)\n        .map(|(_, v)| v)\n        .collect();\n    values.sort_unstable();\n    values\n}\n\nfn canonicalized_resource(account: &str, uri: &Url) -> String {\n    let mut can_res = String::new();\n    can_res.push('/');\n    can_res.push_str(account);\n\n    for p in uri.path_segments().into_iter().flatten() {\n        can_res.push('/');\n        can_res.push_str(p);\n    }\n    can_res.push('\\n');\n\n    let query_pairs: Vec<_> = uri.query_pairs().collect();\n    let mut qps = Vec::new();\n    for (q, _) in query_pairs {\n        if !qps.iter().any(|x: &String| x == &*q) {\n            qps.push(q.into_owned());\n        }\n    }\n    qps.sort();\n\n    for qparam in &qps {\n        let ret = lexy_sort(uri.query_pairs(), qparam);\n        can_res.push_str(&qparam.to_lowercase());\n        can_res.push(':');\n        for (i, item) in ret.iter().enumerate() {\n            if i > 0 {\n                can_res.push(',');\n            }\n            can_res.push_str(item);\n        }\n        can_res.push('\\n');\n    }\n\n    can_res[..can_res.len() - 1].to_owned()\n}\n\npub fn hmac_sha256(data: &str, key: &Secret) -> String {\n    let key = BASE64_STANDARD.decode(key.secret()).unwrap();\n    let mut hmac = Hmac::<Sha256>::new_from_slice(&key).unwrap();\n    hmac.update(data.as_bytes());\n    BASE64_STANDARD.encode(hmac.finalize().into_bytes())\n}\n"
  },
  {
    "path": "crates/remote/src/state.rs",
    "content": "use std::sync::Arc;\n\nuse sqlx::PgPool;\n\nuse crate::{\n    analytics::AnalyticsService,\n    auth::{JwtService, OAuthHandoffService, OAuthTokenValidator, ProviderRegistry},\n    azure_blob::AzureBlobService,\n    billing::BillingService,\n    config::RemoteServerConfig,\n    github_app::GitHubAppService,\n    mail::Mailer,\n    r2::R2Service,\n};\n\n#[derive(Clone)]\npub struct AppState {\n    pub pool: PgPool,\n    pub config: RemoteServerConfig,\n    pub jwt: Arc<JwtService>,\n    pub mailer: Arc<dyn Mailer>,\n    pub server_public_base_url: String,\n    pub http_client: reqwest::Client,\n    handoff: Arc<OAuthHandoffService>,\n    oauth_token_validator: Arc<OAuthTokenValidator>,\n    r2: Option<R2Service>,\n    azure_blob: Option<AzureBlobService>,\n    github_app: Option<Arc<GitHubAppService>>,\n    billing: BillingService,\n    analytics: Option<AnalyticsService>,\n}\n\nimpl AppState {\n    #[allow(clippy::too_many_arguments)]\n    pub fn new(\n        pool: PgPool,\n        config: RemoteServerConfig,\n        jwt: Arc<JwtService>,\n        handoff: Arc<OAuthHandoffService>,\n        oauth_token_validator: Arc<OAuthTokenValidator>,\n        mailer: Arc<dyn Mailer>,\n        server_public_base_url: String,\n        http_client: reqwest::Client,\n        r2: Option<R2Service>,\n        azure_blob: Option<AzureBlobService>,\n        github_app: Option<Arc<GitHubAppService>>,\n        billing: BillingService,\n        analytics: Option<AnalyticsService>,\n    ) -> Self {\n        Self {\n            pool,\n            config,\n            jwt,\n            mailer,\n            server_public_base_url,\n            http_client,\n            handoff,\n            oauth_token_validator,\n            r2,\n            azure_blob,\n            github_app,\n            billing,\n            analytics,\n        }\n    }\n\n    pub fn pool(&self) -> &PgPool {\n        &self.pool\n    }\n\n    pub fn config(&self) -> &RemoteServerConfig {\n        &self.config\n    }\n\n    pub fn jwt(&self) -> Arc<JwtService> {\n        Arc::clone(&self.jwt)\n    }\n\n    pub fn handoff(&self) -> Arc<OAuthHandoffService> {\n        Arc::clone(&self.handoff)\n    }\n\n    pub fn providers(&self) -> Arc<ProviderRegistry> {\n        self.handoff.providers()\n    }\n\n    pub fn oauth_token_validator(&self) -> Arc<OAuthTokenValidator> {\n        Arc::clone(&self.oauth_token_validator)\n    }\n\n    pub fn r2(&self) -> Option<&R2Service> {\n        self.r2.as_ref()\n    }\n\n    pub fn azure_blob(&self) -> Option<&AzureBlobService> {\n        self.azure_blob.as_ref()\n    }\n\n    pub fn github_app(&self) -> Option<&GitHubAppService> {\n        self.github_app.as_deref()\n    }\n\n    pub fn billing(&self) -> &BillingService {\n        &self.billing\n    }\n\n    pub fn analytics(&self) -> Option<&AnalyticsService> {\n        self.analytics.as_ref()\n    }\n}\n"
  },
  {
    "path": "crates/review/Cargo.toml",
    "content": "[package]\nname = \"review\"\nversion = \"0.1.33\"\nedition = \"2024\"\npublish = false\n\n[[bin]]\nname = \"review\"\npath = \"src/main.rs\"\n\n[dependencies]\nclap = { version = \"4\", features = [\"derive\", \"env\"] }\ntokio = { workspace = true }\nreqwest = { version = \"0.13\", default-features = false, features = [\"json\", \"stream\", \"rustls\"] }\nrustls = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\ntar = \"0.4\"\nflate2 = \"1.0\"\nindicatif = \"0.17\"\nanyhow = { workspace = true }\nthiserror = { workspace = true }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\ntempfile = \"3.8\"\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\nutils = { path = \"../utils\" }\ndialoguer = \"0.11\"\ndirs = \"5.0\"\ntoml = \"0.8\"\n"
  },
  {
    "path": "crates/review/src/api.rs",
    "content": "use reqwest::Client;\nuse serde::{Deserialize, Serialize};\nuse tracing::debug;\nuse uuid::Uuid;\n\nuse crate::error::ReviewError;\n\n/// API client for the review service\npub struct ReviewApiClient {\n    client: Client,\n    base_url: String,\n}\n\n/// Response from POST /review/init\n#[derive(Debug, Deserialize)]\npub struct InitResponse {\n    pub review_id: Uuid,\n    pub upload_url: String,\n    pub object_key: String,\n}\n\n/// Request body for POST /review/init\n#[derive(Debug, Serialize)]\nstruct InitRequest {\n    gh_pr_url: String,\n    email: String,\n    pr_title: String,\n}\n\n/// Request body for POST /review/start\n#[derive(Debug, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct StartRequest {\n    pub id: String,\n    pub title: String,\n    pub description: String,\n    pub org: String,\n    pub repo: String,\n    pub codebase_url: String,\n    pub base_commit: String,\n}\n\n/// Response from GET /review/{id}/status\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct StatusResponse {\n    pub status: ReviewStatus,\n    pub progress: Option<String>,\n    pub error: Option<String>,\n}\n\n/// Possible review statuses\n#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ReviewStatus {\n    Queued,\n    Extracting,\n    Running,\n    Completed,\n    Failed,\n}\n\nimpl std::fmt::Display for ReviewStatus {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            ReviewStatus::Queued => write!(f, \"queued\"),\n            ReviewStatus::Extracting => write!(f, \"extracting\"),\n            ReviewStatus::Running => write!(f, \"running\"),\n            ReviewStatus::Completed => write!(f, \"completed\"),\n            ReviewStatus::Failed => write!(f, \"failed\"),\n        }\n    }\n}\n\nimpl ReviewApiClient {\n    /// Create a new API client\n    pub fn new(base_url: String) -> Self {\n        Self {\n            client: Client::new(),\n            base_url,\n        }\n    }\n\n    /// Initialize a review upload and get a presigned URL\n    pub async fn init(\n        &self,\n        pr_url: &str,\n        email: &str,\n        pr_title: &str,\n    ) -> Result<InitResponse, ReviewError> {\n        let url = format!(\"{}/v1/review/init\", self.base_url);\n        debug!(\"POST {url}\");\n\n        let response = self\n            .client\n            .post(&url)\n            .json(&InitRequest {\n                gh_pr_url: pr_url.to_string(),\n                email: email.to_string(),\n                pr_title: pr_title.to_string(),\n            })\n            .send()\n            .await\n            .map_err(|e| ReviewError::ApiError(e.to_string()))?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let body = response\n                .text()\n                .await\n                .unwrap_or_else(|_| \"Unknown error\".to_string());\n            return Err(ReviewError::ApiError(format!(\"{status}: {body}\")));\n        }\n\n        let init_response: InitResponse = response\n            .json()\n            .await\n            .map_err(|e| ReviewError::ApiError(e.to_string()))?;\n\n        debug!(\"Review ID: {}\", init_response.review_id);\n\n        Ok(init_response)\n    }\n\n    /// Upload the tarball to the presigned URL\n    pub async fn upload(&self, upload_url: &str, payload: Vec<u8>) -> Result<(), ReviewError> {\n        debug!(\"PUT {} ({} bytes)\", upload_url, payload.len());\n\n        let response = self\n            .client\n            .put(upload_url)\n            .header(\"Content-Type\", \"application/gzip\")\n            .body(payload)\n            .send()\n            .await\n            .map_err(|e| ReviewError::UploadFailed(e.to_string()))?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let body = response\n                .text()\n                .await\n                .unwrap_or_else(|_| \"Unknown error\".to_string());\n            return Err(ReviewError::UploadFailed(format!(\"{status}: {body}\")));\n        }\n\n        Ok(())\n    }\n\n    /// Start the review process\n    pub async fn start(&self, request: StartRequest) -> Result<(), ReviewError> {\n        let url = format!(\"{}/v1/review/start\", self.base_url);\n        debug!(\"POST {url}\");\n\n        let response = self\n            .client\n            .post(&url)\n            .json(&request)\n            .send()\n            .await\n            .map_err(|e| ReviewError::ApiError(e.to_string()))?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let body = response\n                .text()\n                .await\n                .unwrap_or_else(|_| \"Unknown error\".to_string());\n            return Err(ReviewError::ApiError(format!(\"{status}: {body}\")));\n        }\n\n        Ok(())\n    }\n\n    /// Poll the review status\n    pub async fn poll_status(&self, review_id: &str) -> Result<StatusResponse, ReviewError> {\n        let url = format!(\"{}/v1/review/{}/status\", self.base_url, review_id);\n        debug!(\"GET {url}\");\n\n        let response = self\n            .client\n            .get(&url)\n            .send()\n            .await\n            .map_err(|e| ReviewError::ApiError(e.to_string()))?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let body = response\n                .text()\n                .await\n                .unwrap_or_else(|_| \"Unknown error\".to_string());\n            return Err(ReviewError::ApiError(format!(\"{status}: {body}\")));\n        }\n\n        let status_response: StatusResponse = response\n            .json()\n            .await\n            .map_err(|e| ReviewError::ApiError(e.to_string()))?;\n\n        Ok(status_response)\n    }\n\n    /// Get the review URL for a given review ID\n    pub fn review_url(&self, review_id: &str) -> String {\n        format!(\"{}/review/{}\", self.base_url, review_id)\n    }\n}\n"
  },
  {
    "path": "crates/review/src/archive.rs",
    "content": "use std::{fs::File, path::Path};\n\nuse flate2::{Compression, write::GzEncoder};\nuse tar::Builder;\nuse tracing::debug;\n\nuse crate::error::ReviewError;\n\n/// Create a tar.gz archive from a directory\npub fn create_tarball(source_dir: &Path) -> Result<Vec<u8>, ReviewError> {\n    debug!(\"Creating tarball from {}\", source_dir.display());\n\n    let mut buffer = Vec::new();\n\n    {\n        let encoder = GzEncoder::new(&mut buffer, Compression::default());\n        let mut archive = Builder::new(encoder);\n\n        add_directory_to_archive(&mut archive, source_dir, source_dir)?;\n\n        let encoder = archive\n            .into_inner()\n            .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n        encoder\n            .finish()\n            .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n    }\n\n    debug!(\"Created tarball: {} bytes\", buffer.len());\n\n    Ok(buffer)\n}\n\nfn add_directory_to_archive<W: std::io::Write>(\n    archive: &mut Builder<W>,\n    base_dir: &Path,\n    current_dir: &Path,\n) -> Result<(), ReviewError> {\n    let entries =\n        std::fs::read_dir(current_dir).map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n\n    for entry in entries {\n        let entry = entry.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n        let path = entry.path();\n\n        let relative_path = path\n            .strip_prefix(base_dir)\n            .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n\n        let metadata = entry\n            .metadata()\n            .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n\n        if metadata.is_dir() {\n            // Recursively add directory contents\n            add_directory_to_archive(archive, base_dir, &path)?;\n        } else if metadata.is_file() {\n            // Add file to archive\n            let mut file =\n                File::open(&path).map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n            archive\n                .append_file(relative_path, &mut file)\n                .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n        }\n        // Skip symlinks and other special files\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n\n    use tempfile::TempDir;\n\n    use super::*;\n\n    #[test]\n    fn test_create_tarball() {\n        let temp_dir = TempDir::new().unwrap();\n        let base = temp_dir.path();\n\n        // Create some test files\n        std::fs::write(base.join(\"file1.txt\"), \"content1\").unwrap();\n        std::fs::create_dir(base.join(\"subdir\")).unwrap();\n        std::fs::write(base.join(\"subdir/file2.txt\"), \"content2\").unwrap();\n\n        let tarball = create_tarball(base).expect(\"Should create tarball\");\n\n        // Verify tarball is not empty\n        assert!(!tarball.is_empty());\n\n        // Decompress and verify contents\n        let decoder = flate2::read::GzDecoder::new(&tarball[..]);\n        let mut archive = tar::Archive::new(decoder);\n\n        let entries: Vec<_> = archive\n            .entries()\n            .unwrap()\n            .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())\n            .collect();\n\n        assert!(entries.contains(&\"file1.txt\".to_string()));\n        assert!(entries.contains(&\"subdir/file2.txt\".to_string()));\n    }\n}\n"
  },
  {
    "path": "crates/review/src/claude_session.rs",
    "content": "use std::{\n    fs::{self, File},\n    io::{BufRead, BufReader},\n    path::{Path, PathBuf},\n    time::SystemTime,\n};\n\nuse serde::Deserialize;\nuse tracing::debug;\n\nuse crate::error::ReviewError;\n\n/// Represents a Claude Code project directory\n#[derive(Debug, Clone)]\npub struct ClaudeProject {\n    pub path: PathBuf,\n    pub name: String,\n    pub git_branch: Option<String>,\n    pub first_prompt: Option<String>,\n    pub session_count: usize,\n    pub modified_at: SystemTime,\n}\n\n/// Represents a single session file within a project\n#[derive(Debug, Clone)]\npub struct ClaudeSession {\n    pub path: PathBuf,\n    pub git_branch: Option<String>,\n    pub first_prompt: Option<String>,\n    pub modified_at: SystemTime,\n}\n\n/// A JSONL record for metadata extraction\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct JsonlRecord {\n    git_branch: Option<String>,\n    message: Option<JsonlMessage>,\n}\n\n/// Message within a JSONL record\n#[derive(Debug, Deserialize)]\nstruct JsonlMessage {\n    role: Option<String>,\n    content: Option<serde_json::Value>,\n}\n\n/// Get the Claude projects directory path (~/.claude/projects)\npub fn get_claude_projects_dir() -> Option<PathBuf> {\n    dirs::home_dir().map(|home| home.join(\".claude\").join(\"projects\"))\n}\n\n/// Discover all Claude projects, sorted by modification time (most recent first)\n/// Aggregates session metadata (git_branch, first_prompt, session_count) from each project's sessions\npub fn discover_projects() -> Result<Vec<ClaudeProject>, ReviewError> {\n    let projects_dir = get_claude_projects_dir().ok_or_else(|| {\n        ReviewError::SessionDiscoveryFailed(\"Could not find home directory\".into())\n    })?;\n\n    if !projects_dir.exists() {\n        debug!(\n            \"Claude projects directory does not exist: {:?}\",\n            projects_dir\n        );\n        return Ok(Vec::new());\n    }\n\n    let mut projects = Vec::new();\n\n    let entries = fs::read_dir(&projects_dir)\n        .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n\n    for entry in entries {\n        let entry = entry.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n        let path = entry.path();\n\n        if !path.is_dir() {\n            continue;\n        }\n\n        let metadata = entry\n            .metadata()\n            .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n\n        let modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);\n\n        // Extract a friendly name from the directory name\n        // e.g., \"-private-var-...-worktrees-a04a-store-payloads-i\" -> \"store-payloads-i\"\n        let dir_name = path\n            .file_name()\n            .and_then(|n| n.to_str())\n            .unwrap_or(\"unknown\");\n\n        let name = extract_project_name(dir_name);\n\n        // Discover sessions to get aggregated metadata\n        let sessions = discover_sessions_in_dir(&path)?;\n        let session_count = sessions.len();\n\n        // Skip projects with no sessions\n        if session_count == 0 {\n            continue;\n        }\n\n        // Get metadata from the most recent session\n        let most_recent = &sessions[0]; // Already sorted by modification time\n        let git_branch = most_recent.git_branch.clone();\n        let first_prompt = most_recent.first_prompt.clone();\n\n        projects.push(ClaudeProject {\n            path,\n            name,\n            git_branch,\n            first_prompt,\n            session_count,\n            modified_at,\n        });\n    }\n\n    // Sort by modification time, most recent first\n    projects.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));\n\n    Ok(projects)\n}\n\n/// Extract a friendly project name from the Claude directory name\nfn extract_project_name(dir_name: &str) -> String {\n    // Directory names look like:\n    // \"-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-a04a-store-payloads-i\"\n    // We want to extract the meaningful part after \"worktrees-\"\n    if let Some(idx) = dir_name.find(\"worktrees-\") {\n        let after_worktrees = &dir_name[idx + \"worktrees-\".len()..];\n        // Skip the short hash prefix (e.g., \"a04a-\")\n        if let Some(dash_idx) = after_worktrees.find('-') {\n            return after_worktrees[dash_idx + 1..].to_string();\n        }\n        return after_worktrees.to_string();\n    }\n\n    // Fallback: use last segment after the final dash\n    dir_name.rsplit('-').next().unwrap_or(dir_name).to_string()\n}\n\n/// Discover sessions in a project, excluding agent-* files\npub fn discover_sessions(project: &ClaudeProject) -> Result<Vec<ClaudeSession>, ReviewError> {\n    discover_sessions_in_dir(&project.path)\n}\n\n/// Discover sessions in a directory, excluding agent-* files\nfn discover_sessions_in_dir(dir_path: &Path) -> Result<Vec<ClaudeSession>, ReviewError> {\n    let mut sessions = Vec::new();\n\n    let entries =\n        fs::read_dir(dir_path).map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n\n    for entry in entries {\n        let entry = entry.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n        let path = entry.path();\n\n        // Only process .jsonl files\n        if path.extension().and_then(|e| e.to_str()) != Some(\"jsonl\") {\n            continue;\n        }\n\n        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(\"\");\n\n        // Skip agent-* files\n        if file_name.starts_with(\"agent-\") {\n            continue;\n        }\n\n        let metadata = entry\n            .metadata()\n            .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n\n        let modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);\n\n        // Extract metadata from the JSONL file\n        let (git_branch, first_prompt) = extract_session_metadata(&path);\n\n        sessions.push(ClaudeSession {\n            path,\n            git_branch,\n            first_prompt,\n            modified_at,\n        });\n    }\n\n    // Sort by modification time, most recent first\n    sessions.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));\n\n    Ok(sessions)\n}\n\n/// Extract session metadata from a JSONL file\n/// Returns: (git_branch, first_prompt)\nfn extract_session_metadata(path: &Path) -> (Option<String>, Option<String>) {\n    let file = match File::open(path) {\n        Ok(f) => f,\n        Err(_) => return (None, None),\n    };\n    let reader = BufReader::new(file);\n\n    let mut git_branch: Option<String> = None;\n    let mut first_prompt: Option<String> = None;\n\n    // Check first 50 lines for metadata\n    for line in reader.lines().take(50) {\n        let line = match line {\n            Ok(l) => l,\n            Err(_) => continue,\n        };\n        if line.trim().is_empty() {\n            continue;\n        }\n\n        if let Ok(record) = serde_json::from_str::<JsonlRecord>(&line) {\n            // Extract git branch if not already found\n            if git_branch.is_none() && record.git_branch.is_some() {\n                git_branch = record.git_branch;\n            }\n\n            // Extract first user prompt if not already found\n            if first_prompt.is_none()\n                && let Some(ref message) = record.message\n                && message.role.as_deref() == Some(\"user\")\n                && let Some(ref content) = message.content\n            {\n                // Content can be a string or an array\n                if let Some(text) = content.as_str() {\n                    first_prompt = Some(truncate_string(text, 60));\n                }\n            }\n\n            // Stop early if we have both\n            if git_branch.is_some() && first_prompt.is_some() {\n                break;\n            }\n        }\n    }\n\n    (git_branch, first_prompt)\n}\n\n/// Truncate a string to max length, adding \"...\" if truncated\nfn truncate_string(s: &str, max_len: usize) -> String {\n    // Replace newlines with spaces for display\n    let s = s.replace('\\n', \" \");\n    if s.len() <= max_len {\n        s\n    } else {\n        format!(\"{}...\", &s[..max_len - 3])\n    }\n}\n\n/// Find projects matching a specific git branch using fuzzy matching\n/// Returns matching projects with all their sessions\npub fn find_projects_by_branch(\n    projects: &[ClaudeProject],\n    target_branch: &str,\n) -> Result<Vec<(ClaudeProject, Vec<ClaudeSession>)>, ReviewError> {\n    let mut matches = Vec::new();\n\n    for project in projects {\n        // Check if project's branch matches\n        if let Some(ref project_branch) = project.git_branch\n            && branches_match(target_branch, project_branch)\n        {\n            let sessions = discover_sessions(project)?;\n            matches.push((project.clone(), sessions));\n        }\n    }\n\n    // Sort by modification time, most recent first\n    matches.sort_by(|a, b| b.0.modified_at.cmp(&a.0.modified_at));\n\n    Ok(matches)\n}\n\n/// Check if two branch names match using fuzzy matching\nfn branches_match(target: &str, session_branch: &str) -> bool {\n    let target_normalized = normalize_branch(target);\n    let session_normalized = normalize_branch(session_branch);\n\n    // Exact match after normalization\n    if target_normalized == session_normalized {\n        return true;\n    }\n\n    // Check if the slug portions match (e.g., \"feature-auth\" matches \"vk/feature-auth\")\n    let target_slug = extract_branch_slug(&target_normalized);\n    let session_slug = extract_branch_slug(&session_normalized);\n\n    target_slug == session_slug && !target_slug.is_empty()\n}\n\n/// Normalize a branch name by stripping common prefixes\nfn normalize_branch(branch: &str) -> String {\n    let branch = branch.strip_prefix(\"refs/heads/\").unwrap_or(branch);\n\n    branch.to_lowercase()\n}\n\n/// Extract the \"slug\" portion of a branch name\n/// e.g., \"vk/a04a-store-payloads-i\" -> \"a04a-store-payloads-i\"\nfn extract_branch_slug(branch: &str) -> String {\n    // Split by '/' and take the last part\n    branch.rsplit('/').next().unwrap_or(branch).to_string()\n}\n\n/// A record with timestamp for sorting\nstruct TimestampedMessage {\n    timestamp: String,\n    message: serde_json::Value,\n}\n\n/// Concatenate multiple JSONL files into a single JSON array of messages.\n///\n/// Filters to include only:\n/// - User messages (role = \"user\")\n/// - Assistant messages with text content (role = \"assistant\" with content[].type = \"text\")\n///\n/// For assistant messages, only text content blocks are kept (tool_use, etc. are filtered out).\npub fn concatenate_sessions_to_json(session_paths: &[PathBuf]) -> Result<String, ReviewError> {\n    let mut all_messages: Vec<TimestampedMessage> = Vec::new();\n\n    for path in session_paths {\n        let file = File::open(path)\n            .map_err(|e| ReviewError::JsonlParseFailed(format!(\"{}: {}\", path.display(), e)))?;\n        let reader = BufReader::new(file);\n\n        for (line_num, line) in reader.lines().enumerate() {\n            let line = line.map_err(|e| {\n                ReviewError::JsonlParseFailed(format!(\"{}:{}: {}\", path.display(), line_num + 1, e))\n            })?;\n\n            if line.trim().is_empty() {\n                continue;\n            }\n\n            let record: serde_json::Value = serde_json::from_str(&line).map_err(|e| {\n                ReviewError::JsonlParseFailed(format!(\"{}:{}: {}\", path.display(), line_num + 1, e))\n            })?;\n\n            // Extract timestamp for sorting\n            let timestamp = record\n                .get(\"timestamp\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"\")\n                .to_string();\n\n            // Extract and filter the message\n            if let Some(message) = extract_filtered_message(&record) {\n                all_messages.push(TimestampedMessage { timestamp, message });\n            }\n        }\n    }\n\n    // Sort by timestamp\n    all_messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));\n\n    // Extract just the messages\n    let messages: Vec<serde_json::Value> = all_messages.into_iter().map(|m| m.message).collect();\n\n    serde_json::to_string(&messages).map_err(|e| ReviewError::JsonlParseFailed(e.to_string()))\n}\n\n/// Extract and filter a message from a JSONL record.\n///\n/// Returns Some(message) if the record should be included, None otherwise.\n/// - User messages: include if content is a string, or if content array has text blocks\n/// - Assistant messages: include if content array has text blocks (filter out tool_use, etc.)\nfn extract_filtered_message(record: &serde_json::Value) -> Option<serde_json::Value> {\n    let message = record.get(\"message\")?;\n    let role = message.get(\"role\")?.as_str()?;\n    let content = message.get(\"content\")?;\n\n    match role {\n        \"user\" => {\n            // If content is a string, include directly\n            if content.is_string() {\n                return Some(message.clone());\n            }\n\n            // If content is an array, filter to text blocks only\n            if let Some(content_array) = content.as_array() {\n                let text_blocks: Vec<serde_json::Value> = content_array\n                    .iter()\n                    .filter(|block| block.get(\"type\").and_then(|t| t.as_str()) == Some(\"text\"))\n                    .cloned()\n                    .collect();\n\n                // Skip if no text content (e.g., only tool_result)\n                if text_blocks.is_empty() {\n                    return None;\n                }\n\n                // Create filtered message with only text content\n                let mut filtered_message = serde_json::Map::new();\n                filtered_message.insert(\n                    \"role\".to_string(),\n                    serde_json::Value::String(\"user\".to_string()),\n                );\n                filtered_message\n                    .insert(\"content\".to_string(), serde_json::Value::Array(text_blocks));\n\n                return Some(serde_json::Value::Object(filtered_message));\n            }\n\n            None\n        }\n        \"assistant\" => {\n            // Filter assistant messages to only include text content\n            if let Some(content_array) = content.as_array() {\n                // Filter to only text blocks\n                let text_blocks: Vec<serde_json::Value> = content_array\n                    .iter()\n                    .filter(|block| block.get(\"type\").and_then(|t| t.as_str()) == Some(\"text\"))\n                    .cloned()\n                    .collect();\n\n                // Skip if no text content\n                if text_blocks.is_empty() {\n                    return None;\n                }\n\n                // Create filtered message with only text content\n                let mut filtered_message = serde_json::Map::new();\n                filtered_message.insert(\n                    \"role\".to_string(),\n                    serde_json::Value::String(\"assistant\".to_string()),\n                );\n                filtered_message\n                    .insert(\"content\".to_string(), serde_json::Value::Array(text_blocks));\n\n                Some(serde_json::Value::Object(filtered_message))\n            } else {\n                // Content is not an array (unusual), skip\n                None\n            }\n        }\n        _ => None,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_extract_project_name() {\n        assert_eq!(\n            extract_project_name(\n                \"-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-a04a-store-payloads-i\"\n            ),\n            \"store-payloads-i\"\n        );\n\n        assert_eq!(\n            extract_project_name(\n                \"-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-1ff1-new-rust-binary\"\n            ),\n            \"new-rust-binary\"\n        );\n    }\n\n    #[test]\n    fn test_branches_match() {\n        // Exact match\n        assert!(branches_match(\"feature-auth\", \"feature-auth\"));\n\n        // With prefix\n        assert!(branches_match(\"feature-auth\", \"vk/feature-auth\"));\n        assert!(branches_match(\"vk/feature-auth\", \"feature-auth\"));\n\n        // Slug matching\n        assert!(branches_match(\n            \"a04a-store-payloads-i\",\n            \"vk/a04a-store-payloads-i\"\n        ));\n\n        // Case insensitive\n        assert!(branches_match(\"Feature-Auth\", \"feature-auth\"));\n\n        // Non-matches\n        assert!(!branches_match(\"feature-auth\", \"feature-other\"));\n        assert!(!branches_match(\"main\", \"feature-auth\"));\n\n        // Regression tests: substring matches should NOT match\n        // (these were incorrectly matching before the fix)\n        assert!(!branches_match(\"vk/d13f-remove-compare-c\", \"c\"));\n        assert!(!branches_match(\"vk/d13f-remove-compare-c\", \"compare\"));\n        assert!(!branches_match(\"feature-auth\", \"auth\"));\n        assert!(!branches_match(\"feature-auth\", \"feature\"));\n    }\n\n    #[test]\n    fn test_normalize_branch() {\n        assert_eq!(normalize_branch(\"refs/heads/main\"), \"main\");\n        assert_eq!(normalize_branch(\"Feature-Auth\"), \"feature-auth\");\n        assert_eq!(normalize_branch(\"vk/feature-auth\"), \"vk/feature-auth\");\n    }\n\n    #[test]\n    fn test_extract_branch_slug() {\n        assert_eq!(extract_branch_slug(\"vk/feature-auth\"), \"feature-auth\");\n        assert_eq!(extract_branch_slug(\"feature-auth\"), \"feature-auth\");\n        assert_eq!(\n            extract_branch_slug(\"user/prefix/feature-auth\"),\n            \"feature-auth\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/review/src/config.rs",
    "content": "use std::path::PathBuf;\n\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Default, Serialize, Deserialize)]\npub struct Config {\n    #[serde(default)]\n    pub email: Option<String>,\n}\n\nimpl Config {\n    /// Get the path to the config file (~/.config/vibe-kanban/review.toml)\n    fn config_path() -> Option<PathBuf> {\n        dirs::config_dir().map(|p| p.join(\"vibe-kanban\").join(\"review.toml\"))\n    }\n\n    /// Load config from disk, returning default if file doesn't exist\n    pub fn load() -> Self {\n        let Some(path) = Self::config_path() else {\n            return Self::default();\n        };\n\n        if !path.exists() {\n            return Self::default();\n        }\n\n        match std::fs::read_to_string(&path) {\n            Ok(contents) => toml::from_str(&contents).unwrap_or_default(),\n            Err(_) => Self::default(),\n        }\n    }\n\n    /// Save config to disk\n    pub fn save(&self) -> std::io::Result<()> {\n        let Some(path) = Self::config_path() else {\n            return Ok(());\n        };\n\n        // Create parent directories if needed\n        if let Some(parent) = path.parent() {\n            std::fs::create_dir_all(parent)?;\n        }\n\n        let contents = toml::to_string_pretty(self).unwrap_or_default();\n        std::fs::write(&path, contents)\n    }\n}\n"
  },
  {
    "path": "crates/review/src/error.rs",
    "content": "use thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum ReviewError {\n    #[error(\"GitHub CLI (gh) is not installed. Install it from https://cli.github.com/\")]\n    GhNotInstalled,\n\n    #[error(\"GitHub CLI is not authenticated. Run 'gh auth login' first.\")]\n    GhNotAuthenticated,\n\n    #[error(\"Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123\")]\n    InvalidPrUrl,\n\n    #[error(\"Failed to get PR information: {0}\")]\n    PrInfoFailed(String),\n\n    #[error(\"Failed to clone repository: {0}\")]\n    CloneFailed(String),\n\n    #[error(\"Failed to checkout PR: {0}\")]\n    CheckoutFailed(String),\n\n    #[error(\"Failed to create archive: {0}\")]\n    ArchiveFailed(String),\n\n    #[error(\"API request failed: {0}\")]\n    ApiError(String),\n\n    #[error(\"Upload failed: {0}\")]\n    UploadFailed(String),\n\n    #[error(\"Review failed: {0}\")]\n    ReviewFailed(String),\n\n    #[error(\"Review timed out after 10 minutes\")]\n    Timeout,\n\n    #[error(\"Failed to discover Claude Code sessions: {0}\")]\n    SessionDiscoveryFailed(String),\n\n    #[error(\"Failed to parse JSONL file: {0}\")]\n    JsonlParseFailed(String),\n}\n"
  },
  {
    "path": "crates/review/src/github.rs",
    "content": "use std::{path::Path, process::Command};\n\nuse serde::Deserialize;\nuse tracing::debug;\nuse utils::command_ext::NoWindowExt;\n\nuse crate::error::ReviewError;\n\n/// Information about a pull request\n#[derive(Debug)]\npub struct PrInfo {\n    pub owner: String,\n    pub repo: String,\n    pub title: String,\n    pub description: String,\n    pub base_commit: String,\n    pub head_commit: String,\n    pub head_ref_name: String,\n}\n\n/// Response from `gh pr view --json`\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct GhPrView {\n    title: String,\n    body: String,\n    base_ref_oid: String,\n    head_ref_oid: String,\n    head_ref_name: String,\n}\n\n/// Response from `gh api /repos/{owner}/{repo}/pulls/{number}`\n/// Used as fallback for older gh CLI versions that don't support baseRefOid/headRefOid fields\n#[derive(Debug, Deserialize)]\nstruct GhApiPr {\n    title: String,\n    body: Option<String>,\n    base: GhApiRef,\n    head: GhApiRef,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GhApiRef {\n    sha: String,\n    #[serde(rename = \"ref\")]\n    ref_name: String,\n}\n\n/// Parse a GitHub PR URL to extract owner, repo, and PR number\n///\n/// Expected format: https://github.com/owner/repo/pull/123\npub fn parse_pr_url(url: &str) -> Result<(String, String, i64), ReviewError> {\n    let url = url.trim();\n\n    // Remove trailing slashes\n    let url = url.trim_end_matches('/');\n\n    // Try to parse as URL\n    let parts: Vec<&str> = url.split('/').collect();\n\n    // Find the index of \"github.com\" and then extract owner/repo/pull/number\n    let github_idx = parts\n        .iter()\n        .position(|&p| p == \"github.com\")\n        .ok_or(ReviewError::InvalidPrUrl)?;\n\n    // We need at least: github.com / owner / repo / pull / number\n    if parts.len() < github_idx + 5 {\n        return Err(ReviewError::InvalidPrUrl);\n    }\n\n    let owner = parts[github_idx + 1].to_string();\n    let repo = parts[github_idx + 2].to_string();\n\n    if parts[github_idx + 3] != \"pull\" {\n        return Err(ReviewError::InvalidPrUrl);\n    }\n\n    let pr_number: i64 = parts[github_idx + 4]\n        .parse()\n        .map_err(|_| ReviewError::InvalidPrUrl)?;\n\n    if owner.is_empty() || repo.is_empty() || pr_number <= 0 {\n        return Err(ReviewError::InvalidPrUrl);\n    }\n\n    Ok((owner, repo, pr_number))\n}\n\n/// Check if the GitHub CLI is installed\nfn ensure_gh_available() -> Result<(), ReviewError> {\n    let output = Command::new(\"which\")\n        .arg(\"gh\")\n        .no_window()\n        .output()\n        .map_err(|_| ReviewError::GhNotInstalled)?;\n\n    if !output.status.success() {\n        return Err(ReviewError::GhNotInstalled);\n    }\n\n    Ok(())\n}\n\n/// Get PR information using `gh api` (REST API)\n/// This is used as a fallback for older gh CLI versions that don't support\n/// the baseRefOid/headRefOid fields in `gh pr view --json`\nfn get_pr_info_via_api(owner: &str, repo: &str, pr_number: i64) -> Result<PrInfo, ReviewError> {\n    debug!(\"Fetching PR info via gh api for {owner}/{repo}#{pr_number}\");\n\n    let output = Command::new(\"gh\")\n        .args([\"api\", &format!(\"repos/{owner}/{repo}/pulls/{pr_number}\")])\n        .no_window()\n        .output()\n        .map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        let lower = stderr.to_ascii_lowercase();\n\n        if lower.contains(\"authentication\")\n            || lower.contains(\"gh auth login\")\n            || lower.contains(\"unauthorized\")\n        {\n            return Err(ReviewError::GhNotAuthenticated);\n        }\n\n        return Err(ReviewError::PrInfoFailed(stderr.to_string()));\n    }\n\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    let api_pr: GhApiPr =\n        serde_json::from_str(&stdout).map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?;\n\n    Ok(PrInfo {\n        owner: owner.to_string(),\n        repo: repo.to_string(),\n        title: api_pr.title,\n        description: api_pr.body.unwrap_or_default(),\n        base_commit: api_pr.base.sha,\n        head_commit: api_pr.head.sha,\n        head_ref_name: api_pr.head.ref_name,\n    })\n}\n\n/// Get PR information using `gh pr view`\npub fn get_pr_info(owner: &str, repo: &str, pr_number: i64) -> Result<PrInfo, ReviewError> {\n    ensure_gh_available()?;\n\n    debug!(\"Fetching PR info for {owner}/{repo}#{pr_number}\");\n\n    let output = Command::new(\"gh\")\n        .args([\n            \"pr\",\n            \"view\",\n            &pr_number.to_string(),\n            \"--repo\",\n            &format!(\"{owner}/{repo}\"),\n            \"--json\",\n            \"title,body,baseRefOid,headRefOid,headRefName\",\n        ])\n        .no_window()\n        .output()\n        .map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        let lower = stderr.to_ascii_lowercase();\n\n        // Check for old gh CLI version that doesn't support these JSON fields\n        if lower.contains(\"unknown json field\") {\n            debug!(\"gh pr view --json failed with unknown field, falling back to gh api\");\n            return get_pr_info_via_api(owner, repo, pr_number);\n        }\n\n        if lower.contains(\"authentication\")\n            || lower.contains(\"gh auth login\")\n            || lower.contains(\"unauthorized\")\n        {\n            return Err(ReviewError::GhNotAuthenticated);\n        }\n\n        return Err(ReviewError::PrInfoFailed(stderr.to_string()));\n    }\n\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    let pr_view: GhPrView =\n        serde_json::from_str(&stdout).map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?;\n\n    Ok(PrInfo {\n        owner: owner.to_string(),\n        repo: repo.to_string(),\n        title: pr_view.title,\n        description: pr_view.body,\n        base_commit: pr_view.base_ref_oid,\n        head_commit: pr_view.head_ref_oid,\n        head_ref_name: pr_view.head_ref_name,\n    })\n}\n\n/// Clone a repository using `gh repo clone`\npub fn clone_repo(owner: &str, repo: &str, target_dir: &Path) -> Result<(), ReviewError> {\n    ensure_gh_available()?;\n\n    debug!(\"Cloning {owner}/{repo} to {}\", target_dir.display());\n\n    let output = Command::new(\"gh\")\n        .args([\n            \"repo\",\n            \"clone\",\n            &format!(\"{owner}/{repo}\"),\n            target_dir\n                .to_str()\n                .ok_or_else(|| ReviewError::CloneFailed(\"Invalid target path\".to_string()))?,\n        ])\n        .no_window()\n        .output()\n        .map_err(|e| ReviewError::CloneFailed(e.to_string()))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(ReviewError::CloneFailed(stderr.to_string()));\n    }\n\n    Ok(())\n}\n\n/// Checkout a specific commit by SHA\n///\n/// This is more reliable than `gh pr checkout` because it works even when\n/// the PR's branch has been deleted (common for merged PRs).\npub fn checkout_commit(commit_sha: &str, repo_dir: &Path) -> Result<(), ReviewError> {\n    debug!(\"Fetching commit {commit_sha} in {}\", repo_dir.display());\n\n    // First, fetch the specific commit\n    let output = Command::new(\"git\")\n        .args([\"fetch\", \"origin\", commit_sha])\n        .current_dir(repo_dir)\n        .no_window()\n        .output()\n        .map_err(|e| ReviewError::CheckoutFailed(e.to_string()))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(ReviewError::CheckoutFailed(format!(\n            \"Failed to fetch commit: {stderr}\"\n        )));\n    }\n\n    debug!(\"Checking out commit {commit_sha}\");\n\n    // Then checkout the commit\n    let output = Command::new(\"git\")\n        .args([\"checkout\", commit_sha])\n        .current_dir(repo_dir)\n        .no_window()\n        .output()\n        .map_err(|e| ReviewError::CheckoutFailed(e.to_string()))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(ReviewError::CheckoutFailed(format!(\n            \"Failed to checkout commit: {stderr}\"\n        )));\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_pr_url_valid() {\n        let (owner, repo, pr) = parse_pr_url(\"https://github.com/anthropics/claude-code/pull/123\")\n            .expect(\"Should parse valid URL\");\n        assert_eq!(owner, \"anthropics\");\n        assert_eq!(repo, \"claude-code\");\n        assert_eq!(pr, 123);\n    }\n\n    #[test]\n    fn test_parse_pr_url_with_trailing_slash() {\n        let (owner, repo, pr) =\n            parse_pr_url(\"https://github.com/owner/repo/pull/456/\").expect(\"Should parse\");\n        assert_eq!(owner, \"owner\");\n        assert_eq!(repo, \"repo\");\n        assert_eq!(pr, 456);\n    }\n\n    #[test]\n    fn test_parse_pr_url_invalid_format() {\n        assert!(parse_pr_url(\"https://github.com/owner/repo\").is_err());\n        assert!(parse_pr_url(\"https://github.com/owner/repo/issues/123\").is_err());\n        assert!(parse_pr_url(\"https://gitlab.com/owner/repo/pull/123\").is_err());\n        assert!(parse_pr_url(\"not a url\").is_err());\n    }\n}\n"
  },
  {
    "path": "crates/review/src/main.rs",
    "content": "mod api;\nmod archive;\nmod claude_session;\nmod config;\nmod error;\nmod github;\nmod session_selector;\n\nuse std::time::Duration;\n\nuse anyhow::Result;\nuse api::{ReviewApiClient, ReviewStatus, StartRequest};\nuse clap::Parser;\nuse error::ReviewError;\nuse github::{checkout_commit, clone_repo, get_pr_info, parse_pr_url};\nuse indicatif::{ProgressBar, ProgressStyle};\nuse tempfile::TempDir;\nuse tracing::debug;\nuse tracing_subscriber::EnvFilter;\n\nconst DEFAULT_API_URL: &str = \"https://api.vibekanban.com\";\nconst POLL_INTERVAL: Duration = Duration::from_secs(10);\nconst TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes\n\nconst BANNER: &str = r#\"\n██████╗ ███████╗██╗   ██╗██╗███████╗██╗    ██╗   ███████╗ █████╗ ███████╗████████╗\n██╔══██╗██╔════╝██║   ██║██║██╔════╝██║    ██║   ██╔════╝██╔══██╗██╔════╝╚══██╔══╝\n██████╔╝█████╗  ██║   ██║██║█████╗  ██║ █╗ ██║   █████╗  ███████║███████╗   ██║   \n██╔══██╗██╔══╝  ╚██╗ ██╔╝██║██╔══╝  ██║███╗██║   ██╔══╝  ██╔══██║╚════██║   ██║   \n██║  ██║███████╗ ╚████╔╝ ██║███████╗╚███╔███╔╝██╗██║     ██║  ██║███████║   ██║   \n╚═╝  ╚═╝╚══════╝  ╚═══╝  ╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝╚═╝     ╚═╝  ╚═╝╚══════╝   ╚═╝   \n\n\"#;\n\n#[derive(Parser, Debug)]\n#[command(name = \"review\")]\n#[command(\n    about = \"Vibe-Kanban Review helps you review GitHub pull requests by turning them into a clear, story-driven summary instead of a wall of diffs. You provide a pull request URL, optionally link a Claude Code project for additional context, and it builds a narrative that highlights key events and important decisions, helping you prioritise what actually needs attention. It's particularly useful when reviewing large amounts of AI-generated code. Note that code is uploaded to and processed on Vibe-Kanban servers using AI.\"\n)]\n#[command(version)]\nstruct Args {\n    /// GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)\n    pr_url: String,\n\n    /// Enable verbose output\n    #[arg(short, long, default_value_t = false)]\n    verbose: bool,\n\n    /// API base URL\n    #[arg(long, env = \"REVIEW_API_URL\", default_value = DEFAULT_API_URL)]\n    api_url: String,\n}\n\nfn show_disclaimer() {\n    println!();\n    println!(\n        \"DISCLAIMER: Your code will be processed on our secure remote servers, all artefacts (code, AI logs, etc...) will be deleted after 14 days.\"\n    );\n    println!();\n    println!(\"Full terms and conditions and privacy policy: https://review.fast/terms\");\n    println!();\n    println!(\"Press Enter to accept and continue...\");\n\n    let mut input = String::new();\n    std::io::stdin().read_line(&mut input).ok();\n}\n\nfn prompt_email(config: &mut config::Config) -> String {\n    use dialoguer::Input;\n\n    let mut input: Input<String> =\n        Input::new().with_prompt(\"Email address (we'll send a link to the review here, no spam)\");\n\n    if let Some(ref saved_email) = config.email {\n        input = input.default(saved_email.clone());\n    }\n\n    let email: String = input.interact_text().expect(\"Failed to read email\");\n\n    // Save email for next time\n    config.email = Some(email.clone());\n    if let Err(e) = config.save() {\n        debug!(\"Failed to save config: {}\", e);\n    }\n\n    email\n}\n\nfn create_spinner(message: &str) -> ProgressBar {\n    let spinner = ProgressBar::new_spinner();\n    spinner.set_style(\n        ProgressStyle::default_spinner()\n            .template(\"{spinner:.green} {msg}\")\n            .expect(\"Invalid spinner template\"),\n    );\n    spinner.set_message(message.to_string());\n    spinner.enable_steady_tick(Duration::from_millis(100));\n    spinner\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n    // Install rustls crypto provider before any TLS operations\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .expect(\"Failed to install rustls crypto provider\");\n\n    let args = Args::parse();\n\n    // Initialize tracing\n    let filter = if args.verbose {\n        EnvFilter::new(\"debug\")\n    } else {\n        EnvFilter::new(\"warn\")\n    };\n    tracing_subscriber::fmt().with_env_filter(filter).init();\n\n    println!(\"{}\", BANNER);\n\n    show_disclaimer();\n\n    debug!(\"Args: {:?}\", args);\n\n    // Run the main flow and handle errors\n    if let Err(e) = run(args).await {\n        eprintln!(\"Error: {e}\");\n        std::process::exit(1);\n    }\n\n    Ok(())\n}\n\nasync fn run(args: Args) -> Result<(), ReviewError> {\n    // 1. Load config and prompt for email\n    let mut config = config::Config::load();\n    let email = prompt_email(&mut config);\n\n    // 2. Parse PR URL\n    let spinner = create_spinner(\"Parsing PR URL...\");\n    let (owner, repo, pr_number) = parse_pr_url(&args.pr_url)?;\n    spinner.finish_with_message(format!(\"PR: {owner}/{repo}#{pr_number}\"));\n\n    // 3. Get PR info\n    let spinner = create_spinner(\"Fetching PR information...\");\n    let pr_info = get_pr_info(&owner, &repo, pr_number)?;\n    spinner.finish_with_message(format!(\"PR: {}\", pr_info.title));\n\n    // 4. Select Claude Code session (optional)\n    let session_files = match session_selector::select_session(&pr_info.head_ref_name) {\n        Ok(session_selector::SessionSelection::Selected(files)) => {\n            println!(\"  Selected {} session file(s)\", files.len());\n            Some(files)\n        }\n        Ok(session_selector::SessionSelection::Skipped) => {\n            println!(\"  Skipping project attachment\");\n            None\n        }\n        Err(e) => {\n            debug!(\"Session selection error: {}\", e);\n            println!(\"  No sessions found\");\n            None\n        }\n    };\n\n    // 5. Clone repository to temp directory\n    let temp_dir = TempDir::new().map_err(|e| ReviewError::CloneFailed(e.to_string()))?;\n    let repo_dir = temp_dir.path().join(&repo);\n\n    let spinner = create_spinner(\"Cloning repository...\");\n    clone_repo(&owner, &repo, &repo_dir)?;\n    spinner.finish_with_message(\"Repository cloned\");\n\n    // 6. Checkout PR head commit\n    let spinner = create_spinner(\"Checking out PR...\");\n    checkout_commit(&pr_info.head_commit, &repo_dir)?;\n    spinner.finish_with_message(\"PR checked out\");\n\n    // 7. Create tarball (with optional session data)\n    let spinner = create_spinner(\"Creating archive...\");\n\n    // If sessions were selected, write .agent-messages.json to repo root\n    if let Some(ref files) = session_files {\n        let json_content = claude_session::concatenate_sessions_to_json(files)?;\n        let agent_messages_path = repo_dir.join(\".agent-messages.json\");\n        std::fs::write(&agent_messages_path, json_content)\n            .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;\n    }\n\n    let payload = archive::create_tarball(&repo_dir)?;\n    let size_mb = payload.len() as f64 / 1_048_576.0;\n    spinner.finish_with_message(format!(\"Archive created ({size_mb:.2} MB)\"));\n\n    // 8. Initialize review\n    let client = ReviewApiClient::new(args.api_url.clone());\n    let spinner = create_spinner(\"Initializing review...\");\n    let init_response = client.init(&args.pr_url, &email, &pr_info.title).await?;\n    spinner.finish_with_message(format!(\"Review ID: {}\", init_response.review_id));\n\n    // 9. Upload archive\n    let spinner = create_spinner(\"Uploading archive...\");\n    client.upload(&init_response.upload_url, payload).await?;\n    spinner.finish_with_message(\"Upload complete\");\n\n    // 10. Start review\n    let spinner = create_spinner(\"Starting review...\");\n    let codebase_url = format!(\"r2://{}\", init_response.object_key);\n    client\n        .start(StartRequest {\n            id: init_response.review_id.to_string(),\n            title: pr_info.title,\n            description: pr_info.description,\n            org: pr_info.owner,\n            repo: pr_info.repo,\n            codebase_url,\n            base_commit: pr_info.base_commit,\n        })\n        .await?;\n    spinner.finish_with_message(format!(\"Review started, we'll send you an email at {} when the review is ready. This can take a few minutes, you may now close the terminal\", email));\n\n    // 11. Poll for completion\n    let spinner = create_spinner(\"Review in progress...\");\n    let start_time = std::time::Instant::now();\n\n    loop {\n        tokio::time::sleep(POLL_INTERVAL).await;\n\n        // Check for timeout\n        if start_time.elapsed() > TIMEOUT {\n            spinner.finish_with_message(\"Timed out\");\n            return Err(ReviewError::Timeout);\n        }\n\n        let status = client\n            .poll_status(&init_response.review_id.to_string())\n            .await?;\n\n        match status.status {\n            ReviewStatus::Completed => {\n                spinner.finish_with_message(\"Review completed!\");\n                break;\n            }\n            ReviewStatus::Failed => {\n                spinner.finish_with_message(\"Review failed\");\n                let error_msg = status.error.unwrap_or_else(|| \"Unknown error\".to_string());\n                return Err(ReviewError::ReviewFailed(error_msg));\n            }\n            _ => {\n                let progress = status.progress.unwrap_or_else(|| status.status.to_string());\n                spinner.set_message(format!(\"Review in progress: {progress}\"));\n            }\n        }\n    }\n\n    // 12. Print result URL\n    let review_url = client.review_url(&init_response.review_id.to_string());\n    println!(\"\\nReview available at:\");\n    println!(\"  {review_url}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/review/src/session_selector.rs",
    "content": "use std::{path::PathBuf, time::SystemTime};\n\nuse dialoguer::{Select, theme::ColorfulTheme};\nuse tracing::debug;\n\nuse crate::{\n    claude_session::{\n        ClaudeProject, discover_projects, discover_sessions, find_projects_by_branch,\n    },\n    error::ReviewError,\n};\n\n/// Result of session selection process\npub enum SessionSelection {\n    /// User selected session files to include (all sessions from a project)\n    Selected(Vec<PathBuf>),\n    /// User chose to skip session attachment\n    Skipped,\n}\n\n/// Prompt user to select a Claude Code project\n///\n/// Flow:\n/// 1. Try auto-match by branch name\n/// 2. If match found, confirm with user\n/// 3. If no match or user declines, show scrollable project list\n/// 4. Allow user to skip entirely\n///\n/// When a project is selected, ALL sessions from that project are included.\npub fn select_session(pr_branch: &str) -> Result<SessionSelection, ReviewError> {\n    debug!(\n        \"Looking for Claude Code projects matching branch: {}\",\n        pr_branch\n    );\n\n    let projects = discover_projects()?;\n\n    if projects.is_empty() {\n        debug!(\"No Claude Code projects found\");\n        return Ok(SessionSelection::Skipped);\n    }\n\n    // Try auto-match by branch\n    let matches = find_projects_by_branch(&projects, pr_branch)?;\n\n    if !matches.is_empty() {\n        // Found a matching project, ask for confirmation\n        let (project, sessions) = &matches[0];\n\n        println!();\n        println!();\n        println!(\n            \"Found matching Claude Code project for branch '{}'\",\n            pr_branch\n        );\n        println!(\"  Project: {}\", project.name);\n        if let Some(ref prompt) = project.first_prompt {\n            println!(\"  \\\"{}\\\"\", prompt);\n        }\n        println!(\n            \"  {} session{} · Last modified: {}\",\n            project.session_count,\n            if project.session_count == 1 { \"\" } else { \"s\" },\n            format_time_ago(project.modified_at)\n        );\n        println!();\n\n        let selection = Select::with_theme(&ColorfulTheme::default())\n            .with_prompt(\"Use this project to improve review quality?\")\n            .items(&[\n                \"Yes, use this project\",\n                \"No, choose a different project\",\n                \"Skip (generate review from just code changes)\",\n            ])\n            .default(0)\n            .interact()\n            .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n\n        match selection {\n            0 => {\n                // Yes, use all sessions from this project\n                let paths: Vec<PathBuf> = sessions.iter().map(|s| s.path.clone()).collect();\n                return Ok(SessionSelection::Selected(paths));\n            }\n            2 => {\n                // Skip\n                return Ok(SessionSelection::Skipped);\n            }\n            _ => {\n                // Fall through to manual selection\n            }\n        }\n    }\n\n    // Manual selection: select a project\n    select_project(&projects)\n}\n\n/// Manual project selection - returns all sessions from selected project\nfn select_project(projects: &[ClaudeProject]) -> Result<SessionSelection, ReviewError> {\n    // Build project list with rich metadata\n    let mut items: Vec<String> = Vec::new();\n    items.push(\"Skip (no project)\\n\".to_string());\n    items.extend(projects.iter().map(format_project_item));\n    items.push(\"Skip (no project)\\n\".to_string());\n\n    println!();\n    println!();\n    let selection = Select::with_theme(&ColorfulTheme::default())\n        .with_prompt(\"Select a Claude Code project to improve review quality\")\n        .items(&items)\n        .default(0)\n        .max_length(5)\n        .interact()\n        .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;\n\n    // Skip option\n    if selection == 0 || selection == items.len() - 1 {\n        return Ok(SessionSelection::Skipped);\n    }\n\n    let project = &projects[selection];\n    let sessions = discover_sessions(project)?;\n\n    // Return all session paths from this project\n    let paths: Vec<PathBuf> = sessions.iter().map(|s| s.path.clone()).collect();\n    Ok(SessionSelection::Selected(paths))\n}\n\n/// Format a project item for display in the selection list\nfn format_project_item(project: &ClaudeProject) -> String {\n    let prompt_line = project\n        .first_prompt\n        .as_ref()\n        .map(|p| format!(\"\\n  \\\"{}\\\"\", p))\n        .unwrap_or_default();\n\n    let branch = project\n        .git_branch\n        .as_ref()\n        .map(|b| format!(\"branch: {}\", b))\n        .unwrap_or_else(|| \"no branch\".to_string());\n\n    format!(\n        \"{}{}\\n  {} · {} session{} · {}\\n\",\n        project.name,\n        prompt_line,\n        branch,\n        project.session_count,\n        if project.session_count == 1 { \"\" } else { \"s\" },\n        format_time_ago(project.modified_at)\n    )\n}\n\n/// Format a SystemTime as a human-readable \"time ago\" string\nfn format_time_ago(time: SystemTime) -> String {\n    let now = SystemTime::now();\n    let duration = now.duration_since(time).unwrap_or_default();\n    let secs = duration.as_secs();\n\n    if secs < 60 {\n        \"just now\".to_string()\n    } else if secs < 3600 {\n        let mins = secs / 60;\n        format!(\"{} minute{} ago\", mins, if mins == 1 { \"\" } else { \"s\" })\n    } else if secs < 86400 {\n        let hours = secs / 3600;\n        format!(\"{} hour{} ago\", hours, if hours == 1 { \"\" } else { \"s\" })\n    } else {\n        let days = secs / 86400;\n        format!(\"{} day{} ago\", days, if days == 1 { \"\" } else { \"s\" })\n    }\n}\n"
  },
  {
    "path": "crates/server/Cargo.toml",
    "content": "[package]\nname = \"server\"\nversion = \"0.1.33\"\nedition = \"2024\"\ndefault-run = \"server\"\n\n[lints.clippy]\nuninlined-format-args = \"allow\"\n\n[dependencies]\napi-types = { path = \"../api-types\" }\ndeployment = { path = \"../deployment\" }\nexecutors = { path = \"../executors\" }\nlocal-deployment = { path = \"../local-deployment\" }\nutils = { path = \"../utils\" }\ngit = { path = \"../git\" }\ngit-host = { path = \"../git-host\" }\ndb = { path = \"../db\" }\nservices = { path = \"../services\" }\nworktree-manager = { path = \"../worktree-manager\" }\nworkspace-manager = { path = \"../workspace-manager\" }\nrelay-tunnel = { path = \"../relay-tunnel\" }\ntrusted-key-auth = { path = \"../trusted-key-auth\" }\ntokio = { workspace = true }\nshlex = \"1.3.0\"\ntokio-util = { version = \"0.7\", features = [\"io\"] }\naxum = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nanyhow = { workspace = true }\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\nsqlx = { version = \"0.8.6\", features = [\"runtime-tokio\", \"tls-rustls-aws-lc-rs\", \"sqlite\", \"sqlite-preupdate-hook\", \"chrono\", \"uuid\"] }\nchrono = { version = \"0.4\", features = [\"serde\"] }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nts-rs = { workspace = true }\ntower-http = { workspace = true }\nschemars = { workspace = true }\nsentry = { version = \"0.46.2\", default-features = false, features = [\"anyhow\", \"backtrace\", \"panic\", \"debug-images\", \"reqwest\", \"rustls\"] }\nreqwest = { workspace = true }\nrustls = { workspace = true }\naws-lc-sys = { workspace = true }\naws-lc-rs = { workspace = true }\nstrip-ansi-escapes = \"0.2.1\"\nthiserror = { workspace = true }\nos_info = \"3.12.0\"\nfutures-util = \"0.3\"\nhttp = \"1\"\nbase64 = \"0.22\"\ngit2 = { workspace = true }\nmime_guess = \"2.0\"\nrust-embed = \"8.2\"\nurl = \"2.5\"\nrand = { version = \"0.8\", features = [\"std\"] }\nsha2 = \"0.10\"\ntokio-tungstenite = { version = \"0.26\", features = [\"rustls-tls-native-roots\"] }\n\n[build-dependencies]\ndotenv = \"0.15\"\n\n[dev-dependencies]\ntempfile = \"3\"\n\n[features]\ndefault = []\nqa-mode = [\"services/qa-mode\", \"executors/qa-mode\"]\n"
  },
  {
    "path": "crates/server/build.rs",
    "content": "use std::{fs, path::Path};\n\nfn main() {\n    // Load .env from the workspace root\n    let workspace_root = Path::new(env!(\"CARGO_MANIFEST_DIR\")).join(\"../..\");\n    let env_file = workspace_root.join(\".env\");\n    dotenv::from_path(&env_file).ok();\n\n    // Re-run build script when these env vars or .env file change\n    println!(\"cargo:rerun-if-env-changed=POSTHOG_API_KEY\");\n    println!(\"cargo:rerun-if-env-changed=POSTHOG_API_ENDPOINT\");\n    println!(\"cargo:rerun-if-env-changed=VK_SHARED_API_BASE\");\n    println!(\"cargo:rerun-if-env-changed=SENTRY_DSN\");\n    if env_file.exists() {\n        println!(\"cargo:rerun-if-changed={}\", env_file.display());\n    }\n\n    if let Ok(api_key) = std::env::var(\"POSTHOG_API_KEY\") {\n        println!(\"cargo:rustc-env=POSTHOG_API_KEY={}\", api_key);\n    }\n    if let Ok(api_endpoint) = std::env::var(\"POSTHOG_API_ENDPOINT\") {\n        println!(\"cargo:rustc-env=POSTHOG_API_ENDPOINT={}\", api_endpoint);\n    }\n    if let Ok(vk_shared_api_base) = std::env::var(\"VK_SHARED_API_BASE\") {\n        println!(\"cargo:rustc-env=VK_SHARED_API_BASE={}\", vk_shared_api_base);\n    }\n    if let Ok(vk_shared_relay_api_base) = std::env::var(\"VK_SHARED_RELAY_API_BASE\") {\n        println!(\n            \"cargo:rustc-env=VK_SHARED_RELAY_API_BASE={}\",\n            vk_shared_relay_api_base\n        );\n    }\n\n    // Create packages/local-web/dist directory if it doesn't exist\n    let dist_path = Path::new(\"../../packages/local-web/dist\");\n    if !dist_path.exists() {\n        println!(\"cargo:warning=Creating dummy packages/local-web/dist directory for compilation\");\n        fs::create_dir_all(dist_path).unwrap();\n\n        // Create a dummy index.html\n        let dummy_html = r#\"<!DOCTYPE html>\n<html><head><title>Build web app first</title></head>\n<body><h1>Please build @vibe/local-web first</h1></body></html>\"#;\n\n        fs::write(dist_path.join(\"index.html\"), dummy_html).unwrap();\n    }\n}\n"
  },
  {
    "path": "crates/server/src/bin/generate_types.rs",
    "content": "use std::{collections::HashMap, env, fs, path::Path};\n\nuse schemars::{JsonSchema, Schema, SchemaGenerator, generate::SchemaSettings};\nuse services::services::config::{DEFAULT_COMMIT_REMINDER_PROMPT, DEFAULT_PR_DESCRIPTION_PROMPT};\nuse ts_rs::TS;\n\nfn generate_types_content() -> String {\n    // 4. Friendly banner\n    const HEADER: &str = \"// This file was generated by `crates/core/src/bin/generate_types.rs`.\\n\n// Do not edit this file manually.\\n\n// If you are an AI, and you absolutely have to edit this file, please confirm with the user first.\";\n\n    let decls: Vec<String> = vec![\n        db::models::repo::Repo::decl(),\n        db::models::project::Project::decl(),\n        db::models::repo::UpdateRepo::decl(),\n        db::models::repo::SearchResult::decl(),\n        db::models::repo::SearchMatchType::decl(),\n        db::models::workspace_repo::WorkspaceRepo::decl(),\n        db::models::workspace_repo::CreateWorkspaceRepo::decl(),\n        db::models::workspace_repo::RepoWithTargetBranch::decl(),\n        db::models::tag::Tag::decl(),\n        db::models::tag::CreateTag::decl(),\n        db::models::tag::UpdateTag::decl(),\n        db::models::scratch::DraftFollowUpData::decl(),\n        db::models::scratch::DraftWorkspaceData::decl(),\n        db::models::scratch::DraftWorkspaceAttachment::decl(),\n        db::models::scratch::DraftWorkspaceLinkedIssue::decl(),\n        db::models::scratch::DraftWorkspaceRepo::decl(),\n        db::models::scratch::DraftIssueData::decl(),\n        db::models::scratch::PreviewSettingsData::decl(),\n        db::models::scratch::WorkspaceNotesData::decl(),\n        db::models::scratch::WorkspacePanelStateData::decl(),\n        db::models::scratch::WorkspacePrFilterData::decl(),\n        db::models::scratch::WorkspaceSortByData::decl(),\n        db::models::scratch::WorkspaceSortOrderData::decl(),\n        db::models::scratch::WorkspaceFilterStateData::decl(),\n        db::models::scratch::WorkspaceSortStateData::decl(),\n        db::models::scratch::UiPreferencesData::decl(),\n        db::models::scratch::ProjectRepoDefaultsData::decl(),\n        db::models::scratch::ScratchPayload::decl(),\n        db::models::scratch::ScratchType::decl(),\n        db::models::scratch::Scratch::decl(),\n        db::models::scratch::CreateScratch::decl(),\n        db::models::scratch::UpdateScratch::decl(),\n        db::models::workspace::Workspace::decl(),\n        db::models::workspace::WorkspaceWithStatus::decl(),\n        db::models::session::Session::decl(),\n        db::models::execution_process::ExecutionProcess::decl(),\n        db::models::execution_process::ExecutionProcessStatus::decl(),\n        db::models::execution_process::ExecutionProcessRunReason::decl(),\n        db::models::execution_process_repo_state::ExecutionProcessRepoState::decl(),\n        db::models::merge::Merge::decl(),\n        db::models::merge::DirectMerge::decl(),\n        db::models::merge::PrMerge::decl(),\n        db::models::merge::MergeStatus::decl(),\n        db::models::merge::PullRequestInfo::decl(),\n        services::services::approvals::ApprovalInfo::decl(),\n        utils::approvals::ApprovalStatus::decl(),\n        utils::approvals::QuestionAnswer::decl(),\n        utils::approvals::QuestionStatus::decl(),\n        utils::approvals::ApprovalOutcome::decl(),\n        utils::approvals::ApprovalResponse::decl(),\n        utils::diff::Diff::decl(),\n        utils::diff::DiffChangeKind::decl(),\n        utils::response::ApiResponse::<()>::decl(),\n        api_types::LoginStatus::decl(),\n        api_types::ProfileResponse::decl(),\n        api_types::ProviderProfile::decl(),\n        api_types::StatusResponse::decl(),\n        api_types::MemberRole::decl(),\n        api_types::InvitationStatus::decl(),\n        api_types::Organization::decl(),\n        api_types::OrganizationWithRole::decl(),\n        api_types::ListOrganizationsResponse::decl(),\n        api_types::GetOrganizationResponse::decl(),\n        api_types::CreateOrganizationRequest::decl(),\n        api_types::CreateOrganizationResponse::decl(),\n        api_types::UpdateOrganizationRequest::decl(),\n        api_types::Invitation::decl(),\n        api_types::CreateInvitationRequest::decl(),\n        api_types::CreateInvitationResponse::decl(),\n        api_types::ListInvitationsResponse::decl(),\n        api_types::GetInvitationResponse::decl(),\n        api_types::AcceptInvitationResponse::decl(),\n        api_types::RevokeInvitationRequest::decl(),\n        api_types::OrganizationMemberInfo::decl(),\n        api_types::OrganizationMemberWithProfile::decl(),\n        api_types::ListMembersResponse::decl(),\n        api_types::UpdateMemberRoleRequest::decl(),\n        api_types::UpdateMemberRoleResponse::decl(),\n        services::services::migration::MigrationRequest::decl(),\n        services::services::migration::MigrationResponse::decl(),\n        services::services::migration::MigrationReport::decl(),\n        services::services::migration::EntityReport::decl(),\n        services::services::migration::EntityError::decl(),\n        server::routes::repo::RegisterRepoRequest::decl(),\n        server::routes::repo::InitRepoRequest::decl(),\n        server::routes::tags::TagSearchParams::decl(),\n        server::routes::oauth::TokenResponse::decl(),\n        server::routes::config::UserSystemInfo::decl(),\n        server::routes::config::Environment::decl(),\n        server::routes::config::McpServerQuery::decl(),\n        server::routes::config::UpdateMcpServersBody::decl(),\n        server::routes::config::GetMcpServerResponse::decl(),\n        server::routes::config::CheckEditorAvailabilityQuery::decl(),\n        server::routes::config::CheckEditorAvailabilityResponse::decl(),\n        server::routes::config::CheckAgentAvailabilityQuery::decl(),\n        server::routes::config::AgentPresetOptionsQuery::decl(),\n        server::routes::oauth::CurrentUserResponse::decl(),\n        server::routes::relay_auth::StartSpake2EnrollmentRequest::decl(),\n        server::routes::relay_auth::FinishSpake2EnrollmentRequest::decl(),\n        server::routes::relay_auth::StartSpake2EnrollmentResponse::decl(),\n        server::routes::relay_auth::FinishSpake2EnrollmentResponse::decl(),\n        server::routes::relay_auth::RelayPairedClient::decl(),\n        server::routes::relay_auth::ListRelayPairedClientsResponse::decl(),\n        server::routes::relay_auth::RemoveRelayPairedClientResponse::decl(),\n        server::routes::relay_auth::RefreshRelaySigningSessionRequest::decl(),\n        server::routes::relay_auth::RefreshRelaySigningSessionResponse::decl(),\n        server::routes::sessions::CreateFollowUpAttempt::decl(),\n        server::routes::sessions::ResetProcessRequest::decl(),\n        server::routes::workspaces::git::ChangeTargetBranchRequest::decl(),\n        server::routes::workspaces::git::ChangeTargetBranchResponse::decl(),\n        server::routes::workspaces::repos::AddWorkspaceRepoRequest::decl(),\n        server::routes::workspaces::repos::AddWorkspaceRepoResponse::decl(),\n        server::routes::workspaces::git::MergeWorkspaceRequest::decl(),\n        server::routes::workspaces::git::PushWorkspaceRequest::decl(),\n        server::routes::workspaces::git::RenameBranchRequest::decl(),\n        server::routes::workspaces::git::RenameBranchResponse::decl(),\n        server::routes::sessions::review::StartReviewRequest::decl(),\n        server::routes::sessions::review::ReviewError::decl(),\n        server::routes::workspaces::integration::OpenEditorRequest::decl(),\n        server::routes::workspaces::integration::OpenEditorResponse::decl(),\n        db::models::requests::LinkedIssueInfo::decl(),\n        server::routes::workspaces::pr::CreatePrApiRequest::decl(),\n        server::routes::attachments::AttachmentResponse::decl(),\n        server::routes::attachments::AttachmentMetadata::decl(),\n        db::models::requests::WorkspaceRepoInput::decl(),\n        server::routes::workspaces::integration::RunAgentSetupRequest::decl(),\n        server::routes::workspaces::integration::RunAgentSetupResponse::decl(),\n        server::routes::workspaces::gh_cli_setup::GhCliSetupError::decl(),\n        server::routes::workspaces::git::RebaseWorkspaceRequest::decl(),\n        server::routes::workspaces::git::ContinueRebaseRequest::decl(),\n        server::routes::workspaces::git::AbortConflictsRequest::decl(),\n        server::routes::workspaces::git::GitOperationError::decl(),\n        server::routes::workspaces::git::PushError::decl(),\n        server::routes::workspaces::pr::PrError::decl(),\n        server::routes::workspaces::execution::RunScriptError::decl(),\n        server::routes::workspaces::attachments::AssociateWorkspaceAttachmentsRequest::decl(),\n        server::routes::workspaces::attachments::ImportIssueAttachmentsRequest::decl(),\n        server::routes::workspaces::attachments::ImportIssueAttachmentsResponse::decl(),\n        server::routes::workspaces::pr::AttachPrResponse::decl(),\n        server::routes::workspaces::pr::AttachExistingPrRequest::decl(),\n        server::routes::workspaces::pr::PrCommentsResponse::decl(),\n        server::routes::workspaces::pr::GetPrCommentsError::decl(),\n        server::routes::workspaces::pr::GetPrCommentsQuery::decl(),\n        db::models::requests::CreateAndStartWorkspaceRequest::decl(),\n        db::models::requests::CreateAndStartWorkspaceResponse::decl(),\n        git_host::UnifiedPrComment::decl(),\n        git_host::ProviderKind::decl(),\n        git_host::OpenPrInfo::decl(),\n        git::GitRemote::decl(),\n        server::routes::repo::ListPrsError::decl(),\n        server::routes::workspaces::pr::CreateWorkspaceFromPrBody::decl(),\n        server::routes::workspaces::pr::CreateWorkspaceFromPrResponse::decl(),\n        server::routes::workspaces::pr::CreateFromPrError::decl(),\n        server::routes::workspaces::git::RepoBranchStatus::decl(),\n        db::models::requests::UpdateWorkspace::decl(),\n        db::models::requests::UpdateSession::decl(),\n        server::routes::workspaces::workspace_summary::WorkspaceSummaryRequest::decl(),\n        server::routes::workspaces::workspace_summary::WorkspaceSummary::decl(),\n        server::routes::workspaces::workspace_summary::WorkspaceSummaryResponse::decl(),\n        server::routes::workspaces::workspace_summary::DiffStats::decl(),\n        services::services::filesystem::DirectoryEntry::decl(),\n        services::services::filesystem::DirectoryListResponse::decl(),\n        services::services::file_search::SearchMode::decl(),\n        services::services::config::Config::decl(),\n        services::services::config::NotificationConfig::decl(),\n        services::services::config::ThemeMode::decl(),\n        services::services::config::EditorConfig::decl(),\n        services::services::config::EditorType::decl(),\n        services::services::config::EditorOpenError::decl(),\n        services::services::config::GitHubConfig::decl(),\n        services::services::config::SoundFile::decl(),\n        services::services::config::UiLanguage::decl(),\n        services::services::config::ShowcaseState::decl(),\n        services::services::config::SendMessageShortcut::decl(),\n        git::GitBranch::decl(),\n        services::services::queued_message::QueuedMessage::decl(),\n        services::services::queued_message::QueueStatus::decl(),\n        git::ConflictOp::decl(),\n        executors::actions::ExecutorAction::decl(),\n        executors::mcp_config::McpConfig::decl(),\n        executors::actions::ExecutorActionType::decl(),\n        executors::profile::ExecutorConfig::decl(),\n        executors::actions::script::ScriptContext::decl(),\n        executors::actions::script::ScriptRequest::decl(),\n        executors::actions::script::ScriptRequestLanguage::decl(),\n        executors::executors::BaseCodingAgent::decl(),\n        executors::executors::CodingAgent::decl(),\n        executors::executors::SlashCommandDescription::decl(),\n        executors::executors::AvailabilityInfo::decl(),\n        executors::command::CommandBuilder::decl(),\n        executors::profile::ExecutorProfileId::decl(),\n        executors::profile::ExecutorRecentModels::decl(),\n        executors::profile::ExecutorProfile::decl(),\n        executors::profile::ExecutorConfigs::decl(),\n        executors::executors::BaseAgentCapability::decl(),\n        executors::executors::claude::ClaudeEffort::decl(),\n        executors::executors::claude::ClaudeCode::decl(),\n        executors::executors::gemini::Gemini::decl(),\n        executors::executors::amp::Amp::decl(),\n        executors::executors::codex::Codex::decl(),\n        executors::executors::codex::SandboxMode::decl(),\n        executors::executors::codex::AskForApproval::decl(),\n        executors::executors::codex::ReasoningEffort::decl(),\n        executors::executors::codex::ReasoningSummary::decl(),\n        executors::executors::codex::ReasoningSummaryFormat::decl(),\n        executors::executors::cursor::CursorAgent::decl(),\n        executors::executors::copilot::Copilot::decl(),\n        executors::executors::opencode::Opencode::decl(),\n        executors::executors::qwen::QwenCode::decl(),\n        executors::executors::droid::Droid::decl(),\n        executors::executors::droid::Autonomy::decl(),\n        executors::executors::droid::ReasoningEffortLevel::decl(),\n        executors::executors::AppendPrompt::decl(),\n        executors::actions::coding_agent_initial::CodingAgentInitialRequest::decl(),\n        executors::actions::coding_agent_follow_up::CodingAgentFollowUpRequest::decl(),\n        executors::actions::review::ReviewRequest::decl(),\n        executors::actions::review::RepoReviewContext::decl(),\n        executors::logs::CommandExitStatus::decl(),\n        executors::logs::CommandRunResult::decl(),\n        executors::logs::utils::shell_command_parsing::CommandCategory::decl(),\n        executors::logs::NormalizedEntry::decl(),\n        executors::logs::NormalizedEntryType::decl(),\n        executors::logs::TokenUsageInfo::decl(),\n        executors::logs::FileChange::decl(),\n        executors::logs::ActionType::decl(),\n        executors::logs::AnsweredQuestion::decl(),\n        executors::logs::AskUserQuestionItem::decl(),\n        executors::logs::AskUserQuestionOption::decl(),\n        executors::logs::TodoItem::decl(),\n        executors::logs::NormalizedEntryError::decl(),\n        executors::logs::ToolResult::decl(),\n        executors::logs::ToolResultValueType::decl(),\n        executors::logs::ToolStatus::decl(),\n        executors::logs::utils::patch::PatchType::decl(),\n        executors::model_selector::ModelInfo::decl(),\n        executors::model_selector::ReasoningOption::decl(),\n        executors::model_selector::ModelProvider::decl(),\n        executors::model_selector::AgentInfo::decl(),\n        executors::model_selector::PermissionPolicy::decl(),\n        executors::model_selector::ModelSelectorConfig::decl(),\n        executors::executor_discovery::ExecutorDiscoveredOptions::decl(),\n        serde_json::Value::decl(),\n    ];\n\n    let body = decls\n        .into_iter()\n        .map(|d| {\n            let trimmed = d.trim_start();\n            if trimmed.starts_with(\"export\") {\n                d\n            } else {\n                format!(\"export {trimmed}\")\n            }\n        })\n        .collect::<Vec<_>>()\n        .join(\"\\n\\n\");\n\n    // Append exported constants\n    let constants = format!(\n        \"export const DEFAULT_PR_DESCRIPTION_PROMPT = {};\\n\\nexport const DEFAULT_COMMIT_REMINDER_PROMPT = {};\",\n        serde_json::to_string(DEFAULT_PR_DESCRIPTION_PROMPT).unwrap(),\n        serde_json::to_string(DEFAULT_COMMIT_REMINDER_PROMPT).unwrap()\n    );\n\n    format!(\"{HEADER}\\n\\n{body}\\n\\n{constants}\")\n}\n\nfn generate_json_schema<T: JsonSchema>() -> Result<String, serde_json::Error> {\n    // Draft-07, inline everything (no $defs)\n    let mut settings = SchemaSettings::draft07();\n    settings.inline_subschemas = true;\n\n    let generator: SchemaGenerator = settings.into_generator();\n    let schema: Schema = generator.into_root_schema_for::<T>();\n\n    // Convert to JSON value to manipulate it\n    let mut schema_value: serde_json::Value = serde_json::to_value(&schema)?;\n    // Remove the title from root schema to prevent RJSF from creating an outer field container\n    if let Some(obj) = schema_value.as_object_mut() {\n        obj.remove(\"title\");\n    }\n    let formatted = serde_json::to_string_pretty(&schema_value)?;\n    Ok(formatted)\n}\n\nfn generate_schemas() -> Result<HashMap<&'static str, String>, serde_json::Error> {\n    // // Generate schemas for all executor types\n    println!(\"Generating JSON schemas…\");\n    let schemas: HashMap<&str, String> = HashMap::from([\n        (\n            \"amp\",\n            generate_json_schema::<executors::executors::amp::Amp>()?,\n        ),\n        (\n            \"claude_code\",\n            generate_json_schema::<executors::executors::claude::ClaudeCode>()?,\n        ),\n        (\n            \"gemini\",\n            generate_json_schema::<executors::executors::gemini::Gemini>()?,\n        ),\n        (\n            \"codex\",\n            generate_json_schema::<executors::executors::codex::Codex>()?,\n        ),\n        (\n            \"cursor_agent\",\n            generate_json_schema::<executors::executors::cursor::CursorAgent>()?,\n        ),\n        (\n            \"opencode\",\n            generate_json_schema::<executors::executors::opencode::Opencode>()?,\n        ),\n        (\n            \"qwen_code\",\n            generate_json_schema::<executors::executors::qwen::QwenCode>()?,\n        ),\n        (\n            \"copilot\",\n            generate_json_schema::<executors::executors::copilot::Copilot>()?,\n        ),\n        (\n            \"droid\",\n            generate_json_schema::<executors::executors::droid::Droid>()?,\n        ),\n    ]);\n    println!(\n        \"✅ JSON schemas generated. {} schemas created.\",\n        schemas.len()\n    );\n    Ok(schemas)\n}\n\nfn write_schemas(\n    schemas_path: &Path,\n    schemas: HashMap<&str, String>,\n) -> Result<(), Box<dyn std::error::Error>> {\n    fs::create_dir_all(schemas_path)?;\n\n    for (name, content) in schemas {\n        let schema_file = schemas_path.join(format!(\"{}.json\", name));\n        fs::write(&schema_file, content)?;\n        println!(\"✅ Generated schema: {}\", schema_file.display());\n    }\n\n    Ok(())\n}\n\nfn schemas_up_to_date(schemas_path: &Path, schemas: &HashMap<&str, String>) -> bool {\n    for (name, expected_content) in schemas {\n        let schema_file = schemas_path.join(format!(\"{}.json\", name));\n        let current_content = fs::read_to_string(&schema_file).unwrap_or_default();\n        if &current_content != expected_content {\n            eprintln!(\"❌ Schema shared/schemas/{}.json is not up to date.\", name);\n            return false;\n        }\n    }\n    true\n}\n\nfn main() {\n    let args: Vec<String> = env::args().collect();\n    let check_mode = args.iter().any(|arg| arg == \"--check\");\n\n    let shared_path = Path::new(\"shared\");\n\n    println!(\"Generating TypeScript types…\");\n\n    let generated_types = generate_types_content();\n    let schema_content = match generate_schemas() {\n        Ok(s) => s,\n        Err(e) => {\n            eprintln!(\"❌ Failed to generate JSON schemas: {}\", e);\n            std::process::exit(1);\n        }\n    };\n\n    let types_path = shared_path.join(\"types.ts\");\n    let schemas_path = shared_path.join(\"schemas\");\n\n    if check_mode {\n        // Check TypeScript types\n        let current = fs::read_to_string(&types_path).unwrap_or_default();\n        let types_up_to_date = if current == generated_types {\n            println!(\"✅ shared/types.ts is up to date.\");\n            true\n        } else {\n            eprintln!(\"❌ shared/types.ts is not up to date.\");\n            false\n        };\n\n        // Check JSON schemas\n        let schemas_up_to_date = schemas_up_to_date(&schemas_path, &schema_content);\n\n        // Exit with appropriate code\n        if types_up_to_date && schemas_up_to_date {\n            std::process::exit(0);\n        } else {\n            eprintln!(\"Please run 'npm run generate-types' and commit the changes.\");\n            std::process::exit(1);\n        }\n    } else {\n        fs::create_dir_all(shared_path).expect(\"cannot create shared\");\n\n        fs::remove_file(&types_path).ok();\n        fs::remove_dir_all(&schemas_path).ok();\n\n        fs::write(&types_path, generated_types).expect(\"unable to write types.ts\");\n        println!(\"✅ TypeScript types generated in shared/types.ts\");\n\n        write_schemas(&schemas_path, schema_content).expect(\"unable to write schemas\");\n\n        println!(\"✅ JSON schemas generated in shared/schemas/\");\n    }\n}\n"
  },
  {
    "path": "crates/server/src/error.rs",
    "content": "use axum::{\n    Json,\n    extract::multipart::MultipartError,\n    http::StatusCode,\n    response::{IntoResponse, Response},\n};\nuse db::models::{\n    execution_process::ExecutionProcessError, repo::RepoError, scratch::ScratchError,\n    session::SessionError, workspace::WorkspaceError,\n};\nuse deployment::{DeploymentError, RemoteClientNotConfigured};\nuse executors::{command::CommandBuildError, executors::ExecutorError};\nuse git::GitServiceError;\nuse git_host::GitHostError;\nuse git2::Error as Git2Error;\nuse local_deployment::pty::PtyError;\nuse services::services::{\n    config::{ConfigError, EditorOpenError},\n    container::ContainerError,\n    file::FileError,\n    migration::MigrationError,\n    remote_client::RemoteClientError,\n    repo::RepoError as RepoServiceError,\n};\nuse thiserror::Error;\nuse trusted_key_auth::error::TrustedKeyAuthError;\nuse utils::response::ApiResponse;\nuse workspace_manager::WorkspaceError as WorkspaceManagerError;\nuse worktree_manager::WorktreeError;\n\n#[derive(Debug, Error, ts_rs::TS)]\n#[ts(type = \"string\")]\npub enum ApiError {\n    #[error(transparent)]\n    Repo(#[from] RepoError),\n    #[error(transparent)]\n    Workspace(#[from] WorkspaceError),\n    #[error(transparent)]\n    Session(#[from] SessionError),\n    #[error(transparent)]\n    ScratchError(#[from] ScratchError),\n    #[error(transparent)]\n    ExecutionProcess(#[from] ExecutionProcessError),\n    #[error(transparent)]\n    GitService(#[from] GitServiceError),\n    #[error(transparent)]\n    GitHost(#[from] GitHostError),\n    #[error(transparent)]\n    Deployment(#[from] DeploymentError),\n    #[error(transparent)]\n    Container(#[from] ContainerError),\n    #[error(transparent)]\n    Executor(#[from] ExecutorError),\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(transparent)]\n    Worktree(#[from] WorktreeError),\n    #[error(transparent)]\n    Config(#[from] ConfigError),\n    #[error(transparent)]\n    File(#[from] FileError),\n    #[error(\"Multipart error: {0}\")]\n    Multipart(#[from] MultipartError),\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(transparent)]\n    EditorOpen(#[from] EditorOpenError),\n    #[error(transparent)]\n    RemoteClient(#[from] RemoteClientError),\n    #[error(\"Unauthorized\")]\n    Unauthorized,\n    #[error(\"Bad request: {0}\")]\n    BadRequest(String),\n    #[error(\"Conflict: {0}\")]\n    Conflict(String),\n    #[error(\"Forbidden: {0}\")]\n    Forbidden(String),\n    #[error(\"Too many requests: {0}\")]\n    TooManyRequests(String),\n    #[error(transparent)]\n    CommandBuilder(#[from] CommandBuildError),\n    #[error(transparent)]\n    Pty(#[from] PtyError),\n    #[error(transparent)]\n    Migration(#[from] MigrationError),\n}\n\nimpl From<&'static str> for ApiError {\n    fn from(msg: &'static str) -> Self {\n        ApiError::BadRequest(msg.to_string())\n    }\n}\n\nimpl From<Git2Error> for ApiError {\n    fn from(err: Git2Error) -> Self {\n        ApiError::GitService(GitServiceError::from(err))\n    }\n}\n\nimpl From<RemoteClientNotConfigured> for ApiError {\n    fn from(_: RemoteClientNotConfigured) -> Self {\n        ApiError::BadRequest(\"Remote client not configured\".to_string())\n    }\n}\n\nimpl From<WorkspaceManagerError> for ApiError {\n    fn from(err: WorkspaceManagerError) -> Self {\n        match err {\n            WorkspaceManagerError::Database(err) => ApiError::Database(err),\n            WorkspaceManagerError::Repo(err) => ApiError::Repo(err),\n            WorkspaceManagerError::Worktree(err) => ApiError::Worktree(err),\n            WorkspaceManagerError::GitService(err) => ApiError::GitService(err),\n            WorkspaceManagerError::Io(err) => ApiError::Io(err),\n            WorkspaceManagerError::WorkspaceNotFound => {\n                ApiError::Workspace(WorkspaceError::WorkspaceNotFound)\n            }\n            WorkspaceManagerError::RepoAlreadyAttached => {\n                ApiError::Conflict(\"Repository already attached to workspace\".to_string())\n            }\n            WorkspaceManagerError::BranchNotFound { repo_name, branch } => {\n                ApiError::BadRequest(format!(\n                    \"Branch '{}' does not exist in repository '{}'\",\n                    branch, repo_name\n                ))\n            }\n            WorkspaceManagerError::NoRepositories => {\n                ApiError::BadRequest(\"Workspace has no repositories configured\".to_string())\n            }\n            WorkspaceManagerError::PartialCreation(msg) => ApiError::Conflict(msg),\n        }\n    }\n}\n\nstruct ErrorInfo {\n    status: StatusCode,\n    error_type: &'static str,\n    message: Option<String>,\n}\n\nimpl ErrorInfo {\n    fn internal(error_type: &'static str) -> Self {\n        Self {\n            status: StatusCode::INTERNAL_SERVER_ERROR,\n            error_type,\n            message: Some(\"An internal error occurred. Please try again.\".into()),\n        }\n    }\n\n    fn not_found(error_type: &'static str, msg: impl Into<String>) -> Self {\n        Self {\n            status: StatusCode::NOT_FOUND,\n            error_type,\n            message: Some(msg.into()),\n        }\n    }\n\n    fn bad_request(error_type: &'static str, msg: impl Into<String>) -> Self {\n        Self {\n            status: StatusCode::BAD_REQUEST,\n            error_type,\n            message: Some(msg.into()),\n        }\n    }\n\n    fn conflict(error_type: &'static str, msg: impl Into<String>) -> Self {\n        Self {\n            status: StatusCode::CONFLICT,\n            error_type,\n            message: Some(msg.into()),\n        }\n    }\n\n    fn with_status(status: StatusCode, error_type: &'static str, msg: impl Into<String>) -> Self {\n        Self {\n            status,\n            error_type,\n            message: Some(msg.into()),\n        }\n    }\n}\n\nfn remote_client_error(err: &RemoteClientError) -> ErrorInfo {\n    use services::services::remote_client::HandoffErrorCode;\n    match err {\n        RemoteClientError::Auth => ErrorInfo::with_status(\n            StatusCode::UNAUTHORIZED,\n            \"RemoteClientError\",\n            \"Unauthorized. Please sign in again.\",\n        ),\n        RemoteClientError::Timeout => ErrorInfo::with_status(\n            StatusCode::GATEWAY_TIMEOUT,\n            \"RemoteClientError\",\n            \"Remote service timeout. Please try again.\",\n        ),\n        RemoteClientError::TokenRefreshTimeout => ErrorInfo::with_status(\n            StatusCode::UNAUTHORIZED,\n            \"RemoteClientError\",\n            \"Remote service timeout during token refresh. Please sign in again.\",\n        ),\n        RemoteClientError::Transport(_) => ErrorInfo::with_status(\n            StatusCode::BAD_GATEWAY,\n            \"RemoteClientError\",\n            \"Remote service unavailable. Please try again.\",\n        ),\n        RemoteClientError::Http { status, body } => {\n            let msg = if body.is_empty() {\n                \"Remote service error. Please try again.\".into()\n            } else {\n                serde_json::from_str::<serde_json::Value>(body)\n                    .ok()\n                    .and_then(|v| v.get(\"error\")?.as_str().map(String::from))\n                    .unwrap_or_else(|| body.clone())\n            };\n            ErrorInfo::with_status(\n                StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY),\n                \"RemoteClientError\",\n                msg,\n            )\n        }\n        RemoteClientError::Token(_) => ErrorInfo::with_status(\n            StatusCode::BAD_GATEWAY,\n            \"RemoteClientError\",\n            \"Remote service returned an invalid access token. Please sign in again.\",\n        ),\n        RemoteClientError::Storage(_) => ErrorInfo {\n            status: StatusCode::INTERNAL_SERVER_ERROR,\n            error_type: \"RemoteClientError\",\n            message: Some(\"Failed to persist credentials locally. Please retry.\".into()),\n        },\n        RemoteClientError::Api(code) => {\n            let (status, msg) = match code {\n                HandoffErrorCode::NotFound => (\n                    StatusCode::NOT_FOUND,\n                    \"The requested resource was not found.\",\n                ),\n                HandoffErrorCode::Expired => {\n                    (StatusCode::UNAUTHORIZED, \"The link or token has expired.\")\n                }\n                HandoffErrorCode::AccessDenied => (StatusCode::FORBIDDEN, \"Access denied.\"),\n                HandoffErrorCode::UnsupportedProvider => (\n                    StatusCode::BAD_REQUEST,\n                    \"Unsupported authentication provider.\",\n                ),\n                HandoffErrorCode::InvalidReturnUrl => {\n                    (StatusCode::BAD_REQUEST, \"Invalid return URL.\")\n                }\n                HandoffErrorCode::InvalidChallenge => {\n                    (StatusCode::BAD_REQUEST, \"Invalid authentication challenge.\")\n                }\n                HandoffErrorCode::ProviderError => (\n                    StatusCode::BAD_GATEWAY,\n                    \"Authentication provider error. Please try again.\",\n                ),\n                HandoffErrorCode::InternalError => (\n                    StatusCode::BAD_GATEWAY,\n                    \"Internal remote service error. Please try again.\",\n                ),\n                HandoffErrorCode::Other(m) => {\n                    return ErrorInfo::bad_request(\n                        \"RemoteClientError\",\n                        format!(\"Authentication error: {}\", m),\n                    );\n                }\n            };\n            ErrorInfo::with_status(status, \"RemoteClientError\", msg)\n        }\n        RemoteClientError::Serde(_) => ErrorInfo::bad_request(\n            \"RemoteClientError\",\n            \"Unexpected response from remote service.\",\n        ),\n        RemoteClientError::Url(_) => {\n            ErrorInfo::bad_request(\"RemoteClientError\", \"Remote service URL is invalid.\")\n        }\n    }\n}\n\nimpl IntoResponse for ApiError {\n    fn into_response(self) -> Response {\n        let info = match &self {\n            ApiError::Repo(RepoError::Database(_)) => ErrorInfo::internal(\"RepoError\"),\n            ApiError::Repo(RepoError::NotFound) => {\n                ErrorInfo::not_found(\"RepoError\", \"Repository not found.\")\n            }\n\n            ApiError::Workspace(WorkspaceError::Database(_)) => {\n                ErrorInfo::internal(\"WorkspaceError\")\n            }\n            ApiError::Workspace(WorkspaceError::WorkspaceNotFound) => {\n                ErrorInfo::not_found(\"WorkspaceError\", \"Workspace not found.\")\n            }\n            ApiError::Workspace(WorkspaceError::ValidationError(msg)) => {\n                ErrorInfo::bad_request(\"WorkspaceError\", msg.clone())\n            }\n            ApiError::Workspace(WorkspaceError::BranchNotFound(branch)) => {\n                ErrorInfo::not_found(\"WorkspaceError\", format!(\"Branch '{}' not found.\", branch))\n            }\n\n            ApiError::Session(SessionError::Database(_)) => ErrorInfo::internal(\"SessionError\"),\n            ApiError::Session(SessionError::NotFound) => {\n                ErrorInfo::not_found(\"SessionError\", \"Session not found.\")\n            }\n            ApiError::Session(SessionError::WorkspaceNotFound) => {\n                ErrorInfo::not_found(\"SessionError\", \"Workspace not found.\")\n            }\n            ApiError::Session(SessionError::ExecutorMismatch { expected, actual }) => {\n                ErrorInfo::conflict(\n                    \"SessionError\",\n                    format!(\n                        \"Executor mismatch: session uses {} but request specified {}.\",\n                        expected, actual\n                    ),\n                )\n            }\n\n            ApiError::ScratchError(ScratchError::Database(_)) => {\n                ErrorInfo::internal(\"ScratchError\")\n            }\n            ApiError::ScratchError(ScratchError::Serde(_)) => {\n                ErrorInfo::bad_request(\"ScratchError\", \"Invalid scratch data format.\")\n            }\n            ApiError::ScratchError(ScratchError::TypeMismatch { expected, actual }) => {\n                ErrorInfo::bad_request(\n                    \"ScratchError\",\n                    format!(\n                        \"Scratch type mismatch: expected '{}' but got '{}'.\",\n                        expected, actual\n                    ),\n                )\n            }\n\n            ApiError::ExecutionProcess(ExecutionProcessError::ExecutionProcessNotFound) => {\n                ErrorInfo::not_found(\"ExecutionProcessError\", \"Execution process not found.\")\n            }\n            ApiError::ExecutionProcess(_) => ErrorInfo::internal(\"ExecutionProcessError\"),\n\n            ApiError::GitService(git::GitServiceError::MergeConflicts { message, .. }) => {\n                ErrorInfo::conflict(\"GitServiceError\", message.clone())\n            }\n            ApiError::GitService(git::GitServiceError::RebaseInProgress) => ErrorInfo::conflict(\n                \"GitServiceError\",\n                \"A rebase is already in progress. Resolve conflicts or abort the rebase, then retry.\",\n            ),\n            ApiError::GitService(git::GitServiceError::BranchNotFound(branch)) => {\n                ErrorInfo::not_found(\n                    \"GitServiceError\",\n                    format!(\n                        \"Branch '{}' not found. Try changing the target branch.\",\n                        branch\n                    ),\n                )\n            }\n            ApiError::GitService(git::GitServiceError::BranchesDiverged(msg)) => {\n                ErrorInfo::conflict(\n                    \"GitServiceError\",\n                    format!(\n                        \"{} Rebase onto the target branch first, then retry the merge.\",\n                        msg\n                    ),\n                )\n            }\n            ApiError::GitService(git::GitServiceError::WorktreeDirty(branch, files)) => {\n                ErrorInfo::conflict(\n                    \"GitServiceError\",\n                    format!(\n                        \"Branch '{}' has uncommitted changes ({}). Commit or revert them before retrying.\",\n                        branch, files\n                    ),\n                )\n            }\n            ApiError::GitService(git::GitServiceError::GitCLI(git::GitCliError::AuthFailed(\n                msg,\n            ))) => ErrorInfo::with_status(\n                StatusCode::UNAUTHORIZED,\n                \"GitServiceError\",\n                format!(\n                    \"{}. Check your git credentials or SSH keys and try again.\",\n                    msg\n                ),\n            ),\n            ApiError::GitService(e) => ErrorInfo::with_status(\n                StatusCode::INTERNAL_SERVER_ERROR,\n                \"GitServiceError\",\n                format!(\"Git operation failed: {}\", e),\n            ),\n            ApiError::GitHost(_) => ErrorInfo::internal(\"GitHostError\"),\n\n            ApiError::File(FileError::TooLarge(size, max)) => ErrorInfo::with_status(\n                StatusCode::PAYLOAD_TOO_LARGE,\n                \"FileTooLarge\",\n                format!(\n                    \"This file is too large ({:.1} MB). Maximum file size is {:.1} MB.\",\n                    *size as f64 / 1_048_576.0,\n                    *max as f64 / 1_048_576.0\n                ),\n            ),\n            ApiError::File(FileError::NotFound) => {\n                ErrorInfo::not_found(\"FileNotFound\", \"File not found.\")\n            }\n            ApiError::File(_) => ErrorInfo {\n                status: StatusCode::INTERNAL_SERVER_ERROR,\n                error_type: \"FileError\",\n                message: Some(\"Failed to process file. Please try again.\".into()),\n            },\n\n            ApiError::EditorOpen(EditorOpenError::LaunchFailed { .. }) => {\n                ErrorInfo::internal(\"EditorLaunchError\")\n            }\n            ApiError::EditorOpen(_) => {\n                ErrorInfo::bad_request(\"EditorOpenError\", format!(\"{}\", self))\n            }\n\n            ApiError::RemoteClient(err) => remote_client_error(err),\n\n            ApiError::Pty(PtyError::SessionNotFound(_)) => {\n                ErrorInfo::not_found(\"PtyError\", \"PTY session not found.\")\n            }\n            ApiError::Pty(PtyError::SessionClosed) => {\n                ErrorInfo::with_status(StatusCode::GONE, \"PtyError\", \"PTY session closed.\")\n            }\n            ApiError::Pty(_) => ErrorInfo::internal(\"PtyError\"),\n\n            ApiError::Unauthorized => ErrorInfo::with_status(\n                StatusCode::UNAUTHORIZED,\n                \"Unauthorized\",\n                \"Unauthorized. Please sign in again.\",\n            ),\n            ApiError::BadRequest(msg) => ErrorInfo::bad_request(\"BadRequest\", msg.clone()),\n            ApiError::Conflict(msg) => ErrorInfo::conflict(\"ConflictError\", msg.clone()),\n            ApiError::Forbidden(msg) => {\n                ErrorInfo::with_status(StatusCode::FORBIDDEN, \"ForbiddenError\", msg.clone())\n            }\n            ApiError::TooManyRequests(msg) => ErrorInfo::with_status(\n                StatusCode::TOO_MANY_REQUESTS,\n                \"TooManyRequests\",\n                msg.clone(),\n            ),\n            ApiError::Multipart(_) => ErrorInfo::bad_request(\n                \"MultipartError\",\n                \"Failed to upload file. Please ensure the file is valid and try again.\",\n            ),\n\n            ApiError::Deployment(_) => ErrorInfo::internal(\"DeploymentError\"),\n            ApiError::Container(err) => match err {\n                ContainerError::GitServiceError(_) => ErrorInfo::internal(\"ContainerError\"),\n                ContainerError::Workspace(WorkspaceError::WorkspaceNotFound) => {\n                    ErrorInfo::not_found(\"ContainerError\", \"Workspace not found.\")\n                }\n                ContainerError::Workspace(WorkspaceError::ValidationError(msg)) => {\n                    ErrorInfo::bad_request(\"ContainerError\", msg.clone())\n                }\n                ContainerError::Workspace(WorkspaceError::BranchNotFound(branch)) => {\n                    ErrorInfo::not_found(\n                        \"ContainerError\",\n                        format!(\"Branch '{}' not found.\", branch),\n                    )\n                }\n                ContainerError::ExecutorError(e) => ErrorInfo::with_status(\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"ContainerError\",\n                    format!(\"Executor error: {e}\"),\n                ),\n                _ => ErrorInfo::internal(\"ContainerError\"),\n            },\n            ApiError::Executor(_) => ErrorInfo::internal(\"ExecutorError\"),\n            ApiError::CommandBuilder(_) => ErrorInfo::internal(\"CommandBuildError\"),\n            ApiError::Database(_) => ErrorInfo::internal(\"DatabaseError\"),\n            ApiError::Worktree(_) => ErrorInfo::internal(\"WorktreeError\"),\n            ApiError::Config(_) => ErrorInfo::internal(\"ConfigError\"),\n            ApiError::Io(_) => ErrorInfo::internal(\"IoError\"),\n            ApiError::Migration(MigrationError::Database(_)) => {\n                ErrorInfo::internal(\"MigrationError\")\n            }\n            ApiError::Migration(MigrationError::MigrationState(_)) => {\n                ErrorInfo::internal(\"MigrationError\")\n            }\n            ApiError::Migration(MigrationError::Workspace(_)) => {\n                ErrorInfo::internal(\"MigrationError\")\n            }\n            ApiError::Migration(MigrationError::RemoteClient(err)) => remote_client_error(err),\n            ApiError::Migration(MigrationError::NotAuthenticated) => ErrorInfo::with_status(\n                StatusCode::UNAUTHORIZED,\n                \"MigrationError\",\n                \"Not authenticated - please log in first.\",\n            ),\n            ApiError::Migration(MigrationError::OrganizationNotFound) => {\n                ErrorInfo::not_found(\"MigrationError\", \"Organization not found for user.\")\n            }\n            ApiError::Migration(MigrationError::EntityNotFound { entity_type, id }) => {\n                ErrorInfo::not_found(\n                    \"MigrationError\",\n                    format!(\"Entity not found: {} with id {}\", entity_type, id),\n                )\n            }\n            ApiError::Migration(MigrationError::MigrationInProgress) => {\n                ErrorInfo::conflict(\"MigrationError\", \"Migration already in progress.\")\n            }\n            ApiError::Migration(MigrationError::StatusMappingFailed(status)) => {\n                ErrorInfo::bad_request(\n                    \"MigrationError\",\n                    format!(\"Status mapping failed: unknown status '{}'\", status),\n                )\n            }\n            ApiError::Migration(MigrationError::BrokenReferenceChain(msg)) => {\n                ErrorInfo::bad_request(\"MigrationError\", format!(\"Broken reference chain: {}\", msg))\n            }\n            ApiError::Migration(MigrationError::RemoteError(msg)) => ErrorInfo::with_status(\n                StatusCode::BAD_GATEWAY,\n                \"MigrationError\",\n                format!(\"Remote error: {}\", msg),\n            ),\n        };\n\n        // Log internal errors so they are visible in server output.\n        if info.status.is_server_error() {\n            tracing::error!(\n                error_type = info.error_type,\n                status = %info.status,\n                error = ?self,\n                \"API request failed\"\n            );\n        }\n\n        let message = info\n            .message\n            .unwrap_or_else(|| format!(\"{}: {}\", info.error_type, self));\n        let response = ApiResponse::<()>::error(&message);\n        (info.status, Json(response)).into_response()\n    }\n}\n\nimpl From<TrustedKeyAuthError> for ApiError {\n    fn from(err: TrustedKeyAuthError) -> Self {\n        match err {\n            TrustedKeyAuthError::Unauthorized => ApiError::Unauthorized,\n            TrustedKeyAuthError::BadRequest(msg) => ApiError::BadRequest(msg),\n            TrustedKeyAuthError::Forbidden(msg) => ApiError::Forbidden(msg),\n            TrustedKeyAuthError::TooManyRequests(msg) => ApiError::TooManyRequests(msg),\n            TrustedKeyAuthError::Io(e) => ApiError::Io(e),\n        }\n    }\n}\n\nimpl From<RepoServiceError> for ApiError {\n    fn from(err: RepoServiceError) -> Self {\n        match err {\n            RepoServiceError::Database(db_err) => ApiError::Database(db_err),\n            RepoServiceError::Io(io_err) => ApiError::Io(io_err),\n            RepoServiceError::PathNotFound(path) => {\n                ApiError::BadRequest(format!(\"Path does not exist: {}\", path.display()))\n            }\n            RepoServiceError::PathNotDirectory(path) => {\n                ApiError::BadRequest(format!(\"Path is not a directory: {}\", path.display()))\n            }\n            RepoServiceError::NotGitRepository(path) => {\n                ApiError::BadRequest(format!(\"Path is not a git repository: {}\", path.display()))\n            }\n            RepoServiceError::NotFound => ApiError::BadRequest(\"Repository not found\".to_string()),\n            RepoServiceError::DirectoryAlreadyExists(path) => {\n                ApiError::BadRequest(format!(\"Directory already exists: {}\", path.display()))\n            }\n            RepoServiceError::Git(git_err) => {\n                ApiError::BadRequest(format!(\"Git error: {}\", git_err))\n            }\n            RepoServiceError::InvalidFolderName(name) => {\n                ApiError::BadRequest(format!(\"Invalid folder name: {}\", name))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/server/src/lib.rs",
    "content": "pub mod error;\npub mod middleware;\npub mod preview_proxy;\npub mod routes;\npub mod startup;\npub mod tunnel;\n\n// #[cfg(feature = \"cloud\")]\n// type DeploymentImpl = vibe_kanban_cloud::deployment::CloudDeployment;\n// #[cfg(not(feature = \"cloud\"))]\npub type DeploymentImpl = local_deployment::LocalDeployment;\n"
  },
  {
    "path": "crates/server/src/main.rs",
    "content": "use anyhow::{self, Error as AnyhowError};\nuse deployment::DeploymentError;\nuse server::startup;\nuse sqlx::Error as SqlxError;\nuse strip_ansi_escapes::strip;\nuse thiserror::Error;\nuse tracing_subscriber::{EnvFilter, prelude::*};\nuse utils::{\n    port_file::write_port_file_with_proxy,\n    sentry::{self as sentry_utils, SentrySource, sentry_layer},\n};\n\n#[derive(Debug, Error)]\npub enum VibeKanbanError {\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(transparent)]\n    Sqlx(#[from] SqlxError),\n    #[error(transparent)]\n    Deployment(#[from] DeploymentError),\n    #[error(transparent)]\n    Other(#[from] AnyhowError),\n}\n\n#[tokio::main]\nasync fn main() -> Result<(), VibeKanbanError> {\n    // Install rustls crypto provider before any TLS operations\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .expect(\"Failed to install rustls crypto provider\");\n\n    sentry_utils::init_once(SentrySource::Backend);\n\n    let log_level = std::env::var(\"RUST_LOG\").unwrap_or_else(|_| \"info\".to_string());\n    let filter_string = format!(\n        \"warn,server={level},services={level},db={level},executors={level},deployment={level},local_deployment={level},utils={level},codex_core=off\",\n        level = log_level\n    );\n    let env_filter = EnvFilter::try_new(filter_string).expect(\"Failed to create tracing filter\");\n    tracing_subscriber::registry()\n        .with(tracing_subscriber::fmt::layer().with_filter(env_filter))\n        .with(sentry_layer())\n        .init();\n\n    let port = std::env::var(\"BACKEND_PORT\")\n        .or_else(|_| std::env::var(\"PORT\"))\n        .ok()\n        .and_then(|s| {\n            let cleaned =\n                String::from_utf8(strip(s.as_bytes())).expect(\"UTF-8 after stripping ANSI\");\n            cleaned.trim().parse::<u16>().ok()\n        })\n        .unwrap_or_else(|| {\n            tracing::info!(\"No PORT environment variable set, using port 0 for auto-assignment\");\n            0\n        });\n\n    let proxy_port = std::env::var(\"PREVIEW_PROXY_PORT\")\n        .ok()\n        .and_then(|s| s.trim().parse::<u16>().ok())\n        .unwrap_or(0);\n\n    let host = std::env::var(\"HOST\").unwrap_or_else(|_| \"127.0.0.1\".to_string());\n\n    let handle =\n        startup::start_with_bind(&format!(\"{host}:{port}\"), &format!(\"{host}:{proxy_port}\"))\n            .await?;\n\n    if let Err(e) = write_port_file_with_proxy(handle.port, Some(handle.proxy_port)).await {\n        tracing::warn!(\"Failed to write port file: {}\", e);\n    }\n\n    // Production only: open browser\n    if !cfg!(debug_assertions) {\n        tracing::info!(\"Opening browser...\");\n        let url = handle.url();\n        tokio::spawn(async move {\n            if let Err(e) = utils::browser::open_browser(&url).await {\n                tracing::warn!(\n                    \"Failed to open browser automatically: {e}. Please open {url} manually.\"\n                );\n            }\n        });\n    }\n\n    // Cancel the server when a shutdown signal (Ctrl-C / SIGTERM) arrives.\n    let shutdown_token = handle.shutdown_token();\n    tokio::spawn(async move {\n        shutdown_signal().await;\n        tracing::info!(\"Shutdown signal received\");\n        shutdown_token.cancel();\n    });\n\n    handle.serve().await?;\n\n    Ok(())\n}\n\nasync fn shutdown_signal() {\n    // Always wait for Ctrl+C\n    let ctrl_c = async {\n        if let Err(e) = tokio::signal::ctrl_c().await {\n            tracing::error!(\"Failed to install Ctrl+C handler: {e}\");\n        }\n    };\n\n    #[cfg(unix)]\n    {\n        use tokio::signal::unix::{SignalKind, signal};\n\n        // Try to install SIGTERM handler, but don't panic if it fails\n        let terminate = async {\n            if let Ok(mut sigterm) = signal(SignalKind::terminate()) {\n                sigterm.recv().await;\n            } else {\n                tracing::error!(\"Failed to install SIGTERM handler\");\n                // Fallback: never resolves\n                std::future::pending::<()>().await;\n            }\n        };\n\n        tokio::select! {\n            _ = ctrl_c => {},\n            _ = terminate => {},\n        }\n    }\n\n    #[cfg(not(unix))]\n    {\n        // Only ctrl_c is available, so just await it\n        ctrl_c.await;\n    }\n}\n"
  },
  {
    "path": "crates/server/src/middleware/error_logging.rs",
    "content": "use axum::{\n    extract::{MatchedPath, OriginalUri, Request},\n    middleware::Next,\n    response::Response,\n};\n\npub async fn log_server_errors(request: Request, next: Next) -> Response {\n    let method = request.method().clone();\n    let uri = request\n        .extensions()\n        .get::<OriginalUri>()\n        .map(|original| original.0.clone())\n        .unwrap_or_else(|| request.uri().clone());\n    let matched_path = request\n        .extensions()\n        .get::<MatchedPath>()\n        .map(|matched| matched.as_str().to_owned());\n\n    let response = next.run(request).await;\n\n    if response.status().is_server_error() {\n        tracing::error!(\n            method = %method,\n            uri = %uri,\n            matched_path = matched_path.as_deref().unwrap_or(\"<unmatched>\"),\n            status = %response.status(),\n            \"API request returned server error\"\n        );\n    }\n\n    response\n}\n"
  },
  {
    "path": "crates/server/src/middleware/mod.rs",
    "content": "pub mod error_logging;\npub mod model_loaders;\npub mod origin;\npub mod relay_request_signature;\n\npub use error_logging::*;\npub use model_loaders::*;\npub use origin::*;\npub use relay_request_signature::*;\n"
  },
  {
    "path": "crates/server/src/middleware/model_loaders.rs",
    "content": "use axum::{\n    extract::{Path, Request, State},\n    http::StatusCode,\n    middleware::Next,\n    response::Response,\n};\nuse db::models::{\n    execution_process::ExecutionProcess, session::Session, tag::Tag, workspace::Workspace,\n};\nuse deployment::Deployment;\nuse uuid::Uuid;\n\nuse crate::DeploymentImpl;\n\npub async fn load_workspace_middleware(\n    State(deployment): State<DeploymentImpl>,\n    Path(workspace_id): Path<Uuid>,\n    mut request: Request,\n    next: Next,\n) -> Result<Response, StatusCode> {\n    // Load the Workspace from the database\n    let workspace = match Workspace::find_by_id(&deployment.db().pool, workspace_id).await {\n        Ok(Some(w)) => w,\n        Ok(None) => {\n            tracing::warn!(\"Workspace {} not found\", workspace_id);\n            return Err(StatusCode::NOT_FOUND);\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to fetch Workspace {}: {}\", workspace_id, e);\n            return Err(StatusCode::INTERNAL_SERVER_ERROR);\n        }\n    };\n\n    // Insert the workspace into extensions\n    request.extensions_mut().insert(workspace);\n\n    // Continue on\n    Ok(next.run(request).await)\n}\n\npub async fn load_execution_process_middleware(\n    State(deployment): State<DeploymentImpl>,\n    Path(process_id): Path<Uuid>,\n    mut request: Request,\n    next: Next,\n) -> Result<Response, StatusCode> {\n    // Load the execution process from the database\n    let execution_process =\n        match ExecutionProcess::find_by_id(&deployment.db().pool, process_id).await {\n            Ok(Some(process)) => process,\n            Ok(None) => {\n                tracing::warn!(\"ExecutionProcess {} not found\", process_id);\n                return Err(StatusCode::NOT_FOUND);\n            }\n            Err(e) => {\n                tracing::error!(\"Failed to fetch execution process {}: {}\", process_id, e);\n                return Err(StatusCode::INTERNAL_SERVER_ERROR);\n            }\n        };\n\n    // Inject the execution process into the request\n    request.extensions_mut().insert(execution_process);\n\n    // Continue to the next middleware/handler\n    Ok(next.run(request).await)\n}\n\n// Middleware that loads and injects Tag based on the tag_id path parameter\npub async fn load_tag_middleware(\n    State(deployment): State<DeploymentImpl>,\n    Path(tag_id): Path<Uuid>,\n    request: axum::extract::Request,\n    next: Next,\n) -> Result<Response, StatusCode> {\n    // Load the tag from the database\n    let tag = match Tag::find_by_id(&deployment.db().pool, tag_id).await {\n        Ok(Some(tag)) => tag,\n        Ok(None) => {\n            tracing::warn!(\"Tag {} not found\", tag_id);\n            return Err(StatusCode::NOT_FOUND);\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to fetch tag {}: {}\", tag_id, e);\n            return Err(StatusCode::INTERNAL_SERVER_ERROR);\n        }\n    };\n\n    // Insert the tag as an extension\n    let mut request = request;\n    request.extensions_mut().insert(tag);\n\n    // Continue with the next middleware/handler\n    Ok(next.run(request).await)\n}\n\npub async fn load_session_middleware(\n    State(deployment): State<DeploymentImpl>,\n    Path(session_id): Path<Uuid>,\n    mut request: Request,\n    next: Next,\n) -> Result<Response, StatusCode> {\n    let session = match Session::find_by_id(&deployment.db().pool, session_id).await {\n        Ok(Some(session)) => session,\n        Ok(None) => {\n            tracing::warn!(\"Session {} not found\", session_id);\n            return Err(StatusCode::NOT_FOUND);\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to fetch session {}: {}\", session_id, e);\n            return Err(StatusCode::INTERNAL_SERVER_ERROR);\n        }\n    };\n\n    request.extensions_mut().insert(session);\n    Ok(next.run(request).await)\n}\n"
  },
  {
    "path": "crates/server/src/middleware/origin.rs",
    "content": "use std::{net::IpAddr, sync::OnceLock};\n\nuse axum::{\n    body::Body,\n    extract::Request,\n    http::{StatusCode, header},\n    response::Response,\n};\nuse url::Url;\n\n#[derive(Clone, Debug, Eq, PartialEq)]\nstruct OriginKey {\n    https: bool,\n    host: String,\n    port: u16,\n}\n\nimpl OriginKey {\n    fn from_origin(origin: &str) -> Option<Self> {\n        let url = Url::parse(origin).ok()?;\n        let https = match url.scheme() {\n            \"http\" => false,\n            \"https\" => true,\n            _ => return None,\n        };\n        let host = normalize_host(url.host_str()?);\n        let port = url.port_or_known_default()?;\n        Some(Self { https, host, port })\n    }\n\n    fn from_host_header(host: &str, https: bool) -> Option<Self> {\n        let authority: axum::http::uri::Authority = host.parse().ok()?;\n        let host = normalize_host(authority.host());\n        let port = authority.port_u16().unwrap_or_else(|| default_port(https));\n        Some(Self { https, host, port })\n    }\n}\n\n#[allow(clippy::result_large_err)]\npub fn validate_origin<B>(req: &mut Request<B>) -> Result<(), Response> {\n    // Relay-proxied requests are authenticated through the relay's own session\n    // system, so origin validation is not applicable.\n    if is_relay_request(req) {\n        return Ok(());\n    }\n\n    let Some(origin) = get_origin_header(req) else {\n        return Ok(());\n    };\n\n    if origin.eq_ignore_ascii_case(\"null\") {\n        return Err(forbidden());\n    }\n\n    let host = get_host_header(req);\n\n    // quick short-circuit same-origin check\n    if host.is_some_and(|host| origin_matches_host(origin, host)) {\n        return Ok(());\n    }\n\n    let Some(origin_key) = OriginKey::from_origin(origin) else {\n        return Err(forbidden());\n    };\n\n    if allowed_origins()\n        .iter()\n        .any(|allowed| allowed == &origin_key)\n    {\n        return Ok(());\n    }\n\n    if let Some(host_key) =\n        host.and_then(|host| OriginKey::from_host_header(host, origin_key.https))\n        && host_key == origin_key\n    {\n        return Ok(());\n    }\n\n    Err(forbidden())\n}\n\nfn get_origin_header<B>(req: &Request<B>) -> Option<&str> {\n    get_header(req, header::ORIGIN)\n}\n\nfn get_host_header<B>(req: &Request<B>) -> Option<&str> {\n    get_header(req, header::HOST)\n}\n\nfn get_header<B>(req: &Request<B>, name: header::HeaderName) -> Option<&str> {\n    req.headers()\n        .get(name)\n        .and_then(|v| v.to_str().ok())\n        .map(str::trim)\n}\n\nfn is_relay_request<B>(req: &Request<B>) -> bool {\n    req.headers()\n        .get(\"x-vk-relayed\")\n        .and_then(|v| v.to_str().ok())\n        .is_some_and(|v| v.trim() == \"1\")\n}\n\nfn forbidden() -> Response {\n    Response::builder()\n        .status(StatusCode::FORBIDDEN)\n        .body(Body::empty())\n        .unwrap_or_else(|_| Response::new(Body::empty()))\n}\n\nfn origin_matches_host(origin: &str, host: &str) -> bool {\n    origin\n        .strip_prefix(\"http://\")\n        .or_else(|| origin.strip_prefix(\"https://\"))\n        .is_some_and(|rest| rest.eq_ignore_ascii_case(host))\n}\n\nfn normalize_host(host: &str) -> String {\n    let trimmed = host.trim().trim_start_matches('[').trim_end_matches(']');\n    let lower = trimmed.to_ascii_lowercase();\n    if lower == \"localhost\" {\n        return \"localhost\".to_string();\n    }\n    if let Ok(ip) = lower.parse::<IpAddr>() {\n        if ip.is_loopback() {\n            return \"localhost\".to_string();\n        }\n        return ip.to_string();\n    }\n    lower\n}\n\nfn default_port(https: bool) -> u16 {\n    if https { 443 } else { 80 }\n}\n\nfn allowed_origins() -> &'static Vec<OriginKey> {\n    static ALLOWED: OnceLock<Vec<OriginKey>> = OnceLock::new();\n    ALLOWED.get_or_init(|| {\n        let value = match std::env::var(\"VK_ALLOWED_ORIGINS\") {\n            Ok(value) => value,\n            Err(_) => return Vec::new(),\n        };\n\n        value\n            .split(',')\n            .filter_map(|origin| OriginKey::from_origin(origin.trim()))\n            .collect()\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use axum::http::{Request, header};\n\n    use super::*;\n\n    fn make_request(origin: Option<&str>, host: Option<&str>) -> Request<Body> {\n        let mut builder = Request::builder().uri(\"/test\").method(\"GET\");\n        if let Some(origin) = origin {\n            builder = builder.header(header::ORIGIN, origin);\n        }\n        if let Some(host) = host {\n            builder = builder.header(header::HOST, host);\n        }\n        builder.body(Body::empty()).unwrap()\n    }\n\n    fn is_forbidden(result: Result<(), Response>) -> bool {\n        matches!(result, Err(resp) if resp.status() == StatusCode::FORBIDDEN)\n    }\n\n    #[test]\n    fn no_origin_header_allows_request() {\n        let mut req = make_request(None, Some(\"example.com\"));\n        assert!(validate_origin(&mut req).is_ok());\n    }\n\n    #[test]\n    fn null_origin_is_forbidden() {\n        for null in [\"null\", \"NULL\", \"Null\"] {\n            let mut req = make_request(Some(null), Some(\"example.com\"));\n            assert!(is_forbidden(validate_origin(&mut req)));\n        }\n    }\n\n    #[test]\n    fn same_origin_allows_request() {\n        // HTTP, HTTPS, with port, case-insensitive\n        let cases = [\n            (\"http://example.com\", \"example.com\"),\n            (\"https://example.com\", \"example.com\"),\n            (\"http://example.com:8080\", \"example.com:8080\"),\n            (\"http://EXAMPLE.COM\", \"example.com\"),\n        ];\n        for (origin, host) in cases {\n            let mut req = make_request(Some(origin), Some(host));\n            assert!(validate_origin(&mut req).is_ok(), \"{origin} vs {host}\");\n        }\n    }\n\n    #[test]\n    fn cross_origin_forbidden() {\n        let cases = [\n            (\"http://unknown.com\", \"example.com\"),         // different host\n            (\"http://example.com:8080\", \"example.com:80\"), // different port\n            (\"ftp://example.com\", \"example.com\"),          // non-http scheme\n            (\"not-a-valid-url\", \"example.com\"),            // invalid URL\n            (\"http://example.com\", \"\"),                    // missing host (invalid)\n        ];\n        for (origin, host) in cases {\n            let host_opt = if host.is_empty() { None } else { Some(host) };\n            let mut req = make_request(Some(origin), host_opt);\n            assert!(is_forbidden(validate_origin(&mut req)), \"{origin}\");\n        }\n    }\n\n    #[test]\n    fn loopback_addresses_normalized_and_equivalent() {\n        // All loopback forms normalize to \"localhost\"\n        assert_eq!(\n            OriginKey::from_origin(\"http://localhost:3000\")\n                .unwrap()\n                .host,\n            \"localhost\"\n        );\n        assert_eq!(\n            OriginKey::from_origin(\"http://127.0.0.1:3000\")\n                .unwrap()\n                .host,\n            \"localhost\"\n        );\n        assert_eq!(\n            OriginKey::from_origin(\"http://[::1]:3000\").unwrap().host,\n            \"localhost\"\n        );\n\n        // Cross-loopback requests should be allowed\n        let mut req = make_request(Some(\"http://127.0.0.1:3000\"), Some(\"[::1]:3000\"));\n        assert!(validate_origin(&mut req).is_ok());\n    }\n\n    #[test]\n    fn default_ports_handled_correctly() {\n        assert_eq!(\n            OriginKey::from_origin(\"http://example.com\").unwrap().port,\n            80\n        );\n        assert_eq!(\n            OriginKey::from_origin(\"https://example.com\").unwrap().port,\n            443\n        );\n\n        // Explicit default port matches implicit\n        let mut req = make_request(Some(\"http://example.com:80\"), Some(\"example.com\"));\n        assert!(validate_origin(&mut req).is_ok());\n    }\n}\n"
  },
  {
    "path": "crates/server/src/middleware/relay_request_signature.rs",
    "content": "use std::time::{SystemTime, UNIX_EPOCH};\n\nuse axum::{\n    body::{Body, to_bytes},\n    extract::{OriginalUri, Request, State},\n    http::HeaderValue,\n    middleware::Next,\n    response::Response,\n};\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse deployment::Deployment;\nuse sha2::{Digest, Sha256};\nuse url::form_urlencoded;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\nconst RELAY_HEADER: &str = \"x-vk-relayed\";\nconst SIGNING_SESSION_HEADER: &str = \"x-vk-sig-session\";\nconst TIMESTAMP_HEADER: &str = \"x-vk-sig-ts\";\nconst NONCE_HEADER: &str = \"x-vk-sig-nonce\";\nconst REQUEST_SIGNATURE_HEADER: &str = \"x-vk-sig-signature\";\n\nconst RESPONSE_TIMESTAMP_HEADER: &str = \"x-vk-resp-ts\";\nconst RESPONSE_NONCE_HEADER: &str = \"x-vk-resp-nonce\";\nconst RESPONSE_SIGNATURE_HEADER: &str = \"x-vk-resp-signature\";\n\n#[derive(Clone, Debug)]\npub struct RelayRequestSignatureContext {\n    pub signing_session_id: Uuid,\n    pub request_nonce: String,\n}\n\n#[derive(Debug)]\nstruct RelayRequestSignatureInput {\n    signing_session_id: Uuid,\n    timestamp: i64,\n    nonce: String,\n    request_signature_b64: String,\n    path_and_query: String,\n}\n\npub async fn require_relay_request_signature(\n    State(deployment): State<DeploymentImpl>,\n    request: Request,\n    next: Next,\n) -> Result<Response, ApiError> {\n    if !is_relay_request(&request) {\n        return Ok(next.run(request).await);\n    }\n\n    let signature_input = extract_relay_request_signature_input(&request)?;\n\n    let method = request.method().as_str().to_string();\n    let (parts, body) = request.into_parts();\n    let body_bytes = to_bytes(body, usize::MAX)\n        .await\n        .map_err(|_| ApiError::Unauthorized)?;\n\n    let message = build_request_message(\n        signature_input.timestamp,\n        &method,\n        &signature_input.path_and_query,\n        &signature_input.signing_session_id,\n        &signature_input.nonce,\n        &body_bytes,\n    );\n\n    if let Err(error) = deployment\n        .relay_signing()\n        .verify_message(\n            signature_input.signing_session_id,\n            signature_input.timestamp,\n            &signature_input.nonce,\n            message.as_bytes(),\n            &signature_input.request_signature_b64,\n        )\n        .await\n    {\n        tracing::warn!(\n            signing_session_id = %signature_input.signing_session_id,\n            path = %signature_input.path_and_query,\n            reason = %error.as_str(),\n            \"Rejecting relay request with invalid signature\"\n        );\n        return Err(ApiError::Unauthorized);\n    }\n\n    let mut request = Request::from_parts(parts, Body::from(body_bytes));\n    request\n        .extensions_mut()\n        .insert(RelayRequestSignatureContext {\n            signing_session_id: signature_input.signing_session_id,\n            request_nonce: signature_input.nonce,\n        });\n\n    Ok(next.run(request).await)\n}\n\npub async fn sign_relay_response(\n    State(deployment): State<DeploymentImpl>,\n    request: Request,\n    next: Next,\n) -> Result<Response, ApiError> {\n    if !is_relay_request(&request) {\n        return Ok(next.run(request).await);\n    }\n\n    let signature_input = extract_relay_request_signature_input(&request)?;\n\n    let response = next.run(request).await;\n    let (mut parts, body) = response.into_parts();\n    let body_bytes = to_bytes(body, usize::MAX)\n        .await\n        .map_err(|_| ApiError::Unauthorized)?;\n    let response_timestamp = unix_timestamp_now().map_err(|_| ApiError::Unauthorized)?;\n    let response_nonce = Uuid::new_v4().simple().to_string();\n    let status = parts.status.as_u16();\n\n    let message = build_response_message(\n        response_timestamp,\n        status,\n        &signature_input.path_and_query,\n        &signature_input.signing_session_id,\n        &signature_input.nonce,\n        &response_nonce,\n        &body_bytes,\n    );\n\n    let response_signature = deployment\n        .relay_signing()\n        .sign_message(signature_input.signing_session_id, message.as_bytes())\n        .await\n        .map_err(|error| {\n            tracing::warn!(\n                signing_session_id = %signature_input.signing_session_id,\n                path = %signature_input.path_and_query,\n                reason = %error.as_str(),\n                \"Failed to sign relay response\"\n            );\n            ApiError::Unauthorized\n        })?;\n\n    insert_header(\n        &mut parts,\n        RESPONSE_TIMESTAMP_HEADER,\n        &response_timestamp.to_string(),\n    );\n    insert_header(&mut parts, RESPONSE_NONCE_HEADER, &response_nonce);\n    insert_header(&mut parts, RESPONSE_SIGNATURE_HEADER, &response_signature);\n\n    Ok(Response::from_parts(parts, Body::from(body_bytes)))\n}\n\nfn build_request_message(\n    timestamp: i64,\n    method: &str,\n    path_and_query: &str,\n    signing_session_id: &Uuid,\n    nonce: &str,\n    body: &[u8],\n) -> String {\n    let body_hash = BASE64_STANDARD.encode(Sha256::digest(body));\n    format!(\"v1|{timestamp}|{method}|{path_and_query}|{signing_session_id}|{nonce}|{body_hash}\")\n}\n\nfn build_response_message(\n    timestamp: i64,\n    status: u16,\n    path_and_query: &str,\n    signing_session_id: &Uuid,\n    request_nonce: &str,\n    response_nonce: &str,\n    body: &[u8],\n) -> String {\n    let body_hash = BASE64_STANDARD.encode(Sha256::digest(body));\n    format!(\n        \"v1|{timestamp}|{status}|{path_and_query}|{signing_session_id}|{request_nonce}|{response_nonce}|{body_hash}\"\n    )\n}\n\nfn relay_path_and_query(request: &Request) -> Result<String, ApiError> {\n    let Some(original_uri) = request.extensions().get::<OriginalUri>() else {\n        tracing::warn!(\"Rejecting relay request without OriginalUri extension\");\n        return Err(ApiError::Unauthorized);\n    };\n\n    Ok(original_uri\n        .0\n        .path_and_query()\n        .map(|path_and_query| path_and_query.as_str().to_string())\n        .unwrap_or_else(|| original_uri.0.path().to_string()))\n}\n\nfn extract_relay_request_signature_input(\n    request: &Request,\n) -> Result<RelayRequestSignatureInput, ApiError> {\n    if let Some(from_headers) = try_parse_signature_from_headers(request)? {\n        return Ok(from_headers);\n    }\n\n    if let Some(from_query) = try_parse_signature_from_query(request)? {\n        return Ok(from_query);\n    }\n\n    Err(ApiError::Unauthorized)\n}\n\nfn try_parse_signature_from_headers(\n    request: &Request,\n) -> Result<Option<RelayRequestSignatureInput>, ApiError> {\n    let signing_session = parse_header_optional::<String>(request, SIGNING_SESSION_HEADER);\n    let timestamp = parse_header_optional::<String>(request, TIMESTAMP_HEADER);\n    let nonce = parse_header_optional::<String>(request, NONCE_HEADER);\n    let request_signature = parse_header_optional::<String>(request, REQUEST_SIGNATURE_HEADER);\n\n    let any_present = signing_session.is_some()\n        || timestamp.is_some()\n        || nonce.is_some()\n        || request_signature.is_some();\n    let all_present = signing_session.is_some()\n        && timestamp.is_some()\n        && nonce.is_some()\n        && request_signature.is_some();\n\n    if any_present && !all_present {\n        return Err(ApiError::Unauthorized);\n    }\n\n    if !all_present {\n        return Ok(None);\n    }\n\n    let signing_session_id = signing_session\n        .and_then(|value| value.parse::<Uuid>().ok())\n        .ok_or(ApiError::Unauthorized)?;\n    let timestamp = timestamp\n        .and_then(|value| value.parse::<i64>().ok())\n        .ok_or(ApiError::Unauthorized)?;\n    let nonce = nonce.ok_or(ApiError::Unauthorized)?;\n    let request_signature_b64 = request_signature.ok_or(ApiError::Unauthorized)?;\n\n    Ok(Some(RelayRequestSignatureInput {\n        signing_session_id,\n        timestamp,\n        nonce,\n        request_signature_b64,\n        path_and_query: relay_path_and_query(request)?,\n    }))\n}\n\nfn try_parse_signature_from_query(\n    request: &Request,\n) -> Result<Option<RelayRequestSignatureInput>, ApiError> {\n    let Some(original_uri) = request.extensions().get::<OriginalUri>() else {\n        tracing::warn!(\"Rejecting relay request without OriginalUri extension\");\n        return Err(ApiError::Unauthorized);\n    };\n\n    let path = original_uri.0.path().to_string();\n    let query = original_uri.0.query().unwrap_or_default();\n    if query.is_empty() {\n        return Ok(None);\n    }\n\n    let mut filtered_query = form_urlencoded::Serializer::new(String::new());\n    let mut signing_session: Option<String> = None;\n    let mut timestamp: Option<String> = None;\n    let mut nonce: Option<String> = None;\n    let mut request_signature: Option<String> = None;\n\n    for (key, value) in form_urlencoded::parse(query.as_bytes()) {\n        match key.as_ref() {\n            SIGNING_SESSION_HEADER => signing_session = Some(value.into_owned()),\n            TIMESTAMP_HEADER => timestamp = Some(value.into_owned()),\n            NONCE_HEADER => nonce = Some(value.into_owned()),\n            REQUEST_SIGNATURE_HEADER => request_signature = Some(value.into_owned()),\n            _ => {\n                filtered_query.append_pair(&key, &value);\n            }\n        }\n    }\n\n    let any_present = signing_session.is_some()\n        || timestamp.is_some()\n        || nonce.is_some()\n        || request_signature.is_some();\n    let all_present = signing_session.is_some()\n        && timestamp.is_some()\n        && nonce.is_some()\n        && request_signature.is_some();\n\n    if any_present && !all_present {\n        return Err(ApiError::Unauthorized);\n    }\n\n    if !any_present {\n        return Ok(None);\n    }\n\n    let signing_session_id = signing_session\n        .and_then(|value| value.parse::<Uuid>().ok())\n        .ok_or(ApiError::Unauthorized)?;\n    let timestamp = timestamp\n        .and_then(|value| value.parse::<i64>().ok())\n        .ok_or(ApiError::Unauthorized)?;\n    let nonce = nonce.ok_or(ApiError::Unauthorized)?;\n    let request_signature_b64 = request_signature.ok_or(ApiError::Unauthorized)?;\n\n    let filtered = filtered_query.finish();\n    let path_and_query = if filtered.is_empty() {\n        path\n    } else {\n        format!(\"{path}?{filtered}\")\n    };\n\n    Ok(Some(RelayRequestSignatureInput {\n        signing_session_id,\n        timestamp,\n        nonce,\n        request_signature_b64,\n        path_and_query,\n    }))\n}\n\nfn parse_header_optional<T: std::str::FromStr>(request: &Request, name: &'static str) -> Option<T> {\n    request\n        .headers()\n        .get(name)\n        .and_then(|v| v.to_str().ok())\n        .and_then(|value| value.parse::<T>().ok())\n}\n\nfn insert_header(parts: &mut axum::http::response::Parts, name: &'static str, value: &str) {\n    if let Ok(value) = HeaderValue::from_str(value) {\n        parts.headers.insert(name, value);\n    }\n}\n\nfn unix_timestamp_now() -> Result<i64, ()> {\n    let duration = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .map_err(|_| ())?;\n    i64::try_from(duration.as_secs()).map_err(|_| ())\n}\n\nfn is_relay_request(request: &Request) -> bool {\n    request\n        .headers()\n        .get(RELAY_HEADER)\n        .and_then(|value| value.to_str().ok())\n        .is_some_and(|value| value.trim() == \"1\")\n}\n"
  },
  {
    "path": "crates/server/src/preview_proxy/bippy_bundle.js",
    "content": "var VKBippy=(()=>{var se=Object.defineProperty;var ut=Object.getOwnPropertyDescriptor;var ct=Object.getOwnPropertyNames;var ft=Object.prototype.hasOwnProperty;var mt=(e,t)=>{for(var n in t)se(e,n,{get:t[n],enumerable:!0})},pt=(e,t,n,r)=>{if(t&&typeof t==\"object\"||typeof t==\"function\")for(let a of ct(t))!ft.call(e,a)&&a!==n&&se(e,a,{get:()=>t[a],enumerable:!(r=ut(t,a))||r.enumerable});return e};var dt=e=>pt(se({},\"__esModule\",{value:!0}),e);var ir={};mt(ir,{getDisplayName:()=>L,getFiberFromHostInstance:()=>Re,getOwnerStack:()=>Xe,isCompositeFiber:()=>ge,isInstrumentationActive:()=>ve,isSourceFile:()=>et,normalizeFileName:()=>Fe,traverseFiber:()=>V});var De=\"0.5.28\",J=`bippy-${De}`,ke=Object.defineProperty,bt=Object.prototype.hasOwnProperty,U=()=>{},Ae=e=>{try{Function.prototype.toString.call(e).indexOf(\"^_^\")>-1&&setTimeout(()=>{throw Error(\"React is running in production mode, but dead code elimination has not been applied. Read how to correctly configure React for production: https://reactjs.org/link/perf-use-production-build\")})}catch{}},X=(e=D())=>\"getFiberRoots\"in e,He=!1,Ie,Y=(e=D())=>He?!0:(typeof e.inject==\"function\"&&(Ie=e.inject.toString()),!!Ie?.includes(\"(injected)\")),Z=new Set,j=new Set,Me=e=>{let t=new Map,n=0,r={_instrumentationIsActive:!1,_instrumentationSource:J,checkDCE:Ae,hasUnsupportedRendererAttached:!1,inject(a){let s=++n;return t.set(s,a),j.add(a),r._instrumentationIsActive||(r._instrumentationIsActive=!0,Z.forEach(u=>u())),s},on:U,onCommitFiberRoot:U,onCommitFiberUnmount:U,onPostCommitFiberRoot:U,renderers:t,supportsFiber:!0,supportsFlight:!0};try{ke(globalThis,\"__REACT_DEVTOOLS_GLOBAL_HOOK__\",{configurable:!0,enumerable:!0,get(){return r},set(u){if(u&&typeof u==\"object\"){let d=r.renderers;r=u,d.size>0&&(d.forEach((f,m)=>{j.add(f),u.renderers.set(m,f)}),Q(e))}}});let a=window.hasOwnProperty,s=!1;ke(window,\"hasOwnProperty\",{configurable:!0,value:function(...u){try{if(!s&&u[0]===\"__REACT_DEVTOOLS_GLOBAL_HOOK__\")return globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__=void 0,s=!0,-0}catch{}return a.apply(this,u)},writable:!0})}catch{Q(e)}return r},Q=e=>{e&&Z.add(e);try{let t=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!t)return;if(!t._instrumentationSource){t.checkDCE=Ae,t.supportsFiber=!0,t.supportsFlight=!0,t.hasUnsupportedRendererAttached=!1,t._instrumentationSource=J,t._instrumentationIsActive=!1;let n=X(t);if(n||(t.on=U),t.renderers.size){t._instrumentationIsActive=!0,Z.forEach(s=>s());return}let r=t.inject,a=Y(t);a&&!n&&(He=!0,t.inject({scheduleRefresh(){}})&&(t._instrumentationIsActive=!0)),t.inject=s=>{let u=r(s);return j.add(s),a&&t.renderers.set(u,s),t._instrumentationIsActive=!0,Z.forEach(d=>d()),u}}(t.renderers.size||t._instrumentationIsActive||Y())&&e?.()}catch{}},ie=()=>bt.call(globalThis,\"__REACT_DEVTOOLS_GLOBAL_HOOK__\"),D=e=>ie()?(Q(e),globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__):Me(e),je=()=>!!(typeof window<\"u\"&&(window.document?.createElement||window.navigator?.product===\"ReactNative\")),G=()=>{try{je()&&D()}catch{}};G();var ee=0,te=1;var le=5;var re=11,ue=13,Le=14,ne=15,ce=16;var fe=19;var me=26,pe=27,de=28,be=30;var gt=2,ht=4096,yt=4;var Tt=16,vt=32,Rt=1024,St=8192,mr=gt|yt|Tt|vt|ht|St|Rt;var ge=e=>{switch(e.tag){case te:case re:case ee:case Le:case ne:return!0;default:return!1}};function V(e,t,n=!1){if(!e)return null;let r=t(e);if(r instanceof Promise)return(async()=>{if(await r===!0)return e;let s=n?e.return:e.child;for(;s;){let u=await ye(s,t,n);if(u)return u;s=n?null:s.sibling}return null})();if(r===!0)return e;let a=n?e.return:e.child;for(;a;){let s=he(a,t,n);if(s)return s;a=n?null:a.sibling}return null}var he=(e,t,n=!1)=>{if(!e)return null;if(t(e)===!0)return e;let r=n?e.return:e.child;for(;r;){let a=he(r,t,n);if(a)return a;r=n?null:r.sibling}return null},ye=async(e,t,n=!1)=>{if(!e)return null;if(await t(e)===!0)return e;let r=n?e.return:e.child;for(;r;){let a=await ye(r,t,n);if(a)return a;r=n?null:r.sibling}return null};var Te=e=>{let t=e;return typeof t==\"function\"?t:typeof t==\"object\"&&t?Te(t.type||t.render):null},L=e=>{let t=e;if(typeof t==\"string\")return t;if(typeof t!=\"function\"&&!(typeof t==\"object\"&&t))return null;let n=t.displayName||t.name||null;if(n)return n;let r=Te(t);return r&&(r.displayName||r.name)||null};var ve=()=>!!D()._instrumentationIsActive||X()||Y();var Re=e=>{let t=D();for(let n of t.renderers.values())try{let r=n.findFiberByHostInstance?.(e);if(r)return r}catch{}if(typeof e==\"object\"&&e){if(\"_reactRootContainer\"in e)return e._reactRootContainer?._internalRoot?.current?.child;for(let n in e)if(n.startsWith(\"__reactContainer$\")||n.startsWith(\"__reactInternalInstance$\")||n.startsWith(\"__reactFiber\"))return e[n]||null}return null},Nt=Error();var Ct=Object.create,Ge=Object.defineProperty,Ft=Object.getOwnPropertyDescriptor,_t=Object.getOwnPropertyNames,Ot=Object.getPrototypeOf,wt=Object.prototype.hasOwnProperty,Et=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),kt=(e,t,n,r)=>{if(t&&typeof t==\"object\"||typeof t==\"function\")for(var a=_t(t),s=0,u=a.length,d;s<u;s++)d=a[s],!wt.call(e,d)&&d!==n&&Ge(e,d,{get:(f=>t[f]).bind(null,d),enumerable:!(r=Ft(t,d))||r.enumerable});return e},It=(e,t,n)=>(n=e==null?{}:Ct(Ot(e)),kt(t||!e||!e.__esModule?Ge(n,\"default\",{value:e,enumerable:!0}):n,e)),xe=/^[a-zA-Z][a-zA-Z\\d+\\-.]*:/,Dt=[\"rsc://\",\"file:///\",\"webpack://\",\"webpack-internal://\",\"node:\",\"turbopack://\",\"metro://\",\"/app-pages-browser/\"],$e=\"about://React/\",At=[\"<anonymous>\",\"eval\",\"\"],Ht=/\\.(jsx|tsx|ts|js)$/,Mt=/(\\.min|bundle|chunk|vendor|vendors|runtime|polyfill|polyfills)\\.(js|mjs|cjs)$|(chunk|bundle|vendor|vendors|runtime|polyfill|polyfills|framework|app|main|index)[-_.][A-Za-z0-9_-]{4,}\\.(js|mjs|cjs)$|[\\da-f]{8,}\\.(js|mjs|cjs)$|[-_.][\\da-f]{20,}\\.(js|mjs|cjs)$|\\/dist\\/|\\/build\\/|\\/.next\\/|\\/out\\/|\\/node_modules\\/|\\.webpack\\.|\\.vite\\.|\\.turbopack\\./i,jt=/^\\?[\\w~.\\-]+(?:=[^&#]*)?(?:&[\\w~.\\-]+(?:=[^&#]*)?)*$/,Ve=\"(at Server)\",Lt=/(^|@)\\S+:\\d+/,We=/^\\s*at .*(\\S+:\\d+|\\(native\\))/m,xt=/^(eval@)?(\\[native code\\])?$/;var Ke=(e,t)=>{if(t?.includeInElement!==!1){let n=e.split(`\n`),r=[];for(let a of n)if(/^\\s*at\\s+/.test(a)){let s=Pe(a,void 0)[0];s&&r.push(s)}else if(/^\\s*in\\s+/.test(a)){let s=a.replace(/^\\s*in\\s+/,\"\").replace(/\\s*\\(at .*\\)$/,\"\");r.push({functionName:s,source:a})}else if(a.match(Lt)){let s=ze(a,void 0)[0];s&&r.push(s)}return Ce(r,t)}return e.match(We)?Pe(e,t):ze(e,t)},qe=e=>{if(!e.includes(\":\"))return[e,void 0,void 0];let t=e.startsWith(\"(\")&&/:\\d+\\)$/.test(e),n=t?e.slice(1,-1):e,r=/(.+?)(?::(\\d+))?(?::(\\d+))?$/,a=r.exec(n);return a?[a[1],a[2]||void 0,a[3]||void 0]:[n,void 0,void 0]},Ce=(e,t)=>t&&t.slice!=null?Array.isArray(t.slice)?e.slice(t.slice[0],t.slice[1]):e.slice(0,t.slice):e;var Pe=(e,t)=>Ce(e.split(`\n`).filter(r=>!!r.match(We)),t).map(r=>{let a=r;a.includes(\"(eval \")&&(a=a.replace(/eval code/g,\"eval\").replace(/(\\(eval at [^()]*)|(,.*$)/g,\"\"));let s=a.replace(/^\\s+/,\"\").replace(/\\(eval code/g,\"(\").replace(/^.*?\\s+/,\"\"),u=s.match(/ (\\(.+\\)$)/);s=u?s.replace(u[0],\"\"):s;let d=qe(u?u[1]:s),f=u&&s||void 0,m=[\"eval\",\"<anonymous>\"].includes(d[0])?void 0:d[0];return{functionName:f,fileName:m,lineNumber:d[1]?+d[1]:void 0,columnNumber:d[2]?+d[2]:void 0,source:a}});var ze=(e,t)=>Ce(e.split(`\n`).filter(r=>!r.match(xt)),t).map(r=>{let a=r;if(a.includes(\" > eval\")&&(a=a.replace(/ line (\\d+)(?: > eval line \\d+)* > eval:\\d+:\\d+/g,\":$1\")),!a.includes(\"@\")&&!a.includes(\":\"))return{functionName:a};{let s=/(([^\\n\\r\"\\u2028\\u2029]*\".[^\\n\\r\"\\u2028\\u2029]*\"[^\\n\\r@\\u2028\\u2029]*(?:@[^\\n\\r\"\\u2028\\u2029]*\"[^\\n\\r@\\u2028\\u2029]*)*(?:[\\n\\r\\u2028\\u2029][^@]*)?)?[^@]*)@/,u=a.match(s),d=u&&u[1]?u[1]:void 0,f=qe(a.replace(s,\"\"));return{functionName:d,fileName:f[0],lineNumber:f[1]?+f[1]:void 0,columnNumber:f[2]?+f[2]:void 0,source:a}}});var $t=Et((e,t)=>{(function(n,r){typeof e==\"object\"&&t!==void 0?r(e):typeof define==\"function\"&&define.amd?define([\"exports\"],r):(n=typeof globalThis<\"u\"?globalThis:n||self,r(n.sourcemapCodec={}))})(void 0,function(n){\"use strict\";let r=44,a=59,s=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\",u=new Uint8Array(64),d=new Uint8Array(128);for(let c=0;c<s.length;c++){let i=s.charCodeAt(c);u[c]=i,d[i]=c}function f(c,i){let o=0,l=0,p=0;do{let R=c.next();p=d[R],o|=(p&31)<<l,l+=5}while(p&32);let b=o&1;return o>>>=1,b&&(o=-2147483648|-o),i+o}function m(c,i,o){let l=i-o;l=l<0?-l<<1|1:l<<1;do{let p=l&31;l>>>=5,l>0&&(p|=32),c.write(u[p])}while(l>0);return i}function _(c,i){return c.pos>=i?!1:c.peek()!==r}let H=1024*16,C=typeof TextDecoder<\"u\"?new TextDecoder:typeof Buffer<\"u\"?{decode(c){return Buffer.from(c.buffer,c.byteOffset,c.byteLength).toString()}}:{decode(c){let i=\"\";for(let o=0;o<c.length;o++)i+=String.fromCharCode(c[o]);return i}};class h{constructor(){this.pos=0,this.out=\"\",this.buffer=new Uint8Array(H)}write(i){let{buffer:o}=this;o[this.pos++]=i,this.pos===H&&(this.out+=C.decode(o),this.pos=0)}flush(){let{buffer:i,out:o,pos:l}=this;return l>0?o+C.decode(i.subarray(0,l)):o}}class y{constructor(i){this.pos=0,this.buffer=i}next(){return this.buffer.charCodeAt(this.pos++)}peek(){return this.buffer.charCodeAt(this.pos)}indexOf(i){let{buffer:o,pos:l}=this,p=o.indexOf(i,l);return p===-1?o.length:p}}let F=[];function x(c){let{length:i}=c,o=new y(c),l=[],p=[],b=0;for(;o.pos<i;o.pos++){b=f(o,b);let R=f(o,0);if(!_(o,i)){let w=p.pop();w[2]=b,w[3]=R;continue}let T=f(o,0),N=f(o,0),S=N&1,v=S?[b,R,0,0,T,f(o,0)]:[b,R,0,0,T],O=F;if(_(o,i)){O=[];do{let w=f(o,0);O.push(w)}while(_(o,i))}v.vars=O,l.push(v),p.push(v)}return l}function K(c){let i=new h;for(let o=0;o<c.length;)o=_e(c,o,i,[0]);return i.flush()}function _e(c,i,o,l){let p=c[i],{0:b,1:R,2:T,3:N,4:S,vars:v}=p;i>0&&o.write(r),l[0]=m(o,b,l[0]),m(o,R,0),m(o,S,0);let O=p.length===6?1:0;m(o,O,0),p.length===6&&m(o,p[5],0);for(let w of v)m(o,w,0);for(i++;i<c.length;){let w=c[i],{0:g,1:E}=w;if(g>T||g===T&&E>=N)break;i=_e(c,i,o,l)}return o.write(r),l[0]=m(o,T,l[0]),m(o,N,0),i}function tt(c){let{length:i}=c,o=new y(c),l=[],p=[],b=0,R=0,T=0,N=0,S=0,v=0,O=0,w=0;do{let g=o.indexOf(\";\"),E=0;for(;o.pos<g;o.pos++){if(E=f(o,E),!_(o,g)){let k=p.pop();k[2]=b,k[3]=E;continue}let I=f(o,0),q=I&1,P=I&2,z=I&4,Ee=null,oe=F,M;if(q){let k=f(o,R);T=f(o,R===k?T:0),R=k,M=[b,E,0,0,k,T]}else M=[b,E,0,0];if(M.isScope=!!z,P){let k=N,$=S;N=f(o,N);let B=k===N;S=f(o,B?S:0),v=f(o,B&&$===S?v:0),Ee=[N,S,v]}if(M.callsite=Ee,_(o,g)){oe=[];do{O=b,w=E;let k=f(o,0),$;if(k<-1){$=[[f(o,0)]];for(let B=-1;B>k;B--){let it=O;O=f(o,O),w=f(o,O===it?w:0);let lt=f(o,0);$.push([lt,O,w])}}else $=[[k]];oe.push($)}while(_(o,g))}M.bindings=oe,l.push(M),p.push(M)}b++,o.pos=g+1}while(o.pos<i);return l}function rt(c){if(c.length===0)return\"\";let i=new h;for(let o=0;o<c.length;)o=Oe(c,o,i,[0,0,0,0,0,0,0]);return i.flush()}function Oe(c,i,o,l){let p=c[i],{0:b,1:R,2:T,3:N,isScope:S,callsite:v,bindings:O}=p;l[0]<b?(we(o,l[0],b),l[0]=b,l[1]=0):i>0&&o.write(r),l[1]=m(o,p[1],l[1]);let w=(p.length===6?1:0)|(v?2:0)|(S?4:0);if(m(o,w,0),p.length===6){let{4:g,5:E}=p;g!==l[2]&&(l[3]=0),l[2]=m(o,g,l[2]),l[3]=m(o,E,l[3])}if(v){let{0:g,1:E,2:I}=p.callsite;g===l[4]?E!==l[5]&&(l[6]=0):(l[5]=0,l[6]=0),l[4]=m(o,g,l[4]),l[5]=m(o,E,l[5]),l[6]=m(o,I,l[6])}if(O)for(let g of O){g.length>1&&m(o,-g.length,0);let E=g[0][0];m(o,E,0);let I=b,q=R;for(let P=1;P<g.length;P++){let z=g[P];I=m(o,z[1],I),q=m(o,z[2],q),m(o,z[0],0)}}for(i++;i<c.length;){let g=c[i],{0:E,1:I}=g;if(E>T||E===T&&I>=N)break;i=Oe(c,i,o,l)}return l[0]<T?(we(o,l[0],T),l[0]=T,l[1]=0):o.write(r),l[1]=m(o,N,l[1]),i}function we(c,i,o){do c.write(a);while(++i<o)}function nt(c){let{length:i}=c,o=new y(c),l=[],p=0,b=0,R=0,T=0,N=0;do{let S=o.indexOf(\";\"),v=[],O=!0,w=0;for(p=0;o.pos<S;){let g;p=f(o,p),p<w&&(O=!1),w=p,_(o,S)?(b=f(o,b),R=f(o,R),T=f(o,T),_(o,S)?(N=f(o,N),g=[p,b,R,T,N]):g=[p,b,R,T]):g=[p],v.push(g),o.pos++}O||at(v),l.push(v),o.pos=S+1}while(o.pos<=i);return l}function at(c){c.sort(ot)}function ot(c,i){return c[0]-i[0]}function st(c){let i=new h,o=0,l=0,p=0,b=0;for(let R=0;R<c.length;R++){let T=c[R];if(R>0&&i.write(a),T.length===0)continue;let N=0;for(let S=0;S<T.length;S++){let v=T[S];S>0&&i.write(r),N=m(i,v[0],N),v.length!==1&&(o=m(i,v[1],o),l=m(i,v[2],l),p=m(i,v[3],p),v.length!==4&&(b=m(i,v[4],b)))}}return i.flush()}n.decode=nt,n.decodeGeneratedRanges=tt,n.decodeOriginalScopes=x,n.encode=st,n.encodeGeneratedRanges=rt,n.encodeOriginalScopes=K,Object.defineProperty(n,\"__esModule\",{value:!0})})}),Ze=It($t(),1),Qe=/^[a-zA-Z][a-zA-Z\\d+\\-.]*:/,Pt=/^data:application\\/json[^,]+base64,/,zt=/(?:\\/\\/[@#][ \\t]+sourceMappingURL=([^\\s'\"]+?)[ \\t]*$)|(?:\\/\\*[@#][ \\t]+sourceMappingURL=([^*]+?)[ \\t]*(?:\\*\\/)[ \\t]*$)/,Je=typeof WeakRef<\"u\",W=new Map,ae=new Map,Bt=e=>Je&&e instanceof WeakRef,Be=(e,t,n,r)=>{if(n<0||n>=e.length)return null;let a=e[n];if(!a||a.length===0)return null;let s=null;for(let _ of a)if(_[0]<=r)s=_;else break;if(!s||s.length<4)return null;let[,u,d,f]=s;if(u===void 0||d===void 0||f===void 0)return null;let m=t[u];return m?{columnNumber:f,fileName:m,lineNumber:d+1}:null},Ut=(e,t,n)=>{if(e.sections){let r=null;for(let u of e.sections)if(t>u.offset.line||t===u.offset.line&&n>=u.offset.column)r=u;else break;if(!r)return null;let a=t-r.offset.line,s=t===r.offset.line?n-r.offset.column:n;return Be(r.map.mappings,r.map.sources,a,s)}return Be(e.mappings,e.sources,t-1,n)},Yt=(e,t)=>{let n=t.split(`\n`),r;for(let s=n.length-1;s>=0&&!r;s--){let u=n[s].match(zt);u&&(r=u[1]||u[2])}if(!r)return null;let a=Qe.test(r);if(!(Pt.test(r)||a||r.startsWith(\"/\"))){let s=e.split(\"/\");s[s.length-1]=r,r=s.join(\"/\")}return r},Gt=e=>({file:e.file,mappings:(0,Ze.decode)(e.mappings),names:e.names,sourceRoot:e.sourceRoot,sources:e.sources,sourcesContent:e.sourcesContent,version:3}),Vt=e=>{let t=e.sections.map(({map:r,offset:a})=>({map:{...r,mappings:(0,Ze.decode)(r.mappings)},offset:a})),n=new Set;for(let r of t)for(let a of r.map.sources)n.add(a);return{file:e.file,mappings:[],names:[],sections:t,sourceRoot:void 0,sources:Array.from(n),sourcesContent:void 0,version:3}},Ue=e=>{if(!e)return!1;let t=e.trim();if(!t)return!1;let n=t.match(Qe);if(!n)return!0;let r=n[0].toLowerCase();return r===\"http:\"||r===\"https:\"},Wt=async(e,t=fetch)=>{if(!Ue(e))return null;let n;try{let a=await t(e);if(!a.ok)return null;n=await a.text()}catch{return null}if(!n)return null;let r=Yt(e,n);if(!r||!Ue(r))return null;try{let a=await t(r);if(!a.ok)return null;let s=await a.json();return\"sections\"in s?Vt(s):Gt(s)}catch{return null}},Kt=async(e,t=!0,n)=>{if(t&&W.has(e)){let s=W.get(e);if(s==null)return null;if(Bt(s)){let u=s.deref();if(u)return u;W.delete(e)}else return s}if(t&&ae.has(e))return ae.get(e);let r=Wt(e,n);t&&ae.set(e,r);let a=await r;return t&&ae.delete(e),t&&(a===null?W.set(e,null):W.set(e,Je?new WeakRef(a):a)),a},qt=async(e,t=!0,n)=>await Promise.all(e.map(async r=>{if(!r.fileName)return r;let a=await Kt(r.fileName,t,n);if(!a||typeof r.lineNumber!=\"number\"||typeof r.columnNumber!=\"number\")return r;let s=Ut(a,r.lineNumber,r.columnNumber);return s?{...r,source:s.fileName&&r.source?r.source.replace(r.fileName,s.fileName):r.source,fileName:s.fileName,lineNumber:s.lineNumber,columnNumber:s.columnNumber,isSymbolicated:!0}:r})),Zt=e=>e._debugStack instanceof Error&&typeof e._debugStack?.stack==\"string\",Qt=()=>{let e=D();for(let t of[...Array.from(j),...Array.from(e.renderers.values())]){let n=t.currentDispatcherRef;if(n&&typeof n==\"object\")return\"H\"in n?n.H:n.current}return null},Ye=e=>{for(let t of j){let n=t.currentDispatcherRef;n&&typeof n==\"object\"&&(\"H\"in n?n.H=e:n.current=e)}},A=e=>`\n    in ${e}`,Jt=(e,t)=>{let n=A(e);return t&&(n+=` (at ${t})`),n},Se=!1,Ne=(e,t)=>{if(!e||Se)return\"\";let n=Error.prepareStackTrace;Error.prepareStackTrace=void 0,Se=!0;let r=Qt();Ye(null);let a=console.error,s=console.warn;console.error=()=>{},console.warn=()=>{};try{let f={DetermineComponentFrameRoot(){let C;try{if(t){let h=function(){throw Error()};if(Object.defineProperty(h.prototype,\"props\",{set:function(){throw Error()}}),typeof Reflect==\"object\"&&Reflect.construct){try{Reflect.construct(h,[])}catch(y){C=y}Reflect.construct(e,[],h)}else{try{h.call()}catch(y){C=y}e.call(h.prototype)}}else{try{throw Error()}catch(y){C=y}let h=e();h&&typeof h.catch==\"function\"&&h.catch(()=>{})}}catch(h){if(h instanceof Error&&C instanceof Error&&typeof h.stack==\"string\")return[h.stack,C.stack]}return[null,null]}};f.DetermineComponentFrameRoot.displayName=\"DetermineComponentFrameRoot\",Object.getOwnPropertyDescriptor(f.DetermineComponentFrameRoot,\"name\")?.configurable&&Object.defineProperty(f.DetermineComponentFrameRoot,\"name\",{value:\"DetermineComponentFrameRoot\"});let[_,H]=f.DetermineComponentFrameRoot();if(_&&H){let C=_.split(`\n`),h=H.split(`\n`),y=0,F=0;for(;y<C.length&&!C[y].includes(\"DetermineComponentFrameRoot\");)y++;for(;F<h.length&&!h[F].includes(\"DetermineComponentFrameRoot\");)F++;if(y===C.length||F===h.length)for(y=C.length-1,F=h.length-1;y>=1&&F>=0&&C[y]!==h[F];)F--;for(;y>=1&&F>=0;y--,F--)if(C[y]!==h[F]){if(y!==1||F!==1)do if(y--,F--,F<0||C[y]!==h[F]){let x=`\n${C[y].replace(\" at new \",\" at \")}`,K=L(e);return K&&x.includes(\"<anonymous>\")&&(x=x.replace(\"<anonymous>\",K)),x}while(y>=1&&F>=0);break}}}finally{Se=!1,Error.prepareStackTrace=n,Ye(r),console.error=a,console.warn=s}let u=e?L(e):\"\";return u?A(u):\"\"},Xt=(e,t)=>{let n=e.tag,r=\"\";switch(n){case de:r=A(\"Activity\");break;case te:r=Ne(e.type,!0);break;case re:r=Ne(e.type.render,!1);break;case ee:case ne:r=Ne(e.type,!1);break;case le:case me:case pe:r=A(e.type);break;case ce:r=A(\"Lazy\");break;case ue:r=e.child!==t&&t!==null?A(\"Suspense Fallback\"):A(\"Suspense\");break;case fe:r=A(\"SuspenseList\");break;case be:r=A(\"ViewTransition\");break;default:return\"\"}return r},er=e=>{try{let t=\"\",n=e,r=null;do{t+=Xt(n,r);let a=n._debugInfo;if(a&&Array.isArray(a))for(let s=a.length-1;s>=0;s--){let u=a[s];typeof u.name==\"string\"&&(t+=Jt(u.name,u.env))}r=n,n=n.return}while(n);return t}catch(t){return t instanceof Error?`\nError generating stack: ${t.message}\n${t.stack}`:\"\"}},tr=e=>{let t=Error.prepareStackTrace;Error.prepareStackTrace=void 0;let n=e;if(!n)return\"\";Error.prepareStackTrace=t,n.startsWith(`Error: react-stack-top-frame\n`)&&(n=n.slice(29));let r=n.indexOf(`\n`);if(r!==-1&&(n=n.slice(r+1)),r=Math.max(n.indexOf(\"react_stack_bottom_frame\"),n.indexOf(\"react-stack-bottom-frame\")),r!==-1&&(r=n.lastIndexOf(`\n`,r)),r!==-1)n=n.slice(0,r);else return\"\";return n},rr=e=>!!(e.fileName?.startsWith(\"rsc://\")&&e.functionName),nr=(e,t)=>e.fileName===t.fileName&&e.lineNumber===t.lineNumber&&e.columnNumber===t.columnNumber,ar=e=>{let t=new Map;for(let n of e)for(let r of n.stackFrames){if(!rr(r))continue;let a=r.functionName,s=t.get(a)??[];s.some(d=>nr(d,r))||(s.push(r),t.set(a,s))}return t},or=(e,t,n)=>{if(!e.functionName)return{...e,isServer:!0};let r=t.get(e.functionName);if(!r||r.length===0)return{...e,isServer:!0};let a=n.get(e.functionName)??0,s=r[a%r.length];return n.set(e.functionName,a+1),{...e,isServer:!0,fileName:s.fileName,lineNumber:s.lineNumber,columnNumber:s.columnNumber,source:e.source?.replace(Ve,`(${s.fileName}:${s.lineNumber}:${s.columnNumber})`)}},sr=e=>{let t=[];return V(e,n=>{if(!Zt(n))return;let r=typeof n.type==\"string\"?n.type:L(n.type)||\"<anonymous>\";t.push({componentName:r,stackFrames:Ke(tr(n._debugStack?.stack))})},!0),t},Xe=async(e,t=!0,n)=>{let r=sr(e),a=Ke(er(e)),s=ar(r),u=new Map,d=a.map(m=>m.source?.includes(Ve)??!1?or(m,s,u):m),f=d.filter((m,_,H)=>{if(_===0)return!0;let C=H[_-1];return m.functionName!==C.functionName});return qt(f,t,n)};var Fe=e=>{if(!e||At.some(a=>a===e))return\"\";let t=e;if(t.startsWith(\"http://\")||t.startsWith(\"https://\"))try{t=new URL(t).pathname}catch{}if(t.startsWith($e)){let a=t.slice($e.length),s=a.indexOf(\"/\"),u=a.indexOf(\":\");t=s!==-1&&(u===-1||s<u)?a.slice(s+1):a}let n=!0;for(;n;){n=!1;for(let a of Dt)if(t.startsWith(a)){t=t.slice(a.length),a===\"file:///\"&&(t=`/${t.replace(/^\\/+/,\"\")}`),n=!0;break}}if(xe.test(t)){let a=t.match(xe);a&&(t=t.slice(a[0].length))}if(t.startsWith(\"//\")){let a=t.indexOf(\"/\",2);t=a===-1?\"\":t.slice(a)}let r=t.indexOf(\"?\");if(r!==-1){let a=t.slice(r);jt.test(a)&&(t=t.slice(0,r))}return t},et=e=>{let t=Fe(e);return!(!t||!Ht.test(t)||Mt.test(t))};G();return dt(ir);})();\n/*! Bundled license information:\n\nbippy/dist/rdt-hook-5L_ky0r0.js:\nbippy/dist/install-hook-only-CgvoC7AQ.js:\nbippy/dist/core-U1d648PH.js:\nbippy/dist/index.js:\nbippy/dist/source.js:\n  (**\n   * @license bippy\n   *\n   * Copyright (c) Aiden Bai\n   *\n   * This source code is licensed under the MIT license found in the\n   * LICENSE file in the root directory of this source tree.\n   *)\n*/\n"
  },
  {
    "path": "crates/server/src/preview_proxy/click_to_component_script.js",
    "content": "(function() {\n  'use strict';\n\n  // =============================================================================\n  // === CORE: State & Utilities ===\n  // =============================================================================\n\n  var SOURCE = 'click-to-component';\n  var inspectModeActive = false;\n  var overlay = null;\n  var nameLabel = null;\n  var lastHoveredElement = null;\n\n  // --- Helper: send message to parent ---\n  function send(type, payload, version) {\n    try {\n      var msg = { source: SOURCE, type: type, payload: payload };\n      if (version) msg.version = version;\n      window.parent.postMessage(msg, '*');\n    } catch(e) {}\n  }\n\n  // --- Helper: truncate attribute value ---\n  function truncateAttr(val) {\n    return val.length > 50 ? val.slice(0, 50) + '...' : val;\n  }\n\n  // --- Helper: generate HTML preview of element ---\n  function getHTMLPreview(element) {\n    var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown';\n    var attrs = '';\n    if (element.attributes) {\n      for (var i = 0; i < element.attributes.length; i++) {\n        var attr = element.attributes[i];\n        attrs += ' ' + attr.name + '=\"' + truncateAttr(attr.value) + '\"';\n      }\n    }\n    var text = '';\n    if (element.innerText) {\n      text = element.innerText.trim();\n      if (text.length > 100) text = text.slice(0, 100) + '...';\n    }\n    if (text) {\n      return '<' + tagName + attrs + '>\\n  ' + text + '\\n</' + tagName + '>';\n    }\n    return '<' + tagName + attrs + ' />';\n  }\n\n  // =============================================================================\n  // === ADAPTER INTERFACE ===\n  // =============================================================================\n  //\n  // Each adapter implements:\n  //   {\n  //     name: string,\n  //     detect: function(element: HTMLElement) -> boolean,\n  //     getComponentInfo: function(element: HTMLElement) -> Promise<ComponentPayload | null>,\n  //     getOverlayLabel?: function(element: HTMLElement) -> string | null\n  //   }\n  //\n  // ComponentPayload:\n  //   {\n  //     framework: string,\n  //     component: string,\n  //     tagName?: string,\n  //     file?: string,\n  //     line?: number,\n  //     column?: number,\n  //     cssClass?: string,\n  //     stack?: Array<{ name: string, file?: string }>,\n  //     htmlPreview: string\n  //   }\n  //\n  // The dispatcher iterates adapters in order. First adapter where detect()\n  // returns true gets getComponentInfo() called. If it returns null, the\n  // HTML fallback is used.\n\n  // =============================================================================\n  // === REACT ADAPTER ===\n  // =============================================================================\n\n  // Internal component name lists to filter out\n  var NEXT_INTERNAL = ['InnerLayoutRouter', 'RedirectErrorBoundary', 'RedirectBoundary',\n    'HTTPAccessFallbackErrorBoundary', 'HTTPAccessFallbackBoundary', 'LoadingBoundary',\n    'ErrorBoundary', 'InnerScrollAndFocusHandler', 'ScrollAndFocusHandler',\n    'RenderFromTemplateContext', 'OuterLayoutRouter', 'body', 'html',\n    'DevRootHTTPAccessFallbackBoundary', 'AppDevOverlayErrorBoundary', 'AppDevOverlay',\n    'HotReload', 'Router', 'ErrorBoundaryHandler', 'AppRouter', 'ServerRoot',\n    'SegmentStateProvider', 'RootErrorBoundary', 'LoadableComponent', 'MotionDOMComponent'];\n  var REACT_INTERNAL = ['Suspense', 'Fragment', 'StrictMode', 'Profiler', 'SuspenseList'];\n\n  function isSourceComponentName(name) {\n    if (!name || name.length <= 1) return false;\n    if (name.charAt(0) === '_') return false;\n    if (NEXT_INTERNAL.indexOf(name) !== -1) return false;\n    if (REACT_INTERNAL.indexOf(name) !== -1) return false;\n    if (name.charAt(0) !== name.charAt(0).toUpperCase()) return false;\n    if (name.indexOf('Primitive.') === 0) return false;\n    if (name.indexOf('Provider') !== -1 && name.indexOf('Context') !== -1) return false;\n    return true;\n  }\n\n  function isUsefulComponentName(name) {\n    if (!name) return false;\n    if (name.charAt(0) === '_') return false;\n    if (NEXT_INTERNAL.indexOf(name) !== -1) return false;\n    if (REACT_INTERNAL.indexOf(name) !== -1) return false;\n    if (name.indexOf('Primitive.') === 0) return false;\n    if (name === 'SlotClone' || name === 'Slot') return false;\n    return true;\n  }\n\n  // --- Check if owner stack has source files ---\n  function hasSourceFiles(stack) {\n    if (!stack) return false;\n    for (var i = 0; i < stack.length; i++) {\n      if (stack[i].isServer) return true;\n      if (stack[i].fileName && typeof VKBippy !== 'undefined' && VKBippy.isSourceFile(stack[i].fileName)) return true;\n    }\n    return false;\n  }\n\n  // --- Build ComponentPayload stack entries from owner stack ---\n  function buildStackEntries(stack, maxLines) {\n    var entries = [];\n    var count = 0;\n    for (var i = 0; i < stack.length && count < maxLines; i++) {\n      var frame = stack[i];\n      if (frame.isServer) {\n        entries.push({ name: frame.functionName || '<anonymous>', file: 'Server' });\n        count++;\n        continue;\n      }\n      if (frame.fileName && typeof VKBippy !== 'undefined' && VKBippy.isSourceFile(frame.fileName)) {\n        var name = '';\n        var file = VKBippy.normalizeFileName(frame.fileName);\n        if (frame.lineNumber && frame.columnNumber) {\n          file += ':' + frame.lineNumber + ':' + frame.columnNumber;\n        }\n        if (frame.functionName && isSourceComponentName(frame.functionName)) {\n          name = frame.functionName;\n        }\n        entries.push({ name: name, file: file });\n        count++;\n      }\n    }\n    return entries;\n  }\n\n  // --- Get component names by walking fiber tree ---\n  function getComponentNamesFromFiber(element, maxCount) {\n    var fiber = VKBippy.getFiberFromHostInstance(element);\n    if (!fiber) return [];\n    var names = [];\n    VKBippy.traverseFiber(fiber, function(f) {\n      if (names.length >= maxCount) return true;\n      if (VKBippy.isCompositeFiber(f)) {\n        var name = VKBippy.getDisplayName(f.type);\n        if (name && isUsefulComponentName(name)) names.push(name);\n      }\n      return false;\n    }, true); // goUp = true\n    return names;\n  }\n\n  // --- Get nearest component display name (for overlay label) ---\n  function getNearestComponentName(element) {\n    if (typeof VKBippy === 'undefined' || !VKBippy.isInstrumentationActive()) return null;\n    var fiber = VKBippy.getFiberFromHostInstance(element);\n    if (!fiber) return null;\n    var current = fiber.return;\n    while (current) {\n      if (VKBippy.isCompositeFiber(current)) {\n        var name = VKBippy.getDisplayName(current.type);\n        if (name && isUsefulComponentName(name)) return name;\n      }\n      current = current.return;\n    }\n    return null;\n  }\n\n  var reactAdapter = {\n    name: 'react',\n\n    detect: function(element) {\n      return typeof VKBippy !== 'undefined' &&\n        VKBippy.isInstrumentationActive() &&\n        !!VKBippy.getFiberFromHostInstance(element);\n    },\n\n    getComponentInfo: function(element) {\n      var fiber = VKBippy.getFiberFromHostInstance(element);\n      if (!fiber) return Promise.resolve(null);\n\n      var htmlPreview = getHTMLPreview(element);\n      var componentName = getNearestComponentName(element) || element.tagName.toLowerCase();\n\n      return VKBippy.getOwnerStack(fiber).then(function(stack) {\n        if (hasSourceFiles(stack)) {\n          var payload = {\n            framework: 'react',\n            component: componentName,\n            htmlPreview: htmlPreview,\n            stack: buildStackEntries(stack, 3)\n          };\n          try {\n            for (var i = 0; i < stack.length; i++) {\n              var frame = stack[i];\n              if (!frame.isServer && frame.fileName && VKBippy.isSourceFile(frame.fileName)) {\n                payload.file = VKBippy.normalizeFileName(frame.fileName);\n                if (frame.lineNumber != null) payload.line = frame.lineNumber;\n                if (frame.columnNumber != null) payload.column = frame.columnNumber;\n                break;\n              }\n            }\n          } catch(e) {}\n          return payload;\n        }\n        // Fallback: component names without file paths\n        var names = getComponentNamesFromFiber(element, 3);\n        if (names.length > 0) {\n          var stackEntries = [];\n          for (var i = 0; i < names.length; i++) {\n            stackEntries.push({ name: names[i] });\n          }\n          return {\n            framework: 'react',\n            component: names[0],\n            htmlPreview: htmlPreview,\n            stack: stackEntries\n          };\n        }\n        return { framework: 'react', component: componentName, htmlPreview: htmlPreview };\n      }).catch(function() {\n        // getOwnerStack failed - fall back to fiber walk\n        var names = getComponentNamesFromFiber(element, 3);\n        if (names.length > 0) {\n          var stackEntries = [];\n          for (var i = 0; i < names.length; i++) {\n            stackEntries.push({ name: names[i] });\n          }\n          return {\n            framework: 'react',\n            component: names[0],\n            htmlPreview: htmlPreview,\n            stack: stackEntries\n          };\n        }\n        return { framework: 'react', component: componentName, htmlPreview: htmlPreview };\n      });\n    },\n\n    getOverlayLabel: function(element) {\n      return getNearestComponentName(element);\n    }\n  };\n\n  // =============================================================================\n  // === VUE ADAPTER ===\n  // =============================================================================\n\n  // --- Helper: extract component name from file path ---\n  // e.g. '/src/components/AppHeader.vue' → 'AppHeader'\n  function extractNameFromFile(filePath) {\n    if (!filePath || typeof filePath !== 'string') return null;\n    var parts = filePath.replace(/\\\\/g, '/').split('/');\n    var fileName = parts[parts.length - 1];\n    if (!fileName) return null;\n    var dotIndex = fileName.lastIndexOf('.');\n    if (dotIndex > 0) return fileName.slice(0, dotIndex);\n    return fileName;\n  }\n\n  // --- Helper: find Vue component instance from a DOM element ---\n  // Walks up the DOM tree (max 50 ancestors) looking for __VUE__ or __vueParentComponent\n  function findVueInstance(element) {\n    var el = element;\n    var depth = 0;\n    while (el && depth < 50) {\n      if (el.__VUE__ && el.__VUE__[0]) return el.__VUE__[0];\n      if (el.__vueParentComponent) return el.__vueParentComponent;\n      el = el.parentElement;\n      depth++;\n    }\n    return null;\n  }\n\n  // --- Helper: detect if element is inside a Vue 3 app ---\n  function isVueElement(element) {\n    // Check global hint first\n    if (window.__VUE__) return true;\n    // Walk up DOM looking for Vue markers\n    var el = element;\n    var depth = 0;\n    while (el && depth < 50) {\n      if (el.__VUE__ || el.__vueParentComponent) return true;\n      el = el.parentElement;\n      depth++;\n    }\n    return false;\n  }\n\n  // --- Helper: get Vue component name from instance with multi-level fallback ---\n  function getVueComponentName(instance) {\n    if (!instance || !instance.type) return 'Anonymous';\n    var type = instance.type;\n    return type.displayName || type.name || type.__name || extractNameFromFile(type.__file) || 'Anonymous';\n  }\n\n  // --- Helper: build component stack by walking instance.parent chain ---\n  function buildVueComponentStack(instance, maxLevels) {\n    var stack = [];\n    var current = instance;\n    var count = 0;\n    while (current && count < maxLevels) {\n      var name = getVueComponentName(current);\n      if (name && name !== 'Anonymous') {\n        var entry = { name: name };\n        if (current.type && current.type.__file) {\n          entry.file = current.type.__file;\n        }\n        stack.push(entry);\n      }\n      current = current.parent;\n      count++;\n    }\n    return stack;\n  }\n\n  var vueAdapter = {\n    name: 'vue',\n\n    detect: function(element) {\n      return isVueElement(element);\n    },\n\n    getComponentInfo: function(element) {\n      var instance = findVueInstance(element);\n      if (!instance) return Promise.resolve(null);\n\n      var componentName = getVueComponentName(instance);\n      var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown';\n      var cssClass = element.className ? String(element.className).split(' ')[0] : undefined;\n      var filePath = (instance.type && instance.type.__file) ? instance.type.__file : undefined;\n      var htmlPreview = getHTMLPreview(element);\n      var stack = buildVueComponentStack(instance, 20);\n\n      var payload = {\n        framework: 'vue',\n        component: componentName,\n        tagName: tagName,\n        htmlPreview: htmlPreview\n      };\n      if (cssClass) payload.cssClass = cssClass;\n      if (filePath) payload.file = filePath;\n      if (stack.length > 0) payload.stack = stack;\n\n      return Promise.resolve(payload);\n    },\n\n    getOverlayLabel: function(element) {\n      var instance = findVueInstance(element);\n      if (!instance) return null;\n      var name = getVueComponentName(instance);\n      return (name && name !== 'Anonymous') ? name : null;\n    }\n  };\n\n  // =============================================================================\n  // === SVELTE ADAPTER ===\n  // =============================================================================\n\n  // --- Helper: find nearest element with __svelte_meta by walking up DOM ---\n  function findSvelteMeta(element) {\n    var el = element;\n    var depth = 0;\n    while (el && depth < 50) {\n      if (el.__svelte_meta) return el;\n      el = el.parentElement;\n      depth++;\n    }\n    return null;\n  }\n\n  // --- Helper: check if element or ancestor has svelte-* CSS class (hint only) ---\n  function hasSvelteClassHint(element) {\n    var el = element;\n    var depth = 0;\n    while (el && depth < 50) {\n      if (el.className && typeof el.className === 'string') {\n        var classes = el.className.split(' ');\n        for (var i = 0; i < classes.length; i++) {\n          if (classes[i].indexOf('svelte-') === 0) return true;\n        }\n      }\n      el = el.parentElement;\n      depth++;\n    }\n    return false;\n  }\n\n  // --- Helper: extract component name from Svelte file path ---\n  // e.g. 'src/routes/+page.svelte' → '+page', 'src/lib/Button.svelte' → 'Button'\n  function extractSvelteComponentName(filePath) {\n    if (!filePath || typeof filePath !== 'string') return null;\n    var parts = filePath.replace(/\\\\/g, '/').split('/');\n    var fileName = parts[parts.length - 1];\n    if (!fileName) return null;\n    var dotIndex = fileName.lastIndexOf('.');\n    if (dotIndex > 0) return fileName.slice(0, dotIndex);\n    return fileName;\n  }\n\n  // --- Helper: get first non-svelte-hash CSS class ---\n  function getFirstNonSvelteClass(element) {\n    if (!element.className || typeof element.className !== 'string') return undefined;\n    var classes = element.className.split(' ');\n    for (var i = 0; i < classes.length; i++) {\n      var cls = classes[i].trim();\n      if (cls && cls.indexOf('svelte-') !== 0) return cls;\n    }\n    return undefined;\n  }\n\n  var svelteAdapter = {\n    name: 'svelte',\n\n    detect: function(element) {\n      // Check element and ancestors for __svelte_meta (max 50 depth)\n      // Also check for svelte-* CSS class as a hint, but only return true\n      // if __svelte_meta is actually found somewhere\n      if (findSvelteMeta(element)) return true;\n      // Svelte CSS class hint present but no __svelte_meta found — not enough\n      return false;\n    },\n\n    getComponentInfo: function(element) {\n      var metaEl = findSvelteMeta(element);\n      if (!metaEl || !metaEl.__svelte_meta) return Promise.resolve(null);\n\n      var meta = metaEl.__svelte_meta;\n      var loc = meta.loc;\n      if (!loc || !loc.file) return Promise.resolve(null);\n\n      var componentName = extractSvelteComponentName(loc.file) || 'Unknown';\n      var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown';\n      var cssClass = getFirstNonSvelteClass(element);\n      var htmlPreview = getHTMLPreview(element);\n\n      var fileLoc = loc.file;\n      if (loc.line != null) fileLoc += ':' + loc.line;\n      if (loc.column != null) fileLoc += ':' + loc.column;\n\n      var payload = {\n        framework: 'svelte',\n        component: componentName,\n        tagName: tagName,\n        file: loc.file,\n        line: loc.line,\n        column: loc.column,\n        htmlPreview: htmlPreview,\n        stack: [{ name: componentName, file: fileLoc }]\n      };\n      if (cssClass) payload.cssClass = cssClass;\n\n      return Promise.resolve(payload);\n    },\n\n    getOverlayLabel: function(element) {\n      var metaEl = findSvelteMeta(element);\n      if (!metaEl || !metaEl.__svelte_meta || !metaEl.__svelte_meta.loc) return null;\n      return extractSvelteComponentName(metaEl.__svelte_meta.loc.file);\n    }\n  };\n\n  // =============================================================================\n  // === ASTRO ADAPTER ===\n  // =============================================================================\n\n  // --- Helper: extract component name from Astro component-url ---\n  // e.g. '/src/components/Counter.jsx' → 'Counter'\n  function extractAstroComponentName(componentUrl) {\n    if (!componentUrl || typeof componentUrl !== 'string') return null;\n    var clean = componentUrl.split('?')[0].split('#')[0];\n    var parts = clean.replace(/\\\\/g, '/').split('/');\n    var fileName = parts[parts.length - 1];\n    if (!fileName) return null;\n    var dotIndex = fileName.lastIndexOf('.');\n    if (dotIndex > 0) return fileName.slice(0, dotIndex);\n    return fileName;\n  }\n\n  // --- Helper: detect likely inner framework from renderer-url ---\n  function detectInnerFramework(rendererUrl) {\n    if (!rendererUrl || typeof rendererUrl !== 'string') return null;\n    var url = rendererUrl.toLowerCase();\n    if (url.indexOf('react') !== -1 || url.indexOf('preact') !== -1) return 'react';\n    if (url.indexOf('vue') !== -1) return 'vue';\n    if (url.indexOf('svelte') !== -1) return 'svelte';\n    if (url.indexOf('solid') !== -1) return 'solid';\n    return null;\n  }\n\n  // --- Helper: attempt inner framework detection within an island ---\n  // Tries adapters directly (not via the adapters array) to get inner component info.\n  // Only tries frameworks hinted by renderer-url, falling back to trying all.\n  function getInnerFrameworkInfo(element, island, rendererHint) {\n    var candidates = [];\n\n    if (rendererHint === 'react') {\n      candidates.push(reactAdapter);\n    } else if (rendererHint === 'vue') {\n      candidates.push(vueAdapter);\n    } else if (rendererHint === 'svelte') {\n      candidates.push(svelteAdapter);\n    } else {\n      candidates.push(reactAdapter);\n      candidates.push(vueAdapter);\n      candidates.push(svelteAdapter);\n    }\n\n    var el = element;\n    while (el && el !== island.parentElement) {\n      for (var i = 0; i < candidates.length; i++) {\n        if (candidates[i].detect(el)) {\n          return candidates[i].getComponentInfo(el);\n        }\n      }\n      el = el.parentElement;\n    }\n\n    return Promise.resolve(null);\n  }\n\n  var astroAdapter = {\n    name: 'astro',\n\n    detect: function(element) {\n      return !!element.closest && !!element.closest('astro-island');\n    },\n\n    getComponentInfo: function(element) {\n      var island = element.closest('astro-island');\n      if (!island) return Promise.resolve(null);\n\n      var componentUrl = island.getAttribute('component-url') || '';\n      var componentExport = island.getAttribute('component-export') || 'default';\n      var rendererUrl = island.getAttribute('renderer-url') || '';\n      var clientDirective = island.getAttribute('client') || '';\n      var componentName = extractAstroComponentName(componentUrl) || 'AstroIsland';\n      var htmlPreview = getHTMLPreview(element);\n      var rendererHint = detectInnerFramework(rendererUrl);\n\n      return getInnerFrameworkInfo(element, island, rendererHint).then(function(innerPayload) {\n        var stack = [];\n\n        if (innerPayload) {\n          if (innerPayload.stack) {\n            for (var i = 0; i < innerPayload.stack.length; i++) {\n              stack.push(innerPayload.stack[i]);\n            }\n          } else {\n            var innerEntry = { name: innerPayload.component || 'Unknown' };\n            if (innerPayload.file) innerEntry.file = innerPayload.file;\n            stack.push(innerEntry);\n          }\n        }\n\n        var astroEntry = { name: componentName };\n        if (componentUrl) astroEntry.file = componentUrl;\n        stack.push(astroEntry);\n\n        var payload = {\n          framework: 'astro',\n          component: innerPayload ? innerPayload.component : componentName,\n          htmlPreview: htmlPreview,\n          stack: stack\n        };\n\n        if (componentUrl) payload.file = componentUrl;\n\n        return payload;\n      });\n    },\n\n    getOverlayLabel: function(element) {\n      var island = element.closest('astro-island');\n      if (!island) return null;\n\n      var rendererUrl = island.getAttribute('renderer-url') || '';\n      var rendererHint = detectInnerFramework(rendererUrl);\n\n      if (rendererHint === 'react' && reactAdapter.getOverlayLabel) {\n        var reactLabel = reactAdapter.getOverlayLabel(element);\n        if (reactLabel) return reactLabel;\n      }\n      if (rendererHint === 'vue' && vueAdapter.getOverlayLabel) {\n        var vueLabel = vueAdapter.getOverlayLabel(element);\n        if (vueLabel) return vueLabel;\n      }\n      if (rendererHint === 'svelte' && svelteAdapter.getOverlayLabel) {\n        var svelteLabel = svelteAdapter.getOverlayLabel(element);\n        if (svelteLabel) return svelteLabel;\n      }\n\n      var componentUrl = island.getAttribute('component-url');\n      return extractAstroComponentName(componentUrl) || null;\n    }\n  };\n\n  // =============================================================================\n  // === HTML FALLBACK ===\n  // =============================================================================\n\n  var htmlFallbackAdapter = {\n    name: 'html-fallback',\n\n    detect: function() {\n      return true;\n    },\n\n    getComponentInfo: function(element) {\n      var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown';\n      var cssClass = element.className ? String(element.className).split(' ')[0] : undefined;\n      return Promise.resolve({\n        framework: 'html',\n        component: tagName,\n        tagName: tagName,\n        cssClass: cssClass,\n        htmlPreview: getHTMLPreview(element)\n      });\n    }\n  };\n\n  // =============================================================================\n  // === ADAPTER REGISTRY & DISPATCHER ===\n  // =============================================================================\n\n  var adapters = [astroAdapter, reactAdapter, vueAdapter, svelteAdapter];\n\n  // --- Diagnostic: detect which frameworks are present on the page ---\n  function detectFrameworks() {\n    var detected = [];\n    // Check for Astro islands\n    if (document.querySelector('astro-island')) detected.push('astro');\n    // Check for React (VKBippy)\n    if (typeof VKBippy !== 'undefined' && VKBippy.isInstrumentationActive && VKBippy.isInstrumentationActive()) detected.push('react');\n    // Check for Vue\n    if (window.__VUE__ || document.querySelector('[data-v-app]')) detected.push('vue');\n    // Check for Svelte (check for svelte CSS classes)\n    if (document.querySelector('[class*=\"svelte-\"]') || document.querySelector('[data-svelte-h]')) detected.push('svelte');\n    return detected;\n  }\n\n  // --- Convert ComponentPayload to markdown string (v1 postMessage format) ---\n  function payloadToMarkdown(payload) {\n    var markdown = payload.htmlPreview;\n    if (payload.stack) {\n      for (var i = 0; i < payload.stack.length; i++) {\n        var entry = payload.stack[i];\n        markdown += '\\n  in ';\n        if (entry.name && entry.file) {\n          markdown += entry.name + ' (at ' + entry.file + ')';\n        } else if (entry.file) {\n          markdown += entry.file;\n        } else if (entry.name) {\n          markdown += entry.name;\n        }\n      }\n    }\n    return markdown;\n  }\n\n  // --- Dispatcher: iterate adapters, first match wins, fallback to HTML ---\n  // Returns raw ComponentPayload (v2 protocol — no markdown conversion)\n  function getElementContext(element) {\n    for (var i = 0; i < adapters.length; i++) {\n      if (adapters[i].detect(element)) {\n        return adapters[i].getComponentInfo(element).then(function(payload) {\n          if (payload) return payload;\n          return htmlFallbackAdapter.getComponentInfo(element);\n        });\n      }\n    }\n    return htmlFallbackAdapter.getComponentInfo(element);\n  }\n\n  // --- Get overlay label from first matching adapter ---\n  function getOverlayLabelForElement(element) {\n    for (var i = 0; i < adapters.length; i++) {\n      if (adapters[i].getOverlayLabel) {\n        var label = adapters[i].getOverlayLabel(element);\n        if (label) return label;\n      }\n    }\n    return null;\n  }\n\n  // =============================================================================\n  // === CORE: Overlay, Events & Initialization ===\n  // =============================================================================\n\n  function createOverlay() {\n    if (overlay) return;\n    overlay = document.createElement('div');\n    overlay.style.cssText = 'position:fixed;pointer-events:none;z-index:999999;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease;display:none;';\n    nameLabel = document.createElement('div');\n    nameLabel.style.cssText = 'position:absolute;top:-22px;left:0;background:#3b82f6;color:white;font-size:11px;padding:2px 6px;border-radius:3px;white-space:nowrap;font-family:system-ui,sans-serif;';\n    overlay.appendChild(nameLabel);\n    document.body.appendChild(overlay);\n  }\n\n  function removeOverlay() {\n    if (overlay && overlay.parentNode) {\n      overlay.parentNode.removeChild(overlay);\n    }\n    overlay = null;\n    nameLabel = null;\n  }\n\n  function positionOverlay(element) {\n    if (!overlay) return;\n    var rect = element.getBoundingClientRect();\n    overlay.style.display = 'block';\n    overlay.style.top = rect.top + 'px';\n    overlay.style.left = rect.left + 'px';\n    overlay.style.width = rect.width + 'px';\n    overlay.style.height = rect.height + 'px';\n    var compName = getOverlayLabelForElement(element);\n    if (nameLabel) {\n      nameLabel.textContent = compName || element.tagName.toLowerCase();\n      nameLabel.style.display = 'block';\n    }\n  }\n\n  function hideOverlay() {\n    if (overlay) overlay.style.display = 'none';\n  }\n\n  // --- Event handlers ---\n  function onMouseOver(event) {\n    if (!inspectModeActive) return;\n    var el = event.target;\n    if (el === overlay || (overlay && overlay.contains(el))) return;\n    if (el === lastHoveredElement) return;\n    lastHoveredElement = el;\n    positionOverlay(el);\n  }\n\n  function onClick(event) {\n    if (!inspectModeActive) return;\n    event.preventDefault();\n    event.stopPropagation();\n    event.stopImmediatePropagation();\n    var el = event.target;\n    if (el === overlay || (overlay && overlay.contains(el))) return;\n\n    // Exit inspect mode immediately (visual feedback)\n    setInspectMode(false);\n\n    getElementContext(el).then(function(componentPayload) {\n      send('component-detected', componentPayload, 2);\n    });\n  }\n\n  // --- setInspectMode ---\n  function setInspectMode(active) {\n    if (active === inspectModeActive) return;\n    inspectModeActive = active;\n\n    if (active) {\n      createOverlay();\n      document.body.style.cursor = 'crosshair';\n      document.addEventListener('mouseover', onMouseOver, true);\n      document.addEventListener('click', onClick, true);\n    } else {\n      document.removeEventListener('mouseover', onMouseOver, true);\n      document.removeEventListener('click', onClick, true);\n      document.body.style.cursor = '';\n      hideOverlay();\n      removeOverlay();\n      lastHoveredElement = null;\n    }\n  }\n\n  // --- Message listener ---\n  window.addEventListener('message', function(event) {\n    if (!event.data || event.data.source !== SOURCE) return;\n    if (event.data.type === 'toggle-inspect') {\n      setInspectMode(event.data.payload && event.data.payload.active);\n    }\n  });\n\n  // --- Log detected frameworks on page load (diagnostic only) ---\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', function() {\n      console.debug('[vk-ctc] Detected frameworks:', detectFrameworks().join(', ') || 'none');\n    });\n  } else {\n    console.debug('[vk-ctc] Detected frameworks:', detectFrameworks().join(', ') || 'none');\n  }\n})();\n"
  },
  {
    "path": "crates/server/src/preview_proxy/devtools_script.js",
    "content": "(function() {\n  'use strict';\n\n  var SOURCE = 'vibe-devtools';\n  var NAV_SESSION_POINTER_KEY = '__vk_nav_session';\n  var NAV_SESSION_PREFIX = '__vk_nav_';\n  var DOC_ID = Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);\n\n  function send(type, payload) {\n    try {\n      window.parent.postMessage({ source: SOURCE, type: type, payload: payload }, '*');\n    } catch (e) {\n      // Ignore if parent is not accessible\n    }\n  }\n\n  function getNavStorageKey() {\n    var sessionId = 'default';\n    try {\n      var params = new URLSearchParams(location.search);\n      var refresh = params.get('_refresh');\n      if (refresh) {\n        sessionStorage.setItem(NAV_SESSION_POINTER_KEY, refresh);\n        sessionId = refresh;\n      } else {\n        var saved = sessionStorage.getItem(NAV_SESSION_POINTER_KEY);\n        if (saved) sessionId = saved;\n      }\n    } catch (e) {\n      // sessionStorage may be unavailable\n    }\n    return NAV_SESSION_PREFIX + sessionId;\n  }\n\n  var NAV_STORAGE_KEY = getNavStorageKey();\n  var navStack = [];\n  var navIndex = -1;\n  var navSeq = 0;\n  var lastObservedHref = location.href;\n  var originalPushState = history.pushState;\n  var originalReplaceState = history.replaceState;\n\n  function normalizeUrl(url) {\n    try {\n      var u = new URL(url);\n      u.searchParams.delete('_refresh');\n      return u.toString();\n    } catch (e) {\n      return url;\n    }\n  }\n\n  function loadNavState() {\n    try {\n      var saved = sessionStorage.getItem(NAV_STORAGE_KEY);\n      if (!saved) return;\n\n      var state = JSON.parse(saved);\n      if (Array.isArray(state.stack)) {\n        navStack = state.stack\n          .map(function(entry) {\n            if (typeof entry === 'string') return entry;\n            if (entry && typeof entry.url === 'string') return entry.url;\n            return null;\n          })\n          .filter(function(entry) {\n            return typeof entry === 'string' && entry.length > 0;\n          });\n      } else {\n        navStack = [];\n      }\n\n      navIndex = typeof state.index === 'number' ? state.index : -1;\n      if (navIndex >= navStack.length) navIndex = navStack.length - 1;\n    } catch (e) {\n      navStack = [];\n      navIndex = -1;\n    }\n  }\n\n  function saveNavState() {\n    try {\n      sessionStorage.setItem(\n        NAV_STORAGE_KEY,\n        JSON.stringify({\n          stack: navStack,\n          index: navIndex\n        })\n      );\n    } catch (e) {\n      // ignore storage errors\n    }\n  }\n\n  function sendNavigation() {\n    navSeq += 1;\n    send('navigation', {\n      docId: DOC_ID,\n      seq: navSeq,\n      url: location.href,\n      title: document.title,\n      canGoBack: navIndex > 0,\n      canGoForward: navIndex < navStack.length - 1,\n      timestamp: Date.now()\n    });\n  }\n\n  function ensureCurrentInStack(currentHref, mode) {\n    var normalized = normalizeUrl(currentHref);\n    var found = false;\n\n    if (mode === 'replace') {\n      if (navIndex >= 0 && navIndex < navStack.length) {\n        navStack[navIndex] = currentHref;\n      } else {\n        navStack = [currentHref];\n        navIndex = 0;\n      }\n      return;\n    }\n\n    if (mode === 'push') {\n      navStack = navStack.slice(0, navIndex + 1);\n      navStack.push(currentHref);\n      navIndex = navStack.length - 1;\n      return;\n    }\n\n    if (navIndex >= 0 && navIndex < navStack.length &&\n        normalizeUrl(navStack[navIndex]) === normalized) {\n      navStack[navIndex] = currentHref;\n      found = true;\n    } else if (navIndex + 1 < navStack.length &&\n               normalizeUrl(navStack[navIndex + 1]) === normalized) {\n      navIndex++;\n      navStack[navIndex] = currentHref;\n      found = true;\n    } else if (navIndex > 0 &&\n               normalizeUrl(navStack[navIndex - 1]) === normalized) {\n      navIndex--;\n      navStack[navIndex] = currentHref;\n      found = true;\n    } else {\n      for (var i = 0; i < navStack.length; i++) {\n        if (normalizeUrl(navStack[i]) === normalized) {\n          navIndex = i;\n          navStack[navIndex] = currentHref;\n          found = true;\n          break;\n        }\n      }\n    }\n\n    if (!found) {\n      navStack = navStack.slice(0, navIndex + 1);\n      navStack.push(currentHref);\n      navIndex = navStack.length - 1;\n    }\n  }\n\n  function observeLocation(mode) {\n    var currentHref = location.href;\n    lastObservedHref = currentHref;\n\n    ensureCurrentInStack(currentHref, mode || 'auto');\n    saveNavState();\n    sendNavigation();\n  }\n\n  function initializeNavigation() {\n    loadNavState();\n\n    if (navStack.length === 0) {\n      navStack = [location.href];\n      navIndex = 0;\n      saveNavState();\n    } else if (navIndex < 0 || navIndex >= navStack.length) {\n      navIndex = navStack.length - 1;\n      if (navIndex < 0) navIndex = 0;\n      saveNavState();\n    }\n\n    observeLocation();\n  }\n\n  window.addEventListener('popstate', function() {\n    observeLocation('auto');\n  });\n\n  window.addEventListener('hashchange', function() {\n    observeLocation('auto');\n  });\n\n  window.addEventListener('pageshow', function() {\n    observeLocation('auto');\n  });\n\n  window.addEventListener('load', function() {\n    observeLocation('auto');\n  });\n\n  history.pushState = function(state, title, url) {\n    var result = originalPushState.apply(this, arguments);\n    observeLocation('push');\n    return result;\n  };\n\n  history.replaceState = function(state, title, url) {\n    var result = originalReplaceState.apply(this, arguments);\n    observeLocation('replace');\n    return result;\n  };\n\n  window.addEventListener('message', function(event) {\n    if (!event.data || event.data.source !== SOURCE || event.data.type !== 'navigate') {\n      return;\n    }\n\n    var payload = event.data.payload;\n    if (!payload) return;\n\n    switch (payload.action) {\n      case 'back':\n        if (navIndex > 0) history.back();\n        break;\n      case 'forward':\n        if (navIndex < navStack.length - 1) history.forward();\n        break;\n      case 'refresh':\n        location.reload();\n        break;\n      case 'goto':\n        if (payload.url) {\n          navStack = navStack.slice(0, navIndex + 1);\n          navStack.push(payload.url);\n          navIndex = navStack.length - 1;\n          saveNavState();\n          sendNavigation();\n          location.href = payload.url;\n        }\n        break;\n    }\n  });\n\n  window.setInterval(function() {\n    if (location.href !== lastObservedHref) {\n      observeLocation('auto');\n    }\n  }, 150);\n\n  send('ready', { docId: DOC_ID });\n\n  initializeNavigation();\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', function() {\n      observeLocation();\n    });\n  } else {\n    observeLocation();\n  }\n})();\n"
  },
  {
    "path": "crates/server/src/preview_proxy/eruda_init.js",
    "content": "(function() {\n  'use strict';\n  \n  const SOURCE = 'vibe-devtools';\n  const COMMAND_SOURCE = 'vibe-kanban';\n  \n  // === Helper: Send message to parent ===\n  function send(type, payload) {\n    try {\n      window.parent.postMessage({ source: SOURCE, type, payload }, '*');\n    } catch (e) {\n      // Ignore if parent is not accessible\n    }\n  }\n  \n  // === Initialize Eruda ===\n  function initEruda() {\n    if (typeof window.eruda === 'undefined') {\n      // Eruda CDN failed to load, silently skip\n      return;\n    }\n    \n    // Initialize with dark theme\n    window.eruda.init({ defaults: { theme: 'Dark' } });\n    window.eruda.hide();\n    \n    try {\n      var entryBtn = window.eruda._entryBtn;\n      if (entryBtn && entryBtn._$el && entryBtn._$el[0]) {\n        entryBtn._$el[0].style.display = 'none';\n      }\n    } catch (e) { /* ignore */ }\n    \n    // Send ready signal\n    send('eruda-ready', {});\n  }\n  \n  // === Command Receiver ===\n  window.addEventListener('message', function(event) {\n    if (!event.data || event.data.source !== COMMAND_SOURCE) {\n      return;\n    }\n    \n    if (typeof window.eruda === 'undefined') {\n      return;\n    }\n    \n    var command = event.data.command;\n    \n    switch (command) {\n      case 'toggle-eruda':\n        if (window.eruda._isShow) {\n          window.eruda.hide();\n        } else {\n          window.eruda.show();\n        }\n        break;\n      case 'show-eruda':\n        window.eruda.show();\n        break;\n      case 'hide-eruda':\n        window.eruda.hide();\n        break;\n    }\n  });\n  \n  // === Initialize when ready ===\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', initEruda);\n  } else {\n    initEruda();\n  }\n})();\n"
  },
  {
    "path": "crates/server/src/preview_proxy/mod.rs",
    "content": "//! Preview Proxy Server Module\n//!\n//! Provides a separate HTTP server for serving preview iframe content.\n//! This isolates preview content from the main application for security.\n//!\n//! The proxy listens on a separate port and routes requests based on the\n//! Host header subdomain. A request to `{port}.localhost:{proxy_port}/path`\n//! is forwarded to `localhost:{port}/path`.\n\nuse std::sync::OnceLock;\n\nuse axum::{\n    Router,\n    body::Body,\n    extract::{FromRequestParts, Request, ws::WebSocketUpgrade},\n    http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},\n    response::{IntoResponse, Response},\n};\nuse futures_util::{SinkExt, StreamExt};\nuse reqwest::Client;\nuse tokio_tungstenite::tungstenite::{self, client::IntoClientRequest};\nuse tower_http::validate_request::ValidateRequestHeaderLayer;\n\n/// Global storage for the preview proxy port once assigned.\n/// Set once during server startup, read by the config API.\nstatic PROXY_PORT: OnceLock<u16> = OnceLock::new();\n\n/// Shared HTTP client for proxying requests.\n/// Reused across all requests to leverage connection pooling per upstream host:port.\nstatic HTTP_CLIENT: OnceLock<Client> = OnceLock::new();\n/// Get or initialize the shared HTTP client.\nfn http_client() -> &'static Client {\n    HTTP_CLIENT.get_or_init(|| {\n        Client::builder()\n            .redirect(reqwest::redirect::Policy::none())\n            .build()\n            .expect(\"failed to build proxy HTTP client\")\n    })\n}\n\nfn env_flag_enabled(name: &str) -> bool {\n    std::env::var(name).is_ok_and(|value| {\n        value == \"1\"\n            || value.eq_ignore_ascii_case(\"true\")\n            || value.eq_ignore_ascii_case(\"yes\")\n            || value.eq_ignore_ascii_case(\"on\")\n    })\n}\n/// Get the preview proxy port if set.\npub fn get_proxy_port() -> Option<u16> {\n    PROXY_PORT.get().copied()\n}\n\n/// Set the preview proxy port. Can only be called once.\n/// Returns the port if successfully set, or None if already set.\npub fn set_proxy_port(port: u16) -> Option<u16> {\n    PROXY_PORT.set(port).ok().map(|()| port)\n}\n\nconst SKIP_REQUEST_HEADERS: &[&str] = &[\n    \"host\",\n    \"connection\",\n    \"transfer-encoding\",\n    \"upgrade\",\n    \"proxy-connection\",\n    \"keep-alive\",\n    \"te\",\n    \"trailer\",\n    \"sec-websocket-key\",\n    \"sec-websocket-version\",\n    \"sec-websocket-extensions\",\n    \"accept-encoding\",\n    \"origin\",\n];\n\n/// Headers that should be stripped from the proxied response.\nconst STRIP_RESPONSE_HEADERS: &[&str] = &[\n    \"content-security-policy\",\n    \"content-security-policy-report-only\",\n    \"x-frame-options\",\n    \"x-content-type-options\",\n    \"transfer-encoding\",\n    \"connection\",\n    \"content-encoding\",\n];\n\n/// DevTools script injected before </body> in HTML responses.\n/// Captures console, network, errors and sends via postMessage.\nconst DEVTOOLS_SCRIPT: &str = include_str!(\"devtools_script.js\");\n\n/// Bippy bundle script injected after <head> to install React DevTools hook\n/// before React initializes. Provides fiber inspection utilities.\nconst BIPPY_BUNDLE: &str = include_str!(\"bippy_bundle.js\");\n\n/// Click-to-component detection script injected before </body>.\n/// Enables inspect mode for detecting React component hierarchy.\nconst CLICK_TO_COMPONENT_SCRIPT: &str = include_str!(\"click_to_component_script.js\");\n\n/// Eruda DevTools initialization script. Initializes Eruda with dark theme\n/// and listens for toggle commands from parent window.\nconst ERUDA_INIT: &str = include_str!(\"eruda_init.js\");\n\n/// Collect response headers to forward to the iframe response.\n/// Keeps duplicate headers (e.g. `Set-Cookie`) by preserving each entry.\nfn collect_response_headers(\n    upstream_headers: &HeaderMap,\n    is_html: bool,\n) -> Vec<(HeaderName, HeaderValue)> {\n    let mut headers = Vec::new();\n\n    for (name, value) in upstream_headers {\n        let name_lower = name.as_str().to_ascii_lowercase();\n        if STRIP_RESPONSE_HEADERS.contains(&name_lower.as_str()) {\n            continue;\n        }\n        if is_html && name_lower == \"content-length\" {\n            continue;\n        }\n\n        if let Ok(header_value) = HeaderValue::from_bytes(value.as_bytes()) {\n            headers.push((name.clone(), header_value));\n        }\n    }\n\n    headers\n}\n\nfn is_loopback_redirect_host(host: &str) -> bool {\n    matches!(host, \"localhost\" | \"127.0.0.1\" | \"0.0.0.0\" | \"::1\")\n}\n\nfn trim_wrapping_quotes(value: &str) -> &str {\n    if value.len() < 2 {\n        return value;\n    }\n\n    let bytes = value.as_bytes();\n    let first = bytes[0];\n    let last = bytes[value.len() - 1];\n    let has_matching_double = first == b'\"' && last == b'\"';\n    let has_matching_single = first == b'\\'' && last == b'\\'';\n\n    if has_matching_double || has_matching_single {\n        &value[1..value.len() - 1]\n    } else {\n        value\n    }\n}\n\nfn trim_trailing_redirect_punctuation(mut value: &str) -> &str {\n    loop {\n        let trimmed = value.trim_end();\n        if trimmed.ends_with(',') || trimmed.ends_with(';') {\n            value = trimmed[..trimmed.len() - 1].trim_end();\n            continue;\n        }\n        return trimmed;\n    }\n}\n\nfn normalize_redirect_like_url_token(value: &str) -> Option<String> {\n    let mut candidate = value.trim();\n    if candidate.is_empty() {\n        return None;\n    }\n\n    candidate = trim_trailing_redirect_punctuation(candidate);\n\n    loop {\n        let unquoted = trim_wrapping_quotes(candidate).trim();\n        if unquoted == candidate {\n            break;\n        }\n        candidate = trim_trailing_redirect_punctuation(unquoted);\n    }\n\n    // We only rewrite plain URL tokens. Values containing spaces/quotes usually belong\n    // to structured headers and must be left untouched.\n    if candidate.is_empty()\n        || candidate.chars().any(char::is_whitespace)\n        || candidate.contains('\"')\n        || candidate.contains('\\'')\n    {\n        return None;\n    }\n\n    Some(candidate.to_string())\n}\n\nfn normalize_refresh_url_token(raw_value: &str) -> &str {\n    let without_trailing_punctuation = trim_trailing_redirect_punctuation(raw_value.trim());\n    trim_wrapping_quotes(without_trailing_punctuation).trim()\n}\n\nfn rewrite_redirect_like_header_value(\n    value: &str,\n    target_port: u16,\n    proxy_port: u16,\n) -> Option<String> {\n    let original_value = value.trim();\n    if original_value.is_empty() {\n        return None;\n    }\n\n    let normalized_value = normalize_redirect_like_url_token(original_value)?;\n\n    // Relative redirects should stay relative so browser keeps current proxy origin.\n    if (normalized_value.starts_with('/') && !normalized_value.starts_with(\"//\"))\n        || normalized_value.starts_with('?')\n        || normalized_value.starts_with('#')\n    {\n        if normalized_value == original_value {\n            return None;\n        }\n        return Some(normalized_value);\n    }\n\n    let mut parsed = if normalized_value.starts_with(\"//\") {\n        reqwest::Url::parse(&format!(\"http:{normalized_value}\")).ok()?\n    } else {\n        reqwest::Url::parse(&normalized_value).ok()?\n    };\n    let host = parsed.host_str()?.to_ascii_lowercase();\n    if !is_loopback_redirect_host(&host) {\n        if normalized_value == original_value {\n            return None;\n        }\n        return Some(normalized_value);\n    }\n\n    let parsed_port = parsed.port_or_known_default()?;\n    if parsed_port != target_port {\n        if normalized_value == original_value {\n            return None;\n        }\n        return Some(normalized_value);\n    }\n\n    parsed.set_scheme(\"http\").ok()?;\n    parsed\n        .set_host(Some(&format!(\"{target_port}.localhost\")))\n        .ok()?;\n    parsed.set_port(Some(proxy_port)).ok()?;\n    Some(parsed.to_string())\n}\n\nfn rewrite_refresh_header_value(value: &str, target_port: u16, proxy_port: u16) -> Option<String> {\n    let mut segments: Vec<String> = value.split(';').map(|s| s.trim().to_string()).collect();\n    if segments.len() < 2 {\n        return None;\n    }\n\n    for segment in segments.iter_mut().skip(1) {\n        let segment_lower = segment.to_ascii_lowercase();\n        if !segment_lower.starts_with(\"url=\") {\n            continue;\n        }\n\n        let raw_value = segment[4..].trim();\n        let raw_unquoted = normalize_refresh_url_token(raw_value);\n        if raw_unquoted.is_empty() {\n            continue;\n        }\n\n        if let Some(rewritten) =\n            rewrite_redirect_like_header_value(raw_unquoted, target_port, proxy_port)\n        {\n            *segment = format!(\"url={rewritten}\");\n            return Some(segments.join(\"; \"));\n        }\n    }\n\n    None\n}\n\nfn is_redirect_like_header_name(name_lower: &str) -> bool {\n    name_lower == \"location\"\n        || name_lower == \"content-location\"\n        || name_lower == \"refresh\"\n        || name_lower.contains(\"redirect\")\n        || name_lower.contains(\"rewrite\")\n}\n\nfn rewrite_redirect_like_headers(\n    headers: &mut [(HeaderName, HeaderValue)],\n    target_port: u16,\n    proxy_port: Option<u16>,\n) {\n    let Some(proxy_port) = proxy_port else {\n        return;\n    };\n\n    for (name, value) in headers.iter_mut() {\n        let name_lower = name.as_str().to_ascii_lowercase();\n        if !is_redirect_like_header_name(&name_lower) {\n            continue;\n        }\n\n        let Ok(value_str) = value.to_str() else {\n            continue;\n        };\n\n        let rewritten = if name_lower == \"refresh\" {\n            rewrite_refresh_header_value(value_str, target_port, proxy_port)\n        } else {\n            rewrite_redirect_like_header_value(value_str, target_port, proxy_port)\n        };\n\n        if let Some(rewritten) = rewritten\n            && let Ok(rewritten_header) = HeaderValue::from_str(&rewritten)\n        {\n            *value = rewritten_header;\n        }\n    }\n}\n\nfn extract_target_from_host(headers: &HeaderMap) -> Option<u16> {\n    let host = headers.get(header::HOST)?.to_str().ok()?;\n    let subdomain = host.split('.').next()?;\n    subdomain.parse::<u16>().ok()\n}\n\nasync fn subdomain_proxy(request: Request) -> Response {\n    let target_port = match extract_target_from_host(request.headers()) {\n        Some(port) => port,\n        None => {\n            return (StatusCode::BAD_REQUEST, \"No valid port in Host subdomain\").into_response();\n        }\n    };\n\n    let path = request.uri().path().trim_start_matches('/').to_string();\n\n    proxy_impl(target_port, path, request).await\n}\n\nasync fn proxy_impl(target_port: u16, path_str: String, request: Request) -> Response {\n    let (mut parts, body) = request.into_parts();\n\n    // Extract query string and subprotocols before WebSocket upgrade.\n    // Both are required: Vite 6+ needs ?token= for auth, and checks\n    // Sec-WebSocket-Protocol: vite-hmr before accepting the upgrade.\n    let query_string = parts.uri.query().map(|q| q.to_string());\n    let ws_protocols: Option<String> = parts\n        .headers\n        .get(\"sec-websocket-protocol\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string());\n\n    if let Ok(ws) = WebSocketUpgrade::from_request_parts(&mut parts, &()).await {\n        tracing::debug!(\n            \"WebSocket upgrade request for path: {} -> localhost:{}\",\n            path_str,\n            target_port\n        );\n\n        let ws = if let Some(ref protocols) = ws_protocols {\n            let protocol_list: Vec<String> =\n                protocols.split(',').map(|p| p.trim().to_string()).collect();\n            ws.protocols(protocol_list)\n        } else {\n            ws\n        };\n\n        return ws\n            .on_upgrade(move |client_socket| async move {\n                if let Err(e) = handle_ws_proxy(\n                    client_socket,\n                    target_port,\n                    path_str,\n                    query_string,\n                    ws_protocols,\n                )\n                .await\n                {\n                    tracing::warn!(\"WebSocket proxy closed: {}\", e);\n                }\n            })\n            .into_response();\n    }\n\n    let request = Request::from_parts(parts, body);\n    http_proxy_handler(target_port, path_str, request).await\n}\n\nasync fn http_proxy_handler(target_port: u16, path_str: String, request: Request) -> Response {\n    let (parts, body) = request.into_parts();\n    let method = parts.method;\n    let headers = parts.headers;\n    let original_uri = parts.uri;\n\n    let query_string = original_uri.query().unwrap_or_default();\n\n    let target_url = if query_string.is_empty() {\n        format!(\"http://localhost:{}/{}\", target_port, path_str)\n    } else {\n        format!(\n            \"http://localhost:{}/{}?{}\",\n            target_port, path_str, query_string\n        )\n    };\n\n    let is_rsc_request = headers.contains_key(header::HeaderName::from_static(\"rsc\"));\n    let is_get_request = method == axum::http::Method::GET;\n\n    let client = http_client();\n\n    let mut req_builder = client.request(\n        reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET),\n        &target_url,\n    );\n\n    for (name, value) in headers.iter() {\n        let name_lower = name.as_str().to_ascii_lowercase();\n        if !SKIP_REQUEST_HEADERS.contains(&name_lower.as_str())\n            && let Ok(v) = value.to_str()\n        {\n            req_builder = req_builder.header(name.as_str(), v);\n        }\n    }\n\n    if let Some(host) = headers.get(header::HOST)\n        && let Ok(host_str) = host.to_str()\n    {\n        req_builder = req_builder.header(\"X-Forwarded-Host\", host_str);\n    }\n    req_builder = req_builder.header(\"X-Forwarded-Proto\", \"http\");\n    req_builder = req_builder.header(\"Accept-Encoding\", \"identity\");\n\n    let forwarded_for = headers\n        .get(\"x-forwarded-for\")\n        .and_then(|v| v.to_str().ok())\n        .unwrap_or(\"127.0.0.1\");\n    req_builder = req_builder.header(\"X-Forwarded-For\", forwarded_for);\n\n    let body_bytes = match axum::body::to_bytes(body, 50 * 1024 * 1024).await {\n        Ok(b) => b,\n        Err(e) => {\n            tracing::error!(\"Failed to read request body: {}\", e);\n            return (StatusCode::BAD_REQUEST, \"Failed to read request body\").into_response();\n        }\n    };\n\n    if !body_bytes.is_empty() {\n        req_builder = req_builder.body(body_bytes.to_vec());\n    }\n\n    let response = match req_builder.send().await {\n        Ok(r) => r,\n        Err(e) => {\n            tracing::error!(\"Failed to proxy request to {}: {}\", target_url, e);\n            return (\n                StatusCode::BAD_GATEWAY,\n                format!(\"Dev server unreachable: {}\", e),\n            )\n                .into_response();\n        }\n    };\n\n    let content_type = response\n        .headers()\n        .get(reqwest::header::CONTENT_TYPE)\n        .and_then(|v| v.to_str().ok())\n        .unwrap_or_default();\n    let is_html = content_type.contains(\"text/html\");\n\n    let mut response_headers = collect_response_headers(response.headers(), is_html);\n    rewrite_redirect_like_headers(&mut response_headers, target_port, get_proxy_port());\n\n    let status = StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::OK);\n\n    // RSC redirect interception — BEFORE is_html branch to catch all response types.\n    // Response is 200 with x-nextjs-redirect header → convert to 307 so\n    //   the browser follows it natively (V1 approach, now in correct location).\n    if is_get_request && is_rsc_request {\n        // Scenario 2: 200 with x-nextjs-redirect — convert to 307 (V1 approach, now before is_html)\n        if !status.is_redirection() {\n            let rsc_redirect_target = response_headers\n                .iter()\n                .find(|(name, _)| name.as_str().eq_ignore_ascii_case(\"x-nextjs-redirect\"))\n                .and_then(|(_, value)| value.to_str().ok())\n                .map(|v| v.to_owned());\n\n            if let Some(ref redirect_target) = rsc_redirect_target {\n                // Consume body before building new response\n                let _ = response.bytes().await;\n\n                let mut builder = Response::builder().status(StatusCode::TEMPORARY_REDIRECT);\n                for (name, value) in &response_headers {\n                    builder = builder.header(name.clone(), value.clone());\n                }\n                if let Ok(location_value) = HeaderValue::from_str(redirect_target) {\n                    builder = builder.header(header::LOCATION, location_value);\n                }\n\n                return builder.body(Body::empty()).unwrap_or_else(|_| {\n                    (\n                        StatusCode::INTERNAL_SERVER_ERROR,\n                        \"Failed to build RSC redirect response\",\n                    )\n                        .into_response()\n                });\n            }\n        }\n    }\n\n    if is_html {\n        match response.bytes().await {\n            Ok(body_bytes) => {\n                let mut html = String::from_utf8_lossy(&body_bytes).to_string();\n\n                // Inject bippy bundle after <head> (must load before React)\n                if let Some(pos) = html.to_lowercase().find(\"<head>\") {\n                    let head_end = pos + \"<head>\".len();\n                    let bippy_tag = format!(\"<script>{}</script>\", BIPPY_BUNDLE);\n                    html.insert_str(head_end, &bippy_tag);\n                }\n\n                // Inject Eruda CDN, init, devtools and click-to-component scripts before </body>\n                if let Some(pos) = html.to_lowercase().rfind(\"</body>\") {\n                    let nav_script_disabled = env_flag_enabled(\"VK_PREVIEW_DISABLE_NAV_SCRIPT\");\n                    let scripts = if nav_script_disabled {\n                        format!(\n                            \"<script src=\\\"https://cdn.jsdelivr.net/npm/eruda@3.4.3/eruda.js\\\"></script><script>{}</script><script>{}</script>\",\n                            ERUDA_INIT, CLICK_TO_COMPONENT_SCRIPT\n                        )\n                    } else {\n                        format!(\n                            \"<script src=\\\"https://cdn.jsdelivr.net/npm/eruda@3.4.3/eruda.js\\\"></script><script>{}</script><script>{}</script><script>{}</script>\",\n                            ERUDA_INIT, DEVTOOLS_SCRIPT, CLICK_TO_COMPONENT_SCRIPT\n                        )\n                    };\n                    html.insert_str(pos, &scripts);\n                }\n\n                let mut builder = Response::builder().status(status);\n                for (name, value) in &response_headers {\n                    builder = builder.header(name.clone(), value.clone());\n                }\n\n                builder.body(Body::from(html)).unwrap_or_else(|_| {\n                    (\n                        StatusCode::INTERNAL_SERVER_ERROR,\n                        \"Failed to build response\",\n                    )\n                        .into_response()\n                })\n            }\n            Err(e) => {\n                tracing::error!(\"Failed to read HTML response: {}\", e);\n                (\n                    StatusCode::BAD_GATEWAY,\n                    \"Failed to read response from dev server\",\n                )\n                    .into_response()\n            }\n        }\n    } else {\n        // x-nextjs-redirect header already handled above (Path A)\n\n        // For RSC GET requests, read body to detect redirect encoded in flight data\n        if is_get_request && is_rsc_request {\n            let body_bytes = response.bytes().await.unwrap_or_default();\n\n            // V5: Detect redirect encoded in RSC flight data body\n            if let Some(redirect_info) = detect_rsc_redirect_in_body(&body_bytes) {\n                // Determine the final redirect URL\n                let final_url = if redirect_info.url.starts_with(\"http://\")\n                    || redirect_info.url.starts_with(\"https://\")\n                {\n                    // Absolute URL — rewrite to maintain proxy isolation\n                    if let Some(proxy_port) = get_proxy_port() {\n                        rewrite_redirect_like_header_value(\n                            &redirect_info.url,\n                            target_port,\n                            proxy_port,\n                        )\n                        .unwrap_or_else(|| redirect_info.url.clone())\n                    } else {\n                        redirect_info.url.clone()\n                    }\n                } else {\n                    // Relative URL — use as-is (browser resolves against proxy origin)\n                    redirect_info.url.clone()\n                };\n\n                // Build redirect response with the status from the digest\n                let redirect_status = StatusCode::from_u16(redirect_info.status_code)\n                    .unwrap_or(StatusCode::TEMPORARY_REDIRECT);\n\n                let mut builder = Response::builder().status(redirect_status);\n                // Preserve all response headers (cookies, cache-control, etc.)\n                for (name, value) in &response_headers {\n                    builder = builder.header(name.clone(), value.clone());\n                }\n                // Set Location header\n                if let Ok(location_value) = HeaderValue::from_str(&final_url) {\n                    builder = builder.header(header::LOCATION, location_value);\n                }\n\n                return builder.body(Body::empty()).unwrap_or_else(|_| {\n                    (\n                        StatusCode::INTERNAL_SERVER_ERROR,\n                        \"Failed to build RSC flight redirect response\",\n                    )\n                        .into_response()\n                });\n            }\n\n            let mut builder = Response::builder().status(status);\n            for (name, value) in &response_headers {\n                builder = builder.header(name.clone(), value.clone());\n            }\n\n            builder.body(Body::from(body_bytes)).unwrap_or_else(|_| {\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"Failed to build response\",\n                )\n                    .into_response()\n            })\n        } else {\n            let stream = response.bytes_stream();\n            let body = Body::from_stream(stream);\n\n            let mut builder = Response::builder().status(status);\n            for (name, value) in &response_headers {\n                builder = builder.header(name.clone(), value.clone());\n            }\n\n            builder.body(body).unwrap_or_else(|_| {\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"Failed to build response\",\n                )\n                    .into_response()\n            })\n        }\n    }\n}\n\nasync fn handle_ws_proxy(\n    client_socket: axum::extract::ws::WebSocket,\n    target_port: u16,\n    path: String,\n    query_string: Option<String>,\n    ws_protocols: Option<String>,\n) -> anyhow::Result<()> {\n    let ws_url = match &query_string {\n        Some(q) if !q.is_empty() => {\n            format!(\"ws://localhost:{}/{}?{}\", target_port, path, q)\n        }\n        _ => format!(\"ws://localhost:{}/{}\", target_port, path),\n    };\n    tracing::debug!(\"Connecting to dev server WebSocket: {}\", ws_url);\n\n    let mut ws_request = ws_url.into_client_request()?;\n    if let Some(ref protocols) = ws_protocols {\n        ws_request\n            .headers_mut()\n            .insert(\"sec-websocket-protocol\", protocols.parse()?);\n    }\n    let (dev_server_ws, _response) = tokio_tungstenite::connect_async(ws_request).await?;\n    tracing::debug!(\"Connected to dev server WebSocket\");\n\n    let (mut client_sender, mut client_receiver) = client_socket.split();\n    let (mut dev_sender, mut dev_receiver) = dev_server_ws.split();\n\n    let client_to_dev = tokio::spawn(async move {\n        while let Some(msg_result) = client_receiver.next().await {\n            match msg_result {\n                Ok(axum_msg) => {\n                    let tungstenite_msg = match axum_msg {\n                        axum::extract::ws::Message::Text(text) => {\n                            tungstenite::Message::Text(text.to_string().into())\n                        }\n                        axum::extract::ws::Message::Binary(data) => {\n                            tungstenite::Message::Binary(data.to_vec().into())\n                        }\n                        axum::extract::ws::Message::Ping(data) => {\n                            tungstenite::Message::Ping(data.to_vec().into())\n                        }\n                        axum::extract::ws::Message::Pong(data) => {\n                            tungstenite::Message::Pong(data.to_vec().into())\n                        }\n                        axum::extract::ws::Message::Close(close_frame) => {\n                            let close = close_frame.map(|cf| tungstenite::protocol::CloseFrame {\n                                code: tungstenite::protocol::frame::coding::CloseCode::from(\n                                    cf.code,\n                                ),\n                                reason: cf.reason.to_string().into(),\n                            });\n                            tungstenite::Message::Close(close)\n                        }\n                    };\n\n                    if dev_sender.send(tungstenite_msg).await.is_err() {\n                        break;\n                    }\n                }\n                Err(e) => {\n                    tracing::debug!(\"Client WebSocket receive error: {}\", e);\n                    break;\n                }\n            }\n        }\n        let _ = dev_sender.close().await;\n    });\n\n    let dev_to_client = tokio::spawn(async move {\n        while let Some(msg_result) = dev_receiver.next().await {\n            match msg_result {\n                Ok(tungstenite_msg) => {\n                    let axum_msg = match tungstenite_msg {\n                        tungstenite::Message::Text(text) => {\n                            axum::extract::ws::Message::Text(text.to_string().into())\n                        }\n                        tungstenite::Message::Binary(data) => {\n                            axum::extract::ws::Message::Binary(data.to_vec().into())\n                        }\n                        tungstenite::Message::Ping(data) => {\n                            axum::extract::ws::Message::Ping(data.to_vec().into())\n                        }\n                        tungstenite::Message::Pong(data) => {\n                            axum::extract::ws::Message::Pong(data.to_vec().into())\n                        }\n                        tungstenite::Message::Close(close_frame) => {\n                            let close = close_frame.map(|cf| axum::extract::ws::CloseFrame {\n                                code: cf.code.into(),\n                                reason: cf.reason.to_string().into(),\n                            });\n                            axum::extract::ws::Message::Close(close)\n                        }\n                        tungstenite::Message::Frame(_) => continue,\n                    };\n\n                    if client_sender.send(axum_msg).await.is_err() {\n                        break;\n                    }\n                }\n                Err(e) => {\n                    tracing::debug!(\"Dev server WebSocket receive error: {}\", e);\n                    break;\n                }\n            }\n        }\n        let _ = client_sender.close().await;\n    });\n\n    tokio::select! {\n        _ = client_to_dev => {\n            tracing::debug!(\"Client to dev server forwarding completed\");\n        }\n        _ = dev_to_client => {\n            tracing::debug!(\"Dev server to client forwarding completed\");\n        }\n    }\n\n    Ok(())\n}\n\npub fn router<S>() -> Router<S>\nwhere\n    S: Clone + Send + Sync + 'static,\n{\n    Router::new()\n        .fallback(subdomain_proxy)\n        .layer(ValidateRequestHeaderLayer::custom(\n            crate::middleware::validate_origin,\n        ))\n}\n\n#[derive(Debug, Clone, PartialEq)]\nstruct RscRedirectInfo {\n    url: String,\n    redirect_type: String,\n    status_code: u16,\n}\n\n/// Detects Next.js RSC redirect instructions encoded in flight data response bodies.\n///\n/// Next.js `redirect()` in Server Components serializes the redirect as an error\n/// with digest `NEXT_REDIRECT;{type};{url};{statusCode};` inside the flight data.\n/// This function scans the body for this pattern and extracts the redirect info.\n///\n/// Returns `None` if no redirect is found, if the body is too large (>1MB),\n/// or if the digest format is invalid.\nfn detect_rsc_redirect_in_body(body: &[u8]) -> Option<RscRedirectInfo> {\n    // Skip bodies larger than 1MB\n    if body.len() > 1_048_576 {\n        return None;\n    }\n\n    let body_str = String::from_utf8_lossy(body);\n\n    // Find the reliable marker: \"digest\":\"NEXT_REDIRECT;\n    let marker = \"\\\"digest\\\":\\\"NEXT_REDIRECT;\";\n    let marker_pos = body_str.find(marker)?;\n\n    // Extract the full digest value starting after '\"digest\":\"'\n    let digest_prefix = \"\\\"digest\\\":\\\"\";\n    let digest_start = marker_pos + digest_prefix.len();\n    let remaining = &body_str[digest_start..];\n\n    // Find the closing unescaped quote\n    let digest_end = remaining.find('\"')?;\n    let digest = &remaining[..digest_end];\n\n    // Parse the digest: NEXT_REDIRECT;{type};{url};{statusCode};\n    let parts: Vec<&str> = digest.split(';').collect();\n\n    // Minimum: [\"NEXT_REDIRECT\", type, url, statusCode, \"\"]\n    if parts.len() < 5 {\n        return None;\n    }\n\n    if parts[0] != \"NEXT_REDIRECT\" {\n        return None;\n    }\n\n    let redirect_type = parts[1];\n    if redirect_type != \"push\" && redirect_type != \"replace\" {\n        return None;\n    }\n\n    // Last element must be empty (trailing semicolon)\n    if !parts[parts.len() - 1].is_empty() {\n        return None;\n    }\n\n    // Second-to-last is the status code\n    let status_str = parts[parts.len() - 2];\n    let status_code: u16 = status_str.parse().ok()?;\n\n    // Validate status code\n    if !matches!(status_code, 301 | 302 | 303 | 307 | 308) {\n        return None;\n    }\n\n    // URL is everything between type and status code (handles URLs with semicolons)\n    let url = parts[2..parts.len() - 2].join(\";\");\n\n    Some(RscRedirectInfo {\n        url,\n        redirect_type: redirect_type.to_string(),\n        status_code,\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use axum::http::header::{\n        CACHE_CONTROL, CONTENT_LENGTH, CONTENT_SECURITY_POLICY, LOCATION, SET_COOKIE,\n    };\n\n    use super::*;\n\n    #[test]\n    fn collect_response_headers_preserves_multiple_set_cookie_values() {\n        let mut upstream_headers = HeaderMap::new();\n        upstream_headers.append(SET_COOKIE, HeaderValue::from_static(\"first=1; Path=/\"));\n        upstream_headers.append(SET_COOKIE, HeaderValue::from_static(\"second=2; Path=/\"));\n\n        let proxied = collect_response_headers(&upstream_headers, false);\n        let set_cookie_values: Vec<Vec<u8>> = proxied\n            .iter()\n            .filter(|(name, _)| *name == SET_COOKIE)\n            .map(|(_, value)| value.as_bytes().to_vec())\n            .collect();\n\n        assert_eq!(\n            set_cookie_values,\n            vec![b\"first=1; Path=/\".to_vec(), b\"second=2; Path=/\".to_vec()]\n        );\n    }\n\n    #[test]\n    fn response_builder_preserves_multiple_set_cookie_values() {\n        let mut upstream_headers = HeaderMap::new();\n        upstream_headers.append(SET_COOKIE, HeaderValue::from_static(\"first=1; Path=/\"));\n        upstream_headers.append(SET_COOKIE, HeaderValue::from_static(\"second=2; Path=/\"));\n\n        let response_headers = collect_response_headers(&upstream_headers, false);\n\n        let mut builder = Response::builder().status(StatusCode::OK);\n        for (name, value) in &response_headers {\n            builder = builder.header(name.clone(), value.clone());\n        }\n        let response = builder.body(Body::empty()).expect(\"response builds\");\n\n        assert_eq!(response.headers().get_all(SET_COOKIE).iter().count(), 2);\n    }\n\n    #[test]\n    fn collect_response_headers_preserves_mixed_headers_and_three_cookies() {\n        let mut upstream_headers = HeaderMap::new();\n        upstream_headers.append(SET_COOKIE, HeaderValue::from_static(\"first=1; Path=/\"));\n        upstream_headers.append(CACHE_CONTROL, HeaderValue::from_static(\"no-store\"));\n        upstream_headers.append(SET_COOKIE, HeaderValue::from_static(\"second=2; Path=/\"));\n        upstream_headers.append(SET_COOKIE, HeaderValue::from_static(\"third=3; Path=/\"));\n        upstream_headers.insert(\"x-custom-header\", HeaderValue::from_static(\"present\"));\n\n        let proxied = collect_response_headers(&upstream_headers, false);\n\n        assert_eq!(\n            proxied\n                .iter()\n                .filter(|(name, _)| *name == SET_COOKIE)\n                .count(),\n            3\n        );\n        assert!(\n            proxied.iter().any(|(name, value)| *name == CACHE_CONTROL\n                && value == HeaderValue::from_static(\"no-store\"))\n        );\n        assert!(proxied.iter().any(|(name, value)| name == \"x-custom-header\"\n            && value == HeaderValue::from_static(\"present\")));\n    }\n\n    #[test]\n    fn collect_response_headers_drops_content_length_for_html_only() {\n        let mut upstream_headers = HeaderMap::new();\n        upstream_headers.insert(CONTENT_LENGTH, HeaderValue::from_static(\"123\"));\n\n        let html_headers = collect_response_headers(&upstream_headers, true);\n        assert!(html_headers.iter().all(|(name, _)| *name != CONTENT_LENGTH));\n\n        let non_html_headers = collect_response_headers(&upstream_headers, false);\n        assert_eq!(non_html_headers.len(), 1);\n        assert_eq!(non_html_headers[0].0, CONTENT_LENGTH);\n    }\n\n    #[test]\n    fn collect_response_headers_strips_blocked_headers() {\n        let mut upstream_headers = HeaderMap::new();\n        upstream_headers.insert(\n            CONTENT_SECURITY_POLICY,\n            HeaderValue::from_static(\"frame-ancestors 'none'\"),\n        );\n\n        let proxied = collect_response_headers(&upstream_headers, false);\n        assert!(\n            proxied\n                .iter()\n                .all(|(name, _)| *name != CONTENT_SECURITY_POLICY)\n        );\n    }\n\n    #[test]\n    fn rewrite_redirect_like_header_value_rewrites_loopback_absolute_url() {\n        let rewritten = rewrite_redirect_like_header_value(\n            \"http://localhost:4000/generate?from=auth#done\",\n            4000,\n            3009,\n        );\n\n        assert_eq!(\n            rewritten.as_deref(),\n            Some(\"http://4000.localhost:3009/generate?from=auth#done\")\n        );\n    }\n\n    #[test]\n    fn rewrite_redirect_like_header_value_keeps_relative_and_non_loopback_urls() {\n        assert_eq!(\n            rewrite_redirect_like_header_value(\"/generate\", 4000, 3009),\n            None\n        );\n        assert_eq!(\n            rewrite_redirect_like_header_value(\"?from=auth\", 4000, 3009),\n            None\n        );\n        assert_eq!(\n            rewrite_redirect_like_header_value(\"https://example.com/generate\", 4000, 3009),\n            None\n        );\n    }\n\n    #[test]\n    fn rewrite_redirect_like_header_value_rewrites_scheme_relative_loopback_url() {\n        let rewritten = rewrite_redirect_like_header_value(\"//localhost:4000/generate\", 4000, 3009);\n\n        assert_eq!(\n            rewritten.as_deref(),\n            Some(\"http://4000.localhost:3009/generate\")\n        );\n    }\n\n    #[test]\n    fn rewrite_refresh_header_value_rewrites_embedded_url() {\n        let rewritten = rewrite_refresh_header_value(\n            \"0; URL='http://localhost:4000/generate?from=auth'\",\n            4000,\n            3009,\n        );\n\n        assert_eq!(\n            rewritten.as_deref(),\n            Some(\"0; url=http://4000.localhost:3009/generate?from=auth\")\n        );\n    }\n\n    #[test]\n    fn rewrite_refresh_header_value_handles_trailing_comma_in_quoted_url() {\n        let rewritten = rewrite_refresh_header_value(\n            \"0; URL=\\\"http://localhost:4000/?_refresh=7\\\",\",\n            4000,\n            3009,\n        );\n\n        assert_eq!(\n            rewritten.as_deref(),\n            Some(\"0; url=http://4000.localhost:3009/?_refresh=7\")\n        );\n    }\n\n    #[test]\n    fn rewrite_redirect_like_header_value_cleans_quoted_relative_url() {\n        let rewritten = rewrite_redirect_like_header_value(\"\\\"/generate\\\",\", 4000, 3009);\n\n        assert_eq!(rewritten.as_deref(), Some(\"/generate\"));\n    }\n\n    #[test]\n    fn rewrite_redirect_like_header_value_cleans_and_rewrites_quoted_absolute_url() {\n        let rewritten =\n            rewrite_redirect_like_header_value(\"\\\"http://localhost:4000/generate\\\",\", 4000, 3009);\n\n        assert_eq!(\n            rewritten.as_deref(),\n            Some(\"http://4000.localhost:3009/generate\")\n        );\n    }\n\n    #[test]\n    fn rewrite_redirect_like_header_value_skips_structured_values() {\n        let rewritten = rewrite_redirect_like_header_value(\n            \"url=\\\"http://localhost:4000/generate\\\", mode=replace\",\n            4000,\n            3009,\n        );\n\n        assert_eq!(rewritten, None);\n    }\n\n    #[test]\n    fn rewrite_redirect_like_headers_rewrites_generic_redirect_headers_only() {\n        let mut headers = vec![\n            (\n                LOCATION,\n                HeaderValue::from_static(\"http://localhost:4000/generate\"),\n            ),\n            (\n                HeaderName::from_static(\"x-auth-redirect-url\"),\n                HeaderValue::from_static(\"http://localhost:4000/generate\"),\n            ),\n            (\n                HeaderName::from_static(\"refresh\"),\n                HeaderValue::from_static(\"0; url=http://localhost:4000/generate\"),\n            ),\n            (\n                HeaderName::from_static(\"x-custom-header\"),\n                HeaderValue::from_static(\"http://localhost:4000/keep\"),\n            ),\n        ];\n\n        rewrite_redirect_like_headers(&mut headers, 4000, Some(3009));\n\n        assert_eq!(\n            headers[0].1,\n            HeaderValue::from_static(\"http://4000.localhost:3009/generate\")\n        );\n        assert_eq!(\n            headers[1].1,\n            HeaderValue::from_static(\"http://4000.localhost:3009/generate\")\n        );\n        assert_eq!(\n            headers[2].1,\n            HeaderValue::from_static(\"0; url=http://4000.localhost:3009/generate\")\n        );\n        assert_eq!(\n            headers[3].1,\n            HeaderValue::from_static(\"http://localhost:4000/keep\")\n        );\n    }\n\n    #[test]\n    fn rewrite_redirect_like_headers_rewrites_rewrite_headers_and_keeps_plain_url_headers() {\n        let mut headers = vec![\n            (\n                HeaderName::from_static(\"x-router-rewrite\"),\n                HeaderValue::from_static(\"http://localhost:4000/generate\"),\n            ),\n            (\n                HeaderName::from_static(\"x-target-url\"),\n                HeaderValue::from_static(\"http://localhost:4000/generate\"),\n            ),\n        ];\n\n        rewrite_redirect_like_headers(&mut headers, 4000, Some(3009));\n\n        assert_eq!(\n            headers[0].1,\n            HeaderValue::from_static(\"http://4000.localhost:3009/generate\")\n        );\n        assert_eq!(\n            headers[1].1,\n            HeaderValue::from_static(\"http://localhost:4000/generate\")\n        );\n    }\n\n    #[test]\n    fn is_redirect_like_header_name_matches_nextjs_redirect() {\n        assert!(is_redirect_like_header_name(\"x-nextjs-redirect\"));\n        assert!(!is_redirect_like_header_name(\"x-nextjs-data\"));\n        assert!(!is_redirect_like_header_name(\"rsc\"));\n    }\n\n    #[test]\n    fn is_redirect_like_header_name_matches_action_redirect() {\n        // x-action-redirect contains \"redirect\" so it matches,\n        // but our interception logic specifically looks for x-nextjs-redirect\n        assert!(is_redirect_like_header_name(\"x-action-redirect\"));\n    }\n\n    #[test]\n    fn rewrite_redirect_like_headers_rewrites_nextjs_redirect() {\n        let mut headers = vec![(\n            HeaderName::from_static(\"x-nextjs-redirect\"),\n            HeaderValue::from_static(\"http://localhost:4000/generate\"),\n        )];\n\n        rewrite_redirect_like_headers(&mut headers, 4000, Some(3009));\n\n        assert_eq!(\n            headers[0].1,\n            HeaderValue::from_static(\"http://4000.localhost:3009/generate\")\n        );\n    }\n\n    #[test]\n    fn collect_response_headers_preserves_nextjs_redirect() {\n        let mut upstream_headers = HeaderMap::new();\n        upstream_headers.insert(\n            HeaderName::from_static(\"x-nextjs-redirect\"),\n            HeaderValue::from_static(\"/generate\"),\n        );\n\n        let proxied = collect_response_headers(&upstream_headers, false);\n        assert_eq!(proxied.len(), 1);\n        assert_eq!(proxied[0].0, \"x-nextjs-redirect\");\n        assert_eq!(proxied[0].1, \"/generate\");\n    }\n\n    #[test]\n    fn rewrite_redirect_like_headers_preserves_relative_nextjs_redirect() {\n        let mut headers = vec![(\n            HeaderName::from_static(\"x-nextjs-redirect\"),\n            HeaderValue::from_static(\"/generate\"),\n        )];\n\n        rewrite_redirect_like_headers(&mut headers, 4000, Some(3009));\n\n        // Relative URLs are NOT rewritten — only absolute loopback URLs are\n        assert_eq!(headers[0].1, HeaderValue::from_static(\"/generate\"));\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_basic() {\n        let body = b\"0:\\\"$Sreact.suspense\\\"\\n1:I[\\\"123\\\",[]]\\\"]\\n3:E{\\\"digest\\\":\\\"NEXT_REDIRECT;replace;/generate;307;\\\",\\\"message\\\":\\\"NEXT_REDIRECT\\\"}\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(\n            result,\n            Some(RscRedirectInfo {\n                url: \"/generate\".to_string(),\n                redirect_type: \"replace\".to_string(),\n                status_code: 307,\n            })\n        );\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_url_with_semicolons() {\n        let body = b\"{\\\"digest\\\":\\\"NEXT_REDIRECT;push;/path;with;semicolons;308;\\\"}\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(\n            result,\n            Some(RscRedirectInfo {\n                url: \"/path;with;semicolons\".to_string(),\n                redirect_type: \"push\".to_string(),\n                status_code: 308,\n            })\n        );\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_false_positive_no_json_prefix() {\n        let body = b\"The error NEXT_REDIRECT; was logged\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_body_size_cap() {\n        let mut body = vec![0u8; 1_048_577];\n        let payload = b\"{\\\"digest\\\":\\\"NEXT_REDIRECT;replace;/generate;307;\\\"}\";\n        body[..payload.len()].copy_from_slice(payload);\n        let result = detect_rsc_redirect_in_body(&body);\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_invalid_type() {\n        let body = b\"{\\\"digest\\\":\\\"NEXT_REDIRECT;invalid;/url;307;\\\"}\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_invalid_status_code() {\n        let body = b\"{\\\"digest\\\":\\\"NEXT_REDIRECT;replace;/url;999;\\\"}\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_permanent_redirect() {\n        let body = b\"{\\\"digest\\\":\\\"NEXT_REDIRECT;replace;/permanent;301;\\\"}\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(\n            result,\n            Some(RscRedirectInfo {\n                url: \"/permanent\".to_string(),\n                redirect_type: \"replace\".to_string(),\n                status_code: 301,\n            })\n        );\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_absolute_url() {\n        let body = b\"{\\\"digest\\\":\\\"NEXT_REDIRECT;push;https://example.com/callback;307;\\\"}\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(\n            result,\n            Some(RscRedirectInfo {\n                url: \"https://example.com/callback\".to_string(),\n                redirect_type: \"push\".to_string(),\n                status_code: 307,\n            })\n        );\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_empty_body() {\n        let result = detect_rsc_redirect_in_body(b\"\");\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn test_detect_rsc_redirect_no_redirect_in_body() {\n        let body = b\"0:\\\"$Sreact.suspense\\\"\\n1:I[\\\"456\\\",[]]\\\"]\\n2:{\\\"name\\\":\\\"MyComponent\\\"}\";\n        let result = detect_rsc_redirect_in_body(body);\n        assert_eq!(result, None);\n    }\n}\n"
  },
  {
    "path": "crates/server/src/routes/approvals.rs",
    "content": "use axum::{\n    Router,\n    extract::{State, ws::Message},\n    http::StatusCode,\n    response::{IntoResponse, Json as ResponseJson},\n    routing::{get, post},\n};\nuse deployment::Deployment;\nuse futures_util::StreamExt;\nuse utils::{\n    approvals::{ApprovalOutcome, ApprovalResponse},\n    log_msg::LogMsg,\n    response::ApiResponse,\n};\n\nuse crate::{\n    DeploymentImpl,\n    routes::relay_ws::{SignedWebSocket, SignedWsUpgrade},\n};\n\npub async fn respond_to_approval(\n    State(deployment): State<DeploymentImpl>,\n    axum::extract::Path(id): axum::extract::Path<String>,\n    ResponseJson(request): ResponseJson<ApprovalResponse>,\n) -> Result<ResponseJson<ApiResponse<ApprovalOutcome>>, StatusCode> {\n    let service = deployment.approvals();\n\n    match service.respond(&id, request).await {\n        Ok((outcome, context)) => {\n            deployment\n                .track_if_analytics_allowed(\n                    \"approval_responded\",\n                    serde_json::json!({\n                        \"approval_id\": &id,\n                        \"status\": format!(\"{:?}\", outcome),\n                        \"tool_name\": context.tool_name,\n                        \"execution_process_id\": context.execution_process_id.to_string(),\n                    }),\n                )\n                .await;\n\n            Ok(ResponseJson(ApiResponse::success(outcome)))\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to respond to approval: {:?}\", e);\n            Err(StatusCode::INTERNAL_SERVER_ERROR)\n        }\n    }\n}\n\npub async fn stream_approvals_ws(\n    ws: SignedWsUpgrade,\n    State(deployment): State<DeploymentImpl>,\n) -> impl IntoResponse {\n    ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_approvals_ws(socket, deployment).await {\n            tracing::warn!(\"approvals WS closed: {}\", e);\n        }\n    })\n}\n\nasync fn handle_approvals_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n) -> anyhow::Result<()> {\n    let mut stream = deployment.approvals().patch_stream();\n\n    if let Some(snapshot_patch) = stream.next().await {\n        socket\n            .send(LogMsg::JsonPatch(snapshot_patch).to_ws_message_unchecked())\n            .await?;\n    } else {\n        return Ok(());\n    }\n    socket.send(LogMsg::Ready.to_ws_message_unchecked()).await?;\n\n    loop {\n        tokio::select! {\n            patch = stream.next() => {\n                let Some(patch) = patch else {\n                    break;\n                };\n\n                if socket\n                    .send(LogMsg::JsonPatch(patch).to_ws_message_unchecked())\n                    .await\n                    .is_err()\n                {\n                    break;\n                }\n            }\n            inbound = socket.recv() => {\n                match inbound {\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(error) => {\n                        tracing::warn!(\"approvals WS receive error: {}\", error);\n                        break;\n                    }\n                }\n            }\n        }\n    }\n\n    Ok(())\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/approvals/{id}/respond\", post(respond_to_approval))\n        .route(\"/approvals/stream/ws\", get(stream_approvals_ws))\n}\n"
  },
  {
    "path": "crates/server/src/routes/attachments.rs",
    "content": "use axum::{\n    Router,\n    body::Body,\n    extract::{DefaultBodyLimit, Multipart, Path, State},\n    http::{StatusCode, header},\n    response::{Json as ResponseJson, Response},\n    routing::{delete, get, post},\n};\nuse chrono::{DateTime, Utc};\nuse db::models::file::{File, WorkspaceAttachment};\nuse deployment::Deployment;\nuse serde::{Deserialize, Serialize};\nuse services::services::file::FileError;\nuse tokio::fs::File as TokioFile;\nuse tokio_util::io::ReaderStream;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\npub(crate) fn content_type_and_disposition_for_attachment(\n    mime_type: &str,\n) -> (&str, Option<&'static str>) {\n    if is_safe_inline_attachment_mime_type(mime_type) {\n        (mime_type, None)\n    } else {\n        (\"application/octet-stream\", Some(\"attachment\"))\n    }\n}\n\nfn is_safe_inline_attachment_mime_type(mime_type: &str) -> bool {\n    matches!(\n        mime_type,\n        \"image/png\"\n            | \"image/jpeg\"\n            | \"image/gif\"\n            | \"image/webp\"\n            | \"image/bmp\"\n            | \"image/x-icon\"\n            | \"image/tiff\"\n    )\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct AttachmentResponse {\n    pub id: Uuid,\n    pub file_path: String, // relative path to display in markdown\n    pub original_name: String,\n    pub mime_type: Option<String>,\n    pub size_bytes: i64,\n    pub hash: String,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n\nimpl AttachmentResponse {\n    pub fn from_file(file: File) -> Self {\n        let markdown_path = format!(\"{}/{}\", utils::path::VIBE_ATTACHMENTS_DIR, file.file_path);\n        Self {\n            id: file.id,\n            file_path: markdown_path,\n            original_name: file.original_name,\n            mime_type: file.mime_type,\n            size_bytes: file.size_bytes,\n            hash: file.hash,\n            created_at: file.created_at,\n            updated_at: file.updated_at,\n        }\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct AttachmentMetadata {\n    pub exists: bool,\n    pub file_name: Option<String>,\n    pub path: Option<String>,\n    pub size_bytes: Option<i64>,\n    pub format: Option<String>,\n    pub proxy_url: Option<String>,\n}\n\npub async fn upload_file(\n    State(deployment): State<DeploymentImpl>,\n    multipart: Multipart,\n) -> Result<ResponseJson<ApiResponse<AttachmentResponse>>, ApiError> {\n    let file_response = process_file_upload(&deployment, multipart, None).await?;\n    Ok(ResponseJson(ApiResponse::success(file_response)))\n}\n\npub(crate) async fn process_file_upload(\n    deployment: &DeploymentImpl,\n    mut multipart: Multipart,\n    link_workspace_id: Option<Uuid>,\n) -> Result<AttachmentResponse, ApiError> {\n    let file_service = deployment.file();\n\n    while let Some(field) = multipart.next_field().await? {\n        if field.name() == Some(\"image\") {\n            let filename = field\n                .file_name()\n                .map(|s| s.to_string())\n                .unwrap_or_else(|| \"file.bin\".to_string());\n\n            let data = field.bytes().await?;\n            let file = file_service.store_file(&data, &filename).await?;\n\n            if let Some(workspace_id) = link_workspace_id {\n                WorkspaceAttachment::associate_many_dedup(\n                    &deployment.db().pool,\n                    workspace_id,\n                    std::slice::from_ref(&file.id),\n                )\n                .await?;\n            }\n\n            deployment\n                .track_if_analytics_allowed(\n                    \"file_uploaded\",\n                    serde_json::json!({\n                        \"file_id\": file.id.to_string(),\n                        \"size_bytes\": file.size_bytes,\n                        \"mime_type\": file.mime_type,\n                        \"workspace_id\": link_workspace_id.map(|id| id.to_string()),\n                    }),\n                )\n                .await;\n\n            return Ok(AttachmentResponse::from_file(file));\n        }\n    }\n\n    Err(ApiError::File(FileError::NotFound))\n}\n\npub async fn serve_file(\n    Path(file_id): Path<Uuid>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<Response, ApiError> {\n    let file_service = deployment.file();\n    let file_record = file_service\n        .get_file(file_id)\n        .await?\n        .ok_or_else(|| ApiError::File(FileError::NotFound))?;\n    let file_path = file_service.get_absolute_path(&file_record);\n\n    let file = TokioFile::open(&file_path).await?;\n    let metadata = file.metadata().await?;\n\n    let stream = ReaderStream::new(file);\n    let body = Body::from_stream(stream);\n\n    let content_type = file_record\n        .mime_type\n        .as_deref()\n        .unwrap_or(\"application/octet-stream\");\n    let (content_type, content_disposition) =\n        content_type_and_disposition_for_attachment(content_type);\n\n    let mut response = Response::builder()\n        .status(StatusCode::OK)\n        .header(header::CONTENT_TYPE, content_type)\n        .header(header::CONTENT_LENGTH, metadata.len())\n        .header(header::CACHE_CONTROL, \"public, max-age=31536000\")\n        .header(header::X_CONTENT_TYPE_OPTIONS, \"nosniff\");\n    if let Some(content_disposition) = content_disposition {\n        response = response.header(header::CONTENT_DISPOSITION, content_disposition);\n    }\n    let response = response\n        .body(body)\n        .map_err(|e| ApiError::File(FileError::ResponseBuildError(e.to_string())))?;\n\n    Ok(response)\n}\n\npub async fn delete_file(\n    Path(file_id): Path<Uuid>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let file_service = deployment.file();\n    file_service.delete_file(file_id).await?;\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub fn routes() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\n            \"/upload\",\n            post(upload_file).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),\n        )\n        .route(\"/{id}/file\", get(serve_file))\n        .route(\"/{id}\", delete(delete_file))\n}\n\n#[cfg(test)]\nmod tests {\n    use axum::http::header;\n\n    use super::content_type_and_disposition_for_attachment;\n\n    #[test]\n    fn allows_safe_images_inline() {\n        let (content_type, disposition) = content_type_and_disposition_for_attachment(\"image/png\");\n        assert_eq!(content_type, \"image/png\");\n        assert_eq!(disposition, None);\n    }\n\n    #[test]\n    fn forces_html_to_download() {\n        let (content_type, disposition) = content_type_and_disposition_for_attachment(\"text/html\");\n        assert_eq!(content_type, \"application/octet-stream\");\n        assert_eq!(disposition, Some(\"attachment\"));\n    }\n\n    #[test]\n    fn forces_svg_to_download() {\n        let (content_type, disposition) =\n            content_type_and_disposition_for_attachment(\"image/svg+xml\");\n        assert_eq!(content_type, \"application/octet-stream\");\n        assert_eq!(disposition, Some(\"attachment\"));\n    }\n\n    #[test]\n    fn forces_pdf_to_download() {\n        let (content_type, disposition) =\n            content_type_and_disposition_for_attachment(\"application/pdf\");\n        assert_eq!(content_type, \"application/octet-stream\");\n        assert_eq!(disposition, Some(\"attachment\"));\n    }\n\n    #[test]\n    fn forces_unknown_types_to_download() {\n        let (content_type, disposition) =\n            content_type_and_disposition_for_attachment(\"application/octet-stream\");\n        assert_eq!(content_type, \"application/octet-stream\");\n        assert_eq!(disposition, Some(\"attachment\"));\n    }\n\n    #[test]\n    fn nosniff_header_name_matches_expected() {\n        assert_eq!(\n            header::X_CONTENT_TYPE_OPTIONS.as_str(),\n            \"x-content-type-options\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/server/src/routes/config.rs",
    "content": "use std::collections::HashMap;\n\nuse api_types::LoginStatus;\nuse axum::{\n    Json, Router,\n    body::Body,\n    extract::{Path, Query, State, ws::Message},\n    http,\n    response::{IntoResponse, Json as ResponseJson, Response},\n    routing::{get, put},\n};\nuse deployment::{Deployment, DeploymentError};\nuse executors::{\n    executors::{\n        AvailabilityInfo, BaseAgentCapability, BaseCodingAgent, StandardCodingAgentExecutor,\n    },\n    mcp_config::{McpConfig, read_agent_config, write_agent_config},\n    profile::{ExecutorConfigs, ExecutorProfileId},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse services::services::{\n    config::{\n        Config, ConfigError, SoundFile,\n        editor::{EditorConfig, EditorType},\n        save_config_to_file,\n    },\n    container::ContainerService,\n};\nuse tokio::fs;\nuse ts_rs::TS;\nuse utils::{assets::config_path, log_msg::LogMsg, response::ApiResponse};\nuse uuid::Uuid;\n\nuse crate::{\n    DeploymentImpl,\n    error::ApiError,\n    routes::relay_ws::{SignedWebSocket, SignedWsUpgrade},\n    tunnel,\n};\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/info\", get(get_user_system_info))\n        .route(\"/config\", put(update_config))\n        .route(\"/sounds/{sound}\", get(get_sound))\n        .route(\"/mcp-config\", get(get_mcp_servers).post(update_mcp_servers))\n        .route(\"/profiles\", get(get_profiles).put(update_profiles))\n        .route(\n            \"/editors/check-availability\",\n            get(check_editor_availability),\n        )\n        .route(\"/agents/check-availability\", get(check_agent_availability))\n        .route(\"/agents/preset-options\", get(get_agent_preset_options))\n        .route(\n            \"/agents/discovered-options/ws\",\n            get(stream_executor_discovered_options_ws),\n        )\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct Environment {\n    pub os_type: String,\n    pub os_version: String,\n    pub os_architecture: String,\n    pub bitness: String,\n}\n\nimpl Default for Environment {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl Environment {\n    pub fn new() -> Self {\n        let info = os_info::get();\n        Environment {\n            os_type: info.os_type().to_string(),\n            os_version: info.version().to_string(),\n            os_architecture: info.architecture().unwrap_or(\"unknown\").to_string(),\n            bitness: info.bitness().to_string(),\n        }\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct UserSystemInfo {\n    pub version: String,\n    pub config: Config,\n    pub analytics_user_id: String,\n    pub login_status: LoginStatus,\n    #[serde(flatten)]\n    pub profiles: ExecutorConfigs,\n    pub environment: Environment,\n    /// Capabilities supported per executor (e.g., { \"CLAUDE_CODE\": [\"SESSION_FORK\"] })\n    pub capabilities: HashMap<String, Vec<BaseAgentCapability>>,\n    pub shared_api_base: Option<String>,\n    pub preview_proxy_port: Option<u16>,\n}\n\n// TODO: update frontend, BE schema has changed, this replaces GET /config and /config/constants\n#[axum::debug_handler]\nasync fn get_user_system_info(\n    State(deployment): State<DeploymentImpl>,\n) -> ResponseJson<ApiResponse<UserSystemInfo>> {\n    let config = deployment.config().read().await;\n    let login_status = tokio::time::timeout(\n        std::time::Duration::from_secs(2),\n        deployment.get_login_status(),\n    )\n    .await\n    .unwrap_or(LoginStatus::LoggedOut);\n\n    let user_system_info = UserSystemInfo {\n        version: env!(\"CARGO_PKG_VERSION\").to_string(),\n        config: config.clone(),\n        analytics_user_id: deployment.user_id().to_string(),\n        login_status,\n        profiles: ExecutorConfigs::get_cached(),\n        environment: Environment::new(),\n        capabilities: {\n            let mut caps: HashMap<String, Vec<BaseAgentCapability>> = HashMap::new();\n            let profs = ExecutorConfigs::get_cached();\n            for key in profs.executors.keys() {\n                if let Some(agent) = profs.get_coding_agent(&ExecutorProfileId::new(*key)) {\n                    caps.insert(key.to_string(), agent.capabilities());\n                }\n            }\n            caps\n        },\n        shared_api_base: deployment.shared_api_base(),\n        preview_proxy_port: crate::preview_proxy::get_proxy_port(),\n    };\n\n    ResponseJson(ApiResponse::success(user_system_info))\n}\n\nasync fn update_config(\n    State(deployment): State<DeploymentImpl>,\n    Json(new_config): Json<Config>,\n) -> ResponseJson<ApiResponse<Config>> {\n    let config_path = config_path();\n\n    // Validate git branch prefix\n    if !git::is_valid_branch_prefix(&new_config.git_branch_prefix) {\n        return ResponseJson(ApiResponse::error(\n            \"Invalid git branch prefix. Must be a valid git branch name component without slashes.\",\n        ));\n    }\n\n    // Get old config state before updating\n    let old_config = deployment.config().read().await.clone();\n\n    match save_config_to_file(&new_config, &config_path).await {\n        Ok(_) => {\n            let mut config = deployment.config().write().await;\n            *config = new_config.clone();\n            drop(config);\n\n            // Track config events when fields transition from false → true and run side effects\n            handle_config_events(&deployment, &old_config, &new_config).await;\n\n            ResponseJson(ApiResponse::success(new_config))\n        }\n        Err(e) => ResponseJson(ApiResponse::error(&format!(\"Failed to save config: {}\", e))),\n    }\n}\n\n/// Track config events when fields transition from false → true\nasync fn track_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) {\n    let events = [\n        (\n            !old.disclaimer_acknowledged && new.disclaimer_acknowledged,\n            \"onboarding_disclaimer_accepted\",\n            serde_json::json!({}),\n        ),\n        (\n            !old.onboarding_acknowledged && new.onboarding_acknowledged,\n            \"onboarding_completed\",\n            serde_json::json!({\n                \"profile\": new.executor_profile,\n                \"editor\": new.editor\n            }),\n        ),\n        (\n            !old.analytics_enabled && new.analytics_enabled,\n            \"analytics_session_start\",\n            serde_json::json!({}),\n        ),\n    ];\n\n    for (should_track, event_name, properties) in events {\n        if should_track {\n            deployment\n                .track_if_analytics_allowed(event_name, properties)\n                .await;\n        }\n    }\n}\n\nasync fn handle_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) {\n    track_config_events(deployment, old, new).await;\n\n    let old_relay_host_name = tunnel::effective_relay_host_name(old, deployment.user_id());\n    let new_relay_host_name = tunnel::effective_relay_host_name(new, deployment.user_id());\n\n    deployment\n        .server_info()\n        .set_hostname(new_relay_host_name.clone())\n        .await;\n\n    match (old.relay_enabled, new.relay_enabled) {\n        (false, true) => tunnel::spawn_relay(deployment).await,\n        (true, false) => tunnel::stop_relay(deployment).await,\n        (true, true) => {\n            if old_relay_host_name != new_relay_host_name {\n                tunnel::spawn_relay(deployment).await;\n            }\n        }\n        (false, false) => (),\n    }\n}\n\nasync fn get_sound(Path(sound): Path<SoundFile>) -> Result<Response, ApiError> {\n    let sound = sound.serve().await.map_err(DeploymentError::Other)?;\n    let response = Response::builder()\n        .status(http::StatusCode::OK)\n        .header(\n            http::header::CONTENT_TYPE,\n            http::HeaderValue::from_static(\"audio/wav\"),\n        )\n        .body(Body::from(sound.data.into_owned()))\n        .unwrap();\n    Ok(response)\n}\n\n#[derive(TS, Debug, Deserialize)]\npub struct McpServerQuery {\n    executor: BaseCodingAgent,\n}\n\n#[derive(TS, Debug, Serialize, Deserialize)]\npub struct GetMcpServerResponse {\n    // servers: HashMap<String, Value>,\n    mcp_config: McpConfig,\n    config_path: String,\n}\n\n#[derive(TS, Debug, Serialize, Deserialize)]\npub struct UpdateMcpServersBody {\n    servers: HashMap<String, Value>,\n}\n\nasync fn get_mcp_servers(\n    State(_deployment): State<DeploymentImpl>,\n    Query(query): Query<McpServerQuery>,\n) -> Result<ResponseJson<ApiResponse<GetMcpServerResponse>>, ApiError> {\n    let coding_agent = ExecutorConfigs::get_cached()\n        .get_coding_agent(&ExecutorProfileId::new(query.executor))\n        .ok_or(ConfigError::ValidationError(\n            \"Executor not found\".to_string(),\n        ))?;\n\n    if !coding_agent.supports_mcp() {\n        return Ok(ResponseJson(ApiResponse::error(\n            \"MCP not supported by this executor\",\n        )));\n    }\n\n    // Resolve supplied config path or agent default\n    let config_path = match coding_agent.default_mcp_config_path() {\n        Some(path) => path,\n        None => {\n            return Ok(ResponseJson(ApiResponse::error(\n                \"Could not determine config file path\",\n            )));\n        }\n    };\n\n    let mut mcpc = coding_agent.get_mcp_config();\n    let raw_config = read_agent_config(&config_path, &mcpc).await?;\n    let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path);\n    mcpc.set_servers(servers);\n    Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse {\n        mcp_config: mcpc,\n        config_path: config_path.to_string_lossy().to_string(),\n    })))\n}\n\nasync fn update_mcp_servers(\n    State(_deployment): State<DeploymentImpl>,\n    Query(query): Query<McpServerQuery>,\n    Json(payload): Json<UpdateMcpServersBody>,\n) -> Result<ResponseJson<ApiResponse<String>>, ApiError> {\n    let profiles = ExecutorConfigs::get_cached();\n    let agent = profiles\n        .get_coding_agent(&ExecutorProfileId::new(query.executor))\n        .ok_or(ConfigError::ValidationError(\n            \"Executor not found\".to_string(),\n        ))?;\n\n    if !agent.supports_mcp() {\n        return Ok(ResponseJson(ApiResponse::error(\n            \"This executor does not support MCP servers\",\n        )));\n    }\n\n    // Resolve supplied config path or agent default\n    let config_path = match agent.default_mcp_config_path() {\n        Some(path) => path.to_path_buf(),\n        None => {\n            return Ok(ResponseJson(ApiResponse::error(\n                \"Could not determine config file path\",\n            )));\n        }\n    };\n\n    let mcpc = agent.get_mcp_config();\n    match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await {\n        Ok(message) => Ok(ResponseJson(ApiResponse::success(message))),\n        Err(e) => Ok(ResponseJson(ApiResponse::error(&format!(\n            \"Failed to update MCP servers: {}\",\n            e\n        )))),\n    }\n}\n\nasync fn update_mcp_servers_in_config(\n    config_path: &std::path::Path,\n    mcpc: &McpConfig,\n    new_servers: HashMap<String, Value>,\n) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {\n    // Ensure parent directory exists\n    if let Some(parent) = config_path.parent() {\n        fs::create_dir_all(parent).await?;\n    }\n    // Read existing config (JSON or TOML depending on agent)\n    let mut config = read_agent_config(config_path, mcpc).await?;\n\n    // Get the current server count for comparison\n    let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len();\n\n    // Set the MCP servers using the correct attribute path\n    set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?;\n\n    // Write the updated config back to file (JSON or TOML depending on agent)\n    write_agent_config(config_path, mcpc, &config).await?;\n\n    let new_count = new_servers.len();\n    let message = match (old_servers, new_count) {\n        (0, 0) => \"No MCP servers configured\".to_string(),\n        (0, n) => format!(\"Added {} MCP server(s)\", n),\n        (old, new) if old == new => format!(\"Updated MCP server configuration ({} server(s))\", new),\n        (old, new) => format!(\n            \"Updated MCP server configuration (was {}, now {})\",\n            old, new\n        ),\n    };\n\n    Ok(message)\n}\n\n/// Helper function to get MCP servers from config using a path\nfn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap<String, Value> {\n    let mut current = raw_config;\n    for part in path {\n        current = match current.get(part) {\n            Some(val) => val,\n            None => return HashMap::new(),\n        };\n    }\n    // Extract the servers object\n    match current.as_object() {\n        Some(servers) => servers\n            .iter()\n            .map(|(k, v)| (k.clone(), v.clone()))\n            .collect(),\n        None => HashMap::new(),\n    }\n}\n\n/// Helper function to set MCP servers in config using a path\nfn set_mcp_servers_in_config_path(\n    raw_config: &mut Value,\n    path: &[String],\n    servers: &HashMap<String, Value>,\n) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {\n    // Ensure config is an object\n    if !raw_config.is_object() {\n        *raw_config = serde_json::json!({});\n    }\n\n    let mut current = raw_config;\n    // Navigate/create the nested structure (all parts except the last)\n    for part in &path[..path.len() - 1] {\n        if current.get(part).is_none() {\n            current\n                .as_object_mut()\n                .unwrap()\n                .insert(part.to_string(), serde_json::json!({}));\n        }\n        current = current.get_mut(part).unwrap();\n        if !current.is_object() {\n            *current = serde_json::json!({});\n        }\n    }\n\n    // Set the final attribute\n    let final_attr = path.last().unwrap();\n    current\n        .as_object_mut()\n        .unwrap()\n        .insert(final_attr.to_string(), serde_json::to_value(servers)?);\n\n    Ok(())\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ProfilesContent {\n    pub content: String,\n    pub path: String,\n}\n\nasync fn get_profiles(\n    State(_deployment): State<DeploymentImpl>,\n) -> ResponseJson<ApiResponse<ProfilesContent>> {\n    let profiles_path = utils::assets::profiles_path();\n\n    // Use cached data to ensure consistency with runtime and PUT updates\n    let profiles = ExecutorConfigs::get_cached();\n\n    let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| {\n        tracing::error!(\"Failed to serialize profiles to JSON: {}\", e);\n        serde_json::to_string_pretty(&ExecutorConfigs::from_defaults())\n            .unwrap_or_else(|_| \"{}\".to_string())\n    });\n\n    ResponseJson(ApiResponse::success(ProfilesContent {\n        content,\n        path: profiles_path.display().to_string(),\n    }))\n}\n\nasync fn update_profiles(\n    State(_deployment): State<DeploymentImpl>,\n    body: String,\n) -> ResponseJson<ApiResponse<String>> {\n    // Try to parse as ExecutorProfileConfigs format\n    match serde_json::from_str::<ExecutorConfigs>(&body) {\n        Ok(executor_profiles) => {\n            // Save the profiles to file\n            match executor_profiles.save_overrides() {\n                Ok(_) => {\n                    tracing::info!(\"Executor profiles saved successfully\");\n                    // Reload the cached profiles\n                    ExecutorConfigs::reload();\n                    ResponseJson(ApiResponse::success(\n                        \"Executor profiles updated successfully\".to_string(),\n                    ))\n                }\n                Err(e) => {\n                    tracing::error!(\"Failed to save executor profiles: {}\", e);\n                    ResponseJson(ApiResponse::error(&format!(\n                        \"Failed to save executor profiles: {}\",\n                        e\n                    )))\n                }\n            }\n        }\n        Err(e) => ResponseJson(ApiResponse::error(&format!(\n            \"Invalid executor profiles format: {}\",\n            e\n        ))),\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CheckEditorAvailabilityQuery {\n    editor_type: EditorType,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CheckEditorAvailabilityResponse {\n    available: bool,\n}\n\nasync fn check_editor_availability(\n    State(_deployment): State<DeploymentImpl>,\n    Query(query): Query<CheckEditorAvailabilityQuery>,\n) -> ResponseJson<ApiResponse<CheckEditorAvailabilityResponse>> {\n    // Construct a minimal EditorConfig for checking\n    let editor_config = EditorConfig::new(\n        query.editor_type,\n        None,  // custom_command\n        None,  // remote_ssh_host\n        None,  // remote_ssh_user\n        false, // auto_install_extension\n    );\n\n    let available = editor_config.check_availability().await;\n    ResponseJson(ApiResponse::success(CheckEditorAvailabilityResponse {\n        available,\n    }))\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CheckAgentAvailabilityQuery {\n    executor: BaseCodingAgent,\n}\n\nasync fn check_agent_availability(\n    State(_deployment): State<DeploymentImpl>,\n    Query(query): Query<CheckAgentAvailabilityQuery>,\n) -> ResponseJson<ApiResponse<AvailabilityInfo>> {\n    let profiles = ExecutorConfigs::get_cached();\n    let profile_id = ExecutorProfileId::new(query.executor);\n\n    let info = match profiles.get_coding_agent(&profile_id) {\n        Some(agent) => agent.get_availability_info(),\n        None => AvailabilityInfo::NotFound,\n    };\n\n    ResponseJson(ApiResponse::success(info))\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct AgentPresetOptionsQuery {\n    pub executor: BaseCodingAgent,\n    pub variant: Option<String>,\n}\n\nasync fn get_agent_preset_options(\n    Query(query): Query<AgentPresetOptionsQuery>,\n) -> ResponseJson<ApiResponse<executors::profile::ExecutorConfig>> {\n    let profiles = ExecutorConfigs::get_cached();\n    let profile_id = if let Some(variant) = query.variant {\n        ExecutorProfileId::with_variant(query.executor, variant)\n    } else {\n        ExecutorProfileId::new(query.executor)\n    };\n\n    let options = match profiles.get_coding_agent(&profile_id) {\n        Some(agent) => agent.get_preset_options(),\n        None => {\n            // Return a default config if not found\n            executors::profile::ExecutorConfig::new(query.executor)\n        }\n    };\n\n    ResponseJson(ApiResponse::success(options))\n}\n\n#[derive(Debug, Deserialize)]\npub struct ExecutorDiscoveredOptionsStreamQuery {\n    executor: BaseCodingAgent,\n    #[serde(default)]\n    session_id: Option<Uuid>,\n    #[serde(default)]\n    workspace_id: Option<Uuid>,\n    #[serde(default)]\n    repo_id: Option<Uuid>,\n}\n\npub async fn stream_executor_discovered_options_ws(\n    ws: SignedWsUpgrade,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ExecutorDiscoveredOptionsStreamQuery>,\n) -> impl IntoResponse {\n    ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_executor_discovered_options_ws(socket, deployment, query).await {\n            tracing::warn!(\"discovered options WS closed: {}\", e);\n        }\n    })\n}\n\nasync fn handle_executor_discovered_options_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n    query: ExecutorDiscoveredOptionsStreamQuery,\n) -> anyhow::Result<()> {\n    use futures_util::StreamExt;\n\n    match deployment\n        .container()\n        .discover_executor_options(\n            ExecutorProfileId::new(query.executor),\n            query.session_id,\n            query.workspace_id,\n            query.repo_id,\n        )\n        .await\n    {\n        Ok(Some(mut stream)) => {\n            if let Some(patch) = stream.next().await {\n                let _ = socket\n                    .send(LogMsg::JsonPatch(patch).to_ws_message_unchecked())\n                    .await;\n            }\n\n            let _ = socket.send(LogMsg::Ready.to_ws_message_unchecked()).await;\n\n            loop {\n                tokio::select! {\n                    patch = stream.next() => {\n                        let Some(patch) = patch else {\n                            break;\n                        };\n                        if socket\n                            .send(LogMsg::JsonPatch(patch).to_ws_message_unchecked())\n                            .await\n                            .is_err()\n                        {\n                            break;\n                        }\n                    }\n                    inbound = socket.recv() => {\n                        match inbound {\n                            Ok(Some(Message::Close(_))) => break,\n                            Ok(Some(_)) => {}\n                            Ok(None) => break,\n                            Err(_) => break,\n                        }\n                    }\n                }\n            }\n        }\n        Ok(None) => {\n            let _ = socket.send(LogMsg::Ready.to_ws_message_unchecked()).await;\n        }\n        Err(e) => {\n            tracing::warn!(\"Failed to start discovered options stream: {}\", e);\n        }\n    }\n\n    let _ = socket\n        .send(LogMsg::Finished.to_ws_message_unchecked())\n        .await;\n    Ok(())\n}\n"
  },
  {
    "path": "crates/server/src/routes/containers.rs",
    "content": "use axum::{\n    Router,\n    extract::{Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse db::models::{\n    requests::ContainerQuery,\n    workspace::{Workspace, WorkspaceContext},\n};\nuse deployment::Deployment;\nuse serde::Serialize;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Serialize)]\npub struct ContainerInfo {\n    pub attempt_id: Uuid,\n}\n\npub async fn get_container_info(\n    Query(query): Query<ContainerQuery>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<ContainerInfo>>, ApiError> {\n    let info =\n        Workspace::resolve_container_ref_by_prefix(&deployment.db().pool, &query.container_ref)\n            .await\n            .map_err(ApiError::Database)?;\n\n    Ok(ResponseJson(ApiResponse::success(ContainerInfo {\n        attempt_id: info.workspace_id,\n    })))\n}\n\npub async fn get_context(\n    State(deployment): State<DeploymentImpl>,\n    Query(payload): Query<ContainerQuery>,\n) -> Result<ResponseJson<ApiResponse<WorkspaceContext>>, ApiError> {\n    let info =\n        Workspace::resolve_container_ref_by_prefix(&deployment.db().pool, &payload.container_ref)\n            .await\n            .map_err(ApiError::Database)?;\n\n    let ctx = Workspace::load_context(&deployment.db().pool, info.workspace_id).await?;\n    Ok(ResponseJson(ApiResponse::success(ctx)))\n}\n\npub fn router(_deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    Router::new()\n        // NOTE: /containers/info is required by the VSCode extension (vibe-kanban-vscode)\n        // to auto-detect workspaces. It maps workspace_id to attempt_id for compatibility.\n        // Do not remove this endpoint without updating the extension.\n        .route(\"/containers/info\", get(get_container_info))\n        .route(\"/containers/attempt-context\", get(get_context))\n}\n"
  },
  {
    "path": "crates/server/src/routes/events.rs",
    "content": "use axum::{\n    BoxError, Router,\n    extract::State,\n    response::{\n        Sse,\n        sse::{Event, KeepAlive},\n    },\n    routing::get,\n};\nuse deployment::Deployment;\nuse futures_util::TryStreamExt;\n\nuse crate::DeploymentImpl;\n\npub async fn events(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, BoxError>>>, axum::http::StatusCode>\n{\n    // Ask the container service for a combined \"history + live\" stream\n    let stream = deployment.stream_events().await;\n    Ok(Sse::new(stream.map_err(|e| -> BoxError { e.into() })).keep_alive(KeepAlive::default()))\n}\n\npub fn router(_: &DeploymentImpl) -> Router<DeploymentImpl> {\n    let events_router = Router::new().route(\"/\", get(events));\n\n    Router::new().nest(\"/events\", events_router)\n}\n"
  },
  {
    "path": "crates/server/src/routes/execution_processes.rs",
    "content": "use anyhow;\nuse axum::{\n    Extension, Router,\n    extract::{Path, Query, State, ws::Message},\n    middleware::from_fn_with_state,\n    response::{IntoResponse, Json as ResponseJson},\n    routing::{get, post},\n};\nuse db::models::{\n    execution_process::{ExecutionProcess, ExecutionProcessError, ExecutionProcessStatus},\n    execution_process_repo_state::ExecutionProcessRepoState,\n};\nuse deployment::Deployment;\nuse futures_util::{StreamExt, TryStreamExt};\nuse serde::Deserialize;\nuse services::services::container::ContainerService;\nuse utils::{log_msg::LogMsg, response::ApiResponse};\nuse uuid::Uuid;\n\nuse crate::{\n    DeploymentImpl,\n    error::ApiError,\n    middleware::load_execution_process_middleware,\n    routes::relay_ws::{SignedWebSocket, SignedWsUpgrade},\n};\n\n#[derive(Debug, Deserialize)]\npub struct SessionExecutionProcessQuery {\n    pub session_id: Uuid,\n    /// If true, include soft-deleted (dropped) processes in results/stream\n    #[serde(default)]\n    pub show_soft_deleted: Option<bool>,\n}\n\npub async fn get_execution_process_by_id(\n    Extension(execution_process): Extension<ExecutionProcess>,\n    State(_deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<ExecutionProcess>>, ApiError> {\n    Ok(ResponseJson(ApiResponse::success(execution_process)))\n}\n\npub async fn stream_raw_logs_ws(\n    ws: SignedWsUpgrade,\n    State(deployment): State<DeploymentImpl>,\n    Path(exec_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ApiError> {\n    // Check if the stream exists before upgrading the WebSocket\n    let _stream = deployment\n        .container()\n        .stream_raw_logs(&exec_id)\n        .await\n        .ok_or_else(|| {\n            ApiError::ExecutionProcess(ExecutionProcessError::ExecutionProcessNotFound)\n        })?;\n\n    Ok(ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_raw_logs_ws(socket, deployment, exec_id).await {\n            tracing::warn!(\"raw logs WS closed: {}\", e);\n        }\n    }))\n}\n\nasync fn handle_raw_logs_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n    exec_id: Uuid,\n) -> anyhow::Result<()> {\n    use std::sync::{\n        Arc,\n        atomic::{AtomicUsize, Ordering},\n    };\n\n    use executors::logs::utils::patch::ConversationPatch;\n    use utils::log_msg::LogMsg;\n\n    // Get the raw stream and convert to JSON patches on-the-fly\n    let raw_stream = deployment\n        .container()\n        .stream_raw_logs(&exec_id)\n        .await\n        .ok_or_else(|| anyhow::anyhow!(\"Execution process not found\"))?;\n\n    let counter = Arc::new(AtomicUsize::new(0));\n    let mut stream = raw_stream.map_ok({\n        let counter = counter.clone();\n        move |m| match m {\n            LogMsg::Stdout(content) => {\n                let index = counter.fetch_add(1, Ordering::SeqCst);\n                let patch = ConversationPatch::add_stdout(index, content);\n                LogMsg::JsonPatch(patch).to_ws_message_unchecked()\n            }\n            LogMsg::Stderr(content) => {\n                let index = counter.fetch_add(1, Ordering::SeqCst);\n                let patch = ConversationPatch::add_stderr(index, content);\n                LogMsg::JsonPatch(patch).to_ws_message_unchecked()\n            }\n            LogMsg::Finished => LogMsg::Finished.to_ws_message_unchecked(),\n            _ => unreachable!(\"Raw stream should only have Stdout/Stderr/Finished\"),\n        }\n    });\n\n    loop {\n        tokio::select! {\n            item = stream.next() => {\n                match item {\n                    Some(Ok(msg)) => {\n                        if socket.send(msg).await.is_err() {\n                            break;\n                        }\n                    }\n                    Some(Err(e)) => {\n                        tracing::error!(\"stream error: {}\", e);\n                        break;\n                    }\n                    None => break,\n                }\n            }\n            inbound = socket.recv() => {\n                match inbound {\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(_) => break,\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\npub async fn stream_normalized_logs_ws(\n    ws: SignedWsUpgrade,\n    State(deployment): State<DeploymentImpl>,\n    Path(exec_id): Path<Uuid>,\n) -> Result<impl IntoResponse, ApiError> {\n    let stream = deployment\n        .container()\n        .stream_normalized_logs(&exec_id)\n        .await\n        .ok_or_else(|| {\n            ApiError::ExecutionProcess(ExecutionProcessError::ExecutionProcessNotFound)\n        })?;\n\n    // Convert the error type to anyhow::Error and turn TryStream -> Stream<Result<_, _>>\n    let stream = stream.err_into::<anyhow::Error>().into_stream();\n\n    Ok(ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_normalized_logs_ws(socket, stream).await {\n            tracing::warn!(\"normalized logs WS closed: {}\", e);\n        }\n    }))\n}\n\nasync fn handle_normalized_logs_ws(\n    mut socket: SignedWebSocket,\n    stream: impl futures_util::Stream<Item = anyhow::Result<LogMsg>> + Unpin + Send + 'static,\n) -> anyhow::Result<()> {\n    let mut stream = stream.map_ok(|msg| msg.to_ws_message_unchecked());\n    loop {\n        tokio::select! {\n            item = stream.next() => {\n                match item {\n                    Some(Ok(msg)) => {\n                        if socket.send(msg).await.is_err() {\n                            break;\n                        }\n                    }\n                    Some(Err(e)) => {\n                        tracing::error!(\"stream error: {}\", e);\n                        break;\n                    }\n                    None => break,\n                }\n            }\n            inbound = socket.recv() => {\n                match inbound {\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(_) => break,\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\npub async fn stop_execution_process(\n    Extension(execution_process): Extension<ExecutionProcess>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    deployment\n        .container()\n        .stop_execution(&execution_process, ExecutionProcessStatus::Killed)\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub async fn stream_execution_processes_by_session_ws(\n    ws: SignedWsUpgrade,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<SessionExecutionProcessQuery>,\n) -> impl IntoResponse {\n    ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_execution_processes_by_session_ws(\n            socket,\n            deployment,\n            query.session_id,\n            query.show_soft_deleted.unwrap_or(false),\n        )\n        .await\n        {\n            tracing::warn!(\"execution processes by session WS closed: {}\", e);\n        }\n    })\n}\n\nasync fn handle_execution_processes_by_session_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n    session_id: uuid::Uuid,\n    show_soft_deleted: bool,\n) -> anyhow::Result<()> {\n    // Get the raw stream and convert LogMsg to WebSocket messages\n    let mut stream = deployment\n        .events()\n        .stream_execution_processes_for_session_raw(session_id, show_soft_deleted)\n        .await?\n        .map_ok(|msg| msg.to_ws_message_unchecked());\n\n    loop {\n        tokio::select! {\n            item = stream.next() => {\n                match item {\n                    Some(Ok(msg)) => {\n                        if socket.send(msg).await.is_err() {\n                            break;\n                        }\n                    }\n                    Some(Err(e)) => {\n                        tracing::error!(\"stream error: {}\", e);\n                        break;\n                    }\n                    None => break,\n                }\n            }\n            inbound = socket.recv() => {\n                match inbound {\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(_) => break,\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\npub async fn get_execution_process_repo_states(\n    Extension(execution_process): Extension<ExecutionProcess>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<ExecutionProcessRepoState>>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let repo_states =\n        ExecutionProcessRepoState::find_by_execution_process_id(pool, execution_process.id).await?;\n    Ok(ResponseJson(ApiResponse::success(repo_states)))\n}\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    let workspace_id_router = Router::new()\n        .route(\"/\", get(get_execution_process_by_id))\n        .route(\"/stop\", post(stop_execution_process))\n        .route(\"/repo-states\", get(get_execution_process_repo_states))\n        .route(\"/raw-logs/ws\", get(stream_raw_logs_ws))\n        .route(\"/normalized-logs/ws\", get(stream_normalized_logs_ws))\n        .layer(from_fn_with_state(\n            deployment.clone(),\n            load_execution_process_middleware,\n        ));\n\n    let workspaces_router = Router::new()\n        .route(\n            \"/stream/session/ws\",\n            get(stream_execution_processes_by_session_ws),\n        )\n        .nest(\"/{id}\", workspace_id_router);\n\n    Router::new().nest(\"/execution-processes\", workspaces_router)\n}\n"
  },
  {
    "path": "crates/server/src/routes/filesystem.rs",
    "content": "use axum::{\n    Router,\n    extract::{Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse deployment::Deployment;\nuse serde::Deserialize;\nuse services::services::filesystem::{DirectoryEntry, DirectoryListResponse, FilesystemError};\nuse utils::response::ApiResponse;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct ListDirectoryQuery {\n    path: Option<String>,\n}\n\npub async fn list_directory(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListDirectoryQuery>,\n) -> Result<ResponseJson<ApiResponse<DirectoryListResponse>>, ApiError> {\n    match deployment.filesystem().list_directory(query.path).await {\n        Ok(response) => Ok(ResponseJson(ApiResponse::success(response))),\n        Err(FilesystemError::DirectoryDoesNotExist) => {\n            Ok(ResponseJson(ApiResponse::error(\"Directory does not exist\")))\n        }\n        Err(FilesystemError::PathIsNotDirectory) => {\n            Ok(ResponseJson(ApiResponse::error(\"Path is not a directory\")))\n        }\n        Err(FilesystemError::Io(e)) => {\n            tracing::error!(\"Failed to read directory: {}\", e);\n            Ok(ResponseJson(ApiResponse::error(&format!(\n                \"Failed to read directory: {}\",\n                e\n            ))))\n        }\n    }\n}\n\npub async fn list_git_repos(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListDirectoryQuery>,\n) -> Result<ResponseJson<ApiResponse<Vec<DirectoryEntry>>>, ApiError> {\n    let res = if let Some(ref path) = query.path {\n        deployment\n            .filesystem()\n            .list_git_repos(Some(path.clone()), 800, 1200, Some(3))\n            .await\n    } else {\n        deployment\n            .filesystem()\n            .list_common_git_repos(800, 1200, Some(4))\n            .await\n    };\n    match res {\n        Ok(response) => Ok(ResponseJson(ApiResponse::success(response))),\n        Err(FilesystemError::DirectoryDoesNotExist) => {\n            Ok(ResponseJson(ApiResponse::error(\"Directory does not exist\")))\n        }\n        Err(FilesystemError::PathIsNotDirectory) => {\n            Ok(ResponseJson(ApiResponse::error(\"Path is not a directory\")))\n        }\n        Err(FilesystemError::Io(e)) => {\n            tracing::error!(\"Failed to read directory: {}\", e);\n            Ok(ResponseJson(ApiResponse::error(&format!(\n                \"Failed to read directory: {}\",\n                e\n            ))))\n        }\n    }\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/filesystem/directory\", get(list_directory))\n        .route(\"/filesystem/git-repos\", get(list_git_repos))\n}\n"
  },
  {
    "path": "crates/server/src/routes/frontend.rs",
    "content": "use axum::{\n    body::Body,\n    http::HeaderValue,\n    response::{IntoResponse, Response},\n};\nuse reqwest::{StatusCode, header};\nuse rust_embed::RustEmbed;\n\n#[derive(RustEmbed)]\n#[folder = \"../../packages/local-web/dist\"]\npub struct Assets;\n\npub async fn serve_frontend(uri: axum::extract::Path<String>) -> impl IntoResponse {\n    let path = uri.trim_start_matches('/');\n    serve_file(path).await\n}\n\npub async fn serve_frontend_root() -> impl IntoResponse {\n    serve_file(\"index.html\").await\n}\n\nasync fn serve_file(path: &str) -> impl IntoResponse + use<> {\n    let file = Assets::get(path);\n\n    match file {\n        Some(content) => {\n            let mime = mime_guess::from_path(path).first_or_octet_stream();\n\n            Response::builder()\n                .status(StatusCode::OK)\n                .header(\n                    header::CONTENT_TYPE,\n                    HeaderValue::from_str(mime.as_ref()).unwrap(),\n                )\n                .body(Body::from(content.data.into_owned()))\n                .unwrap()\n        }\n        None => {\n            // For SPA routing, serve index.html for unknown routes\n            if let Some(index) = Assets::get(\"index.html\") {\n                Response::builder()\n                    .status(StatusCode::OK)\n                    .header(header::CONTENT_TYPE, HeaderValue::from_static(\"text/html\"))\n                    .body(Body::from(index.data.into_owned()))\n                    .unwrap()\n            } else {\n                Response::builder()\n                    .status(StatusCode::NOT_FOUND)\n                    .body(Body::from(\"404 Not Found\"))\n                    .unwrap()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/server/src/routes/health.rs",
    "content": "use axum::response::Json;\nuse utils::response::ApiResponse;\n\npub async fn health_check() -> Json<ApiResponse<String>> {\n    Json(ApiResponse::success(\"OK\".to_string()))\n}\n"
  },
  {
    "path": "crates/server/src/routes/migration.rs",
    "content": "use axum::{\n    Router,\n    extract::{Json, State},\n    response::Json as ResponseJson,\n    routing::{get, post},\n};\nuse db::models::project::Project;\nuse deployment::Deployment;\nuse services::services::migration::{MigrationRequest, MigrationResponse, MigrationService};\nuse utils::response::ApiResponse;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/migration/start\", post(start_migration))\n        .route(\"/migration/projects\", get(list_projects))\n}\n\nasync fn start_migration(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<MigrationRequest>,\n) -> Result<ResponseJson<ApiResponse<MigrationResponse>>, ApiError> {\n    let remote_client = deployment.remote_client()?;\n    let sqlite_pool = deployment.db().pool.clone();\n\n    let service = MigrationService::new(sqlite_pool, remote_client);\n    let project_ids = request.project_id_set();\n    let report = service\n        .run_migration(request.organization_id, project_ids)\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(MigrationResponse {\n        report,\n    })))\n}\n\nasync fn list_projects(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let projects = Project::find_all(pool).await?;\n\n    Ok(ResponseJson(ApiResponse::success(projects)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/mod.rs",
    "content": "use axum::{\n    Router,\n    routing::{IntoMakeService, get},\n};\nuse tower_http::{compression::CompressionLayer, validate_request::ValidateRequestHeaderLayer};\n\nuse crate::{DeploymentImpl, middleware};\n\npub mod approvals;\npub mod config;\npub mod containers;\npub mod filesystem;\n// pub mod github;\npub mod attachments;\npub mod events;\npub mod execution_processes;\npub mod frontend;\npub mod health;\npub mod migration;\npub mod oauth;\npub mod organizations;\npub mod relay_auth;\npub mod relay_ws;\npub mod releases;\npub mod remote;\npub mod repo;\npub mod scratch;\npub mod search;\npub mod sessions;\npub mod tags;\npub mod terminal;\npub mod workspaces;\n\npub fn router(deployment: DeploymentImpl) -> IntoMakeService<Router> {\n    let relay_signed_routes = Router::new()\n        .route(\"/health\", get(health::health_check))\n        .merge(config::router())\n        .merge(containers::router(&deployment))\n        .merge(workspaces::router(&deployment))\n        .merge(execution_processes::router(&deployment))\n        .merge(tags::router(&deployment))\n        .merge(oauth::router())\n        .merge(organizations::router())\n        .merge(filesystem::router())\n        .merge(repo::router())\n        .merge(events::router(&deployment))\n        .merge(approvals::router())\n        .merge(scratch::router(&deployment))\n        .merge(search::router(&deployment))\n        .merge(releases::router())\n        .merge(migration::router())\n        .merge(sessions::router(&deployment))\n        .merge(terminal::router())\n        .nest(\"/remote\", remote::router())\n        .nest(\"/attachments\", attachments::routes())\n        .layer(axum::middleware::from_fn_with_state(\n            deployment.clone(),\n            middleware::sign_relay_response,\n        ))\n        .layer(axum::middleware::from_fn_with_state(\n            deployment.clone(),\n            middleware::require_relay_request_signature,\n        ))\n        .with_state(deployment.clone());\n\n    let api_routes = Router::new()\n        .merge(relay_auth::router())\n        .merge(relay_signed_routes)\n        .layer(ValidateRequestHeaderLayer::custom(\n            middleware::validate_origin,\n        ))\n        .layer(axum::middleware::from_fn(middleware::log_server_errors))\n        .with_state(deployment);\n\n    Router::new()\n        .route(\"/\", get(frontend::serve_frontend_root))\n        .route(\"/{*path}\", get(frontend::serve_frontend))\n        .nest(\"/api\", api_routes)\n        .layer(CompressionLayer::new())\n        .into_make_service()\n}\n"
  },
  {
    "path": "crates/server/src/routes/oauth.rs",
    "content": "use api_types::{HandoffInitRequest, HandoffRedeemRequest, StatusResponse};\nuse axum::{\n    Router,\n    extract::{Json, Query, State},\n    http::{Response, StatusCode},\n    response::Json as ResponseJson,\n    routing::{get, post},\n};\nuse chrono::{DateTime, Utc};\nuse deployment::Deployment;\nuse rand::{Rng, distributions::Alphanumeric};\nuse serde::{Deserialize, Serialize};\nuse services::services::{\n    config::save_config_to_file, oauth_credentials::Credentials, remote_sync,\n};\nuse sha2::{Digest, Sha256};\nuse ts_rs::TS;\nuse utils::{assets::config_path, jwt::extract_expiration, response::ApiResponse};\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError, tunnel};\n\n/// Base64-encoded 32x32 app icon (from `crates/tauri-app/icons/32x32.png`).\nconst APP_ICON_BASE64: &str = \"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAASAAAAABAAABIAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAA5NwgRAAAACXBIWXMAACxLAAAsSwGlPZapAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkZpZ21hPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoE/1zIAAAFUElEQVRYCe1Vy2tcVRj/3cfcmZt5ZPKibRK1bVrpg1YplIq0vhAqVkEqVVxapNpF/wGhO3cuXCmI4tpSXIkLi9KHm1KktVXsC5omNWk6ycRkJjN35r6Ov+/eO5mZDoIbySaHOXPvPb/vfN/vfK+jlT7eFQLQONdkmFBrZ1xOLATWdKwTWPdArwcCP05KjZWpG70JGgaASjJXcJHrXDNkT0dVd2Fmj75uApoOY2RrZFj5DYSLM9SltzfRsF7YCC2T45pCILjfhF4cg2bZXAoRlB/EhIQYhz4wDi2VIRYkGNtOggneJiCb7QIGTn4DIz8Ed/YOlr48Ad1ZFrloBG4TmcOnkNv/pthH+auT8G5eQv/R07B3PA/lNlD+4jgw82ck7wcBiu98gszEPoSNFZQ/fx/a3N1EW/zQoz4gHuX0xLuGBc20EGb6Ee58ld6mBxI8CBR8zYxwGCbC7Yfg5TYgUFq0JnuDHa9A2QPQGErfcRBAjzA9W4z0hekCPUQvJDo7PBCTaMXXr1fQqK3AZjwNEZYRbYo/FA3UF0vUJQTjtdB34SwtIB0qWAzLwLEPkd64BcvTtzF74SysxhL6eAC9pY8quzwQGYgsSZg0gi2jjz0TGV1i2aFMoq4JGc+Fmyogd/BdpPpH0Jy5A0z9hky2wFQQN7f19XogUb76aAnLQoexCH/sW9N1pArDTOTNCPMjzMmQOerQhxr6RsZRu/IDcmEtTuxkbw+BVZ3y0jlbBLoEOmT4qmey2HTkBLTXP+AXzeoabn59GurRPQw/sYUhWIRpWV0HaROQS5mblBbXfuD7CFwXCKRsCIlrmyxNxjkaZhohk49/FAkh2+XEhpmKYOHp+x6Kg8NSa/BKD2BL31g9QCTWUYZivFHH3JVzyD+9D5mhURRfOAbv0S1gfgoqN4js66dgbtuH5UczmP35DIylOZgkHJIkCwQBCd47/x28yiLSgxsx/uJbyB94A7VfzkLdvQw9zwp4jEA7CUnAbNZRu/w9nPlZGIUh2LsPQu09DJXJw2U5mofeQ2psGxoPJ+H9cRFW2o5OHbLbBSThOTU4V3+CunMZ7uR1klLom9iL7P4jSG99lgy7E1DItAnwI2WaMCev8dTTaDp1ePUVONkRBJt2QPVvgN9w0KxWGZom+kZJ5Pp5YImlyFAEVC5hy45vg1Vn85qfhlNZhks9DhuMO7qbUZSQSazas50DssiyKsBD+eIZrNQdjL38NorPHQEOvBZt0llG97/9DM0bFzC4fQ8KTpn6aFiyXQjI9DzYzt/wGLZbp4/iyY8+RWH7M/CHR+FOXYe5MEVdJMKfDD1i1GLFp8HLJFudg1GZh2JHZPECdj/Qx86om8gV+jEwsZv3xBw3M9OpjDcEFDFlpHhKfjHZ8kaI8WYJRuhB8S5Qdh6NnS8hsHiPMHFbnmh7ICZEZjoyThW1qz9ifvMeOLOTZCwMaUya0/x9pGVO34Bh25Gu6t1rWCwvwJ+bhFWrROcSYiaJVG9fQ/nhXwiYN2alBIudMKqzJAy9BAiYzIV8bQFLv56DqpTZOoM4eXk6c2gTLJZanxWXm0FS4e+X4A09Ra+VYI5NsNuRsOjhX8hw+cVRGNUFWGNb250wObBWOr5LuPQMOXPFzCFkvUvHjTTSCyHdl3GryCrpBwIo1LUUHCtP1zMUvCNybgVWRFkwCw1i0pQUkzRPLKW1TfZ6QGxxyD1QoLBqRp9df9EdELPiugZbeUjXy5GMUBIi8SAWusQWurG2/c5GlOzpeOhCI8nWjuX4tUOJ9HoJxer4j5jI/6sHVpX9zy/rBNY9sOYe+AcCwIEbenVoBQAAAABJRU5ErkJggg==\";\n\n/// Shared CSS styles for standalone OAuth HTML pages (success & error).\n/// Colors and typography match the app's design system (light mode defaults\n/// from `packages/web-core/src/app/styles/new/index.css`).\nconst AUTH_PAGE_STYLES: &str = r#\"<style>\n  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap');\n  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n  body {\n    font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n    background: #f2f2f2;\n    color: #333;\n    min-height: 100vh;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n  .container {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 24px;\n    padding: 24px;\n  }\n  .logo { width: 40px; height: 40px; }\n  .content {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 4px;\n  }\n  .title {\n    font-size: 13px;\n    font-weight: 500;\n    color: #0d0d0d;\n  }\n  .subtitle {\n    font-size: 12px;\n    color: #636363;\n  }\n</style>\"#;\n\n/// Response from GET /api/auth/token - returns the current access token\n#[derive(Debug, Serialize, TS)]\npub struct TokenResponse {\n    pub access_token: String,\n    pub expires_at: Option<DateTime<Utc>>,\n}\n\n/// Response from GET /api/auth/user - returns the current user ID\n#[derive(Debug, Serialize, TS)]\npub struct CurrentUserResponse {\n    pub user_id: String,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/auth/handoff/init\", post(handoff_init))\n        .route(\"/auth/handoff/complete\", get(handoff_complete))\n        .route(\"/auth/logout\", post(logout))\n        .route(\"/auth/status\", get(status))\n        .route(\"/auth/token\", get(get_token))\n        .route(\"/auth/user\", get(get_current_user))\n}\n\n#[derive(Debug, Deserialize)]\nstruct HandoffInitPayload {\n    provider: String,\n    return_to: String,\n}\n\n#[derive(Debug, Serialize)]\nstruct HandoffInitResponseBody {\n    handoff_id: Uuid,\n    authorize_url: String,\n}\n\nasync fn handoff_init(\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<HandoffInitPayload>,\n) -> Result<ResponseJson<ApiResponse<HandoffInitResponseBody>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let app_verifier = generate_secret();\n    let app_challenge = hash_sha256_hex(&app_verifier);\n\n    let request = HandoffInitRequest {\n        provider: payload.provider.clone(),\n        return_to: payload.return_to.clone(),\n        app_challenge,\n    };\n\n    let response = client.handoff_init(&request).await?;\n\n    deployment\n        .store_oauth_handoff(response.handoff_id, payload.provider, app_verifier)\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(\n        HandoffInitResponseBody {\n            handoff_id: response.handoff_id,\n            authorize_url: response.authorize_url,\n        },\n    )))\n}\n\n#[derive(Debug, Deserialize)]\nstruct HandoffCompleteQuery {\n    handoff_id: Uuid,\n    #[serde(default)]\n    app_code: Option<String>,\n    #[serde(default)]\n    error: Option<String>,\n    /// When set to \"desktop\", the callback page will not auto-close so the user\n    /// can see the success message (e.g. when opened from the Tauri desktop app).\n    #[serde(default)]\n    source: Option<String>,\n}\n\nasync fn handoff_complete(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<HandoffCompleteQuery>,\n) -> Result<Response<String>, ApiError> {\n    if let Some(error) = query.error {\n        return Ok(simple_html_response(\n            StatusCode::BAD_REQUEST,\n            format!(\"OAuth authorization failed: {error}\"),\n        ));\n    }\n\n    let Some(app_code) = query.app_code.clone() else {\n        return Ok(simple_html_response(\n            StatusCode::BAD_REQUEST,\n            \"Missing app_code in callback\".to_string(),\n        ));\n    };\n\n    let (provider, app_verifier) = match deployment.take_oauth_handoff(&query.handoff_id).await {\n        Some(state) => state,\n        None => {\n            tracing::warn!(\n                handoff_id = %query.handoff_id,\n                \"received callback for unknown handoff\"\n            );\n            return Ok(simple_html_response(\n                StatusCode::BAD_REQUEST,\n                \"OAuth handoff not found or already completed\".to_string(),\n            ));\n        }\n    };\n\n    let client = deployment.remote_client()?;\n\n    let redeem_request = HandoffRedeemRequest {\n        handoff_id: query.handoff_id,\n        app_code,\n        app_verifier,\n    };\n\n    let redeem = client.handoff_redeem(&redeem_request).await?;\n\n    let expires_at = extract_expiration(&redeem.access_token)\n        .map_err(|err| ApiError::BadRequest(format!(\"Invalid access token: {err}\")))?;\n    let credentials = Credentials {\n        access_token: Some(redeem.access_token.clone()),\n        refresh_token: redeem.refresh_token.clone(),\n        expires_at: Some(expires_at),\n    };\n\n    deployment\n        .auth_context()\n        .save_credentials(&credentials)\n        .await\n        .map_err(|e| {\n            tracing::error!(?e, \"failed to save credentials\");\n            ApiError::Io(e)\n        })?;\n\n    // Enable analytics automatically on login if not already enabled\n    let config_guard = deployment.config().read().await;\n    if !config_guard.analytics_enabled {\n        let mut new_config = config_guard.clone();\n        drop(config_guard); // Release read lock before acquiring write lock\n\n        new_config.analytics_enabled = true;\n\n        // Save updated config to disk\n        let config_path = config_path();\n        if let Err(e) = save_config_to_file(&new_config, &config_path).await {\n            tracing::warn!(\n                ?e,\n                \"failed to save config after enabling analytics on login\"\n            );\n        } else {\n            // Update in-memory config\n            let mut config = deployment.config().write().await;\n            *config = new_config;\n            drop(config);\n\n            tracing::info!(\"analytics automatically enabled after successful login\");\n\n            // Track analytics_session_start event\n            if let Some(analytics) = deployment.analytics() {\n                analytics.track_event(\n                    deployment.user_id(),\n                    \"analytics_session_start\",\n                    Some(serde_json::json!({})),\n                );\n            }\n        }\n    } else {\n        drop(config_guard);\n    }\n\n    // Fetch and cache the user's profile\n    let _ = deployment.get_login_status().await;\n\n    // Sync all linked workspace states and PRs to remote in the background\n    if let Ok(client) = deployment.remote_client() {\n        let pool = deployment.db().pool.clone();\n        let git = deployment.git().clone();\n        tokio::spawn(async move {\n            remote_sync::sync_all_linked_workspaces(&client, &pool, &git).await;\n        });\n    }\n\n    if let Some(profile) = deployment.auth_context().cached_profile().await\n        && let Some(analytics) = deployment.analytics()\n    {\n        analytics.track_event(\n            deployment.user_id(),\n            \"$identify\",\n            Some(serde_json::json!({\n                \"email\": profile.email,\n            })),\n        );\n\n        // Merge the local machine-based ID with the remote user UUID so all\n        // events (local frontend, local backend, remote backend) resolve to\n        // the same PostHog person. Uses $merge_dangerously because\n        // $create_alias is blocked by PostHog's safeguard when the machine\n        // ID was already used as a distinct_id in a prior identify call.\n        analytics.track_event(\n            &profile.user_id.to_string(),\n            \"$merge_dangerously\",\n            Some(serde_json::json!({\n                \"alias\": deployment.user_id(),\n            })),\n        );\n    }\n\n    // Start relay if enabled\n    let relay_deployment = deployment.clone();\n    tokio::spawn(async move {\n        tunnel::spawn_relay(&relay_deployment).await;\n    });\n\n    let is_desktop = query.source.as_deref() == Some(\"desktop\");\n    Ok(close_window_response(\n        format!(\"Signed in with {provider}. You can return to the app.\"),\n        is_desktop,\n    ))\n}\n\nasync fn logout(State(deployment): State<DeploymentImpl>) -> Result<StatusCode, ApiError> {\n    let auth_context = deployment.auth_context();\n\n    if let Ok(client) = deployment.remote_client() {\n        let _ = client.logout().await;\n    }\n\n    auth_context.clear_credentials().await.map_err(|e| {\n        tracing::error!(?e, \"failed to clear credentials\");\n        ApiError::Io(e)\n    })?;\n\n    auth_context.clear_profile().await;\n\n    tunnel::stop_relay(&deployment).await;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\nasync fn status(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<StatusResponse>>, ApiError> {\n    use api_types::LoginStatus;\n\n    match deployment.get_login_status().await {\n        LoginStatus::LoggedOut => Ok(ResponseJson(ApiResponse::success(StatusResponse {\n            logged_in: false,\n            profile: None,\n            degraded: None,\n        }))),\n        LoginStatus::LoggedIn { profile } => {\n            Ok(ResponseJson(ApiResponse::success(StatusResponse {\n                logged_in: true,\n                profile: Some(profile),\n                degraded: None,\n            })))\n        }\n    }\n}\n\n/// Returns the current access token (auto-refreshes if needed)\nasync fn get_token(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<TokenResponse>>, ApiError> {\n    let remote_client = deployment.remote_client()?;\n\n    // This will auto-refresh the token if expired\n    let access_token = remote_client\n        .access_token()\n        .await\n        .map_err(|_| ApiError::Unauthorized)?;\n\n    let creds = deployment.auth_context().get_credentials().await;\n    let expires_at = creds.and_then(|c| c.expires_at);\n\n    Ok(ResponseJson(ApiResponse::success(TokenResponse {\n        access_token,\n        expires_at,\n    })))\n}\n\nasync fn get_current_user(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<CurrentUserResponse>>, ApiError> {\n    let remote_client = deployment.remote_client()?;\n\n    // Get the access token from remote client\n    let access_token = remote_client\n        .access_token()\n        .await\n        .map_err(|_| ApiError::Unauthorized)?;\n\n    // Extract user ID from the JWT token's 'sub' claim\n    let user_id = utils::jwt::extract_subject(&access_token)\n        .map_err(|e| {\n            tracing::error!(\"Failed to extract user ID from token: {}\", e);\n            ApiError::Unauthorized\n        })?\n        .to_string();\n\n    Ok(ResponseJson(ApiResponse::success(CurrentUserResponse {\n        user_id,\n    })))\n}\n\nfn generate_secret() -> String {\n    rand::thread_rng()\n        .sample_iter(&Alphanumeric)\n        .take(64)\n        .map(char::from)\n        .collect()\n}\n\nfn hash_sha256_hex(input: &str) -> String {\n    let mut output = String::with_capacity(64);\n    let digest = Sha256::digest(input.as_bytes());\n    for byte in digest {\n        use std::fmt::Write;\n        let _ = write!(output, \"{:02x}\", byte);\n    }\n    output\n}\n\nfn simple_html_response(status: StatusCode, message: String) -> Response<String> {\n    let body = format!(\n        r#\"<!doctype html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <title>OAuth Error</title>\n    {AUTH_PAGE_STYLES}\n  </head>\n  <body>\n    <div class=\"container\">\n      <img class=\"logo\" src=\"data:image/png;base64,{APP_ICON_BASE64}\" alt=\"Vibe Kanban\">\n      <div class=\"content\">\n        <p class=\"title\">{message}</p>\n        <p class=\"subtitle\">Please close this tab and try again.</p>\n      </div>\n    </div>\n  </body>\n</html>\"#\n    );\n    Response::builder()\n        .status(status)\n        .header(\"content-type\", \"text/html; charset=utf-8\")\n        .body(body)\n        .unwrap()\n}\n\nfn close_window_response(message: String, skip_auto_close: bool) -> Response<String> {\n    let script = if skip_auto_close {\n        \"\" // Desktop app: leave the tab open so the user sees the message\n    } else {\n        \"<script>\\\n           window.addEventListener('load', () => {\\\n             try { window.close(); } catch (err) {}\\\n             setTimeout(() => { window.close(); }, 150);\\\n           });\\\n         </script>\"\n    };\n    let body = format!(\n        r#\"<!doctype html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <title>Authentication Complete</title>\n    {script}\n    {AUTH_PAGE_STYLES}\n  </head>\n  <body>\n    <div class=\"container\">\n      <img class=\"logo\" src=\"data:image/png;base64,{APP_ICON_BASE64}\" alt=\"Vibe Kanban\">\n      <div class=\"content\">\n        <p class=\"title\">{message}</p>\n        <p class=\"subtitle\">You can close this tab and return to the app.</p>\n      </div>\n    </div>\n  </body>\n</html>\"#\n    );\n\n    Response::builder()\n        .status(StatusCode::OK)\n        .header(\"content-type\", \"text/html; charset=utf-8\")\n        .body(body)\n        .unwrap()\n}\n"
  },
  {
    "path": "crates/server/src/routes/organizations.rs",
    "content": "use api_types::{\n    AcceptInvitationResponse, CreateInvitationRequest, CreateInvitationResponse,\n    CreateOrganizationRequest, CreateOrganizationResponse, GetInvitationResponse,\n    GetOrganizationResponse, ListInvitationsResponse, ListMembersResponse,\n    ListOrganizationsResponse, Organization, RevokeInvitationRequest, UpdateMemberRoleRequest,\n    UpdateMemberRoleResponse, UpdateOrganizationRequest,\n};\nuse axum::{\n    Router,\n    extract::{Json, Path, State},\n    http::StatusCode,\n    response::Json as ResponseJson,\n    routing::{delete, get, patch, post},\n};\nuse deployment::Deployment;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/organizations\", get(list_organizations))\n        .route(\"/organizations\", post(create_organization))\n        .route(\"/organizations/{id}\", get(get_organization))\n        .route(\"/organizations/{id}\", patch(update_organization))\n        .route(\"/organizations/{id}\", delete(delete_organization))\n        .route(\n            \"/organizations/{org_id}/invitations\",\n            post(create_invitation),\n        )\n        .route(\"/organizations/{org_id}/invitations\", get(list_invitations))\n        .route(\n            \"/organizations/{org_id}/invitations/revoke\",\n            post(revoke_invitation),\n        )\n        .route(\"/invitations/{token}\", get(get_invitation))\n        .route(\"/invitations/{token}/accept\", post(accept_invitation))\n        .route(\"/organizations/{org_id}/members\", get(list_members))\n        .route(\n            \"/organizations/{org_id}/members/{user_id}\",\n            delete(remove_member),\n        )\n        .route(\n            \"/organizations/{org_id}/members/{user_id}/role\",\n            patch(update_member_role),\n        )\n}\n\nasync fn list_organizations(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<ListOrganizationsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.list_organizations().await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn get_organization(\n    State(deployment): State<DeploymentImpl>,\n    Path(id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<GetOrganizationResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.get_organization(id).await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn create_organization(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<CreateOrganizationRequest>,\n) -> Result<ResponseJson<ApiResponse<CreateOrganizationResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.create_organization(&request).await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"organization_created\",\n            serde_json::json!({\n                \"org_id\": response.organization.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn update_organization(\n    State(deployment): State<DeploymentImpl>,\n    Path(id): Path<Uuid>,\n    Json(request): Json<UpdateOrganizationRequest>,\n) -> Result<ResponseJson<ApiResponse<Organization>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.update_organization(id, &request).await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn delete_organization(\n    State(deployment): State<DeploymentImpl>,\n    Path(id): Path<Uuid>,\n) -> Result<StatusCode, ApiError> {\n    let client = deployment.remote_client()?;\n\n    client.delete_organization(id).await?;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\nasync fn create_invitation(\n    State(deployment): State<DeploymentImpl>,\n    Path(org_id): Path<Uuid>,\n    Json(request): Json<CreateInvitationRequest>,\n) -> Result<ResponseJson<ApiResponse<CreateInvitationResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.create_invitation(org_id, &request).await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"invitation_created\",\n            serde_json::json!({\n                \"invitation_id\": response.invitation.id.to_string(),\n                \"org_id\": org_id.to_string(),\n                \"role\": response.invitation.role,\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn list_invitations(\n    State(deployment): State<DeploymentImpl>,\n    Path(org_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<ListInvitationsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.list_invitations(org_id).await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn get_invitation(\n    State(deployment): State<DeploymentImpl>,\n    Path(token): Path<String>,\n) -> Result<ResponseJson<ApiResponse<GetInvitationResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.get_invitation(&token).await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn revoke_invitation(\n    State(deployment): State<DeploymentImpl>,\n    Path(org_id): Path<Uuid>,\n    Json(payload): Json<RevokeInvitationRequest>,\n) -> Result<StatusCode, ApiError> {\n    let client = deployment.remote_client()?;\n\n    client\n        .revoke_invitation(org_id, payload.invitation_id)\n        .await?;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\nasync fn accept_invitation(\n    State(deployment): State<DeploymentImpl>,\n    Path(invitation_token): Path<String>,\n) -> Result<ResponseJson<ApiResponse<AcceptInvitationResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.accept_invitation(&invitation_token).await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn list_members(\n    State(deployment): State<DeploymentImpl>,\n    Path(org_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<ListMembersResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.list_members(org_id).await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn remove_member(\n    State(deployment): State<DeploymentImpl>,\n    Path((org_id, user_id)): Path<(Uuid, Uuid)>,\n) -> Result<StatusCode, ApiError> {\n    let client = deployment.remote_client()?;\n\n    client.remove_member(org_id, user_id).await?;\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\nasync fn update_member_role(\n    State(deployment): State<DeploymentImpl>,\n    Path((org_id, user_id)): Path<(Uuid, Uuid)>,\n    Json(request): Json<UpdateMemberRoleRequest>,\n) -> Result<ResponseJson<ApiResponse<UpdateMemberRoleResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let response = client.update_member_role(org_id, user_id, &request).await?;\n\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/relay_auth.rs",
    "content": "use std::time::Duration;\n\nuse axum::{\n    Json, Router,\n    extract::{Json as ExtractJson, Path, State},\n    http::HeaderMap,\n    routing::{delete, get, post},\n};\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse deployment::Deployment;\nuse serde::{Deserialize, Serialize};\nuse trusted_key_auth::{\n    key_confirmation::{build_server_proof, verify_client_proof},\n    refresh::{build_refresh_message, validate_refresh_timestamp, verify_refresh_signature},\n    spake2::{generate_one_time_code, start_spake2_enrollment},\n    trusted_keys::{TrustedRelayClient, parse_public_key_base64},\n};\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\nconst RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60);\nconst GENERATE_CODE_GLOBAL_LIMIT: usize = 5;\nconst SPAKE2_START_GLOBAL_LIMIT: usize = 30;\nconst SIGNING_SESSION_REFRESH_GLOBAL_LIMIT: usize = 30;\nconst RELAY_HEADER: &str = \"x-vk-relayed\";\n\n#[derive(Debug, Serialize)]\nstruct GenerateEnrollmentCodeResponse {\n    enrollment_code: String,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct StartSpake2EnrollmentRequest {\n    enrollment_code: String,\n    client_message_b64: String,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct StartSpake2EnrollmentResponse {\n    enrollment_id: Uuid,\n    server_message_b64: String,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct FinishSpake2EnrollmentRequest {\n    enrollment_id: Uuid,\n    client_id: Uuid,\n    client_name: String,\n    client_browser: String,\n    client_os: String,\n    client_device: String,\n    public_key_b64: String,\n    client_proof_b64: String,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct FinishSpake2EnrollmentResponse {\n    signing_session_id: Uuid,\n    server_public_key_b64: String,\n    server_proof_b64: String,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct RelayPairedClient {\n    client_id: Uuid,\n    client_name: String,\n    client_browser: String,\n    client_os: String,\n    client_device: String,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct ListRelayPairedClientsResponse {\n    clients: Vec<RelayPairedClient>,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct RemoveRelayPairedClientResponse {\n    removed: bool,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct RefreshRelaySigningSessionRequest {\n    client_id: Uuid,\n    timestamp: i64,\n    nonce: String,\n    signature_b64: String,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct RefreshRelaySigningSessionResponse {\n    signing_session_id: Uuid,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\n            \"/relay-auth/enrollment-code\",\n            post(generate_enrollment_code),\n        )\n        .route(\"/relay-auth/clients\", get(list_relay_paired_clients))\n        .route(\n            \"/relay-auth/clients/{client_id}\",\n            delete(remove_relay_paired_client),\n        )\n        .route(\n            \"/relay-auth/spake2/start\",\n            post(start_spake2_enrollment_route),\n        )\n        .route(\"/relay-auth/spake2/finish\", post(finish_spake2_enrollment))\n        .route(\n            \"/relay-auth/signing-session/refresh\",\n            post(refresh_relay_signing_session),\n        )\n}\n\nasync fn generate_enrollment_code(\n    State(deployment): State<DeploymentImpl>,\n    headers: HeaderMap,\n) -> Result<Json<ApiResponse<GenerateEnrollmentCodeResponse>>, ApiError> {\n    if is_relay_request(&headers) {\n        return Err(ApiError::Forbidden(\n            \"Enrollment code cannot be fetched over relay.\".to_string(),\n        ));\n    }\n\n    deployment\n        .trusted_key_auth()\n        .enforce_rate_limit(\n            \"relay-auth:code-generation:global\",\n            GENERATE_CODE_GLOBAL_LIMIT,\n            RATE_LIMIT_WINDOW,\n        )\n        .await?;\n\n    let enrollment_code = deployment\n        .trusted_key_auth()\n        .get_or_set_enrollment_code(generate_one_time_code())\n        .await;\n\n    Ok(Json(ApiResponse::success(GenerateEnrollmentCodeResponse {\n        enrollment_code,\n    })))\n}\n\nasync fn start_spake2_enrollment_route(\n    State(deployment): State<DeploymentImpl>,\n    ExtractJson(payload): ExtractJson<StartSpake2EnrollmentRequest>,\n) -> Result<Json<ApiResponse<StartSpake2EnrollmentResponse>>, ApiError> {\n    deployment\n        .trusted_key_auth()\n        .enforce_rate_limit(\n            \"relay-auth:spake2-start:global\",\n            SPAKE2_START_GLOBAL_LIMIT,\n            RATE_LIMIT_WINDOW,\n        )\n        .await?;\n\n    let spake2_start =\n        start_spake2_enrollment(&payload.enrollment_code, &payload.client_message_b64)?;\n\n    if !deployment\n        .trusted_key_auth()\n        .consume_enrollment_code(&spake2_start.enrollment_code)\n        .await\n    {\n        return Err(ApiError::Unauthorized);\n    }\n\n    let enrollment_id = Uuid::new_v4();\n    deployment\n        .trusted_key_auth()\n        .store_pake_enrollment(enrollment_id, spake2_start.shared_key)\n        .await;\n\n    Ok(Json(ApiResponse::success(StartSpake2EnrollmentResponse {\n        enrollment_id,\n        server_message_b64: spake2_start.server_message_b64,\n    })))\n}\n\nasync fn list_relay_paired_clients(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<Json<ApiResponse<ListRelayPairedClientsResponse>>, ApiError> {\n    let clients = deployment.trusted_key_auth().list_trusted_clients().await?;\n    let clients = clients\n        .into_iter()\n        .map(|client| RelayPairedClient {\n            client_id: client.client_id,\n            client_name: client.client_name,\n            client_browser: client.client_browser,\n            client_os: client.client_os,\n            client_device: client.client_device,\n        })\n        .collect();\n\n    Ok(Json(ApiResponse::success(ListRelayPairedClientsResponse {\n        clients,\n    })))\n}\n\nasync fn remove_relay_paired_client(\n    State(deployment): State<DeploymentImpl>,\n    Path(client_id): Path<Uuid>,\n) -> Result<Json<ApiResponse<RemoveRelayPairedClientResponse>>, ApiError> {\n    let removed = deployment\n        .trusted_key_auth()\n        .remove_trusted_client(client_id)\n        .await?;\n\n    Ok(Json(ApiResponse::success(\n        RemoveRelayPairedClientResponse { removed },\n    )))\n}\n\nasync fn finish_spake2_enrollment(\n    State(deployment): State<DeploymentImpl>,\n    ExtractJson(payload): ExtractJson<FinishSpake2EnrollmentRequest>,\n) -> Result<Json<ApiResponse<FinishSpake2EnrollmentResponse>>, ApiError> {\n    let Some(shared_key) = deployment\n        .trusted_key_auth()\n        .take_pake_enrollment(&payload.enrollment_id)\n        .await\n    else {\n        return Err(ApiError::Unauthorized);\n    };\n\n    let browser_public_key = parse_public_key_base64(&payload.public_key_b64)\n        .map_err(|_| ApiError::BadRequest(\"Invalid public_key_b64\".to_string()))?;\n\n    let server_public_key = deployment.relay_signing().server_public_key();\n    let server_public_key_b64 = BASE64_STANDARD.encode(server_public_key.as_bytes());\n\n    verify_client_proof(\n        &shared_key,\n        &payload.enrollment_id,\n        browser_public_key.as_bytes(),\n        &payload.client_proof_b64,\n    )\n    .map_err(|_| ApiError::Unauthorized)?;\n\n    // Persist the browser's public key so it survives server restarts\n    if let Err(e) = deployment\n        .trusted_key_auth()\n        .persist_trusted_client(TrustedRelayClient {\n            client_id: payload.client_id,\n            client_name: payload.client_name.clone(),\n            client_browser: payload.client_browser.clone(),\n            client_os: payload.client_os.clone(),\n            client_device: payload.client_device.clone(),\n            public_key_b64: payload.public_key_b64.clone(),\n        })\n        .await\n    {\n        tracing::warn!(?e, \"Failed to persist trusted relay client\");\n    }\n\n    let signing_session_id = deployment\n        .relay_signing()\n        .create_session(browser_public_key)\n        .await;\n\n    let server_proof_b64 = build_server_proof(\n        &shared_key,\n        &payload.enrollment_id,\n        browser_public_key.as_bytes(),\n        server_public_key.as_bytes(),\n    )\n    .map_err(|_| ApiError::Unauthorized)?;\n\n    tracing::info!(\n        enrollment_id = %payload.enrollment_id,\n        client_id = %payload.client_id,\n        signing_session_id = %signing_session_id,\n        public_key_b64 = %BASE64_STANDARD.encode(browser_public_key.as_bytes()),\n        \"completed relay PAKE enrollment\"\n    );\n\n    deployment\n        .track_if_analytics_allowed(\n            \"relay_host_paired\",\n            serde_json::json!({\n                \"client_id\": payload.client_id,\n                \"client_browser\": payload.client_browser,\n                \"client_os\": payload.client_os,\n                \"client_device\": payload.client_device,\n            }),\n        )\n        .await;\n\n    Ok(Json(ApiResponse::success(FinishSpake2EnrollmentResponse {\n        signing_session_id,\n        server_public_key_b64,\n        server_proof_b64,\n    })))\n}\n\nasync fn refresh_relay_signing_session(\n    State(deployment): State<DeploymentImpl>,\n    ExtractJson(payload): ExtractJson<RefreshRelaySigningSessionRequest>,\n) -> Result<Json<ApiResponse<RefreshRelaySigningSessionResponse>>, ApiError> {\n    deployment\n        .trusted_key_auth()\n        .enforce_rate_limit(\n            \"relay-auth:signing-refresh:global\",\n            SIGNING_SESSION_REFRESH_GLOBAL_LIMIT,\n            RATE_LIMIT_WINDOW,\n        )\n        .await?;\n\n    let trusted_client = deployment\n        .trusted_key_auth()\n        .find_trusted_client(payload.client_id)\n        .await?\n        .ok_or(ApiError::Unauthorized)?;\n\n    let browser_public_key = parse_public_key_base64(&trusted_client.public_key_b64)\n        .map_err(|_| ApiError::Unauthorized)?;\n\n    validate_refresh_timestamp(payload.timestamp)?;\n    deployment\n        .trusted_key_auth()\n        .claim_refresh_nonce(&payload.nonce)\n        .await?;\n\n    let refresh_message =\n        build_refresh_message(payload.timestamp, &payload.nonce, payload.client_id);\n    verify_refresh_signature(\n        &browser_public_key,\n        &refresh_message,\n        &payload.signature_b64,\n    )?;\n\n    let signing_session_id = deployment\n        .relay_signing()\n        .create_session(browser_public_key)\n        .await;\n\n    Ok(Json(ApiResponse::success(\n        RefreshRelaySigningSessionResponse { signing_session_id },\n    )))\n}\n\nfn is_relay_request(headers: &HeaderMap) -> bool {\n    headers\n        .get(RELAY_HEADER)\n        .and_then(|value| value.to_str().ok())\n        .is_some_and(|value| value.trim() == \"1\")\n}\n"
  },
  {
    "path": "crates/server/src/routes/relay_ws.rs",
    "content": "use anyhow::Context as _;\nuse axum::{\n    extract::{\n        FromRef, FromRequestParts,\n        ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},\n    },\n    http::request::Parts,\n    response::IntoResponse,\n};\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse deployment::Deployment;\nuse futures_util::{Sink, SinkExt, Stream, StreamExt};\nuse serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, middleware::RelayRequestSignatureContext};\n\nconst WS_ENVELOPE_VERSION: u8 = 1;\n\n#[derive(Debug, Clone)]\npub struct RelayWsSigningState {\n    signing_session_id: Uuid,\n    request_nonce: String,\n    inbound_seq: u64,\n    outbound_seq: u64,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct RelaySignedWsEnvelope {\n    version: u8,\n    seq: u64,\n    msg_type: RelayWsMessageType,\n    payload_b64: String,\n    signature_b64: String,\n}\n\n#[derive(Debug, Clone, Copy, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\nenum RelayWsMessageType {\n    Text,\n    Binary,\n    Ping,\n    Pong,\n    Close,\n}\n\nimpl RelayWsMessageType {\n    fn as_str(self) -> &'static str {\n        match self {\n            Self::Text => \"text\",\n            Self::Binary => \"binary\",\n            Self::Ping => \"ping\",\n            Self::Pong => \"pong\",\n            Self::Close => \"close\",\n        }\n    }\n}\n\npub fn relay_ws_signing_state(\n    relay_ctx: Option<RelayRequestSignatureContext>,\n) -> Option<RelayWsSigningState> {\n    relay_ctx.map(|ctx| RelayWsSigningState {\n        signing_session_id: ctx.signing_session_id,\n        request_nonce: ctx.request_nonce,\n        inbound_seq: 0,\n        outbound_seq: 0,\n    })\n}\n\npub struct SignedWsUpgrade {\n    ws: WebSocketUpgrade,\n    deployment: DeploymentImpl,\n    relay_signing: Option<RelayWsSigningState>,\n}\n\nimpl<S> FromRequestParts<S> for SignedWsUpgrade\nwhere\n    S: Send + Sync,\n    DeploymentImpl: FromRef<S>,\n{\n    type Rejection = axum::extract::ws::rejection::WebSocketUpgradeRejection;\n\n    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {\n        let ws = WebSocketUpgrade::from_request_parts(parts, state).await?;\n        let deployment = DeploymentImpl::from_ref(state);\n        let relay_ctx = parts\n            .extensions\n            .get::<RelayRequestSignatureContext>()\n            .cloned();\n\n        Ok(Self {\n            ws,\n            deployment,\n            relay_signing: relay_ws_signing_state(relay_ctx),\n        })\n    }\n}\n\nimpl SignedWsUpgrade {\n    pub fn on_upgrade<F, Fut>(self, callback: F) -> impl IntoResponse\n    where\n        F: FnOnce(SignedWebSocket) -> Fut + Send + 'static,\n        Fut: std::future::Future<Output = ()> + Send + 'static,\n    {\n        let deployment = self.deployment.clone();\n        let relay_signing = self.relay_signing.clone();\n        self.ws.on_upgrade(move |socket| async move {\n            let signed_socket = SignedWebSocket {\n                socket,\n                deployment,\n                relay_signing,\n            };\n            callback(signed_socket).await;\n        })\n    }\n}\n\npub struct SignedWebSocket {\n    socket: WebSocket,\n    deployment: DeploymentImpl,\n    relay_signing: Option<RelayWsSigningState>,\n}\n\nimpl SignedWebSocket {\n    pub async fn send(&mut self, message: Message) -> anyhow::Result<()> {\n        send_ws_message(\n            &mut self.socket,\n            &self.deployment,\n            &mut self.relay_signing,\n            message,\n        )\n        .await\n    }\n\n    pub async fn recv(&mut self) -> anyhow::Result<Option<Message>> {\n        recv_ws_message(&mut self.socket, &self.deployment, &mut self.relay_signing).await\n    }\n\n    pub async fn close(&mut self) -> anyhow::Result<()> {\n        self.socket.close().await.map_err(anyhow::Error::from)\n    }\n}\n\npub async fn send_ws_message<S>(\n    sender: &mut S,\n    deployment: &DeploymentImpl,\n    relay_signing: &mut Option<RelayWsSigningState>,\n    message: Message,\n) -> anyhow::Result<()>\nwhere\n    S: Sink<Message, Error = axum::Error> + Unpin,\n{\n    let outbound = if let Some(signing) = relay_signing.as_mut() {\n        match message {\n            Message::Text(text) => {\n                let payload = text.as_str().as_bytes().to_vec();\n                let seq = signing.outbound_seq.saturating_add(1);\n                let envelope = build_signed_envelope(\n                    deployment,\n                    signing,\n                    seq,\n                    RelayWsMessageType::Text,\n                    payload,\n                )\n                .await?;\n                signing.outbound_seq = seq;\n                Message::Binary(serde_json::to_vec(&envelope)?.into())\n            }\n            Message::Binary(payload) => {\n                let seq = signing.outbound_seq.saturating_add(1);\n                let envelope = build_signed_envelope(\n                    deployment,\n                    signing,\n                    seq,\n                    RelayWsMessageType::Binary,\n                    payload.to_vec(),\n                )\n                .await?;\n                signing.outbound_seq = seq;\n                Message::Binary(serde_json::to_vec(&envelope)?.into())\n            }\n            Message::Ping(payload) => {\n                let seq = signing.outbound_seq.saturating_add(1);\n                let envelope = build_signed_envelope(\n                    deployment,\n                    signing,\n                    seq,\n                    RelayWsMessageType::Ping,\n                    payload.to_vec(),\n                )\n                .await?;\n                signing.outbound_seq = seq;\n                Message::Binary(serde_json::to_vec(&envelope)?.into())\n            }\n            Message::Pong(payload) => {\n                let seq = signing.outbound_seq.saturating_add(1);\n                let envelope = build_signed_envelope(\n                    deployment,\n                    signing,\n                    seq,\n                    RelayWsMessageType::Pong,\n                    payload.to_vec(),\n                )\n                .await?;\n                signing.outbound_seq = seq;\n                Message::Binary(serde_json::to_vec(&envelope)?.into())\n            }\n            Message::Close(close_frame) => {\n                let seq = signing.outbound_seq.saturating_add(1);\n                let envelope = build_signed_envelope(\n                    deployment,\n                    signing,\n                    seq,\n                    RelayWsMessageType::Close,\n                    encode_close_payload(close_frame),\n                )\n                .await?;\n                signing.outbound_seq = seq;\n                Message::Binary(serde_json::to_vec(&envelope)?.into())\n            }\n        }\n    } else {\n        message\n    };\n\n    sender.send(outbound).await.map_err(anyhow::Error::from)\n}\n\npub async fn recv_ws_message<S>(\n    receiver: &mut S,\n    deployment: &DeploymentImpl,\n    relay_signing: &mut Option<RelayWsSigningState>,\n) -> anyhow::Result<Option<Message>>\nwhere\n    S: Stream<Item = Result<Message, axum::Error>> + Unpin,\n{\n    let Some(message_result) = receiver.next().await else {\n        return Ok(None);\n    };\n\n    let message = message_result.map_err(anyhow::Error::from)?;\n\n    let decoded = if let Some(signing) = relay_signing.as_mut() {\n        match message {\n            Message::Text(text) => {\n                decode_signed_envelope(deployment, signing, text.as_str().as_bytes()).await?\n            }\n            Message::Binary(data) => decode_signed_envelope(deployment, signing, &data).await?,\n            Message::Ping(payload) => Message::Ping(payload),\n            Message::Pong(payload) => Message::Pong(payload),\n            Message::Close(close_frame) => Message::Close(close_frame),\n        }\n    } else {\n        message\n    };\n\n    Ok(Some(decoded))\n}\n\nasync fn build_signed_envelope(\n    deployment: &DeploymentImpl,\n    signing: &RelayWsSigningState,\n    seq: u64,\n    msg_type: RelayWsMessageType,\n    payload: Vec<u8>,\n) -> anyhow::Result<RelaySignedWsEnvelope> {\n    let sign_message = ws_signing_input(\n        signing.signing_session_id,\n        &signing.request_nonce,\n        seq,\n        msg_type,\n        &payload,\n    );\n\n    let signature_b64 = deployment\n        .relay_signing()\n        .sign_message(signing.signing_session_id, sign_message.as_bytes())\n        .await\n        .map_err(|error| anyhow::anyhow!(\"failed to sign relay WS frame: {}\", error.as_str()))?;\n\n    Ok(RelaySignedWsEnvelope {\n        version: WS_ENVELOPE_VERSION,\n        seq,\n        msg_type,\n        payload_b64: BASE64_STANDARD.encode(payload),\n        signature_b64,\n    })\n}\n\nasync fn decode_signed_envelope(\n    deployment: &DeploymentImpl,\n    signing: &mut RelayWsSigningState,\n    raw_message: &[u8],\n) -> anyhow::Result<Message> {\n    let envelope: RelaySignedWsEnvelope =\n        serde_json::from_slice(raw_message).context(\"invalid relay WS envelope JSON\")?;\n\n    if envelope.version != WS_ENVELOPE_VERSION {\n        return Err(anyhow::anyhow!(\"unsupported relay WS envelope version\"));\n    }\n\n    let expected_seq = signing.inbound_seq.saturating_add(1);\n    if envelope.seq != expected_seq {\n        return Err(anyhow::anyhow!(\n            \"invalid relay WS sequence: expected {}, got {}\",\n            expected_seq,\n            envelope.seq\n        ));\n    }\n\n    let payload = BASE64_STANDARD\n        .decode(&envelope.payload_b64)\n        .context(\"invalid relay WS payload\")?;\n\n    let sign_message = ws_signing_input(\n        signing.signing_session_id,\n        &signing.request_nonce,\n        envelope.seq,\n        envelope.msg_type,\n        &payload,\n    );\n\n    deployment\n        .relay_signing()\n        .verify_signature(\n            signing.signing_session_id,\n            sign_message.as_bytes(),\n            &envelope.signature_b64,\n        )\n        .await\n        .map_err(|error| anyhow::anyhow!(\"invalid relay WS frame signature: {}\", error.as_str()))?;\n\n    signing.inbound_seq = envelope.seq;\n\n    match envelope.msg_type {\n        RelayWsMessageType::Text => {\n            let text = String::from_utf8(payload).context(\"invalid UTF-8 text frame\")?;\n            Ok(Message::Text(text.into()))\n        }\n        RelayWsMessageType::Binary => Ok(Message::Binary(payload.into())),\n        RelayWsMessageType::Ping => Ok(Message::Ping(payload.into())),\n        RelayWsMessageType::Pong => Ok(Message::Pong(payload.into())),\n        RelayWsMessageType::Close => {\n            let close_frame = decode_close_payload(payload)?;\n            Ok(Message::Close(close_frame))\n        }\n    }\n}\n\nfn ws_signing_input(\n    signing_session_id: Uuid,\n    request_nonce: &str,\n    seq: u64,\n    msg_type: RelayWsMessageType,\n    payload: &[u8],\n) -> String {\n    let payload_hash = BASE64_STANDARD.encode(Sha256::digest(payload));\n    format!(\n        \"v1|{signing_session_id}|{request_nonce}|{seq}|{msg_type}|{payload_hash}\",\n        msg_type = msg_type.as_str()\n    )\n}\n\nfn encode_close_payload(close_frame: Option<CloseFrame>) -> Vec<u8> {\n    if let Some(close_frame) = close_frame {\n        let code: u16 = close_frame.code;\n        let reason = close_frame.reason.to_string();\n        let mut payload = Vec::with_capacity(2 + reason.len());\n        payload.extend_from_slice(&code.to_be_bytes());\n        payload.extend_from_slice(reason.as_bytes());\n        payload\n    } else {\n        Vec::new()\n    }\n}\n\nfn decode_close_payload(payload: Vec<u8>) -> anyhow::Result<Option<CloseFrame>> {\n    if payload.is_empty() {\n        return Ok(None);\n    }\n\n    if payload.len() < 2 {\n        return Err(anyhow::anyhow!(\"invalid close payload\"));\n    }\n\n    let code = u16::from_be_bytes([payload[0], payload[1]]);\n    let reason =\n        String::from_utf8(payload[2..].to_vec()).context(\"invalid UTF-8 close frame reason\")?;\n\n    Ok(Some(CloseFrame {\n        code,\n        reason: reason.into(),\n    }))\n}\n"
  },
  {
    "path": "crates/server/src/routes/releases.rs",
    "content": "use std::{\n    sync::OnceLock,\n    time::{Duration, Instant},\n};\n\nuse axum::{Router, response::Json as ResponseJson, routing::get};\nuse reqwest::Client;\nuse serde::{Deserialize, Serialize};\nuse tokio::sync::RwLock;\n\nuse crate::DeploymentImpl;\n\nconst CACHE_TTL: Duration = Duration::from_secs(15 * 60);\nconst GITHUB_API_URL: &str = \"https://api.github.com/repos/BloopAI/vibe-kanban/releases\";\n\ntype ReleasesCache = RwLock<Option<(Vec<GitHubRelease>, Instant)>>;\n\nstatic HTTP_CLIENT: OnceLock<Client> = OnceLock::new();\nstatic RELEASES_CACHE: OnceLock<ReleasesCache> = OnceLock::new();\n\nfn client() -> &'static Client {\n    HTTP_CLIENT.get_or_init(|| {\n        Client::builder()\n            .user_agent(\"vibe-kanban-server\")\n            .build()\n            .expect(\"failed to build releases HTTP client\")\n    })\n}\n\nfn cache() -> &'static RwLock<Option<(Vec<GitHubRelease>, Instant)>> {\n    RELEASES_CACHE.get_or_init(|| RwLock::new(None))\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new().route(\"/releases\", get(get_releases))\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct GitHubRelease {\n    pub name: String,\n    pub tag_name: String,\n    pub published_at: String,\n    pub body: String,\n}\n\n#[derive(Debug, Serialize)]\nstruct ReleasesResponse {\n    releases: Vec<GitHubRelease>,\n}\n\n#[derive(Deserialize)]\nstruct GitHubReleaseRaw {\n    tag_name: String,\n    name: Option<String>,\n    published_at: Option<String>,\n    body: Option<String>,\n    prerelease: bool,\n}\n\nasync fn get_releases() -> ResponseJson<utils::response::ApiResponse<ReleasesResponse>> {\n    // Check cache\n    {\n        let guard = cache().read().await;\n        if let Some((releases, fetched_at)) = guard.as_ref()\n            && fetched_at.elapsed() < CACHE_TTL\n        {\n            return ResponseJson(utils::response::ApiResponse::success(ReleasesResponse {\n                releases: releases.clone(),\n            }));\n        }\n    }\n\n    // Fetch from GitHub\n    match fetch_releases().await {\n        Ok(releases) => {\n            // Update cache\n            {\n                let mut guard = cache().write().await;\n                *guard = Some((releases.clone(), Instant::now()));\n            }\n            ResponseJson(utils::response::ApiResponse::success(ReleasesResponse {\n                releases,\n            }))\n        }\n        Err(e) => {\n            tracing::warn!(\"Failed to fetch GitHub releases: {}\", e);\n            // Return stale cache if available\n            let guard = cache().read().await;\n            if let Some((releases, _)) = guard.as_ref() {\n                return ResponseJson(utils::response::ApiResponse::success(ReleasesResponse {\n                    releases: releases.clone(),\n                }));\n            }\n            drop(guard);\n            ResponseJson(utils::response::ApiResponse::error(&format!(\n                \"Failed to fetch releases: {}\",\n                e\n            )))\n        }\n    }\n}\n\nasync fn fetch_releases() -> Result<Vec<GitHubRelease>, reqwest::Error> {\n    let response = client()\n        .get(GITHUB_API_URL)\n        .query(&[(\"per_page\", \"20\")])\n        .header(\"Accept\", \"application/vnd.github+json\")\n        .send()\n        .await?\n        .error_for_status()?;\n\n    let all_releases: Vec<GitHubReleaseRaw> = response.json().await?;\n\n    Ok(all_releases\n        .into_iter()\n        .filter(|r| {\n            !r.prerelease && !r.tag_name.starts_with(\"remote-\") && !r.tag_name.starts_with(\"relay-\")\n        })\n        .map(|r| GitHubRelease {\n            name: r.name.unwrap_or_else(|| r.tag_name.clone()),\n            tag_name: r.tag_name,\n            published_at: r.published_at.unwrap_or_default(),\n            body: r.body.unwrap_or_default(),\n        })\n        .collect())\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/issue_assignees.rs",
    "content": "use api_types::{\n    CreateIssueAssigneeRequest, IssueAssignee, ListIssueAssigneesResponse, MutationResponse,\n};\nuse axum::{\n    Router,\n    extract::{Json, Path, Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse serde::Deserialize;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct ListIssueAssigneesQuery {\n    pub issue_id: Uuid,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\n            \"/issue-assignees\",\n            get(list_issue_assignees).post(create_issue_assignee),\n        )\n        .route(\n            \"/issue-assignees/{issue_assignee_id}\",\n            get(get_issue_assignee).delete(delete_issue_assignee),\n        )\n}\n\nasync fn list_issue_assignees(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListIssueAssigneesQuery>,\n) -> Result<ResponseJson<ApiResponse<ListIssueAssigneesResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_issue_assignees(query.issue_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn get_issue_assignee(\n    State(deployment): State<DeploymentImpl>,\n    Path(issue_assignee_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<IssueAssignee>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.get_issue_assignee(issue_assignee_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn create_issue_assignee(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<CreateIssueAssigneeRequest>,\n) -> Result<ResponseJson<ApiResponse<MutationResponse<IssueAssignee>>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.create_issue_assignee(&request).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn delete_issue_assignee(\n    State(deployment): State<DeploymentImpl>,\n    Path(issue_assignee_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let client = deployment.remote_client()?;\n    client.delete_issue_assignee(issue_assignee_id).await?;\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/issue_relationships.rs",
    "content": "use api_types::{\n    CreateIssueRelationshipRequest, IssueRelationship, ListIssueRelationshipsQuery,\n    ListIssueRelationshipsResponse, MutationResponse,\n};\nuse axum::{\n    Router,\n    extract::{Json, Path, Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\n            \"/issue-relationships\",\n            get(list_issue_relationships).post(create_issue_relationship),\n        )\n        .route(\n            \"/issue-relationships/{relationship_id}\",\n            axum::routing::delete(delete_issue_relationship),\n        )\n}\n\nasync fn list_issue_relationships(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListIssueRelationshipsQuery>,\n) -> Result<ResponseJson<ApiResponse<ListIssueRelationshipsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_issue_relationships(query.issue_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn create_issue_relationship(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<CreateIssueRelationshipRequest>,\n) -> Result<ResponseJson<ApiResponse<MutationResponse<IssueRelationship>>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.create_issue_relationship(&request).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn delete_issue_relationship(\n    State(deployment): State<DeploymentImpl>,\n    Path(relationship_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let client = deployment.remote_client()?;\n    client.delete_issue_relationship(relationship_id).await?;\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/issue_tags.rs",
    "content": "use api_types::{CreateIssueTagRequest, IssueTag, ListIssueTagsResponse, MutationResponse};\nuse axum::{\n    Router,\n    extract::{Json, Path, Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse serde::Deserialize;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct ListIssueTagsQuery {\n    pub issue_id: Uuid,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/issue-tags\", get(list_issue_tags).post(create_issue_tag))\n        .route(\n            \"/issue-tags/{issue_tag_id}\",\n            get(get_issue_tag).delete(delete_issue_tag),\n        )\n}\n\nasync fn list_issue_tags(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListIssueTagsQuery>,\n) -> Result<ResponseJson<ApiResponse<ListIssueTagsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_issue_tags(query.issue_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn get_issue_tag(\n    State(deployment): State<DeploymentImpl>,\n    Path(issue_tag_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<IssueTag>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.get_issue_tag(issue_tag_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn create_issue_tag(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<CreateIssueTagRequest>,\n) -> Result<ResponseJson<ApiResponse<MutationResponse<IssueTag>>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.create_issue_tag(&request).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn delete_issue_tag(\n    State(deployment): State<DeploymentImpl>,\n    Path(issue_tag_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let client = deployment.remote_client()?;\n    client.delete_issue_tag(issue_tag_id).await?;\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/issues.rs",
    "content": "use api_types::{\n    CreateIssueRequest, Issue, ListIssuesQuery, ListIssuesResponse, MutationResponse,\n    SearchIssuesRequest, UpdateIssueRequest,\n};\nuse axum::{\n    Router,\n    extract::{Json, Path, Query, State},\n    response::Json as ResponseJson,\n    routing::{get, post},\n};\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/issues\", get(list_issues).post(create_issue))\n        .route(\"/issues/search\", post(search_issues))\n        .route(\n            \"/issues/{issue_id}\",\n            get(get_issue).patch(update_issue).delete(delete_issue),\n        )\n}\n\nasync fn list_issues(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListIssuesQuery>,\n) -> Result<ResponseJson<ApiResponse<ListIssuesResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_issues(query.project_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn search_issues(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<SearchIssuesRequest>,\n) -> Result<ResponseJson<ApiResponse<ListIssuesResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.search_issues(&request).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn get_issue(\n    State(deployment): State<DeploymentImpl>,\n    Path(issue_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<Issue>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.get_issue(issue_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn create_issue(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<CreateIssueRequest>,\n) -> Result<ResponseJson<ApiResponse<MutationResponse<Issue>>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.create_issue(&request).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn update_issue(\n    State(deployment): State<DeploymentImpl>,\n    Path(issue_id): Path<Uuid>,\n    Json(request): Json<UpdateIssueRequest>,\n) -> Result<ResponseJson<ApiResponse<MutationResponse<Issue>>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.update_issue(issue_id, &request).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn delete_issue(\n    State(deployment): State<DeploymentImpl>,\n    Path(issue_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let client = deployment.remote_client()?;\n    client.delete_issue(issue_id).await?;\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/mod.rs",
    "content": "use axum::Router;\n\nuse crate::DeploymentImpl;\n\nmod issue_assignees;\nmod issue_relationships;\nmod issue_tags;\nmod issues;\nmod project_statuses;\nmod projects;\nmod pull_requests;\nmod tags;\nmod workspaces;\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .merge(issue_assignees::router())\n        .merge(issue_relationships::router())\n        .merge(issue_tags::router())\n        .merge(issues::router())\n        .merge(projects::router())\n        .merge(project_statuses::router())\n        .merge(pull_requests::router())\n        .merge(tags::router())\n        .merge(workspaces::router())\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/project_statuses.rs",
    "content": "use api_types::ListProjectStatusesResponse;\nuse axum::{\n    Router,\n    extract::{Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse serde::Deserialize;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct ListProjectStatusesQuery {\n    pub project_id: Uuid,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new().route(\"/project-statuses\", get(list_project_statuses))\n}\n\nasync fn list_project_statuses(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListProjectStatusesQuery>,\n) -> Result<ResponseJson<ApiResponse<ListProjectStatusesResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_project_statuses(query.project_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/projects.rs",
    "content": "use api_types::{ListProjectsResponse, Project};\nuse axum::{\n    Router,\n    extract::{Path, Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse serde::Deserialize;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct ListRemoteProjectsQuery {\n    pub organization_id: Uuid,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/projects\", get(list_remote_projects))\n        .route(\"/projects/{project_id}\", get(get_remote_project))\n}\n\nasync fn list_remote_projects(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListRemoteProjectsQuery>,\n) -> Result<ResponseJson<ApiResponse<ListProjectsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_remote_projects(query.organization_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn get_remote_project(\n    State(deployment): State<DeploymentImpl>,\n    Path(project_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<Project>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let project = client.get_remote_project(project_id).await?;\n    Ok(ResponseJson(ApiResponse::success(project)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/pull_requests.rs",
    "content": "use api_types::{ListPullRequestsQuery, ListPullRequestsResponse};\nuse axum::{\n    Router,\n    extract::{Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse utils::response::ApiResponse;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new().route(\"/pull-requests\", get(list_pull_requests))\n}\n\nasync fn list_pull_requests(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListPullRequestsQuery>,\n) -> Result<ResponseJson<ApiResponse<ListPullRequestsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_pull_requests(query.issue_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/tags.rs",
    "content": "use api_types::{ListTagsResponse, Tag};\nuse axum::{\n    Router,\n    extract::{Path, Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse serde::Deserialize;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct ListTagsQuery {\n    pub project_id: Uuid,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/tags\", get(list_tags))\n        .route(\"/tags/{tag_id}\", get(get_tag))\n}\n\nasync fn list_tags(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<ListTagsQuery>,\n) -> Result<ResponseJson<ApiResponse<ListTagsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.list_tags(query.project_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n\nasync fn get_tag(\n    State(deployment): State<DeploymentImpl>,\n    Path(tag_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<Tag>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let response = client.get_tag(tag_id).await?;\n    Ok(ResponseJson(ApiResponse::success(response)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/remote/workspaces.rs",
    "content": "use api_types::Workspace;\nuse axum::{\n    Router,\n    extract::{Path, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new().route(\n        \"/workspaces/by-local-id/{local_workspace_id}\",\n        get(get_workspace_by_local_id),\n    )\n}\n\nasync fn get_workspace_by_local_id(\n    State(deployment): State<DeploymentImpl>,\n    Path(local_workspace_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<Workspace>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let workspace = client.get_workspace_by_local_id(local_workspace_id).await?;\n    Ok(ResponseJson(ApiResponse::success(workspace)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/repo.rs",
    "content": "use std::path::PathBuf;\n\nuse axum::{\n    Router,\n    extract::{Path, Query, State},\n    http::StatusCode,\n    response::Json as ResponseJson,\n    routing::{get, post},\n};\nuse db::models::repo::{Repo, SearchResult, UpdateRepo};\nuse deployment::Deployment;\nuse git::{GitBranch, GitRemote};\nuse git_host::{GitHostError, GitHostProvider, GitHostService, OpenPrInfo, ProviderKind};\nuse serde::{Deserialize, Serialize};\nuse services::services::file_search::SearchQuery;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(serde::Deserialize)]\npub struct OpenEditorRequest {\n    pub editor_type: Option<String>,\n    pub git_repo_path: Option<PathBuf>,\n}\n\n#[derive(Debug, serde::Serialize, ts_rs::TS)]\npub struct OpenEditorResponse {\n    pub url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct RegisterRepoRequest {\n    pub path: String,\n    pub display_name: Option<String>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct InitRepoRequest {\n    pub parent_path: String,\n    pub folder_name: String,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct BatchRepoRequest {\n    pub ids: Vec<Uuid>,\n}\n\npub async fn register_repo(\n    State(deployment): State<DeploymentImpl>,\n    ResponseJson(payload): ResponseJson<RegisterRepoRequest>,\n) -> Result<ResponseJson<ApiResponse<Repo>>, ApiError> {\n    let repo = deployment\n        .repo()\n        .register(\n            &deployment.db().pool,\n            &payload.path,\n            payload.display_name.as_deref(),\n        )\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(repo)))\n}\n\npub async fn init_repo(\n    State(deployment): State<DeploymentImpl>,\n    ResponseJson(payload): ResponseJson<InitRepoRequest>,\n) -> Result<ResponseJson<ApiResponse<Repo>>, ApiError> {\n    let repo = deployment\n        .repo()\n        .init_repo(\n            &deployment.db().pool,\n            deployment.git(),\n            &payload.parent_path,\n            &payload.folder_name,\n        )\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(repo)))\n}\n\npub async fn get_repo_branches(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<Vec<GitBranch>>>, ApiError> {\n    let repo = deployment\n        .repo()\n        .get_by_id(&deployment.db().pool, repo_id)\n        .await?;\n\n    let branches = deployment.git().get_all_branches(&repo.path)?;\n    Ok(ResponseJson(ApiResponse::success(branches)))\n}\n\npub async fn get_repo_remotes(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<Vec<GitRemote>>>, ApiError> {\n    let repo = deployment\n        .repo()\n        .get_by_id(&deployment.db().pool, repo_id)\n        .await?;\n\n    let remotes = deployment.git().list_remotes(&repo.path)?;\n    Ok(ResponseJson(ApiResponse::success(remotes)))\n}\n\npub async fn get_repos_batch(\n    State(deployment): State<DeploymentImpl>,\n    ResponseJson(payload): ResponseJson<BatchRepoRequest>,\n) -> Result<ResponseJson<ApiResponse<Vec<Repo>>>, ApiError> {\n    let repos = Repo::find_by_ids(&deployment.db().pool, &payload.ids).await?;\n    Ok(ResponseJson(ApiResponse::success(repos)))\n}\n\npub async fn get_repos(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<Repo>>>, ApiError> {\n    let repos = Repo::list_all(&deployment.db().pool).await?;\n    Ok(ResponseJson(ApiResponse::success(repos)))\n}\n\npub async fn get_recent_repos(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<Repo>>>, ApiError> {\n    let repos = Repo::list_by_recent_workspace_usage(&deployment.db().pool).await?;\n    Ok(ResponseJson(ApiResponse::success(repos)))\n}\n\npub async fn get_repo(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n) -> Result<ResponseJson<ApiResponse<Repo>>, ApiError> {\n    let repo = deployment\n        .repo()\n        .get_by_id(&deployment.db().pool, repo_id)\n        .await?;\n    Ok(ResponseJson(ApiResponse::success(repo)))\n}\n\npub async fn update_repo(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n    ResponseJson(payload): ResponseJson<UpdateRepo>,\n) -> Result<ResponseJson<ApiResponse<Repo>>, ApiError> {\n    let repo = Repo::update(&deployment.db().pool, repo_id, &payload).await?;\n    Ok(ResponseJson(ApiResponse::success(repo)))\n}\n\npub async fn open_repo_in_editor(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n    ResponseJson(payload): ResponseJson<Option<OpenEditorRequest>>,\n) -> Result<ResponseJson<ApiResponse<OpenEditorResponse>>, ApiError> {\n    let repo = deployment\n        .repo()\n        .get_by_id(&deployment.db().pool, repo_id)\n        .await?;\n\n    let editor_config = {\n        let config = deployment.config().read().await;\n        let editor_type_str = payload.as_ref().and_then(|req| req.editor_type.as_deref());\n        config.editor.with_override(editor_type_str)\n    };\n\n    match editor_config.open_file(&repo.path).await {\n        Ok(url) => {\n            tracing::info!(\n                \"Opened editor for repo {} at path: {}{}\",\n                repo_id,\n                repo.path.to_string_lossy(),\n                if url.is_some() { \" (remote mode)\" } else { \"\" }\n            );\n\n            deployment\n                .track_if_analytics_allowed(\n                    \"repo_editor_opened\",\n                    serde_json::json!({\n                        \"repo_id\": repo_id.to_string(),\n                        \"editor_type\": payload.as_ref().and_then(|req| req.editor_type.as_ref()),\n                        \"remote_mode\": url.is_some(),\n                    }),\n                )\n                .await;\n\n            Ok(ResponseJson(ApiResponse::success(OpenEditorResponse {\n                url,\n            })))\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to open editor for repo {}: {:?}\", repo_id, e);\n            Err(ApiError::EditorOpen(e))\n        }\n    }\n}\n\npub async fn search_repo(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n    Query(search_query): Query<SearchQuery>,\n) -> Result<ResponseJson<ApiResponse<Vec<SearchResult>>>, StatusCode> {\n    if search_query.q.trim().is_empty() {\n        return Ok(ResponseJson(ApiResponse::error(\n            \"Query parameter 'q' is required and cannot be empty\",\n        )));\n    }\n\n    let repo = match deployment\n        .repo()\n        .get_by_id(&deployment.db().pool, repo_id)\n        .await\n    {\n        Ok(repo) => repo,\n        Err(e) => {\n            tracing::error!(\"Failed to get repo {}: {}\", repo_id, e);\n            return Err(StatusCode::NOT_FOUND);\n        }\n    };\n\n    match deployment\n        .file_search_cache()\n        .search_repo(&repo.path, &search_query.q, search_query.mode)\n        .await\n    {\n        Ok(results) => Ok(ResponseJson(ApiResponse::success(results))),\n        Err(e) => {\n            tracing::error!(\"Failed to search files in repo {}: {}\", repo_id, e);\n            Err(StatusCode::INTERNAL_SERVER_ERROR)\n        }\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum ListPrsError {\n    CliNotInstalled { provider: ProviderKind },\n    AuthFailed { message: String },\n    UnsupportedProvider,\n}\n\n#[derive(Debug, Deserialize)]\npub struct ListPrsQuery {\n    pub remote: Option<String>,\n}\n\npub async fn list_open_prs(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n    Query(query): Query<ListPrsQuery>,\n) -> Result<ResponseJson<ApiResponse<Vec<OpenPrInfo>, ListPrsError>>, ApiError> {\n    let repo = deployment\n        .repo()\n        .get_by_id(&deployment.db().pool, repo_id)\n        .await?;\n\n    let remote = match query.remote {\n        Some(name) => GitRemote {\n            url: deployment.git().get_remote_url(&repo.path, &name)?,\n            name,\n        },\n        None => deployment.git().get_default_remote(&repo.path)?,\n    };\n\n    let git_host = match GitHostService::from_url(&remote.url) {\n        Ok(host) => host,\n        Err(GitHostError::UnsupportedProvider) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                ListPrsError::UnsupportedProvider,\n            )));\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to create git host service: {}\", e);\n            return Ok(ResponseJson(ApiResponse::error(&e.to_string())));\n        }\n    };\n\n    match git_host.list_open_prs(&repo.path, &remote.url).await {\n        Ok(prs) => Ok(ResponseJson(ApiResponse::success(prs))),\n        Err(GitHostError::CliNotInstalled { provider }) => Ok(ResponseJson(\n            ApiResponse::error_with_data(ListPrsError::CliNotInstalled { provider }),\n        )),\n        Err(GitHostError::AuthFailed(message)) => Ok(ResponseJson(ApiResponse::error_with_data(\n            ListPrsError::AuthFailed { message },\n        ))),\n        Err(GitHostError::UnsupportedProvider) => Ok(ResponseJson(ApiResponse::error_with_data(\n            ListPrsError::UnsupportedProvider,\n        ))),\n        Err(e) => {\n            tracing::error!(\"Failed to list open PRs for repo {}: {}\", repo_id, e);\n            Ok(ResponseJson(ApiResponse::error(&e.to_string())))\n        }\n    }\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct DeleteRepoConflict {\n    pub message: String,\n    pub workspaces: Vec<String>,\n}\n\npub async fn delete_repo(\n    State(deployment): State<DeploymentImpl>,\n    Path(repo_id): Path<Uuid>,\n) -> Result<\n    (\n        StatusCode,\n        ResponseJson<ApiResponse<(), DeleteRepoConflict>>,\n    ),\n    ApiError,\n> {\n    let active = Repo::active_workspace_names(&deployment.db().pool, repo_id).await?;\n    if !active.is_empty() {\n        return Ok((\n            StatusCode::CONFLICT,\n            ResponseJson(ApiResponse::error_with_data(DeleteRepoConflict {\n                message: format!(\"Repository is used by {} active workspace(s)\", active.len()),\n                workspaces: active,\n            })),\n        ));\n    }\n\n    Repo::delete(&deployment.db().pool, repo_id).await?;\n    Ok((StatusCode::OK, ResponseJson(ApiResponse::success(()))))\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/repos\", get(get_repos).post(register_repo))\n        .route(\"/repos/recent\", get(get_recent_repos))\n        .route(\"/repos/init\", post(init_repo))\n        .route(\"/repos/batch\", post(get_repos_batch))\n        .route(\n            \"/repos/{repo_id}\",\n            get(get_repo).put(update_repo).delete(delete_repo),\n        )\n        .route(\"/repos/{repo_id}/branches\", get(get_repo_branches))\n        .route(\"/repos/{repo_id}/remotes\", get(get_repo_remotes))\n        .route(\"/repos/{repo_id}/prs\", get(list_open_prs))\n        .route(\"/repos/{repo_id}/search\", get(search_repo))\n        .route(\"/repos/{repo_id}/open-editor\", post(open_repo_in_editor))\n}\n"
  },
  {
    "path": "crates/server/src/routes/scratch.rs",
    "content": "use axum::{\n    Json, Router,\n    extract::{Path, State, ws::Message},\n    response::{IntoResponse, Json as ResponseJson},\n    routing::get,\n};\nuse db::models::scratch::{CreateScratch, Scratch, ScratchType, UpdateScratch};\nuse deployment::Deployment;\nuse futures_util::{StreamExt, TryStreamExt};\nuse serde::Deserialize;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{\n    DeploymentImpl,\n    error::ApiError,\n    routes::relay_ws::{SignedWebSocket, SignedWsUpgrade},\n};\n\n/// Path parameters for scratch routes with composite key\n#[derive(Deserialize)]\npub struct ScratchPath {\n    scratch_type: ScratchType,\n    id: Uuid,\n}\n\npub async fn list_scratch(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<Scratch>>>, ApiError> {\n    let scratch_items = Scratch::find_all(&deployment.db().pool).await?;\n    Ok(ResponseJson(ApiResponse::success(scratch_items)))\n}\n\npub async fn get_scratch(\n    State(deployment): State<DeploymentImpl>,\n    Path(ScratchPath { scratch_type, id }): Path<ScratchPath>,\n) -> Result<ResponseJson<ApiResponse<Scratch>>, ApiError> {\n    let scratch = Scratch::find_by_id(&deployment.db().pool, id, &scratch_type)\n        .await?\n        .ok_or_else(|| ApiError::BadRequest(\"Scratch not found\".to_string()))?;\n    Ok(ResponseJson(ApiResponse::success(scratch)))\n}\n\npub async fn create_scratch(\n    State(deployment): State<DeploymentImpl>,\n    Path(ScratchPath { scratch_type, id }): Path<ScratchPath>,\n    Json(payload): Json<CreateScratch>,\n) -> Result<ResponseJson<ApiResponse<Scratch>>, ApiError> {\n    // Reject edits to draft_follow_up if a message is queued for this workspace\n    if matches!(scratch_type, ScratchType::DraftFollowUp)\n        && deployment.queued_message_service().has_queued(id)\n    {\n        return Err(ApiError::BadRequest(\n            \"Cannot edit scratch while a message is queued\".to_string(),\n        ));\n    }\n\n    // Validate that payload type matches URL type\n    payload\n        .payload\n        .validate_type(scratch_type)\n        .map_err(|e| ApiError::BadRequest(e.to_string()))?;\n\n    let scratch = Scratch::create(&deployment.db().pool, id, &payload).await?;\n    Ok(ResponseJson(ApiResponse::success(scratch)))\n}\n\npub async fn update_scratch(\n    State(deployment): State<DeploymentImpl>,\n    Path(ScratchPath { scratch_type, id }): Path<ScratchPath>,\n    Json(payload): Json<UpdateScratch>,\n) -> Result<ResponseJson<ApiResponse<Scratch>>, ApiError> {\n    // Reject edits to draft_follow_up if a message is queued for this workspace\n    if matches!(scratch_type, ScratchType::DraftFollowUp)\n        && deployment.queued_message_service().has_queued(id)\n    {\n        return Err(ApiError::BadRequest(\n            \"Cannot edit scratch while a message is queued\".to_string(),\n        ));\n    }\n\n    // Validate that payload type matches URL type\n    payload\n        .payload\n        .validate_type(scratch_type)\n        .map_err(|e| ApiError::BadRequest(e.to_string()))?;\n\n    // Upsert: creates if not exists, updates if exists\n    let scratch = Scratch::update(&deployment.db().pool, id, &scratch_type, &payload).await?;\n    Ok(ResponseJson(ApiResponse::success(scratch)))\n}\n\npub async fn delete_scratch(\n    State(deployment): State<DeploymentImpl>,\n    Path(ScratchPath { scratch_type, id }): Path<ScratchPath>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let rows = Scratch::delete(&deployment.db().pool, id, &scratch_type).await?;\n    if rows == 0 {\n        return Err(ApiError::BadRequest(\"Scratch not found\".to_string()));\n    }\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub async fn stream_scratch_ws(\n    ws: SignedWsUpgrade,\n    State(deployment): State<DeploymentImpl>,\n    Path(ScratchPath { scratch_type, id }): Path<ScratchPath>,\n) -> impl IntoResponse {\n    ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_scratch_ws(socket, deployment, id, scratch_type).await {\n            tracing::warn!(\"scratch WS closed: {}\", e);\n        }\n    })\n}\n\nasync fn handle_scratch_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n    id: Uuid,\n    scratch_type: ScratchType,\n) -> anyhow::Result<()> {\n    let mut stream = deployment\n        .events()\n        .stream_scratch_raw(id, &scratch_type)\n        .await?\n        .map_ok(|msg| msg.to_ws_message_unchecked());\n\n    loop {\n        tokio::select! {\n            item = stream.next() => {\n                match item {\n                    Some(Ok(msg)) => {\n                        if socket.send(msg).await.is_err() {\n                            break;\n                        }\n                    }\n                    Some(Err(e)) => {\n                        tracing::error!(\"scratch stream error: {}\", e);\n                        break;\n                    }\n                    None => break,\n                }\n            }\n            inbound = socket.recv() => {\n                match inbound {\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(_) => break,\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\npub fn router(_deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/scratch\", get(list_scratch))\n        .route(\n            \"/scratch/{scratch_type}/{id}\",\n            get(get_scratch)\n                .post(create_scratch)\n                .put(update_scratch)\n                .delete(delete_scratch),\n        )\n        .route(\n            \"/scratch/{scratch_type}/{id}/stream/ws\",\n            get(stream_scratch_ws),\n        )\n}\n"
  },
  {
    "path": "crates/server/src/routes/search.rs",
    "content": "use axum::{\n    Router,\n    extract::{Query, State},\n    response::Json as ResponseJson,\n    routing::get,\n};\nuse db::models::repo::{Repo, SearchResult};\nuse deployment::Deployment;\nuse serde::Deserialize;\nuse services::services::file_search::{SearchMode, SearchQuery};\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct MultiRepoSearchQuery {\n    pub q: String,\n    #[serde(default)]\n    pub mode: SearchMode,\n    pub repo_ids: String,\n}\n\npub async fn search_files(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<MultiRepoSearchQuery>,\n) -> Result<ResponseJson<ApiResponse<Vec<SearchResult>>>, ApiError> {\n    let repo_ids: Vec<Uuid> = query\n        .repo_ids\n        .split(',')\n        .filter(|s| !s.trim().is_empty())\n        .map(|s| s.trim().parse::<Uuid>())\n        .collect::<Result<Vec<_>, _>>()\n        .map_err(|_| ApiError::BadRequest(\"Invalid repo_id format\".to_string()))?;\n\n    if repo_ids.is_empty() {\n        return Err(ApiError::BadRequest(\n            \"repo_ids parameter is required\".to_string(),\n        ));\n    }\n\n    if query.q.trim().is_empty() {\n        return Ok(ResponseJson(ApiResponse::error(\n            \"Query parameter 'q' is required and cannot be empty\",\n        )));\n    }\n\n    let repos = Repo::find_by_ids(&deployment.db().pool, &repo_ids).await?;\n\n    let search_query = SearchQuery {\n        q: query.q,\n        mode: query.mode,\n    };\n\n    let results = deployment\n        .repo()\n        .search_files(\n            deployment.file_search_cache().as_ref(),\n            &repos,\n            &search_query,\n        )\n        .await\n        .map_err(|e| {\n            tracing::error!(\"Failed to search files: {}\", e);\n            ApiError::BadRequest(format!(\"Search failed: {}\", e))\n        })?;\n\n    Ok(ResponseJson(ApiResponse::success(results)))\n}\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/search\", get(search_files))\n        .with_state(deployment.clone())\n}\n"
  },
  {
    "path": "crates/server/src/routes/sessions/mod.rs",
    "content": "pub mod queue;\npub mod review;\n\nuse axum::{\n    Extension, Json, Router,\n    extract::{Query, State},\n    middleware::from_fn_with_state,\n    response::Json as ResponseJson,\n    routing::{get, post},\n};\nuse db::models::{\n    coding_agent_turn::CodingAgentTurn,\n    execution_process::{ExecutionProcess, ExecutionProcessRunReason},\n    requests::UpdateSession,\n    scratch::{Scratch, ScratchType},\n    session::{CreateSession, Session, SessionError},\n    workspace::{Workspace, WorkspaceError},\n    workspace_repo::WorkspaceRepo,\n};\nuse deployment::Deployment;\nuse executors::{\n    actions::{\n        ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest,\n    },\n    profile::ExecutorConfig,\n};\nuse serde::Deserialize;\nuse services::services::container::ContainerService;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{\n    DeploymentImpl, error::ApiError, middleware::load_session_middleware,\n    routes::workspaces::execution::RunScriptError,\n};\n\n#[derive(Debug, Deserialize)]\npub struct SessionQuery {\n    pub workspace_id: Uuid,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateSessionRequest {\n    pub workspace_id: Uuid,\n    pub executor: Option<String>,\n    pub name: Option<String>,\n}\n\npub async fn get_sessions(\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<SessionQuery>,\n) -> Result<ResponseJson<ApiResponse<Vec<Session>>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let sessions = Session::find_by_workspace_id(pool, query.workspace_id).await?;\n    Ok(ResponseJson(ApiResponse::success(sessions)))\n}\n\npub async fn get_session(\n    Extension(session): Extension<Session>,\n) -> Result<ResponseJson<ApiResponse<Session>>, ApiError> {\n    Ok(ResponseJson(ApiResponse::success(session)))\n}\n\npub async fn create_session(\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<CreateSessionRequest>,\n) -> Result<ResponseJson<ApiResponse<Session>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    // Verify workspace exists\n    let _workspace = Workspace::find_by_id(pool, payload.workspace_id)\n        .await?\n        .ok_or(ApiError::Workspace(WorkspaceError::ValidationError(\n            \"Workspace not found\".to_string(),\n        )))?;\n\n    let session = Session::create(\n        pool,\n        &CreateSession {\n            executor: payload.executor,\n            name: payload.name,\n        },\n        Uuid::new_v4(),\n        payload.workspace_id,\n    )\n    .await?;\n\n    Ok(ResponseJson(ApiResponse::success(session)))\n}\n\npub async fn update_session(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<UpdateSession>,\n) -> Result<ResponseJson<ApiResponse<Session>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    Session::update(pool, session.id, request.name.as_deref()).await?;\n\n    let updated = Session::find_by_id(pool, session.id)\n        .await?\n        .ok_or(ApiError::Session(SessionError::NotFound))?;\n\n    Ok(ResponseJson(ApiResponse::success(updated)))\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct CreateFollowUpAttempt {\n    pub prompt: String,\n    pub executor_config: ExecutorConfig,\n    pub retry_process_id: Option<Uuid>,\n    pub force_when_dirty: Option<bool>,\n    pub perform_git_reset: Option<bool>,\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct ResetProcessRequest {\n    pub process_id: Uuid,\n    pub force_when_dirty: Option<bool>,\n    pub perform_git_reset: Option<bool>,\n}\n\npub async fn follow_up(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<CreateFollowUpAttempt>,\n) -> Result<ResponseJson<ApiResponse<ExecutionProcess>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    // Load workspace from session\n    let workspace = Workspace::find_by_id(pool, session.workspace_id)\n        .await?\n        .ok_or(ApiError::Workspace(WorkspaceError::ValidationError(\n            \"Workspace not found\".to_string(),\n        )))?;\n\n    tracing::info!(\"{:?}\", workspace);\n\n    deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n\n    let executor_profile_id = payload.executor_config.profile_id();\n\n    // Validate executor matches session if session has prior executions\n    let expected_executor: Option<String> =\n        ExecutionProcess::latest_executor_profile_for_session(pool, session.id)\n            .await?\n            .map(|profile| profile.executor.to_string())\n            .or_else(|| session.executor.clone());\n\n    if let Some(expected) = expected_executor {\n        let actual = executor_profile_id.executor.to_string();\n        if expected != actual {\n            return Err(ApiError::Session(SessionError::ExecutorMismatch {\n                expected,\n                actual,\n            }));\n        }\n    }\n\n    if session.executor.is_none() {\n        Session::update_executor(pool, session.id, &executor_profile_id.executor.to_string())\n            .await?;\n    }\n\n    if let Some(proc_id) = payload.retry_process_id {\n        let force_when_dirty = payload.force_when_dirty.unwrap_or(false);\n        let perform_git_reset = payload.perform_git_reset.unwrap_or(true);\n        deployment\n            .container()\n            .reset_session_to_process(session.id, proc_id, perform_git_reset, force_when_dirty)\n            .await?;\n    }\n\n    let latest_session_info = CodingAgentTurn::find_latest_session_info(pool, session.id).await?;\n\n    let prompt = payload.prompt;\n\n    let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n    let cleanup_action = deployment.container().cleanup_actions_for_repos(&repos);\n\n    let working_dir = session\n        .agent_working_dir\n        .as_ref()\n        .filter(|dir| !dir.is_empty())\n        .cloned();\n\n    let action_type = if let Some(info) = latest_session_info {\n        let is_reset = payload.retry_process_id.is_some();\n        ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest {\n            prompt: prompt.clone(),\n            session_id: info.session_id,\n            reset_to_message_id: if is_reset { info.message_id } else { None },\n            executor_config: payload.executor_config.clone(),\n            working_dir: working_dir.clone(),\n        })\n    } else {\n        ExecutorActionType::CodingAgentInitialRequest(\n            executors::actions::coding_agent_initial::CodingAgentInitialRequest {\n                prompt,\n                executor_config: payload.executor_config.clone(),\n                working_dir,\n            },\n        )\n    };\n\n    let action = ExecutorAction::new(action_type, cleanup_action.map(Box::new));\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            &workspace,\n            &session,\n            &action,\n            &ExecutionProcessRunReason::CodingAgent,\n        )\n        .await?;\n\n    // Clear the draft follow-up scratch on successful spawn\n    // This ensures the scratch is wiped even if the user navigates away quickly\n    if let Err(e) = Scratch::delete(pool, session.id, &ScratchType::DraftFollowUp).await {\n        // Log but don't fail the request - scratch deletion is best-effort\n        tracing::debug!(\n            \"Failed to delete draft follow-up scratch for session {}: {}\",\n            session.id,\n            e\n        );\n    }\n\n    Ok(ResponseJson(ApiResponse::success(execution_process)))\n}\n\npub async fn reset_process(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<ResetProcessRequest>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let force_when_dirty = payload.force_when_dirty.unwrap_or(false);\n    let perform_git_reset = payload.perform_git_reset.unwrap_or(true);\n\n    deployment\n        .container()\n        .reset_session_to_process(\n            session.id,\n            payload.process_id,\n            perform_git_reset,\n            force_when_dirty,\n        )\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub async fn run_setup_script(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<ExecutionProcess, RunScriptError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace = Workspace::find_by_id(pool, session.workspace_id)\n        .await?\n        .ok_or(ApiError::Workspace(WorkspaceError::ValidationError(\n            \"Workspace not found\".to_string(),\n        )))?;\n\n    if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id)\n        .await?\n    {\n        return Ok(ResponseJson(ApiResponse::error_with_data(\n            RunScriptError::ProcessAlreadyRunning,\n        )));\n    }\n\n    deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n\n    let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n    let executor_action = match deployment.container().setup_actions_for_repos(&repos) {\n        Some(action) => action,\n        None => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                RunScriptError::NoScriptConfigured,\n            )));\n        }\n    };\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            &workspace,\n            &session,\n            &executor_action,\n            &ExecutionProcessRunReason::SetupScript,\n        )\n        .await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"setup_script_executed\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(execution_process)))\n}\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    let session_id_router = Router::new()\n        .route(\"/\", get(get_session).put(update_session))\n        .route(\"/follow-up\", post(follow_up))\n        .route(\"/reset\", post(reset_process))\n        .route(\"/setup\", post(run_setup_script))\n        .route(\"/review\", post(review::start_review))\n        .layer(from_fn_with_state(\n            deployment.clone(),\n            load_session_middleware,\n        ));\n\n    let sessions_router = Router::new()\n        .route(\"/\", get(get_sessions).post(create_session))\n        .nest(\"/{session_id}\", session_id_router)\n        .nest(\"/{session_id}/queue\", queue::router(deployment));\n\n    Router::new().nest(\"/sessions\", sessions_router)\n}\n"
  },
  {
    "path": "crates/server/src/routes/sessions/queue.rs",
    "content": "use axum::{\n    Extension, Json, Router, extract::State, middleware::from_fn_with_state,\n    response::Json as ResponseJson, routing::get,\n};\nuse db::models::{scratch::DraftFollowUpData, session::Session};\nuse deployment::Deployment;\nuse executors::profile::ExecutorConfig;\nuse serde::Deserialize;\nuse services::services::queued_message::QueueStatus;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\n\nuse crate::{DeploymentImpl, error::ApiError, middleware::load_session_middleware};\n\n/// Request body for queueing a follow-up message\n#[derive(Debug, Deserialize, TS)]\npub struct QueueMessageRequest {\n    pub message: String,\n    pub executor_config: ExecutorConfig,\n}\n\n/// Queue a follow-up message to be executed when the current execution finishes\npub async fn queue_message(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<QueueMessageRequest>,\n) -> Result<ResponseJson<ApiResponse<QueueStatus>>, ApiError> {\n    let data = DraftFollowUpData {\n        message: payload.message,\n        executor_config: payload.executor_config,\n    };\n\n    let queued = deployment\n        .queued_message_service()\n        .queue_message(session.id, data);\n\n    deployment\n        .track_if_analytics_allowed(\n            \"follow_up_queued\",\n            serde_json::json!({\n                \"session_id\": session.id.to_string(),\n                \"workspace_id\": session.workspace_id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(QueueStatus::Queued {\n        message: queued,\n    })))\n}\n\n/// Cancel a queued follow-up message\npub async fn cancel_queued_message(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<QueueStatus>>, ApiError> {\n    deployment\n        .queued_message_service()\n        .cancel_queued(session.id);\n\n    deployment\n        .track_if_analytics_allowed(\n            \"follow_up_queue_cancelled\",\n            serde_json::json!({\n                \"session_id\": session.id.to_string(),\n                \"workspace_id\": session.workspace_id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(QueueStatus::Empty)))\n}\n\n/// Get the current queue status for a session's workspace\npub async fn get_queue_status(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<QueueStatus>>, ApiError> {\n    let status = deployment.queued_message_service().get_status(session.id);\n\n    Ok(ResponseJson(ApiResponse::success(status)))\n}\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\n            \"/\",\n            get(get_queue_status)\n                .post(queue_message)\n                .delete(cancel_queued_message),\n        )\n        .layer(from_fn_with_state(\n            deployment.clone(),\n            load_session_middleware,\n        ))\n}\n"
  },
  {
    "path": "crates/server/src/routes/sessions/review.rs",
    "content": "use std::path::PathBuf;\n\nuse axum::{Extension, Json, extract::State, response::Json as ResponseJson};\nuse db::models::{\n    coding_agent_turn::CodingAgentTurn,\n    execution_process::{ExecutionProcess, ExecutionProcessRunReason},\n    session::Session,\n    workspace::{Workspace, WorkspaceError},\n    workspace_repo::WorkspaceRepo,\n};\nuse deployment::Deployment;\nuse executors::{\n    actions::{\n        ExecutorAction, ExecutorActionType,\n        review::{RepoReviewContext as ExecutorRepoReviewContext, ReviewRequest as ReviewAction},\n    },\n    executors::build_review_prompt,\n    profile::ExecutorConfig,\n};\nuse serde::{Deserialize, Serialize};\nuse services::services::container::ContainerService;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct StartReviewRequest {\n    pub executor_config: ExecutorConfig,\n    pub additional_prompt: Option<String>,\n    #[serde(default)]\n    pub use_all_workspace_commits: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum ReviewError {\n    ProcessAlreadyRunning,\n}\n\n#[axum::debug_handler]\npub async fn start_review(\n    Extension(session): Extension<Session>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<StartReviewRequest>,\n) -> Result<ResponseJson<ApiResponse<ExecutionProcess, ReviewError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace = Workspace::find_by_id(pool, session.workspace_id)\n        .await?\n        .ok_or(ApiError::Workspace(WorkspaceError::ValidationError(\n            \"Workspace not found\".to_string(),\n        )))?;\n\n    if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id)\n        .await?\n    {\n        return Ok(ResponseJson(ApiResponse::error_with_data(\n            ReviewError::ProcessAlreadyRunning,\n        )));\n    }\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n\n    let agent_session_id = CodingAgentTurn::find_latest_session_info(pool, session.id)\n        .await?\n        .map(|info| info.session_id);\n\n    let context: Option<Vec<ExecutorRepoReviewContext>> = if payload.use_all_workspace_commits {\n        let repos =\n            WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace.id).await?;\n        let workspace_path = PathBuf::from(container_ref.as_str());\n\n        let mut contexts = Vec::new();\n        for repo in repos {\n            let worktree_path = workspace_path.join(&repo.repo.name);\n            if let Ok(base_commit) = deployment.git().get_fork_point(\n                &worktree_path,\n                &repo.target_branch,\n                &workspace.branch,\n            ) {\n                contexts.push(ExecutorRepoReviewContext {\n                    repo_id: repo.repo.id,\n                    repo_name: repo.repo.display_name,\n                    base_commit,\n                });\n            }\n        }\n        if contexts.is_empty() {\n            None\n        } else {\n            Some(contexts)\n        }\n    } else {\n        None\n    };\n\n    let prompt = build_review_prompt(context.as_deref(), payload.additional_prompt.as_deref());\n    let resumed_session = agent_session_id.is_some();\n\n    let action = ExecutorAction::new(\n        ExecutorActionType::ReviewRequest(ReviewAction {\n            executor_config: payload.executor_config.clone(),\n            context,\n            prompt,\n            session_id: agent_session_id,\n            working_dir: session.agent_working_dir.clone(),\n        }),\n        None,\n    );\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            &workspace,\n            &session,\n            &action,\n            &ExecutionProcessRunReason::CodingAgent,\n        )\n        .await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"review_started\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n                \"session_id\": session.id.to_string(),\n                \"executor\": payload.executor_config.executor.to_string(),\n                \"variant\": payload.executor_config.variant,\n                \"resumed_session\": resumed_session,\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(execution_process)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/tags.rs",
    "content": "use axum::{\n    Extension, Json, Router,\n    extract::{Query, State},\n    middleware::from_fn_with_state,\n    response::Json as ResponseJson,\n    routing::{get, put},\n};\nuse db::models::tag::{CreateTag, Tag, UpdateTag};\nuse deployment::Deployment;\nuse serde::Deserialize;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\n\nuse crate::{DeploymentImpl, error::ApiError, middleware::load_tag_middleware};\n\n#[derive(Deserialize, TS)]\npub struct TagSearchParams {\n    #[serde(default)]\n    pub search: Option<String>,\n}\n\npub async fn get_tags(\n    State(deployment): State<DeploymentImpl>,\n    Query(params): Query<TagSearchParams>,\n) -> Result<ResponseJson<ApiResponse<Vec<Tag>>>, ApiError> {\n    let mut tags = Tag::find_all(&deployment.db().pool).await?;\n\n    // Filter by search query if provided\n    if let Some(search_query) = params.search {\n        let search_lower = search_query.to_lowercase();\n        tags.retain(|tag| tag.tag_name.to_lowercase().contains(&search_lower));\n    }\n\n    Ok(ResponseJson(ApiResponse::success(tags)))\n}\n\npub async fn create_tag(\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<CreateTag>,\n) -> Result<ResponseJson<ApiResponse<Tag>>, ApiError> {\n    let tag = Tag::create(&deployment.db().pool, &payload).await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"tag_created\",\n            serde_json::json!({\n                \"tag_id\": tag.id.to_string(),\n                \"tag_name\": tag.tag_name,\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(tag)))\n}\n\npub async fn update_tag(\n    Extension(tag): Extension<Tag>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<UpdateTag>,\n) -> Result<ResponseJson<ApiResponse<Tag>>, ApiError> {\n    let updated_tag = Tag::update(&deployment.db().pool, tag.id, &payload).await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"tag_updated\",\n            serde_json::json!({\n                \"tag_id\": tag.id.to_string(),\n                \"tag_name\": updated_tag.tag_name,\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(updated_tag)))\n}\n\npub async fn delete_tag(\n    Extension(tag): Extension<Tag>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let rows_affected = Tag::delete(&deployment.db().pool, tag.id).await?;\n    if rows_affected == 0 {\n        Err(ApiError::Database(sqlx::Error::RowNotFound))\n    } else {\n        Ok(ResponseJson(ApiResponse::success(())))\n    }\n}\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    let tag_router = Router::new()\n        .route(\"/\", put(update_tag).delete(delete_tag))\n        .layer(from_fn_with_state(deployment.clone(), load_tag_middleware));\n\n    let inner = Router::new()\n        .route(\"/\", get(get_tags).post(create_tag))\n        .nest(\"/{tag_id}\", tag_router);\n\n    Router::new().nest(\"/tags\", inner)\n}\n"
  },
  {
    "path": "crates/server/src/routes/terminal.rs",
    "content": "use std::path::PathBuf;\n\nuse axum::{\n    Router,\n    extract::{Query, State, ws::Message},\n    response::IntoResponse,\n    routing::get,\n};\nuse base64::{Engine, engine::general_purpose::STANDARD as BASE64};\nuse db::models::{workspace::Workspace, workspace_repo::WorkspaceRepo};\nuse deployment::Deployment;\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse crate::{\n    DeploymentImpl,\n    error::ApiError,\n    routes::relay_ws::{SignedWebSocket, SignedWsUpgrade},\n};\n\n#[derive(Debug, Deserialize)]\npub struct TerminalQuery {\n    pub workspace_id: Uuid,\n    #[serde(default = \"default_cols\")]\n    pub cols: u16,\n    #[serde(default = \"default_rows\")]\n    pub rows: u16,\n}\n\nfn default_cols() -> u16 {\n    80\n}\n\nfn default_rows() -> u16 {\n    24\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\nenum TerminalCommand {\n    Input { data: String },\n    Resize { cols: u16, rows: u16 },\n}\n\n#[derive(Debug, Serialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\nenum TerminalMessage {\n    Output { data: String },\n    Error { message: String },\n}\n\npub async fn terminal_ws(\n    ws: SignedWsUpgrade,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<TerminalQuery>,\n) -> Result<impl IntoResponse, ApiError> {\n    let attempt = Workspace::find_by_id(&deployment.db().pool, query.workspace_id)\n        .await?\n        .ok_or_else(|| ApiError::BadRequest(\"Attempt not found\".to_string()))?;\n\n    let container_ref = attempt\n        .container_ref\n        .ok_or_else(|| ApiError::BadRequest(\"Attempt has no workspace directory\".to_string()))?;\n\n    let base_dir = PathBuf::from(&container_ref);\n    if !base_dir.exists() {\n        return Err(ApiError::BadRequest(\n            \"Workspace directory does not exist\".to_string(),\n        ));\n    }\n\n    let mut working_dir = base_dir.clone();\n    match WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, query.workspace_id).await {\n        Ok(repos) if repos.len() == 1 => {\n            let repo_dir = base_dir.join(&repos[0].name);\n            if repo_dir.exists() {\n                working_dir = repo_dir;\n            }\n        }\n        Ok(_) => {}\n        Err(e) => {\n            tracing::warn!(\n                \"Failed to resolve repos for workspace {}: {}\",\n                attempt.id,\n                e\n            );\n        }\n    }\n\n    Ok(ws.on_upgrade(move |socket| {\n        handle_terminal_ws(socket, deployment, working_dir, query.cols, query.rows)\n    }))\n}\n\nasync fn handle_terminal_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n    working_dir: PathBuf,\n    cols: u16,\n    rows: u16,\n) {\n    let (session_id, mut output_rx) = match deployment\n        .pty()\n        .create_session(working_dir, cols, rows)\n        .await\n    {\n        Ok(result) => result,\n        Err(e) => {\n            tracing::error!(\"Failed to create PTY session: {}\", e);\n            let _ = send_error(&mut socket, &e.to_string()).await;\n            return;\n        }\n    };\n\n    let pty_service = deployment.pty().clone();\n    let session_id_for_input = session_id;\n\n    loop {\n        tokio::select! {\n            maybe_output = output_rx.recv() => {\n                let Some(data) = maybe_output else {\n                    break;\n                };\n\n                let msg = TerminalMessage::Output {\n                    data: BASE64.encode(&data),\n                };\n                let json = match serde_json::to_string(&msg) {\n                    Ok(j) => j,\n                    Err(_) => continue,\n                };\n\n                if socket.send(Message::Text(json.into())).await.is_err() {\n                    break;\n                }\n            }\n            inbound = socket.recv() => {\n                match inbound {\n                    Ok(Some(Message::Text(text))) => {\n                        if let Ok(cmd) = serde_json::from_str::<TerminalCommand>(text.as_str()) {\n                            match cmd {\n                                TerminalCommand::Input { data } => {\n                                    if let Ok(bytes) = BASE64.decode(&data) {\n                                        let _ = pty_service.write(session_id_for_input, &bytes).await;\n                                    }\n                                }\n                                TerminalCommand::Resize { cols, rows } => {\n                                    let _ = pty_service.resize(session_id_for_input, cols, rows).await;\n                                }\n                            }\n                        }\n                    }\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(error) => {\n                        tracing::warn!(\"terminal WS receive error: {}\", error);\n                        break;\n                    }\n                }\n            }\n        }\n    }\n\n    let _ = deployment.pty().close_session(session_id).await;\n}\n\nasync fn send_error(socket: &mut SignedWebSocket, message: &str) -> anyhow::Result<()> {\n    let msg = TerminalMessage::Error {\n        message: message.to_string(),\n    };\n    let json = serde_json::to_string(&msg).unwrap_or_default();\n    socket.send(Message::Text(json.into())).await?;\n    socket.close().await?;\n    Ok(())\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new().route(\"/terminal/ws\", get(terminal_ws))\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/attachments.rs",
    "content": "use std::path::Path;\n\nuse axum::{\n    Extension, Router,\n    body::Body,\n    extract::{DefaultBodyLimit, Multipart, Query, Request, State},\n    http::{StatusCode, header},\n    middleware::{Next, from_fn_with_state},\n    response::{Json as ResponseJson, Response},\n    routing::{get, post},\n};\nuse db::models::{file::File, session::Session, workspace::Workspace};\nuse deployment::Deployment;\nuse mime_guess::MimeGuess;\nuse serde::{Deserialize, Serialize};\nuse services::services::{\n    container::ContainerService,\n    file::{FileError, FileService},\n    remote_client::RemoteClient,\n};\nuse tokio::fs::File as TokioFile;\nuse tokio_util::io::ReaderStream;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{\n    DeploymentImpl,\n    error::ApiError,\n    middleware::load_workspace_middleware,\n    routes::attachments::{\n        AttachmentMetadata, AttachmentResponse, content_type_and_disposition_for_attachment,\n        process_file_upload,\n    },\n};\n\n#[derive(Debug, Deserialize)]\npub struct AttachmentMetadataQuery {\n    /// Path relative to worktree root, e.g., \".vibe-attachments/screenshot.png\"\n    pub path: String,\n    pub session_id: Uuid,\n}\n\n#[derive(Debug, Deserialize)]\npub struct SessionScopedQuery {\n    pub session_id: Uuid,\n}\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct AssociateWorkspaceAttachmentsRequest {\n    pub attachment_ids: Vec<Uuid>,\n}\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct ImportIssueAttachmentsRequest {\n    pub issue_id: Uuid,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct ImportIssueAttachmentsResponse {\n    pub attachment_ids: Vec<Uuid>,\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct ImportedIssueAttachment {\n    pub attachment_id: Uuid,\n    pub file: File,\n}\n\npub async fn get_workspace_files(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<AttachmentResponse>>>, ApiError> {\n    let files = File::find_by_workspace_id(&deployment.db().pool, workspace.id).await?;\n    let attachment_responses = files\n        .into_iter()\n        .map(AttachmentResponse::from_file)\n        .collect();\n    Ok(ResponseJson(ApiResponse::success(attachment_responses)))\n}\n\npub async fn upload_file(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<SessionScopedQuery>,\n    multipart: Multipart,\n) -> Result<ResponseJson<ApiResponse<AttachmentResponse>>, ApiError> {\n    let attachment_response =\n        process_file_upload(&deployment, multipart, Some(workspace.id)).await?;\n\n    let base_path = resolve_session_base_path(&deployment, &workspace, query.session_id).await?;\n    deployment\n        .file()\n        .copy_files_by_ids_to_worktree(&base_path, &[attachment_response.id])\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(attachment_response)))\n}\n\npub async fn associate_workspace_attachments(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    axum::Json(payload): axum::Json<AssociateWorkspaceAttachmentsRequest>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let managed_workspace = deployment\n        .workspace_manager()\n        .load_managed_workspace(workspace)\n        .await?;\n    managed_workspace\n        .associate_attachments(&payload.attachment_ids)\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub async fn import_issue_attachments(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    axum::Json(payload): axum::Json<ImportIssueAttachmentsRequest>,\n) -> Result<ResponseJson<ApiResponse<ImportIssueAttachmentsResponse>>, ApiError> {\n    let client = deployment.remote_client()?;\n    let imported_attachments =\n        import_issue_attachments_from_remote(&client, deployment.file(), payload.issue_id).await?;\n    let attachment_ids = imported_attachments\n        .iter()\n        .map(|imported| imported.file.id)\n        .collect::<Vec<_>>();\n\n    let managed_workspace = deployment\n        .workspace_manager()\n        .load_managed_workspace(workspace)\n        .await?;\n    managed_workspace\n        .associate_attachments(&attachment_ids)\n        .await?;\n\n    Ok(ResponseJson(ApiResponse::success(\n        ImportIssueAttachmentsResponse { attachment_ids },\n    )))\n}\n\npub async fn get_attachment_metadata(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<AttachmentMetadataQuery>,\n) -> Result<ResponseJson<ApiResponse<AttachmentMetadata>>, ApiError> {\n    let vibe_attachments_prefix = format!(\"{}/\", utils::path::VIBE_ATTACHMENTS_DIR);\n    if !query.path.starts_with(&vibe_attachments_prefix) {\n        return Ok(ResponseJson(ApiResponse::success(AttachmentMetadata {\n            exists: false,\n            file_name: None,\n            path: Some(query.path),\n            size_bytes: None,\n            format: None,\n            proxy_url: None,\n        })));\n    }\n\n    if query.path.contains(\"..\") {\n        return Ok(ResponseJson(ApiResponse::success(AttachmentMetadata {\n            exists: false,\n            file_name: None,\n            path: Some(query.path),\n            size_bytes: None,\n            format: None,\n            proxy_url: None,\n        })));\n    }\n\n    let base_path = resolve_session_base_path(&deployment, &workspace, query.session_id).await?;\n    let file_path = query\n        .path\n        .strip_prefix(&vibe_attachments_prefix)\n        .unwrap_or(\"\");\n    ensure_workspace_attachment_exists(&deployment, &base_path, file_path).await?;\n    let full_path = base_path.join(&query.path);\n\n    let metadata = match tokio::fs::metadata(&full_path).await {\n        Ok(m) if m.is_file() => m,\n        _ => {\n            return Ok(ResponseJson(ApiResponse::success(AttachmentMetadata {\n                exists: false,\n                file_name: None,\n                path: Some(query.path),\n                size_bytes: None,\n                format: None,\n                proxy_url: None,\n            })));\n        }\n    };\n\n    let file_name = Path::new(&query.path)\n        .file_name()\n        .map(|s| s.to_string_lossy().to_string());\n\n    let format = Path::new(&query.path)\n        .extension()\n        .map(|ext| ext.to_string_lossy().to_lowercase());\n\n    let proxy_url = format!(\n        \"/api/workspaces/{}/attachments/file/{}?session_id={}\",\n        workspace.id, file_path, query.session_id\n    );\n\n    Ok(ResponseJson(ApiResponse::success(AttachmentMetadata {\n        exists: true,\n        file_name,\n        path: Some(query.path),\n        size_bytes: Some(metadata.len() as i64),\n        format,\n        proxy_url: Some(proxy_url),\n    })))\n}\n\npub async fn serve_file(\n    axum::extract::Path((_id, path)): axum::extract::Path<(Uuid, String)>,\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<SessionScopedQuery>,\n) -> Result<Response, ApiError> {\n    if path.contains(\"..\") {\n        return Err(ApiError::File(FileError::NotFound));\n    }\n    let base_path = resolve_session_base_path(&deployment, &workspace, query.session_id).await?;\n    ensure_workspace_attachment_exists(&deployment, &base_path, &path).await?;\n    let vibe_attachments_dir = base_path.join(utils::path::VIBE_ATTACHMENTS_DIR);\n    let full_path = vibe_attachments_dir.join(&path);\n\n    let canonical_path = tokio::fs::canonicalize(&full_path)\n        .await\n        .map_err(|_| ApiError::File(FileError::NotFound))?;\n\n    let canonical_vibe_attachments = tokio::fs::canonicalize(&vibe_attachments_dir)\n        .await\n        .map_err(|_| ApiError::File(FileError::NotFound))?;\n\n    if !canonical_path.starts_with(&canonical_vibe_attachments) {\n        return Err(ApiError::File(FileError::NotFound));\n    }\n\n    let file = TokioFile::open(&canonical_path)\n        .await\n        .map_err(|_| ApiError::File(FileError::NotFound))?;\n\n    let metadata = file\n        .metadata()\n        .await\n        .map_err(|_| ApiError::File(FileError::NotFound))?;\n\n    let stream = ReaderStream::new(file);\n    let body = Body::from_stream(stream);\n\n    let content_type = MimeGuess::from_path(&path)\n        .first_raw()\n        .unwrap_or(\"application/octet-stream\");\n    let (content_type, content_disposition) =\n        content_type_and_disposition_for_attachment(content_type);\n\n    let mut response = Response::builder()\n        .status(StatusCode::OK)\n        .header(header::CONTENT_TYPE, content_type)\n        .header(header::CONTENT_LENGTH, metadata.len())\n        .header(header::CACHE_CONTROL, \"public, max-age=31536000\")\n        .header(header::X_CONTENT_TYPE_OPTIONS, \"nosniff\");\n    if let Some(content_disposition) = content_disposition {\n        response = response.header(header::CONTENT_DISPOSITION, content_disposition);\n    }\n    let response = response\n        .body(body)\n        .map_err(|e| ApiError::File(FileError::ResponseBuildError(e.to_string())))?;\n\n    Ok(response)\n}\n\nasync fn ensure_workspace_attachment_exists(\n    deployment: &DeploymentImpl,\n    base_path: &Path,\n    file_path: &str,\n) -> Result<(), ApiError> {\n    let attachment_dir = base_path.join(utils::path::VIBE_ATTACHMENTS_DIR);\n    let full_path = attachment_dir.join(file_path);\n    if full_path.exists() {\n        return Ok(());\n    }\n\n    let Some(file) = File::find_by_file_path(&deployment.db().pool, file_path).await? else {\n        return Err(ApiError::File(FileError::NotFound));\n    };\n\n    deployment\n        .file()\n        .copy_files_by_ids_to_worktree(base_path, &[file.id])\n        .await?;\n\n    Ok(())\n}\n\nasync fn resolve_session_base_path(\n    deployment: &DeploymentImpl,\n    workspace: &Workspace,\n    session_id: Uuid,\n) -> Result<std::path::PathBuf, ApiError> {\n    let session = Session::find_by_id(&deployment.db().pool, session_id)\n        .await?\n        .ok_or_else(|| ApiError::BadRequest(\"Session not found\".to_string()))?;\n\n    if session.workspace_id != workspace.id {\n        return Err(ApiError::BadRequest(\n            \"Session does not belong to workspace\".to_string(),\n        ));\n    }\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(workspace)\n        .await?;\n    let workspace_path = std::path::PathBuf::from(container_ref);\n    let base_path = match session.agent_working_dir.as_deref() {\n        Some(dir) if !dir.is_empty() => workspace_path.join(dir),\n        _ => workspace_path,\n    };\n    Ok(base_path)\n}\n\n/// Middleware to load Workspace for routes with wildcard path params.\nasync fn load_workspace_with_wildcard(\n    State(deployment): State<DeploymentImpl>,\n    axum::extract::Path((id, _path)): axum::extract::Path<(Uuid, String)>,\n    mut request: Request,\n    next: Next,\n) -> Result<Response, StatusCode> {\n    let attempt = match Workspace::find_by_id(&deployment.db().pool, id).await {\n        Ok(Some(a)) => a,\n        Ok(None) => return Err(StatusCode::NOT_FOUND),\n        Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),\n    };\n    request.extensions_mut().insert(attempt);\n    Ok(next.run(request).await)\n}\n\npub(crate) async fn import_issue_attachments_from_remote(\n    client: &RemoteClient,\n    file_service: &FileService,\n    issue_id: Uuid,\n) -> Result<Vec<ImportedIssueAttachment>, ApiError> {\n    let response = client\n        .list_issue_attachments(issue_id)\n        .await\n        .map_err(ApiError::from)?;\n\n    let mut imported_attachments = Vec::new();\n\n    for entry in response.attachments {\n        let Some(file_url) = entry.file_url.as_deref() else {\n            tracing::warn!(\n                \"No file_url for attachment {}, skipping\",\n                entry.attachment.id\n            );\n            continue;\n        };\n\n        let bytes = match client.download_from_url(file_url).await {\n            Ok(bytes) => bytes,\n            Err(error) => {\n                tracing::warn!(\n                    \"Failed to download attachment {}: {}\",\n                    entry.attachment.id,\n                    error\n                );\n                continue;\n            }\n        };\n\n        let file = match file_service\n            .store_file(&bytes, &entry.attachment.original_name)\n            .await\n        {\n            Ok(file) => file,\n            Err(error) => {\n                tracing::warn!(\n                    \"Failed to store imported file '{}': {}\",\n                    entry.attachment.original_name,\n                    error\n                );\n                continue;\n            }\n        };\n\n        imported_attachments.push(ImportedIssueAttachment {\n            attachment_id: entry.attachment.id,\n            file,\n        });\n    }\n\n    Ok(imported_attachments)\n}\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    let metadata_router = Router::new()\n        .route(\"/\", get(get_workspace_files))\n        .route(\"/associate\", post(associate_workspace_attachments))\n        .route(\"/import-issue-attachments\", post(import_issue_attachments))\n        .route(\"/metadata\", get(get_attachment_metadata))\n        .route(\n            \"/upload\",\n            post(upload_file).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),\n        )\n        .layer(from_fn_with_state(\n            deployment.clone(),\n            load_workspace_middleware,\n        ));\n\n    let file_router =\n        Router::new()\n            .route(\"/file/{*path}\", get(serve_file))\n            .layer(from_fn_with_state(\n                deployment.clone(),\n                load_workspace_with_wildcard,\n            ));\n\n    metadata_router.merge(file_router)\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/codex_setup.rs",
    "content": "use db::models::{\n    execution_process::{ExecutionProcess, ExecutionProcessRunReason},\n    session::{CreateSession, Session},\n    workspace::{Workspace, WorkspaceError},\n};\nuse deployment::Deployment;\nuse executors::{\n    actions::{\n        ExecutorAction, ExecutorActionType,\n        script::{ScriptContext, ScriptRequest, ScriptRequestLanguage},\n    },\n    command::{CommandBuilder, apply_overrides},\n    executors::{ExecutorError, codex::Codex},\n};\nuse services::services::container::ContainerService;\nuse uuid::Uuid;\n\nuse crate::error::ApiError;\n\npub async fn run_codex_setup(\n    deployment: &crate::DeploymentImpl,\n    workspace: &Workspace,\n    codex: &Codex,\n) -> Result<ExecutionProcess, ApiError> {\n    let latest_process = ExecutionProcess::find_latest_by_workspace_and_run_reason(\n        &deployment.db().pool,\n        workspace.id,\n        &ExecutionProcessRunReason::CodingAgent,\n    )\n    .await?;\n\n    let executor_action = if let Some(latest_process) = latest_process {\n        let latest_action = latest_process\n            .executor_action()\n            .map_err(|e| ApiError::Workspace(WorkspaceError::ValidationError(e.to_string())))?;\n        get_setup_helper_action(codex)\n            .await?\n            .append_action(latest_action.to_owned())\n    } else {\n        get_setup_helper_action(codex).await?\n    };\n\n    deployment\n        .container()\n        .ensure_container_exists(workspace)\n        .await?;\n\n    // Get or create a session for setup scripts\n    let session =\n        match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? {\n            Some(s) => s,\n            None => {\n                // Create a new session for setup scripts\n                Session::create(\n                    &deployment.db().pool,\n                    &CreateSession {\n                        executor: Some(\"codex\".to_string()),\n                        name: None,\n                    },\n                    Uuid::new_v4(),\n                    workspace.id,\n                )\n                .await?\n            }\n        };\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            workspace,\n            &session,\n            &executor_action,\n            &ExecutionProcessRunReason::SetupScript,\n        )\n        .await?;\n    Ok(execution_process)\n}\n\nasync fn get_setup_helper_action(codex: &Codex) -> Result<ExecutorAction, ApiError> {\n    let mut login_command = CommandBuilder::new(Codex::base_command());\n    login_command = login_command.extend_params([\"login\"]);\n    login_command = apply_overrides(login_command, &codex.cmd)?;\n\n    let (program_path, args) = login_command\n        .build_initial()\n        .map_err(|err| ApiError::Executor(ExecutorError::from(err)))?\n        .into_resolved()\n        .await\n        .map_err(ApiError::Executor)?;\n    let login_script = format!(\"{} {}\", program_path.to_string_lossy(), args.join(\" \"));\n    let login_request = ScriptRequest {\n        script: login_script,\n        language: ScriptRequestLanguage::Bash,\n        context: ScriptContext::ToolInstallScript,\n        working_dir: None,\n    };\n\n    Ok(ExecutorAction::new(\n        ExecutorActionType::ScriptRequest(login_request),\n        None,\n    ))\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/core.rs",
    "content": "use axum::{\n    Extension, Json,\n    extract::{Query, State},\n    http::StatusCode,\n    response::Json as ResponseJson,\n};\nuse db::models::{\n    coding_agent_turn::CodingAgentTurn,\n    execution_process::{ExecutionProcess, ExecutionProcessStatus},\n    workspace::{Workspace, WorkspaceError},\n};\nuse deployment::Deployment;\nuse serde::Deserialize;\nuse services::services::{container::ContainerService, diff_stream, remote_sync};\nuse sqlx::Error as SqlxError;\nuse utils::response::ApiResponse;\nuse workspace_manager::WorkspaceManager;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize)]\npub struct DeleteWorkspaceQuery {\n    #[serde(default)]\n    pub delete_remote: bool,\n    #[serde(default)]\n    pub delete_branches: bool,\n}\n\npub async fn get_workspaces(\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<Workspace>>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let workspaces = Workspace::fetch_all(pool).await?;\n    Ok(ResponseJson(ApiResponse::success(workspaces)))\n}\n\npub async fn get_workspace(\n    Extension(workspace): Extension<Workspace>,\n) -> Result<ResponseJson<ApiResponse<Workspace>>, ApiError> {\n    Ok(ResponseJson(ApiResponse::success(workspace)))\n}\n\npub async fn update_workspace(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<db::models::requests::UpdateWorkspace>,\n) -> Result<ResponseJson<ApiResponse<Workspace>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let is_archiving = request.archived == Some(true) && !workspace.archived;\n\n    Workspace::update(\n        pool,\n        workspace.id,\n        request.archived,\n        request.pinned,\n        request.name.as_deref(),\n    )\n    .await?;\n    let updated = Workspace::find_by_id(pool, workspace.id)\n        .await?\n        .ok_or(WorkspaceError::WorkspaceNotFound)?;\n\n    if (request.archived.is_some() || request.name.is_some())\n        && let Ok(client) = deployment.remote_client()\n    {\n        let ws = updated.clone();\n        let name = request.name.clone();\n        let archived = request.archived;\n        let stats =\n            diff_stream::compute_diff_stats(&deployment.db().pool, deployment.git(), &ws).await;\n        tokio::spawn(async move {\n            remote_sync::sync_workspace_to_remote(\n                &client,\n                ws.id,\n                name.map(Some),\n                archived,\n                stats.as_ref(),\n            )\n            .await;\n        });\n    }\n\n    if is_archiving && let Err(e) = deployment.container().archive_workspace(workspace.id).await {\n        tracing::error!(\"Failed to archive workspace {}: {}\", workspace.id, e);\n    }\n\n    Ok(ResponseJson(ApiResponse::success(updated)))\n}\n\npub async fn get_first_user_message(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Option<String>>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let message = Workspace::get_first_user_message(pool, workspace.id).await?;\n    Ok(ResponseJson(ApiResponse::success(message)))\n}\n\npub async fn delete_workspace(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<DeleteWorkspaceQuery>,\n) -> Result<(StatusCode, ResponseJson<ApiResponse<()>>), ApiError> {\n    let pool = &deployment.db().pool;\n    let workspace_manager = deployment.workspace_manager();\n    let workspace_id = workspace.id;\n\n    if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace_id)\n        .await?\n    {\n        return Err(ApiError::Conflict(\n            \"Cannot delete workspace while processes are running. Stop all processes first.\"\n                .to_string(),\n        ));\n    }\n\n    let dev_servers =\n        ExecutionProcess::find_running_dev_servers_by_workspace(pool, workspace_id).await?;\n\n    for dev_server in dev_servers {\n        tracing::info!(\n            \"Stopping dev server {} before deleting workspace {}\",\n            dev_server.id,\n            workspace_id\n        );\n\n        if let Err(e) = deployment\n            .container()\n            .stop_execution(&dev_server, ExecutionProcessStatus::Killed)\n            .await\n        {\n            tracing::error!(\n                \"Failed to stop dev server {} for workspace {}: {}\",\n                dev_server.id,\n                workspace_id,\n                e\n            );\n        }\n    }\n\n    let managed_workspace = workspace_manager.load_managed_workspace(workspace).await?;\n    let deletion_context = managed_workspace.prepare_deletion_context().await?;\n    let rows_affected = managed_workspace.delete_record().await?;\n\n    if rows_affected == 0 {\n        return Err(ApiError::Database(SqlxError::RowNotFound));\n    }\n\n    deployment\n        .track_if_analytics_allowed(\n            \"workspace_deleted\",\n            serde_json::json!({\n                \"workspace_id\": workspace_id.to_string(),\n            }),\n        )\n        .await;\n\n    if query.delete_remote {\n        if let Ok(client) = deployment.remote_client() {\n            match client.delete_workspace(workspace_id).await {\n                Ok(()) => {\n                    tracing::info!(\"Deleted remote workspace for {}\", workspace_id);\n                }\n                Err(e) => {\n                    tracing::warn!(\n                        \"Failed to delete remote workspace for {}: {}\",\n                        workspace_id,\n                        e\n                    );\n                }\n            }\n        } else {\n            tracing::debug!(\n                \"Remote client not available, skipping remote deletion for {}\",\n                workspace_id\n            );\n        }\n    }\n\n    WorkspaceManager::spawn_workspace_deletion_cleanup(deletion_context, query.delete_branches);\n\n    Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(()))))\n}\n\n#[axum::debug_handler]\npub async fn mark_seen(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let pool = &deployment.db().pool;\n    CodingAgentTurn::mark_seen_by_workspace_id(pool, workspace.id).await?;\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/create.rs",
    "content": "use std::collections::HashMap;\n\nuse axum::{Json, extract::State, response::Json as ResponseJson};\nuse db::models::{\n    requests::{\n        CreateAndStartWorkspaceRequest, CreateAndStartWorkspaceResponse, CreateWorkspaceApiRequest,\n    },\n    workspace::{CreateWorkspace, Workspace},\n};\nuse deployment::Deployment;\nuse services::services::container::ContainerService;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{\n    DeploymentImpl,\n    error::ApiError,\n    routes::workspaces::attachments::{\n        ImportedIssueAttachment, import_issue_attachments_from_remote,\n    },\n};\n\npub(crate) async fn create_workspace_record(\n    deployment: &DeploymentImpl,\n    name: Option<String>,\n) -> Result<Workspace, ApiError> {\n    let workspace_id = Uuid::new_v4();\n    let branch_label = name\n        .as_deref()\n        .filter(|branch_label| !branch_label.is_empty())\n        .unwrap_or(\"workspace\");\n    let git_branch_name = deployment\n        .container()\n        .git_branch_from_workspace(&workspace_id, branch_label)\n        .await;\n\n    let workspace = Workspace::create(\n        &deployment.db().pool,\n        &CreateWorkspace {\n            branch: git_branch_name,\n            name: name.filter(|workspace_name| !workspace_name.is_empty()),\n        },\n        workspace_id,\n    )\n    .await?;\n\n    Ok(workspace)\n}\n\npub async fn create_workspace(\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<CreateWorkspaceApiRequest>,\n) -> Result<ResponseJson<ApiResponse<Workspace>>, ApiError> {\n    let workspace = create_workspace_record(&deployment, payload.name).await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"workspace_created\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(workspace)))\n}\n\nfn normalize_prompt(prompt: &str) -> Option<String> {\n    let trimmed = prompt.trim();\n    if trimmed.is_empty() {\n        None\n    } else {\n        Some(trimmed.to_string())\n    }\n}\n\nfn escape_markdown_label(label: &str) -> String {\n    let mut escaped = String::with_capacity(label.len());\n    for ch in label.chars() {\n        if matches!(ch, '[' | ']' | '\\\\') {\n            escaped.push('\\\\');\n        }\n        escaped.push(ch);\n    }\n    escaped\n}\n\nfn build_workspace_attachment_markdown(\n    file: &ImportedIssueAttachment,\n    label: &str,\n    uses_image_markdown: bool,\n) -> String {\n    let path = format!(\".vibe-attachments/{}\", file.file.file_path);\n    let normalized_label = if label.trim().is_empty() {\n        file.file.original_name.as_str()\n    } else {\n        label\n    };\n    let escaped_label = escape_markdown_label(normalized_label);\n\n    if uses_image_markdown {\n        format!(\"![{}]({})\", escaped_label, path)\n    } else {\n        format!(\"[{}]({})\", escaped_label, path)\n    }\n}\n\nstruct ParsedAttachmentMarkdown<'a> {\n    attachment_id: Uuid,\n    label: &'a str,\n    uses_image_markdown: bool,\n    end: usize,\n}\n\nfn find_unescaped_char(haystack: &str, target: char) -> Option<usize> {\n    let mut escaped = false;\n\n    for (index, ch) in haystack.char_indices() {\n        if escaped {\n            escaped = false;\n            continue;\n        }\n\n        if ch == '\\\\' {\n            escaped = true;\n            continue;\n        }\n\n        if ch == target {\n            return Some(index);\n        }\n    }\n\n    None\n}\n\nfn parse_attachment_markdown_at(\n    prompt: &str,\n    start: usize,\n) -> Option<ParsedAttachmentMarkdown<'_>> {\n    let rest = prompt.get(start..)?;\n    let (uses_image_markdown, label_start_offset) = if rest.starts_with(\"![\") {\n        (true, 2)\n    } else if rest.starts_with('[') {\n        (false, 1)\n    } else {\n        return None;\n    };\n\n    let label_rest = rest.get(label_start_offset..)?;\n    let label_end_offset = find_unescaped_char(label_rest, ']')?;\n    let label = &label_rest[..label_end_offset];\n\n    let after_label = label_rest.get(label_end_offset + 1..)?;\n    let attachment_prefix = \"(attachment://\";\n    if !after_label.starts_with(attachment_prefix) {\n        return None;\n    }\n\n    let attachment_id_start =\n        start + label_start_offset + label_end_offset + 1 + attachment_prefix.len();\n    let attachment_id_rest = prompt.get(attachment_id_start..)?;\n    let attachment_id_end_offset = attachment_id_rest.find(')')?;\n    let attachment_id = Uuid::parse_str(&attachment_id_rest[..attachment_id_end_offset]).ok()?;\n\n    Some(ParsedAttachmentMarkdown {\n        attachment_id,\n        label,\n        uses_image_markdown,\n        end: attachment_id_start + attachment_id_end_offset + 1,\n    })\n}\n\nfn rewrite_imported_issue_attachments_markdown(\n    prompt: &str,\n    imported_attachments: &[ImportedIssueAttachment],\n) -> String {\n    if imported_attachments.is_empty() {\n        return prompt.to_string();\n    }\n\n    let imported_by_attachment_id = imported_attachments\n        .iter()\n        .map(|attachment| (attachment.attachment_id, attachment))\n        .collect::<HashMap<_, _>>();\n    let mut rewritten = String::with_capacity(prompt.len());\n    let mut index = 0;\n\n    while index < prompt.len() {\n        if let Some(parsed) = parse_attachment_markdown_at(prompt, index)\n            && let Some(attachment) = imported_by_attachment_id.get(&parsed.attachment_id)\n        {\n            rewritten.push_str(&build_workspace_attachment_markdown(\n                attachment,\n                parsed.label,\n                parsed.uses_image_markdown,\n            ));\n            index = parsed.end;\n            continue;\n        }\n\n        let Some(ch) = prompt[index..].chars().next() else {\n            break;\n        };\n        rewritten.push(ch);\n        index += ch.len_utf8();\n    }\n\n    rewritten\n}\n\npub async fn create_and_start_workspace(\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<CreateAndStartWorkspaceRequest>,\n) -> Result<ResponseJson<ApiResponse<CreateAndStartWorkspaceResponse>>, ApiError> {\n    let CreateAndStartWorkspaceRequest {\n        name,\n        repos,\n        linked_issue,\n        executor_config,\n        prompt,\n        attachment_ids,\n    } = payload;\n\n    let mut workspace_prompt = normalize_prompt(&prompt).ok_or_else(|| {\n        ApiError::BadRequest(\n            \"A workspace prompt is required. Provide a non-empty `prompt`.\".to_string(),\n        )\n    })?;\n\n    if repos.is_empty() {\n        return Err(ApiError::BadRequest(\n            \"At least one repository is required\".to_string(),\n        ));\n    }\n\n    let mut managed_workspace = deployment\n        .workspace_manager()\n        .load_managed_workspace(create_workspace_record(&deployment, name).await?)\n        .await?;\n\n    for repo in &repos {\n        managed_workspace\n            .add_repository(repo, deployment.git())\n            .await\n            .map_err(ApiError::from)?;\n    }\n\n    if let Some(ids) = &attachment_ids {\n        managed_workspace.associate_attachments(ids).await?;\n    }\n\n    if let Some(linked_issue) = &linked_issue\n        && let Ok(client) = deployment.remote_client()\n    {\n        match import_issue_attachments_from_remote(\n            &client,\n            deployment.file(),\n            linked_issue.issue_id,\n        )\n        .await\n        {\n            Ok(imported_attachments) if !imported_attachments.is_empty() => {\n                let imported_ids = imported_attachments\n                    .iter()\n                    .map(|imported| imported.file.id)\n                    .collect::<Vec<_>>();\n\n                if let Err(e) = managed_workspace.associate_attachments(&imported_ids).await {\n                    tracing::warn!(\"Failed to associate imported files with workspace: {}\", e);\n                }\n\n                workspace_prompt = rewrite_imported_issue_attachments_markdown(\n                    &workspace_prompt,\n                    &imported_attachments,\n                );\n\n                tracing::info!(\n                    \"Imported {} files from issue {}\",\n                    imported_ids.len(),\n                    linked_issue.issue_id\n                );\n            }\n            Ok(_) => {}\n            Err(e) => {\n                tracing::warn!(\n                    \"Failed to import issue attachments for issue {}: {}\",\n                    linked_issue.issue_id,\n                    e\n                );\n            }\n        }\n    }\n\n    let workspace = managed_workspace.workspace.clone();\n    tracing::info!(\"Created workspace {}\", workspace.id);\n\n    let execution_process = deployment\n        .container()\n        .start_workspace(&workspace, executor_config.clone(), workspace_prompt)\n        .await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"workspace_created_and_started\",\n            serde_json::json!({\n                \"executor\": &executor_config.executor,\n                \"variant\": &executor_config.variant,\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(\n        CreateAndStartWorkspaceResponse {\n            workspace,\n            execution_process,\n        },\n    )))\n}\n\n#[cfg(test)]\nmod tests {\n    use chrono::Utc;\n    use db::models::file::File;\n    use uuid::Uuid;\n\n    use super::{ImportedIssueAttachment, rewrite_imported_issue_attachments_markdown};\n\n    fn imported_file(\n        attachment_id: Uuid,\n        original_name: &str,\n        file_path: &str,\n        mime_type: Option<&str>,\n    ) -> ImportedIssueAttachment {\n        ImportedIssueAttachment {\n            attachment_id,\n            file: File {\n                id: Uuid::new_v4(),\n                file_path: file_path.to_string(),\n                original_name: original_name.to_string(),\n                mime_type: mime_type.map(str::to_string),\n                size_bytes: 123,\n                hash: \"hash\".to_string(),\n                created_at: Utc::now(),\n                updated_at: Utc::now(),\n            },\n        }\n    }\n\n    #[test]\n    fn rewrites_imported_non_image_attachment_links() {\n        let attachment_id = Uuid::new_v4();\n        let prompt = format!(\"[proposal.pdf](attachment://{})\", attachment_id);\n        let imported = vec![imported_file(\n            attachment_id,\n            \"proposal.pdf\",\n            \"abc_proposal.pdf\",\n            Some(\"application/pdf\"),\n        )];\n\n        let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported);\n\n        assert_eq!(\n            rewritten,\n            \"[proposal.pdf](.vibe-attachments/abc_proposal.pdf)\"\n        );\n    }\n\n    #[test]\n    fn preserves_authored_image_markdown_for_imported_images() {\n        let attachment_id = Uuid::new_v4();\n        let prompt = format!(\"![diagram.png](attachment://{})\", attachment_id);\n        let imported = vec![imported_file(\n            attachment_id,\n            \"diagram.png\",\n            \"xyz_diagram.png\",\n            Some(\"image/png\"),\n        )];\n\n        let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported);\n\n        assert_eq!(\n            rewritten,\n            \"![diagram.png](.vibe-attachments/xyz_diagram.png)\"\n        );\n    }\n\n    #[test]\n    fn preserves_authored_link_markdown_for_imported_images() {\n        let attachment_id = Uuid::new_v4();\n        let prompt = format!(\"[diagram.png](attachment://{})\", attachment_id);\n        let imported = vec![imported_file(\n            attachment_id,\n            \"diagram.png\",\n            \"xyz_diagram.png\",\n            Some(\"image/png\"),\n        )];\n\n        let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported);\n\n        assert_eq!(\n            rewritten,\n            \"[diagram.png](.vibe-attachments/xyz_diagram.png)\"\n        );\n    }\n\n    #[test]\n    fn preserves_authored_image_markdown_for_imported_non_images() {\n        let attachment_id = Uuid::new_v4();\n        let prompt = format!(\"![proposal.pdf](attachment://{})\", attachment_id);\n        let imported = vec![imported_file(\n            attachment_id,\n            \"proposal.pdf\",\n            \"abc_proposal.pdf\",\n            Some(\"application/pdf\"),\n        )];\n\n        let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported);\n\n        assert_eq!(\n            rewritten,\n            \"![proposal.pdf](.vibe-attachments/abc_proposal.pdf)\"\n        );\n    }\n\n    #[test]\n    fn leaves_unknown_attachment_references_unchanged() {\n        let prompt = format!(\"[proposal.pdf](attachment://{})\", Uuid::new_v4());\n        let imported = vec![imported_file(\n            Uuid::new_v4(),\n            \"proposal.pdf\",\n            \"abc_proposal.pdf\",\n            Some(\"application/pdf\"),\n        )];\n\n        let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported);\n\n        assert_eq!(rewritten, prompt);\n    }\n\n    #[test]\n    fn rewrites_multiple_attachments_and_leaves_other_links_alone() {\n        let image_attachment_id = Uuid::new_v4();\n        let file_attachment_id = Uuid::new_v4();\n        let prompt = format!(\n            \"See [doc.pdf](attachment://{}) and ![shot.png](attachment://{}). https://example.com\",\n            file_attachment_id, image_attachment_id\n        );\n        let imported = vec![\n            imported_file(\n                file_attachment_id,\n                \"doc.pdf\",\n                \"doc_file.pdf\",\n                Some(\"application/pdf\"),\n            ),\n            imported_file(\n                image_attachment_id,\n                \"shot.png\",\n                \"shot_file.png\",\n                Some(\"image/png\"),\n            ),\n        ];\n\n        let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported);\n\n        assert_eq!(\n            rewritten,\n            \"See [doc.pdf](.vibe-attachments/doc_file.pdf) and ![shot.png](.vibe-attachments/shot_file.png). https://example.com\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/cursor_setup.rs",
    "content": "use db::models::{\n    execution_process::{ExecutionProcess, ExecutionProcessRunReason},\n    session::{CreateSession, Session},\n    workspace::{Workspace, WorkspaceError},\n};\nuse deployment::Deployment;\nuse executors::actions::ExecutorAction;\n#[cfg(unix)]\nuse executors::{\n    actions::{\n        ExecutorActionType,\n        script::{ScriptContext, ScriptRequest, ScriptRequestLanguage},\n    },\n    executors::cursor::CursorAgent,\n};\nuse services::services::container::ContainerService;\nuse uuid::Uuid;\n\nuse crate::error::ApiError;\n\npub async fn run_cursor_setup(\n    deployment: &crate::DeploymentImpl,\n    workspace: &Workspace,\n) -> Result<ExecutionProcess, ApiError> {\n    let latest_process = ExecutionProcess::find_latest_by_workspace_and_run_reason(\n        &deployment.db().pool,\n        workspace.id,\n        &ExecutionProcessRunReason::CodingAgent,\n    )\n    .await?;\n\n    let executor_action = if let Some(latest_process) = latest_process {\n        let latest_action = latest_process\n            .executor_action()\n            .map_err(|e| ApiError::Workspace(WorkspaceError::ValidationError(e.to_string())))?;\n        get_setup_helper_action()\n            .await?\n            .append_action(latest_action.to_owned())\n    } else {\n        get_setup_helper_action().await?\n    };\n    deployment\n        .container()\n        .ensure_container_exists(workspace)\n        .await?;\n\n    // Get or create a session for setup scripts\n    let session =\n        match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? {\n            Some(s) => s,\n            None => {\n                Session::create(\n                    &deployment.db().pool,\n                    &CreateSession {\n                        executor: Some(\"cursor\".to_string()),\n                        name: None,\n                    },\n                    Uuid::new_v4(),\n                    workspace.id,\n                )\n                .await?\n            }\n        };\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            workspace,\n            &session,\n            &executor_action,\n            &ExecutionProcessRunReason::SetupScript,\n        )\n        .await?;\n    Ok(execution_process)\n}\n\nasync fn get_setup_helper_action() -> Result<ExecutorAction, ApiError> {\n    #[cfg(unix)]\n    {\n        use shlex::try_quote;\n        use utils::shell::UnixShell;\n        let base_command = CursorAgent::base_command();\n\n        // Install script with PATH setup\n        let mut install_script = format!(\n            r#\"#!/bin/bash\nset -e\nif ! command -v {base_command} &> /dev/null; then\n    echo \"Installing Cursor CLI...\"\n    curl https://cursor.com/install -fsS | bash\n    echo \"Installation complete!\"\nelse\n    echo \"Cursor CLI already installed\"\nfi\"#\n        );\n        let shell = UnixShell::current_shell();\n        if let Some(config_file) = shell.config_file()\n            && let Ok(config_file_str) = try_quote(config_file.to_string_lossy().as_ref())\n        {\n            install_script.push_str(&format!(\n                r#\"\n            echo \"Setting up PATH...\"\n            echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> {config_file_str}\n            \"#\n            ));\n        }\n\n        let install_request = ScriptRequest {\n            script: install_script,\n            language: ScriptRequestLanguage::Bash,\n            context: ScriptContext::ToolInstallScript,\n            working_dir: None,\n        };\n        // Second action (chained): Login\n        let login_script = format!(\n            r#\"#!/bin/bash\nset -e\nexport PATH=\"$HOME/.local/bin:$PATH\"\n{base_command} login\n\"#\n        );\n        let login_request = ScriptRequest {\n            script: login_script,\n            language: ScriptRequestLanguage::Bash,\n            context: ScriptContext::ToolInstallScript,\n            working_dir: None,\n        };\n\n        // Chain them: install → login\n        Ok(ExecutorAction::new(\n            ExecutorActionType::ScriptRequest(install_request),\n            Some(Box::new(ExecutorAction::new(\n                ExecutorActionType::ScriptRequest(login_request),\n                None,\n            ))),\n        ))\n    }\n\n    #[cfg(not(unix))]\n    {\n        use executors::executors::ExecutorError::SetupHelperNotSupported;\n        Err(ApiError::Executor(SetupHelperNotSupported))\n    }\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/execution.rs",
    "content": "use axum::{Extension, Router, extract::State, response::Json as ResponseJson, routing::post};\nuse db::models::{\n    execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus},\n    session::{CreateSession, Session},\n    workspace::Workspace,\n    workspace_repo::WorkspaceRepo,\n};\nuse deployment::Deployment;\nuse executors::actions::{\n    ExecutorAction, ExecutorActionType,\n    script::{ScriptContext, ScriptRequest, ScriptRequestLanguage},\n};\nuse serde::{Deserialize, Serialize};\nuse services::services::container::ContainerService;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum RunScriptError {\n    NoScriptConfigured,\n    ProcessAlreadyRunning,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/dev-server/start\", post(start_dev_server))\n        .route(\"/cleanup\", post(run_cleanup_script))\n        .route(\"/archive\", post(run_archive_script))\n        .route(\"/stop\", post(stop_workspace_execution))\n}\n\n#[axum::debug_handler]\npub async fn start_dev_server(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<ExecutionProcess>>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let existing_dev_servers =\n        match ExecutionProcess::find_running_dev_servers_by_workspace(pool, workspace.id).await {\n            Ok(servers) => servers,\n            Err(e) => {\n                tracing::error!(\n                    \"Failed to find running dev servers for workspace {}: {}\",\n                    workspace.id,\n                    e\n                );\n                return Err(ApiError::Workspace(\n                    db::models::workspace::WorkspaceError::ValidationError(e.to_string()),\n                ));\n            }\n        };\n\n    for dev_server in existing_dev_servers {\n        tracing::info!(\n            \"Stopping existing dev server {} for workspace {}\",\n            dev_server.id,\n            workspace.id\n        );\n\n        if let Err(e) = deployment\n            .container()\n            .stop_execution(&dev_server, ExecutionProcessStatus::Killed)\n            .await\n        {\n            tracing::error!(\"Failed to stop dev server {}: {}\", dev_server.id, e);\n        }\n    }\n\n    let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n    let repos_with_dev_script: Vec<_> = repos\n        .iter()\n        .filter(|r| r.dev_server_script.as_ref().is_some_and(|s| !s.is_empty()))\n        .collect();\n\n    if repos_with_dev_script.is_empty() {\n        return Ok(ResponseJson(ApiResponse::error(\n            \"No dev server script configured for any repository in this workspace\",\n        )));\n    }\n\n    let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? {\n        Some(s) => s,\n        None => {\n            Session::create(\n                pool,\n                &CreateSession {\n                    executor: Some(\"dev-server\".to_string()),\n                    name: None,\n                },\n                Uuid::new_v4(),\n                workspace.id,\n            )\n            .await?\n        }\n    };\n\n    let mut execution_processes = Vec::new();\n    for repo in repos_with_dev_script {\n        let executor_action = ExecutorAction::new(\n            ExecutorActionType::ScriptRequest(ScriptRequest {\n                script: repo.dev_server_script.clone().unwrap(),\n                language: ScriptRequestLanguage::Bash,\n                context: ScriptContext::DevServer,\n                working_dir: Some(repo.name.clone()),\n            }),\n            None,\n        );\n\n        let execution_process = deployment\n            .container()\n            .start_execution(\n                &workspace,\n                &session,\n                &executor_action,\n                &ExecutionProcessRunReason::DevServer,\n            )\n            .await?;\n        execution_processes.push(execution_process);\n    }\n\n    deployment\n        .track_if_analytics_allowed(\n            \"dev_server_started\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(execution_processes)))\n}\n\npub async fn stop_workspace_execution(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    deployment.container().try_stop(&workspace, false).await;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"task_attempt_stopped\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\n#[axum::debug_handler]\npub async fn run_cleanup_script(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<ExecutionProcess, RunScriptError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id)\n        .await?\n    {\n        return Ok(ResponseJson(ApiResponse::error_with_data(\n            RunScriptError::ProcessAlreadyRunning,\n        )));\n    }\n\n    deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n\n    let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n    let executor_action = match deployment.container().cleanup_actions_for_repos(&repos) {\n        Some(action) => action,\n        None => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                RunScriptError::NoScriptConfigured,\n            )));\n        }\n    };\n\n    let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? {\n        Some(s) => s,\n        None => {\n            Session::create(\n                pool,\n                &CreateSession {\n                    executor: None,\n                    name: None,\n                },\n                Uuid::new_v4(),\n                workspace.id,\n            )\n            .await?\n        }\n    };\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            &workspace,\n            &session,\n            &executor_action,\n            &ExecutionProcessRunReason::CleanupScript,\n        )\n        .await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"cleanup_script_executed\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(execution_process)))\n}\n\npub async fn run_archive_script(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<ExecutionProcess, RunScriptError>>, ApiError> {\n    let pool = &deployment.db().pool;\n    if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id)\n        .await?\n    {\n        return Ok(ResponseJson(ApiResponse::error_with_data(\n            RunScriptError::ProcessAlreadyRunning,\n        )));\n    }\n\n    deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n\n    let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n    let executor_action = match deployment.container().archive_actions_for_repos(&repos) {\n        Some(action) => action,\n        None => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                RunScriptError::NoScriptConfigured,\n            )));\n        }\n    };\n    let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? {\n        Some(s) => s,\n        None => {\n            Session::create(\n                pool,\n                &CreateSession {\n                    executor: None,\n                    name: None,\n                },\n                Uuid::new_v4(),\n                workspace.id,\n            )\n            .await?\n        }\n    };\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            &workspace,\n            &session,\n            &executor_action,\n            &ExecutionProcessRunReason::ArchiveScript,\n        )\n        .await?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"archive_script_executed\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(execution_process)))\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/gh_cli_setup.rs",
    "content": "use db::models::{\n    execution_process::{ExecutionProcess, ExecutionProcessRunReason},\n    session::{CreateSession, Session},\n    workspace::Workspace,\n};\nuse deployment::Deployment;\nuse executors::actions::ExecutorAction;\n#[cfg(unix)]\nuse executors::{\n    actions::{\n        ExecutorActionType,\n        script::{ScriptContext, ScriptRequest, ScriptRequestLanguage},\n    },\n    executors::ExecutorError,\n};\nuse serde::{Deserialize, Serialize};\nuse services::services::container::ContainerService;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\nuse crate::error::ApiError;\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[ts(rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum GhCliSetupError {\n    BrewMissing,\n    SetupHelperNotSupported,\n    Other { message: String },\n}\n\npub async fn run_gh_cli_setup(\n    deployment: &crate::DeploymentImpl,\n    workspace: &Workspace,\n) -> Result<ExecutionProcess, ApiError> {\n    let executor_action = get_gh_cli_setup_helper_action().await?;\n\n    deployment\n        .container()\n        .ensure_container_exists(workspace)\n        .await?;\n\n    // Get or create a session for setup scripts\n    let session =\n        match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? {\n            Some(s) => s,\n            None => {\n                Session::create(\n                    &deployment.db().pool,\n                    &CreateSession {\n                        executor: Some(\"gh-cli\".to_string()),\n                        name: None,\n                    },\n                    Uuid::new_v4(),\n                    workspace.id,\n                )\n                .await?\n            }\n        };\n\n    let execution_process = deployment\n        .container()\n        .start_execution(\n            workspace,\n            &session,\n            &executor_action,\n            &ExecutionProcessRunReason::SetupScript,\n        )\n        .await?;\n    Ok(execution_process)\n}\n\nasync fn get_gh_cli_setup_helper_action() -> Result<ExecutorAction, ApiError> {\n    #[cfg(unix)]\n    {\n        use utils::shell::resolve_executable_path;\n\n        if resolve_executable_path(\"brew\").await.is_none() {\n            return Err(ApiError::Executor(ExecutorError::ExecutableNotFound {\n                program: \"brew\".to_string(),\n            }));\n        }\n\n        // Install script\n        let install_script = r#\"#!/bin/bash\nset -e\nif ! command -v gh &> /dev/null; then\n    echo \"Installing GitHub CLI...\"\n    brew install gh\n    echo \"Installation complete!\"\nelse\n    echo \"GitHub CLI already installed\"\nfi\"#\n        .to_string();\n\n        let install_request = ScriptRequest {\n            script: install_script,\n            language: ScriptRequestLanguage::Bash,\n            context: ScriptContext::ToolInstallScript,\n            working_dir: None,\n        };\n\n        // Auth script\n        let auth_script = r#\"#!/bin/bash\nset -e\nexport GH_PROMPT_DISABLED=1\ngh auth login --web --git-protocol https --skip-ssh-key\n\"#\n        .to_string();\n\n        let auth_request = ScriptRequest {\n            script: auth_script,\n            language: ScriptRequestLanguage::Bash,\n            context: ScriptContext::ToolInstallScript,\n            working_dir: None,\n        };\n\n        // Chain them: install → auth\n        Ok(ExecutorAction::new(\n            ExecutorActionType::ScriptRequest(install_request),\n            Some(Box::new(ExecutorAction::new(\n                ExecutorActionType::ScriptRequest(auth_request),\n                None,\n            ))),\n        ))\n    }\n\n    #[cfg(not(unix))]\n    {\n        use executors::executors::ExecutorError::SetupHelperNotSupported;\n        Err(ApiError::Executor(SetupHelperNotSupported))\n    }\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/git.rs",
    "content": "use std::{\n    collections::HashMap,\n    path::{Path, PathBuf},\n};\n\nuse axum::{\n    Extension, Json, Router,\n    extract::State,\n    response::{IntoResponse, Json as ResponseJson},\n    routing::{get, post},\n};\nuse db::models::{\n    merge::{Merge, MergeStatus, PrMerge, PullRequestInfo},\n    repo::{Repo, RepoError},\n    workspace::Workspace,\n    workspace_repo::WorkspaceRepo,\n};\nuse deployment::Deployment;\nuse git::{ConflictOp, GitCliError, GitServiceError};\nuse git2::BranchType;\nuse serde::{Deserialize, Serialize};\nuse services::services::{container::ContainerService, diff_stream, remote_sync};\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse super::streams::{DiffStreamQuery, stream_workspace_diff_ws};\nuse crate::{DeploymentImpl, error::ApiError, routes::relay_ws::SignedWsUpgrade};\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct RebaseWorkspaceRequest {\n    pub repo_id: Uuid,\n    pub old_base_branch: Option<String>,\n    pub new_base_branch: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct AbortConflictsRequest {\n    pub repo_id: Uuid,\n}\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct ContinueRebaseRequest {\n    pub repo_id: Uuid,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum GitOperationError {\n    MergeConflicts {\n        message: String,\n        op: ConflictOp,\n        conflicted_files: Vec<String>,\n        target_branch: String,\n    },\n    RebaseInProgress,\n}\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct MergeWorkspaceRequest {\n    pub repo_id: Uuid,\n}\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct PushWorkspaceRequest {\n    pub repo_id: Uuid,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum PushError {\n    ForcePushRequired,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct BranchStatus {\n    pub commits_behind: Option<usize>,\n    pub commits_ahead: Option<usize>,\n    pub has_uncommitted_changes: Option<bool>,\n    pub head_oid: Option<String>,\n    pub uncommitted_count: Option<usize>,\n    pub untracked_count: Option<usize>,\n    pub target_branch_name: String,\n    pub remote_commits_behind: Option<usize>,\n    pub remote_commits_ahead: Option<usize>,\n    pub merges: Vec<Merge>,\n    pub is_rebase_in_progress: bool,\n    pub conflict_op: Option<ConflictOp>,\n    pub conflicted_files: Vec<String>,\n    pub is_target_remote: bool,\n}\n\n#[derive(Debug, Clone, Serialize, TS)]\npub struct RepoBranchStatus {\n    pub repo_id: Uuid,\n    pub repo_name: String,\n    #[serde(flatten)]\n    pub status: BranchStatus,\n}\n\n#[derive(Deserialize, Debug, TS)]\npub struct ChangeTargetBranchRequest {\n    pub repo_id: Uuid,\n    pub new_target_branch: String,\n}\n\n#[derive(Serialize, Debug, TS)]\npub struct ChangeTargetBranchResponse {\n    pub repo_id: Uuid,\n    pub new_target_branch: String,\n    pub status: (usize, usize),\n}\n\n#[derive(Deserialize, Debug, TS)]\npub struct RenameBranchRequest {\n    pub new_branch_name: String,\n}\n\n#[derive(Serialize, Debug, TS)]\npub struct RenameBranchResponse {\n    pub branch: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum RenameBranchError {\n    EmptyBranchName,\n    InvalidBranchNameFormat,\n    OpenPullRequest,\n    BranchAlreadyExists { repo_name: String },\n    RebaseInProgress { repo_name: String },\n    RenameFailed { repo_name: String, message: String },\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/status\", get(get_workspace_branch_status))\n        .route(\"/diff/ws\", get(stream_diff_ws))\n        .route(\"/merge\", post(merge_workspace))\n        .route(\"/push\", post(push_workspace_branch))\n        .route(\"/push/force\", post(force_push_workspace_branch))\n        .route(\"/rebase\", post(rebase_workspace))\n        .route(\"/rebase/continue\", post(continue_workspace_rebase))\n        .route(\"/conflicts/abort\", post(abort_workspace_conflicts))\n        .route(\"/target-branch\", axum::routing::put(change_target_branch))\n        .route(\"/branch\", axum::routing::put(rename_branch))\n}\n\nasync fn resolve_vibe_kanban_identifier(\n    deployment: &DeploymentImpl,\n    local_workspace_id: Uuid,\n) -> String {\n    if let Ok(client) = deployment.remote_client()\n        && let Ok(remote_ws) = client.get_workspace_by_local_id(local_workspace_id).await\n        && let Some(issue_id) = remote_ws.issue_id\n        && let Ok(issue) = client.get_issue(issue_id).await\n    {\n        if !issue.simple_id.is_empty() {\n            return issue.simple_id;\n        }\n        return issue_id.to_string();\n    }\n    local_workspace_id.to_string()\n}\n\n#[axum::debug_handler]\npub async fn stream_diff_ws(\n    ws: SignedWsUpgrade,\n    query: axum::extract::Query<DiffStreamQuery>,\n    workspace: Extension<Workspace>,\n    deployment: State<DeploymentImpl>,\n) -> impl IntoResponse {\n    stream_workspace_diff_ws(ws, query, workspace, deployment).await\n}\n\n#[axum::debug_handler]\npub async fn merge_workspace(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<MergeWorkspaceRequest>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace_repo =\n        WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n    let repo = Repo::find_by_id(pool, workspace_repo.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let merges = Merge::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id).await?;\n    let has_open_pr = merges\n        .iter()\n        .any(|m| matches!(m, Merge::Pr(pr) if matches!(pr.pr_info.status, MergeStatus::Open)));\n    if has_open_pr {\n        return Err(ApiError::BadRequest(\n            \"Cannot merge directly when a pull request is open for this repository.\".to_string(),\n        ));\n    }\n\n    let target_branch_type = deployment\n        .git()\n        .find_branch_type(&repo.path, &workspace_repo.target_branch)?;\n    if target_branch_type == BranchType::Remote {\n        return Err(ApiError::BadRequest(\n            \"Cannot merge directly into a remote branch. Please create a pull request instead.\"\n                .to_string(),\n        ));\n    }\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_path = Path::new(&container_ref);\n    let worktree_path = workspace_path.join(repo.name);\n\n    let workspace_label = workspace.name.as_deref().unwrap_or(&workspace.branch);\n    let vk_id = resolve_vibe_kanban_identifier(&deployment, workspace.id).await;\n    let commit_message = format!(\"{} (vibe-kanban {})\", workspace_label, vk_id);\n\n    let merge_commit_id = deployment.git().merge_changes(\n        &repo.path,\n        &worktree_path,\n        &workspace.branch,\n        &workspace_repo.target_branch,\n        &commit_message,\n    )?;\n\n    Merge::create_direct(\n        pool,\n        workspace.id,\n        workspace_repo.repo_id,\n        &workspace_repo.target_branch,\n        &merge_commit_id,\n    )\n    .await?;\n\n    if let Ok(client) = deployment.remote_client() {\n        let workspace_id = workspace.id;\n        tokio::spawn(async move {\n            remote_sync::sync_local_workspace_merge_to_remote(&client, workspace_id).await;\n        });\n    }\n\n    if !workspace.pinned\n        && let Err(e) = deployment.container().archive_workspace(workspace.id).await\n    {\n        tracing::error!(\"Failed to archive workspace {}: {}\", workspace.id, e);\n    }\n\n    deployment\n        .track_if_analytics_allowed(\n            \"task_attempt_merged\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub async fn push_workspace_branch(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<PushWorkspaceRequest>,\n) -> Result<ResponseJson<ApiResponse<(), PushError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace_repo =\n        WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n    let repo = Repo::find_by_id(pool, workspace_repo.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_path = Path::new(&container_ref);\n    let worktree_path = workspace_path.join(&repo.name);\n\n    match deployment\n        .git()\n        .push_to_remote(&worktree_path, &workspace.branch, false)\n    {\n        Ok(_) => {\n            if let Ok(client) = deployment.remote_client() {\n                let pool = deployment.db().pool.clone();\n                let git = deployment.git().clone();\n                let mut ws = workspace.clone();\n                ws.container_ref = Some(container_ref.clone());\n                tokio::spawn(async move {\n                    let stats = diff_stream::compute_diff_stats(&pool, &git, &ws).await;\n                    remote_sync::sync_workspace_to_remote(\n                        &client,\n                        ws.id,\n                        None,\n                        None,\n                        stats.as_ref(),\n                    )\n                    .await;\n                });\n            }\n            Ok(ResponseJson(ApiResponse::success(())))\n        }\n        Err(GitServiceError::GitCLI(GitCliError::PushRejected(_))) => Ok(ResponseJson(\n            ApiResponse::error_with_data(PushError::ForcePushRequired),\n        )),\n        Err(e) => Err(ApiError::GitService(e)),\n    }\n}\n\npub async fn force_push_workspace_branch(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<PushWorkspaceRequest>,\n) -> Result<ResponseJson<ApiResponse<(), PushError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace_repo =\n        WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n    let repo = Repo::find_by_id(pool, workspace_repo.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_path = Path::new(&container_ref);\n    let worktree_path = workspace_path.join(&repo.name);\n\n    deployment\n        .git()\n        .push_to_remote(&worktree_path, &workspace.branch, true)?;\n\n    if let Ok(client) = deployment.remote_client() {\n        let pool = deployment.db().pool.clone();\n        let git = deployment.git().clone();\n        let mut ws = workspace.clone();\n        ws.container_ref = Some(container_ref.clone());\n        tokio::spawn(async move {\n            let stats = diff_stream::compute_diff_stats(&pool, &git, &ws).await;\n            remote_sync::sync_workspace_to_remote(&client, ws.id, None, None, stats.as_ref()).await;\n        });\n    }\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub async fn get_workspace_branch_status(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<RepoBranchStatus>>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let repositories = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n    let workspace_repos = WorkspaceRepo::find_by_workspace_id(pool, workspace.id).await?;\n    let target_branches: HashMap<_, _> = workspace_repos\n        .iter()\n        .map(|wr| (wr.repo_id, wr.target_branch.clone()))\n        .collect();\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_dir = PathBuf::from(&container_ref);\n\n    let all_merges = Merge::find_by_workspace_id(pool, workspace.id).await?;\n    let merges_by_repo: HashMap<Uuid, Vec<Merge>> =\n        all_merges\n            .into_iter()\n            .fold(HashMap::new(), |mut acc, merge| {\n                let repo_id = match &merge {\n                    Merge::Direct(dm) => dm.repo_id,\n                    Merge::Pr(pm) => pm.repo_id,\n                };\n                acc.entry(repo_id).or_insert_with(Vec::new).push(merge);\n                acc\n            });\n\n    let mut results = Vec::with_capacity(repositories.len());\n\n    for repo in repositories {\n        let Some(target_branch) = target_branches.get(&repo.id).cloned() else {\n            continue;\n        };\n\n        let repo_merges = merges_by_repo.get(&repo.id).cloned().unwrap_or_default();\n        let worktree_path = workspace_dir.join(&repo.name);\n\n        let head_oid = deployment\n            .git()\n            .get_head_info(&worktree_path)\n            .ok()\n            .map(|h| h.oid);\n\n        let (is_rebase_in_progress, conflicted_files, conflict_op) = {\n            let in_rebase = deployment\n                .git()\n                .is_rebase_in_progress(&worktree_path)\n                .unwrap_or(false);\n            let conflicts = deployment\n                .git()\n                .get_conflicted_files(&worktree_path)\n                .unwrap_or_default();\n            let op = if conflicts.is_empty() {\n                None\n            } else {\n                deployment\n                    .git()\n                    .detect_conflict_op(&worktree_path)\n                    .unwrap_or(None)\n            };\n            (in_rebase, conflicts, op)\n        };\n\n        let (uncommitted_count, untracked_count) =\n            match deployment.git().get_worktree_change_counts(&worktree_path) {\n                Ok((a, b)) => (Some(a), Some(b)),\n                Err(_) => (None, None),\n            };\n\n        let has_uncommitted_changes = uncommitted_count.map(|c| c > 0);\n\n        let target_branch_type = deployment\n            .git()\n            .find_branch_type(&repo.path, &target_branch)?;\n\n        let (commits_ahead, commits_behind) = match target_branch_type {\n            BranchType::Local => {\n                let (a, b) = deployment.git().get_branch_status(\n                    &repo.path,\n                    &workspace.branch,\n                    &target_branch,\n                )?;\n                (Some(a), Some(b))\n            }\n            BranchType::Remote => {\n                let (ahead, behind) = deployment.git().get_remote_branch_status(\n                    &repo.path,\n                    &workspace.branch,\n                    Some(&target_branch),\n                )?;\n                (Some(ahead), Some(behind))\n            }\n        };\n\n        let (remote_ahead, remote_behind) = if let Some(Merge::Pr(PrMerge {\n            pr_info:\n                PullRequestInfo {\n                    status: MergeStatus::Open,\n                    ..\n                },\n            ..\n        })) = repo_merges.first()\n        {\n            match deployment\n                .git()\n                .get_remote_branch_status(&repo.path, &workspace.branch, None)\n            {\n                Ok((ahead, behind)) => (Some(ahead), Some(behind)),\n                Err(_) => (None, None),\n            }\n        } else {\n            (None, None)\n        };\n\n        results.push(RepoBranchStatus {\n            repo_id: repo.id,\n            repo_name: repo.name,\n            status: BranchStatus {\n                commits_ahead,\n                commits_behind,\n                has_uncommitted_changes,\n                head_oid,\n                uncommitted_count,\n                untracked_count,\n                remote_commits_ahead: remote_ahead,\n                remote_commits_behind: remote_behind,\n                merges: repo_merges,\n                target_branch_name: target_branch,\n                is_rebase_in_progress,\n                conflict_op,\n                conflicted_files,\n                is_target_remote: target_branch_type == BranchType::Remote,\n            },\n        });\n    }\n\n    Ok(ResponseJson(ApiResponse::success(results)))\n}\n\n#[axum::debug_handler]\npub async fn change_target_branch(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<ChangeTargetBranchRequest>,\n) -> Result<ResponseJson<ApiResponse<ChangeTargetBranchResponse>>, ApiError> {\n    let repo_id = payload.repo_id;\n    let new_target_branch = payload.new_target_branch;\n    let pool = &deployment.db().pool;\n\n    let repo = Repo::find_by_id(pool, repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    if !deployment\n        .git()\n        .check_branch_exists(&repo.path, &new_target_branch)?\n    {\n        return Ok(ResponseJson(ApiResponse::error(\n            format!(\n                \"Branch '{}' does not exist in repository '{}'\",\n                new_target_branch, repo.name\n            )\n            .as_str(),\n        )));\n    };\n\n    WorkspaceRepo::update_target_branch(pool, workspace.id, repo_id, &new_target_branch).await?;\n\n    let status =\n        deployment\n            .git()\n            .get_branch_status(&repo.path, &workspace.branch, &new_target_branch)?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"task_attempt_target_branch_changed\",\n            serde_json::json!({\n                \"repo_id\": repo_id.to_string(),\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(\n        ChangeTargetBranchResponse {\n            repo_id,\n            new_target_branch,\n            status,\n        },\n    )))\n}\n\n#[axum::debug_handler]\npub async fn rename_branch(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<RenameBranchRequest>,\n) -> Result<ResponseJson<ApiResponse<RenameBranchResponse, RenameBranchError>>, ApiError> {\n    let new_branch_name = payload.new_branch_name.trim();\n\n    if new_branch_name.is_empty() {\n        return Ok(ResponseJson(ApiResponse::error_with_data(\n            RenameBranchError::EmptyBranchName,\n        )));\n    }\n    if !deployment.git().is_branch_name_valid(new_branch_name) {\n        return Ok(ResponseJson(ApiResponse::error_with_data(\n            RenameBranchError::InvalidBranchNameFormat,\n        )));\n    }\n    if new_branch_name == workspace.branch {\n        return Ok(ResponseJson(ApiResponse::success(RenameBranchResponse {\n            branch: workspace.branch.clone(),\n        })));\n    }\n\n    let pool = &deployment.db().pool;\n\n    let merges = Merge::find_by_workspace_id(pool, workspace.id).await?;\n    let has_open_pr = merges.into_iter().any(|merge| {\n        matches!(merge, Merge::Pr(pr_merge) if matches!(pr_merge.pr_info.status, MergeStatus::Open))\n    });\n    if has_open_pr {\n        return Ok(ResponseJson(ApiResponse::error_with_data(\n            RenameBranchError::OpenPullRequest,\n        )));\n    }\n\n    let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_dir = PathBuf::from(&container_ref);\n\n    for repo in &repos {\n        let worktree_path = workspace_dir.join(&repo.name);\n\n        if deployment\n            .git()\n            .check_branch_exists(&repo.path, new_branch_name)?\n        {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                RenameBranchError::BranchAlreadyExists {\n                    repo_name: repo.name.clone(),\n                },\n            )));\n        }\n\n        if deployment.git().is_rebase_in_progress(&worktree_path)? {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                RenameBranchError::RebaseInProgress {\n                    repo_name: repo.name.clone(),\n                },\n            )));\n        }\n    }\n\n    let old_branch = workspace.branch.clone();\n    let mut renamed_repos: Vec<&Repo> = Vec::new();\n\n    for repo in &repos {\n        let worktree_path = workspace_dir.join(&repo.name);\n\n        match deployment.git().rename_local_branch(\n            &worktree_path,\n            &workspace.branch,\n            new_branch_name,\n        ) {\n            Ok(()) => {\n                renamed_repos.push(repo);\n            }\n            Err(e) => {\n                for renamed_repo in &renamed_repos {\n                    let rollback_path = workspace_dir.join(&renamed_repo.name);\n                    if let Err(rollback_err) = deployment.git().rename_local_branch(\n                        &rollback_path,\n                        new_branch_name,\n                        &old_branch,\n                    ) {\n                        tracing::error!(\n                            \"Failed to rollback branch rename in '{}': {}\",\n                            renamed_repo.name,\n                            rollback_err\n                        );\n                    }\n                }\n                return Ok(ResponseJson(ApiResponse::error_with_data(\n                    RenameBranchError::RenameFailed {\n                        repo_name: repo.name.clone(),\n                        message: e.to_string(),\n                    },\n                )));\n            }\n        }\n    }\n\n    db::models::workspace::Workspace::update_branch_name(pool, workspace.id, new_branch_name)\n        .await?;\n    let updated_children_count = WorkspaceRepo::update_target_branch_for_children_of_workspace(\n        pool,\n        workspace.id,\n        &old_branch,\n        new_branch_name,\n    )\n    .await?;\n\n    if updated_children_count > 0 {\n        tracing::info!(\n            \"Updated {} child workspaces to target new branch '{}'\",\n            updated_children_count,\n            new_branch_name\n        );\n    }\n\n    deployment\n        .track_if_analytics_allowed(\n            \"task_attempt_branch_renamed\",\n            serde_json::json!({\n                \"updated_children\": updated_children_count,\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(RenameBranchResponse {\n        branch: new_branch_name.to_string(),\n    })))\n}\n\n#[axum::debug_handler]\npub async fn rebase_workspace(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<RebaseWorkspaceRequest>,\n) -> Result<ResponseJson<ApiResponse<(), GitOperationError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace_repo =\n        WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, payload.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n    let repo = Repo::find_by_id(pool, workspace_repo.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let old_base_branch = payload\n        .old_base_branch\n        .unwrap_or_else(|| workspace_repo.target_branch.clone());\n    let new_base_branch = payload\n        .new_base_branch\n        .unwrap_or_else(|| workspace_repo.target_branch.clone());\n\n    match deployment\n        .git()\n        .check_branch_exists(&repo.path, &new_base_branch)?\n    {\n        true => {\n            WorkspaceRepo::update_target_branch(\n                pool,\n                workspace.id,\n                payload.repo_id,\n                &new_base_branch,\n            )\n            .await?;\n        }\n        false => {\n            return Ok(ResponseJson(ApiResponse::error(\n                format!(\n                    \"Branch '{}' does not exist in the repository\",\n                    new_base_branch\n                )\n                .as_str(),\n            )));\n        }\n    }\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_path = Path::new(&container_ref);\n    let worktree_path = workspace_path.join(&repo.name);\n\n    let result = deployment.git().rebase_branch(\n        &repo.path,\n        &worktree_path,\n        &new_base_branch,\n        &old_base_branch,\n        &workspace.branch.clone(),\n    );\n    if let Err(e) = result {\n        return match e {\n            GitServiceError::MergeConflicts {\n                message,\n                conflicted_files,\n            } => Ok(ResponseJson(\n                ApiResponse::<(), GitOperationError>::error_with_data(\n                    GitOperationError::MergeConflicts {\n                        message,\n                        op: ConflictOp::Rebase,\n                        conflicted_files,\n                        target_branch: new_base_branch.clone(),\n                    },\n                ),\n            )),\n            GitServiceError::RebaseInProgress => Ok(ResponseJson(ApiResponse::<\n                (),\n                GitOperationError,\n            >::error_with_data(\n                GitOperationError::RebaseInProgress,\n            ))),\n            other => Err(ApiError::GitService(other)),\n        };\n    }\n\n    deployment\n        .track_if_analytics_allowed(\n            \"task_attempt_rebased\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n                \"repo_id\": payload.repo_id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\n#[axum::debug_handler]\npub async fn abort_workspace_conflicts(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<AbortConflictsRequest>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let repo = Repo::find_by_id(pool, payload.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_path = Path::new(&container_ref);\n    let worktree_path = workspace_path.join(&repo.name);\n\n    deployment.git().abort_conflicts(&worktree_path)?;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\n#[axum::debug_handler]\npub async fn continue_workspace_rebase(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<ContinueRebaseRequest>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let repo = Repo::find_by_id(pool, payload.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_path = Path::new(&container_ref);\n    let worktree_path = workspace_path.join(&repo.name);\n\n    deployment.git().continue_rebase(&worktree_path)?;\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/integration.rs",
    "content": "use std::path::Path;\n\nuse axum::{\n    Extension, Json, Router, extract::State, response::Json as ResponseJson, routing::post,\n};\nuse db::models::{workspace::Workspace, workspace_repo::WorkspaceRepo};\nuse deployment::Deployment;\nuse executors::{\n    executors::{CodingAgent, ExecutorError},\n    profile::{ExecutorConfigs, ExecutorProfileId},\n};\nuse serde::{Deserialize, Serialize};\nuse services::services::container::ContainerService;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\n\nuse super::{codex_setup, cursor_setup, gh_cli_setup::GhCliSetupError};\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct RunAgentSetupRequest {\n    pub executor_profile_id: ExecutorProfileId,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct RunAgentSetupResponse {}\n\n#[derive(Deserialize, TS)]\npub struct OpenEditorRequest {\n    editor_type: Option<String>,\n    file_path: Option<String>,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct OpenEditorResponse {\n    pub url: Option<String>,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/editor/open\", post(open_workspace_in_editor))\n        .route(\"/agent/setup\", post(run_agent_setup))\n        .route(\"/github/cli/setup\", post(gh_cli_setup_handler))\n}\n\n#[axum::debug_handler]\npub async fn run_agent_setup(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<RunAgentSetupRequest>,\n) -> Result<ResponseJson<ApiResponse<RunAgentSetupResponse>>, ApiError> {\n    let executor_profile_id = payload.executor_profile_id;\n    let config = ExecutorConfigs::get_cached();\n    let coding_agent = config.get_coding_agent_or_default(&executor_profile_id);\n    match coding_agent {\n        CodingAgent::CursorAgent(_) => {\n            cursor_setup::run_cursor_setup(&deployment, &workspace).await?;\n        }\n        CodingAgent::Codex(codex) => {\n            codex_setup::run_codex_setup(&deployment, &workspace, &codex).await?;\n        }\n        _ => return Err(ApiError::Executor(ExecutorError::SetupHelperNotSupported)),\n    }\n\n    deployment\n        .track_if_analytics_allowed(\n            \"agent_setup_script_executed\",\n            serde_json::json!({\n                \"executor_profile_id\": executor_profile_id.to_string(),\n                \"workspace_id\": workspace.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(RunAgentSetupResponse {})))\n}\n\npub async fn open_workspace_in_editor(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<OpenEditorRequest>,\n) -> Result<ResponseJson<ApiResponse<OpenEditorResponse>>, ApiError> {\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    deployment.container().touch(&workspace).await?;\n\n    let workspace_path = Path::new(&container_ref);\n    let workspace_repos =\n        WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, workspace.id).await?;\n    let workspace_path = if workspace_repos.len() == 1 && payload.file_path.is_none() {\n        workspace_path.join(&workspace_repos[0].name)\n    } else {\n        workspace_path.to_path_buf()\n    };\n\n    let path = if let Some(file_path) = payload.file_path.as_ref() {\n        workspace_path.join(file_path)\n    } else {\n        workspace_path\n    };\n\n    let editor_config = {\n        let config = deployment.config().read().await;\n        let editor_type_str = payload.editor_type.as_deref();\n        config.editor.with_override(editor_type_str)\n    };\n\n    match editor_config.open_file(path.as_path()).await {\n        Ok(url) => {\n            tracing::info!(\n                \"Opened editor for workspace {} at path: {}{}\",\n                workspace.id,\n                path.display(),\n                if url.is_some() { \" (remote mode)\" } else { \"\" }\n            );\n\n            deployment\n                .track_if_analytics_allowed(\n                    \"task_attempt_editor_opened\",\n                    serde_json::json!({\n                        \"workspace_id\": workspace.id.to_string(),\n                        \"editor_type\": payload.editor_type.as_ref(),\n                        \"remote_mode\": url.is_some(),\n                    }),\n                )\n                .await;\n\n            Ok(ResponseJson(ApiResponse::success(OpenEditorResponse {\n                url,\n            })))\n        }\n        Err(e) => {\n            tracing::error!(\n                \"Failed to open editor for attempt {}: {:?}\",\n                workspace.id,\n                e\n            );\n            Err(ApiError::EditorOpen(e))\n        }\n    }\n}\n\n#[axum::debug_handler]\npub async fn gh_cli_setup_handler(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<\n    ResponseJson<ApiResponse<db::models::execution_process::ExecutionProcess, GhCliSetupError>>,\n    ApiError,\n> {\n    match super::gh_cli_setup::run_gh_cli_setup(&deployment, &workspace).await {\n        Ok(execution_process) => {\n            deployment\n                .track_if_analytics_allowed(\n                    \"gh_cli_setup_executed\",\n                    serde_json::json!({\n                        \"workspace_id\": workspace.id.to_string(),\n                    }),\n                )\n                .await;\n\n            Ok(ResponseJson(ApiResponse::success(execution_process)))\n        }\n        Err(ApiError::Executor(executors::executors::ExecutorError::ExecutableNotFound {\n            program,\n        })) if program == \"brew\" => Ok(ResponseJson(ApiResponse::error_with_data(\n            GhCliSetupError::BrewMissing,\n        ))),\n        Err(ApiError::Executor(ExecutorError::SetupHelperNotSupported)) => Ok(ResponseJson(\n            ApiResponse::error_with_data(GhCliSetupError::SetupHelperNotSupported),\n        )),\n        Err(ApiError::Executor(err)) => Ok(ResponseJson(ApiResponse::error_with_data(\n            GhCliSetupError::Other {\n                message: err.to_string(),\n            },\n        ))),\n        Err(err) => Err(err),\n    }\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/links.rs",
    "content": "use api_types::{CreateWorkspaceRequest, PullRequestStatus, UpsertPullRequestRequest};\nuse axum::{\n    Extension, Json, Router,\n    extract::{Path as AxumPath, State},\n    middleware::from_fn_with_state,\n    response::Json as ResponseJson,\n    routing::{delete, post},\n};\nuse db::models::{\n    merge::{Merge, MergeStatus},\n    workspace::Workspace,\n};\nuse deployment::Deployment;\nuse serde::Deserialize;\nuse services::services::{diff_stream, remote_client::RemoteClientError, remote_sync};\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError, middleware::load_workspace_middleware};\n\n#[derive(Debug, Deserialize)]\npub struct LinkWorkspaceRequest {\n    pub project_id: Uuid,\n    pub issue_id: Uuid,\n}\n\npub async fn link_workspace(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<LinkWorkspaceRequest>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    let stats =\n        diff_stream::compute_diff_stats(&deployment.db().pool, deployment.git(), &workspace).await;\n\n    client\n        .create_workspace(CreateWorkspaceRequest {\n            project_id: payload.project_id,\n            local_workspace_id: workspace.id,\n            issue_id: payload.issue_id,\n            name: workspace.name.clone(),\n            archived: Some(workspace.archived),\n            files_changed: stats.as_ref().map(|s| s.files_changed as i32),\n            lines_added: stats.as_ref().map(|s| s.lines_added as i32),\n            lines_removed: stats.as_ref().map(|s| s.lines_removed as i32),\n        })\n        .await?;\n\n    {\n        let pool = deployment.db().pool.clone();\n        let ws_id = workspace.id;\n        let client = client.clone();\n        tokio::spawn(async move {\n            let merges = match Merge::find_by_workspace_id(&pool, ws_id).await {\n                Ok(m) => m,\n                Err(e) => {\n                    tracing::error!(\n                        \"Failed to fetch merges for workspace {} during link: {}\",\n                        ws_id,\n                        e\n                    );\n                    return;\n                }\n            };\n            for merge in merges {\n                if let Merge::Pr(pr_merge) = merge {\n                    let pr_status = match pr_merge.pr_info.status {\n                        MergeStatus::Open => PullRequestStatus::Open,\n                        MergeStatus::Merged => PullRequestStatus::Merged,\n                        MergeStatus::Closed => PullRequestStatus::Closed,\n                        MergeStatus::Unknown => continue,\n                    };\n                    remote_sync::sync_pr_to_remote(\n                        &client,\n                        UpsertPullRequestRequest {\n                            url: pr_merge.pr_info.url,\n                            number: pr_merge.pr_info.number as i32,\n                            status: pr_status,\n                            merged_at: pr_merge.pr_info.merged_at,\n                            merge_commit_sha: pr_merge.pr_info.merge_commit_sha,\n                            target_branch_name: pr_merge.target_branch_name,\n                            local_workspace_id: ws_id,\n                        },\n                    )\n                    .await;\n                }\n            }\n        });\n    }\n\n    Ok(ResponseJson(ApiResponse::success(())))\n}\n\npub async fn unlink_workspace(\n    AxumPath(workspace_id): AxumPath<uuid::Uuid>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {\n    let client = deployment.remote_client()?;\n\n    match client.delete_workspace(workspace_id).await {\n        Ok(()) => Ok(ResponseJson(ApiResponse::success(()))),\n        Err(RemoteClientError::Http { status: 404, .. }) => {\n            Ok(ResponseJson(ApiResponse::success(())))\n        }\n        Err(e) => Err(e.into()),\n    }\n}\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    let post_router = Router::new()\n        .route(\"/\", post(link_workspace))\n        .layer(from_fn_with_state(\n            deployment.clone(),\n            load_workspace_middleware,\n        ));\n\n    let delete_router = Router::new().route(\"/\", delete(unlink_workspace));\n\n    post_router.merge(delete_router)\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/mod.rs",
    "content": "pub mod attachments;\npub mod codex_setup;\npub mod core;\npub mod create;\npub mod cursor_setup;\npub mod execution;\npub mod gh_cli_setup;\npub mod git;\npub mod integration;\npub mod links;\npub mod pr;\npub mod repos;\npub mod streams;\npub mod workspace_summary;\n\nuse axum::{\n    Router,\n    middleware::from_fn_with_state,\n    routing::{get, post},\n};\n\nuse crate::{DeploymentImpl, middleware::load_workspace_middleware};\n\npub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {\n    let workspace_id_router = Router::new()\n        .route(\n            \"/\",\n            get(core::get_workspace)\n                .put(core::update_workspace)\n                .delete(core::delete_workspace),\n        )\n        .route(\"/messages/first\", get(core::get_first_user_message))\n        .route(\"/seen\", axum::routing::put(core::mark_seen))\n        .nest(\"/git\", git::router())\n        .nest(\"/execution\", execution::router())\n        .nest(\"/integration\", integration::router())\n        .nest(\"/repos\", repos::router())\n        .nest(\"/pull-requests\", pr::router())\n        .layer(from_fn_with_state(\n            deployment.clone(),\n            load_workspace_middleware,\n        ));\n\n    let workspaces_router = Router::new()\n        .route(\n            \"/\",\n            get(core::get_workspaces).post(create::create_workspace),\n        )\n        .route(\"/start\", post(create::create_and_start_workspace))\n        .route(\"/from-pr\", post(pr::create_workspace_from_pr))\n        .route(\"/streams/ws\", get(streams::stream_workspaces_ws))\n        .route(\n            \"/summaries\",\n            post(workspace_summary::get_workspace_summaries),\n        )\n        .nest(\"/{id}\", workspace_id_router)\n        .nest(\"/{id}/attachments\", attachments::router(deployment))\n        .nest(\"/{id}/links\", links::router(deployment));\n\n    Router::new().nest(\"/workspaces\", workspaces_router)\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/pr.rs",
    "content": "use std::path::PathBuf;\n\nuse api_types::{PullRequestStatus, UpsertPullRequestRequest};\nuse axum::{\n    Extension, Json, Router,\n    extract::{Query, State},\n    response::Json as ResponseJson,\n    routing::{get, post},\n};\nuse db::models::{\n    coding_agent_turn::CodingAgentTurn,\n    execution_process::{ExecutionProcess, ExecutionProcessRunReason},\n    merge::{Merge, MergeStatus},\n    repo::{Repo, RepoError},\n    session::{CreateSession, Session},\n    workspace::{CreateWorkspace, Workspace, WorkspaceError},\n    workspace_repo::{CreateWorkspaceRepo, WorkspaceRepo},\n};\nuse deployment::Deployment;\nuse executors::actions::{\n    ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest,\n    coding_agent_initial::CodingAgentInitialRequest,\n};\nuse git::{GitCliError, GitRemote, GitServiceError};\nuse git_host::{\n    CreatePrRequest, GitHostError, GitHostProvider, GitHostService, ProviderKind, UnifiedPrComment,\n    github::GhCli,\n};\nuse serde::{Deserialize, Serialize};\nuse services::services::{\n    config::DEFAULT_PR_DESCRIPTION_PROMPT, container::ContainerService, remote_sync,\n};\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\nuse workspace_manager::WorkspaceManager;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct CreatePrApiRequest {\n    pub title: String,\n    pub body: Option<String>,\n    pub target_branch: Option<String>,\n    pub draft: Option<bool>,\n    pub repo_id: Uuid,\n    #[serde(default)]\n    pub auto_generate_description: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum PrError {\n    CliNotInstalled { provider: ProviderKind },\n    CliNotLoggedIn { provider: ProviderKind },\n    GitCliNotLoggedIn,\n    GitCliNotInstalled,\n    TargetBranchNotFound { branch: String },\n    UnsupportedProvider,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct AttachPrResponse {\n    pub pr_attached: bool,\n    pub pr_url: Option<String>,\n    pub pr_number: Option<i64>,\n    pub pr_status: Option<MergeStatus>,\n}\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct AttachExistingPrRequest {\n    pub repo_id: Uuid,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct PrCommentsResponse {\n    pub comments: Vec<UnifiedPrComment>,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum GetPrCommentsError {\n    NoPrAttached,\n    CliNotInstalled { provider: ProviderKind },\n    CliNotLoggedIn { provider: ProviderKind },\n}\n\n#[derive(Debug, Deserialize, TS)]\npub struct GetPrCommentsQuery {\n    pub repo_id: Uuid,\n}\n\nasync fn trigger_pr_description_follow_up(\n    deployment: &DeploymentImpl,\n    workspace: &Workspace,\n    pr_number: i64,\n    pr_url: &str,\n) -> Result<(), ApiError> {\n    // Get the custom prompt from config, or use default\n    let config = deployment.config().read().await;\n    let prompt_template = config\n        .pr_auto_description_prompt\n        .as_deref()\n        .unwrap_or(DEFAULT_PR_DESCRIPTION_PROMPT);\n\n    // Replace placeholders in prompt\n    let prompt = prompt_template\n        .replace(\"{pr_number}\", &pr_number.to_string())\n        .replace(\"{pr_url}\", pr_url);\n\n    drop(config); // Release the lock before async operations\n\n    // Get or create a session for this follow-up\n    let session =\n        match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? {\n            Some(s) => s,\n            None => {\n                Session::create(\n                    &deployment.db().pool,\n                    &CreateSession {\n                        executor: None,\n                        name: None,\n                    },\n                    Uuid::new_v4(),\n                    workspace.id,\n                )\n                .await?\n            }\n        };\n\n    // Get executor profile from the latest coding agent process in this session\n    let Some(executor_profile_id) =\n        ExecutionProcess::latest_executor_profile_for_session(&deployment.db().pool, session.id)\n            .await?\n    else {\n        tracing::warn!(\n            \"No executor profile found for session {}, skipping PR description follow-up\",\n            session.id\n        );\n        return Ok(());\n    };\n\n    // Get latest agent turn if one exists (for coding agent continuity)\n    let latest_session_info =\n        CodingAgentTurn::find_latest_session_info(&deployment.db().pool, session.id).await?;\n\n    let working_dir = session\n        .agent_working_dir\n        .as_ref()\n        .filter(|dir| !dir.is_empty())\n        .cloned();\n\n    // Build the action type (follow-up if session exists, otherwise initial)\n    let action_type = if let Some(info) = latest_session_info {\n        ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest {\n            prompt,\n            session_id: info.session_id,\n            reset_to_message_id: None,\n            executor_config: executors::profile::ExecutorConfig::from(executor_profile_id.clone()),\n            working_dir: working_dir.clone(),\n        })\n    } else {\n        ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest {\n            prompt,\n            executor_config: executors::profile::ExecutorConfig::from(executor_profile_id.clone()),\n            working_dir,\n        })\n    };\n\n    let action = ExecutorAction::new(action_type, None);\n\n    deployment\n        .container()\n        .start_execution(\n            workspace,\n            &session,\n            &action,\n            &ExecutionProcessRunReason::CodingAgent,\n        )\n        .await?;\n\n    Ok(())\n}\n\npub async fn create_pr(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<CreatePrApiRequest>,\n) -> Result<ResponseJson<ApiResponse<String, PrError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace_repo =\n        WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n    let repo = Repo::find_by_id(pool, workspace_repo.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let repo_path = repo.path.clone();\n    let target_branch = if let Some(branch) = request.target_branch {\n        branch\n    } else {\n        workspace_repo.target_branch.clone()\n    };\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n    let workspace_path = PathBuf::from(&container_ref);\n    let worktree_path = workspace_path.join(&repo.name);\n\n    let git = deployment.git();\n    let push_remote = git.resolve_remote_for_branch(&repo_path, &workspace.branch)?;\n\n    // Try to get the remote from the branch name (works for remote-tracking branches like \"upstream/main\").\n    // Fall back to push_remote if the branch doesn't exist locally or isn't a remote-tracking branch.\n    let (target_remote, base_branch) =\n        match git.get_remote_from_branch_name(&repo_path, &target_branch) {\n            Ok(remote) => {\n                let branch = target_branch\n                    .strip_prefix(&format!(\"{}/\", remote.name))\n                    .unwrap_or(&target_branch);\n                (remote, branch.to_string())\n            }\n            Err(_) => (push_remote.clone(), target_branch.clone()),\n        };\n\n    match git.check_remote_branch_exists(&repo_path, &target_remote.url, &base_branch) {\n        Ok(false) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::TargetBranchNotFound {\n                    branch: target_branch.clone(),\n                },\n            )));\n        }\n        Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::GitCliNotLoggedIn,\n            )));\n        }\n        Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::GitCliNotInstalled,\n            )));\n        }\n        Err(e) => return Err(ApiError::GitService(e)),\n        Ok(true) => {}\n    }\n\n    if let Err(e) = git.push_to_remote(&worktree_path, &workspace.branch, false) {\n        tracing::error!(\"Failed to push branch to remote: {}\", e);\n        match e {\n            GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => {\n                return Ok(ResponseJson(ApiResponse::error_with_data(\n                    PrError::GitCliNotLoggedIn,\n                )));\n            }\n            GitServiceError::GitCLI(GitCliError::NotAvailable) => {\n                return Ok(ResponseJson(ApiResponse::error_with_data(\n                    PrError::GitCliNotInstalled,\n                )));\n            }\n            _ => return Err(ApiError::GitService(e)),\n        }\n    }\n\n    let git_host = match GitHostService::from_url(&target_remote.url) {\n        Ok(host) => host,\n        Err(GitHostError::UnsupportedProvider) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::UnsupportedProvider,\n            )));\n        }\n        Err(GitHostError::CliNotInstalled { provider }) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::CliNotInstalled { provider },\n            )));\n        }\n        Err(e) => return Err(ApiError::GitHost(e)),\n    };\n\n    let provider = git_host.provider_kind();\n\n    // Create the PR\n    let pr_request = CreatePrRequest {\n        title: request.title.clone(),\n        body: request.body.clone(),\n        head_branch: workspace.branch.clone(),\n        base_branch: base_branch.clone(),\n        draft: request.draft,\n        head_repo_url: Some(push_remote.url.clone()),\n    };\n\n    match git_host\n        .create_pr(&repo_path, &target_remote.url, &pr_request)\n        .await\n    {\n        Ok(pr_info) => {\n            // Update the workspace with PR information\n            if let Err(e) = Merge::create_pr(\n                pool,\n                workspace.id,\n                workspace_repo.repo_id,\n                &base_branch,\n                pr_info.number,\n                &pr_info.url,\n            )\n            .await\n            {\n                tracing::error!(\"Failed to update workspace PR status: {}\", e);\n            }\n\n            if let Ok(client) = deployment.remote_client() {\n                let request = UpsertPullRequestRequest {\n                    url: pr_info.url.clone(),\n                    number: pr_info.number as i32,\n                    status: PullRequestStatus::Open,\n                    merged_at: None,\n                    merge_commit_sha: None,\n                    target_branch_name: base_branch.clone(),\n                    local_workspace_id: workspace.id,\n                };\n                tokio::spawn(async move {\n                    remote_sync::sync_pr_to_remote(&client, request).await;\n                });\n            }\n\n            // Auto-open PR in browser\n            if let Err(e) = utils::browser::open_browser(&pr_info.url).await {\n                tracing::warn!(\"Failed to open PR in browser: {}\", e);\n            }\n\n            deployment\n                .track_if_analytics_allowed(\n                    \"pr_created\",\n                    serde_json::json!({\n                        \"workspace_id\": workspace.id.to_string(),\n                        \"provider\": format!(\"{:?}\", provider),\n                    }),\n                )\n                .await;\n\n            // Trigger auto-description follow-up if enabled\n            if request.auto_generate_description\n                && let Err(e) = trigger_pr_description_follow_up(\n                    &deployment,\n                    &workspace,\n                    pr_info.number,\n                    &pr_info.url,\n                )\n                .await\n            {\n                tracing::warn!(\n                    \"Failed to trigger PR description follow-up for attempt {}: {}\",\n                    workspace.id,\n                    e\n                );\n            }\n\n            Ok(ResponseJson(ApiResponse::success(pr_info.url)))\n        }\n        Err(e) => {\n            tracing::error!(\n                \"Failed to create PR for attempt {} using {:?}: {}\",\n                workspace.id,\n                provider,\n                e\n            );\n            match &e {\n                GitHostError::CliNotInstalled { provider } => Ok(ResponseJson(\n                    ApiResponse::error_with_data(PrError::CliNotInstalled {\n                        provider: *provider,\n                    }),\n                )),\n                GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data(\n                    PrError::CliNotLoggedIn { provider },\n                ))),\n                _ => Err(ApiError::GitHost(e)),\n            }\n        }\n    }\n}\n\npub async fn attach_existing_pr(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<AttachExistingPrRequest>,\n) -> Result<ResponseJson<ApiResponse<AttachPrResponse, PrError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let workspace_repo =\n        WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n    let repo = Repo::find_by_id(pool, workspace_repo.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    // Check if PR already attached for this repo\n    let merges = Merge::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id).await?;\n    if let Some(Merge::Pr(pr_merge)) = merges.into_iter().next() {\n        return Ok(ResponseJson(ApiResponse::success(AttachPrResponse {\n            pr_attached: true,\n            pr_url: Some(pr_merge.pr_info.url.clone()),\n            pr_number: Some(pr_merge.pr_info.number),\n            pr_status: Some(pr_merge.pr_info.status.clone()),\n        })));\n    }\n\n    let git = deployment.git();\n    let remote = git.resolve_remote_for_branch(&repo.path, &workspace_repo.target_branch)?;\n\n    let git_host = match GitHostService::from_url(&remote.url) {\n        Ok(host) => host,\n        Err(GitHostError::UnsupportedProvider) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::UnsupportedProvider,\n            )));\n        }\n        Err(GitHostError::CliNotInstalled { provider }) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::CliNotInstalled { provider },\n            )));\n        }\n        Err(e) => return Err(ApiError::GitHost(e)),\n    };\n\n    let provider = git_host.provider_kind();\n\n    // List all PRs for branch (open, closed, and merged)\n    let prs = match git_host\n        .list_prs_for_branch(&repo.path, &remote.url, &workspace.branch)\n        .await\n    {\n        Ok(prs) => prs,\n        Err(GitHostError::CliNotInstalled { provider }) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::CliNotInstalled { provider },\n            )));\n        }\n        Err(GitHostError::AuthFailed(_)) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                PrError::CliNotLoggedIn { provider },\n            )));\n        }\n        Err(e) => return Err(ApiError::GitHost(e)),\n    };\n\n    // Take the first PR (prefer open, but also accept merged/closed)\n    if let Some(pr_info) = prs.into_iter().next() {\n        // Save PR info to database\n        let merge = Merge::create_pr(\n            pool,\n            workspace.id,\n            workspace_repo.repo_id,\n            &workspace_repo.target_branch,\n            pr_info.number,\n            &pr_info.url,\n        )\n        .await?;\n\n        // Update status if not open\n        if !matches!(pr_info.status, MergeStatus::Open) {\n            Merge::update_status(\n                pool,\n                merge.id,\n                pr_info.status.clone(),\n                pr_info.merge_commit_sha.clone(),\n            )\n            .await?;\n        }\n\n        if let Ok(client) = deployment.remote_client() {\n            let pr_status = match pr_info.status {\n                MergeStatus::Open => PullRequestStatus::Open,\n                MergeStatus::Merged => PullRequestStatus::Merged,\n                MergeStatus::Closed => PullRequestStatus::Closed,\n                MergeStatus::Unknown => PullRequestStatus::Open,\n            };\n            let request = UpsertPullRequestRequest {\n                url: pr_info.url.clone(),\n                number: pr_info.number as i32,\n                status: pr_status,\n                merged_at: None,\n                merge_commit_sha: pr_info.merge_commit_sha.clone(),\n                target_branch_name: workspace_repo.target_branch.clone(),\n                local_workspace_id: workspace.id,\n            };\n            tokio::spawn(async move {\n                remote_sync::sync_pr_to_remote(&client, request).await;\n            });\n        }\n\n        // If PR is merged, archive workspace\n        if matches!(pr_info.status, MergeStatus::Merged) {\n            let open_pr_count = Merge::count_open_prs_for_workspace(pool, workspace.id).await?;\n\n            if open_pr_count == 0 {\n                if !workspace.pinned\n                    && let Err(e) = deployment.container().archive_workspace(workspace.id).await\n                {\n                    tracing::error!(\"Failed to archive workspace {}: {}\", workspace.id, e);\n                }\n            } else {\n                tracing::info!(\n                    \"PR #{} was merged, leaving workspace {} active with {} open PR(s)\",\n                    pr_info.number,\n                    workspace.id,\n                    open_pr_count\n                );\n            }\n        }\n\n        Ok(ResponseJson(ApiResponse::success(AttachPrResponse {\n            pr_attached: true,\n            pr_url: Some(pr_info.url),\n            pr_number: Some(pr_info.number),\n            pr_status: Some(pr_info.status),\n        })))\n    } else {\n        Ok(ResponseJson(ApiResponse::success(AttachPrResponse {\n            pr_attached: false,\n            pr_url: None,\n            pr_number: None,\n            pr_status: None,\n        })))\n    }\n}\n\npub async fn get_pr_comments(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Query(query): Query<GetPrCommentsQuery>,\n) -> Result<ResponseJson<ApiResponse<PrCommentsResponse, GetPrCommentsError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    // Look up the specific repo using the multi-repo pattern\n    let workspace_repo =\n        WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, query.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n    let repo = Repo::find_by_id(pool, workspace_repo.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    // Find the merge/PR for this specific repo\n    let merges = Merge::find_by_workspace_and_repo_id(pool, workspace.id, query.repo_id).await?;\n\n    // Ensure there's an attached PR for this repo\n    let pr_info = match merges.into_iter().next() {\n        Some(Merge::Pr(pr_merge)) => pr_merge.pr_info,\n        _ => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                GetPrCommentsError::NoPrAttached,\n            )));\n        }\n    };\n\n    let git = deployment.git();\n    let remote = git.resolve_remote_for_branch(&repo.path, &workspace_repo.target_branch)?;\n\n    let git_host = match GitHostService::from_url(&remote.url) {\n        Ok(host) => host,\n        Err(GitHostError::CliNotInstalled { provider }) => {\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                GetPrCommentsError::CliNotInstalled { provider },\n            )));\n        }\n        Err(e) => return Err(ApiError::GitHost(e)),\n    };\n\n    let provider = git_host.provider_kind();\n\n    match git_host\n        .get_pr_comments(&repo.path, &remote.url, pr_info.number)\n        .await\n    {\n        Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse {\n            comments,\n        }))),\n        Err(e) => {\n            tracing::error!(\n                \"Failed to fetch PR comments for attempt {}, PR #{}: {}\",\n                workspace.id,\n                pr_info.number,\n                e\n            );\n            match &e {\n                GitHostError::CliNotInstalled { provider } => Ok(ResponseJson(\n                    ApiResponse::error_with_data(GetPrCommentsError::CliNotInstalled {\n                        provider: *provider,\n                    }),\n                )),\n                GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data(\n                    GetPrCommentsError::CliNotLoggedIn { provider },\n                ))),\n                _ => Err(ApiError::GitHost(e)),\n            }\n        }\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct CreateWorkspaceFromPrBody {\n    pub repo_id: Uuid,\n    pub pr_number: i64,\n    pub pr_title: String,\n    pub pr_url: String,\n    pub head_branch: String,\n    pub base_branch: String,\n    pub run_setup: bool,\n    pub remote_name: Option<String>,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct CreateWorkspaceFromPrResponse {\n    pub workspace: Workspace,\n}\n\n#[derive(Debug, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum CreateFromPrError {\n    PrNotFound,\n    BranchFetchFailed { message: String },\n    CliNotInstalled { provider: ProviderKind },\n    AuthFailed { message: String },\n    UnsupportedProvider,\n}\n\n/// Best-effort cleanup of partially-created workspace resources.\n/// Used when workspace creation from PR fails after DB records and filesystem\n/// resources have already been created.\n///\n/// DB records are deleted synchronously (fast). Filesystem cleanup is spawned\n/// as a background task to avoid blocking the error response.\nasync fn cleanup_failed_pr_workspace(pool: &sqlx::SqlitePool, workspace: &Workspace) {\n    let workspace_id = workspace.id;\n\n    // Gather data needed for background filesystem cleanup before deleting DB records\n    let workspace_dir = workspace.container_ref.clone().map(PathBuf::from);\n    let repositories = match WorkspaceRepo::find_repos_for_workspace(pool, workspace_id).await {\n        Ok(repos) => repos,\n        Err(e) => {\n            tracing::warn!(\n                \"Failed to find repos for workspace {} during cleanup: {}\",\n                workspace_id,\n                e\n            );\n            vec![]\n        }\n    };\n\n    // Delete the workspace — FK CASCADE handles workspace_repos, sessions, merges, etc.\n    if let Err(e) = Workspace::delete(pool, workspace_id).await {\n        tracing::warn!(\n            \"Failed to delete workspace {} during cleanup: {}\",\n            workspace_id,\n            e\n        );\n    }\n\n    // Spawn background cleanup for filesystem resources (worktrees, workspace dir)\n    if let Some(workspace_dir) = workspace_dir {\n        tokio::spawn(async move {\n            if let Err(e) = WorkspaceManager::cleanup_workspace(&workspace_dir, &repositories).await\n            {\n                tracing::error!(\n                    \"Background cleanup failed for workspace {} at {}: {}\",\n                    workspace_id,\n                    workspace_dir.display(),\n                    e\n                );\n            }\n        });\n    }\n}\n\n#[axum::debug_handler]\npub async fn create_workspace_from_pr(\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<CreateWorkspaceFromPrBody>,\n) -> Result<ResponseJson<ApiResponse<CreateWorkspaceFromPrResponse, CreateFromPrError>>, ApiError> {\n    let pool = &deployment.db().pool;\n\n    let repo = Repo::find_by_id(pool, payload.repo_id)\n        .await?\n        .ok_or(RepoError::NotFound)?;\n\n    let remote = match payload.remote_name {\n        Some(ref name) => GitRemote {\n            url: deployment.git().get_remote_url(&repo.path, name)?,\n            name: name.clone(),\n        },\n        None => deployment.git().get_default_remote(&repo.path)?,\n    };\n\n    // Use target branch initially - we'll switch to PR branch via gh pr checkout\n    let target_branch_ref = format!(\"{}/{}\", remote.name, payload.base_branch);\n\n    // Create workspace with target branch initially\n    let workspace_id = Uuid::new_v4();\n    let mut workspace = Workspace::create(\n        pool,\n        &CreateWorkspace {\n            branch: target_branch_ref.clone(),\n            name: Some(payload.pr_title.clone()),\n        },\n        workspace_id,\n    )\n    .await?;\n\n    WorkspaceRepo::create_many(\n        pool,\n        workspace.id,\n        &[CreateWorkspaceRepo {\n            repo_id: payload.repo_id,\n            target_branch: target_branch_ref.clone(),\n        }],\n    )\n    .await?;\n\n    let container_ref = deployment\n        .container()\n        .ensure_container_exists(&workspace)\n        .await?;\n\n    // Update workspace with container_ref so start_execution can find it\n    workspace.container_ref = Some(container_ref.clone());\n\n    // Use gh pr checkout to fetch and switch to the PR branch\n    // This handles SSH/HTTPS auth correctly regardless of fork URL format\n    let worktree_path = PathBuf::from(&container_ref).join(&repo.name);\n    match GhCli::new().get_repo_info(&remote.url, &worktree_path) {\n        Ok(repo_info) => {\n            if let Err(e) = GhCli::new().pr_checkout(\n                &worktree_path,\n                &repo_info.owner,\n                &repo_info.repo_name,\n                payload.pr_number,\n            ) {\n                tracing::error!(\"Failed to checkout PR branch: {e}\");\n                cleanup_failed_pr_workspace(pool, &workspace).await;\n                return Ok(ResponseJson(ApiResponse::error_with_data(\n                    CreateFromPrError::BranchFetchFailed {\n                        message: e.to_string(),\n                    },\n                )));\n            }\n            // Update workspace branch to the actual PR branch\n            Workspace::update_branch_name(pool, workspace.id, &payload.head_branch).await?;\n            workspace.branch = payload.head_branch.clone();\n        }\n        Err(e) => {\n            tracing::error!(\n                \"Failed to get repo info for PR checkout (gh CLI may not be installed): {e}\"\n            );\n            cleanup_failed_pr_workspace(pool, &workspace).await;\n            return Ok(ResponseJson(ApiResponse::error_with_data(\n                CreateFromPrError::BranchFetchFailed {\n                    message: format!(\"Failed to get repository info: {e}\"),\n                },\n            )));\n        }\n    }\n\n    Merge::create_pr(\n        pool,\n        workspace.id,\n        payload.repo_id,\n        &format!(\"{}/{}\", remote.name, payload.base_branch),\n        payload.pr_number,\n        &payload.pr_url,\n    )\n    .await?;\n\n    if payload.run_setup {\n        let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n        if let Some(setup_action) = deployment.container().setup_actions_for_repos(&repos) {\n            let session = Session::create(\n                pool,\n                &CreateSession {\n                    executor: None,\n                    name: None,\n                },\n                Uuid::new_v4(),\n                workspace.id,\n            )\n            .await?;\n\n            if let Err(e) = deployment\n                .container()\n                .start_execution(\n                    &workspace,\n                    &session,\n                    &setup_action,\n                    &ExecutionProcessRunReason::SetupScript,\n                )\n                .await\n            {\n                tracing::error!(\"Failed to run setup script: {}\", e);\n            }\n        }\n    }\n\n    deployment\n        .track_if_analytics_allowed(\n            \"workspace_created_from_pr\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n                \"pr_number\": payload.pr_number,\n                \"run_setup\": payload.run_setup,\n            }),\n        )\n        .await;\n\n    tracing::info!(\n        \"Created workspace {} from PR #{}\",\n        workspace.id,\n        payload.pr_number,\n    );\n\n    let workspace = Workspace::find_by_id(pool, workspace.id)\n        .await?\n        .ok_or(WorkspaceError::WorkspaceNotFound)?;\n\n    Ok(ResponseJson(ApiResponse::success(\n        CreateWorkspaceFromPrResponse { workspace },\n    )))\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new()\n        .route(\"/\", post(create_pr))\n        .route(\"/attach\", post(attach_existing_pr))\n        .route(\"/comments\", get(get_pr_comments))\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/repos.rs",
    "content": "use axum::{Extension, Json, Router, extract::State, response::Json as ResponseJson, routing::get};\nuse db::models::{\n    requests::WorkspaceRepoInput,\n    workspace::{Workspace, WorkspaceError},\n    workspace_repo::{RepoWithTargetBranch, WorkspaceRepo},\n};\nuse deployment::Deployment;\nuse serde::{Deserialize, Serialize};\nuse services::services::container::ContainerService;\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct AddWorkspaceRepoRequest {\n    pub repo_id: Uuid,\n    pub target_branch: String,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct AddWorkspaceRepoResponse {\n    pub workspace: Workspace,\n    pub repo: RepoWithTargetBranch,\n}\n\npub fn router() -> Router<DeploymentImpl> {\n    Router::new().route(\"/\", get(get_workspace_repos).post(add_workspace_repo))\n}\n\npub async fn get_workspace_repos(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> Result<ResponseJson<ApiResponse<Vec<RepoWithTargetBranch>>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let repos =\n        WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace.id).await?;\n    Ok(ResponseJson(ApiResponse::success(repos)))\n}\n\n#[axum::debug_handler]\npub async fn add_workspace_repo(\n    Extension(workspace): Extension<Workspace>,\n    State(deployment): State<DeploymentImpl>,\n    Json(payload): Json<AddWorkspaceRepoRequest>,\n) -> Result<ResponseJson<ApiResponse<AddWorkspaceRepoResponse>>, ApiError> {\n    let mut managed_workspace = deployment\n        .workspace_manager()\n        .load_managed_workspace(workspace)\n        .await?;\n\n    let repo_input = WorkspaceRepoInput {\n        repo_id: payload.repo_id,\n        target_branch: payload.target_branch,\n    };\n\n    managed_workspace\n        .add_repository(&repo_input, deployment.git())\n        .await\n        .map_err(ApiError::from)?;\n\n    deployment\n        .container()\n        .ensure_container_exists(&managed_workspace.workspace)\n        .await?;\n\n    let workspace = Workspace::find_by_id(&deployment.db().pool, managed_workspace.workspace.id)\n        .await?\n        .ok_or(WorkspaceError::WorkspaceNotFound)?;\n    let repo = managed_workspace\n        .repos\n        .iter()\n        .find(|repo_with_target| repo_with_target.repo.id == repo_input.repo_id)\n        .cloned()\n        .ok_or_else(|| {\n            ApiError::Conflict(\"Repository already attached to workspace\".to_string())\n        })?;\n\n    deployment\n        .track_if_analytics_allowed(\n            \"task_attempt_repo_added\",\n            serde_json::json!({\n                \"workspace_id\": workspace.id.to_string(),\n                \"repo_id\": repo.repo.id.to_string(),\n            }),\n        )\n        .await;\n\n    Ok(ResponseJson(ApiResponse::success(\n        AddWorkspaceRepoResponse { workspace, repo },\n    )))\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/streams.rs",
    "content": "use axum::{\n    Extension,\n    extract::{Query, State, ws::Message},\n    response::IntoResponse,\n};\nuse deployment::Deployment;\nuse serde::Deserialize;\nuse services::services::container::ContainerService;\n\nuse crate::{\n    DeploymentImpl,\n    routes::relay_ws::{SignedWebSocket, SignedWsUpgrade},\n};\n\n#[derive(Debug, Deserialize)]\npub struct DiffStreamQuery {\n    #[serde(default)]\n    pub stats_only: bool,\n}\n\n#[derive(Debug, Deserialize)]\npub struct WorkspaceStreamQuery {\n    pub archived: Option<bool>,\n    pub limit: Option<i64>,\n}\n\npub async fn stream_workspaces_ws(\n    ws: SignedWsUpgrade,\n    Query(query): Query<WorkspaceStreamQuery>,\n    State(deployment): State<DeploymentImpl>,\n) -> impl IntoResponse {\n    ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_workspaces_ws(socket, deployment, query.archived, query.limit).await\n        {\n            tracing::warn!(\"workspaces WS closed: {}\", e);\n        }\n    })\n}\n\npub async fn stream_workspace_diff_ws(\n    ws: SignedWsUpgrade,\n    Query(params): Query<DiffStreamQuery>,\n    Extension(workspace): Extension<db::models::workspace::Workspace>,\n    State(deployment): State<DeploymentImpl>,\n) -> impl IntoResponse {\n    let _ = deployment.container().touch(&workspace).await;\n    let stats_only = params.stats_only;\n    ws.on_upgrade(move |socket| async move {\n        if let Err(e) = handle_workspace_diff_ws(socket, deployment, workspace, stats_only).await {\n            tracing::warn!(\"diff WS closed: {}\", e);\n        }\n    })\n}\n\nasync fn handle_workspace_diff_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n    workspace: db::models::workspace::Workspace,\n    stats_only: bool,\n) -> anyhow::Result<()> {\n    use futures_util::{StreamExt, TryStreamExt};\n    use utils::log_msg::LogMsg;\n\n    let stream = deployment\n        .container()\n        .stream_diff(&workspace, stats_only)\n        .await?;\n\n    let mut stream = stream.map_ok(|msg: LogMsg| msg.to_ws_message_unchecked());\n\n    loop {\n        tokio::select! {\n            item = stream.next() => {\n                match item {\n                    Some(Ok(msg)) => {\n                        if socket.send(msg).await.is_err() {\n                            break;\n                        }\n                    }\n                    Some(Err(e)) => {\n                        tracing::error!(\"stream error: {}\", e);\n                        break;\n                    }\n                    None => break,\n                }\n            }\n            msg = socket.recv() => {\n                match msg {\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(_) => break,\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\nasync fn handle_workspaces_ws(\n    mut socket: SignedWebSocket,\n    deployment: DeploymentImpl,\n    archived: Option<bool>,\n    limit: Option<i64>,\n) -> anyhow::Result<()> {\n    use futures_util::{StreamExt, TryStreamExt};\n\n    let mut stream = deployment\n        .events()\n        .stream_workspaces_raw(archived, limit)\n        .await?\n        .map_ok(|msg| msg.to_ws_message_unchecked());\n\n    loop {\n        tokio::select! {\n            item = stream.next() => {\n                match item {\n                    Some(Ok(msg)) => {\n                        if socket.send(msg).await.is_err() {\n                            break;\n                        }\n                    }\n                    Some(Err(e)) => {\n                        tracing::error!(\"stream error: {}\", e);\n                        break;\n                    }\n                    None => break,\n                }\n            }\n            msg = socket.recv() => {\n                match msg {\n                    Ok(Some(Message::Close(_))) => break,\n                    Ok(Some(_)) => {}\n                    Ok(None) => break,\n                    Err(_) => break,\n                }\n            }\n        }\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "crates/server/src/routes/workspaces/workspace_summary.rs",
    "content": "use std::collections::HashMap;\n\nuse axum::{Json, extract::State, response::Json as ResponseJson};\nuse db::models::{\n    coding_agent_turn::CodingAgentTurn,\n    execution_process::{ExecutionProcess, ExecutionProcessStatus},\n    merge::{Merge, MergeStatus},\n    workspace::Workspace,\n};\nuse deployment::Deployment;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse utils::response::ApiResponse;\nuse uuid::Uuid;\n\nuse crate::{DeploymentImpl, error::ApiError};\n\n/// Request for fetching workspace summaries\n#[derive(Debug, Deserialize, Serialize, TS)]\npub struct WorkspaceSummaryRequest {\n    pub archived: bool,\n}\n\n/// Summary info for a single workspace\n#[derive(Debug, Serialize, TS)]\npub struct WorkspaceSummary {\n    pub workspace_id: Uuid,\n    /// Session ID of the latest execution process\n    pub latest_session_id: Option<Uuid>,\n    /// Is a tool approval currently pending?\n    pub has_pending_approval: bool,\n    /// Number of files with changes\n    pub files_changed: Option<usize>,\n    /// Total lines added across all files\n    pub lines_added: Option<usize>,\n    /// Total lines removed across all files\n    pub lines_removed: Option<usize>,\n    /// When the latest execution process completed\n    #[ts(optional)]\n    pub latest_process_completed_at: Option<chrono::DateTime<chrono::Utc>>,\n    /// Status of the latest execution process\n    pub latest_process_status: Option<ExecutionProcessStatus>,\n    /// Is a dev server currently running?\n    pub has_running_dev_server: bool,\n    /// Does this workspace have unseen coding agent turns?\n    pub has_unseen_turns: bool,\n    /// PR status for this workspace (if any PR exists)\n    pub pr_status: Option<MergeStatus>,\n    /// PR number for this workspace (if any PR exists)\n    pub pr_number: Option<i64>,\n    /// PR URL for this workspace (if any PR exists)\n    pub pr_url: Option<String>,\n}\n\n/// Response containing summaries for requested workspaces\n#[derive(Debug, Serialize, TS)]\npub struct WorkspaceSummaryResponse {\n    pub summaries: Vec<WorkspaceSummary>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, TS)]\npub struct DiffStats {\n    pub files_changed: usize,\n    pub lines_added: usize,\n    pub lines_removed: usize,\n}\n\n/// Fetch summary information for workspaces filtered by archived status.\n/// This endpoint returns data that cannot be efficiently included in the streaming endpoint.\n#[axum::debug_handler]\npub async fn get_workspace_summaries(\n    State(deployment): State<DeploymentImpl>,\n    Json(request): Json<WorkspaceSummaryRequest>,\n) -> Result<ResponseJson<ApiResponse<WorkspaceSummaryResponse>>, ApiError> {\n    let pool = &deployment.db().pool;\n    let archived = request.archived;\n\n    // 1. Fetch all workspaces with the given archived status\n    let workspaces: Vec<Workspace> = Workspace::find_all_with_status(pool, Some(archived), None)\n        .await?\n        .into_iter()\n        .map(|ws| ws.workspace)\n        .collect();\n\n    if workspaces.is_empty() {\n        return Ok(ResponseJson(ApiResponse::success(\n            WorkspaceSummaryResponse { summaries: vec![] },\n        )));\n    }\n\n    // 2. Fetch latest process info for workspaces with this archived status\n    let latest_processes = ExecutionProcess::find_latest_for_workspaces(pool, archived).await?;\n\n    // 3. Check which workspaces have running dev servers\n    let dev_server_workspaces =\n        ExecutionProcess::find_workspaces_with_running_dev_servers(pool, archived).await?;\n\n    // 4. Check pending approvals for running processes\n    let running_ep_ids: Vec<_> = latest_processes\n        .values()\n        .filter(|info| info.status == ExecutionProcessStatus::Running)\n        .map(|info| info.execution_process_id)\n        .collect();\n    let pending_approval_eps = deployment\n        .approvals()\n        .get_pending_execution_process_ids(&running_ep_ids);\n\n    // 5. Check which workspaces have unseen coding agent turns\n    let unseen_workspaces = CodingAgentTurn::find_workspaces_with_unseen(pool, archived).await?;\n\n    // 6. Get PR status for each workspace\n    let pr_statuses = Merge::get_latest_pr_status_for_workspaces(pool, archived).await?;\n\n    // 7. Compute diff stats for each workspace (in parallel)\n    let diff_futures: Vec<_> = workspaces\n        .iter()\n        .map(|ws| {\n            let workspace = ws.clone();\n            let deployment = deployment.clone();\n            async move {\n                if workspace.container_ref.is_some() {\n                    compute_workspace_diff_stats(&deployment, &workspace)\n                        .await\n                        .map(|stats| (workspace.id, stats))\n                } else {\n                    None\n                }\n            }\n        })\n        .collect();\n\n    let diff_results: Vec<Option<(Uuid, DiffStats)>> =\n        futures_util::future::join_all(diff_futures).await;\n    let diff_stats: HashMap<Uuid, DiffStats> = diff_results.into_iter().flatten().collect();\n\n    // 8. Assemble response\n    let summaries: Vec<WorkspaceSummary> = workspaces\n        .iter()\n        .map(|ws| {\n            let id = ws.id;\n            let latest = latest_processes.get(&id);\n            let has_pending = latest\n                .map(|p| pending_approval_eps.contains(&p.execution_process_id))\n                .unwrap_or(false);\n            let stats = diff_stats.get(&id);\n\n            WorkspaceSummary {\n                workspace_id: id,\n                latest_session_id: latest.map(|p| p.session_id),\n                has_pending_approval: has_pending,\n                files_changed: stats.map(|s| s.files_changed),\n                lines_added: stats.map(|s| s.lines_added),\n                lines_removed: stats.map(|s| s.lines_removed),\n                latest_process_completed_at: latest.and_then(|p| p.completed_at),\n                latest_process_status: latest.map(|p| p.status.clone()),\n                has_running_dev_server: dev_server_workspaces.contains(&id),\n                has_unseen_turns: unseen_workspaces.contains(&id),\n                pr_status: pr_statuses.get(&id).map(|pr| pr.pr_info.status.clone()),\n                pr_number: pr_statuses.get(&id).map(|pr| pr.pr_info.number),\n                pr_url: pr_statuses.get(&id).map(|pr| pr.pr_info.url.clone()),\n            }\n        })\n        .collect();\n\n    Ok(ResponseJson(ApiResponse::success(\n        WorkspaceSummaryResponse { summaries },\n    )))\n}\n\n/// Compute diff stats for a workspace.\npub async fn compute_workspace_diff_stats(\n    deployment: &DeploymentImpl,\n    workspace: &Workspace,\n) -> Option<DiffStats> {\n    let stats = services::services::diff_stream::compute_diff_stats(\n        &deployment.db().pool,\n        deployment.git(),\n        workspace,\n    )\n    .await?;\n\n    Some(DiffStats {\n        files_changed: stats.files_changed,\n        lines_added: stats.lines_added,\n        lines_removed: stats.lines_removed,\n    })\n}\n"
  },
  {
    "path": "crates/server/src/startup.rs",
    "content": "use std::{\n    collections::HashSet,\n    fs, io,\n    path::{Path, PathBuf},\n};\n\nuse deployment::{Deployment, DeploymentError};\nuse services::services::container::ContainerService;\nuse tokio_util::sync::CancellationToken;\nuse utils::assets::asset_dir;\n\nuse crate::{DeploymentImpl, tunnel};\n\n/// A running server instance. Callers can read the port, then call `serve()`\n/// to run the server until the shutdown token is cancelled.\npub struct ServerHandle {\n    pub port: u16,\n    pub proxy_port: u16,\n    pub deployment: DeploymentImpl,\n    shutdown_token: CancellationToken,\n    main_listener: tokio::net::TcpListener,\n    proxy_listener: tokio::net::TcpListener,\n}\n\nimpl ServerHandle {\n    /// The base URL the main server is listening on.\n    ///\n    /// Uses `localhost` rather than `127.0.0.1` so that macOS ATS\n    /// (App Transport Security) exception domains apply correctly in\n    /// the Tauri desktop app — IP address literals aren't reliably\n    /// matched by ATS, which causes WebSocket connections to fail.\n    pub fn url(&self) -> String {\n        format!(\"http://localhost:{}\", self.port)\n    }\n\n    /// Run both the main and proxy servers until the shutdown token is cancelled.\n    pub async fn serve(self) -> anyhow::Result<()> {\n        // Start relay tunnel so the host registers with the relay server.\n        // This must happen after the port is known (it's needed for local\n        // proxying) and is shared between the standalone binary and Tauri.\n        self.deployment.server_info().set_port(self.port).await;\n        self.deployment\n            .server_info()\n            .set_bind_ip(self.main_listener.local_addr()?.ip())\n            .await;\n        let relay_host_name = {\n            let config = self.deployment.config().read().await;\n            tunnel::effective_relay_host_name(&config, self.deployment.user_id())\n        };\n        self.deployment\n            .server_info()\n            .set_hostname(relay_host_name)\n            .await;\n        tunnel::spawn_relay(&self.deployment).await;\n\n        let app_router = crate::routes::router(self.deployment.clone());\n        let proxy_router: axum::Router = crate::preview_proxy::router();\n\n        let main_shutdown = self.shutdown_token.clone();\n        let proxy_shutdown = self.shutdown_token.clone();\n\n        let main_server = axum::serve(self.main_listener, app_router)\n            .with_graceful_shutdown(async move { main_shutdown.cancelled().await });\n        let proxy_server = axum::serve(self.proxy_listener, proxy_router)\n            .with_graceful_shutdown(async move { proxy_shutdown.cancelled().await });\n\n        let main_handle = tokio::spawn(async move {\n            if let Err(e) = main_server.await {\n                tracing::error!(\"Main server error: {}\", e);\n            }\n        });\n        let proxy_handle = tokio::spawn(async move {\n            if let Err(e) = proxy_server.await {\n                tracing::error!(\"Preview proxy error: {}\", e);\n            }\n        });\n\n        tokio::select! {\n            _ = main_handle => {}\n            _ = proxy_handle => {}\n        }\n\n        perform_cleanup_actions(&self.deployment).await;\n        Ok(())\n    }\n\n    /// Return a clone of the shutdown token. Cancel it to stop `serve()`.\n    pub fn shutdown_token(&self) -> CancellationToken {\n        self.shutdown_token.clone()\n    }\n}\n\n/// Initialize the deployment, bind listeners on `localhost` with OS-assigned\n/// ports, and return a handle that is ready to serve.\n///\n/// Uses `localhost` rather than `127.0.0.1` so the bind address matches\n/// the hostname the frontend connects to. On modern macOS, `localhost`\n/// resolves to `::1` (IPv6) first — binding to `127.0.0.1` (IPv4) while\n/// the browser connects via `::1` causes \"connection refused\".\npub async fn start() -> anyhow::Result<ServerHandle> {\n    start_with_bind(\"localhost:0\", \"localhost:0\").await\n}\n\n/// Like [`start`], but lets the caller specify the bind addresses for the main\n/// server and the preview proxy (e.g. `\"0.0.0.0:8080\"`).\npub async fn start_with_bind(main_addr: &str, proxy_addr: &str) -> anyhow::Result<ServerHandle> {\n    let deployment = initialize_deployment().await?;\n\n    let listener = tokio::net::TcpListener::bind(main_addr).await?;\n    let port = listener.local_addr()?.port();\n\n    let proxy_listener = tokio::net::TcpListener::bind(proxy_addr).await?;\n    let proxy_port = proxy_listener.local_addr()?.port();\n    crate::preview_proxy::set_proxy_port(proxy_port);\n\n    tracing::info!(\"Server on :{port}, Preview proxy on :{proxy_port}\");\n\n    Ok(ServerHandle {\n        port,\n        proxy_port,\n        deployment,\n        shutdown_token: CancellationToken::new(),\n        main_listener: listener,\n        proxy_listener,\n    })\n}\n\n/// Initialize the deployment: create asset directory, run migrations, backfill data,\n/// and pre-warm caches. Shared between the standalone server and the Tauri app.\npub async fn initialize_deployment() -> Result<DeploymentImpl, DeploymentError> {\n    // Create asset directory if it doesn't exist\n    if !asset_dir().exists() {\n        std::fs::create_dir_all(asset_dir()).map_err(|e| {\n            DeploymentError::Other(anyhow::anyhow!(\"Failed to create asset directory: {}\", e))\n        })?;\n    }\n\n    // Copy old database to new location for safe downgrades\n    let old_db = asset_dir().join(\"db.sqlite\");\n    let new_db = asset_dir().join(\"db.v2.sqlite\");\n    if !new_db.exists() && old_db.exists() {\n        tracing::info!(\n            \"Copying database to new location: {:?} -> {:?}\",\n            old_db,\n            new_db\n        );\n        std::fs::copy(&old_db, &new_db).expect(\"Failed to copy database file\");\n        tracing::info!(\"Database copy complete\");\n    }\n\n    let deployment = DeploymentImpl::new().await?;\n    migrate_legacy_attachment_directories(&deployment).await?;\n    deployment.update_sentry_scope().await?;\n    deployment\n        .container()\n        .cleanup_orphan_executions()\n        .await\n        .map_err(DeploymentError::from)?;\n    deployment\n        .container()\n        .backfill_before_head_commits()\n        .await\n        .map_err(DeploymentError::from)?;\n    deployment\n        .container()\n        .backfill_repo_names()\n        .await\n        .map_err(DeploymentError::from)?;\n    deployment\n        .track_if_analytics_allowed(\"session_start\", serde_json::json!({}))\n        .await;\n\n    // Preload global executor options cache for all executors with DEFAULT presets\n    tokio::spawn(async move {\n        executors::executors::utils::preload_global_executor_options_cache().await;\n    });\n\n    Ok(deployment)\n}\n\n/// Gracefully shut down running execution processes.\npub async fn perform_cleanup_actions(deployment: &DeploymentImpl) {\n    deployment\n        .container()\n        .kill_all_running_processes()\n        .await\n        .expect(\"Failed to cleanly kill running execution processes\");\n}\n\nconst LEGACY_ATTACHMENT_MIGRATION_MARKER: &str = \".attachment-directories-migrated-v1\";\n\n#[derive(Default)]\nstruct DirectoryMigrationStats {\n    moved_files: u64,\n    removed_duplicates: u64,\n    created_directories: u64,\n    failures: u64,\n}\n\nimpl DirectoryMigrationStats {\n    fn merge(&mut self, other: DirectoryMigrationStats) {\n        self.moved_files += other.moved_files;\n        self.removed_duplicates += other.removed_duplicates;\n        self.created_directories += other.created_directories;\n        self.failures += other.failures;\n    }\n}\n\nasync fn migrate_legacy_attachment_directories(\n    deployment: &DeploymentImpl,\n) -> Result<(), DeploymentError> {\n    let marker_path = asset_dir().join(LEGACY_ATTACHMENT_MIGRATION_MARKER);\n    if marker_path.exists() {\n        return Ok(());\n    }\n\n    let mut stats = DirectoryMigrationStats::default();\n\n    let cache_root = utils::cache_dir();\n    stats.merge(migrate_legacy_directory(\n        &cache_root.join(\"images\"),\n        &cache_root.join(\"attachments\"),\n        false,\n    ));\n\n    for base_path in collect_attachment_migration_paths(deployment).await? {\n        stats.merge(migrate_legacy_directory(\n            &base_path.join(\".vibe-images\"),\n            &base_path.join(utils::path::VIBE_ATTACHMENTS_DIR),\n            true,\n        ));\n    }\n\n    if stats.failures == 0 {\n        fs::write(&marker_path, b\"ok\")?;\n        tracing::info!(\n            \"Legacy attachment directory migration completed: moved {}, removed duplicates {}, created directories {}\",\n            stats.moved_files,\n            stats.removed_duplicates,\n            stats.created_directories\n        );\n    } else {\n        tracing::warn!(\n            \"Legacy attachment directory migration completed with {} failures; will retry on next startup\",\n            stats.failures\n        );\n    }\n\n    Ok(())\n}\n\nasync fn collect_attachment_migration_paths(\n    deployment: &DeploymentImpl,\n) -> Result<Vec<PathBuf>, DeploymentError> {\n    use db::models::{session::Session, workspace::Workspace, workspace_repo::WorkspaceRepo};\n\n    let workspaces = Workspace::fetch_all(&deployment.db().pool).await?;\n    let mut paths = HashSet::new();\n\n    for workspace in workspaces {\n        let Some(container_ref) = workspace.container_ref.as_deref() else {\n            continue;\n        };\n        if container_ref.is_empty() {\n            continue;\n        }\n\n        let workspace_root = PathBuf::from(container_ref);\n        paths.insert(workspace_root.clone());\n\n        for repo in\n            WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, workspace.id).await?\n        {\n            let repo_base = match repo.default_working_dir.as_deref() {\n                Some(default_dir) if !default_dir.is_empty() => {\n                    workspace_root.join(&repo.name).join(default_dir)\n                }\n                _ => workspace_root.join(&repo.name),\n            };\n            paths.insert(repo_base);\n        }\n\n        for session in Session::find_by_workspace_id(&deployment.db().pool, workspace.id).await? {\n            let base_path = match session.agent_working_dir.as_deref() {\n                Some(dir) if !dir.is_empty() => workspace_root.join(dir),\n                _ => workspace_root.clone(),\n            };\n            paths.insert(base_path);\n        }\n    }\n\n    let mut paths = paths.into_iter().collect::<Vec<_>>();\n    paths.sort();\n    Ok(paths)\n}\n\nfn migrate_legacy_directory(\n    src_dir: &Path,\n    dst_dir: &Path,\n    ensure_gitignore: bool,\n) -> DirectoryMigrationStats {\n    let mut stats = DirectoryMigrationStats::default();\n\n    if !src_dir.exists() {\n        return stats;\n    }\n\n    if let Err(error) = fs::create_dir_all(dst_dir) {\n        tracing::warn!(\n            \"Failed to create attachment directory {}: {}\",\n            dst_dir.display(),\n            error\n        );\n        stats.failures += 1;\n        return stats;\n    }\n    stats.created_directories += 1;\n\n    if let Err(error) = migrate_directory_contents(src_dir, dst_dir, ensure_gitignore, &mut stats) {\n        tracing::warn!(\n            \"Failed to migrate legacy attachment directory {} -> {}: {}\",\n            src_dir.display(),\n            dst_dir.display(),\n            error\n        );\n        stats.failures += 1;\n    }\n\n    if ensure_gitignore && let Err(error) = ensure_attachments_gitignore(dst_dir) {\n        tracing::warn!(\n            \"Failed to ensure .gitignore in {}: {}\",\n            dst_dir.display(),\n            error\n        );\n        stats.failures += 1;\n    }\n\n    if let Err(error) = remove_empty_dir_tree(src_dir) {\n        tracing::warn!(\n            \"Failed to clean up legacy attachment directory {}: {}\",\n            src_dir.display(),\n            error\n        );\n        stats.failures += 1;\n    }\n\n    stats\n}\n\nfn migrate_directory_contents(\n    src_dir: &Path,\n    dst_dir: &Path,\n    ensure_gitignore: bool,\n    stats: &mut DirectoryMigrationStats,\n) -> io::Result<()> {\n    for entry in fs::read_dir(src_dir)? {\n        let entry = entry?;\n        let src_path = entry.path();\n        let file_name = entry.file_name();\n\n        if ensure_gitignore && file_name == \".gitignore\" {\n            continue;\n        }\n\n        let dst_path = dst_dir.join(&file_name);\n        let file_type = entry.file_type()?;\n\n        if file_type.is_dir() {\n            fs::create_dir_all(&dst_path)?;\n            migrate_directory_contents(&src_path, &dst_path, false, stats)?;\n            remove_empty_dir_tree(&src_path)?;\n            continue;\n        }\n\n        if dst_path.exists() {\n            fs::remove_file(&src_path)?;\n            stats.removed_duplicates += 1;\n            continue;\n        }\n\n        move_path(&src_path, &dst_path)?;\n        stats.moved_files += 1;\n    }\n\n    Ok(())\n}\n\nfn move_path(src_path: &Path, dst_path: &Path) -> io::Result<()> {\n    match fs::rename(src_path, dst_path) {\n        Ok(()) => Ok(()),\n        Err(error) if error.kind() == io::ErrorKind::CrossesDevices => {\n            fs::copy(src_path, dst_path)?;\n            fs::remove_file(src_path)\n        }\n        Err(error) => Err(error),\n    }\n}\n\nfn ensure_attachments_gitignore(dir: &Path) -> io::Result<()> {\n    let gitignore_path = dir.join(\".gitignore\");\n    if !gitignore_path.exists() {\n        fs::write(gitignore_path, \"*\\n\")?;\n    }\n    Ok(())\n}\n\nfn remove_empty_dir_tree(path: &Path) -> io::Result<()> {\n    if !path.exists() {\n        return Ok(());\n    }\n\n    if fs::read_dir(path)?.next().is_none() {\n        fs::remove_dir(path)?;\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n\n    use super::*;\n\n    #[test]\n    fn migrates_legacy_cache_directory_contents() {\n        let temp_dir = TempDir::new().unwrap();\n        let src = temp_dir.path().join(\"images\");\n        let dst = temp_dir.path().join(\"attachments\");\n        fs::create_dir_all(&src).unwrap();\n        fs::write(src.join(\"asset.png\"), b\"hello\").unwrap();\n\n        let stats = migrate_legacy_directory(&src, &dst, false);\n\n        assert_eq!(stats.moved_files, 1);\n        assert!(dst.join(\"asset.png\").exists());\n        assert!(!src.exists());\n    }\n\n    #[test]\n    fn removes_legacy_duplicates_when_destination_exists() {\n        let temp_dir = TempDir::new().unwrap();\n        let src = temp_dir.path().join(\".vibe-images\");\n        let dst = temp_dir.path().join(\".vibe-attachments\");\n        fs::create_dir_all(&src).unwrap();\n        fs::create_dir_all(&dst).unwrap();\n        fs::write(src.join(\"asset.png\"), b\"old\").unwrap();\n        fs::write(dst.join(\"asset.png\"), b\"new\").unwrap();\n\n        let stats = migrate_legacy_directory(&src, &dst, true);\n\n        assert_eq!(stats.removed_duplicates, 1);\n        assert_eq!(fs::read(dst.join(\"asset.png\")).unwrap(), b\"new\");\n        assert!(!src.exists());\n    }\n\n    #[test]\n    fn ensures_gitignore_for_workspace_attachment_dir() {\n        let temp_dir = TempDir::new().unwrap();\n        let src = temp_dir.path().join(\".vibe-images\");\n        let dst = temp_dir.path().join(\".vibe-attachments\");\n        fs::create_dir_all(&src).unwrap();\n        fs::write(src.join(\"file.pdf\"), b\"attachment\").unwrap();\n\n        migrate_legacy_directory(&src, &dst, true);\n\n        assert_eq!(fs::read_to_string(dst.join(\".gitignore\")).unwrap(), \"*\\n\");\n        assert!(dst.join(\"file.pdf\").exists());\n    }\n}\n"
  },
  {
    "path": "crates/server/src/tunnel.rs",
    "content": "//! Relay client bootstrap for remote access to the local server.\n//!\n//! App-specific concerns (login, host lifecycle) stay here. The transport and\n//! muxing implementation lives in the `relay-tunnel` crate.\n\nuse std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};\n\nuse anyhow::Context as _;\nuse deployment::Deployment as _;\nuse relay_tunnel::client::{RelayClientConfig, start_relay_client};\nuse services::services::{config::Config, remote_client::RemoteClient};\n\nuse crate::DeploymentImpl;\n\nconst RELAY_RECONNECT_INITIAL_DELAY_SECS: u64 = 1;\nconst RELAY_RECONNECT_MAX_DELAY_SECS: u64 = 30;\n\npub fn default_relay_host_name(user_id: &str) -> String {\n    let os_type = os_info::get().os_type().to_string();\n    format!(\"{os_type} host ({user_id})\")\n}\n\npub fn effective_relay_host_name(config: &Config, user_id: &str) -> String {\n    config\n        .relay_host_name\n        .as_deref()\n        .map(str::trim)\n        .filter(|name| !name.is_empty())\n        .map(str::to_string)\n        .unwrap_or_else(|| default_relay_host_name(user_id))\n}\n\nfn relay_api_base() -> Option<String> {\n    std::env::var(\"VK_SHARED_RELAY_API_BASE\")\n        .ok()\n        .or_else(|| option_env!(\"VK_SHARED_RELAY_API_BASE\").map(|s| s.to_string()))\n}\n\nstruct RelayParams {\n    local_port: u16,\n    local_bind_ip: IpAddr,\n    remote_client: RemoteClient,\n    relay_base: String,\n    machine_id: String,\n    host_name: String,\n}\n\n/// Resolve all preconditions for starting the relay. Returns `None` if any\n/// requirement is missing (config, env, login, server info).\nasync fn resolve_relay_params(deployment: &DeploymentImpl) -> Option<RelayParams> {\n    let config = deployment.config().read().await;\n    if !config.relay_enabled {\n        tracing::info!(\"Relay disabled by config\");\n        return None;\n    }\n    drop(config);\n\n    let relay_base = relay_api_base().or_else(|| {\n        tracing::debug!(\"VK_SHARED_RELAY_API_BASE not set; relay unavailable\");\n        None\n    })?;\n\n    let remote_client = deployment.remote_client().ok().or_else(|| {\n        tracing::debug!(\"Remote client not configured; relay unavailable\");\n        None\n    })?;\n\n    let login_status = deployment.get_login_status().await;\n    if matches!(login_status, api_types::LoginStatus::LoggedOut) {\n        tracing::info!(\"Not logged in; relay will start on login\");\n        return None;\n    }\n\n    let local_port = deployment.server_info().get_port().await.or_else(|| {\n        tracing::warn!(\"Relay local port not set; cannot spawn relay\");\n        None\n    })?;\n\n    let local_bind_ip = deployment.server_info().get_bind_ip().await.or_else(|| {\n        tracing::warn!(\"Relay local bind IP not set; cannot spawn relay\");\n        None\n    })?;\n\n    let host_name = deployment.server_info().get_hostname().await.or_else(|| {\n        tracing::warn!(\"Server hostname not set; cannot spawn relay\");\n        None\n    })?;\n\n    Some(RelayParams {\n        local_port,\n        local_bind_ip,\n        remote_client,\n        relay_base,\n        machine_id: deployment.user_id().to_string(),\n        host_name,\n    })\n}\n\n/// Spawn the relay reconnect loop. Safe to call multiple times — cancels any\n/// previous session first via `RelayControl::reset`.\npub async fn spawn_relay(deployment: &DeploymentImpl) {\n    let Some(params) = resolve_relay_params(deployment).await else {\n        return;\n    };\n\n    let cancel_token = deployment.relay_control().reset().await;\n\n    tokio::spawn(async move {\n        tracing::info!(\"Relay auto-reconnect loop started\");\n\n        let mut delay = std::time::Duration::from_secs(RELAY_RECONNECT_INITIAL_DELAY_SECS);\n        let max_delay = std::time::Duration::from_secs(RELAY_RECONNECT_MAX_DELAY_SECS);\n\n        while !cancel_token.is_cancelled()\n            && let Err(error) = start_relay(&params, cancel_token.clone()).await\n        {\n            tracing::debug!(\n                ?error,\n                retry_in_secs = delay.as_secs(),\n                \"Relay connection failed; retrying\"\n            );\n\n            tokio::select! {\n                _ = cancel_token.cancelled() => break,\n                _ = tokio::time::sleep(delay) => {}\n            }\n\n            delay = std::cmp::min(delay.saturating_mul(2), max_delay);\n        }\n\n        tracing::info!(\"Relay reconnect loop exited\");\n    });\n}\n\n/// Stop the relay by cancelling the current session token.\npub async fn stop_relay(deployment: &DeploymentImpl) {\n    deployment.relay_control().stop().await;\n    tracing::info!(\"Relay stopped\");\n}\n\n/// Start the relay client transport.\nasync fn start_relay(\n    params: &RelayParams,\n    shutdown: tokio_util::sync::CancellationToken,\n) -> anyhow::Result<()> {\n    let base_url = params.relay_base.trim_end_matches('/');\n\n    let encoded_name = url::form_urlencoded::Serializer::new(String::new())\n        .append_pair(\"machine_id\", &params.machine_id)\n        .append_pair(\"name\", &params.host_name)\n        .append_pair(\"agent_version\", env!(\"CARGO_PKG_VERSION\"))\n        .finish();\n\n    let ws_url = if let Some(rest) = base_url.strip_prefix(\"https://\") {\n        format!(\"wss://{rest}/v1/relay/connect?{encoded_name}\")\n    } else if let Some(rest) = base_url.strip_prefix(\"http://\") {\n        format!(\"ws://{rest}/v1/relay/connect?{encoded_name}\")\n    } else {\n        anyhow::bail!(\"Unexpected base URL scheme: {base_url}\");\n    };\n\n    let access_token = params\n        .remote_client\n        .access_token()\n        .await\n        .context(\"Failed to get access token for relay\")?;\n\n    tracing::info!(%ws_url, \"Connecting relay control channel\");\n    let local_addr = relay_local_addr(params.local_bind_ip, params.local_port);\n\n    start_relay_client(RelayClientConfig {\n        ws_url,\n        bearer_token: access_token,\n        local_addr: local_addr.to_string(),\n        shutdown,\n    })\n    .await\n}\n\nfn relay_local_addr(bind_ip: IpAddr, port: u16) -> SocketAddr {\n    SocketAddr::new(normalize_relay_bind_ip(bind_ip), port)\n}\n\nfn normalize_relay_bind_ip(bind_ip: IpAddr) -> IpAddr {\n    match bind_ip {\n        IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),\n        IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),\n        ip => ip,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};\n\n    use super::{normalize_relay_bind_ip, relay_local_addr};\n\n    #[test]\n    fn relay_local_addr_keeps_ipv4_loopback() {\n        let bind_ip = IpAddr::V4(Ipv4Addr::LOCALHOST);\n        let local_addr = relay_local_addr(bind_ip, 8080);\n\n        assert_eq!(local_addr, SocketAddr::new(bind_ip, 8080));\n    }\n\n    #[test]\n    fn relay_local_addr_keeps_ipv6_loopback() {\n        let bind_ip = IpAddr::V6(Ipv6Addr::LOCALHOST);\n        let local_addr = relay_local_addr(bind_ip, 8080);\n\n        assert_eq!(local_addr, SocketAddr::new(bind_ip, 8080));\n    }\n\n    #[test]\n    fn relay_local_addr_maps_unspecified_ipv4_to_loopback() {\n        let local_addr = relay_local_addr(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8080);\n\n        assert_eq!(\n            local_addr,\n            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080)\n        );\n    }\n\n    #[test]\n    fn relay_local_addr_maps_unspecified_ipv6_to_loopback() {\n        let local_addr = relay_local_addr(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 8080);\n\n        assert_eq!(\n            local_addr,\n            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080)\n        );\n    }\n\n    #[test]\n    fn normalize_relay_bind_ip_preserves_non_wildcard_addresses() {\n        let ipv4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));\n        let ipv6 = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1));\n\n        assert_eq!(normalize_relay_bind_ip(ipv4), ipv4);\n        assert_eq!(normalize_relay_bind_ip(ipv6), ipv6);\n    }\n}\n"
  },
  {
    "path": "crates/server-info/Cargo.toml",
    "content": "[package]\nname = \"server-info\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/server-info/src/lib.rs",
    "content": "use std::net::IpAddr;\n\nuse tokio::sync::RwLock;\n\n/// Runtime information about the local server (port, hostname).\npub struct ServerInfo {\n    port: RwLock<Option<u16>>,\n    bind_ip: RwLock<Option<IpAddr>>,\n    hostname: RwLock<Option<String>>,\n}\n\nimpl Default for ServerInfo {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl ServerInfo {\n    pub fn new() -> Self {\n        Self {\n            port: RwLock::new(None),\n            bind_ip: RwLock::new(None),\n            hostname: RwLock::new(None),\n        }\n    }\n\n    pub async fn set_port(&self, port: u16) {\n        *self.port.write().await = Some(port);\n    }\n\n    pub async fn get_port(&self) -> Option<u16> {\n        *self.port.read().await\n    }\n\n    pub async fn set_bind_ip(&self, bind_ip: IpAddr) {\n        *self.bind_ip.write().await = Some(bind_ip);\n    }\n\n    pub async fn get_bind_ip(&self) -> Option<IpAddr> {\n        *self.bind_ip.read().await\n    }\n\n    pub async fn set_hostname(&self, hostname: String) {\n        *self.hostname.write().await = Some(hostname);\n    }\n\n    pub async fn get_hostname(&self) -> Option<String> {\n        self.hostname.read().await.clone()\n    }\n}\n"
  },
  {
    "path": "crates/services/Cargo.toml",
    "content": "[package]\nname = \"services\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[features]\ndefault = []\ncloud = []\nqa-mode = [\"executors/qa-mode\"]\n\n[dependencies]\nindicatif = \"0.17\"\napi-types = { path = \"../api-types\" }\nutils = { path = \"../utils\" }\ngit = { path = \"../git\" }\ngit-host = { path = \"../git-host\" }\nexecutors = { path = \"../executors\" }\ndb = { path = \"../db\" }\nworktree-manager = { path = \"../worktree-manager\" }\ntokio = { workspace = true }\ntokio-util = { version = \"0.7\", features = [\"io\"] }\nserde = { workspace = true }\nserde_json = { workspace = true }\nurl = \"2.5\"\nanyhow = { workspace = true }\ntracing = { workspace = true }\nsqlx = { version = \"0.8.6\", features = [\"runtime-tokio\", \"tls-rustls-aws-lc-rs\", \"sqlite\", \"sqlite-preupdate-hook\", \"chrono\", \"uuid\"] }\nchrono = { version = \"0.4\", features = [\"serde\"] }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nts-rs = { workspace = true }\ndirs = \"5.0\"\ngit2 = { workspace = true }\nasync-trait = { workspace = true }\nrust-embed = \"8.2\"\nignore = \"0.4\"\nnotify-rust = \"4.11\"\nos_info = \"3.12.0\"\nreqwest = { workspace = true }\njson-patch = \"2.0\"\nbackon = \"1.5.1\"\nthiserror = { workspace = true }\nfutures = \"0.3.31\"\ntokio-stream = \"0.1.17\"\nstrum = \"0.27.2\"\nstrum_macros = \"0.27.2\"\nnotify = \"8.2.0\"\nnotify-debouncer-full = \"0.5.0\"\ndunce = \"1.0\"\ndashmap = \"6.1\"\nonce_cell = \"1.20\"\nsha2 = \"0.10\"\nfst = \"0.4\"\nmoka = { version = \"0.12\", features = [\"future\"] }\nmime_guess = \"2.0\"\n\n[dev-dependencies]\ntempfile = \"3\"\n"
  },
  {
    "path": "crates/services/src/lib.rs",
    "content": "pub mod services;\n\npub use services::remote_client::{HandoffErrorCode, RemoteClient, RemoteClientError};\n"
  },
  {
    "path": "crates/services/src/services/analytics.rs",
    "content": "use std::{\n    collections::hash_map::DefaultHasher,\n    hash::{Hash, Hasher},\n    time::Duration,\n};\n\nuse os_info;\nuse serde_json::{Value, json};\n\n#[derive(Debug, Clone)]\npub struct AnalyticsContext {\n    pub user_id: String,\n    pub analytics_service: AnalyticsService,\n}\n\n#[derive(Debug, Clone)]\npub struct AnalyticsConfig {\n    pub posthog_api_key: String,\n    pub posthog_api_endpoint: String,\n}\n\nimpl AnalyticsConfig {\n    pub fn new() -> Option<Self> {\n        let api_key = option_env!(\"POSTHOG_API_KEY\")\n            .map(|s| s.to_string())\n            .or_else(|| std::env::var(\"POSTHOG_API_KEY\").ok())?;\n        let api_endpoint = option_env!(\"POSTHOG_API_ENDPOINT\")\n            .map(|s| s.to_string())\n            .or_else(|| std::env::var(\"POSTHOG_API_ENDPOINT\").ok())?;\n\n        Some(Self {\n            posthog_api_key: api_key,\n            posthog_api_endpoint: api_endpoint,\n        })\n    }\n}\n\n#[derive(Clone, Debug)]\npub struct AnalyticsService {\n    config: AnalyticsConfig,\n    client: reqwest::Client,\n}\n\nimpl AnalyticsService {\n    pub fn new(config: AnalyticsConfig) -> Self {\n        let client = reqwest::Client::builder()\n            .timeout(Duration::from_secs(30))\n            .build()\n            .unwrap();\n\n        Self { config, client }\n    }\n\n    pub fn track_event(&self, user_id: &str, event_name: &str, properties: Option<Value>) {\n        let endpoint = format!(\n            \"{}/capture/\",\n            self.config.posthog_api_endpoint.trim_end_matches('/')\n        );\n\n        let mut payload = json!({\n            \"api_key\": self.config.posthog_api_key,\n            \"event\": event_name,\n            \"distinct_id\": user_id,\n        });\n        if event_name == \"$identify\" {\n            // For $identify, set person properties in $set\n            if let Some(props) = properties {\n                payload[\"$set\"] = props;\n            }\n        } else {\n            // For other events, use properties as before\n            let mut event_properties = properties.unwrap_or_else(|| json!({}));\n            if let Some(props) = event_properties.as_object_mut() {\n                props.insert(\n                    \"timestamp\".to_string(),\n                    json!(chrono::Utc::now().to_rfc3339()),\n                );\n                props.insert(\"version\".to_string(), json!(env!(\"CARGO_PKG_VERSION\")));\n                props.insert(\"device\".to_string(), get_device_info());\n                props.insert(\"source\".to_string(), json!(\"backend\"));\n            }\n            payload[\"properties\"] = event_properties;\n        }\n\n        let client = self.client.clone();\n        let event_name = event_name.to_string();\n\n        tokio::spawn(async move {\n            match client\n                .post(&endpoint)\n                .header(\"Content-Type\", \"application/json\")\n                .json(&payload)\n                .send()\n                .await\n            {\n                Ok(response) => {\n                    if response.status().is_success() {\n                        tracing::debug!(\"Event '{}' sent successfully\", event_name);\n                    } else {\n                        let status = response.status();\n                        let response_text = response.text().await.unwrap_or_default();\n                        tracing::error!(\n                            \"Failed to send event. Status: {}. Response: {}\",\n                            status,\n                            response_text\n                        );\n                    }\n                }\n                Err(e) => {\n                    tracing::error!(\"Error sending event '{}': {}\", event_name, e);\n                }\n            }\n        });\n    }\n}\n\n/// Generates a consistent, anonymous user ID for npm package telemetry.\n/// Returns a hex string prefixed with \"npm_user_\"\npub fn generate_user_id() -> String {\n    let mut hasher = DefaultHasher::new();\n\n    #[cfg(target_os = \"macos\")]\n    {\n        // Use ioreg to get hardware UUID\n        if let Ok(output) = std::process::Command::new(\"ioreg\")\n            .args([\"-rd1\", \"-c\", \"IOPlatformExpertDevice\"])\n            .output()\n        {\n            let stdout = String::from_utf8_lossy(&output.stdout);\n            if let Some(line) = stdout.lines().find(|l| l.contains(\"IOPlatformUUID\")) {\n                line.hash(&mut hasher);\n            }\n        }\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        if let Ok(machine_id) = std::fs::read_to_string(\"/etc/machine-id\") {\n            machine_id.trim().hash(&mut hasher);\n        }\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        use utils::command_ext::NoWindowExt;\n        // Use PowerShell to get machine GUID from registry\n        if let Ok(output) = std::process::Command::new(\"powershell\")\n            .args(&[\n                \"-NoProfile\",\n                \"-Command\",\n                \"(Get-ItemProperty -Path 'HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Cryptography').MachineGuid\",\n            ])\n            .no_window()\n            .output()\n        {\n            if output.status.success() {\n                output.stdout.hash(&mut hasher);\n            }\n        }\n    }\n\n    // Add username for per-user differentiation\n    if let Ok(user) = std::env::var(\"USER\").or_else(|_| std::env::var(\"USERNAME\")) {\n        user.hash(&mut hasher);\n    }\n\n    // Add home directory for additional entropy\n    if let Ok(home) = std::env::var(\"HOME\").or_else(|_| std::env::var(\"USERPROFILE\")) {\n        home.hash(&mut hasher);\n    }\n\n    format!(\"npm_user_{:016x}\", hasher.finish())\n}\n\nfn get_device_info() -> Value {\n    let info = os_info::get();\n\n    json!({\n        \"os_type\": info.os_type().to_string(),\n        \"os_version\": info.version().to_string(),\n        \"architecture\": info.architecture().unwrap_or(\"unknown\").to_string(),\n        \"bitness\": info.bitness().to_string(),\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_generate_user_id_format() {\n        let id = generate_user_id();\n        assert!(id.starts_with(\"npm_user_\"));\n        assert_eq!(id.len(), 25);\n    }\n\n    #[test]\n    fn test_consistency() {\n        let id1 = generate_user_id();\n        let id2 = generate_user_id();\n        assert_eq!(id1, id2, \"ID should be consistent across calls\");\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/approvals/executor_approvals.rs",
    "content": "use std::{collections::HashMap, sync::Arc};\n\nuse async_trait::async_trait;\nuse db::{self, DBService, models::execution_process::ExecutionProcess};\nuse executors::approvals::{ExecutorApprovalError, ExecutorApprovalService};\nuse tokio::sync::Mutex;\nuse tokio_util::sync::CancellationToken;\nuse utils::approvals::{ApprovalOutcome, ApprovalRequest, ApprovalStatus, QuestionStatus};\nuse uuid::Uuid;\n\nuse crate::services::{approvals::Approvals, notification::NotificationService};\n\ntype ApprovalWaiter = futures::future::Shared<futures::future::BoxFuture<'static, ApprovalOutcome>>;\n\npub struct ExecutorApprovalBridge {\n    approvals: Approvals,\n    db: DBService,\n    notification_service: NotificationService,\n    execution_process_id: Uuid,\n    /// Waiters stored between create and wait phases, keyed by approval_id.\n    waiters: Mutex<HashMap<String, ApprovalWaiter>>,\n}\n\nimpl ExecutorApprovalBridge {\n    pub fn new(\n        approvals: Approvals,\n        db: DBService,\n        notification_service: NotificationService,\n        execution_process_id: Uuid,\n    ) -> Arc<Self> {\n        Arc::new(Self {\n            approvals,\n            db,\n            notification_service,\n            execution_process_id,\n            waiters: Mutex::new(HashMap::new()),\n        })\n    }\n\n    async fn create_internal(\n        &self,\n        tool_name: &str,\n        is_question: bool,\n        question_count: Option<usize>,\n    ) -> Result<String, ExecutorApprovalError> {\n        let request = ApprovalRequest::new(tool_name.to_string(), self.execution_process_id);\n\n        let (request, waiter) = self\n            .approvals\n            .create_with_waiter(request, is_question)\n            .await\n            .map_err(ExecutorApprovalError::request_failed)?;\n\n        let approval_id = request.id.clone();\n\n        // Store waiter for the wait phase\n        self.waiters\n            .lock()\n            .await\n            .insert(approval_id.clone(), waiter);\n\n        let (workspace_name, workspace_id) =\n            ExecutionProcess::load_context(&self.db.pool, self.execution_process_id)\n                .await\n                .map(|ctx| {\n                    let name = ctx\n                        .workspace\n                        .name\n                        .unwrap_or_else(|| ctx.workspace.branch.clone());\n                    (name, Some(ctx.workspace.id))\n                })\n                .unwrap_or_else(|_| (\"Unknown workspace\".to_string(), None));\n\n        let (title, message) = if let Some(count) = question_count {\n            if count == 1 {\n                (\n                    format!(\"Question Asked: {}\", workspace_name),\n                    \"1 question requires an answer\".to_string(),\n                )\n            } else {\n                (\n                    format!(\"Question Asked: {}\", workspace_name),\n                    format!(\"{} questions require answers\", count),\n                )\n            }\n        } else {\n            (\n                format!(\"Approval Needed: {}\", workspace_name),\n                format!(\"Tool '{}' requires approval\", tool_name),\n            )\n        };\n\n        self.notification_service\n            .notify(&title, &message, workspace_id)\n            .await;\n\n        Ok(approval_id)\n    }\n\n    async fn wait_internal(\n        &self,\n        approval_id: &str,\n        cancel: CancellationToken,\n    ) -> Result<ApprovalOutcome, ExecutorApprovalError> {\n        let waiter = self\n            .waiters\n            .lock()\n            .await\n            .remove(approval_id)\n            .ok_or_else(|| {\n                ExecutorApprovalError::request_failed(format!(\n                    \"no waiter found for approval_id={}\",\n                    approval_id\n                ))\n            })?;\n\n        let outcome = tokio::select! {\n            _ = cancel.cancelled() => {\n                tracing::info!(\"Approval request cancelled for approval_id={}\", approval_id);\n                self.approvals.cancel(approval_id).await;\n                return Err(ExecutorApprovalError::Cancelled);\n            }\n            outcome = waiter => outcome,\n        };\n\n        Ok(outcome)\n    }\n}\n\n#[async_trait]\nimpl ExecutorApprovalService for ExecutorApprovalBridge {\n    async fn create_tool_approval(&self, tool_name: &str) -> Result<String, ExecutorApprovalError> {\n        self.create_internal(tool_name, false, None).await\n    }\n\n    async fn create_question_approval(\n        &self,\n        tool_name: &str,\n        question_count: usize,\n    ) -> Result<String, ExecutorApprovalError> {\n        self.create_internal(tool_name, true, Some(question_count))\n            .await\n    }\n\n    async fn wait_tool_approval(\n        &self,\n        approval_id: &str,\n        cancel: CancellationToken,\n    ) -> Result<ApprovalStatus, ExecutorApprovalError> {\n        let outcome = self.wait_internal(approval_id, cancel).await?;\n\n        match outcome {\n            ApprovalOutcome::Approved => Ok(ApprovalStatus::Approved),\n            ApprovalOutcome::Denied { reason } => Ok(ApprovalStatus::Denied { reason }),\n            ApprovalOutcome::TimedOut => Ok(ApprovalStatus::TimedOut),\n            ApprovalOutcome::Answered { .. } => Err(ExecutorApprovalError::request_failed(\n                \"unexpected question response for permission request\",\n            )),\n        }\n    }\n\n    async fn wait_question_answer(\n        &self,\n        approval_id: &str,\n        cancel: CancellationToken,\n    ) -> Result<QuestionStatus, ExecutorApprovalError> {\n        let outcome = self.wait_internal(approval_id, cancel).await?;\n\n        match outcome {\n            ApprovalOutcome::Answered { answers } => Ok(QuestionStatus::Answered { answers }),\n            ApprovalOutcome::TimedOut => Ok(QuestionStatus::TimedOut),\n            ApprovalOutcome::Approved | ApprovalOutcome::Denied { .. } => {\n                Err(ExecutorApprovalError::request_failed(\n                    \"unexpected permission response for question request\",\n                ))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/approvals.rs",
    "content": "pub mod executor_approvals;\n\nuse std::{collections::HashSet, sync::Arc, time::Duration as StdDuration};\n\nuse chrono::{DateTime, Utc};\nuse dashmap::DashMap;\nuse futures::{\n    StreamExt,\n    future::{BoxFuture, FutureExt, Shared},\n};\nuse json_patch::Patch;\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse tokio::sync::{broadcast, oneshot};\nuse tokio_stream::wrappers::BroadcastStream;\nuse ts_rs::TS;\nuse utils::approvals::{ApprovalOutcome, ApprovalRequest, ApprovalResponse};\nuse uuid::Uuid;\n\n#[derive(Debug)]\nstruct PendingApproval {\n    execution_process_id: Uuid,\n    tool_name: String,\n    is_question: bool,\n    created_at: DateTime<Utc>,\n    timeout_at: DateTime<Utc>,\n    response_tx: oneshot::Sender<ApprovalOutcome>,\n}\n\npub(crate) type ApprovalWaiter = Shared<BoxFuture<'static, ApprovalOutcome>>;\n\n#[derive(Debug)]\npub struct ToolContext {\n    pub tool_name: String,\n    pub execution_process_id: Uuid,\n}\n\n/// Info about a currently pending approval, sent to the frontend via WebSocket.\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct ApprovalInfo {\n    pub approval_id: String,\n    pub tool_name: String,\n    pub execution_process_id: Uuid,\n    pub is_question: bool,\n    pub created_at: DateTime<Utc>,\n    pub timeout_at: DateTime<Utc>,\n}\n\n#[derive(Clone)]\npub struct Approvals {\n    pending: Arc<DashMap<String, PendingApproval>>,\n    completed: Arc<DashMap<String, ApprovalOutcome>>,\n    patches_tx: broadcast::Sender<Patch>,\n}\n\n#[derive(Debug, Error)]\npub enum ApprovalError {\n    #[error(\"approval request not found\")]\n    NotFound,\n    #[error(\"approval request already completed\")]\n    AlreadyCompleted,\n    #[error(\"no executor session found for session_id: {0}\")]\n    NoExecutorSession(String),\n    #[error(\"invalid approval status for this tool type\")]\n    InvalidStatus,\n    #[error(transparent)]\n    Custom(#[from] anyhow::Error),\n}\n\nimpl Default for Approvals {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl Approvals {\n    pub fn new() -> Self {\n        let (patches_tx, _) = broadcast::channel(64);\n        Self {\n            pending: Arc::new(DashMap::new()),\n            completed: Arc::new(DashMap::new()),\n            patches_tx,\n        }\n    }\n\n    pub async fn create_with_waiter(\n        &self,\n        request: ApprovalRequest,\n        is_question: bool,\n    ) -> Result<(ApprovalRequest, ApprovalWaiter), ApprovalError> {\n        let (tx, rx) = oneshot::channel();\n        let default_timeout = ApprovalOutcome::TimedOut;\n        let waiter: ApprovalWaiter = rx\n            .map(move |result| result.unwrap_or(default_timeout))\n            .boxed()\n            .shared();\n        let req_id = request.id.clone();\n\n        let info = ApprovalInfo {\n            approval_id: req_id.clone(),\n            tool_name: request.tool_name.clone(),\n            execution_process_id: request.execution_process_id,\n            is_question,\n            created_at: request.created_at,\n            timeout_at: request.timeout_at,\n        };\n\n        let pending_approval = PendingApproval {\n            execution_process_id: request.execution_process_id,\n            tool_name: request.tool_name.clone(),\n            is_question,\n            created_at: request.created_at,\n            timeout_at: request.timeout_at,\n            response_tx: tx,\n        };\n\n        self.pending.insert(req_id.clone(), pending_approval);\n\n        let _ = self\n            .patches_tx\n            .send(crate::services::events::patches::approvals_patch::created(\n                &info,\n            ));\n\n        self.spawn_timeout_watcher(req_id.clone(), request.timeout_at, waiter.clone());\n        Ok((request, waiter))\n    }\n\n    fn validate_approval_response(\n        outcome: &ApprovalOutcome,\n        is_question: bool,\n    ) -> Result<(), ApprovalError> {\n        match outcome {\n            ApprovalOutcome::Approved | ApprovalOutcome::Denied { .. } if is_question => {\n                Err(ApprovalError::InvalidStatus)\n            }\n            ApprovalOutcome::Answered { .. } if !is_question => Err(ApprovalError::InvalidStatus),\n            _ => Ok(()),\n        }\n    }\n\n    #[tracing::instrument(skip(self, id, req))]\n    pub async fn respond(\n        &self,\n        id: &str,\n        req: ApprovalResponse,\n    ) -> Result<(ApprovalOutcome, ToolContext), ApprovalError> {\n        if let Some((_, p)) = self.pending.remove(id) {\n            if let Err(e) = Self::validate_approval_response(&req.status, p.is_question) {\n                self.pending.insert(id.to_string(), p);\n                return Err(e);\n            }\n\n            let outcome = req.status.clone();\n            self.completed.insert(id.to_string(), outcome.clone());\n            let _ = p.response_tx.send(outcome.clone());\n\n            let _ =\n                self.patches_tx\n                    .send(crate::services::events::patches::approvals_patch::resolved(\n                        id,\n                    ));\n\n            let tool_ctx = ToolContext {\n                tool_name: p.tool_name,\n                execution_process_id: p.execution_process_id,\n            };\n\n            Ok((outcome, tool_ctx))\n        } else if self.completed.contains_key(id) {\n            Err(ApprovalError::AlreadyCompleted)\n        } else {\n            Err(ApprovalError::NotFound)\n        }\n    }\n\n    #[tracing::instrument(skip(self, id, timeout_at, waiter))]\n    fn spawn_timeout_watcher(\n        &self,\n        id: String,\n        timeout_at: chrono::DateTime<chrono::Utc>,\n        waiter: ApprovalWaiter,\n    ) {\n        let pending = self.pending.clone();\n        let completed = self.completed.clone();\n        let patches_tx = self.patches_tx.clone();\n\n        let timeout_outcome = ApprovalOutcome::TimedOut;\n\n        let now = chrono::Utc::now();\n        let to_wait = (timeout_at - now)\n            .to_std()\n            .unwrap_or_else(|_| StdDuration::from_secs(0));\n        let deadline = tokio::time::Instant::now() + to_wait;\n\n        tokio::spawn(async move {\n            let outcome = tokio::select! {\n                biased;\n\n                resolved = waiter.clone() => resolved,\n                _ = tokio::time::sleep_until(deadline) => timeout_outcome,\n            };\n\n            let is_timeout = matches!(&outcome, ApprovalOutcome::TimedOut);\n            completed.insert(id.clone(), outcome.clone());\n\n            if is_timeout && let Some((_, pending_approval)) = pending.remove(&id) {\n                let _ = patches_tx.send(\n                    crate::services::events::patches::approvals_patch::resolved(&id),\n                );\n                if pending_approval.response_tx.send(outcome).is_err() {\n                    tracing::debug!(\"approval '{}' timeout notification receiver dropped\", id);\n                }\n            }\n        });\n    }\n\n    pub(crate) async fn cancel(&self, id: &str) {\n        if let Some((_, _pending_approval)) = self.pending.remove(id) {\n            let outcome = ApprovalOutcome::Denied {\n                reason: Some(\"Cancelled\".to_string()),\n            };\n            self.completed.insert(id.to_string(), outcome);\n            let _ =\n                self.patches_tx\n                    .send(crate::services::events::patches::approvals_patch::resolved(\n                        id,\n                    ));\n            tracing::debug!(\"Cancelled approval '{}'\", id);\n        }\n    }\n\n    pub fn patch_stream(&self) -> futures::stream::BoxStream<'static, Patch> {\n        let approvals = self.clone();\n        let snapshot =\n            crate::services::events::patches::approvals_patch::snapshot(&approvals.pending_infos());\n\n        let live = BroadcastStream::new(self.patches_tx.subscribe()).filter_map(move |result| {\n            let approvals = approvals.clone();\n            async move {\n                match result {\n                    Ok(patch) => Some(patch),\n                    Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(_)) => {\n                        Some(crate::services::events::patches::approvals_patch::snapshot(\n                            &approvals.pending_infos(),\n                        ))\n                    }\n                }\n            }\n        });\n\n        futures::stream::iter([snapshot]).chain(live).boxed()\n    }\n\n    /// Check which execution processes have pending approvals.\n    /// Returns a set of execution_process_ids that have at least one pending approval.\n    pub fn get_pending_execution_process_ids(\n        &self,\n        execution_process_ids: &[Uuid],\n    ) -> HashSet<Uuid> {\n        let id_set: HashSet<_> = execution_process_ids.iter().collect();\n        self.pending\n            .iter()\n            .filter_map(|entry| {\n                let ep_id = entry.value().execution_process_id;\n                if id_set.contains(&ep_id) {\n                    Some(ep_id)\n                } else {\n                    None\n                }\n            })\n            .collect()\n    }\n\n    fn pending_infos(&self) -> Vec<ApprovalInfo> {\n        self.pending\n            .iter()\n            .map(|entry| {\n                let p = entry.value();\n                ApprovalInfo {\n                    approval_id: entry.key().clone(),\n                    tool_name: p.tool_name.clone(),\n                    execution_process_id: p.execution_process_id,\n                    is_question: p.is_question,\n                    created_at: p.created_at,\n                    timeout_at: p.timeout_at,\n                }\n            })\n            .collect()\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/auth.rs",
    "content": "use std::sync::Arc;\n\nuse api_types::ProfileResponse;\nuse tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard, RwLock};\n\nuse super::oauth_credentials::{Credentials, OAuthCredentials};\n\n#[derive(Clone)]\npub struct AuthContext {\n    oauth: Arc<OAuthCredentials>,\n    profile: Arc<RwLock<Option<ProfileResponse>>>,\n    refresh_lock: Arc<TokioMutex<()>>,\n}\n\nimpl AuthContext {\n    pub fn new(\n        oauth: Arc<OAuthCredentials>,\n        profile: Arc<RwLock<Option<ProfileResponse>>>,\n    ) -> Self {\n        Self {\n            oauth,\n            profile,\n            refresh_lock: Arc::new(TokioMutex::new(())),\n        }\n    }\n\n    pub async fn get_credentials(&self) -> Option<Credentials> {\n        self.oauth.get().await\n    }\n\n    pub async fn save_credentials(&self, creds: &Credentials) -> std::io::Result<()> {\n        self.oauth.save(creds).await\n    }\n\n    pub async fn clear_credentials(&self) -> std::io::Result<()> {\n        self.oauth.clear().await\n    }\n\n    pub async fn cached_profile(&self) -> Option<ProfileResponse> {\n        self.profile.read().await.clone()\n    }\n\n    pub async fn set_profile(&self, profile: ProfileResponse) {\n        *self.profile.write().await = Some(profile)\n    }\n\n    pub async fn clear_profile(&self) {\n        *self.profile.write().await = None\n    }\n\n    pub async fn refresh_guard(&self) -> OwnedMutexGuard<()> {\n        self.refresh_lock.clone().lock_owned().await\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/editor/mod.rs",
    "content": "use std::{path::Path, str::FromStr};\n\nuse executors::{command::CommandBuilder, executors::ExecutorError};\nuse serde::{Deserialize, Serialize};\nuse strum_macros::{EnumIter, EnumString};\nuse thiserror::Error;\nuse ts_rs::TS;\n\nfn default_auto_install_extension() -> bool {\n    true\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, Error)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(tag = \"type\", rename_all = \"snake_case\")]\npub enum EditorOpenError {\n    #[error(\"Editor executable '{executable}' not found in PATH\")]\n    ExecutableNotFound {\n        executable: String,\n        editor_type: EditorType,\n    },\n    #[error(\"Editor command for {editor_type:?} is invalid: {details}\")]\n    InvalidCommand {\n        details: String,\n        editor_type: EditorType,\n    },\n    #[error(\"Failed to launch '{executable}' for {editor_type:?}: {details}\")]\n    LaunchFailed {\n        executable: String,\n        details: String,\n        editor_type: EditorType,\n    },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct EditorConfig {\n    editor_type: EditorType,\n    custom_command: Option<String>,\n    #[serde(default)]\n    remote_ssh_host: Option<String>,\n    #[serde(default)]\n    remote_ssh_user: Option<String>,\n    #[serde(default = \"default_auto_install_extension\")]\n    auto_install_extension: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString, EnumIter)]\n#[ts(use_ts_enum)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[strum(serialize_all = \"SCREAMING_SNAKE_CASE\")]\npub enum EditorType {\n    VsCode,\n    VsCodeInsiders,\n    Cursor,\n    Windsurf,\n    IntelliJ,\n    Zed,\n    Xcode,\n    GoogleAntigravity,\n    Custom,\n}\n\nimpl Default for EditorConfig {\n    fn default() -> Self {\n        Self {\n            editor_type: EditorType::VsCode,\n            custom_command: None,\n            remote_ssh_host: None,\n            remote_ssh_user: None,\n            auto_install_extension: true,\n        }\n    }\n}\n\nimpl EditorConfig {\n    /// Create a new EditorConfig. This is primarily used by version migrations.\n    pub fn new(\n        editor_type: EditorType,\n        custom_command: Option<String>,\n        remote_ssh_host: Option<String>,\n        remote_ssh_user: Option<String>,\n        auto_install_extension: bool,\n    ) -> Self {\n        Self {\n            editor_type,\n            custom_command,\n            remote_ssh_host,\n            remote_ssh_user,\n            auto_install_extension,\n        }\n    }\n\n    pub fn get_command(&self) -> CommandBuilder {\n        let base_command = match &self.editor_type {\n            EditorType::VsCode => \"code\",\n            EditorType::VsCodeInsiders => \"code-insiders\",\n            EditorType::Cursor => \"cursor\",\n            EditorType::Windsurf => \"windsurf\",\n            EditorType::IntelliJ => \"idea\",\n            EditorType::Zed => \"zed\",\n            EditorType::Xcode => \"xed\",\n            EditorType::GoogleAntigravity => \"antigravity\",\n            EditorType::Custom => {\n                // Custom editor - use user-provided command or fallback to VSCode\n                self.custom_command.as_deref().unwrap_or(\"code\")\n            }\n        };\n        CommandBuilder::new(base_command)\n    }\n\n    /// Resolve the editor command to an executable path and args.\n    /// This is shared logic used by both check_availability() and spawn_local().\n    async fn resolve_command(&self) -> Result<(std::path::PathBuf, Vec<String>), EditorOpenError> {\n        let command_builder = self.get_command();\n        let command_parts =\n            command_builder\n                .build_initial()\n                .map_err(|e| EditorOpenError::InvalidCommand {\n                    details: e.to_string(),\n                    editor_type: self.editor_type.clone(),\n                })?;\n\n        let (executable, args) = command_parts.into_resolved().await.map_err(|e| match e {\n            ExecutorError::ExecutableNotFound { program } => EditorOpenError::ExecutableNotFound {\n                executable: program,\n                editor_type: self.editor_type.clone(),\n            },\n            _ => EditorOpenError::InvalidCommand {\n                details: e.to_string(),\n                editor_type: self.editor_type.clone(),\n            },\n        })?;\n\n        Ok((executable, args))\n    }\n\n    /// Check if the editor is available on the system.\n    /// Uses the same command resolution logic as spawn_local().\n    pub async fn check_availability(&self) -> bool {\n        self.resolve_command().await.is_ok()\n    }\n\n    fn should_auto_install_extension(&self) -> bool {\n        self.auto_install_extension\n            && matches!(\n                self.editor_type,\n                EditorType::VsCode | EditorType::VsCodeInsiders | EditorType::Cursor\n            )\n    }\n\n    async fn try_install_extension(&self) {\n        let Ok((executable, args)) = self.resolve_command().await else {\n            return;\n        };\n\n        use utils::command_ext::NoWindowExt;\n        let mut cmd = std::process::Command::new(&executable);\n        cmd.args(&args)\n            .arg(\"--install-extension\")\n            .arg(\"bloop.vibe-kanban\");\n        let _ = cmd.no_window().spawn();\n    }\n\n    pub async fn open_file(&self, path: &Path) -> Result<Option<String>, EditorOpenError> {\n        if let Some(url) = self.remote_url(path) {\n            return Ok(Some(url));\n        }\n        if self.should_auto_install_extension() {\n            self.try_install_extension().await;\n        }\n        self.spawn_local(path).await?;\n        Ok(None)\n    }\n\n    fn remote_url(&self, path: &Path) -> Option<String> {\n        let remote_host = self.remote_ssh_host.as_ref()?;\n        let user_part = self\n            .remote_ssh_user\n            .as_ref()\n            .map(|u| format!(\"{u}@\"))\n            .unwrap_or_default();\n        let path_str = path.to_string_lossy();\n\n        let scheme = match self.editor_type {\n            EditorType::VsCode => \"vscode\",\n            EditorType::VsCodeInsiders => \"vscode-insiders\",\n            EditorType::Cursor => \"cursor\",\n            EditorType::Windsurf => \"windsurf\",\n            EditorType::GoogleAntigravity => \"antigravity\",\n            EditorType::Zed => {\n                return Some(format!(\"zed://ssh/{user_part}{remote_host}{path_str}\"));\n            }\n            _ => return None,\n        };\n\n        // files must contain a line and column number\n        let line_col = if path.is_file() { \":1:1\" } else { \"\" };\n        Some(format!(\n            \"{scheme}://vscode-remote/ssh-remote+{user_part}{remote_host}{path_str}{line_col}?windowId=_blank\"\n        ))\n    }\n\n    pub async fn spawn_local(&self, path: &Path) -> Result<(), EditorOpenError> {\n        let (executable, args) = self.resolve_command().await?;\n\n        use utils::command_ext::NoWindowExt;\n        let mut cmd = std::process::Command::new(&executable);\n        cmd.args(&args).arg(path);\n        cmd.no_window()\n            .spawn()\n            .map_err(|e| EditorOpenError::LaunchFailed {\n                executable: executable.to_string_lossy().into_owned(),\n                details: e.to_string(),\n                editor_type: self.editor_type.clone(),\n            })?;\n        Ok(())\n    }\n\n    pub fn with_override(&self, editor_type_str: Option<&str>) -> Self {\n        if let Some(editor_type_str) = editor_type_str {\n            let editor_type =\n                EditorType::from_str(editor_type_str).unwrap_or(self.editor_type.clone());\n            EditorConfig {\n                editor_type,\n                custom_command: self.custom_command.clone(),\n                remote_ssh_host: self.remote_ssh_host.clone(),\n                remote_ssh_user: self.remote_ssh_user.clone(),\n                auto_install_extension: self.auto_install_extension,\n            }\n        } else {\n            self.clone()\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/mod.rs",
    "content": "use std::path::PathBuf;\n\nuse thiserror::Error;\n\npub mod editor;\nmod versions;\n\npub use editor::EditorOpenError;\n\npub const DEFAULT_PR_DESCRIPTION_PROMPT: &str = r#\"Update the PR that was just created with a better title and description.\nThe PR number is #{pr_number} and the URL is {pr_url}.\n\nAnalyze the changes in this branch and write:\n1. A concise, descriptive title that summarizes the changes, postfixed with \"(Vibe Kanban)\"\n2. A detailed description that explains:\n   - What changes were made\n   - Why they were made (based on the task context)\n   - Any important implementation details\n   - At the end, include a note: \"This PR was written using [Vibe Kanban](https://vibekanban.com)\"\n\nUse the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps).\"#;\n\npub const DEFAULT_COMMIT_REMINDER_PROMPT: &str = \"There are uncommitted changes. Please stage and commit them now with a descriptive commit message.\";\n\n#[derive(Debug, Error)]\npub enum ConfigError {\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(transparent)]\n    Json(#[from] serde_json::Error),\n    #[error(\"Validation error: {0}\")]\n    ValidationError(String),\n}\n\npub type Config = versions::v8::Config;\npub type NotificationConfig = versions::v8::NotificationConfig;\npub type EditorConfig = versions::v8::EditorConfig;\npub type ThemeMode = versions::v8::ThemeMode;\npub type SoundFile = versions::v8::SoundFile;\npub type EditorType = versions::v8::EditorType;\npub type GitHubConfig = versions::v8::GitHubConfig;\npub type UiLanguage = versions::v8::UiLanguage;\npub type ShowcaseState = versions::v8::ShowcaseState;\npub type SendMessageShortcut = versions::v8::SendMessageShortcut;\n\n/// Will always return config, trying old schemas or eventually returning default\npub async fn load_config_from_file(config_path: &PathBuf) -> Config {\n    match std::fs::read_to_string(config_path) {\n        Ok(raw_config) => Config::from(raw_config),\n        Err(_) => {\n            tracing::info!(\"No config file found, creating one\");\n            Config::default()\n        }\n    }\n}\n\n/// Saves the config to the given path\npub async fn save_config_to_file(\n    config: &Config,\n    config_path: &PathBuf,\n) -> Result<(), ConfigError> {\n    let raw_config = serde_json::to_string_pretty(config)?;\n    std::fs::write(config_path, raw_config)?;\n    Ok(())\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/mod.rs",
    "content": "pub(super) mod v1;\npub(super) mod v2;\npub(super) mod v3;\npub(super) mod v4;\npub(super) mod v5;\npub(super) mod v6;\npub(super) mod v7;\npub(super) mod v8;\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v1.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub(super) struct Config {\n    pub(super) theme: ThemeMode,\n    pub(super) executor: ExecutorConfig,\n    pub(super) disclaimer_acknowledged: bool,\n    pub(super) onboarding_acknowledged: bool,\n    pub(super) github_login_acknowledged: bool,\n    pub(super) telemetry_acknowledged: bool,\n    pub(super) sound_alerts: bool,\n    pub(super) sound_file: SoundFile,\n    pub(super) push_notifications: bool,\n    pub(super) editor: EditorConfig,\n    pub(super) github: GitHubConfig,\n    pub(super) analytics_enabled: Option<bool>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"kebab-case\")]\npub(super) enum ExecutorConfig {\n    Echo,\n    Claude,\n    ClaudePlan,\n    Amp,\n    Gemini,\n    #[serde(alias = \"setup_script\")]\n    SetupScript {\n        script: String,\n    },\n    ClaudeCodeRouter,\n    #[serde(alias = \"charmopencode\")]\n    CharmOpencode,\n    #[serde(alias = \"opencode\")]\n    SstOpencode,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub(super) enum ThemeMode {\n    Light,\n    Dark,\n    System,\n    Purple,\n    Green,\n    Blue,\n    Orange,\n    Red,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub(super) struct EditorConfig {\n    pub editor_type: EditorType,\n    pub custom_command: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub(super) struct GitHubConfig {\n    pub pat: Option<String>,\n    pub token: Option<String>,\n    pub username: Option<String>,\n    pub primary_email: Option<String>,\n    pub default_pr_base: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub(super) enum EditorType {\n    VsCode,\n    Cursor,\n    Windsurf,\n    IntelliJ,\n    Zed,\n    Custom,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"kebab-case\")]\npub(super) enum SoundFile {\n    AbstractSound1,\n    AbstractSound2,\n    AbstractSound3,\n    AbstractSound4,\n    CowMooing,\n    PhoneVibration,\n    Rooster,\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v2.rs",
    "content": "use std::path::PathBuf;\n\nuse anyhow::Error;\nuse serde::{Deserialize, Serialize};\nuse strum_macros::EnumString;\nuse ts_rs::TS;\nuse utils::{assets::SoundAssets, cache_dir};\n\n// Re-export editor config from the dedicated editor module\npub use crate::services::config::editor::{EditorConfig, EditorType};\nuse crate::services::config::versions::v1;\n\n// Keep the From conversions here since v1 types are only accessible within versions module\nimpl From<v1::EditorConfig> for EditorConfig {\n    fn from(old: v1::EditorConfig) -> Self {\n        EditorConfig::new(\n            EditorType::from(old.editor_type),\n            old.custom_command,\n            None,\n            None,\n            true,\n        )\n    }\n}\n\nimpl From<v1::EditorType> for EditorType {\n    fn from(old: v1::EditorType) -> Self {\n        match old {\n            v1::EditorType::VsCode => EditorType::VsCode,\n            v1::EditorType::Cursor => EditorType::Cursor,\n            v1::EditorType::Windsurf => EditorType::Windsurf,\n            v1::EditorType::IntelliJ => EditorType::IntelliJ,\n            v1::EditorType::Zed => EditorType::Zed,\n            v1::EditorType::Custom => EditorType::Custom,\n        }\n    }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct Config {\n    pub config_version: String,\n    pub theme: ThemeMode,\n    pub profile: String,\n    pub disclaimer_acknowledged: bool,\n    pub onboarding_acknowledged: bool,\n    pub github_login_acknowledged: bool,\n    pub telemetry_acknowledged: bool,\n    pub notifications: NotificationConfig,\n    pub editor: EditorConfig,\n    pub github: GitHubConfig,\n    pub analytics_enabled: Option<bool>,\n    pub workspace_dir: Option<String>,\n}\n\nimpl Config {\n    pub fn from_previous_version(raw_config: &str) -> Result<Self, Error> {\n        let old_config = match serde_json::from_str::<v1::Config>(raw_config) {\n            Ok(cfg) => cfg,\n            Err(e) => {\n                tracing::error!(\"❌ Failed to parse config: {}\", e);\n                tracing::error!(\"   at line {}, column {}\", e.line(), e.column());\n                return Err(e.into());\n            }\n        };\n\n        let old_config_clone = old_config.clone();\n\n        let mut onboarding_acknowledged = old_config.onboarding_acknowledged;\n\n        // Map old executors to new profiles\n        let profile: &str = match old_config.executor {\n            v1::ExecutorConfig::Claude => \"claude-code\",\n            v1::ExecutorConfig::ClaudeCodeRouter => \"claude-code\",\n            v1::ExecutorConfig::ClaudePlan => \"claude-code-plan\",\n            v1::ExecutorConfig::Amp => \"amp\",\n            v1::ExecutorConfig::Gemini => \"gemini\",\n            v1::ExecutorConfig::SstOpencode => \"opencode\",\n            _ => {\n                onboarding_acknowledged = false; // Reset the user's onboarding if executor is not supported\n                \"claude-code\"\n            }\n        };\n\n        Ok(Self {\n            config_version: \"v2\".to_string(),\n            theme: ThemeMode::from(old_config.theme), // Now SCREAMING_SNAKE_CASE\n            profile: profile.to_string(),\n            disclaimer_acknowledged: old_config.disclaimer_acknowledged,\n            onboarding_acknowledged,\n            github_login_acknowledged: old_config.github_login_acknowledged,\n            telemetry_acknowledged: old_config.telemetry_acknowledged,\n            notifications: NotificationConfig::from(old_config_clone),\n            editor: EditorConfig::from(old_config.editor),\n            github: GitHubConfig::from(old_config.github),\n            analytics_enabled: None,\n            workspace_dir: None,\n        })\n    }\n}\n\nimpl From<String> for Config {\n    fn from(raw_config: String) -> Self {\n        if let Ok(config) = serde_json::from_str(&raw_config) {\n            config\n        } else if let Ok(config) = Self::from_previous_version(&raw_config) {\n            tracing::info!(\"Config upgraded from previous version\");\n            config\n        } else {\n            tracing::warn!(\"Config reset to default\");\n            Self::default()\n        }\n    }\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            config_version: \"v2\".to_string(),\n            theme: ThemeMode::System,\n            profile: String::from(\"claude-code\"),\n            disclaimer_acknowledged: false,\n            onboarding_acknowledged: false,\n            github_login_acknowledged: false,\n            telemetry_acknowledged: false,\n            notifications: NotificationConfig::default(),\n            editor: EditorConfig::default(),\n            github: GitHubConfig::default(),\n            analytics_enabled: None,\n            workspace_dir: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct GitHubConfig {\n    pub pat: Option<String>,\n    pub oauth_token: Option<String>,\n    pub username: Option<String>,\n    pub primary_email: Option<String>,\n    pub default_pr_base: Option<String>,\n}\n\nimpl From<v1::GitHubConfig> for GitHubConfig {\n    fn from(old: v1::GitHubConfig) -> Self {\n        Self {\n            pat: old.pat,\n            oauth_token: old.token, // Map to new field name\n            username: old.username,\n            primary_email: old.primary_email,\n            default_pr_base: old.default_pr_base,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct NotificationConfig {\n    pub sound_enabled: bool,\n    pub push_enabled: bool,\n    pub sound_file: SoundFile,\n}\n\nimpl From<v1::Config> for NotificationConfig {\n    fn from(old: v1::Config) -> Self {\n        Self {\n            sound_enabled: old.sound_alerts,\n            push_enabled: old.push_notifications,\n            sound_file: SoundFile::from(old.sound_file), // Now SCREAMING_SNAKE_CASE\n        }\n    }\n}\n\nimpl Default for NotificationConfig {\n    fn default() -> Self {\n        Self {\n            sound_enabled: true,\n            push_enabled: true,\n            sound_file: SoundFile::CowMooing,\n        }\n    }\n}\n\nimpl Default for GitHubConfig {\n    fn default() -> Self {\n        Self {\n            pat: None,\n            oauth_token: None,\n            username: None,\n            primary_email: None,\n            default_pr_base: Some(\"main\".to_string()),\n        }\n    }\n}\n\nimpl GitHubConfig {\n    pub fn token(&self) -> Option<String> {\n        self.pat\n            .as_deref()\n            .or(self.oauth_token.as_deref())\n            .map(|s| s.to_string())\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)]\n#[ts(use_ts_enum)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[strum(serialize_all = \"SCREAMING_SNAKE_CASE\")]\npub enum SoundFile {\n    AbstractSound1,\n    AbstractSound2,\n    AbstractSound3,\n    AbstractSound4,\n    CowMooing,\n    Fahhhhh,\n    PhoneVibration,\n    Rooster,\n}\n\nimpl SoundFile {\n    pub fn to_filename(&self) -> &'static str {\n        match self {\n            SoundFile::AbstractSound1 => \"abstract-sound1.wav\",\n            SoundFile::AbstractSound2 => \"abstract-sound2.wav\",\n            SoundFile::AbstractSound3 => \"abstract-sound3.wav\",\n            SoundFile::AbstractSound4 => \"abstract-sound4.wav\",\n            SoundFile::CowMooing => \"cow-mooing.wav\",\n            SoundFile::Fahhhhh => \"fahhhhh.wav\",\n            SoundFile::PhoneVibration => \"phone-vibration.wav\",\n            SoundFile::Rooster => \"rooster.wav\",\n        }\n    }\n\n    // load the sound file from the embedded assets or cache\n    pub async fn serve(&self) -> Result<rust_embed::EmbeddedFile, Error> {\n        match SoundAssets::get(self.to_filename()) {\n            Some(content) => Ok(content),\n            None => {\n                tracing::error!(\"Sound file not found: {}\", self.to_filename());\n                Err(anyhow::anyhow!(\n                    \"Sound file not found: {}\",\n                    self.to_filename()\n                ))\n            }\n        }\n    }\n    /// Get or create a cached sound file with the embedded sound data\n    pub async fn get_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {\n        use std::io::Write;\n\n        let filename = self.to_filename();\n        let cache_dir = cache_dir();\n        let cached_path = cache_dir.join(format!(\"sound-{filename}\"));\n\n        // Check if cached file already exists and is valid\n        if cached_path.exists() {\n            // Verify file has content (basic validation)\n            if let Ok(metadata) = std::fs::metadata(&cached_path)\n                && metadata.len() > 0\n            {\n                return Ok(cached_path);\n            }\n        }\n\n        // File doesn't exist or is invalid, create it\n        let sound_data = SoundAssets::get(filename)\n            .ok_or_else(|| format!(\"Embedded sound file not found: {filename}\"))?\n            .data;\n\n        // Ensure cache directory exists\n        std::fs::create_dir_all(&cache_dir)\n            .map_err(|e| format!(\"Failed to create cache directory: {e}\"))?;\n\n        let mut file = std::fs::File::create(&cached_path)\n            .map_err(|e| format!(\"Failed to create cached sound file: {e}\"))?;\n\n        file.write_all(&sound_data)\n            .map_err(|e| format!(\"Failed to write sound data to cached file: {e}\"))?;\n\n        drop(file); // Ensure file is closed\n\n        Ok(cached_path)\n    }\n}\n\nimpl From<v1::SoundFile> for SoundFile {\n    fn from(old: v1::SoundFile) -> Self {\n        match old {\n            v1::SoundFile::AbstractSound1 => SoundFile::AbstractSound1,\n            v1::SoundFile::AbstractSound2 => SoundFile::AbstractSound2,\n            v1::SoundFile::AbstractSound3 => SoundFile::AbstractSound3,\n            v1::SoundFile::AbstractSound4 => SoundFile::AbstractSound4,\n            v1::SoundFile::CowMooing => SoundFile::CowMooing,\n            v1::SoundFile::PhoneVibration => SoundFile::PhoneVibration,\n            v1::SoundFile::Rooster => SoundFile::Rooster,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)]\n#[ts(use_ts_enum)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[strum(serialize_all = \"SCREAMING_SNAKE_CASE\")]\npub enum ThemeMode {\n    Light,\n    Dark,\n    System,\n    Purple,\n    Green,\n    Blue,\n    Orange,\n    Red,\n}\n\nimpl From<v1::ThemeMode> for ThemeMode {\n    fn from(old: v1::ThemeMode) -> Self {\n        match old {\n            v1::ThemeMode::Light => ThemeMode::Light,\n            v1::ThemeMode::Dark => ThemeMode::Dark,\n            v1::ThemeMode::System => ThemeMode::System,\n            v1::ThemeMode::Purple => ThemeMode::Purple,\n            v1::ThemeMode::Green => ThemeMode::Green,\n            v1::ThemeMode::Blue => ThemeMode::Blue,\n            v1::ThemeMode::Orange => ThemeMode::Orange,\n            v1::ThemeMode::Red => ThemeMode::Red,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v3.rs",
    "content": "use anyhow::Error;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\npub use v2::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode};\n\nuse crate::services::config::versions::v2;\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct Config {\n    pub config_version: String,\n    pub theme: ThemeMode,\n    pub profile: String,\n    pub disclaimer_acknowledged: bool,\n    pub onboarding_acknowledged: bool,\n    pub github_login_acknowledged: bool,\n    pub telemetry_acknowledged: bool,\n    pub notifications: NotificationConfig,\n    pub editor: EditorConfig,\n    pub github: GitHubConfig,\n    pub analytics_enabled: Option<bool>,\n    pub workspace_dir: Option<String>,\n}\n\nimpl Config {\n    pub fn from_previous_version(raw_config: &str) -> Result<Self, Error> {\n        let old_config = match serde_json::from_str::<v2::Config>(raw_config) {\n            Ok(cfg) => cfg,\n            Err(e) => {\n                tracing::error!(\"❌ Failed to parse config: {}\", e);\n                tracing::error!(\"   at line {}, column {}\", e.line(), e.column());\n                return Err(e.into());\n            }\n        };\n\n        Ok(Self {\n            config_version: \"v3\".to_string(),\n            theme: old_config.theme,\n            profile: old_config.profile,\n            disclaimer_acknowledged: old_config.disclaimer_acknowledged,\n            onboarding_acknowledged: old_config.onboarding_acknowledged,\n            github_login_acknowledged: old_config.github_login_acknowledged,\n            telemetry_acknowledged: false,\n            notifications: old_config.notifications,\n            editor: old_config.editor,\n            github: old_config.github,\n            analytics_enabled: old_config.analytics_enabled,\n            workspace_dir: old_config.workspace_dir,\n        })\n    }\n}\n\nimpl From<String> for Config {\n    fn from(raw_config: String) -> Self {\n        if let Ok(config) = serde_json::from_str::<Config>(&raw_config)\n            && config.config_version == \"v3\"\n        {\n            return config;\n        }\n\n        match Self::from_previous_version(&raw_config) {\n            Ok(config) => {\n                tracing::info!(\"Config upgraded to v3\");\n                config\n            }\n            Err(e) => {\n                tracing::warn!(\"Config migration failed: {}, using default\", e);\n                Self::default()\n            }\n        }\n    }\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            config_version: \"v3\".to_string(),\n            theme: ThemeMode::System,\n            profile: String::from(\"claude-code\"),\n            disclaimer_acknowledged: false,\n            onboarding_acknowledged: false,\n            github_login_acknowledged: false,\n            telemetry_acknowledged: false,\n            notifications: NotificationConfig::default(),\n            editor: EditorConfig::default(),\n            github: GitHubConfig::default(),\n            analytics_enabled: None,\n            workspace_dir: None,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v4.rs",
    "content": "use anyhow::Error;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\npub use v3::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode};\n\nuse crate::services::config::versions::v3;\n\n// DEPRECATED\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]\npub struct ProfileVariantLabel {\n    pub profile: String,\n    pub variant: Option<String>,\n}\n\nimpl ProfileVariantLabel {\n    pub fn default(profile: String) -> Self {\n        Self {\n            profile,\n            variant: None,\n        }\n    }\n    pub fn with_variant(profile: String, mode: String) -> Self {\n        Self {\n            profile,\n            variant: Some(mode),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct Config {\n    pub config_version: String,\n    pub theme: ThemeMode,\n    pub profile: ProfileVariantLabel,\n    pub disclaimer_acknowledged: bool,\n    pub onboarding_acknowledged: bool,\n    pub github_login_acknowledged: bool,\n    pub telemetry_acknowledged: bool,\n    pub notifications: NotificationConfig,\n    pub editor: EditorConfig,\n    pub github: GitHubConfig,\n    pub analytics_enabled: Option<bool>,\n    pub workspace_dir: Option<String>,\n}\n\nimpl Config {\n    pub fn from_previous_version(raw_config: &str) -> Result<Self, Error> {\n        let old_config = match serde_json::from_str::<v3::Config>(raw_config) {\n            Ok(cfg) => cfg,\n            Err(e) => {\n                tracing::error!(\"❌ Failed to parse config: {}\", e);\n                tracing::error!(\"   at line {}, column {}\", e.line(), e.column());\n                return Err(e.into());\n            }\n        };\n        let mut onboarding_acknowledged = old_config.onboarding_acknowledged;\n        let profile = match old_config.profile.as_str() {\n            \"claude-code\" => ProfileVariantLabel::default(\"claude-code\".to_string()),\n            \"claude-code-plan\" => {\n                ProfileVariantLabel::with_variant(\"claude-code\".to_string(), \"plan\".to_string())\n            }\n            \"claude-code-router\" => {\n                ProfileVariantLabel::with_variant(\"claude-code\".to_string(), \"router\".to_string())\n            }\n            \"amp\" => ProfileVariantLabel::default(\"amp\".to_string()),\n            \"gemini\" => ProfileVariantLabel::default(\"gemini\".to_string()),\n            \"codex\" => ProfileVariantLabel::default(\"codex\".to_string()),\n            \"opencode\" => ProfileVariantLabel::default(\"opencode\".to_string()),\n            \"qwen-code\" => ProfileVariantLabel::default(\"qwen-code\".to_string()),\n            _ => {\n                onboarding_acknowledged = false; // Reset the user's onboarding if executor is not supported\n                ProfileVariantLabel::default(\"claude-code\".to_string())\n            }\n        };\n\n        Ok(Self {\n            config_version: \"v4\".to_string(),\n            theme: old_config.theme,\n            profile,\n            disclaimer_acknowledged: old_config.disclaimer_acknowledged,\n            onboarding_acknowledged,\n            github_login_acknowledged: old_config.github_login_acknowledged,\n            telemetry_acknowledged: old_config.telemetry_acknowledged,\n            notifications: old_config.notifications,\n            editor: old_config.editor,\n            github: old_config.github,\n            analytics_enabled: old_config.analytics_enabled,\n            workspace_dir: old_config.workspace_dir,\n        })\n    }\n}\n\nimpl From<String> for Config {\n    fn from(raw_config: String) -> Self {\n        if let Ok(config) = serde_json::from_str::<Config>(&raw_config)\n            && config.config_version == \"v4\"\n        {\n            return config;\n        }\n\n        match Self::from_previous_version(&raw_config) {\n            Ok(config) => {\n                tracing::info!(\"Config upgraded to v3\");\n                config\n            }\n            Err(e) => {\n                tracing::warn!(\"Config migration failed: {}, using default\", e);\n                Self::default()\n            }\n        }\n    }\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            config_version: \"v4\".to_string(),\n            theme: ThemeMode::System,\n            profile: ProfileVariantLabel::default(\"claude-code\".to_string()),\n            disclaimer_acknowledged: false,\n            onboarding_acknowledged: false,\n            github_login_acknowledged: false,\n            telemetry_acknowledged: false,\n            notifications: NotificationConfig::default(),\n            editor: EditorConfig::default(),\n            github: GitHubConfig::default(),\n            analytics_enabled: None,\n            workspace_dir: None,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v5.rs",
    "content": "use anyhow::Error;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\npub use v4::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode};\n\nuse crate::services::config::versions::v4::{self, ProfileVariantLabel};\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct Config {\n    pub config_version: String,\n    pub theme: ThemeMode,\n    pub profile: ProfileVariantLabel,\n    pub disclaimer_acknowledged: bool,\n    pub onboarding_acknowledged: bool,\n    pub github_login_acknowledged: bool,\n    pub telemetry_acknowledged: bool,\n    pub notifications: NotificationConfig,\n    pub editor: EditorConfig,\n    pub github: GitHubConfig,\n    pub analytics_enabled: Option<bool>,\n    pub workspace_dir: Option<String>,\n    pub last_app_version: Option<String>,\n    pub show_release_notes: bool,\n}\n\nimpl Config {\n    pub fn from_previous_version(raw_config: &str) -> Result<Self, Error> {\n        let old_config = match serde_json::from_str::<v4::Config>(raw_config) {\n            Ok(cfg) => cfg,\n            Err(e) => {\n                tracing::error!(\"❌ Failed to parse config: {}\", e);\n                tracing::error!(\"   at line {}, column {}\", e.line(), e.column());\n                return Err(e.into());\n            }\n        };\n\n        Ok(Self {\n            config_version: \"v5\".to_string(),\n            theme: old_config.theme,\n            profile: old_config.profile,\n            disclaimer_acknowledged: old_config.disclaimer_acknowledged,\n            onboarding_acknowledged: old_config.onboarding_acknowledged,\n            github_login_acknowledged: old_config.github_login_acknowledged,\n            telemetry_acknowledged: old_config.telemetry_acknowledged,\n            notifications: old_config.notifications,\n            editor: old_config.editor,\n            github: old_config.github,\n            analytics_enabled: old_config.analytics_enabled,\n            workspace_dir: old_config.workspace_dir,\n            last_app_version: None,\n            show_release_notes: false,\n        })\n    }\n}\n\nimpl From<String> for Config {\n    fn from(raw_config: String) -> Self {\n        if let Ok(config) = serde_json::from_str::<Config>(&raw_config)\n            && config.config_version == \"v5\"\n        {\n            return config;\n        }\n\n        match Self::from_previous_version(&raw_config) {\n            Ok(config) => {\n                tracing::info!(\"Config upgraded to v5\");\n                config\n            }\n            Err(e) => {\n                tracing::warn!(\"Config migration failed: {}, using default\", e);\n                Self::default()\n            }\n        }\n    }\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            config_version: \"v5\".to_string(),\n            theme: ThemeMode::System,\n            profile: ProfileVariantLabel::default(\"claude-code\".to_string()),\n            disclaimer_acknowledged: false,\n            onboarding_acknowledged: false,\n            github_login_acknowledged: false,\n            telemetry_acknowledged: false,\n            notifications: NotificationConfig::default(),\n            editor: EditorConfig::default(),\n            github: GitHubConfig::default(),\n            analytics_enabled: None,\n            workspace_dir: None,\n            last_app_version: None,\n            show_release_notes: false,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v6.rs",
    "content": "use std::str::FromStr;\n\nuse anyhow::Error;\nuse executors::{executors::BaseCodingAgent, profile::ExecutorProfileId};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse utils;\npub use v5::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode};\n\nuse crate::services::config::versions::v5;\n\n#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS, Default)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum UiLanguage {\n    #[default]\n    Browser, // Detect from browser\n    En,     // Force English\n    Fr,     // Force French\n    Ja,     // Force Japanese\n    Es,     // Force Spanish\n    Ko,     // Force Korean\n    ZhHans, // Force Simplified Chinese\n    ZhHant, // Force Traditional Chinese\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct Config {\n    pub config_version: String,\n    pub theme: ThemeMode,\n    pub executor_profile: ExecutorProfileId,\n    pub disclaimer_acknowledged: bool,\n    pub onboarding_acknowledged: bool,\n    pub github_login_acknowledged: bool,\n    pub telemetry_acknowledged: bool,\n    pub notifications: NotificationConfig,\n    pub editor: EditorConfig,\n    pub github: GitHubConfig,\n    pub analytics_enabled: Option<bool>,\n    pub workspace_dir: Option<String>,\n    pub last_app_version: Option<String>,\n    pub show_release_notes: bool,\n    #[serde(default)]\n    pub language: UiLanguage,\n}\n\nimpl Config {\n    pub fn from_previous_version(raw_config: &str) -> Result<Self, Error> {\n        let old_config = match serde_json::from_str::<v5::Config>(raw_config) {\n            Ok(cfg) => cfg,\n            Err(e) => {\n                tracing::error!(\"❌ Failed to parse config: {}\", e);\n                tracing::error!(\"   at line {}, column {}\", e.line(), e.column());\n                return Err(e.into());\n            }\n        };\n\n        // Backup custom profiles.json if it exists (v6 migration may break compatibility)\n        let profiles_path = utils::assets::profiles_path();\n        if profiles_path.exists() {\n            let backup_name = format!(\n                \"profiles_v5_backup_{}.json\",\n                std::time::SystemTime::now()\n                    .duration_since(std::time::UNIX_EPOCH)\n                    .unwrap()\n                    .as_secs()\n            );\n            let backup_path = profiles_path.parent().unwrap().join(backup_name);\n\n            if let Err(e) = std::fs::rename(&profiles_path, &backup_path) {\n                tracing::warn!(\"Failed to backup profiles.json: {}\", e);\n            } else {\n                tracing::info!(\"Custom profiles.json backed up to {:?}\", backup_path);\n                tracing::info!(\"Please review your custom profiles after migration to v6\");\n            }\n        }\n\n        // Validate and convert ProfileVariantLabel\n        let old_coding_agent = old_config.profile.profile.to_uppercase();\n        let base_coding_agent =\n            BaseCodingAgent::from_str(&old_coding_agent).unwrap_or(BaseCodingAgent::ClaudeCode);\n        let executor_profile = ExecutorProfileId::new(base_coding_agent);\n\n        Ok(Self {\n            config_version: \"v6\".to_string(),\n            theme: old_config.theme,\n            executor_profile,\n            disclaimer_acknowledged: old_config.disclaimer_acknowledged,\n            onboarding_acknowledged: old_config.onboarding_acknowledged,\n            github_login_acknowledged: old_config.github_login_acknowledged,\n            telemetry_acknowledged: old_config.telemetry_acknowledged,\n            notifications: old_config.notifications,\n            editor: old_config.editor,\n            github: old_config.github,\n            analytics_enabled: old_config.analytics_enabled,\n            workspace_dir: old_config.workspace_dir,\n            last_app_version: old_config.last_app_version,\n            show_release_notes: old_config.show_release_notes,\n            language: UiLanguage::default(),\n        })\n    }\n}\n\nimpl From<String> for Config {\n    fn from(raw_config: String) -> Self {\n        if let Ok(config) = serde_json::from_str::<Config>(&raw_config)\n            && config.config_version == \"v6\"\n        {\n            return config;\n        }\n\n        match Self::from_previous_version(&raw_config) {\n            Ok(config) => {\n                tracing::info!(\"Config upgraded to v6\");\n                config\n            }\n            Err(e) => {\n                tracing::warn!(\"Config migration failed: {}, using default\", e);\n                Self::default()\n            }\n        }\n    }\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            config_version: \"v6\".to_string(),\n            theme: ThemeMode::System,\n            executor_profile: ExecutorProfileId::new(BaseCodingAgent::ClaudeCode),\n            disclaimer_acknowledged: false,\n            onboarding_acknowledged: false,\n            github_login_acknowledged: false,\n            telemetry_acknowledged: false,\n            notifications: NotificationConfig::default(),\n            editor: EditorConfig::default(),\n            github: GitHubConfig::default(),\n            analytics_enabled: None,\n            workspace_dir: None,\n            last_app_version: None,\n            show_release_notes: false,\n            language: UiLanguage::default(),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v7.rs",
    "content": "use anyhow::Error;\nuse executors::{executors::BaseCodingAgent, profile::ExecutorProfileId};\nuse serde::{Deserialize, Serialize};\nuse strum_macros::EnumString;\nuse ts_rs::TS;\npub use v6::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, UiLanguage};\n\nuse crate::services::config::versions::v6;\n\nfn default_git_branch_prefix() -> String {\n    \"vk\".to_string()\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS, Default)]\npub struct ShowcaseState {\n    #[serde(default)]\n    pub seen_features: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)]\n#[ts(use_ts_enum)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n#[strum(serialize_all = \"SCREAMING_SNAKE_CASE\")]\npub enum ThemeMode {\n    Light,\n    Dark,\n    System,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct Config {\n    pub config_version: String,\n    pub theme: ThemeMode,\n    pub executor_profile: ExecutorProfileId,\n    pub disclaimer_acknowledged: bool,\n    pub onboarding_acknowledged: bool,\n    pub github_login_acknowledged: bool,\n    #[serde(default)]\n    pub login_acknowledged: bool,\n    pub telemetry_acknowledged: bool,\n    pub notifications: NotificationConfig,\n    pub editor: EditorConfig,\n    pub github: GitHubConfig,\n    pub analytics_enabled: Option<bool>,\n    pub workspace_dir: Option<String>,\n    pub last_app_version: Option<String>,\n    pub show_release_notes: bool,\n    #[serde(default)]\n    pub language: UiLanguage,\n    #[serde(default = \"default_git_branch_prefix\")]\n    pub git_branch_prefix: String,\n    #[serde(default)]\n    pub showcases: ShowcaseState,\n}\n\nimpl Config {\n    pub fn from_previous_version(raw_config: &str) -> Result<Self, Error> {\n        let old_config = match serde_json::from_str::<v6::Config>(raw_config) {\n            Ok(cfg) => cfg,\n            Err(e) => {\n                tracing::error!(\"❌ Failed to parse config: {}\", e);\n                tracing::error!(\"   at line {}, column {}\", e.line(), e.column());\n                return Err(e.into());\n            }\n        };\n\n        // Map old theme modes to new simplified theme modes\n        let theme = match old_config.theme {\n            v6::ThemeMode::Light => ThemeMode::Light,\n            v6::ThemeMode::Dark => ThemeMode::Dark,\n            v6::ThemeMode::System => ThemeMode::System,\n            // Map all color themes to System (respects user's OS preference)\n            v6::ThemeMode::Purple\n            | v6::ThemeMode::Green\n            | v6::ThemeMode::Blue\n            | v6::ThemeMode::Orange\n            | v6::ThemeMode::Red => {\n                tracing::info!(\n                    \"Migrating color theme {:?} to System theme\",\n                    old_config.theme\n                );\n                ThemeMode::System\n            }\n        };\n\n        Ok(Self {\n            config_version: \"v7\".to_string(),\n            theme,\n            executor_profile: old_config.executor_profile,\n            disclaimer_acknowledged: old_config.disclaimer_acknowledged,\n            onboarding_acknowledged: old_config.onboarding_acknowledged,\n            github_login_acknowledged: old_config.github_login_acknowledged,\n            login_acknowledged: false,\n            telemetry_acknowledged: old_config.telemetry_acknowledged,\n            notifications: old_config.notifications,\n            editor: old_config.editor,\n            github: old_config.github,\n            analytics_enabled: old_config.analytics_enabled,\n            workspace_dir: old_config.workspace_dir,\n            last_app_version: old_config.last_app_version,\n            show_release_notes: old_config.show_release_notes,\n            language: old_config.language,\n            git_branch_prefix: default_git_branch_prefix(),\n            showcases: ShowcaseState::default(),\n        })\n    }\n}\n\nimpl From<String> for Config {\n    fn from(raw_config: String) -> Self {\n        if let Ok(config) = serde_json::from_str::<Config>(&raw_config)\n            && config.config_version == \"v7\"\n        {\n            return config;\n        }\n\n        match Self::from_previous_version(&raw_config) {\n            Ok(config) => {\n                tracing::info!(\"Config upgraded to v7\");\n                config\n            }\n            Err(e) => {\n                tracing::warn!(\"Config migration failed: {}, using default\", e);\n                Self::default()\n            }\n        }\n    }\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            config_version: \"v7\".to_string(),\n            theme: ThemeMode::System,\n            executor_profile: ExecutorProfileId::new(BaseCodingAgent::ClaudeCode),\n            disclaimer_acknowledged: false,\n            onboarding_acknowledged: false,\n            github_login_acknowledged: false,\n            login_acknowledged: false,\n            telemetry_acknowledged: false,\n            notifications: NotificationConfig::default(),\n            editor: EditorConfig::default(),\n            github: GitHubConfig::default(),\n            analytics_enabled: None,\n            workspace_dir: None,\n            last_app_version: None,\n            show_release_notes: false,\n            language: UiLanguage::default(),\n            git_branch_prefix: default_git_branch_prefix(),\n            showcases: ShowcaseState::default(),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/config/versions/v8.rs",
    "content": "use anyhow::Error;\nuse executors::{executors::BaseCodingAgent, profile::ExecutorProfileId};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\npub use v7::{\n    EditorConfig, EditorType, GitHubConfig, NotificationConfig, ShowcaseState, SoundFile,\n    ThemeMode, UiLanguage,\n};\n\nuse crate::services::config::versions::v7;\n\nfn default_git_branch_prefix() -> String {\n    \"vk\".to_string()\n}\n\nfn default_pr_auto_description_enabled() -> bool {\n    true\n}\n\nfn default_commit_reminder_enabled() -> bool {\n    true\n}\n\nfn default_relay_enabled() -> bool {\n    true\n}\n\n#[derive(Clone, Debug, Default, Serialize, Deserialize, TS, PartialEq, Eq)]\npub enum SendMessageShortcut {\n    #[default]\n    ModifierEnter,\n    Enter,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, TS)]\npub struct Config {\n    pub config_version: String,\n    pub theme: ThemeMode,\n    pub executor_profile: ExecutorProfileId,\n    pub disclaimer_acknowledged: bool,\n    pub onboarding_acknowledged: bool,\n    #[serde(default)]\n    pub remote_onboarding_acknowledged: bool,\n    pub notifications: NotificationConfig,\n    pub editor: EditorConfig,\n    pub github: GitHubConfig,\n    pub analytics_enabled: bool,\n    pub workspace_dir: Option<String>,\n    pub last_app_version: Option<String>,\n    pub show_release_notes: bool,\n    #[serde(default)]\n    pub language: UiLanguage,\n    #[serde(default = \"default_git_branch_prefix\")]\n    pub git_branch_prefix: String,\n    #[serde(default)]\n    pub showcases: ShowcaseState,\n    #[serde(default = \"default_pr_auto_description_enabled\")]\n    pub pr_auto_description_enabled: bool,\n    #[serde(default)]\n    pub pr_auto_description_prompt: Option<String>,\n    #[serde(default = \"default_commit_reminder_enabled\")]\n    pub commit_reminder_enabled: bool,\n    #[serde(default)]\n    pub commit_reminder_prompt: Option<String>,\n    #[serde(default)]\n    pub send_message_shortcut: SendMessageShortcut,\n    #[serde(default = \"default_relay_enabled\")]\n    pub relay_enabled: bool,\n    #[serde(default)]\n    pub relay_host_name: Option<String>,\n}\n\nimpl Config {\n    fn from_v7_config(old_config: v7::Config) -> Self {\n        // Convert Option<bool> to bool: None or Some(true) become true, Some(false) stays false\n        let analytics_enabled = old_config.analytics_enabled.unwrap_or(true);\n\n        Self {\n            config_version: \"v8\".to_string(),\n            theme: old_config.theme,\n            executor_profile: old_config.executor_profile,\n            disclaimer_acknowledged: old_config.disclaimer_acknowledged,\n            onboarding_acknowledged: old_config.onboarding_acknowledged,\n            remote_onboarding_acknowledged: false,\n            notifications: old_config.notifications,\n            editor: old_config.editor,\n            github: old_config.github,\n            analytics_enabled,\n            workspace_dir: old_config.workspace_dir,\n            last_app_version: old_config.last_app_version,\n            show_release_notes: old_config.show_release_notes,\n            language: old_config.language,\n            git_branch_prefix: old_config.git_branch_prefix,\n            showcases: old_config.showcases,\n            pr_auto_description_enabled: true,\n            pr_auto_description_prompt: None,\n            commit_reminder_enabled: true,\n            commit_reminder_prompt: None,\n            send_message_shortcut: SendMessageShortcut::default(),\n            relay_enabled: true,\n            relay_host_name: None,\n        }\n    }\n\n    pub fn from_previous_version(raw_config: &str) -> Result<Self, Error> {\n        let old_config = v7::Config::from(raw_config.to_string());\n        Ok(Self::from_v7_config(old_config))\n    }\n}\n\nimpl From<String> for Config {\n    fn from(raw_config: String) -> Self {\n        if let Ok(config) = serde_json::from_str::<Config>(&raw_config)\n            && config.config_version == \"v8\"\n        {\n            return config;\n        }\n\n        match Self::from_previous_version(&raw_config) {\n            Ok(config) => {\n                tracing::info!(\"Config upgraded to v8\");\n                config\n            }\n            Err(e) => {\n                tracing::warn!(\"Config migration failed: {}, using default\", e);\n                Self::default()\n            }\n        }\n    }\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            config_version: \"v8\".to_string(),\n            theme: ThemeMode::System,\n            executor_profile: ExecutorProfileId::new(BaseCodingAgent::ClaudeCode),\n            disclaimer_acknowledged: false,\n            onboarding_acknowledged: false,\n            remote_onboarding_acknowledged: false,\n            notifications: NotificationConfig::default(),\n            editor: EditorConfig::default(),\n            github: GitHubConfig::default(),\n            analytics_enabled: true,\n            workspace_dir: None,\n            last_app_version: None,\n            show_release_notes: false,\n            language: UiLanguage::default(),\n            git_branch_prefix: default_git_branch_prefix(),\n            showcases: ShowcaseState::default(),\n            pr_auto_description_enabled: true,\n            pr_auto_description_prompt: None,\n            commit_reminder_enabled: true,\n            commit_reminder_prompt: None,\n            send_message_shortcut: SendMessageShortcut::default(),\n            relay_enabled: true,\n            relay_host_name: None,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/container.rs",
    "content": "use std::{\n    collections::{HashMap, HashSet},\n    path::{Path, PathBuf},\n    sync::Arc,\n};\n\nuse anyhow::{Error as AnyhowError, anyhow};\nuse async_trait::async_trait;\nuse db::{\n    DBService,\n    models::{\n        coding_agent_turn::{CodingAgentTurn, CreateCodingAgentTurn},\n        execution_process::{\n            CreateExecutionProcess, ExecutionContext, ExecutionProcess, ExecutionProcessError,\n            ExecutionProcessRunReason, ExecutionProcessStatus,\n        },\n        execution_process_repo_state::{\n            CreateExecutionProcessRepoState, ExecutionProcessRepoState,\n        },\n        repo::Repo,\n        session::{CreateSession, Session, SessionError},\n        workspace::{Workspace, WorkspaceError},\n        workspace_repo::WorkspaceRepo,\n    },\n};\n#[cfg(feature = \"qa-mode\")]\nuse executors::executors::qa_mock::QaMockExecutor;\n#[cfg(not(feature = \"qa-mode\"))]\nuse executors::profile::ExecutorConfigs;\nuse executors::{\n    actions::{\n        ExecutorAction, ExecutorActionType,\n        coding_agent_initial::CodingAgentInitialRequest,\n        script::{ScriptContext, ScriptRequest, ScriptRequestLanguage},\n    },\n    executors::{ExecutorError, StandardCodingAgentExecutor},\n    logs::{\n        NormalizedEntry, NormalizedEntryError, NormalizedEntryType,\n        utils::{\n            ConversationPatch,\n            patch::{fix_patch_ops, is_add_or_replace, patch_entry_path},\n        },\n    },\n    profile::{ExecutorConfig, ExecutorProfileId},\n};\nuse futures::{StreamExt, future, stream::BoxStream};\nuse git::{GitService, GitServiceError};\nuse json_patch::Patch;\nuse sqlx::Error as SqlxError;\nuse thiserror::Error;\nuse tokio::{sync::RwLock, task::JoinHandle};\nuse utils::{\n    log_msg::LogMsg,\n    msg_store::MsgStore,\n    text::{git_branch_id, short_uuid},\n};\nuse uuid::Uuid;\nuse worktree_manager::WorktreeError;\n\nuse crate::services::{execution_process, notification::NotificationService};\npub type ContainerRef = String;\n\n#[derive(Debug, Error)]\npub enum ContainerError {\n    #[error(transparent)]\n    GitServiceError(#[from] GitServiceError),\n    #[error(transparent)]\n    Sqlx(#[from] SqlxError),\n    #[error(transparent)]\n    ExecutorError(#[from] ExecutorError),\n    #[error(transparent)]\n    Worktree(#[from] WorktreeError),\n    #[error(transparent)]\n    Workspace(#[from] WorkspaceError),\n    #[error(transparent)]\n    Session(#[from] SessionError),\n    #[error(transparent)]\n    ExecutionProcess(#[from] ExecutionProcessError),\n    #[error(\"Io error: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"Failed to kill process: {0}\")]\n    KillFailed(std::io::Error),\n    #[error(transparent)]\n    Other(#[from] AnyhowError), // Catches any unclassified errors\n}\n\n#[async_trait]\npub trait ContainerService {\n    fn msg_stores(&self) -> &Arc<RwLock<HashMap<Uuid, Arc<MsgStore>>>>;\n\n    fn db(&self) -> &DBService;\n\n    fn git(&self) -> &GitService;\n\n    fn notification_service(&self) -> &NotificationService;\n\n    async fn touch(&self, workspace: &Workspace) -> Result<(), ContainerError>;\n\n    fn workspace_to_current_dir(&self, workspace: &Workspace) -> PathBuf;\n\n    async fn discover_executor_options(\n        &self,\n        executor_profile_id: ExecutorProfileId,\n        session_id: Option<Uuid>,\n        workspace_id: Option<Uuid>,\n        repo_id: Option<Uuid>,\n    ) -> Result<Option<BoxStream<'static, Patch>>, ContainerError> {\n        let (workdir, repo_path) = if let Some(session_id) = session_id {\n            let session = Session::find_by_id(&self.db().pool, session_id)\n                .await?\n                .ok_or(SqlxError::RowNotFound)?;\n\n            if let Some(workspace_id) = workspace_id\n                && session.workspace_id != workspace_id\n            {\n                return Err(ContainerError::Other(anyhow!(\n                    \"Session does not belong to workspace\"\n                )));\n            }\n\n            let workspace = Workspace::find_by_id(&self.db().pool, session.workspace_id)\n                .await?\n                .ok_or(SqlxError::RowNotFound)?;\n\n            let container_ref = match workspace.container_ref.as_deref() {\n                Some(container_ref) if !container_ref.is_empty() => container_ref,\n                _ => &self.ensure_container_exists(&workspace).await?,\n            };\n\n            if container_ref.is_empty() {\n                return Err(ContainerError::Other(anyhow!(\"Workspace path is empty\")));\n            }\n\n            let workspace_path = PathBuf::from(container_ref);\n            let workdir = match session.agent_working_dir.as_deref() {\n                Some(dir) if !dir.is_empty() => Some(workspace_path.join(dir)),\n                _ => Some(workspace_path),\n            };\n\n            let repos =\n                WorkspaceRepo::find_repos_for_workspace(&self.db().pool, session.workspace_id)\n                    .await\n                    .unwrap_or_default();\n            let repo_path = if repos.len() == 1 {\n                Some(repos[0].path.clone())\n            } else {\n                None\n            };\n\n            (workdir, repo_path)\n        } else if workspace_id.is_some() {\n            return Err(ContainerError::Other(anyhow!(\n                \"session_id is required when workspace_id is provided\"\n            )));\n        } else if let Some(repo_id) = repo_id {\n            let repo = Repo::find_by_id(&self.db().pool, repo_id)\n                .await\n                .ok()\n                .flatten()\n                .map(|repo| repo.path);\n            (None, repo)\n        } else {\n            (None, None)\n        };\n\n        #[cfg(feature = \"qa-mode\")]\n        {\n            let _ = executor_profile_id;\n            let _ = workdir;\n            let _ = repo_path;\n            return Ok(None);\n        }\n        #[cfg(not(feature = \"qa-mode\"))]\n        {\n            let executor =\n                ExecutorConfigs::get_cached().get_coding_agent_or_default(&executor_profile_id);\n\n            // Spawn background task to refresh global cache for this executor\n            let base_agent = executors::executors::BaseCodingAgent::from(&executor);\n            executors::executors::utils::spawn_global_cache_refresh_for_agent(base_agent);\n\n            let stream = executor\n                .discover_options(workdir.as_deref(), repo_path.as_deref())\n                .await?;\n            Ok(Some(stream))\n        }\n    }\n\n    async fn store_db_stream_handle(&self, id: Uuid, handle: JoinHandle<()>);\n\n    async fn take_db_stream_handle(&self, id: &Uuid) -> Option<JoinHandle<()>>;\n\n    async fn create(&self, workspace: &Workspace) -> Result<ContainerRef, ContainerError>;\n\n    async fn kill_all_running_processes(&self) -> Result<(), ContainerError>;\n\n    async fn delete(&self, workspace: &Workspace) -> Result<(), ContainerError>;\n\n    /// A context is finalized when\n    /// - Always when the execution process has failed or been killed\n    /// - Never when the run reason is DevServer\n    /// - Never when a setup script has no next_action (parallel mode)\n    /// - The next action is None (no follow-up actions)\n    fn should_finalize(&self, ctx: &ExecutionContext) -> bool {\n        // Never finalize DevServer processes\n        if matches!(\n            ctx.execution_process.run_reason,\n            ExecutionProcessRunReason::DevServer\n        ) {\n            return false;\n        }\n\n        // Never finalize setup scripts without a next_action (parallel mode).\n        // In sequential mode, setup scripts have next_action pointing to coding agent,\n        // so they won't finalize anyway (handled by next_action.is_none() check below).\n        let action = ctx.execution_process.executor_action().unwrap();\n        if matches!(\n            ctx.execution_process.run_reason,\n            ExecutionProcessRunReason::SetupScript\n        ) && action.next_action.is_none()\n        {\n            return false;\n        }\n\n        // Always finalize failed or killed executions, regardless of next action\n        if matches!(\n            ctx.execution_process.status,\n            ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed\n        ) {\n            return true;\n        }\n\n        // Otherwise, finalize only if no next action\n        action.next_action.is_none()\n    }\n\n    /// Finalize workspace execution by sending notifications\n    async fn finalize_task(&self, ctx: &ExecutionContext) {\n        // Skip notification if process was intentionally killed by user\n        if matches!(ctx.execution_process.status, ExecutionProcessStatus::Killed) {\n            return;\n        }\n\n        let workspace_name = ctx\n            .workspace\n            .name\n            .as_deref()\n            .unwrap_or(&ctx.workspace.branch);\n        let title = format!(\"Workspace Complete: {}\", workspace_name);\n        let message = match ctx.execution_process.status {\n            ExecutionProcessStatus::Completed => format!(\n                \"✅ '{}' completed successfully\\nBranch: {:?}\\nExecutor: {:?}\",\n                workspace_name, ctx.workspace.branch, ctx.session.executor\n            ),\n            ExecutionProcessStatus::Failed => format!(\n                \"❌ '{}' execution failed\\nBranch: {:?}\\nExecutor: {:?}\",\n                workspace_name, ctx.workspace.branch, ctx.session.executor\n            ),\n            _ => {\n                tracing::warn!(\n                    \"Tried to notify workspace completion for {} but process is still running!\",\n                    ctx.workspace.id\n                );\n                return;\n            }\n        };\n        self.notification_service()\n            .notify(&title, &message, Some(ctx.workspace.id))\n            .await;\n    }\n\n    /// Cleanup executions marked as running in the db, call at startup\n    async fn cleanup_orphan_executions(&self) -> Result<(), ContainerError> {\n        let running_processes = ExecutionProcess::find_running(&self.db().pool).await?;\n        for process in running_processes {\n            tracing::info!(\n                \"Found orphaned execution process {} for session {}\",\n                process.id,\n                process.session_id\n            );\n            // Update the execution process status first\n            if let Err(e) = ExecutionProcess::update_completion(\n                &self.db().pool,\n                process.id,\n                ExecutionProcessStatus::Failed,\n                None, // No exit code for orphaned processes\n            )\n            .await\n            {\n                tracing::error!(\n                    \"Failed to update orphaned execution process {} status: {}\",\n                    process.id,\n                    e\n                );\n                continue;\n            }\n            // Capture after-head commit OID per repository\n            if let Ok(ctx) = ExecutionProcess::load_context(&self.db().pool, process.id).await\n                && let Some(ref container_ref) = ctx.workspace.container_ref\n            {\n                let workspace_root = PathBuf::from(container_ref);\n                for repo in &ctx.repos {\n                    let repo_path = workspace_root.join(&repo.name);\n                    if let Ok(head) = self.git().get_head_info(&repo_path)\n                        && let Err(err) = ExecutionProcessRepoState::update_after_head_commit(\n                            &self.db().pool,\n                            process.id,\n                            repo.id,\n                            &head.oid,\n                        )\n                        .await\n                    {\n                        tracing::warn!(\n                            \"Failed to update after_head_commit for repo {} on process {}: {}\",\n                            repo.id,\n                            process.id,\n                            err\n                        );\n                    }\n                }\n            }\n            // Process marked as failed\n            tracing::info!(\"Marked orphaned execution process {} as failed\", process.id);\n        }\n        Ok(())\n    }\n\n    /// Backfill before_head_commit for legacy execution processes.\n    /// Rules:\n    /// - If a process has after_head_commit and missing before_head_commit,\n    ///   then set before_head_commit to the previous process's after_head_commit.\n    /// - If there is no previous process, set before_head_commit to the base branch commit.\n    async fn backfill_before_head_commits(&self) -> Result<(), ContainerError> {\n        let pool = &self.db().pool;\n        let rows = ExecutionProcess::list_missing_before_context(pool).await?;\n        for row in rows {\n            // Skip if no after commit at all (shouldn't happen due to WHERE)\n            // Prefer previous process after-commit if present\n            let mut before = row.prev_after_head_commit.clone();\n\n            // Fallback to base branch commit OID\n            if before.is_none() {\n                let repo_path = std::path::Path::new(row.repo_path.as_deref().unwrap_or_default());\n                match self\n                    .git()\n                    .get_branch_oid(repo_path, row.target_branch.as_str())\n                {\n                    Ok(oid) => before = Some(oid),\n                    Err(e) => {\n                        tracing::warn!(\n                            \"Backfill: Failed to resolve base branch OID for workspace {} (branch {}): {}\",\n                            row.workspace_id,\n                            row.target_branch,\n                            e\n                        );\n                    }\n                }\n            }\n\n            if let Some(before_oid) = before\n                && let Err(e) = ExecutionProcessRepoState::update_before_head_commit(\n                    pool,\n                    row.id,\n                    row.repo_id,\n                    &before_oid,\n                )\n                .await\n            {\n                tracing::warn!(\n                    \"Backfill: Failed to update before_head_commit for process {}: {}\",\n                    row.id,\n                    e\n                );\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Backfill repo names that were migrated with a sentinel placeholder.\n    /// Also backfills dev_script_working_dir and agent_working_dir for single-repo projects.\n    async fn backfill_repo_names(&self) -> Result<(), ContainerError> {\n        let pool = &self.db().pool;\n        let repos = Repo::list_needing_name_fix(pool).await?;\n\n        if repos.is_empty() {\n            return Ok(());\n        }\n\n        tracing::info!(\"Backfilling {} repo names\", repos.len());\n\n        for repo in repos {\n            let name = repo\n                .path\n                .file_name()\n                .and_then(|n| n.to_str())\n                .unwrap_or(&repo.id.to_string())\n                .to_string();\n\n            Repo::update_name(pool, repo.id, &name, &name).await?;\n        }\n\n        Ok(())\n    }\n\n    fn cleanup_actions_for_repos(&self, repos: &[Repo]) -> Option<ExecutorAction> {\n        let repos_with_cleanup: Vec<_> = repos\n            .iter()\n            .filter(|r| r.cleanup_script.is_some())\n            .collect();\n\n        if repos_with_cleanup.is_empty() {\n            return None;\n        }\n\n        let mut iter = repos_with_cleanup.iter();\n        let first = iter.next()?;\n        let mut root_action = ExecutorAction::new(\n            ExecutorActionType::ScriptRequest(ScriptRequest {\n                script: first.cleanup_script.clone().unwrap(),\n                language: ScriptRequestLanguage::Bash,\n                context: ScriptContext::CleanupScript,\n                working_dir: Some(first.name.clone()),\n            }),\n            None,\n        );\n\n        for repo in iter {\n            root_action = root_action.append_action(ExecutorAction::new(\n                ExecutorActionType::ScriptRequest(ScriptRequest {\n                    script: repo.cleanup_script.clone().unwrap(),\n                    language: ScriptRequestLanguage::Bash,\n                    context: ScriptContext::CleanupScript,\n                    working_dir: Some(repo.name.clone()),\n                }),\n                None,\n            ));\n        }\n\n        Some(root_action)\n    }\n\n    fn archive_actions_for_repos(&self, repos: &[Repo]) -> Option<ExecutorAction> {\n        let repos_with_archive: Vec<_> = repos\n            .iter()\n            .filter(|r| r.archive_script.is_some())\n            .collect();\n\n        if repos_with_archive.is_empty() {\n            return None;\n        }\n\n        let mut iter = repos_with_archive.iter();\n        let first = iter.next()?;\n        let mut root_action = ExecutorAction::new(\n            ExecutorActionType::ScriptRequest(ScriptRequest {\n                script: first.archive_script.clone().unwrap(),\n                language: ScriptRequestLanguage::Bash,\n                context: ScriptContext::ArchiveScript,\n                working_dir: Some(first.name.clone()),\n            }),\n            None,\n        );\n\n        for repo in iter {\n            root_action = root_action.append_action(ExecutorAction::new(\n                ExecutorActionType::ScriptRequest(ScriptRequest {\n                    script: repo.archive_script.clone().unwrap(),\n                    language: ScriptRequestLanguage::Bash,\n                    context: ScriptContext::ArchiveScript,\n                    working_dir: Some(repo.name.clone()),\n                }),\n                None,\n            ));\n        }\n\n        Some(root_action)\n    }\n\n    /// Attempts to run the archive script for a workspace if configured.\n    /// Silently returns Ok if no archive script is configured or if conditions aren't met.\n    async fn try_run_archive_script(&self, workspace_id: Uuid) -> Result<(), ContainerError> {\n        let pool = &self.db().pool;\n        let workspace = Workspace::find_by_id(pool, workspace_id)\n            .await?\n            .ok_or(ContainerError::Other(anyhow!(\"Workspace not found\")))?;\n        if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id)\n            .await\n            .unwrap_or(true)\n        {\n            return Ok(());\n        }\n        if self.ensure_container_exists(&workspace).await.is_err() {\n            return Ok(());\n        }\n        let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n        let Some(action) = self.archive_actions_for_repos(&repos) else {\n            return Ok(());\n        };\n        let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? {\n            Some(s) => s,\n            None => {\n                Session::create(\n                    pool,\n                    &CreateSession {\n                        executor: None,\n                        name: None,\n                    },\n                    Uuid::new_v4(),\n                    workspace.id,\n                )\n                .await?\n            }\n        };\n        self.start_execution(\n            &workspace,\n            &session,\n            &action,\n            &ExecutionProcessRunReason::ArchiveScript,\n        )\n        .await?;\n\n        Ok(())\n    }\n\n    /// Archive a workspace: set archived flag, stop running dev servers, and run archive script.\n    async fn archive_workspace(&self, workspace_id: Uuid) -> Result<(), ContainerError> {\n        let pool = &self.db().pool;\n\n        Workspace::set_archived(pool, workspace_id, true).await?;\n\n        // Stop running dev servers\n        if let Ok(dev_servers) =\n            ExecutionProcess::find_running_dev_servers_by_workspace(pool, workspace_id).await\n        {\n            for dev_server in dev_servers {\n                if let Err(e) = self\n                    .stop_execution(&dev_server, ExecutionProcessStatus::Killed)\n                    .await\n                {\n                    tracing::error!(\n                        \"Failed to stop dev server {} for workspace {}: {}\",\n                        dev_server.id,\n                        workspace_id,\n                        e\n                    );\n                }\n            }\n        }\n\n        // Run archive script (silently skips if not configured)\n        if let Err(e) = self.try_run_archive_script(workspace_id).await {\n            tracing::error!(\n                \"Failed to run archive script for workspace {}: {}\",\n                workspace_id,\n                e\n            );\n        }\n\n        Ok(())\n    }\n\n    fn setup_actions_for_repos(&self, repos: &[Repo]) -> Option<ExecutorAction> {\n        let repos_with_setup: Vec<_> = repos.iter().filter(|r| r.setup_script.is_some()).collect();\n\n        if repos_with_setup.is_empty() {\n            return None;\n        }\n\n        let mut iter = repos_with_setup.iter();\n        let first = iter.next()?;\n        let mut root_action = ExecutorAction::new(\n            ExecutorActionType::ScriptRequest(ScriptRequest {\n                script: first.setup_script.clone().unwrap(),\n                language: ScriptRequestLanguage::Bash,\n                context: ScriptContext::SetupScript,\n                working_dir: Some(first.name.clone()),\n            }),\n            None,\n        );\n\n        for repo in iter {\n            root_action = root_action.append_action(ExecutorAction::new(\n                ExecutorActionType::ScriptRequest(ScriptRequest {\n                    script: repo.setup_script.clone().unwrap(),\n                    language: ScriptRequestLanguage::Bash,\n                    context: ScriptContext::SetupScript,\n                    working_dir: Some(repo.name.clone()),\n                }),\n                None,\n            ));\n        }\n\n        Some(root_action)\n    }\n\n    fn setup_action_for_repo(repo: &Repo) -> Option<ExecutorAction> {\n        repo.setup_script.as_ref().map(|script| {\n            ExecutorAction::new(\n                ExecutorActionType::ScriptRequest(ScriptRequest {\n                    script: script.clone(),\n                    language: ScriptRequestLanguage::Bash,\n                    context: ScriptContext::SetupScript,\n                    working_dir: Some(repo.name.clone()),\n                }),\n                None,\n            )\n        })\n    }\n\n    fn build_sequential_setup_chain(\n        repos: &[&Repo],\n        next_action: ExecutorAction,\n    ) -> ExecutorAction {\n        let mut chained = next_action;\n        for repo in repos.iter().rev() {\n            if let Some(script) = &repo.setup_script {\n                chained = ExecutorAction::new(\n                    ExecutorActionType::ScriptRequest(ScriptRequest {\n                        script: script.clone(),\n                        language: ScriptRequestLanguage::Bash,\n                        context: ScriptContext::SetupScript,\n                        working_dir: Some(repo.name.clone()),\n                    }),\n                    Some(Box::new(chained)),\n                );\n            }\n        }\n        chained\n    }\n\n    /// Reset a session to a specific process: restore worktrees, stop processes, drop later processes.\n    async fn reset_session_to_process(\n        &self,\n        session_id: Uuid,\n        target_process_id: Uuid,\n        perform_git_reset: bool,\n        force_when_dirty: bool,\n    ) -> Result<(), ContainerError> {\n        let pool = &self.db().pool;\n\n        let process = ExecutionProcess::find_by_id(pool, target_process_id)\n            .await?\n            .ok_or_else(|| ContainerError::Other(anyhow!(\"Process not found\")))?;\n        if process.session_id != session_id {\n            return Err(ContainerError::Other(anyhow!(\n                \"Process does not belong to this session\"\n            )));\n        }\n\n        let session = Session::find_by_id(pool, session_id)\n            .await?\n            .ok_or_else(|| ContainerError::Other(anyhow!(\"Session not found\")))?;\n        let workspace = Workspace::find_by_id(pool, session.workspace_id)\n            .await?\n            .ok_or_else(|| ContainerError::Other(anyhow!(\"Workspace not found\")))?;\n\n        let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;\n        let repo_states =\n            ExecutionProcessRepoState::find_by_execution_process_id(pool, target_process_id)\n                .await?;\n\n        let container_ref = self.ensure_container_exists(&workspace).await?;\n        let workspace_dir = std::path::PathBuf::from(container_ref);\n        let is_dirty = self\n            .is_container_clean(&workspace)\n            .await\n            .map(|is_clean| !is_clean)\n            .unwrap_or(false);\n\n        for repo in &repos {\n            let repo_state = repo_states.iter().find(|s| s.repo_id == repo.id);\n            let target_oid = match repo_state.and_then(|s| s.before_head_commit.clone()) {\n                Some(oid) => Some(oid),\n                None => {\n                    ExecutionProcess::find_prev_after_head_commit(\n                        pool,\n                        session_id,\n                        target_process_id,\n                        repo.id,\n                    )\n                    .await?\n                }\n            };\n\n            let worktree_path = workspace_dir.join(&repo.name);\n            if let Some(oid) = target_oid {\n                self.git().reconcile_worktree_to_commit(\n                    &worktree_path,\n                    &oid,\n                    git::WorktreeResetOptions::new(\n                        perform_git_reset,\n                        force_when_dirty,\n                        is_dirty,\n                        perform_git_reset,\n                    ),\n                );\n            }\n        }\n\n        self.try_stop(&workspace, false).await;\n        ExecutionProcess::drop_at_and_after(pool, session_id, target_process_id).await?;\n\n        Ok(())\n    }\n\n    async fn try_stop(&self, workspace: &Workspace, include_dev_server: bool) {\n        // stop execution processes for this workspace's sessions\n        let sessions = match Session::find_by_workspace_id(&self.db().pool, workspace.id).await {\n            Ok(s) => s,\n            Err(_) => return,\n        };\n\n        for session in sessions {\n            if let Ok(processes) =\n                ExecutionProcess::find_by_session_id(&self.db().pool, session.id, false).await\n            {\n                for process in processes {\n                    // Skip dev server processes unless explicitly included\n                    if !include_dev_server\n                        && process.run_reason == ExecutionProcessRunReason::DevServer\n                    {\n                        continue;\n                    }\n                    if process.status == ExecutionProcessStatus::Running {\n                        self.stop_execution(&process, ExecutionProcessStatus::Killed)\n                            .await\n                            .unwrap_or_else(|e| {\n                                tracing::debug!(\n                                    \"Failed to stop execution process {} for workspace {}: {}\",\n                                    process.id,\n                                    workspace.id,\n                                    e\n                                );\n                            });\n                    }\n                }\n            }\n        }\n    }\n\n    async fn ensure_container_exists(\n        &self,\n        workspace: &Workspace,\n    ) -> Result<ContainerRef, ContainerError>;\n\n    async fn is_container_clean(&self, workspace: &Workspace) -> Result<bool, ContainerError>;\n\n    async fn start_execution_inner(\n        &self,\n        workspace: &Workspace,\n        execution_process: &ExecutionProcess,\n        executor_action: &ExecutorAction,\n    ) -> Result<(), ContainerError>;\n\n    async fn stop_execution(\n        &self,\n        execution_process: &ExecutionProcess,\n        status: ExecutionProcessStatus,\n    ) -> Result<(), ContainerError>;\n\n    async fn try_commit_changes(&self, ctx: &ExecutionContext) -> Result<bool, ContainerError>;\n\n    async fn copy_project_files(\n        &self,\n        source_dir: &Path,\n        target_dir: &Path,\n        copy_files: &str,\n    ) -> Result<(), ContainerError>;\n\n    /// Stream diff updates as LogMsg for WebSocket endpoints.\n    async fn stream_diff(\n        &self,\n        workspace: &Workspace,\n        stats_only: bool,\n    ) -> Result<futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>, ContainerError>;\n\n    /// Fetch the MsgStore for a given execution ID, panicking if missing.\n    async fn get_msg_store_by_id(&self, uuid: &Uuid) -> Option<Arc<MsgStore>> {\n        let map = self.msg_stores().read().await;\n        map.get(uuid).cloned()\n    }\n\n    async fn git_branch_prefix(&self) -> String;\n\n    async fn git_branch_from_workspace(&self, workspace_id: &Uuid, task_title: &str) -> String {\n        let task_title_id = git_branch_id(task_title);\n        let prefix = self.git_branch_prefix().await;\n\n        if prefix.is_empty() {\n            format!(\"{}-{}\", short_uuid(workspace_id), task_title_id)\n        } else {\n            format!(\"{}/{}-{}\", prefix, short_uuid(workspace_id), task_title_id)\n        }\n    }\n\n    async fn stream_raw_logs(\n        &self,\n        id: &Uuid,\n    ) -> Option<futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>> {\n        if let Some(store) = self.get_msg_store_by_id(id).await {\n            // First try in-memory store\n            return Some(\n                store\n                    .history_plus_stream()\n                    .filter(|msg| {\n                        future::ready(matches!(\n                            msg,\n                            Ok(LogMsg::Stdout(..) | LogMsg::Stderr(..) | LogMsg::Finished)\n                        ))\n                    })\n                    .boxed(),\n            );\n        } else {\n            let messages = execution_process::load_raw_log_messages(&self.db().pool, *id).await?;\n\n            let stream = futures::stream::iter(\n                messages\n                    .into_iter()\n                    .filter(|m| matches!(m, LogMsg::Stdout(_) | LogMsg::Stderr(_)))\n                    .chain(std::iter::once(LogMsg::Finished))\n                    .map(Ok::<_, std::io::Error>),\n            )\n            .boxed();\n\n            Some(stream)\n        }\n    }\n\n    async fn stream_normalized_logs(\n        &self,\n        id: &Uuid,\n    ) -> Option<futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>> {\n        // First try in-memory store (existing behavior)\n        if let Some(store) = self.get_msg_store_by_id(id).await {\n            Some(\n                store\n                    .history_plus_stream() // BoxStream<Result<LogMsg, io::Error>>\n                    .filter(|msg| future::ready(matches!(msg, Ok(LogMsg::JsonPatch(..)))))\n                    .chain(futures::stream::once(async {\n                        Ok::<_, std::io::Error>(LogMsg::Finished)\n                    }))\n                    .boxed(),\n            )\n        } else {\n            let raw_messages =\n                execution_process::load_raw_log_messages(&self.db().pool, *id).await?;\n\n            // Create temporary store and populate\n            // Include JsonPatch messages (already normalized) and Stdout/Stderr (need normalization)\n            let temp_store = Arc::new(MsgStore::new());\n            for msg in raw_messages {\n                if matches!(\n                    msg,\n                    LogMsg::Stdout(_) | LogMsg::Stderr(_) | LogMsg::JsonPatch(_)\n                ) {\n                    temp_store.push(msg);\n                }\n            }\n            temp_store.push_finished();\n\n            let process = match ExecutionProcess::find_by_id(&self.db().pool, *id).await {\n                Ok(Some(process)) => process,\n                Ok(None) => {\n                    tracing::error!(\"No execution process found for ID: {}\", id);\n                    return None;\n                }\n                Err(e) => {\n                    tracing::error!(\"Failed to fetch execution process {}: {}\", id, e);\n                    return None;\n                }\n            };\n\n            // Get the workspace to determine correct directory\n            let (workspace, _session) =\n                match process.parent_workspace_and_session(&self.db().pool).await {\n                    Ok(Some((workspace, session))) => (workspace, session),\n                    Ok(None) => {\n                        tracing::error!(\n                            \"No workspace/session found for session ID: {}\",\n                            process.session_id\n                        );\n                        return None;\n                    }\n                    Err(e) => {\n                        tracing::error!(\n                            \"Failed to fetch workspace for session {}: {}\",\n                            process.session_id,\n                            e\n                        );\n                        return None;\n                    }\n                };\n\n            if let Err(err) = self.ensure_container_exists(&workspace).await {\n                tracing::warn!(\n                    \"Failed to recreate worktree before log normalization for workspace {}: {}\",\n                    workspace.id,\n                    err\n                );\n            }\n\n            let current_dir = self.workspace_to_current_dir(&workspace);\n\n            let executor_action = if let Ok(executor_action) = process.executor_action() {\n                executor_action\n            } else {\n                tracing::error!(\n                    \"Failed to parse executor action: {:?}\",\n                    process.executor_action()\n                );\n                return None;\n            };\n\n            // Spawn normalizer on populated store and collect JoinHandles\n            let handles = match executor_action.typ() {\n                ExecutorActionType::CodingAgentInitialRequest(request) => {\n                    #[cfg(feature = \"qa-mode\")]\n                    {\n                        let executor = QaMockExecutor;\n                        executor.normalize_logs(\n                            temp_store.clone(),\n                            &request.effective_dir(&current_dir),\n                        )\n                    }\n                    #[cfg(not(feature = \"qa-mode\"))]\n                    {\n                        let executor = ExecutorConfigs::get_cached()\n                            .get_coding_agent_or_default(&request.executor_config.profile_id());\n                        executor.normalize_logs(\n                            temp_store.clone(),\n                            &request.effective_dir(&current_dir),\n                        )\n                    }\n                }\n                ExecutorActionType::CodingAgentFollowUpRequest(request) => {\n                    #[cfg(feature = \"qa-mode\")]\n                    {\n                        let executor = QaMockExecutor;\n                        executor.normalize_logs(\n                            temp_store.clone(),\n                            &request.effective_dir(&current_dir),\n                        )\n                    }\n                    #[cfg(not(feature = \"qa-mode\"))]\n                    {\n                        let executor = ExecutorConfigs::get_cached()\n                            .get_coding_agent_or_default(&request.executor_config.profile_id());\n                        executor.normalize_logs(\n                            temp_store.clone(),\n                            &request.effective_dir(&current_dir),\n                        )\n                    }\n                }\n                #[cfg(feature = \"qa-mode\")]\n                ExecutorActionType::ReviewRequest(_request) => {\n                    let executor = QaMockExecutor;\n                    executor.normalize_logs(temp_store.clone(), &current_dir)\n                }\n                #[cfg(not(feature = \"qa-mode\"))]\n                ExecutorActionType::ReviewRequest(request) => {\n                    let executor = ExecutorConfigs::get_cached()\n                        .get_coding_agent_or_default(&request.executor_config.profile_id());\n                    executor.normalize_logs(temp_store.clone(), &current_dir)\n                }\n                _ => {\n                    tracing::debug!(\n                        \"Executor action doesn't support log normalization: {:?}\",\n                        process.executor_action()\n                    );\n                    return None;\n                }\n            };\n\n            // Await all normalizer tasks, then push Ready so the dedup\n            // stream knows when to flush its buffer and terminate.\n            {\n                let store = temp_store.clone();\n                tokio::spawn(async move {\n                    for handle in handles {\n                        let _ = handle.await;\n                    }\n                    store.push(LogMsg::Ready);\n                });\n            }\n\n            // Stream normalized patches, deduplicating consecutive patches\n            // that target the same path (only the final state matters for\n            // historical replay). The Ready sentinel flushes the buffer.\n            enum PatchOrDone {\n                Patch(Patch),\n                Done,\n            }\n\n            let stream = temp_store\n                .history_plus_stream()\n                .filter_map(|msg| async move {\n                    match msg {\n                        Ok(LogMsg::JsonPatch(patch)) => Some(PatchOrDone::Patch(patch)),\n                        Ok(LogMsg::Ready) => Some(PatchOrDone::Done),\n                        _ => None,\n                    }\n                });\n\n            let deduped = futures::stream::unfold(\n                (stream.boxed(), None::<Patch>, HashSet::<String>::new()),\n                |(mut stream, buffered, mut sent_paths)| async move {\n                    match stream.next().await {\n                        Some(PatchOrDone::Patch(patch)) => {\n                            let Some(prev) = buffered else {\n                                // First patch — just buffer it\n                                return Some((None, (stream, Some(patch), sent_paths)));\n                            };\n                            if patch_entry_path(&patch) == patch_entry_path(&prev)\n                                && is_add_or_replace(&patch)\n                                && is_add_or_replace(&prev)\n                            {\n                                // Same path, both add/replace — replace buffer\n                                Some((None, (stream, Some(patch), sent_paths)))\n                            } else {\n                                // Different — emit prev, buffer new\n                                let prev = fix_patch_ops(prev, &mut sent_paths);\n                                Some((Some(prev), (stream, Some(patch), sent_paths)))\n                            }\n                        }\n                        Some(PatchOrDone::Done) | None => {\n                            // Sentinel or stream end: flush buffer and terminate\n                            if let Some(prev) = buffered {\n                                let prev = fix_patch_ops(prev, &mut sent_paths);\n                                return Some((Some(prev), (stream, None, sent_paths)));\n                            }\n                            None\n                        }\n                    }\n                },\n            )\n            .filter_map(|opt| async move { opt })\n            .map(|p| Ok::<_, std::io::Error>(LogMsg::JsonPatch(p)))\n            .chain(futures::stream::once(async {\n                Ok::<_, std::io::Error>(LogMsg::Finished)\n            }));\n\n            Some(deduped.boxed())\n        }\n    }\n\n    async fn start_workspace(\n        &self,\n        workspace: &Workspace,\n        executor_config: ExecutorConfig,\n        prompt: String,\n    ) -> Result<ExecutionProcess, ContainerError> {\n        // Create container\n        self.create(workspace).await?;\n\n        let repos = WorkspaceRepo::find_repos_for_workspace(&self.db().pool, workspace.id).await?;\n\n        let workspace = Workspace::find_by_id(&self.db().pool, workspace.id)\n            .await?\n            .ok_or(SqlxError::RowNotFound)?;\n\n        // Create a session for this workspace\n        let session = Session::create(\n            &self.db().pool,\n            &CreateSession {\n                executor: Some(executor_config.executor.to_string()),\n                name: None,\n            },\n            Uuid::new_v4(),\n            workspace.id,\n        )\n        .await?;\n\n        let repos_with_setup: Vec<_> = repos.iter().filter(|r| r.setup_script.is_some()).collect();\n\n        let all_parallel = repos_with_setup.iter().all(|r| r.parallel_setup_script);\n\n        let cleanup_action = self.cleanup_actions_for_repos(&repos);\n\n        let working_dir = session\n            .agent_working_dir\n            .as_ref()\n            .filter(|dir| !dir.is_empty())\n            .cloned();\n\n        let coding_action = ExecutorAction::new(\n            ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest {\n                prompt,\n                executor_config: executor_config.clone(),\n                working_dir,\n            }),\n            cleanup_action.map(Box::new),\n        );\n\n        let execution_process = if all_parallel {\n            // All parallel: start each setup independently, then start coding agent\n            for repo in &repos_with_setup {\n                if let Some(action) = Self::setup_action_for_repo(repo)\n                    && let Err(e) = self\n                        .start_execution(\n                            &workspace,\n                            &session,\n                            &action,\n                            &ExecutionProcessRunReason::SetupScript,\n                        )\n                        .await\n                {\n                    tracing::warn!(?e, \"Failed to start setup script in parallel mode\");\n                }\n            }\n            self.start_execution(\n                &workspace,\n                &session,\n                &coding_action,\n                &ExecutionProcessRunReason::CodingAgent,\n            )\n            .await?\n        } else {\n            // Any sequential: chain ALL setups → coding agent via next_action\n            let main_action = Self::build_sequential_setup_chain(&repos_with_setup, coding_action);\n            self.start_execution(\n                &workspace,\n                &session,\n                &main_action,\n                &ExecutionProcessRunReason::SetupScript,\n            )\n            .await?\n        };\n\n        Ok(execution_process)\n    }\n\n    async fn start_execution(\n        &self,\n        workspace: &Workspace,\n        session: &Session,\n        executor_action: &ExecutorAction,\n        run_reason: &ExecutionProcessRunReason,\n    ) -> Result<ExecutionProcess, ContainerError> {\n        // Create new execution process record\n        // Capture current HEAD per repository as the \"before\" commit for this execution\n        let repositories =\n            WorkspaceRepo::find_repos_for_workspace(&self.db().pool, workspace.id).await?;\n        if repositories.is_empty() {\n            return Err(ContainerError::Other(anyhow!(\n                \"Workspace has no repositories configured\"\n            )));\n        }\n\n        let workspace_root = workspace\n            .container_ref\n            .as_ref()\n            .map(std::path::PathBuf::from)\n            .ok_or_else(|| ContainerError::Other(anyhow!(\"Container ref not found\")))?;\n\n        let mut repo_states = Vec::with_capacity(repositories.len());\n        for repo in &repositories {\n            let repo_path = workspace_root.join(&repo.name);\n            let before_head_commit = self.git().get_head_info(&repo_path).ok().map(|h| h.oid);\n            repo_states.push(CreateExecutionProcessRepoState {\n                repo_id: repo.id,\n                before_head_commit,\n                after_head_commit: None,\n                merge_commit: None,\n            });\n        }\n        let create_execution_process = CreateExecutionProcess {\n            session_id: session.id,\n            executor_action: executor_action.clone(),\n            run_reason: run_reason.clone(),\n        };\n\n        let execution_process = ExecutionProcess::create(\n            &self.db().pool,\n            &create_execution_process,\n            Uuid::new_v4(),\n            &repo_states,\n        )\n        .await?;\n        if *run_reason != ExecutionProcessRunReason::ArchiveScript {\n            Workspace::set_archived(&self.db().pool, workspace.id, false).await?;\n        }\n\n        if let Some(prompt) = match executor_action.typ() {\n            ExecutorActionType::CodingAgentInitialRequest(coding_agent_request) => {\n                Some(coding_agent_request.prompt.clone())\n            }\n            ExecutorActionType::CodingAgentFollowUpRequest(follow_up_request) => {\n                Some(follow_up_request.prompt.clone())\n            }\n            ExecutorActionType::ReviewRequest(review_request) => {\n                Some(review_request.prompt.clone())\n            }\n            ExecutorActionType::ScriptRequest(_) => None,\n        } {\n            let create_coding_agent_turn = CreateCodingAgentTurn {\n                execution_process_id: execution_process.id,\n                prompt: Some(prompt),\n            };\n\n            let coding_agent_turn_id = Uuid::new_v4();\n\n            CodingAgentTurn::create(\n                &self.db().pool,\n                &create_coding_agent_turn,\n                coding_agent_turn_id,\n            )\n            .await?;\n        }\n\n        if let Err(start_error) = self\n            .start_execution_inner(workspace, &execution_process, executor_action)\n            .await\n        {\n            // Mark process as failed\n            if let Err(update_error) = ExecutionProcess::update_completion(\n                &self.db().pool,\n                execution_process.id,\n                ExecutionProcessStatus::Failed,\n                None,\n            )\n            .await\n            {\n                tracing::error!(\n                    \"Failed to mark execution process {} as failed after start error: {}\",\n                    execution_process.id,\n                    update_error\n                );\n            }\n            // Emit stderr error message\n            let log_message = LogMsg::Stderr(format!(\"Failed to start execution: {start_error}\"));\n            if let Err(e) = execution_process::append_log_message(\n                session.id,\n                execution_process.id,\n                &log_message,\n            )\n            .await\n            {\n                tracing::error!(\n                    \"Failed to write error log for execution {}: {}\",\n                    execution_process.id,\n                    e\n                );\n            }\n\n            // Emit NextAction with failure context for coding agent requests\n            if let ContainerError::ExecutorError(ExecutorError::ExecutableNotFound { program }) =\n                &start_error\n            {\n                let help_text = format!(\"The required executable `{program}` is not installed.\");\n                let error_message = NormalizedEntry {\n                    timestamp: None,\n                    entry_type: NormalizedEntryType::ErrorMessage {\n                        error_type: NormalizedEntryError::SetupRequired,\n                    },\n                    content: help_text,\n                    metadata: None,\n                };\n                let patch = ConversationPatch::add_normalized_entry(2, error_message);\n                if let Err(e) = execution_process::append_log_message(\n                    session.id,\n                    execution_process.id,\n                    &LogMsg::JsonPatch(patch),\n                )\n                .await\n                {\n                    tracing::error!(\n                        \"Failed to write setup-required log for execution {}: {}\",\n                        execution_process.id,\n                        e\n                    );\n                }\n            };\n            return Err(start_error);\n        }\n\n        // Start processing normalised logs for executor requests and follow ups\n        let workspace_root = self.workspace_to_current_dir(workspace);\n        #[cfg_attr(feature = \"qa-mode\", allow(unused_variables))]\n        if let Some(msg_store) = self.get_msg_store_by_id(&execution_process.id).await\n            && let Some((executor_profile_id, working_dir)) = match executor_action.typ() {\n                ExecutorActionType::CodingAgentInitialRequest(request) => Some((\n                    request.executor_config.profile_id(),\n                    request.effective_dir(&workspace_root),\n                )),\n                ExecutorActionType::CodingAgentFollowUpRequest(request) => Some((\n                    request.executor_config.profile_id(),\n                    request.effective_dir(&workspace_root),\n                )),\n                ExecutorActionType::ReviewRequest(request) => Some((\n                    request.executor_config.profile_id(),\n                    request.effective_dir(&workspace_root),\n                )),\n                _ => None,\n            }\n        {\n            #[cfg(feature = \"qa-mode\")]\n            {\n                let executor = QaMockExecutor;\n                let _ = executor.normalize_logs(msg_store, &working_dir);\n            }\n            #[cfg(not(feature = \"qa-mode\"))]\n            {\n                if let Some(executor) =\n                    ExecutorConfigs::get_cached().get_coding_agent(&executor_profile_id)\n                {\n                    let _ = executor.normalize_logs(msg_store, &working_dir);\n                } else {\n                    tracing::error!(\n                        \"Failed to resolve profile '{:?}' for normalization\",\n                        executor_profile_id\n                    );\n                }\n            }\n        }\n\n        execution_process::spawn_stream_raw_logs_to_storage(\n            self.msg_stores().clone(),\n            self.db().clone(),\n            execution_process.id,\n            session.id,\n        );\n        Ok(execution_process)\n    }\n\n    async fn try_start_next_action(&self, ctx: &ExecutionContext) -> Result<(), ContainerError> {\n        let action = ctx.execution_process.executor_action()?;\n        let next_action = if let Some(next_action) = action.next_action() {\n            next_action\n        } else {\n            tracing::debug!(\"No next action configured\");\n            return Ok(());\n        };\n\n        // Determine the run reason of the next action\n        let next_run_reason = match (action.typ(), next_action.typ()) {\n            (ExecutorActionType::ScriptRequest(_), ExecutorActionType::ScriptRequest(_)) => {\n                ExecutionProcessRunReason::SetupScript\n            }\n            (\n                ExecutorActionType::CodingAgentInitialRequest(_)\n                | ExecutorActionType::CodingAgentFollowUpRequest(_)\n                | ExecutorActionType::ReviewRequest(_),\n                ExecutorActionType::ScriptRequest(_),\n            ) => ExecutionProcessRunReason::CleanupScript,\n            (\n                _,\n                ExecutorActionType::CodingAgentFollowUpRequest(_)\n                | ExecutorActionType::CodingAgentInitialRequest(_)\n                | ExecutorActionType::ReviewRequest(_),\n            ) => ExecutionProcessRunReason::CodingAgent,\n        };\n\n        self.start_execution(&ctx.workspace, &ctx.session, next_action, &next_run_reason)\n            .await?;\n\n        tracing::debug!(\"Started next action: {:?}\", next_action);\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/diff_stream.rs",
    "content": "use std::{\n    collections::HashSet,\n    io,\n    path::{Path, PathBuf},\n    sync::{\n        Arc,\n        atomic::{AtomicUsize, Ordering},\n    },\n    time::Duration,\n};\n\nuse db::{\n    DBService,\n    models::{workspace::Workspace, workspace_repo::WorkspaceRepo},\n};\nuse executors::logs::utils::{ConversationPatch, patch::escape_json_pointer_segment};\nuse futures::StreamExt;\nuse git::{Commit, DiffTarget, GitService, GitServiceError};\nuse notify::{RecommendedWatcher, RecursiveMode};\nuse notify_debouncer_full::{\n    DebounceEventResult, DebouncedEvent, Debouncer, RecommendedCache, new_debouncer,\n};\nuse sqlx::SqlitePool;\nuse thiserror::Error;\nuse tokio::{sync::mpsc, task::JoinHandle};\nuse tokio_stream::wrappers::{IntervalStream, ReceiverStream};\nuse utils::{\n    diff::{self, Diff},\n    log_msg::LogMsg,\n};\nuse uuid::Uuid;\n\nuse crate::services::filesystem_watcher::{self, FilesystemWatcherError};\n\n#[derive(Debug, Clone, Default)]\npub struct DiffStats {\n    pub files_changed: usize,\n    pub lines_added: usize,\n    pub lines_removed: usize,\n}\n\n/// Computes diff stats for a workspace by comparing against target branches.\npub async fn compute_diff_stats(\n    pool: &SqlitePool,\n    git: &GitService,\n    workspace: &Workspace,\n) -> Option<DiffStats> {\n    let container_ref = workspace.container_ref.as_ref()?;\n\n    let workspace_repos =\n        WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace.id)\n            .await\n            .ok()?;\n\n    let mut stats = DiffStats::default();\n\n    for repo_with_branch in workspace_repos {\n        let worktree_path = PathBuf::from(container_ref).join(&repo_with_branch.repo.name);\n        let repo_path = repo_with_branch.repo.path.clone();\n\n        let base_commit_result = tokio::task::spawn_blocking({\n            let git = git.clone();\n            let repo_path = repo_path.clone();\n            let workspace_branch = workspace.branch.clone();\n            let target_branch = repo_with_branch.target_branch.clone();\n            move || git.get_base_commit(&repo_path, &workspace_branch, &target_branch)\n        })\n        .await;\n\n        let base_commit = match base_commit_result {\n            Ok(Ok(commit)) => commit,\n            _ => continue,\n        };\n\n        let diffs_result = tokio::task::spawn_blocking({\n            let git = git.clone();\n            let worktree = worktree_path.clone();\n            move || {\n                git.get_diffs(\n                    DiffTarget::Worktree {\n                        worktree_path: &worktree,\n                        base_commit: &base_commit,\n                    },\n                    None,\n                )\n            }\n        })\n        .await;\n\n        if let Ok(Ok(diffs)) = diffs_result {\n            for diff in diffs {\n                stats.files_changed += 1;\n                stats.lines_added += diff.additions.unwrap_or(0);\n                stats.lines_removed += diff.deletions.unwrap_or(0);\n            }\n        }\n    }\n\n    Some(stats)\n}\n\n/// Maximum cumulative diff bytes to stream before omitting content (200MB)\npub const MAX_CUMULATIVE_DIFF_BYTES: usize = 200 * 1024 * 1024;\n\nconst DIFF_STREAM_CHANNEL_CAPACITY: usize = 1000;\n\n/// Errors that can occur during diff stream creation and operation\n#[derive(Error, Debug)]\npub enum DiffStreamError {\n    #[error(\"Git service error: {0}\")]\n    GitService(#[from] GitServiceError),\n    #[error(\"Filesystem watcher error: {0}\")]\n    FilesystemWatcher(#[from] FilesystemWatcherError),\n    #[error(\"Task join error: {0}\")]\n    TaskJoin(#[from] tokio::task::JoinError),\n    #[error(\"IO error: {0}\")]\n    Io(#[from] io::Error),\n    #[error(\"Notify error: {0}\")]\n    Notify(#[from] notify::Error),\n}\n\nimpl DiffStreamError {\n    /// Returns true if this error is caused by a git repository not being found\n    /// (e.g. the worktree directory was deleted while the diff stream was running).\n    fn is_repo_not_found(&self) -> bool {\n        matches!(\n            self,\n            DiffStreamError::GitService(GitServiceError::Git(git_err))\n                if git_err.code() == git2::ErrorCode::NotFound\n                    && git_err.class() == git2::ErrorClass::Repository\n        )\n    }\n}\n\n/// Diff stream that owns the filesystem watcher task\n/// When this stream is dropped, the watcher is automatically cleaned up\npub struct DiffStreamHandle {\n    stream: futures::stream::BoxStream<'static, Result<LogMsg, io::Error>>,\n    _watcher_task: Option<JoinHandle<()>>,\n}\n\nimpl futures::Stream for DiffStreamHandle {\n    type Item = Result<LogMsg, io::Error>;\n\n    fn poll_next(\n        mut self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<Option<Self::Item>> {\n        // Delegate to inner stream\n        std::pin::Pin::new(&mut self.stream).poll_next(cx)\n    }\n}\n\nimpl Drop for DiffStreamHandle {\n    fn drop(&mut self) {\n        if let Some(handle) = self._watcher_task.take() {\n            handle.abort();\n        }\n    }\n}\n\nimpl DiffStreamHandle {\n    /// Create a new DiffStreamHandle from a boxed stream and optional watcher task\n    pub fn new(\n        stream: futures::stream::BoxStream<'static, Result<LogMsg, io::Error>>,\n        watcher_task: Option<JoinHandle<()>>,\n    ) -> Self {\n        Self {\n            stream,\n            _watcher_task: watcher_task,\n        }\n    }\n}\n\n#[derive(Clone)]\npub struct DiffStreamArgs {\n    pub git_service: GitService,\n    pub db: DBService,\n    pub workspace_id: Uuid,\n    pub repo_id: Uuid,\n    pub repo_path: PathBuf,\n    pub worktree_path: PathBuf,\n    pub branch: String,\n    pub target_branch: String,\n    pub base_commit: Commit,\n    pub stats_only: bool,\n    pub path_prefix: Option<String>,\n}\n\nstruct DiffStreamManager {\n    args: DiffStreamArgs,\n    tx: mpsc::Sender<Result<LogMsg, io::Error>>,\n    cumulative: Arc<AtomicUsize>,\n    known_paths: Arc<std::sync::RwLock<HashSet<String>>>,\n    full_sent: Arc<std::sync::RwLock<HashSet<String>>>,\n    current_base_commit: Commit,\n    current_target_branch: String,\n}\n\nenum DiffEvent {\n    Filesystem(DebounceEventResult),\n    GitStateChange,\n    CheckTarget,\n}\n\npub async fn create(args: DiffStreamArgs) -> Result<DiffStreamHandle, DiffStreamError> {\n    let (tx, rx) = mpsc::channel::<Result<LogMsg, io::Error>>(DIFF_STREAM_CHANNEL_CAPACITY);\n    let manager_args = args.clone();\n\n    let watcher_task = tokio::spawn(async move {\n        let mut manager = DiffStreamManager::new(manager_args, tx);\n        if let Err(e) = manager.run().await {\n            if e.is_repo_not_found() {\n                tracing::warn!(\"Diff stream ended: repository no longer found\");\n            } else {\n                tracing::error!(\"Diff stream manager failed: {e}\");\n            }\n            let _ = manager.tx.send(Err(io::Error::other(e.to_string()))).await;\n        }\n    });\n\n    Ok(DiffStreamHandle::new(\n        ReceiverStream::new(rx).boxed(),\n        Some(watcher_task),\n    ))\n}\n\nimpl DiffStreamManager {\n    fn new(args: DiffStreamArgs, tx: mpsc::Sender<Result<LogMsg, io::Error>>) -> Self {\n        Self {\n            current_base_commit: args.base_commit.clone(),\n            current_target_branch: args.target_branch.clone(),\n            args,\n            tx,\n            cumulative: Arc::new(AtomicUsize::new(0)),\n            known_paths: Arc::new(std::sync::RwLock::new(HashSet::new())),\n            full_sent: Arc::new(std::sync::RwLock::new(HashSet::new())),\n        }\n    }\n\n    async fn run(&mut self) -> Result<(), DiffStreamError> {\n        self.reset_stream().await?;\n\n        // Send Ready message to indicate initial data has been sent\n        let _ready_error = self.tx.send(Ok(LogMsg::Ready)).await;\n\n        let (fs_debouncer, mut fs_rx, canonical_worktree) =\n            filesystem_watcher::async_watcher(self.args.worktree_path.clone())\n                .map_err(|e| io::Error::other(e.to_string()))?;\n        let _fs_guard = fs_debouncer;\n\n        let (git_debouncer, mut git_rx) =\n            match setup_git_watcher(&self.args.git_service, &self.args.worktree_path) {\n                Some((d, rx)) => (Some(d), Some(rx)),\n                None => (None, None),\n            };\n        let _git_guard = git_debouncer;\n\n        let mut target_interval =\n            IntervalStream::new(tokio::time::interval(Duration::from_secs(1)));\n\n        loop {\n            let event = tokio::select! {\n                Some(res) = fs_rx.next() => DiffEvent::Filesystem(res),\n                Ok(()) = async {\n                    match git_rx.as_mut() {\n                        Some(rx) => rx.changed().await,\n                        None => std::future::pending().await,\n                    }\n                } => DiffEvent::GitStateChange,\n                _ = target_interval.next() => DiffEvent::CheckTarget,\n                else => break,\n            };\n\n            match event {\n                DiffEvent::Filesystem(res) => match res {\n                    Ok(events) => {\n                        self.handle_fs_events(events, &canonical_worktree).await?;\n                    }\n                    Err(e) => {\n                        tracing::error!(\"Filesystem watcher error: {e:?}\");\n                        return Err(io::Error::other(format!(\"{e:?}\")).into());\n                    }\n                },\n                DiffEvent::GitStateChange => {\n                    self.handle_git_state_change().await?;\n                }\n                DiffEvent::CheckTarget => {\n                    self.handle_target_check().await?;\n                }\n            }\n        }\n        Ok(())\n    }\n\n    async fn reset_stream(&mut self) -> Result<(), DiffStreamError> {\n        let paths_to_clear: Vec<String> = {\n            let mut guard = self.known_paths.write().unwrap();\n            guard.drain().collect()\n        };\n\n        for raw_path in paths_to_clear {\n            let prefixed = prefix_path(raw_path, self.args.path_prefix.as_deref());\n            let patch = ConversationPatch::remove_diff(escape_json_pointer_segment(&prefixed));\n            if self.tx.send(Ok(LogMsg::JsonPatch(patch))).await.is_err() {\n                return Ok(());\n            }\n        }\n\n        self.cumulative.store(0, Ordering::Relaxed);\n        self.full_sent.write().unwrap().clear();\n\n        let diffs = self.fetch_diffs().await?;\n        self.send_diffs(diffs).await?;\n\n        Ok(())\n    }\n\n    async fn fetch_diffs(&self) -> Result<Vec<Diff>, DiffStreamError> {\n        let git = self.args.git_service.clone();\n        let worktree = self.args.worktree_path.clone();\n        let base = self.current_base_commit.clone();\n        let stats_only = self.args.stats_only;\n        let cumulative = self.cumulative.clone();\n\n        tokio::task::spawn_blocking(move || {\n            let diffs = git.get_diffs(\n                DiffTarget::Worktree {\n                    worktree_path: &worktree,\n                    base_commit: &base,\n                },\n                None,\n            )?;\n\n            let mut processed_diffs = Vec::with_capacity(diffs.len());\n            for mut diff in diffs {\n                apply_stream_omit_policy(&mut diff, &cumulative, stats_only);\n                processed_diffs.push(diff);\n            }\n            Ok(processed_diffs)\n        })\n        .await?\n    }\n\n    async fn send_diffs(&self, diffs: Vec<Diff>) -> Result<(), DiffStreamError> {\n        for mut diff in diffs {\n            let raw_path = GitService::diff_path(&diff);\n\n            {\n                let mut guard = self.known_paths.write().unwrap();\n                guard.insert(raw_path.clone());\n            }\n\n            if !diff.content_omitted {\n                let mut guard = self.full_sent.write().unwrap();\n                guard.insert(raw_path.clone());\n            }\n\n            let prefixed_entry = prefix_path(raw_path, self.args.path_prefix.as_deref());\n            if let Some(old) = diff.old_path {\n                diff.old_path = Some(prefix_path(old, self.args.path_prefix.as_deref()));\n            }\n            if let Some(new) = diff.new_path {\n                diff.new_path = Some(prefix_path(new, self.args.path_prefix.as_deref()));\n            }\n            diff.repo_id = Some(self.args.repo_id);\n\n            let patch =\n                ConversationPatch::add_diff(escape_json_pointer_segment(&prefixed_entry), diff);\n            if self.tx.send(Ok(LogMsg::JsonPatch(patch))).await.is_err() {\n                return Ok(());\n            }\n        }\n        Ok(())\n    }\n\n    async fn handle_fs_events(\n        &self,\n        events: Vec<DebouncedEvent>,\n        canonical_worktree: &Path,\n    ) -> Result<(), DiffStreamError> {\n        let changed_paths =\n            extract_changed_paths(&events, canonical_worktree, &self.args.worktree_path);\n\n        if changed_paths.is_empty() {\n            return Ok(());\n        }\n\n        let git = self.args.git_service.clone();\n        let worktree = self.args.worktree_path.clone();\n        let base = self.current_base_commit.clone();\n        let cumulative = self.cumulative.clone();\n        let full_sent = self.full_sent.clone();\n        let known_paths = self.known_paths.clone();\n        let stats_only = self.args.stats_only;\n        let prefix = self.args.path_prefix.clone();\n        let repo_id = self.args.repo_id;\n\n        let messages = tokio::task::spawn_blocking(move || {\n            process_file_changes(\n                &git,\n                &worktree,\n                &base,\n                &changed_paths,\n                &cumulative,\n                &full_sent,\n                &known_paths,\n                stats_only,\n                prefix.as_deref(),\n                repo_id,\n            )\n        })\n        .await??;\n\n        for msg in messages {\n            if self.tx.send(Ok(msg)).await.is_err() {\n                return Ok(());\n            }\n        }\n        Ok(())\n    }\n\n    async fn handle_git_state_change(&mut self) -> Result<(), DiffStreamError> {\n        let Some(new_base) = self\n            .recompute_base_commit(&self.current_target_branch)\n            .await\n        else {\n            return Ok(());\n        };\n\n        if new_base.as_oid() != self.current_base_commit.as_oid() {\n            self.current_base_commit = new_base;\n            self.reset_stream().await?;\n        }\n        Ok(())\n    }\n\n    async fn handle_target_check(&mut self) -> Result<(), DiffStreamError> {\n        let Ok(Some(repo)) = WorkspaceRepo::find_by_workspace_and_repo_id(\n            &self.args.db.pool,\n            self.args.workspace_id,\n            self.args.repo_id,\n        )\n        .await\n        else {\n            return Ok(());\n        };\n\n        if repo.target_branch != self.current_target_branch\n            && let Some(new_base) = self.recompute_base_commit(&repo.target_branch).await\n        {\n            self.current_target_branch = repo.target_branch;\n            self.current_base_commit = new_base;\n            self.reset_stream().await?;\n        }\n        Ok(())\n    }\n\n    async fn recompute_base_commit(&self, target_branch: &str) -> Option<Commit> {\n        let git = self.args.git_service.clone();\n        let repo_path = self.args.repo_path.clone();\n        let branch = self.args.branch.clone();\n        let target = target_branch.to_string();\n\n        tokio::task::spawn_blocking(move || git.get_base_commit(&repo_path, &branch, &target).ok())\n            .await\n            .ok()\n            .flatten()\n    }\n}\n\nfn prefix_path(path: String, prefix: Option<&str>) -> String {\n    match prefix {\n        Some(p) => format!(\"{p}/{path}\"),\n        None => path,\n    }\n}\n\npub fn apply_stream_omit_policy(diff: &mut Diff, sent_bytes: &Arc<AtomicUsize>, stats_only: bool) {\n    if stats_only {\n        omit_diff_contents(diff);\n        return;\n    }\n\n    let mut size = 0usize;\n    if let Some(ref s) = diff.old_content {\n        size += s.len();\n    }\n    if let Some(ref s) = diff.new_content {\n        size += s.len();\n    }\n\n    if size == 0 {\n        return;\n    }\n\n    let current = sent_bytes.load(Ordering::Relaxed);\n    if current.saturating_add(size) > MAX_CUMULATIVE_DIFF_BYTES {\n        omit_diff_contents(diff);\n    } else {\n        let _ = sent_bytes.fetch_add(size, Ordering::Relaxed);\n    }\n}\n\nfn omit_diff_contents(diff: &mut Diff) {\n    if diff.additions.is_none()\n        && diff.deletions.is_none()\n        && (diff.old_content.is_some() || diff.new_content.is_some())\n    {\n        let old = diff.old_content.as_deref().unwrap_or(\"\");\n        let new = diff.new_content.as_deref().unwrap_or(\"\");\n        let (add, del) = diff::compute_line_change_counts(old, new);\n        diff.additions = Some(add);\n        diff.deletions = Some(del);\n    }\n\n    diff.old_content = None;\n    diff.new_content = None;\n    diff.content_omitted = true;\n}\n\nfn extract_changed_paths(\n    events: &[DebouncedEvent],\n    canonical_worktree_path: &Path,\n    worktree_path: &Path,\n) -> Vec<String> {\n    events\n        .iter()\n        .flat_map(|event| &event.paths)\n        .filter_map(|path| {\n            path.strip_prefix(canonical_worktree_path)\n                .or_else(|_| path.strip_prefix(worktree_path))\n                .ok()\n                .map(|p| p.to_string_lossy().replace('\\\\', \"/\"))\n        })\n        .filter(|s| !s.is_empty())\n        .collect()\n}\n\n#[allow(clippy::too_many_arguments)]\nfn process_file_changes(\n    git_service: &GitService,\n    worktree_path: &Path,\n    base_commit: &Commit,\n    changed_paths: &[String],\n    cumulative_bytes: &Arc<AtomicUsize>,\n    full_sent_paths: &Arc<std::sync::RwLock<HashSet<String>>>,\n    known_paths: &Arc<std::sync::RwLock<HashSet<String>>>,\n    stats_only: bool,\n    path_prefix: Option<&str>,\n    repo_id: Uuid,\n) -> Result<Vec<LogMsg>, DiffStreamError> {\n    let path_filter: Vec<&str> = changed_paths.iter().map(|s| s.as_str()).collect();\n\n    let current_diffs = git_service.get_diffs(\n        DiffTarget::Worktree {\n            worktree_path,\n            base_commit,\n        },\n        Some(&path_filter),\n    )?;\n\n    let mut msgs = Vec::new();\n    let mut files_with_diffs = HashSet::new();\n\n    for mut diff in current_diffs {\n        let raw_file_path = GitService::diff_path(&diff);\n        files_with_diffs.insert(raw_file_path.clone());\n        {\n            let mut guard = known_paths.write().unwrap();\n            guard.insert(raw_file_path.clone());\n        }\n\n        apply_stream_omit_policy(&mut diff, cumulative_bytes, stats_only);\n\n        if diff.content_omitted {\n            if full_sent_paths.read().unwrap().contains(&raw_file_path) {\n                continue;\n            }\n        } else {\n            let mut guard = full_sent_paths.write().unwrap();\n            guard.insert(raw_file_path.clone());\n        }\n\n        let prefixed_entry_index = prefix_path(raw_file_path, path_prefix);\n        if let Some(old) = diff.old_path {\n            diff.old_path = Some(prefix_path(old, path_prefix));\n        }\n        if let Some(new) = diff.new_path {\n            diff.new_path = Some(prefix_path(new, path_prefix));\n        }\n        diff.repo_id = Some(repo_id);\n\n        let patch =\n            ConversationPatch::add_diff(escape_json_pointer_segment(&prefixed_entry_index), diff);\n        msgs.push(LogMsg::JsonPatch(patch));\n    }\n\n    for changed_path in changed_paths {\n        if !files_with_diffs.contains(changed_path) {\n            let prefixed_path = prefix_path(changed_path.clone(), path_prefix);\n            let patch = ConversationPatch::remove_diff(escape_json_pointer_segment(&prefixed_path));\n            msgs.push(LogMsg::JsonPatch(patch));\n            {\n                let mut guard = known_paths.write().unwrap();\n                guard.remove(changed_path);\n            }\n        }\n    }\n\n    Ok(msgs)\n}\n\n/// Watches `.git/HEAD` and `.git/logs/HEAD` for changes.\n/// Correctly resolves gitdir even for worktrees.\nfn setup_git_watcher(\n    git: &GitService,\n    worktree_path: &Path,\n) -> Option<(\n    Debouncer<RecommendedWatcher, RecommendedCache>,\n    tokio::sync::watch::Receiver<()>,\n)> {\n    let Ok(repo) = git.open_repo(worktree_path) else {\n        tracing::warn!(\n            \"Failed to open git repo at {:?}, git events will be ignored\",\n            worktree_path\n        );\n        return None;\n    };\n\n    // For worktrees, repo.path() points to the actual gitdir (e.g. .git/worktrees/name or .git/)\n    let gitdir = repo.path();\n    let paths_to_watch = vec![gitdir.join(\"HEAD\"), gitdir.join(\"logs\").join(\"HEAD\")];\n\n    let (tx, rx) = tokio::sync::watch::channel(());\n\n    // Create debouncer with short timeout since git operations might touch multiple files\n    let mut debouncer = new_debouncer(\n        Duration::from_millis(200),\n        None,\n        move |res: DebounceEventResult| {\n            if res.is_ok() {\n                let _ = tx.send(());\n            }\n        },\n    )\n    .ok()?;\n\n    let mut watched_any = false;\n    for path in paths_to_watch {\n        if path.exists() {\n            if let Err(e) = debouncer.watch(&path, RecursiveMode::NonRecursive) {\n                tracing::debug!(\"Failed to watch git path {:?}: {}\", path, e);\n            } else {\n                watched_any = true;\n            }\n        }\n    }\n\n    if !watched_any {\n        return None;\n    }\n\n    Some((debouncer, rx))\n}\n"
  },
  {
    "path": "crates/services/src/services/events/patches.rs",
    "content": "use db::models::{\n    execution_process::ExecutionProcess, scratch::Scratch, workspace::WorkspaceWithStatus,\n};\nuse json_patch::{AddOperation, Patch, PatchOperation, RemoveOperation, ReplaceOperation};\nuse uuid::Uuid;\n\n// Shared helper to escape JSON Pointer segments\nfn escape_pointer_segment(s: &str) -> String {\n    s.replace('~', \"~0\").replace('/', \"~1\")\n}\n\n/// Helper functions for creating execution process-specific patches\npub mod execution_process_patch {\n    use super::*;\n\n    fn execution_process_path(process_id: Uuid) -> String {\n        format!(\n            \"/execution_processes/{}\",\n            escape_pointer_segment(&process_id.to_string())\n        )\n    }\n\n    /// Create patch for adding a new execution process\n    pub fn add(process: &ExecutionProcess) -> Patch {\n        Patch(vec![PatchOperation::Add(AddOperation {\n            path: execution_process_path(process.id)\n                .try_into()\n                .expect(\"Execution process path should be valid\"),\n            value: serde_json::to_value(process)\n                .expect(\"Execution process serialization should not fail\"),\n        })])\n    }\n\n    /// Create patch for updating an existing execution process\n    pub fn replace(process: &ExecutionProcess) -> Patch {\n        Patch(vec![PatchOperation::Replace(ReplaceOperation {\n            path: execution_process_path(process.id)\n                .try_into()\n                .expect(\"Execution process path should be valid\"),\n            value: serde_json::to_value(process)\n                .expect(\"Execution process serialization should not fail\"),\n        })])\n    }\n\n    /// Create patch for removing an execution process\n    pub fn remove(process_id: Uuid) -> Patch {\n        Patch(vec![PatchOperation::Remove(RemoveOperation {\n            path: execution_process_path(process_id)\n                .try_into()\n                .expect(\"Execution process path should be valid\"),\n        })])\n    }\n}\n\n/// Helper functions for creating workspace-specific patches\npub mod workspace_patch {\n    use super::*;\n\n    fn workspace_path(workspace_id: Uuid) -> String {\n        format!(\n            \"/workspaces/{}\",\n            escape_pointer_segment(&workspace_id.to_string())\n        )\n    }\n\n    pub fn add(workspace: &WorkspaceWithStatus) -> Patch {\n        Patch(vec![PatchOperation::Add(AddOperation {\n            path: workspace_path(workspace.id)\n                .try_into()\n                .expect(\"Workspace path should be valid\"),\n            value: serde_json::to_value(workspace)\n                .expect(\"Workspace serialization should not fail\"),\n        })])\n    }\n\n    pub fn replace(workspace: &WorkspaceWithStatus) -> Patch {\n        Patch(vec![PatchOperation::Replace(ReplaceOperation {\n            path: workspace_path(workspace.id)\n                .try_into()\n                .expect(\"Workspace path should be valid\"),\n            value: serde_json::to_value(workspace)\n                .expect(\"Workspace serialization should not fail\"),\n        })])\n    }\n\n    pub fn remove(workspace_id: Uuid) -> Patch {\n        Patch(vec![PatchOperation::Remove(RemoveOperation {\n            path: workspace_path(workspace_id)\n                .try_into()\n                .expect(\"Workspace path should be valid\"),\n        })])\n    }\n}\n\n/// Helper functions for creating scratch-specific patches.\n/// All patches use path \"/scratch\" - filtering is done by matching id and payload type in the value.\npub mod scratch_patch {\n    use super::*;\n\n    const SCRATCH_PATH: &str = \"/scratch\";\n\n    /// Create patch for adding a new scratch\n    pub fn add(scratch: &Scratch) -> Patch {\n        Patch(vec![PatchOperation::Add(AddOperation {\n            path: SCRATCH_PATH\n                .try_into()\n                .expect(\"Scratch path should be valid\"),\n            value: serde_json::to_value(scratch).expect(\"Scratch serialization should not fail\"),\n        })])\n    }\n\n    /// Create patch for updating an existing scratch\n    pub fn replace(scratch: &Scratch) -> Patch {\n        Patch(vec![PatchOperation::Replace(ReplaceOperation {\n            path: SCRATCH_PATH\n                .try_into()\n                .expect(\"Scratch path should be valid\"),\n            value: serde_json::to_value(scratch).expect(\"Scratch serialization should not fail\"),\n        })])\n    }\n\n    /// Create patch for removing a scratch.\n    /// Uses Replace with deleted marker so clients can filter by id and payload type.\n    pub fn remove(scratch_id: Uuid, scratch_type_str: &str) -> Patch {\n        Patch(vec![PatchOperation::Replace(ReplaceOperation {\n            path: SCRATCH_PATH\n                .try_into()\n                .expect(\"Scratch path should be valid\"),\n            value: serde_json::json!({\n                \"id\": scratch_id,\n                \"payload\": { \"type\": scratch_type_str },\n                \"deleted\": true\n            }),\n        })])\n    }\n}\n\n/// Helper functions for creating approval-specific patches.\npub mod approvals_patch {\n    use super::*;\n\n    const PENDING_PATH: &str = \"/pending\";\n\n    fn pending_path(approval_id: &str) -> String {\n        format!(\"{}/{}\", PENDING_PATH, escape_pointer_segment(approval_id))\n    }\n\n    pub fn snapshot(pending: &[crate::services::approvals::ApprovalInfo]) -> Patch {\n        let pending: serde_json::Map<String, serde_json::Value> = pending\n            .iter()\n            .map(|info| {\n                (\n                    info.approval_id.clone(),\n                    serde_json::to_value(info).unwrap_or(serde_json::Value::Null),\n                )\n            })\n            .collect();\n\n        Patch(vec![PatchOperation::Replace(ReplaceOperation {\n            path: PENDING_PATH\n                .try_into()\n                .expect(\"Pending approvals path should be valid\"),\n            value: serde_json::Value::Object(pending),\n        })])\n    }\n\n    pub fn created(info: &crate::services::approvals::ApprovalInfo) -> Patch {\n        let value = serde_json::to_value(info).unwrap_or(serde_json::Value::Null);\n        Patch(vec![PatchOperation::Replace(ReplaceOperation {\n            path: pending_path(&info.approval_id)\n                .try_into()\n                .expect(\"Approval path should be valid\"),\n            value,\n        })])\n    }\n\n    pub fn resolved(approval_id: &str) -> Patch {\n        Patch(vec![PatchOperation::Remove(RemoveOperation {\n            path: pending_path(approval_id)\n                .try_into()\n                .expect(\"Approval path should be valid\"),\n        })])\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/events/streams.rs",
    "content": "use db::models::{execution_process::ExecutionProcess, scratch::Scratch, workspace::Workspace};\nuse futures::StreamExt;\nuse serde_json::json;\nuse tokio_stream::wrappers::BroadcastStream;\nuse utils::log_msg::LogMsg;\nuse uuid::Uuid;\n\nuse super::{\n    EventService,\n    patches::execution_process_patch,\n    types::{EventPatch, RecordTypes},\n};\n\nimpl EventService {\n    /// Stream execution processes for a specific session with initial snapshot (raw LogMsg format for WebSocket)\n    pub async fn stream_execution_processes_for_session_raw(\n        &self,\n        session_id: Uuid,\n        show_soft_deleted: bool,\n    ) -> Result<\n        futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>,\n        super::types::EventError,\n    > {\n        // Get execution processes for this session\n        let processes =\n            ExecutionProcess::find_by_session_id(&self.db.pool, session_id, show_soft_deleted)\n                .await?;\n\n        // Convert processes array to object keyed by process ID\n        let processes_map: serde_json::Map<String, serde_json::Value> = processes\n            .into_iter()\n            .map(|process| {\n                (\n                    process.id.to_string(),\n                    serde_json::to_value(process).unwrap(),\n                )\n            })\n            .collect();\n\n        let initial_patch = json!([{\n            \"op\": \"replace\",\n            \"path\": \"/execution_processes\",\n            \"value\": processes_map\n        }]);\n        let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap());\n\n        // Get filtered event stream\n        let filtered_stream =\n            BroadcastStream::new(self.msg_store.get_receiver()).filter_map(move |msg_result| {\n                async move {\n                    match msg_result {\n                        Ok(LogMsg::JsonPatch(patch)) => {\n                            // Filter events based on session_id\n                            if let Some(patch_op) = patch.0.first() {\n                                // Check if this is a modern execution process patch\n                                if patch_op.path().starts_with(\"/execution_processes/\") {\n                                    match patch_op {\n                                        json_patch::PatchOperation::Add(op) => {\n                                            // Parse execution process data directly from value\n                                            if let Ok(process) =\n                                                serde_json::from_value::<ExecutionProcess>(\n                                                    op.value.clone(),\n                                                )\n                                                && process.session_id == session_id\n                                            {\n                                                if !show_soft_deleted && process.dropped {\n                                                    let remove_patch =\n                                                        execution_process_patch::remove(process.id);\n                                                    return Some(Ok(LogMsg::JsonPatch(\n                                                        remove_patch,\n                                                    )));\n                                                }\n                                                return Some(Ok(LogMsg::JsonPatch(patch)));\n                                            }\n                                        }\n                                        json_patch::PatchOperation::Replace(op) => {\n                                            // Parse execution process data directly from value\n                                            if let Ok(process) =\n                                                serde_json::from_value::<ExecutionProcess>(\n                                                    op.value.clone(),\n                                                )\n                                                && process.session_id == session_id\n                                            {\n                                                if !show_soft_deleted && process.dropped {\n                                                    let remove_patch =\n                                                        execution_process_patch::remove(process.id);\n                                                    return Some(Ok(LogMsg::JsonPatch(\n                                                        remove_patch,\n                                                    )));\n                                                }\n                                                return Some(Ok(LogMsg::JsonPatch(patch)));\n                                            }\n                                        }\n                                        json_patch::PatchOperation::Remove(_) => {\n                                            // For remove operations, we can't verify session_id\n                                            // so we allow all removals and let the client handle filtering\n                                            return Some(Ok(LogMsg::JsonPatch(patch)));\n                                        }\n                                        _ => {}\n                                    }\n                                }\n                                // Fallback to legacy EventPatch format for backward compatibility\n                                else if let Ok(event_patch_value) = serde_json::to_value(patch_op)\n                                    && let Ok(event_patch) =\n                                        serde_json::from_value::<EventPatch>(event_patch_value)\n                                {\n                                    match &event_patch.value.record {\n                                        RecordTypes::ExecutionProcess(process) => {\n                                            if process.session_id == session_id {\n                                                if !show_soft_deleted && process.dropped {\n                                                    let remove_patch =\n                                                        execution_process_patch::remove(process.id);\n                                                    return Some(Ok(LogMsg::JsonPatch(\n                                                        remove_patch,\n                                                    )));\n                                                }\n                                                return Some(Ok(LogMsg::JsonPatch(patch)));\n                                            }\n                                        }\n                                        RecordTypes::DeletedExecutionProcess {\n                                            session_id: Some(deleted_session_id),\n                                            ..\n                                        } => {\n                                            if *deleted_session_id == session_id {\n                                                return Some(Ok(LogMsg::JsonPatch(patch)));\n                                            }\n                                        }\n                                        _ => {}\n                                    }\n                                }\n                            }\n                            None\n                        }\n                        Ok(other) => Some(Ok(other)), // Pass through non-patch messages\n                        Err(_) => None,               // Filter out broadcast errors\n                    }\n                }\n            });\n\n        // Start with initial snapshot, Ready signal, then live updates\n        let initial_stream = futures::stream::iter(vec![Ok(initial_msg), Ok(LogMsg::Ready)]);\n        let combined_stream = initial_stream.chain(filtered_stream).boxed();\n\n        Ok(combined_stream)\n    }\n\n    /// Stream a single scratch item with initial snapshot (raw LogMsg format for WebSocket)\n    pub async fn stream_scratch_raw(\n        &self,\n        scratch_id: Uuid,\n        scratch_type: &db::models::scratch::ScratchType,\n    ) -> Result<\n        futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>,\n        super::types::EventError,\n    > {\n        // Treat errors (e.g., corrupted/malformed data) the same as \"scratch not found\"\n        // This prevents the websocket from closing and retrying indefinitely\n        let scratch = match Scratch::find_by_id(&self.db.pool, scratch_id, scratch_type).await {\n            Ok(scratch) => scratch,\n            Err(e) => {\n                tracing::warn!(\n                    scratch_id = %scratch_id,\n                    scratch_type = %scratch_type,\n                    error = %e,\n                    \"Failed to load scratch, treating as empty\"\n                );\n                None\n            }\n        };\n\n        let initial_patch = json!([{\n            \"op\": \"replace\",\n            \"path\": \"/scratch\",\n            \"value\": scratch\n        }]);\n        let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap());\n\n        let type_str = scratch_type.to_string();\n\n        // Filter to only this scratch's events by matching id and payload.type in the patch value\n        let filtered_stream =\n            BroadcastStream::new(self.msg_store.get_receiver()).filter_map(move |msg_result| {\n                let id_str = scratch_id.to_string();\n                let type_str = type_str.clone();\n                async move {\n                    match msg_result {\n                        Ok(LogMsg::JsonPatch(patch)) => {\n                            if let Some(op) = patch.0.first()\n                                && op.path() == \"/scratch\"\n                            {\n                                // Extract id and payload.type from the patch value\n                                let value = match op {\n                                    json_patch::PatchOperation::Add(a) => Some(&a.value),\n                                    json_patch::PatchOperation::Replace(r) => Some(&r.value),\n                                    json_patch::PatchOperation::Remove(_) => None,\n                                    _ => None,\n                                };\n\n                                let matches = value.is_some_and(|v| {\n                                    let id_matches =\n                                        v.get(\"id\").and_then(|v| v.as_str()) == Some(&id_str);\n                                    let type_matches = v\n                                        .get(\"payload\")\n                                        .and_then(|p| p.get(\"type\"))\n                                        .and_then(|t| t.as_str())\n                                        == Some(&type_str);\n                                    id_matches && type_matches\n                                });\n\n                                if matches {\n                                    return Some(Ok(LogMsg::JsonPatch(patch)));\n                                }\n                            }\n                            None\n                        }\n                        Ok(other) => Some(Ok(other)),\n                        Err(_) => None,\n                    }\n                }\n            });\n\n        let initial_stream = futures::stream::iter(vec![Ok(initial_msg), Ok(LogMsg::Ready)]);\n        let combined_stream = initial_stream.chain(filtered_stream).boxed();\n        Ok(combined_stream)\n    }\n\n    pub async fn stream_workspaces_raw(\n        &self,\n        archived: Option<bool>,\n        limit: Option<i64>,\n    ) -> Result<\n        futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>,\n        super::types::EventError,\n    > {\n        let workspaces = Workspace::find_all_with_status(&self.db.pool, archived, limit).await?;\n        let workspaces_map: serde_json::Map<String, serde_json::Value> = workspaces\n            .into_iter()\n            .map(|ws| (ws.id.to_string(), serde_json::to_value(ws).unwrap()))\n            .collect();\n\n        let initial_patch = json!([{\n            \"op\": \"replace\",\n            \"path\": \"/workspaces\",\n            \"value\": workspaces_map\n        }]);\n        let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap());\n\n        let filtered_stream = BroadcastStream::new(self.msg_store.get_receiver()).filter_map(\n            move |msg_result| async move {\n                match msg_result {\n                    Ok(LogMsg::JsonPatch(patch)) => {\n                        if let Some(op) = patch.0.first()\n                            && op.path().starts_with(\"/workspaces\")\n                        {\n                            // If archived filter is set, handle state transitions\n                            if let Some(archived_filter) = archived {\n                                // Extract workspace data from Add/Replace operations\n                                let value = match op {\n                                    json_patch::PatchOperation::Add(a) => Some(&a.value),\n                                    json_patch::PatchOperation::Replace(r) => Some(&r.value),\n                                    json_patch::PatchOperation::Remove(_) => {\n                                        // Allow remove operations through - client will handle\n                                        return Some(Ok(LogMsg::JsonPatch(patch)));\n                                    }\n                                    _ => None,\n                                };\n\n                                if let Some(v) = value\n                                    && let Some(ws_archived) =\n                                        v.get(\"archived\").and_then(|a| a.as_bool())\n                                {\n                                    if ws_archived == archived_filter {\n                                        // Workspace matches this filter\n                                        // Convert Replace to Add since workspace may be new to this filtered stream\n                                        if let json_patch::PatchOperation::Replace(r) = op {\n                                            let add_patch = json_patch::Patch(vec![\n                                                json_patch::PatchOperation::Add(\n                                                    json_patch::AddOperation {\n                                                        path: r.path.clone(),\n                                                        value: r.value.clone(),\n                                                    },\n                                                ),\n                                            ]);\n                                            return Some(Ok(LogMsg::JsonPatch(add_patch)));\n                                        }\n                                        return Some(Ok(LogMsg::JsonPatch(patch)));\n                                    } else {\n                                        // Workspace no longer matches this filter - send remove\n                                        let remove_patch = json_patch::Patch(vec![\n                                            json_patch::PatchOperation::Remove(\n                                                json_patch::RemoveOperation {\n                                                    path: op\n                                                        .path()\n                                                        .to_string()\n                                                        .try_into()\n                                                        .expect(\"Workspace path should be valid\"),\n                                                },\n                                            ),\n                                        ]);\n                                        return Some(Ok(LogMsg::JsonPatch(remove_patch)));\n                                    }\n                                }\n                            }\n                            return Some(Ok(LogMsg::JsonPatch(patch)));\n                        }\n                        None\n                    }\n                    Ok(other) => Some(Ok(other)),\n                    Err(_) => None,\n                }\n            },\n        );\n\n        let initial_stream = futures::stream::iter(vec![Ok(initial_msg), Ok(LogMsg::Ready)]);\n        Ok(initial_stream.chain(filtered_stream).boxed())\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/events/types.rs",
    "content": "use anyhow::Error as AnyhowError;\nuse db::models::{execution_process::ExecutionProcess, scratch::Scratch, workspace::Workspace};\nuse serde::{Deserialize, Serialize};\nuse sqlx::Error as SqlxError;\nuse strum_macros::{Display, EnumString};\nuse thiserror::Error;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum EventError {\n    #[error(transparent)]\n    Sqlx(#[from] SqlxError),\n    #[error(transparent)]\n    Parse(#[from] serde_json::Error),\n    #[error(transparent)]\n    Other(#[from] AnyhowError), // Catches any unclassified errors\n}\n\n#[derive(EnumString, Display)]\npub enum HookTables {\n    #[strum(to_string = \"workspaces\")]\n    Workspaces,\n    #[strum(to_string = \"execution_processes\")]\n    ExecutionProcesses,\n    #[strum(to_string = \"scratch\")]\n    Scratch,\n}\n\n#[derive(Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", content = \"data\", rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum RecordTypes {\n    Workspace(Workspace),\n    ExecutionProcess(ExecutionProcess),\n    Scratch(Scratch),\n    DeletedWorkspace {\n        rowid: i64,\n    },\n    DeletedExecutionProcess {\n        rowid: i64,\n        session_id: Option<Uuid>,\n        process_id: Option<Uuid>,\n    },\n    DeletedScratch {\n        rowid: i64,\n        scratch_id: Option<Uuid>,\n        scratch_type: Option<String>,\n    },\n}\n\n#[derive(Serialize, Deserialize, TS)]\npub struct EventPatchInner {\n    pub(crate) db_op: String,\n    pub(crate) record: RecordTypes,\n}\n\n#[derive(Serialize, Deserialize, TS)]\npub struct EventPatch {\n    pub(crate) op: String,\n    pub(crate) path: String,\n    pub(crate) value: EventPatchInner,\n}\n"
  },
  {
    "path": "crates/services/src/services/events.rs",
    "content": "use std::{str::FromStr, sync::Arc};\n\nuse db::{\n    DBService,\n    models::{\n        execution_process::ExecutionProcess, scratch::Scratch, session::Session,\n        workspace::Workspace,\n    },\n};\nuse serde_json::json;\nuse sqlx::{Error as SqlxError, Sqlite, SqlitePool, decode::Decode, sqlite::SqliteOperation};\nuse tokio::sync::RwLock;\nuse utils::msg_store::MsgStore;\nuse uuid::Uuid;\n\n#[path = \"events/patches.rs\"]\npub mod patches;\n#[path = \"events/streams.rs\"]\nmod streams;\n#[path = \"events/types.rs\"]\npub mod types;\n\npub use patches::{execution_process_patch, scratch_patch, workspace_patch};\npub use types::{EventError, EventPatch, EventPatchInner, HookTables, RecordTypes};\n\n#[derive(Clone)]\npub struct EventService {\n    msg_store: Arc<MsgStore>,\n    db: DBService,\n    #[allow(dead_code)]\n    entry_count: Arc<RwLock<usize>>,\n}\n\nimpl EventService {\n    /// Creates a new EventService that will work with a DBService configured with hooks\n    pub fn new(db: DBService, msg_store: Arc<MsgStore>, entry_count: Arc<RwLock<usize>>) -> Self {\n        Self {\n            msg_store,\n            db,\n            entry_count,\n        }\n    }\n\n    async fn push_workspace_update_for_session(\n        pool: &SqlitePool,\n        msg_store: Arc<MsgStore>,\n        session_id: Uuid,\n    ) -> Result<(), SqlxError> {\n        if let Some(session) = Session::find_by_id(pool, session_id).await?\n            && let Some(workspace_with_status) =\n                Workspace::find_by_id_with_status(pool, session.workspace_id).await?\n        {\n            msg_store.push_patch(workspace_patch::replace(&workspace_with_status));\n        }\n        Ok(())\n    }\n\n    /// Creates the hook function that should be used with DBService::new_with_after_connect\n    pub fn create_hook(\n        msg_store: Arc<MsgStore>,\n        entry_count: Arc<RwLock<usize>>,\n        db_service: DBService,\n    ) -> impl for<'a> Fn(\n        &'a mut sqlx::sqlite::SqliteConnection,\n    ) -> std::pin::Pin<\n        Box<dyn std::future::Future<Output = Result<(), sqlx::Error>> + Send + 'a>,\n    > + Send\n    + Sync\n    + 'static {\n        move |conn: &mut sqlx::sqlite::SqliteConnection| {\n            let msg_store_for_hook = msg_store.clone();\n            let entry_count_for_hook = entry_count.clone();\n            let db_for_hook = db_service.clone();\n            Box::pin(async move {\n                let mut handle = conn.lock_handle().await?;\n                let runtime_handle = tokio::runtime::Handle::current();\n                handle.set_preupdate_hook({\n                    let msg_store_for_preupdate = msg_store_for_hook.clone();\n                    move |preupdate: sqlx::sqlite::PreupdateHookResult<'_>| {\n                        if preupdate.operation != SqliteOperation::Delete {\n                            return;\n                        }\n\n                        match preupdate.table {\n                            \"workspaces\" => {\n                                if let Ok(value) = preupdate.get_old_column_value(0)\n                                    && let Ok(workspace_id) =\n                                        <Uuid as Decode<Sqlite>>::decode(value)\n                                {\n                                    let patch = workspace_patch::remove(workspace_id);\n                                    msg_store_for_preupdate.push_patch(patch);\n                                }\n                            }\n                            \"execution_processes\" => {\n                                if let Ok(value) = preupdate.get_old_column_value(0)\n                                    && let Ok(process_id) = <Uuid as Decode<Sqlite>>::decode(value)\n                                {\n                                    let patch = execution_process_patch::remove(process_id);\n                                    msg_store_for_preupdate.push_patch(patch);\n                                }\n                            }\n                            \"scratch\" => {\n                                // Composite key: need both id (column 0) and scratch_type (column 1)\n                                if let Ok(id_val) = preupdate.get_old_column_value(0)\n                                    && let Ok(scratch_id) = <Uuid as Decode<Sqlite>>::decode(id_val)\n                                    && let Ok(type_val) = preupdate.get_old_column_value(1)\n                                    && let Ok(type_str) =\n                                        <String as Decode<Sqlite>>::decode(type_val)\n                                {\n                                    let patch = scratch_patch::remove(scratch_id, &type_str);\n                                    msg_store_for_preupdate.push_patch(patch);\n                                }\n                            }\n                            _ => {}\n                        }\n                    }\n                });\n\n                handle.set_update_hook(move |hook: sqlx::sqlite::UpdateHookResult<'_>| {\n                    let runtime_handle = runtime_handle.clone();\n                    let entry_count_for_hook = entry_count_for_hook.clone();\n                    let msg_store_for_hook = msg_store_for_hook.clone();\n                    let db = db_for_hook.clone();\n\n                    if let Ok(table) = HookTables::from_str(hook.table) {\n                        let rowid = hook.rowid;\n                        runtime_handle.spawn(async move {\n                            let record_type: RecordTypes = match (table, hook.operation.clone()) {\n                                (HookTables::Workspaces, SqliteOperation::Delete)\n                                | (HookTables::ExecutionProcesses, SqliteOperation::Delete)\n                                | (HookTables::Scratch, SqliteOperation::Delete) => {\n                                    return;\n                                }\n                                (HookTables::Workspaces, _) => {\n                                    match Workspace::find_by_rowid(&db.pool, rowid).await {\n                                        Ok(Some(workspace)) => RecordTypes::Workspace(workspace),\n                                        Ok(None) => RecordTypes::DeletedWorkspace {\n                                            rowid,\n                                        },\n                                        Err(e) => {\n                                            tracing::error!(\n                                                \"Failed to fetch workspace: {:?}\",\n                                                e\n                                            );\n                                            return;\n                                        }\n                                    }\n                                }\n                                (HookTables::ExecutionProcesses, _) => {\n                                    match ExecutionProcess::find_by_rowid(&db.pool, rowid).await {\n                                        Ok(Some(process)) => RecordTypes::ExecutionProcess(process),\n                                        Ok(None) => RecordTypes::DeletedExecutionProcess {\n                                            rowid,\n                                            session_id: None,\n                                            process_id: None,\n                                        },\n                                        Err(e) => {\n                                            tracing::error!(\n                                                \"Failed to fetch execution_process: {:?}\",\n                                                e\n                                            );\n                                            return;\n                                        }\n                                    }\n                                }\n                                (HookTables::Scratch, _) => {\n                                    match Scratch::find_by_rowid(&db.pool, rowid).await {\n                                        Ok(Some(scratch)) => RecordTypes::Scratch(scratch),\n                                        Ok(None) => RecordTypes::DeletedScratch {\n                                            rowid,\n                                            scratch_id: None,\n                                            scratch_type: None,\n                                        },\n                                        Err(e) => {\n                                            tracing::error!(\"Failed to fetch scratch: {:?}\", e);\n                                            return;\n                                        }\n                                    }\n                                }\n                            };\n\n                            let db_op: &str = match hook.operation {\n                                SqliteOperation::Insert => \"insert\",\n                                SqliteOperation::Delete => \"delete\",\n                                SqliteOperation::Update => \"update\",\n                                SqliteOperation::Unknown(_) => \"unknown\",\n                            };\n\n                            // Handle operations with direct patches\n                            match &record_type {\n                                RecordTypes::Scratch(scratch) => {\n                                    let patch = match hook.operation {\n                                        SqliteOperation::Insert => scratch_patch::add(scratch),\n                                        SqliteOperation::Update => scratch_patch::replace(scratch),\n                                        _ => scratch_patch::replace(scratch),\n                                    };\n                                    msg_store_for_hook.push_patch(patch);\n                                    return;\n                                }\n                                RecordTypes::DeletedScratch {\n                                    scratch_id: Some(scratch_id),\n                                    scratch_type: Some(scratch_type_str),\n                                    ..\n                                } => {\n                                    let patch = scratch_patch::remove(*scratch_id, scratch_type_str);\n                                    msg_store_for_hook.push_patch(patch);\n                                    return;\n                                }\n                                RecordTypes::Workspace(workspace) => {\n                                    // Emit workspace patch with status\n                                    if let Ok(Some(workspace_with_status)) =\n                                        Workspace::find_by_id_with_status(&db.pool, workspace.id)\n                                            .await\n                                    {\n                                        let patch = match hook.operation {\n                                            SqliteOperation::Insert => {\n                                                workspace_patch::add(&workspace_with_status)\n                                            }\n                                            _ => workspace_patch::replace(&workspace_with_status),\n                                        };\n                                        msg_store_for_hook.push_patch(patch);\n                                    }\n                                    return;\n                                }\n                                RecordTypes::DeletedWorkspace { .. } => {\n                                    return;\n                                }\n                                RecordTypes::ExecutionProcess(process) => {\n                                    let patch = match hook.operation {\n                                        SqliteOperation::Insert => {\n                                            execution_process_patch::add(process)\n                                        }\n                                        SqliteOperation::Update => {\n                                            execution_process_patch::replace(process)\n                                        }\n                                        _ => execution_process_patch::replace(process), // fallback\n                                    };\n                                    msg_store_for_hook.push_patch(patch);\n\n                                    if let Err(err) = EventService::push_workspace_update_for_session(\n                                        &db.pool,\n                                        msg_store_for_hook.clone(),\n                                        process.session_id,\n                                    )\n                                    .await\n                                    {\n                                        tracing::error!(\n                                            \"Failed to push workspace update after execution process change: {:?}\",\n                                            err\n                                        );\n                                    }\n\n                                    return;\n                                }\n                                RecordTypes::DeletedExecutionProcess {\n                                    process_id: Some(process_id),\n                                    session_id,\n                                    ..\n                                } => {\n                                    let patch = execution_process_patch::remove(*process_id);\n                                    msg_store_for_hook.push_patch(patch);\n\n                                    if let Some(session_id) = session_id\n                                        && let Err(err) =\n                                            EventService::push_workspace_update_for_session(\n                                                &db.pool,\n                                                msg_store_for_hook.clone(),\n                                                *session_id,\n                                            )\n                                            .await\n                                        {\n                                            tracing::error!(\n                                                \"Failed to push workspace update after execution process removal: {:?}\",\n                                                err\n                                            );\n                                    }\n\n                                    return;\n                                }\n                                _ => {}\n                            }\n\n                            // Fallback: use the old entries format for other record types\n                            let next_entry_count = {\n                                let mut entry_count = entry_count_for_hook.write().await;\n                                *entry_count += 1;\n                                *entry_count\n                            };\n\n                            let event_patch: EventPatch = EventPatch {\n                                op: \"add\".to_string(),\n                                path: format!(\"/entries/{next_entry_count}\"),\n                                value: EventPatchInner {\n                                    db_op: db_op.to_string(),\n                                    record: record_type,\n                                },\n                            };\n\n                            let patch =\n                                serde_json::from_value(json!([\n                                    serde_json::to_value(event_patch).unwrap()\n                                ]))\n                                .unwrap();\n\n                            msg_store_for_hook.push_patch(patch);\n                        });\n                    }\n                });\n\n                Ok(())\n            })\n        }\n    }\n\n    pub fn msg_store(&self) -> &Arc<MsgStore> {\n        &self.msg_store\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/execution_process.rs",
    "content": "use std::{\n    collections::HashMap,\n    io::{IsTerminal, Write},\n    sync::Arc,\n};\n\nuse anyhow::{Context, Result};\nuse db::{\n    DBService,\n    models::{\n        coding_agent_turn::CodingAgentTurn, execution_process::ExecutionProcess,\n        execution_process_logs::ExecutionProcessLogs,\n    },\n};\nuse futures::{StreamExt, TryStreamExt};\nuse indicatif::{ProgressBar, ProgressStyle};\nuse sqlx::SqlitePool;\nuse tokio::{io::AsyncWriteExt, sync::RwLock, task::JoinHandle};\nuse utils::{\n    assets::prod_asset_dir_path,\n    execution_logs::{\n        ExecutionLogWriter, process_log_file_path, process_log_file_path_in_root,\n        read_execution_log_file,\n    },\n    log_msg::LogMsg,\n    msg_store::MsgStore,\n};\nuse uuid::Uuid;\n\npub async fn migrate_execution_logs_to_files() -> Result<()> {\n    let pool = DBService::new_migration_pool()\n        .await\n        .map_err(|e| anyhow::anyhow!(\"Migration DB pool error: {}\", e))?;\n\n    if !ExecutionProcessLogs::has_any(&pool).await? {\n        return Ok(());\n    }\n\n    let is_tty = std::io::stderr().is_terminal();\n    if is_tty {\n        let _ = writeln!(\n            std::io::stderr(),\n            \"Performing one time database migration to move logs from SQLite to flat file to improve performance, data remains local, may take a few minutes, please don't exit while this process is running...\"\n        );\n    }\n\n    let pb = if is_tty {\n        Some(new_spinner(\"Migrating\"))\n    } else {\n        None\n    };\n\n    let total_processes = Arc::new(std::sync::atomic::AtomicUsize::new(0));\n\n    let count_task = {\n        let pool = pool.clone();\n        let pb = pb.clone();\n        let total_processes = total_processes.clone();\n        tokio::spawn(async move {\n            if let Ok(count) = ExecutionProcessLogs::count_distinct_processes(&pool).await {\n                total_processes.store(count as usize, std::sync::atomic::Ordering::Relaxed);\n                if let Some(pb) = pb {\n                    pb.set_length(count as u64);\n                    pb.set_style(\n                        ProgressStyle::default_bar()\n                            .template(\"{bar:36.yellow} {percent:>3}% {msg:<12.dim}\")\n                            .unwrap_or_else(|_| ProgressStyle::default_bar())\n                            .progress_chars(\"■⬝\"),\n                    );\n                }\n            }\n        })\n    };\n\n    let completed = Arc::new(std::sync::atomic::AtomicUsize::new(0));\n\n    ExecutionProcessLogs::stream_distinct_processes(&pool)\n        .map_err(anyhow::Error::from)\n        .map(|res| {\n            let pool = pool.clone();\n            let pb = pb.clone();\n            let completed = completed.clone();\n            let total_processes = total_processes.clone();\n            async move {\n                let p = res?;\n\n                let path = process_log_file_path(p.session_id, p.execution_id);\n                if path.exists() {\n                    if let Some(pb) = &pb {\n                        pb.inc(1);\n                    }\n                    return Ok::<(), anyhow::Error>(());\n                }\n\n                if let Some(parent) = path.parent() {\n                    tokio::fs::create_dir_all(parent).await?;\n                }\n\n                let temp_path = path.with_extension(\"jsonl.tmp\");\n                let mut file = tokio::fs::OpenOptions::new()\n                    .create(true)\n                    .write(true)\n                    .truncate(true)\n                    .open(&temp_path)\n                    .await?;\n\n                let mut logs_stream =\n                    ExecutionProcessLogs::stream_log_lines_by_execution_id(&pool, &p.execution_id);\n                let mut has_logs = false;\n                while let Some(log_res) = logs_stream.next().await {\n                    let log = log_res?;\n                    has_logs = true;\n                    let mut line = log;\n                    if !line.ends_with('\\n') {\n                        line.push('\\n');\n                    }\n                    file.write_all(line.as_bytes()).await?;\n                }\n\n                if !has_logs {\n                    let _ = tokio::fs::remove_file(&temp_path).await;\n                    if let Some(pb) = &pb {\n                        pb.inc(1);\n                    }\n                    return Ok::<(), anyhow::Error>(());\n                }\n\n                file.sync_all().await?;\n                tokio::fs::rename(temp_path, path).await?;\n\n                let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;\n\n                if let Some(pb) = &pb {\n                    pb.inc(1);\n                } else if c.is_multiple_of(100) {\n                    let t = total_processes.load(std::sync::atomic::Ordering::Relaxed);\n                    let _ = writeln!(\n                        std::io::stderr(),\n                        \"sqlite-migration:{}\",\n                        if t > 0 {\n                            (c * 100 / t).to_string()\n                        } else {\n                            \"?\".to_string()\n                        }\n                    );\n                }\n\n                Ok::<(), anyhow::Error>(())\n            }\n        })\n        .buffer_unordered(64)\n        .try_collect::<Vec<_>>()\n        .await?;\n\n    let _ = count_task.await;\n\n    if let Some(pb) = pb {\n        pb.finish_and_clear();\n    } else {\n        let _ = writeln!(std::io::stderr(), \"sqlite-migration:done\");\n    }\n\n    let vacuum_pb = if is_tty {\n        Some(new_spinner(\"Compacting\"))\n    } else {\n        let _ = writeln!(std::io::stderr(), \"Compacting database...\");\n        None\n    };\n\n    ExecutionProcessLogs::delete_all(&pool).await?;\n    sqlx::query(\"VACUUM\").execute(&pool).await?;\n\n    if let Some(pb) = vacuum_pb {\n        pb.finish_and_clear();\n    }\n\n    let _ = writeln!(std::io::stderr(), \"Database migration complete.\");\n\n    pool.close().await;\n\n    Ok(())\n}\n\npub async fn remove_session_process_logs(session_id: Uuid) -> Result<()> {\n    let dir = utils::execution_logs::process_logs_session_dir(session_id);\n    match tokio::fs::remove_dir_all(&dir).await {\n        Ok(()) => Ok(()),\n        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),\n        Err(e) => {\n            Err(e).with_context(|| format!(\"remove session process logs at {}\", dir.display()))\n        }\n    }\n}\n\npub async fn load_raw_log_messages(pool: &SqlitePool, execution_id: Uuid) -> Option<Vec<LogMsg>> {\n    if let Some(jsonl) = read_execution_logs_for_execution(pool, execution_id)\n        .await\n        .inspect_err(|e| {\n            tracing::warn!(\n                \"Failed to read execution log file for execution {}: {:#}\",\n                execution_id,\n                e\n            );\n        })\n        .ok()\n        .flatten()\n    {\n        let messages = utils::execution_logs::parse_log_jsonl_lossy(execution_id, &jsonl);\n        if !messages.is_empty() {\n            return Some(messages);\n        }\n    }\n\n    let db_log_records = match ExecutionProcessLogs::find_by_execution_id(pool, execution_id).await\n    {\n        Ok(records) if !records.is_empty() => records,\n        Ok(_) => return None,\n        Err(e) => {\n            tracing::error!(\n                \"Failed to fetch DB logs for execution {}: {}\",\n                execution_id,\n                e\n            );\n            return None;\n        }\n    };\n\n    match ExecutionProcessLogs::parse_logs(&db_log_records) {\n        Ok(msgs) => Some(msgs),\n        Err(e) => {\n            tracing::error!(\n                \"Failed to parse DB logs for execution {}: {}\",\n                execution_id,\n                e\n            );\n            None\n        }\n    }\n}\n\npub async fn append_log_message(session_id: Uuid, execution_id: Uuid, msg: &LogMsg) -> Result<()> {\n    let mut log_writer = ExecutionLogWriter::new_for_execution(session_id, execution_id)\n        .await\n        .with_context(|| format!(\"create log writer for execution {}\", execution_id))?;\n    let json_line = serde_json::to_string(msg)\n        .with_context(|| format!(\"serialize log message for execution {}\", execution_id))?;\n    let mut json_line_with_newline = json_line;\n    json_line_with_newline.push('\\n');\n    log_writer\n        .append_jsonl_line(&json_line_with_newline)\n        .await\n        .with_context(|| format!(\"append log message for execution {}\", execution_id))?;\n    Ok(())\n}\n\npub fn spawn_stream_raw_logs_to_storage(\n    msg_stores: Arc<RwLock<HashMap<Uuid, Arc<MsgStore>>>>,\n    db: DBService,\n    execution_id: Uuid,\n    session_id: Uuid,\n) -> JoinHandle<()> {\n    tokio::spawn(async move {\n        let mut log_writer =\n            match ExecutionLogWriter::new_for_execution(session_id, execution_id).await {\n                Ok(w) => w,\n                Err(e) => {\n                    tracing::error!(\n                        \"Failed to create log file writer for execution {}: {}\",\n                        execution_id,\n                        e\n                    );\n                    return;\n                }\n            };\n\n        let store = {\n            let map = msg_stores.read().await;\n            map.get(&execution_id).cloned()\n        };\n\n        if let Some(store) = store {\n            let mut stream = store.history_plus_stream();\n\n            while let Some(Ok(msg)) = stream.next().await {\n                match &msg {\n                    LogMsg::Stdout(_) | LogMsg::Stderr(_) => match serde_json::to_string(&msg) {\n                        Ok(jsonl_line) => {\n                            let mut jsonl_line_with_newline = jsonl_line;\n                            jsonl_line_with_newline.push('\\n');\n\n                            if let Err(e) =\n                                log_writer.append_jsonl_line(&jsonl_line_with_newline).await\n                            {\n                                tracing::error!(\n                                    \"Failed to append log line for execution {}: {}\",\n                                    execution_id,\n                                    e\n                                );\n                            }\n                        }\n                        Err(e) => {\n                            tracing::error!(\n                                \"Failed to serialize log message for execution {}: {}\",\n                                execution_id,\n                                e\n                            );\n                        }\n                    },\n                    LogMsg::SessionId(agent_session_id) => {\n                        if let Err(e) = CodingAgentTurn::update_agent_session_id(\n                            &db.pool,\n                            execution_id,\n                            agent_session_id,\n                        )\n                        .await\n                        {\n                            tracing::error!(\n                                \"Failed to update agent_session_id {} for execution process {}: {}\",\n                                agent_session_id,\n                                execution_id,\n                                e\n                            );\n                        }\n                    }\n                    LogMsg::MessageId(agent_message_id) => {\n                        if let Err(e) = CodingAgentTurn::update_agent_message_id(\n                            &db.pool,\n                            execution_id,\n                            agent_message_id,\n                        )\n                        .await\n                        {\n                            tracing::error!(\n                                \"Failed to update agent_message_id {} for execution process {}: {}\",\n                                agent_message_id,\n                                execution_id,\n                                e\n                            );\n                        }\n                    }\n                    LogMsg::Finished => {\n                        break;\n                    }\n                    LogMsg::JsonPatch(_) | LogMsg::Ready => continue,\n                }\n            }\n        }\n    })\n}\n\nasync fn read_execution_logs_for_execution(\n    pool: &SqlitePool,\n    execution_id: Uuid,\n) -> Result<Option<String>> {\n    let session_id = if let Some(process) = ExecutionProcess::find_by_id(pool, execution_id).await?\n    {\n        process.session_id\n    } else {\n        return Ok(None);\n    };\n    let path = process_log_file_path(session_id, execution_id);\n\n    match tokio::fs::metadata(&path).await {\n        Ok(_) => Ok(Some(read_execution_log_file(&path).await.with_context(\n            || format!(\"read execution log file for execution {execution_id}\"),\n        )?)),\n        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {\n            if cfg!(debug_assertions) {\n                // Convenience for local development with a clone of a prod db. Read only access to prod logs.\n                let prod_path =\n                    process_log_file_path_in_root(&prod_asset_dir_path(), session_id, execution_id);\n                match read_execution_log_file(&prod_path).await {\n                    Ok(contents) => return Ok(Some(contents)),\n                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}\n                    Err(err) => {\n                        return Err(err).with_context(|| {\n                            format!(\n                                \"read execution log file for execution {execution_id} from {}\",\n                                prod_path.display()\n                            )\n                        });\n                    }\n                }\n            }\n            Ok(None)\n        }\n        Err(e) => Err(e).with_context(|| {\n            format!(\n                \"check execution log file exists for execution {execution_id} at {}\",\n                path.display()\n            )\n        }),\n    }\n}\n\nfn new_spinner(message: &'static str) -> ProgressBar {\n    let pb = ProgressBar::new_spinner();\n    pb.set_style(\n        ProgressStyle::default_spinner()\n            .template(\"{spinner:.yellow} {msg:<12.dim}\")\n            .unwrap_or_else(|_| ProgressStyle::default_spinner())\n            .tick_chars(\"⠁⠂⠄⡀⢀⠠⠐⠈ \"),\n    );\n    pb.set_message(message);\n    pb.enable_steady_tick(std::time::Duration::from_millis(100));\n    pb\n}\n"
  },
  {
    "path": "crates/services/src/services/file.rs",
    "content": "use std::{\n    fs,\n    path::{Path, PathBuf},\n};\n\nuse db::models::file::{CreateFile, File};\nuse mime_guess::MimeGuess;\nuse sha2::{Digest, Sha256};\nuse sqlx::SqlitePool;\nuse uuid::Uuid;\n\n#[derive(Debug, thiserror::Error)]\npub enum FileError {\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n\n    #[error(\"Database error: {0}\")]\n    Database(#[from] sqlx::Error),\n\n    #[error(\"File too large: {0} bytes (max: {1} bytes)\")]\n    TooLarge(u64, u64),\n\n    #[error(\"File not found\")]\n    NotFound,\n\n    #[error(\"Failed to build response: {0}\")]\n    ResponseBuildError(String),\n}\n\n/// Sanitize filename for filesystem safety:\n/// - Lowercase\n/// - Spaces → underscores\n/// - Remove special characters (keep alphanumeric and underscores)\n/// - Truncate if too long\nfn sanitize_filename(name: &str) -> String {\n    let stem = Path::new(name)\n        .file_stem()\n        .and_then(|s| s.to_str())\n        .unwrap_or(\"file\");\n\n    let clean: String = stem\n        .to_lowercase()\n        .chars()\n        .map(|c| if c.is_whitespace() { '_' } else { c })\n        .filter(|c| c.is_alphanumeric() || *c == '_')\n        .collect();\n\n    // Truncate to reasonable length to avoid filesystem limits\n    let max_len = 50;\n    if clean.len() > max_len {\n        clean[..max_len].to_string()\n    } else if clean.is_empty() {\n        \"file\".to_string()\n    } else {\n        clean\n    }\n}\n\n#[derive(Clone)]\npub struct FileService {\n    cache_dir: PathBuf,\n    legacy_cache_dir: PathBuf,\n    pool: SqlitePool,\n    max_size_bytes: u64,\n}\n\nimpl FileService {\n    pub fn new(pool: SqlitePool) -> Result<Self, FileError> {\n        let cache_dir = utils::cache_dir().join(\"attachments\");\n        let legacy_cache_dir = utils::cache_dir().join(\"images\");\n        fs::create_dir_all(&cache_dir)?;\n        Ok(Self {\n            cache_dir,\n            legacy_cache_dir,\n            pool,\n            max_size_bytes: 20 * 1024 * 1024, // 20MB default\n        })\n    }\n\n    pub async fn store_file(\n        &self,\n        data: &[u8],\n        original_filename: &str,\n    ) -> Result<File, FileError> {\n        let file_size = data.len() as u64;\n\n        if file_size > self.max_size_bytes {\n            return Err(FileError::TooLarge(file_size, self.max_size_bytes));\n        }\n\n        let hash = format!(\"{:x}\", Sha256::digest(data));\n\n        let extension = Path::new(original_filename)\n            .extension()\n            .and_then(|e| e.to_str())\n            .unwrap_or(\"bin\");\n\n        let mime_type = MimeGuess::from_path(original_filename)\n            .first_raw()\n            .map(str::to_string)\n            .or_else(|| {\n                MimeGuess::from_ext(extension)\n                    .first_raw()\n                    .map(str::to_string)\n            });\n\n        let existing_file = File::find_by_hash(&self.pool, &hash).await?;\n\n        if let Some(existing) = existing_file {\n            tracing::debug!(\"Reusing existing file record with hash {}\", hash);\n            return Ok(existing);\n        }\n\n        let clean_name = sanitize_filename(original_filename);\n        let new_filename = format!(\"{}_{}.{}\", Uuid::new_v4(), clean_name, extension);\n        let cached_path = self.cache_dir.join(&new_filename);\n        fs::write(&cached_path, data)?;\n\n        let file = File::create(\n            &self.pool,\n            &CreateFile {\n                file_path: new_filename,\n                original_name: original_filename.to_string(),\n                mime_type,\n                size_bytes: file_size as i64,\n                hash,\n            },\n        )\n        .await?;\n        Ok(file)\n    }\n\n    pub async fn delete_orphaned_files(&self) -> Result<(), FileError> {\n        let orphaned_files = File::find_orphaned_files(&self.pool).await?;\n        if orphaned_files.is_empty() {\n            tracing::debug!(\"No orphaned files found during cleanup\");\n            return Ok(());\n        }\n\n        tracing::debug!(\"Found {} orphaned files to clean up\", orphaned_files.len());\n        let mut deleted_count = 0;\n        let mut failed_count = 0;\n\n        for file in orphaned_files {\n            match self.delete_file(file.id).await {\n                Ok(_) => {\n                    deleted_count += 1;\n                    tracing::debug!(\"Deleted orphaned file: {}\", file.id);\n                }\n                Err(e) => {\n                    failed_count += 1;\n                    tracing::error!(\"Failed to delete orphaned file {}: {}\", file.id, e);\n                }\n            }\n        }\n\n        tracing::info!(\n            \"File cleanup completed: {} deleted, {} failed\",\n            deleted_count,\n            failed_count\n        );\n\n        Ok(())\n    }\n\n    pub fn get_absolute_path(&self, file: &File) -> PathBuf {\n        self.resolve_cached_path(&file.file_path)\n            .unwrap_or_else(|| self.cache_dir.join(&file.file_path))\n    }\n\n    pub async fn get_file(&self, id: Uuid) -> Result<Option<File>, FileError> {\n        Ok(File::find_by_id(&self.pool, id).await?)\n    }\n\n    pub async fn delete_file(&self, id: Uuid) -> Result<(), FileError> {\n        if let Some(file) = File::find_by_id(&self.pool, id).await? {\n            let file_path = self.cache_dir.join(&file.file_path);\n            if file_path.exists() {\n                fs::remove_file(file_path)?;\n            }\n\n            let legacy_file_path = self.legacy_cache_dir.join(&file.file_path);\n            if legacy_file_path.exists() {\n                fs::remove_file(legacy_file_path)?;\n            }\n\n            File::delete(&self.pool, id).await?;\n        }\n\n        Ok(())\n    }\n\n    pub async fn copy_files_by_workspace_to_worktree(\n        &self,\n        worktree_path: &Path,\n        workspace_id: Uuid,\n        agent_working_dir: Option<&str>,\n    ) -> Result<(), FileError> {\n        let files = File::find_by_workspace_id(&self.pool, workspace_id).await?;\n        let target_path = match agent_working_dir {\n            Some(dir) if !dir.is_empty() => worktree_path.join(dir),\n            _ => worktree_path.to_path_buf(),\n        };\n        self.copy_files(&target_path, files)\n    }\n\n    pub async fn copy_files_by_ids_to_worktree(\n        &self,\n        worktree_path: &Path,\n        file_ids: &[Uuid],\n    ) -> Result<(), FileError> {\n        let mut files = Vec::new();\n        for id in file_ids {\n            if let Some(file) = File::find_by_id(&self.pool, *id).await? {\n                files.push(file);\n            }\n        }\n        self.copy_files(worktree_path, files)\n    }\n\n    /// Copy files to the worktree. Skips files that already exist at target.\n    fn copy_files(&self, worktree_path: &Path, files: Vec<File>) -> Result<(), FileError> {\n        if files.is_empty() {\n            return Ok(());\n        }\n\n        let attachments_dir = worktree_path.join(utils::path::VIBE_ATTACHMENTS_DIR);\n\n        // Fast path: check if all files exist before doing anything\n        let all_exist = files\n            .iter()\n            .all(|file| attachments_dir.join(&file.file_path).exists());\n        if all_exist {\n            return Ok(());\n        }\n\n        std::fs::create_dir_all(&attachments_dir)?;\n\n        // Create .gitignore to ignore all files in this directory\n        let gitignore_path = attachments_dir.join(\".gitignore\");\n        if !gitignore_path.exists() {\n            std::fs::write(&gitignore_path, \"*\\n\")?;\n        }\n\n        for file in files {\n            let src = self\n                .resolve_cached_path(&file.file_path)\n                .unwrap_or_else(|| self.cache_dir.join(&file.file_path));\n            let dst = attachments_dir.join(&file.file_path);\n\n            if dst.exists() {\n                continue;\n            }\n\n            if src.exists() {\n                if let Err(e) = std::fs::copy(&src, &dst) {\n                    tracing::error!(\"Failed to copy {}: {}\", file.file_path, e);\n                } else {\n                    tracing::debug!(\"Copied {}\", file.file_path);\n                }\n            } else {\n                tracing::warn!(\"Missing cache file: {}\", src.display());\n            }\n        }\n\n        Ok(())\n    }\n\n    fn resolve_cached_path(&self, file_path: &str) -> Option<PathBuf> {\n        let primary = self.cache_dir.join(file_path);\n        if primary.exists() {\n            return Some(primary);\n        }\n\n        let legacy = self.legacy_cache_dir.join(file_path);\n        if legacy.exists() {\n            tracing::info!(\n                \"Using legacy attachment cache path for {}: {}\",\n                file_path,\n                legacy.display()\n            );\n            return Some(legacy);\n        }\n\n        None\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/file_ranker.rs",
    "content": "use std::{\n    collections::HashMap,\n    path::{Path, PathBuf},\n    sync::Arc,\n};\n\nuse dashmap::DashMap;\nuse db::models::repo::{SearchMatchType, SearchResult};\nuse git::{FileStat, GitService, GitServiceError};\nuse once_cell::sync::Lazy;\nuse tokio::task;\n\n/// File statistics for a repository\npub type FileStats = HashMap<String, FileStat>;\n\n/// Cache entry for repository history\n#[derive(Clone)]\nstruct RepoHistoryCache {\n    head_sha: String,\n    stats: Arc<FileStats>,\n}\n\n/// Global cache for file ranking statistics\nstatic FILE_STATS_CACHE: Lazy<DashMap<PathBuf, RepoHistoryCache>> = Lazy::new(DashMap::new);\n\n/// Configuration constants for ranking algorithm\nconst DEFAULT_COMMIT_LIMIT: usize = 100;\nconst BASE_MATCH_SCORE_FILENAME: i64 = 100;\nconst BASE_MATCH_SCORE_DIRNAME: i64 = 10;\nconst BASE_MATCH_SCORE_FULLPATH: i64 = 1;\nconst RECENCY_WEIGHT: i64 = 2;\nconst FREQUENCY_WEIGHT: i64 = 1;\n\n/// Service for ranking files based on git history\n#[derive(Clone)]\npub struct FileRanker {\n    git_service: GitService,\n}\n\nimpl Default for FileRanker {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl FileRanker {\n    pub fn new() -> Self {\n        Self {\n            git_service: GitService::new(),\n        }\n    }\n\n    /// Get file statistics for a repository, using cache when possible\n    pub async fn get_stats(&self, repo_path: &Path) -> Result<Arc<FileStats>, GitServiceError> {\n        let repo_path = repo_path.to_path_buf();\n\n        // Check if we have a valid cache entry\n        if let Some(cache_entry) = FILE_STATS_CACHE.get(&repo_path) {\n            // Verify cache is still valid by checking HEAD\n            if let Ok(head_info) = self.git_service.get_head_info(&repo_path)\n                && head_info.oid == cache_entry.head_sha\n            {\n                return Ok(Arc::clone(&cache_entry.stats));\n            }\n        }\n\n        // Cache miss or invalid - compute new stats\n        let stats = self.compute_stats(&repo_path).await?;\n        Ok(stats)\n    }\n\n    /// Re-rank search results based on git history statistics\n    pub fn rerank(&self, results: &mut [SearchResult], stats: &FileStats) {\n        results.sort_by(|a, b| {\n            let score_a = self.calculate_score(a, stats);\n            let score_b = self.calculate_score(b, stats);\n            score_b.cmp(&score_a) // Higher scores first\n        });\n    }\n\n    /// Calculate relevance score for a search result\n    pub fn calculate_score(&self, result: &SearchResult, stats: &FileStats) -> i64 {\n        let base_score = match result.match_type {\n            SearchMatchType::FileName => BASE_MATCH_SCORE_FILENAME,\n            SearchMatchType::DirectoryName => BASE_MATCH_SCORE_DIRNAME,\n            SearchMatchType::FullPath => BASE_MATCH_SCORE_FULLPATH,\n        };\n\n        if let Some(stat) = stats.get(&result.path) {\n            let recency_bonus = (100 - stat.last_index.min(99) as i64) * RECENCY_WEIGHT;\n            let frequency_bonus = stat.commit_count as i64 * FREQUENCY_WEIGHT;\n\n            // Multiply base score to maintain hierarchy, add git-based bonuses\n            base_score * 1000 + recency_bonus * 10 + frequency_bonus\n        } else {\n            // Files not in git history get base score only\n            base_score * 1000\n        }\n    }\n\n    /// Compute file statistics from git history\n    async fn compute_stats(&self, repo_path: &Path) -> Result<Arc<FileStats>, GitServiceError> {\n        let repo_path = repo_path.to_path_buf();\n        let repo_path_for_error = repo_path.clone();\n        let git_service = self.git_service.clone();\n\n        // Run git analysis in blocking task to avoid blocking async runtime\n        let stats = task::spawn_blocking(move || {\n            git_service.collect_recent_file_stats(&repo_path, DEFAULT_COMMIT_LIMIT)\n        })\n        .await\n        .map_err(|e| GitServiceError::InvalidRepository(format!(\"Task join error: {e}\")))?;\n\n        let stats = match stats {\n            Ok(s) => s,\n            Err(e) => {\n                tracing::warn!(\n                    \"Failed to collect file stats for {:?}: {}\",\n                    repo_path_for_error,\n                    e\n                );\n                // Return empty stats on error - search will still work without ranking\n                HashMap::new()\n            }\n        };\n\n        let stats_arc = Arc::new(stats);\n\n        // Update cache\n        if let Ok(head_info) = self.git_service.get_head_info(&repo_path_for_error) {\n            FILE_STATS_CACHE.insert(\n                repo_path_for_error,\n                RepoHistoryCache {\n                    head_sha: head_info.oid,\n                    stats: Arc::clone(&stats_arc),\n                },\n            );\n        }\n\n        Ok(stats_arc)\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/file_search.rs",
    "content": "use std::{\n    path::{Path, PathBuf},\n    sync::Arc,\n    time::{Duration, Instant},\n};\n\nuse dashmap::DashMap;\nuse db::models::repo::{SearchMatchType, SearchResult};\nuse fst::{Map, MapBuilder};\nuse git::GitService;\nuse ignore::WalkBuilder;\nuse moka::future::Cache;\nuse notify::{RecommendedWatcher, RecursiveMode};\nuse notify_debouncer_full::{DebounceEventResult, new_debouncer};\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse tokio::sync::mpsc;\nuse tracing::{error, info, warn};\nuse ts_rs::TS;\n\nuse super::file_ranker::{FileRanker, FileStats};\n\n/// Search mode for different use cases\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"lowercase\")]\n#[derive(Default)]\npub enum SearchMode {\n    #[default]\n    TaskForm, // Default: exclude ignored files (clean results)\n    Settings, // Include ignored files (for project config like .env)\n}\n\n/// Search query parameters for typed Axum extraction\n#[derive(Debug, Clone, Deserialize)]\npub struct SearchQuery {\n    pub q: String,\n    #[serde(default)]\n    pub mode: SearchMode,\n}\n\n/// FST-indexed file search result\n#[derive(Clone, Debug)]\npub struct IndexedFile {\n    pub path: String,\n    pub is_file: bool,\n    pub match_type: SearchMatchType,\n    pub path_lowercase: Arc<str>,\n    pub is_ignored: bool, // Track if file is gitignored\n}\n\n/// File index build result containing indexed files and FST map\n#[derive(Debug)]\npub struct FileIndex {\n    pub files: Vec<IndexedFile>,\n    pub map: Map<Vec<u8>>,\n}\n\n/// Errors that can occur during file index building\n#[derive(Error, Debug)]\npub enum FileIndexError {\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(transparent)]\n    Fst(#[from] fst::Error),\n    #[error(transparent)]\n    Walk(#[from] ignore::Error),\n    #[error(transparent)]\n    StripPrefix(#[from] std::path::StripPrefixError),\n}\n\n/// Cached repository data with FST index and git stats\n#[derive(Clone)]\npub struct CachedRepo {\n    pub head_sha: String,\n    pub fst_index: Map<Vec<u8>>,\n    pub indexed_files: Vec<IndexedFile>,\n    pub stats: Arc<FileStats>,\n    pub build_ts: Instant,\n}\n\n/// Cache miss error\n#[derive(Debug)]\npub enum CacheError {\n    Miss,\n    BuildError(String),\n}\n\n/// File search cache with FST indexing\npub struct FileSearchCache {\n    cache: Cache<PathBuf, CachedRepo>,\n    git_service: GitService,\n    file_ranker: FileRanker,\n    build_queue: mpsc::UnboundedSender<PathBuf>,\n    watchers: DashMap<PathBuf, RecommendedWatcher>,\n}\n\nimpl FileSearchCache {\n    pub fn new() -> Self {\n        let (build_sender, build_receiver) = mpsc::unbounded_channel();\n\n        // Create cache with 100MB limit and 1 hour TTL\n        let cache = Cache::builder()\n            .max_capacity(50) // Max 50 repos\n            .time_to_live(Duration::from_secs(3600)) // 1 hour TTL\n            .build();\n\n        let cache_for_worker = cache.clone();\n        let git_service = GitService::new();\n        let file_ranker = FileRanker::new();\n\n        // Spawn background worker\n        let worker_git_service = git_service.clone();\n        let worker_file_ranker = file_ranker.clone();\n        tokio::spawn(async move {\n            Self::background_worker(\n                build_receiver,\n                cache_for_worker,\n                worker_git_service,\n                worker_file_ranker,\n            )\n            .await;\n        });\n\n        Self {\n            cache,\n            git_service,\n            file_ranker,\n            build_queue: build_sender,\n            watchers: DashMap::new(),\n        }\n    }\n\n    /// Search files in repository using cache\n    pub async fn search(\n        &self,\n        repo_path: &Path,\n        query: &str,\n        mode: SearchMode,\n    ) -> Result<Vec<SearchResult>, CacheError> {\n        let repo_path_buf = repo_path.to_path_buf();\n\n        // Check if we have a valid cache entry\n        if let Some(cached) = self.cache.get(&repo_path_buf).await\n            && let Ok(head_info) = self.git_service.get_head_info(&repo_path_buf)\n            && head_info.oid == cached.head_sha\n        {\n            // Cache hit - perform fast search with mode-based filtering\n            return Ok(self.search_in_cache(&cached, query, mode).await);\n        }\n\n        // Cache miss - trigger background refresh and return error\n        if let Err(e) = self.build_queue.send(repo_path_buf) {\n            warn!(\"Failed to enqueue cache build: {}\", e);\n        }\n\n        Err(CacheError::Miss)\n    }\n\n    /// Pre-warm cache for given repositories\n    pub async fn warm_repos(&self, repo_paths: Vec<PathBuf>) -> Result<(), String> {\n        for repo_path in repo_paths {\n            if let Err(e) = self.build_queue.send(repo_path.clone()) {\n                error!(\n                    \"Failed to enqueue repo for warming: {:?} - {}\",\n                    repo_path, e\n                );\n            }\n        }\n        Ok(())\n    }\n\n    /// Search within cached index with mode-based filtering\n    async fn search_in_cache(\n        &self,\n        cached: &CachedRepo,\n        query: &str,\n        mode: SearchMode,\n    ) -> Vec<SearchResult> {\n        let query_lower = query.to_lowercase();\n        let mut results = Vec::new();\n\n        // Search through indexed files with mode-based filtering\n        for indexed_file in &cached.indexed_files {\n            if indexed_file.path_lowercase.contains(&query_lower) {\n                // Apply mode-based filtering\n                match mode {\n                    SearchMode::TaskForm => {\n                        // Exclude ignored files for task forms\n                        if indexed_file.is_ignored {\n                            continue;\n                        }\n                    }\n                    SearchMode::Settings => {\n                        // Include all files (including ignored) for project settings\n                        // No filtering needed\n                    }\n                }\n\n                results.push(SearchResult {\n                    path: indexed_file.path.clone(),\n                    is_file: indexed_file.is_file,\n                    match_type: indexed_file.match_type.clone(),\n                    score: 0,\n                });\n            }\n        }\n\n        // Apply git history-based ranking\n        self.file_ranker.rerank(&mut results, &cached.stats);\n\n        // Populate scores for sorted results\n        for result in &mut results {\n            result.score = self.file_ranker.calculate_score(result, &cached.stats);\n        }\n\n        // Limit to top 10 results\n        results.truncate(10);\n        results\n    }\n\n    /// Search files in a single repository with cache + fallback\n    pub async fn search_repo(\n        &self,\n        repo_path: &Path,\n        query: &str,\n        mode: SearchMode,\n    ) -> Result<Vec<SearchResult>, String> {\n        let query = query.trim();\n        if query.is_empty() {\n            return Ok(vec![]);\n        }\n\n        // Try cache first\n        match self.search(repo_path, query, mode.clone()).await {\n            Ok(results) => Ok(results),\n            Err(CacheError::Miss) | Err(CacheError::BuildError(_)) => {\n                // Fall back to filesystem search\n                self.search_files_no_cache(repo_path, query, mode).await\n            }\n        }\n    }\n\n    /// Fallback filesystem search when cache is not available\n    async fn search_files_no_cache(\n        &self,\n        repo_path: &Path,\n        query: &str,\n        mode: SearchMode,\n    ) -> Result<Vec<SearchResult>, String> {\n        if !repo_path.exists() {\n            return Err(format!(\"Path not found: {:?}\", repo_path));\n        }\n\n        let mut results = Vec::new();\n        let query_lower = query.to_lowercase();\n\n        let walker = match mode {\n            SearchMode::Settings => {\n                // Settings mode: Include ignored files but exclude performance killers\n                WalkBuilder::new(repo_path)\n                    .git_ignore(false)\n                    .git_global(false)\n                    .git_exclude(false)\n                    .hidden(false)\n                    .filter_entry(|entry| {\n                        let name = entry.file_name().to_string_lossy();\n                        name != \".git\"\n                            && name != \"node_modules\"\n                            && name != \"target\"\n                            && name != \"dist\"\n                            && name != \"build\"\n                    })\n                    .build()\n            }\n            SearchMode::TaskForm => WalkBuilder::new(repo_path)\n                .git_ignore(true)\n                .git_global(true)\n                .git_exclude(true)\n                .hidden(false)\n                .filter_entry(|entry| {\n                    let name = entry.file_name().to_string_lossy();\n                    name != \".git\"\n                })\n                .build(),\n        };\n\n        for result in walker {\n            let entry = match result {\n                Ok(e) => e,\n                Err(_) => continue,\n            };\n            let path = entry.path();\n\n            // Skip the root directory itself\n            if path == repo_path {\n                continue;\n            }\n\n            let relative_path = match path.strip_prefix(repo_path) {\n                Ok(p) => p,\n                Err(_) => continue,\n            };\n            let relative_path_str = relative_path.to_string_lossy().to_lowercase();\n\n            let file_name = path\n                .file_name()\n                .map(|name| name.to_string_lossy().to_lowercase())\n                .unwrap_or_default();\n\n            if file_name.contains(&query_lower) {\n                results.push(SearchResult {\n                    path: relative_path.to_string_lossy().to_string(),\n                    is_file: path.is_file(),\n                    match_type: SearchMatchType::FileName,\n                    score: 0,\n                });\n            } else if relative_path_str.contains(&query_lower) {\n                let match_type = if path\n                    .parent()\n                    .and_then(|p| p.file_name())\n                    .map(|name| name.to_string_lossy().to_lowercase())\n                    .unwrap_or_default()\n                    .contains(&query_lower)\n                {\n                    SearchMatchType::DirectoryName\n                } else {\n                    SearchMatchType::FullPath\n                };\n\n                results.push(SearchResult {\n                    path: relative_path.to_string_lossy().to_string(),\n                    is_file: path.is_file(),\n                    match_type,\n                    score: 0,\n                });\n            }\n        }\n\n        // Apply git history-based ranking\n        match self.file_ranker.get_stats(repo_path).await {\n            Ok(stats) => {\n                self.file_ranker.rerank(&mut results, &stats);\n                // Populate scores for sorted results\n                for result in &mut results {\n                    result.score = self.file_ranker.calculate_score(result, &stats);\n                }\n            }\n            Err(_) => {\n                // Fallback to basic priority sorting\n                results.sort_by(|a, b| {\n                    let priority = |match_type: &SearchMatchType| match match_type {\n                        SearchMatchType::FileName => 0,\n                        SearchMatchType::DirectoryName => 1,\n                        SearchMatchType::FullPath => 2,\n                    };\n\n                    priority(&a.match_type)\n                        .cmp(&priority(&b.match_type))\n                        .then_with(|| a.path.cmp(&b.path))\n                });\n            }\n        }\n\n        results.truncate(10);\n        Ok(results)\n    }\n\n    /// Build cache entry for a repository\n    async fn build_repo_cache(&self, repo_path: &Path) -> Result<CachedRepo, String> {\n        let repo_path_buf = repo_path.to_path_buf();\n\n        info!(\"Building cache for repo: {:?}\", repo_path);\n\n        // Get current HEAD\n        let head_info = self\n            .git_service\n            .get_head_info(&repo_path_buf)\n            .map_err(|e| format!(\"Failed to get HEAD info: {e}\"))?;\n\n        // Get git stats\n        let stats = self\n            .file_ranker\n            .get_stats(repo_path)\n            .await\n            .map_err(|e| format!(\"Failed to get git stats: {e}\"))?;\n\n        // Build file index\n        let file_index = Self::build_file_index(repo_path)\n            .map_err(|e| format!(\"Failed to build file index: {e}\"))?;\n\n        Ok(CachedRepo {\n            head_sha: head_info.oid,\n            fst_index: file_index.map,\n            indexed_files: file_index.files,\n            stats,\n            build_ts: Instant::now(),\n        })\n    }\n\n    /// Build FST index from filesystem traversal using superset approach\n    fn build_file_index(repo_path: &Path) -> Result<FileIndex, FileIndexError> {\n        let mut indexed_files = Vec::new();\n        let mut fst_keys = Vec::new();\n\n        // Build superset walker - include ignored files but exclude .git and performance killers\n        let mut builder = WalkBuilder::new(repo_path);\n        builder\n            .git_ignore(false) // Include all files initially\n            .git_global(false)\n            .git_exclude(false)\n            .hidden(false) // Show hidden files like .env\n            .filter_entry(|entry| {\n                let name = entry.file_name().to_string_lossy();\n                // Always exclude .git directories\n                if name == \".git\" {\n                    return false;\n                }\n                // Exclude performance killers even when including ignored files\n                if name == \"node_modules\" || name == \"target\" || name == \"dist\" || name == \"build\" {\n                    return false;\n                }\n                true\n            });\n\n        let walker = builder.build();\n\n        // Create a second walker for checking ignore status\n        let ignore_walker = WalkBuilder::new(repo_path)\n            .git_ignore(true) // This will tell us what's ignored\n            .git_global(true)\n            .git_exclude(true)\n            .hidden(false)\n            .filter_entry(|entry| {\n                let name = entry.file_name().to_string_lossy();\n                name != \".git\"\n            })\n            .build();\n\n        // Collect paths from ignore-aware walker to know what's NOT ignored\n        let mut non_ignored_paths = std::collections::HashSet::new();\n        for result in ignore_walker {\n            if let Ok(entry) = result\n                && let Ok(relative_path) = entry.path().strip_prefix(repo_path)\n            {\n                non_ignored_paths.insert(relative_path.to_path_buf());\n            }\n        }\n\n        // Now walk all files and determine their ignore status\n        for result in walker {\n            let entry = result?;\n            let path = entry.path();\n\n            if path == repo_path {\n                continue;\n            }\n\n            let relative_path = path.strip_prefix(repo_path)?;\n            let relative_path_str = relative_path.to_string_lossy().to_string();\n            let relative_path_lower = relative_path_str.to_lowercase();\n\n            // Skip empty paths\n            if relative_path_lower.is_empty() {\n                continue;\n            }\n\n            // Determine if this file is ignored\n            let is_ignored = !non_ignored_paths.contains(relative_path);\n\n            let file_name = path\n                .file_name()\n                .map(|name| name.to_string_lossy().to_lowercase())\n                .unwrap_or_default();\n\n            // Determine match type\n            let match_type = if !file_name.is_empty() {\n                SearchMatchType::FileName\n            } else if path\n                .parent()\n                .and_then(|p| p.file_name())\n                .map(|name| name.to_string_lossy().to_lowercase())\n                .unwrap_or_default()\n                != relative_path_lower\n            {\n                SearchMatchType::DirectoryName\n            } else {\n                SearchMatchType::FullPath\n            };\n\n            let indexed_file = IndexedFile {\n                path: relative_path_str,\n                is_file: path.is_file(),\n                match_type,\n                path_lowercase: Arc::from(relative_path_lower.as_str()),\n                is_ignored,\n            };\n\n            // Store the key for FST along with file index\n            let file_index = indexed_files.len() as u64;\n            fst_keys.push((relative_path_lower, file_index));\n            indexed_files.push(indexed_file);\n        }\n\n        // Sort keys for FST (required for building)\n        fst_keys.sort_by(|a, b| a.0.cmp(&b.0));\n\n        // Remove duplicates (keep first occurrence)\n        fst_keys.dedup_by(|a, b| a.0 == b.0);\n\n        // Build FST\n        let mut fst_builder = MapBuilder::memory();\n        for (key, value) in fst_keys {\n            fst_builder.insert(&key, value)?;\n        }\n\n        let fst_map = fst_builder.into_map();\n        Ok(FileIndex {\n            files: indexed_files,\n            map: fst_map,\n        })\n    }\n\n    /// Background worker for cache building\n    async fn background_worker(\n        mut build_receiver: mpsc::UnboundedReceiver<PathBuf>,\n        cache: Cache<PathBuf, CachedRepo>,\n        git_service: GitService,\n        file_ranker: FileRanker,\n    ) {\n        while let Some(repo_path) = build_receiver.recv().await {\n            if !repo_path.exists() {\n                warn!(\n                    \"Skipping cache build for non-existent repo path: {:?}\",\n                    repo_path\n                );\n                continue;\n            }\n\n            let cache_builder = FileSearchCache {\n                cache: cache.clone(),\n                git_service: git_service.clone(),\n                file_ranker: file_ranker.clone(),\n                build_queue: mpsc::unbounded_channel().0, // Dummy sender\n                watchers: DashMap::new(),\n            };\n\n            match cache_builder.build_repo_cache(&repo_path).await {\n                Ok(cached_repo) => {\n                    cache.insert(repo_path.clone(), cached_repo).await;\n                    info!(\"Successfully cached repo: {:?}\", repo_path);\n                }\n                Err(e) => {\n                    error!(\"Failed to cache repo {:?}: {}\", repo_path, e);\n                }\n            }\n        }\n    }\n\n    /// Setup file watcher for repository\n    pub async fn setup_watcher(&self, repo_path: &Path) -> Result<(), String> {\n        let repo_path_buf = repo_path.to_path_buf();\n\n        if self.watchers.contains_key(&repo_path_buf) {\n            return Ok(()); // Already watching\n        }\n\n        let git_dir = repo_path.join(\".git\");\n        if !git_dir.exists() {\n            return Err(\"Not a git repository\".to_string());\n        }\n\n        let build_queue = self.build_queue.clone();\n        let watched_path = repo_path_buf.clone();\n\n        let (tx, mut rx) = mpsc::unbounded_channel();\n\n        let mut debouncer = new_debouncer(\n            Duration::from_millis(500),\n            None,\n            move |res: DebounceEventResult| {\n                if let Ok(events) = res {\n                    for event in events {\n                        // Check if any path contains HEAD file\n                        for path in &event.event.paths {\n                            if path.file_name().is_some_and(|name| name == \"HEAD\") {\n                                if let Err(e) = tx.send(()) {\n                                    error!(\"Failed to send HEAD change event: {}\", e);\n                                }\n                                break;\n                            }\n                        }\n                    }\n                }\n            },\n        )\n        .map_err(|e| format!(\"Failed to create file watcher: {e}\"))?;\n\n        debouncer\n            .watch(git_dir.join(\"HEAD\"), RecursiveMode::NonRecursive)\n            .map_err(|e| format!(\"Failed to watch HEAD file: {e}\"))?;\n\n        // Spawn task to handle HEAD changes\n        tokio::spawn(async move {\n            while rx.recv().await.is_some() {\n                info!(\"HEAD changed for repo: {:?}\", watched_path);\n                if let Err(e) = build_queue.send(watched_path.clone()) {\n                    error!(\"Failed to enqueue cache refresh: {}\", e);\n                }\n            }\n        });\n\n        info!(\"Setup file watcher for repo: {:?}\", repo_path);\n        Ok(())\n    }\n}\n\nimpl Default for FileSearchCache {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/filesystem.rs",
    "content": "#[cfg(not(feature = \"qa-mode\"))]\nuse std::collections::HashSet;\nuse std::{\n    fs,\n    path::{Path, PathBuf},\n};\n\n#[cfg(not(feature = \"qa-mode\"))]\nuse ignore::WalkBuilder;\nuse serde::Serialize;\nuse thiserror::Error;\n#[cfg(not(feature = \"qa-mode\"))]\nuse tokio_util::sync::CancellationToken;\nuse ts_rs::TS;\n\n#[derive(Clone)]\npub struct FilesystemService {}\n\n#[derive(Debug, Error)]\npub enum FilesystemError {\n    #[error(\"Directory does not exist\")]\n    DirectoryDoesNotExist,\n    #[error(\"Path is not a directory\")]\n    PathIsNotDirectory,\n    #[error(\"Failed to read directory: {0}\")]\n    Io(#[from] std::io::Error),\n}\n#[derive(Debug, Serialize, TS)]\npub struct DirectoryListResponse {\n    pub entries: Vec<DirectoryEntry>,\n    pub current_path: String,\n}\n\n#[derive(Debug, Serialize, TS)]\npub struct DirectoryEntry {\n    pub name: String,\n    pub path: PathBuf,\n    pub is_directory: bool,\n    pub is_git_repo: bool,\n    pub last_modified: Option<u64>,\n}\n\nimpl Default for FilesystemService {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl FilesystemService {\n    pub fn new() -> Self {\n        FilesystemService {}\n    }\n\n    #[cfg(not(feature = \"qa-mode\"))]\n    fn get_directories_to_skip() -> HashSet<String> {\n        let mut skip_dirs = HashSet::from(\n            [\n                \"node_modules\",\n                \"target\",\n                \"build\",\n                \"dist\",\n                \".next\",\n                \".nuxt\",\n                \".cache\",\n                \".npm\",\n                \".yarn\",\n                \".pnpm-store\",\n                \"Library\",\n                \"AppData\",\n                \"Applications\",\n            ]\n            .map(String::from),\n        );\n\n        [\n            dirs::executable_dir(),\n            dirs::data_dir(),\n            dirs::download_dir(),\n            dirs::picture_dir(),\n            dirs::video_dir(),\n            dirs::audio_dir(),\n        ]\n        .into_iter()\n        .flatten()\n        .filter_map(|path| path.file_name()?.to_str().map(String::from))\n        .for_each(|name| {\n            skip_dirs.insert(name);\n        });\n\n        skip_dirs\n    }\n\n    #[cfg_attr(feature = \"qa-mode\", allow(unused_variables))]\n    pub async fn list_git_repos(\n        &self,\n        path: Option<String>,\n        timeout_ms: u64,\n        hard_timeout_ms: u64,\n        max_depth: Option<usize>,\n    ) -> Result<Vec<DirectoryEntry>, FilesystemError> {\n        #[cfg(feature = \"qa-mode\")]\n        {\n            tracing::info!(\"QA mode: returning hardcoded QA repos instead of scanning filesystem\");\n            super::qa_repos::get_qa_repos()\n        }\n\n        #[cfg(not(feature = \"qa-mode\"))]\n        {\n            let base_path = path\n                .map(PathBuf::from)\n                .unwrap_or_else(Self::get_home_directory);\n            Self::verify_directory(&base_path)?;\n            self.list_git_repos_with_timeout(\n                vec![base_path],\n                timeout_ms,\n                hard_timeout_ms,\n                max_depth,\n            )\n            .await\n        }\n    }\n\n    #[cfg(not(feature = \"qa-mode\"))]\n    async fn list_git_repos_with_timeout(\n        &self,\n        paths: Vec<PathBuf>,\n        timeout_ms: u64,\n        hard_timeout_ms: u64,\n        max_depth: Option<usize>,\n    ) -> Result<Vec<DirectoryEntry>, FilesystemError> {\n        let cancel_token = CancellationToken::new();\n        let cancel_after_delay = cancel_token.clone();\n        tokio::spawn(async move {\n            tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)).await;\n            cancel_after_delay.cancel();\n        });\n        let service = self.clone();\n        let cancel_for_scan = cancel_token.clone();\n        let mut scan_handle = tokio::spawn(async move {\n            service\n                .list_git_repos_inner(paths, max_depth, Some(&cancel_for_scan))\n                .await\n        });\n\n        let hard_timeout = tokio::time::sleep(std::time::Duration::from_millis(hard_timeout_ms));\n        tokio::pin!(hard_timeout);\n\n        tokio::select! {\n            res = &mut scan_handle => {\n                match res {\n                    Ok(Ok(repos)) => Ok(repos),\n                    Ok(Err(err)) => Err(err),\n                    Err(join_err) => Err(FilesystemError::Io(\n                        std::io::Error::other(join_err.to_string())))\n                }\n                }\n            _ = &mut hard_timeout => {\n                scan_handle.abort();\n                tracing::warn!(\"list_git_repos_with_timeout: hard timeout reached after {}ms\", hard_timeout_ms);\n                Err(FilesystemError::Io(std::io::Error::new(\n                    std::io::ErrorKind::TimedOut,\n                    \"Operation forcibly terminated due to hard timeout\",\n                )))\n            }\n        }\n    }\n\n    #[cfg_attr(feature = \"qa-mode\", allow(unused_variables))]\n    pub async fn list_common_git_repos(\n        &self,\n        timeout_ms: u64,\n        hard_timeout_ms: u64,\n        max_depth: Option<usize>,\n    ) -> Result<Vec<DirectoryEntry>, FilesystemError> {\n        #[cfg(feature = \"qa-mode\")]\n        {\n            tracing::info!(\n                \"QA mode: returning hardcoded QA repos instead of scanning common directories\"\n            );\n            super::qa_repos::get_qa_repos()\n        }\n\n        #[cfg(not(feature = \"qa-mode\"))]\n        {\n            let search_strings = [\"repos\", \"dev\", \"work\", \"code\", \"projects\"];\n            let home_dir = Self::get_home_directory();\n            let mut paths: Vec<PathBuf> = search_strings\n                .iter()\n                .map(|s| home_dir.join(s))\n                .filter(|p| p.exists() && p.is_dir())\n                .collect();\n            paths.insert(0, home_dir);\n            if let Some(cwd) = std::env::current_dir().ok()\n                && cwd.exists()\n                && cwd.is_dir()\n            {\n                paths.insert(0, cwd);\n            }\n            self.list_git_repos_with_timeout(paths, timeout_ms, hard_timeout_ms, max_depth)\n                .await\n        }\n    }\n\n    #[cfg(not(feature = \"qa-mode\"))]\n    async fn list_git_repos_inner(\n        &self,\n        path: Vec<PathBuf>,\n        max_depth: Option<usize>,\n        cancel: Option<&CancellationToken>,\n    ) -> Result<Vec<DirectoryEntry>, FilesystemError> {\n        let base_dir = match path.first() {\n            Some(dir) => dir,\n            None => return Ok(vec![]),\n        };\n        let skip_dirs = Self::get_directories_to_skip();\n        let vibe_kanban_temp_dir = utils::path::get_vibe_kanban_temp_dir();\n        let mut walker_builder = WalkBuilder::new(base_dir);\n        walker_builder\n            .follow_links(false)\n            .hidden(true) // true to skip hidden files\n            .git_ignore(true)\n            .filter_entry({\n                let cancel = cancel.cloned();\n                move |entry| {\n                    if let Some(token) = cancel.as_ref()\n                        && token.is_cancelled()\n                    {\n                        tracing::debug!(\"Cancellation token triggered\");\n                        return false;\n                    }\n\n                    let path = entry.path();\n                    if !path.is_dir() {\n                        return false;\n                    }\n\n                    // Skip vibe-kanban temp directory and all subdirectories\n                    // Normalize to handle macOS /private/var vs /var aliasing\n                    if utils::path::normalize_macos_private_alias(path)\n                        .starts_with(&vibe_kanban_temp_dir)\n                    {\n                        return false;\n                    }\n\n                    // Skip common non-git folders\n                    if let Some(name) = path.file_name().and_then(|n| n.to_str())\n                        && skip_dirs.contains(name)\n                    {\n                        return false;\n                    }\n\n                    true\n                }\n            })\n            .max_depth(max_depth)\n            .git_exclude(true);\n        for p in path.iter().skip(1) {\n            walker_builder.add(p);\n        }\n        let mut seen_dirs = HashSet::new();\n        let mut git_repos: Vec<DirectoryEntry> = walker_builder\n            .build()\n            .filter_map(|entry| {\n                let entry = entry.ok()?;\n                if seen_dirs.contains(entry.path()) {\n                    return None;\n                }\n                seen_dirs.insert(entry.path().to_owned());\n                let name = entry.file_name().to_str()?;\n                if !entry.path().join(\".git\").exists() {\n                    return None;\n                }\n                let last_modified = entry\n                    .metadata()\n                    .ok()\n                    .and_then(|m| m.modified().ok())\n                    .map(|t| t.elapsed().unwrap_or_default().as_secs());\n                Some(DirectoryEntry {\n                    name: name.to_string(),\n                    path: entry.into_path(),\n                    is_directory: true,\n                    is_git_repo: true,\n                    last_modified,\n                })\n            })\n            .collect();\n        git_repos.sort_by_key(|entry| entry.last_modified.unwrap_or(0));\n        Ok(git_repos)\n    }\n\n    fn get_home_directory() -> PathBuf {\n        dirs::home_dir()\n            .or_else(dirs::desktop_dir)\n            .or_else(dirs::document_dir)\n            .unwrap_or_else(|| {\n                if cfg!(windows) {\n                    std::env::var(\"USERPROFILE\")\n                        .map(PathBuf::from)\n                        .unwrap_or_else(|_| PathBuf::from(\"C:\\\\\"))\n                } else {\n                    PathBuf::from(\"/\")\n                }\n            })\n    }\n\n    fn verify_directory(path: &Path) -> Result<(), FilesystemError> {\n        if !path.exists() {\n            return Err(FilesystemError::DirectoryDoesNotExist);\n        }\n        if !path.is_dir() {\n            return Err(FilesystemError::PathIsNotDirectory);\n        }\n        Ok(())\n    }\n\n    pub async fn list_directory(\n        &self,\n        path: Option<String>,\n    ) -> Result<DirectoryListResponse, FilesystemError> {\n        let path = path\n            .map(PathBuf::from)\n            .unwrap_or_else(Self::get_home_directory);\n        Self::verify_directory(&path)?;\n\n        let entries = fs::read_dir(&path)?;\n        let mut directory_entries = Vec::new();\n\n        for entry in entries.flatten() {\n            let path = entry.path();\n            let metadata = entry.metadata().ok();\n            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {\n                // Skip hidden files/directories\n                if name.starts_with('.') && name != \"..\" {\n                    continue;\n                }\n\n                let is_directory = metadata.is_some_and(|m| m.is_dir());\n                let is_git_repo = if is_directory {\n                    path.join(\".git\").exists()\n                } else {\n                    false\n                };\n\n                directory_entries.push(DirectoryEntry {\n                    name: name.to_string(),\n                    path,\n                    is_directory,\n                    is_git_repo,\n                    last_modified: None,\n                });\n            }\n        }\n        // Sort: directories first, then files, both alphabetically\n        directory_entries.sort_by(|a, b| match (a.is_directory, b.is_directory) {\n            (true, false) => std::cmp::Ordering::Less,\n            (false, true) => std::cmp::Ordering::Greater,\n            _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),\n        });\n\n        Ok(DirectoryListResponse {\n            entries: directory_entries,\n            current_path: path.to_string_lossy().to_string(),\n        })\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/filesystem_watcher.rs",
    "content": "use std::{\n    collections::{HashMap, HashSet},\n    path::{Path, PathBuf},\n    sync::{Arc, Mutex},\n    time::Duration,\n};\n\nuse futures::{\n    SinkExt, StreamExt,\n    channel::mpsc::{Receiver, channel},\n};\nuse ignore::{\n    WalkBuilder,\n    gitignore::{Gitignore, GitignoreBuilder},\n};\nuse notify::{\n    RecommendedWatcher, RecursiveMode,\n    event::{EventKind, ModifyKind, RenameMode},\n};\nuse notify_debouncer_full::{\n    DebounceEventResult, DebouncedEvent, Debouncer, RecommendedCache, new_debouncer,\n};\nuse thiserror::Error;\nuse utils::path::ALWAYS_SKIP_DIRS;\n\npub type WatcherComponents = (\n    Arc<Mutex<Debouncer<RecommendedWatcher, RecommendedCache>>>,\n    Receiver<DebounceEventResult>,\n    PathBuf,\n);\n\n#[derive(Debug, Error)]\npub enum FilesystemWatcherError {\n    #[error(transparent)]\n    Notify(#[from] notify::Error),\n    #[error(transparent)]\n    Ignore(#[from] ignore::Error),\n    #[error(transparent)]\n    IoError(#[from] std::io::Error),\n    #[error(\"Failed to build gitignore: {0}\")]\n    GitignoreBuilder(String),\n    #[error(\"Invalid path: {0}\")]\n    InvalidPath(String),\n}\n\nfn canonicalize_lossy(path: &Path) -> PathBuf {\n    dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn should_skip_dir(name: &str) -> bool {\n    ALWAYS_SKIP_DIRS.contains(&name)\n}\n\n/// Check if the platform supports efficient native recursive watching.\n/// macOS (FSEvents) and Windows (ReadDirectoryChangesW) support recursive watching natively.\n/// Linux (inotify) does not - it requires a watch descriptor per directory.\nfn platform_supports_native_recursive() -> bool {\n    cfg!(target_os = \"macos\") || cfg!(target_os = \"windows\")\n}\n\nfn build_gitignore_set(root: &Path) -> Result<Gitignore, FilesystemWatcherError> {\n    let mut builder = GitignoreBuilder::new(root);\n\n    // Walk once to collect all .gitignore files under root\n    // Use git_ignore(true) to avoid walking into gitignored directories\n    WalkBuilder::new(root)\n        .follow_links(false)\n        .hidden(false) // we *want* to see .gitignore\n        .git_ignore(true) // Respect gitignore to skip heavy directories\n        .filter_entry(|entry| {\n            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);\n\n            // Skip .git directory\n            if is_dir\n                && let Some(name) = entry.file_name().to_str()\n                && should_skip_dir(name)\n            {\n                return false;\n            }\n\n            // only recurse into directories and .gitignore files\n            is_dir\n                || entry\n                    .file_name()\n                    .to_str()\n                    .is_some_and(|name| name == \".gitignore\")\n        })\n        .build()\n        .try_for_each(|result| {\n            // everything that is not a directory and is named .gitignore\n            match result {\n                Ok(dir_entry) => {\n                    if !dir_entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {\n                        builder.add(dir_entry.path());\n                    }\n                    Ok(())\n                }\n                Err(err)\n                    if err.io_error().is_some_and(|io_err| {\n                        io_err.kind() == std::io::ErrorKind::PermissionDenied\n                    }) =>\n                {\n                    // Skip entries we don't have permission to read\n                    tracing::warn!(\"Permission denied reading path: {}\", err);\n                    Ok(())\n                }\n                Err(e) => Err(FilesystemWatcherError::Ignore(e)),\n            }\n        })?;\n\n    // Optionally include repo-local excludes\n    let info_exclude = root.join(\".git/info/exclude\");\n    if info_exclude.exists() {\n        builder.add(info_exclude);\n    }\n\n    Ok(builder.build()?)\n}\n\nfn path_allowed(path: &Path, gi: &Gitignore, canonical_root: &Path) -> bool {\n    let canonical_path = canonicalize_lossy(path);\n\n    // Convert absolute path to relative path from the gitignore root\n    let relative_path = match canonical_path.strip_prefix(canonical_root) {\n        Ok(rel_path) => rel_path,\n        Err(_) => {\n            // Path is outside the watched root, don't ignore it\n            return true;\n        }\n    };\n\n    // Check if path is inside any of the always-skip directories\n    if let Some(parent) = relative_path.parent() {\n        for component in parent.components() {\n            if let std::path::Component::Normal(name) = component\n                && let Some(name_str) = name.to_str()\n                && should_skip_dir(name_str)\n            {\n                return false;\n            }\n        }\n    }\n\n    let is_dir = if let Ok(metadata) = std::fs::metadata(&canonical_path) {\n        metadata.is_dir()\n    } else {\n        // File may already be gone (e.g., remove event). Fall back to the\n        // old extension heuristic so directory-only rules still match.\n        // FIXME: capture file-type information earlier (e.g., when we add\n        // watches) so we don't have to guess after the fact.\n        relative_path.extension().is_none()\n    };\n    let matched = gi.matched_path_or_any_parents(relative_path, is_dir);\n\n    !matched.is_ignore()\n}\n\nfn debounced_should_forward(event: &DebouncedEvent, gi: &Gitignore, canonical_root: &Path) -> bool {\n    // DebouncedEvent is a struct that wraps the underlying notify::Event\n    if event.kind.is_access() {\n        // Ignore access events\n        return false;\n    }\n    // We can check its paths field to determine if the event should be forwarded\n    event\n        .paths\n        .iter()\n        .all(|path| path_allowed(path, gi, canonical_root))\n}\n\n/// Represents a directory to watch with its recursive mode.\n#[derive(Debug, Clone)]\nstruct WatchTarget {\n    path: PathBuf,\n    recursive: RecursiveMode,\n}\n\n#[derive(Default)]\nstruct WatchedDirs {\n    all: HashSet<PathBuf>,\n    recursive: HashSet<PathBuf>,\n}\n\nimpl WatchedDirs {\n    fn contains(&self, path: &Path) -> bool {\n        self.all.contains(path)\n    }\n\n    fn has_recursive_cover(&self, path: &Path) -> bool {\n        self.recursive\n            .iter()\n            .any(|ancestor| ancestor != path && path.starts_with(ancestor))\n    }\n\n    fn insert(&mut self, path: PathBuf, mode: RecursiveMode) {\n        if matches!(mode, RecursiveMode::Recursive) {\n            self.recursive.insert(path.clone());\n        } else {\n            self.recursive.remove(&path);\n        }\n        self.all.insert(path);\n    }\n\n    fn remove_dir_and_children<F>(&mut self, prefix: &Path, f: F)\n    where\n        F: FnMut(&Path),\n    {\n        self.remove_with_prefix(prefix, true, f);\n    }\n\n    fn remove_children_only<F>(&mut self, prefix: &Path, f: F)\n    where\n        F: FnMut(&Path),\n    {\n        self.remove_with_prefix(prefix, false, f);\n    }\n\n    fn remove_with_prefix<F>(&mut self, prefix: &Path, include_prefix: bool, mut f: F)\n    where\n        F: FnMut(&Path),\n    {\n        let to_remove: Vec<PathBuf> = self\n            .all\n            .iter()\n            .filter(|path| path.starts_with(prefix) && (include_prefix || *path != prefix))\n            .cloned()\n            .collect();\n\n        for path in to_remove {\n            self.all.remove(&path);\n            self.recursive.remove(&path);\n            f(&path);\n        }\n    }\n}\n\n/// Check if a directory or any of its descendants has gitignored directories.\n/// Used on macOS/Windows to determine if we can watch recursively.\n///\n/// This checks recursively to ensure we don't use Recursive mode on a directory\n/// that has gitignored descendants (e.g., packages/app1/node_modules).\nfn has_ignored_descendants(\n    dir: &Path,\n    gi: &Gitignore,\n    canonical_root: &Path,\n    allowed_dirs: &std::collections::HashSet<PathBuf>,\n    cache: &mut HashMap<PathBuf, bool>,\n) -> bool {\n    let key = dir.to_path_buf();\n    if let Some(&cached) = cache.get(&key) {\n        return cached;\n    }\n\n    // Read immediate children\n    let result = (|| {\n        let Ok(entries) = std::fs::read_dir(dir) else {\n            return false;\n        };\n\n        for entry in entries.flatten() {\n            let Ok(file_type) = entry.file_type() else {\n                continue;\n            };\n\n            if !file_type.is_dir() {\n                continue;\n            }\n\n            let path = entry.path();\n\n            // Check if this subdirectory should be skipped\n            if let Some(name) = path.file_name().and_then(|n| n.to_str())\n                && should_skip_dir(name)\n            {\n                return true;\n            }\n\n            // If it's not in allowed_dirs, it means WalkBuilder skipped it (gitignored)\n            if !allowed_dirs.contains(&path) && !path_allowed(&path, gi, canonical_root) {\n                return true;\n            }\n\n            if has_ignored_descendants(&path, gi, canonical_root, allowed_dirs, cache) {\n                return true;\n            }\n        }\n        false\n    })();\n\n    cache.insert(key, result);\n    result\n}\n\n/// Collect directories to watch, respecting gitignore and excluding .git.\n/// On macOS/Windows, use recursive mode for directories without ignored subdirectories.\n/// On Linux, use non-recursive mode for all directories.\nfn collect_watch_directories(root: &Path, gi: &Gitignore) -> Vec<WatchTarget> {\n    let use_recursive = platform_supports_native_recursive();\n\n    let mut allowed_dirs: Vec<PathBuf> = WalkBuilder::new(root)\n        .follow_links(false)\n        .hidden(false)\n        .git_ignore(true) // Respect gitignore to skip node_modules, target, etc.\n        .filter_entry(|entry| {\n            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);\n            if !is_dir {\n                return false;\n            }\n\n            if let Some(name) = entry.file_name().to_str()\n                && should_skip_dir(name)\n            {\n                return false;\n            }\n\n            true\n        })\n        .build()\n        .filter_map(|result| result.ok())\n        .filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false))\n        .map(|entry| entry.into_path())\n        .collect();\n\n    allowed_dirs.sort();\n\n    let allowed_dirs_set: HashSet<PathBuf> = allowed_dirs.iter().cloned().collect();\n    let mut ignored_cache = HashMap::new();\n    let mut ancestor_stack: Vec<(PathBuf, bool)> = Vec::new();\n\n    allowed_dirs\n        .into_iter()\n        .filter_map(|path| {\n            while ancestor_stack\n                .last()\n                .is_some_and(|(ancestor, _)| !path.starts_with(ancestor))\n            {\n                ancestor_stack.pop();\n            }\n\n            if ancestor_stack\n                .last()\n                .is_some_and(|(_, is_recursive)| *is_recursive)\n            {\n                return None;\n            }\n\n            let recursive_mode = if use_recursive {\n                if has_ignored_descendants(&path, gi, root, &allowed_dirs_set, &mut ignored_cache) {\n                    RecursiveMode::NonRecursive\n                } else {\n                    RecursiveMode::Recursive\n                }\n            } else {\n                RecursiveMode::NonRecursive\n            };\n\n            let is_recursive = matches!(recursive_mode, RecursiveMode::Recursive);\n            ancestor_stack.push((path.clone(), is_recursive));\n\n            Some(WatchTarget {\n                path,\n                recursive: recursive_mode,\n            })\n        })\n        .collect()\n}\n\n/// Helper to determine watch mode for a directory (used for dynamically added directories).\n/// This does a simple check of immediate children only, since we don't have the full\n/// allowed_dirs set at runtime.\nfn determine_watch_mode(path: &Path, gi: &Gitignore, canonical_root: &Path) -> RecursiveMode {\n    if !platform_supports_native_recursive() {\n        return RecursiveMode::NonRecursive;\n    }\n\n    let Ok(entries) = std::fs::read_dir(path) else {\n        return RecursiveMode::Recursive;\n    };\n\n    for entry in entries.flatten() {\n        let Ok(file_type) = entry.file_type() else {\n            continue;\n        };\n\n        if !file_type.is_dir() {\n            continue;\n        }\n\n        let child_path = entry.path();\n\n        if let Some(name) = child_path.file_name().and_then(|n| n.to_str())\n            && should_skip_dir(name)\n        {\n            return RecursiveMode::NonRecursive;\n        }\n\n        if !path_allowed(&child_path, gi, canonical_root) {\n            return RecursiveMode::NonRecursive;\n        }\n    }\n\n    RecursiveMode::Recursive\n}\n\n/// Add a watch for a newly created directory\nfn add_directory_watch(\n    debouncer: &mut Debouncer<RecommendedWatcher, RecommendedCache>,\n    watched_dirs: &mut WatchedDirs,\n    dir_path: &Path,\n    gi: &Gitignore,\n    canonical_root: &Path,\n) {\n    let canonical_dir = canonicalize_lossy(dir_path);\n\n    if !path_allowed(&canonical_dir, gi, canonical_root) {\n        return;\n    }\n\n    if watched_dirs.contains(&canonical_dir) || watched_dirs.has_recursive_cover(&canonical_dir) {\n        return;\n    }\n\n    let mode = determine_watch_mode(&canonical_dir, gi, canonical_root);\n\n    if let Err(e) = debouncer.watch(&canonical_dir, mode) {\n        tracing::warn!(\"Failed to watch new directory {:?}: {}\", canonical_dir, e);\n    } else {\n        if matches!(mode, RecursiveMode::Recursive) {\n            watched_dirs.remove_children_only(&canonical_dir, |child| {\n                if let Err(err) = debouncer.unwatch(child) {\n                    tracing::warn!(\"Could not unwatch covered directory {:?}: {}\", child, err);\n                }\n            });\n        }\n\n        watched_dirs.insert(canonical_dir, mode);\n    }\n}\n\n/// Remove a watch for a deleted directory\nfn remove_directory_watch(\n    debouncer: &mut Debouncer<RecommendedWatcher, RecommendedCache>,\n    watched_dirs: &mut WatchedDirs,\n    dir_path: &Path,\n) {\n    let canonical_dir = canonicalize_lossy(dir_path);\n\n    watched_dirs.remove_dir_and_children(&canonical_dir, |path| {\n        if let Err(e) = debouncer.unwatch(path) {\n            tracing::warn!(\"Could not unwatch deleted directory {:?}: {}\", path, e);\n        }\n    });\n}\n\npub fn async_watcher(root: PathBuf) -> Result<WatcherComponents, FilesystemWatcherError> {\n    let canonical_root = canonicalize_lossy(&root);\n    let gi_set = Arc::new(build_gitignore_set(&canonical_root)?);\n    // NOTE: changes to .gitignore aren’t picked up until the watcher is rebuilt.\n    // Recomputing on every change would require rebuilding the full watcher fleet.\n\n    let (mut raw_tx, mut raw_rx) = channel::<DebounceEventResult>(64);\n    let (mut filtered_tx, filtered_rx) = channel::<DebounceEventResult>(64);\n\n    let gi_clone = gi_set.clone();\n    let root_for_task = canonical_root.clone();\n\n    let debouncer_unwrapped = new_debouncer(\n        Duration::from_millis(200),\n        None,\n        move |res: DebounceEventResult| {\n            futures::executor::block_on(async {\n                raw_tx.send(res).await.ok();\n            });\n        },\n    )?;\n\n    let debouncer = Arc::new(Mutex::new(debouncer_unwrapped));\n    let debouncer_for_init = debouncer.clone();\n    let debouncer_for_task = Arc::downgrade(&debouncer);\n\n    let watched_dirs: Arc<Mutex<WatchedDirs>> = Arc::new(Mutex::new(WatchedDirs::default()));\n    let watched_dirs_for_task = watched_dirs.clone();\n\n    let watch_targets = collect_watch_directories(&canonical_root, &gi_set);\n    {\n        let mut debouncer_guard = debouncer_for_init.lock().unwrap();\n        let mut watched = watched_dirs.lock().unwrap();\n\n        for target in &watch_targets {\n            if let Err(e) = debouncer_guard.watch(&target.path, target.recursive) {\n                tracing::warn!(\"Failed to watch {:?}: {}\", target.path, e);\n            } else {\n                watched.insert(target.path.clone(), target.recursive);\n            }\n        }\n    }\n\n    std::thread::spawn(move || {\n        while let Some(result) = futures::executor::block_on(async { raw_rx.next().await }) {\n            let Some(debouncer_arc) = debouncer_for_task.upgrade() else {\n                break;\n            };\n\n            match result {\n                Ok(events) => {\n                    let mut debouncer_guard = debouncer_arc.lock().unwrap();\n                    let mut watched = watched_dirs_for_task.lock().unwrap();\n\n                    for event in &events {\n                        if event.kind.is_create() {\n                            for path in &event.paths {\n                                if path.is_dir() {\n                                    add_directory_watch(\n                                        &mut debouncer_guard,\n                                        &mut watched,\n                                        path,\n                                        &gi_clone,\n                                        &root_for_task,\n                                    );\n                                }\n                            }\n                        } else if event.kind.is_remove() {\n                            for path in &event.paths {\n                                remove_directory_watch(&mut debouncer_guard, &mut watched, path);\n                            }\n                        } else if let EventKind::Modify(ModifyKind::Name(mode)) = &event.kind {\n                            match mode {\n                                RenameMode::From => {\n                                    for path in &event.paths {\n                                        remove_directory_watch(\n                                            &mut debouncer_guard,\n                                            &mut watched,\n                                            path,\n                                        );\n                                    }\n                                }\n                                RenameMode::To => {\n                                    for path in &event.paths {\n                                        if path.is_dir() {\n                                            add_directory_watch(\n                                                &mut debouncer_guard,\n                                                &mut watched,\n                                                path,\n                                                &gi_clone,\n                                                &root_for_task,\n                                            );\n                                        }\n                                    }\n                                }\n                                RenameMode::Both => {\n                                    if let Some((from, rest)) = event.paths.split_first() {\n                                        remove_directory_watch(\n                                            &mut debouncer_guard,\n                                            &mut watched,\n                                            from,\n                                        );\n\n                                        if let Some(to) = rest.last()\n                                            && to.is_dir()\n                                        {\n                                            add_directory_watch(\n                                                &mut debouncer_guard,\n                                                &mut watched,\n                                                to,\n                                                &gi_clone,\n                                                &root_for_task,\n                                            );\n                                        }\n                                    }\n                                }\n                                RenameMode::Any | RenameMode::Other => {\n                                    for path in &event.paths {\n                                        remove_directory_watch(\n                                            &mut debouncer_guard,\n                                            &mut watched,\n                                            path,\n                                        );\n                                    }\n\n                                    for path in &event.paths {\n                                        if path.is_dir() {\n                                            add_directory_watch(\n                                                &mut debouncer_guard,\n                                                &mut watched,\n                                                path,\n                                                &gi_clone,\n                                                &root_for_task,\n                                            );\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    }\n\n                    drop(debouncer_guard);\n                    drop(watched);\n\n                    let filtered_events: Vec<DebouncedEvent> = events\n                        .into_iter()\n                        .filter(|ev| debounced_should_forward(ev, &gi_set, &root_for_task))\n                        .collect();\n\n                    if !filtered_events.is_empty() {\n                        futures::executor::block_on(async {\n                            filtered_tx.send(Ok(filtered_events)).await.ok();\n                        });\n                    }\n                }\n                Err(errors) => {\n                    futures::executor::block_on(async {\n                        filtered_tx.send(Err(errors)).await.ok();\n                    });\n                }\n            }\n        }\n    });\n\n    Ok((debouncer, filtered_rx, canonical_root))\n}\n"
  },
  {
    "path": "crates/services/src/services/migration/error.rs",
    "content": "use thiserror::Error;\n\nuse crate::services::remote_client::RemoteClientError;\n\n#[derive(Debug, Error)]\npub enum MigrationError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n\n    #[error(transparent)]\n    MigrationState(#[from] db::models::migration_state::MigrationStateError),\n\n    #[error(transparent)]\n    Workspace(#[from] db::models::workspace::WorkspaceError),\n\n    #[error(transparent)]\n    RemoteClient(#[from] RemoteClientError),\n\n    #[error(\"not authenticated - please log in first\")]\n    NotAuthenticated,\n\n    #[error(\"organization not found for user\")]\n    OrganizationNotFound,\n\n    #[error(\"entity not found: {entity_type} with id {id}\")]\n    EntityNotFound { entity_type: String, id: String },\n\n    #[error(\"migration already in progress\")]\n    MigrationInProgress,\n\n    #[error(\"status mapping failed: unknown status '{0}'\")]\n    StatusMappingFailed(String),\n\n    #[error(\"broken reference chain: {0}\")]\n    BrokenReferenceChain(String),\n\n    #[error(\"remote error: {0}\")]\n    RemoteError(String),\n}\n"
  },
  {
    "path": "crates/services/src/services/migration/mod.rs",
    "content": "mod error;\nmod types;\n\nuse std::collections::HashSet;\n\nuse api_types::{\n    BulkMigrateRequest, BulkMigrateResponse, MigrateIssueRequest, MigrateProjectRequest,\n    MigratePullRequestRequest, MigrateWorkspaceRequest,\n};\nuse db::models::{\n    merge::{Merge, MergeStatus, PrMerge},\n    migration_state::{CreateMigrationState, EntityType, MigrationState, MigrationStatus},\n    project::Project,\n    task::{Task, TaskStatus},\n    workspace::Workspace,\n};\npub use error::MigrationError;\nuse sqlx::SqlitePool;\nuse tracing::info;\npub use types::*;\nuse uuid::Uuid;\n\nuse crate::services::remote_client::RemoteClient;\n\nconst BATCH_SIZE: usize = 100;\n\npub struct MigrationService {\n    sqlite_pool: SqlitePool,\n    remote_client: RemoteClient,\n}\n\nimpl MigrationService {\n    pub fn new(sqlite_pool: SqlitePool, remote_client: RemoteClient) -> Self {\n        Self {\n            sqlite_pool,\n            remote_client,\n        }\n    }\n\n    pub async fn run_migration(\n        &self,\n        organization_id: Uuid,\n        project_ids: HashSet<Uuid>,\n    ) -> Result<MigrationReport, MigrationError> {\n        let mut report = MigrationReport::default();\n\n        info!(\n            \"Starting migration to organization {} for {} projects\",\n            organization_id,\n            project_ids.len()\n        );\n\n        info!(\"Phase 1: Migrating projects...\");\n        self.migrate_projects(organization_id, &project_ids, &mut report)\n            .await?;\n\n        info!(\"Phase 2: Migrating tasks to issues...\");\n        self.migrate_tasks(&project_ids, &mut report).await?;\n\n        info!(\"Phase 3: Migrating PR merges to pull requests...\");\n        self.migrate_pr_merges(&project_ids, &mut report).await?;\n\n        info!(\"Phase 4: Migrating workspaces...\");\n        self.migrate_workspaces(&project_ids, &mut report).await?;\n\n        info!(\n            \"Migration complete. Projects: {}/{}, Tasks: {}/{}, PRs: {}/{}, Workspaces: {}/{}\",\n            report.projects.migrated,\n            report.projects.total,\n            report.tasks.migrated,\n            report.tasks.total,\n            report.pr_merges.migrated,\n            report.pr_merges.total,\n            report.workspaces.migrated,\n            report.workspaces.total\n        );\n\n        Ok(report)\n    }\n\n    pub async fn get_status(\n        &self,\n        project_ids: &HashSet<Uuid>,\n    ) -> Result<MigrationReport, MigrationError> {\n        let projects =\n            MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::Project).await?;\n        let tasks =\n            MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::Task).await?;\n        let pr_merges =\n            MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::PrMerge).await?;\n        let workspaces =\n            MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::Workspace).await?;\n\n        let projects: Vec<_> = projects\n            .into_iter()\n            .filter(|s| project_ids.contains(&s.local_id))\n            .collect();\n\n        Ok(MigrationReport {\n            projects: Self::entity_report_from_states(&projects),\n            tasks: Self::entity_report_from_states(&tasks),\n            pr_merges: Self::entity_report_from_states(&pr_merges),\n            workspaces: Self::entity_report_from_states(&workspaces),\n            warnings: vec![],\n        })\n    }\n\n    pub async fn resume_migration(\n        &self,\n        organization_id: Uuid,\n        project_ids: HashSet<Uuid>,\n    ) -> Result<MigrationReport, MigrationError> {\n        MigrationState::reset_failed(&self.sqlite_pool).await?;\n        self.run_migration(organization_id, project_ids).await\n    }\n\n    async fn migrate_projects(\n        &self,\n        organization_id: Uuid,\n        project_ids: &HashSet<Uuid>,\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        let all_projects = Project::find_all(&self.sqlite_pool).await?;\n        let projects: Vec<_> = all_projects\n            .into_iter()\n            .filter(|p| project_ids.contains(&p.id))\n            .collect();\n        report.projects.total = projects.len();\n\n        let mut pending_projects = Vec::new();\n        for project in &projects {\n            if let Some(existing) =\n                MigrationState::find_by_entity(&self.sqlite_pool, EntityType::Project, project.id)\n                    .await?\n                && existing.status == MigrationStatus::Migrated\n            {\n                report.projects.skipped += 1;\n                continue;\n            }\n            pending_projects.push(project.clone());\n        }\n\n        for chunk in pending_projects.chunks(BATCH_SIZE) {\n            self.migrate_project_batch(organization_id, chunk, report)\n                .await?;\n        }\n\n        Ok(())\n    }\n\n    async fn migrate_project_batch(\n        &self,\n        organization_id: Uuid,\n        projects: &[Project],\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        for project in projects {\n            MigrationState::upsert(\n                &self.sqlite_pool,\n                &CreateMigrationState {\n                    entity_type: EntityType::Project,\n                    local_id: project.id,\n                },\n            )\n            .await?;\n        }\n\n        let requests: Vec<MigrateProjectRequest> = projects\n            .iter()\n            .enumerate()\n            .map(|(i, p)| MigrateProjectRequest {\n                organization_id,\n                name: p.name.clone(),\n                color: generate_hsl_color(i),\n                created_at: p.created_at,\n            })\n            .collect();\n\n        let response: BulkMigrateResponse = self\n            .remote_client\n            .post_authed(\n                \"/v1/migration/projects\",\n                Some(&BulkMigrateRequest { items: requests }),\n            )\n            .await?;\n\n        for (project, remote_id) in projects.iter().zip(response.ids.iter()) {\n            MigrationState::mark_migrated(\n                &self.sqlite_pool,\n                EntityType::Project,\n                project.id,\n                *remote_id,\n            )\n            .await?;\n\n            Project::set_remote_project_id(&self.sqlite_pool, project.id, Some(*remote_id)).await?;\n\n            report.projects.migrated += 1;\n        }\n\n        Ok(())\n    }\n\n    async fn migrate_tasks(\n        &self,\n        project_ids: &HashSet<Uuid>,\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        let all_tasks = Task::find_all(&self.sqlite_pool).await?;\n        let tasks: Vec<_> = all_tasks\n            .into_iter()\n            .filter(|t| project_ids.contains(&t.project_id))\n            .collect();\n        report.tasks.total = tasks.len();\n\n        let mut pending_tasks = Vec::new();\n        for task in &tasks {\n            if let Some(existing) =\n                MigrationState::find_by_entity(&self.sqlite_pool, EntityType::Task, task.id).await?\n                && existing.status == MigrationStatus::Migrated\n            {\n                report.tasks.skipped += 1;\n                continue;\n            }\n            pending_tasks.push(task.clone());\n        }\n\n        for chunk in pending_tasks.chunks(BATCH_SIZE) {\n            self.migrate_task_batch(chunk, report).await?;\n        }\n\n        Ok(())\n    }\n\n    async fn migrate_task_batch(\n        &self,\n        tasks: &[Task],\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        for task in tasks {\n            MigrationState::upsert(\n                &self.sqlite_pool,\n                &CreateMigrationState {\n                    entity_type: EntityType::Task,\n                    local_id: task.id,\n                },\n            )\n            .await?;\n        }\n\n        let mut requests = Vec::new();\n        let mut request_task_ids = Vec::new();\n\n        for task in tasks {\n            let remote_project_id = MigrationState::get_remote_id(\n                &self.sqlite_pool,\n                EntityType::Project,\n                task.project_id,\n            )\n            .await?;\n\n            match remote_project_id {\n                Some(project_id) => {\n                    requests.push(MigrateIssueRequest {\n                        project_id,\n                        status_name: map_task_status(&task.status),\n                        title: task.title.chars().take(255).collect(),\n                        description: task.description.clone(),\n                        created_at: task.created_at,\n                    });\n                    request_task_ids.push(task.id);\n                }\n                None => {\n                    let error_msg = format!(\"Project {} not migrated\", task.project_id);\n                    MigrationState::mark_skipped(\n                        &self.sqlite_pool,\n                        EntityType::Task,\n                        task.id,\n                        &error_msg,\n                    )\n                    .await?;\n                    report.tasks.skipped += 1;\n                    report\n                        .warnings\n                        .push(format!(\"Skipped task {}: {}\", task.id, error_msg));\n                }\n            }\n        }\n\n        if requests.is_empty() {\n            return Ok(());\n        }\n\n        let response: BulkMigrateResponse = self\n            .remote_client\n            .post_authed(\n                \"/v1/migration/issues\",\n                Some(&BulkMigrateRequest { items: requests }),\n            )\n            .await?;\n\n        for (task_id, remote_id) in request_task_ids.iter().zip(response.ids.iter()) {\n            MigrationState::mark_migrated(\n                &self.sqlite_pool,\n                EntityType::Task,\n                *task_id,\n                *remote_id,\n            )\n            .await?;\n            report.tasks.migrated += 1;\n        }\n\n        Ok(())\n    }\n\n    async fn migrate_pr_merges(\n        &self,\n        project_ids: &HashSet<Uuid>,\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        let all_pr_merges = Merge::find_all_pr(&self.sqlite_pool).await?;\n\n        let mut pr_merges = Vec::new();\n        for pr_merge in all_pr_merges {\n            if let Some(workspace) =\n                Workspace::find_by_id(&self.sqlite_pool, pr_merge.workspace_id).await?\n                && let Some(task_id) = workspace.task_id\n                && let Some(task) = Task::find_by_id(&self.sqlite_pool, task_id).await?\n                && project_ids.contains(&task.project_id)\n            {\n                pr_merges.push(pr_merge);\n            }\n        }\n        report.pr_merges.total = pr_merges.len();\n\n        let mut pending_merges = Vec::new();\n        for pr_merge in &pr_merges {\n            if let Some(existing) =\n                MigrationState::find_by_entity(&self.sqlite_pool, EntityType::PrMerge, pr_merge.id)\n                    .await?\n                && existing.status == MigrationStatus::Migrated\n            {\n                report.pr_merges.skipped += 1;\n                continue;\n            }\n            pending_merges.push(pr_merge.clone());\n        }\n\n        for chunk in pending_merges.chunks(BATCH_SIZE) {\n            self.migrate_pr_merge_batch(chunk, report).await?;\n        }\n\n        Ok(())\n    }\n\n    async fn migrate_pr_merge_batch(\n        &self,\n        pr_merges: &[PrMerge],\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        for pr_merge in pr_merges {\n            MigrationState::upsert(\n                &self.sqlite_pool,\n                &CreateMigrationState {\n                    entity_type: EntityType::PrMerge,\n                    local_id: pr_merge.id,\n                },\n            )\n            .await?;\n        }\n\n        let mut requests = Vec::new();\n        let mut request_merge_ids = Vec::new();\n\n        for pr_merge in pr_merges {\n            let workspace = Workspace::find_by_id(&self.sqlite_pool, pr_merge.workspace_id).await?;\n\n            let issue_id = match workspace.and_then(|ws| ws.task_id) {\n                Some(task_id) => {\n                    MigrationState::get_remote_id(&self.sqlite_pool, EntityType::Task, task_id)\n                        .await?\n                }\n                None => None,\n            };\n\n            match issue_id {\n                Some(remote_issue_id) => {\n                    requests.push(MigratePullRequestRequest {\n                        url: pr_merge.pr_info.url.clone(),\n                        number: pr_merge.pr_info.number as i32,\n                        status: map_merge_status(&pr_merge.pr_info.status),\n                        merged_at: pr_merge.pr_info.merged_at,\n                        merge_commit_sha: pr_merge.pr_info.merge_commit_sha.clone(),\n                        target_branch_name: pr_merge.target_branch_name.clone(),\n                        issue_id: remote_issue_id,\n                    });\n                    request_merge_ids.push(pr_merge.id);\n                }\n                None => {\n                    let error_msg = format!(\n                        \"Cannot resolve issue for PR merge {} (workspace: {})\",\n                        pr_merge.id, pr_merge.workspace_id\n                    );\n                    MigrationState::mark_skipped(\n                        &self.sqlite_pool,\n                        EntityType::PrMerge,\n                        pr_merge.id,\n                        &error_msg,\n                    )\n                    .await?;\n                    report.pr_merges.skipped += 1;\n                    report\n                        .warnings\n                        .push(format!(\"Skipped PR {}: {}\", pr_merge.id, error_msg));\n                }\n            }\n        }\n\n        if requests.is_empty() {\n            return Ok(());\n        }\n\n        let response: BulkMigrateResponse = self\n            .remote_client\n            .post_authed(\n                \"/v1/migration/pull_requests\",\n                Some(&BulkMigrateRequest { items: requests }),\n            )\n            .await?;\n\n        for (merge_id, remote_id) in request_merge_ids.iter().zip(response.ids.iter()) {\n            MigrationState::mark_migrated(\n                &self.sqlite_pool,\n                EntityType::PrMerge,\n                *merge_id,\n                *remote_id,\n            )\n            .await?;\n            report.pr_merges.migrated += 1;\n        }\n\n        Ok(())\n    }\n\n    async fn migrate_workspaces(\n        &self,\n        project_ids: &HashSet<Uuid>,\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        let all_workspaces = Workspace::fetch_all(&self.sqlite_pool).await?;\n\n        let mut workspaces = Vec::new();\n        for workspace in all_workspaces {\n            if let Some(task_id) = workspace.task_id\n                && let Some(task) = Task::find_by_id(&self.sqlite_pool, task_id).await?\n                && project_ids.contains(&task.project_id)\n            {\n                workspaces.push(workspace);\n            }\n        }\n        report.workspaces.total = workspaces.len();\n\n        let mut pending_workspaces = Vec::new();\n        for workspace in &workspaces {\n            if let Some(existing) = MigrationState::find_by_entity(\n                &self.sqlite_pool,\n                EntityType::Workspace,\n                workspace.id,\n            )\n            .await?\n                && existing.status == MigrationStatus::Migrated\n            {\n                report.workspaces.skipped += 1;\n                continue;\n            }\n            pending_workspaces.push(workspace.clone());\n        }\n\n        for chunk in pending_workspaces.chunks(BATCH_SIZE) {\n            self.migrate_workspace_batch(chunk, report).await?;\n        }\n\n        Ok(())\n    }\n\n    async fn migrate_workspace_batch(\n        &self,\n        workspaces: &[Workspace],\n        report: &mut MigrationReport,\n    ) -> Result<(), MigrationError> {\n        for workspace in workspaces {\n            MigrationState::upsert(\n                &self.sqlite_pool,\n                &CreateMigrationState {\n                    entity_type: EntityType::Workspace,\n                    local_id: workspace.id,\n                },\n            )\n            .await?;\n        }\n\n        let mut requests = Vec::new();\n        let mut request_workspace_ids = Vec::new();\n\n        for workspace in workspaces {\n            let task_id = match workspace.task_id {\n                Some(id) => id,\n                None => {\n                    let error_msg = \"Workspace has no task_id\".to_string();\n                    MigrationState::mark_skipped(\n                        &self.sqlite_pool,\n                        EntityType::Workspace,\n                        workspace.id,\n                        &error_msg,\n                    )\n                    .await?;\n                    report.workspaces.skipped += 1;\n                    report\n                        .warnings\n                        .push(format!(\"Skipped workspace {}: {}\", workspace.id, error_msg));\n                    continue;\n                }\n            };\n\n            let task = match Task::find_by_id(&self.sqlite_pool, task_id).await? {\n                Some(t) => t,\n                None => {\n                    let error_msg = format!(\"Task {} not found for workspace\", task_id);\n                    MigrationState::mark_skipped(\n                        &self.sqlite_pool,\n                        EntityType::Workspace,\n                        workspace.id,\n                        &error_msg,\n                    )\n                    .await?;\n                    report.workspaces.skipped += 1;\n                    report\n                        .warnings\n                        .push(format!(\"Skipped workspace {}: {}\", workspace.id, error_msg));\n                    continue;\n                }\n            };\n\n            let remote_project_id = MigrationState::get_remote_id(\n                &self.sqlite_pool,\n                EntityType::Project,\n                task.project_id,\n            )\n            .await?;\n\n            let remote_project_id = match remote_project_id {\n                Some(id) => id,\n                None => {\n                    let error_msg = format!(\"Project {} not migrated\", task.project_id);\n                    MigrationState::mark_skipped(\n                        &self.sqlite_pool,\n                        EntityType::Workspace,\n                        workspace.id,\n                        &error_msg,\n                    )\n                    .await?;\n                    report.workspaces.skipped += 1;\n                    report\n                        .warnings\n                        .push(format!(\"Skipped workspace {}: {}\", workspace.id, error_msg));\n                    continue;\n                }\n            };\n\n            let remote_issue_id =\n                MigrationState::get_remote_id(&self.sqlite_pool, EntityType::Task, task_id).await?;\n\n            if remote_issue_id.is_none() {\n                let error_msg = format!(\"Task {} not migrated\", task_id);\n                MigrationState::mark_skipped(\n                    &self.sqlite_pool,\n                    EntityType::Workspace,\n                    workspace.id,\n                    &error_msg,\n                )\n                .await?;\n                report.workspaces.skipped += 1;\n                report\n                    .warnings\n                    .push(format!(\"Skipped workspace {}: {}\", workspace.id, error_msg));\n                continue;\n            }\n\n            requests.push(MigrateWorkspaceRequest {\n                project_id: remote_project_id,\n                issue_id: remote_issue_id,\n                local_workspace_id: workspace.id,\n                archived: workspace.archived,\n                created_at: workspace.created_at,\n            });\n            request_workspace_ids.push(workspace.id);\n        }\n\n        if requests.is_empty() {\n            return Ok(());\n        }\n\n        let response: BulkMigrateResponse = self\n            .remote_client\n            .post_authed(\n                \"/v1/migration/workspaces\",\n                Some(&BulkMigrateRequest { items: requests }),\n            )\n            .await?;\n\n        for (workspace_id, remote_id) in request_workspace_ids.iter().zip(response.ids.iter()) {\n            MigrationState::mark_migrated(\n                &self.sqlite_pool,\n                EntityType::Workspace,\n                *workspace_id,\n                *remote_id,\n            )\n            .await?;\n            report.workspaces.migrated += 1;\n        }\n\n        Ok(())\n    }\n\n    fn entity_report_from_states(states: &[MigrationState]) -> EntityReport {\n        let mut report = EntityReport {\n            total: states.len(),\n            ..Default::default()\n        };\n\n        for state in states {\n            match state.status {\n                MigrationStatus::Migrated => report.migrated += 1,\n                MigrationStatus::Failed => {\n                    report.failed += 1;\n                    if let Some(ref msg) = state.error_message {\n                        report.errors.push(EntityError {\n                            local_id: state.local_id,\n                            error: msg.clone(),\n                        });\n                    }\n                }\n                MigrationStatus::Skipped => report.skipped += 1,\n                MigrationStatus::Pending => {}\n            }\n        }\n\n        report\n    }\n}\n\nfn map_task_status(status: &TaskStatus) -> String {\n    match status {\n        TaskStatus::Todo => \"To do\".to_string(),\n        TaskStatus::InProgress => \"In progress\".to_string(),\n        TaskStatus::InReview => \"In review\".to_string(),\n        TaskStatus::Done => \"Done\".to_string(),\n        TaskStatus::Cancelled => \"Cancelled\".to_string(),\n    }\n}\n\nfn map_merge_status(status: &MergeStatus) -> String {\n    match status {\n        MergeStatus::Open => \"open\".to_string(),\n        MergeStatus::Merged => \"merged\".to_string(),\n        MergeStatus::Closed => \"closed\".to_string(),\n        MergeStatus::Unknown => \"open\".to_string(),\n    }\n}\n\nfn generate_hsl_color(index: usize) -> String {\n    let hues = [217, 142, 38, 258, 0, 180, 300, 60];\n    let h = hues[index % hues.len()];\n    let s = 70 + (index / hues.len()) % 20;\n    let l = 50 + (index / hues.len()) % 15;\n    format!(\"{} {}% {}%\", h, s, l)\n}\n"
  },
  {
    "path": "crates/services/src/services/migration/types.rs",
    "content": "use std::collections::HashSet;\n\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct MigrationRequest {\n    pub organization_id: Uuid,\n    /// List of local project IDs to migrate.\n    pub project_ids: Vec<Uuid>,\n}\n\nimpl MigrationRequest {\n    /// Returns the set of project IDs to migrate.\n    pub fn project_id_set(&self) -> HashSet<Uuid> {\n        self.project_ids.iter().copied().collect()\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct MigrationResponse {\n    pub report: MigrationReport,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\npub struct MigrationReport {\n    pub projects: EntityReport,\n    pub tasks: EntityReport,\n    pub pr_merges: EntityReport,\n    pub workspaces: EntityReport,\n    pub warnings: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\npub struct EntityReport {\n    pub total: usize,\n    pub migrated: usize,\n    pub failed: usize,\n    pub skipped: usize,\n    pub errors: Vec<EntityError>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct EntityError {\n    pub local_id: Uuid,\n    pub error: String,\n}\n"
  },
  {
    "path": "crates/services/src/services/mod.rs",
    "content": "pub mod analytics;\npub mod approvals;\npub mod auth;\npub mod config;\npub mod container;\npub mod diff_stream;\npub mod events;\npub mod execution_process;\npub mod file;\npub mod file_ranker;\npub mod file_search;\npub mod filesystem;\npub mod filesystem_watcher;\npub mod migration;\npub mod notification;\npub mod oauth_credentials;\npub mod pr_monitor;\n\n#[cfg(feature = \"qa-mode\")]\npub mod qa_repos;\npub mod queued_message;\npub mod remote_client;\npub mod remote_sync;\npub mod repo;\n"
  },
  {
    "path": "crates/services/src/services/notification.rs",
    "content": "use std::sync::{Arc, OnceLock};\n\nuse async_trait::async_trait;\nuse tokio::sync::RwLock;\nuse utils::{self, command_ext::NoWindowExt};\nuse uuid::Uuid;\n\nuse crate::services::config::{Config, SoundFile};\n\n/// Trait for sending push notifications. Implementations can use\n/// platform-specific OS commands, Tauri's notification plugin, etc.\n#[async_trait]\npub trait PushNotifier: Send + Sync + 'static {\n    async fn send(&self, title: &str, message: &str, workspace_id: Option<Uuid>);\n}\n\n/// Global push notifier set before server startup (e.g., by the Tauri app).\n/// Falls back to `DefaultPushNotifier` if not set.\nstatic GLOBAL_PUSH_NOTIFIER: OnceLock<Arc<dyn PushNotifier>> = OnceLock::new();\n\n/// Register a custom push notifier globally. Must be called before the server\n/// starts (i.e., before `LocalDeployment::new()`). Typically called from the\n/// Tauri app to inject a `TauriNotifier` that uses the native notification API.\npub fn set_global_push_notifier(notifier: Arc<dyn PushNotifier>) {\n    let _ = GLOBAL_PUSH_NOTIFIER.set(notifier);\n}\n\n/// Get the global push notifier, or `DefaultPushNotifier` if none was set.\npub fn get_global_push_notifier() -> Arc<dyn PushNotifier> {\n    GLOBAL_PUSH_NOTIFIER\n        .get()\n        .cloned()\n        .unwrap_or_else(|| Arc::new(DefaultPushNotifier))\n}\n\n/// Default push notifier using platform-specific OS commands.\n/// Used as a fallback when no Tauri app handle is available.\npub struct DefaultPushNotifier;\n\n/// Cache for WSL root path from PowerShell\nstatic WSL_ROOT_PATH_CACHE: OnceLock<Option<String>> = OnceLock::new();\n\n#[async_trait]\nimpl PushNotifier for DefaultPushNotifier {\n    async fn send(&self, title: &str, message: &str, _workspace_id: Option<Uuid>) {\n        if cfg!(target_os = \"macos\") {\n            send_macos_notification(title, message).await;\n        } else if cfg!(target_os = \"linux\") && !utils::is_wsl2() {\n            send_linux_notification(title, message).await;\n        } else if cfg!(target_os = \"windows\") || (cfg!(target_os = \"linux\") && utils::is_wsl2()) {\n            send_windows_notification(title, message).await;\n        }\n    }\n}\n\n/// Service for handling cross-platform notifications including sound alerts and push notifications\n#[derive(Clone)]\npub struct NotificationService {\n    config: Arc<RwLock<Config>>,\n    push_notifier: Arc<dyn PushNotifier>,\n}\n\nimpl std::fmt::Debug for NotificationService {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"NotificationService\")\n            .field(\"config\", &self.config)\n            .finish()\n    }\n}\n\nimpl NotificationService {\n    pub fn new(config: Arc<RwLock<Config>>) -> Self {\n        Self {\n            config,\n            push_notifier: get_global_push_notifier(),\n        }\n    }\n\n    pub fn new_with_notifier(\n        config: Arc<RwLock<Config>>,\n        push_notifier: Arc<dyn PushNotifier>,\n    ) -> Self {\n        Self {\n            config,\n            push_notifier,\n        }\n    }\n\n    /// Send both sound and push notifications if enabled.\n    /// `workspace_id` is forwarded to the push notifier so Tauri can emit a\n    /// navigation event when the notification is clicked.\n    pub async fn notify(&self, title: &str, message: &str, workspace_id: Option<Uuid>) {\n        let config = self.config.read().await.notifications.clone();\n\n        if config.sound_enabled {\n            Self::play_sound_notification(&config.sound_file).await;\n        }\n\n        if config.push_enabled {\n            self.push_notifier.send(title, message, workspace_id).await;\n        }\n    }\n\n    /// Play a system sound notification across platforms\n    async fn play_sound_notification(sound_file: &SoundFile) {\n        let file_path = match sound_file.get_path().await {\n            Ok(path) => path,\n            Err(e) => {\n                tracing::error!(\"Failed to create cached sound file: {}\", e);\n                return;\n            }\n        };\n\n        // Use platform-specific sound notification\n        // Note: spawn() calls are intentionally not awaited - sound notifications should be fire-and-forget\n        if cfg!(target_os = \"macos\") {\n            let _ = tokio::process::Command::new(\"afplay\")\n                .arg(&file_path)\n                .spawn();\n        } else if cfg!(target_os = \"linux\") && !utils::is_wsl2() {\n            // Try different Linux audio players\n            if tokio::process::Command::new(\"paplay\")\n                .arg(&file_path)\n                .spawn()\n                .is_ok()\n            {\n                // Success with paplay\n            } else if tokio::process::Command::new(\"aplay\")\n                .arg(&file_path)\n                .spawn()\n                .is_ok()\n            {\n                // Success with aplay\n            } else {\n                // Try system bell as fallback\n                let _ = tokio::process::Command::new(\"echo\")\n                    .arg(\"-e\")\n                    .arg(\"\\\\a\")\n                    .spawn();\n            }\n        } else if cfg!(target_os = \"windows\") || (cfg!(target_os = \"linux\") && utils::is_wsl2()) {\n            // Convert WSL path to Windows path if in WSL2\n            let file_path = if utils::is_wsl2() {\n                if let Some(windows_path) = wsl_to_windows_path(&file_path).await {\n                    windows_path\n                } else {\n                    file_path.to_string_lossy().to_string()\n                }\n            } else {\n                file_path.to_string_lossy().to_string()\n            };\n\n            let _ = tokio::process::Command::new(\"powershell.exe\")\n                .arg(\"-c\")\n                .arg(format!(\n                    r#\"(New-Object Media.SoundPlayer \"{file_path}\").PlaySync()\"#\n                ))\n                .no_window()\n                .spawn();\n        }\n    }\n}\n\n// --- Platform-specific push notification helpers (used by DefaultPushNotifier) ---\n\n/// Send macOS notification using osascript\nasync fn send_macos_notification(title: &str, message: &str) {\n    let script = format!(\n        r#\"display notification \"{message}\" with title \"{title}\" sound name \"Glass\"\"#,\n        message = message.replace('\"', r#\"\\\"\"#),\n        title = title.replace('\"', r#\"\\\"\"#)\n    );\n\n    let _ = tokio::process::Command::new(\"osascript\")\n        .arg(\"-e\")\n        .arg(script)\n        .spawn();\n}\n\n/// Send Linux notification using notify-rust\nasync fn send_linux_notification(title: &str, message: &str) {\n    use notify_rust::Notification;\n\n    let title = title.to_string();\n    let message = message.to_string();\n\n    let _handle = tokio::task::spawn_blocking(move || {\n        match Notification::new()\n            .summary(&title)\n            .body(&message)\n            .timeout(10000)\n            .show()\n        {\n            Ok(_) => {}\n            Err(e) => {\n                let err_str = e.to_string();\n                if err_str.contains(\"ServiceUnknown\")\n                    || err_str.contains(\"org.freedesktop.Notifications\")\n                {\n                    tracing::warn!(\"Linux notification daemon not available: {}\", e);\n                } else {\n                    tracing::warn!(\"Failed to send Linux notification: {}\", e);\n                }\n            }\n        }\n    });\n    drop(_handle); // Don't await, fire-and-forget\n}\n\n/// Send Windows/WSL notification using PowerShell toast script\nasync fn send_windows_notification(title: &str, message: &str) {\n    let script_path = match utils::get_powershell_script().await {\n        Ok(path) => path,\n        Err(e) => {\n            tracing::error!(\"Failed to get PowerShell script: {}\", e);\n            return;\n        }\n    };\n\n    // Convert WSL path to Windows path if in WSL2\n    let script_path_str = if utils::is_wsl2() {\n        if let Some(windows_path) = wsl_to_windows_path(&script_path).await {\n            windows_path\n        } else {\n            script_path.to_string_lossy().to_string()\n        }\n    } else {\n        script_path.to_string_lossy().to_string()\n    };\n\n    let _ = tokio::process::Command::new(\"powershell.exe\")\n        .arg(\"-NoProfile\")\n        .arg(\"-ExecutionPolicy\")\n        .arg(\"Bypass\")\n        .arg(\"-File\")\n        .arg(script_path_str)\n        .arg(\"-Title\")\n        .arg(title)\n        .arg(\"-Message\")\n        .arg(message)\n        .no_window()\n        .spawn();\n}\n\n/// Get WSL root path via PowerShell (cached)\nasync fn get_wsl_root_path() -> Option<String> {\n    if let Some(cached) = WSL_ROOT_PATH_CACHE.get() {\n        return cached.clone();\n    }\n\n    match tokio::process::Command::new(\"powershell.exe\")\n        .arg(\"-c\")\n        .arg(\"(Get-Location).Path -replace '^.*::', ''\")\n        .current_dir(\"/\")\n        .no_window()\n        .output()\n        .await\n    {\n        Ok(output) => {\n            match String::from_utf8(output.stdout) {\n                Ok(pwd_str) => {\n                    let pwd = pwd_str.trim();\n                    tracing::info!(\"WSL root path detected: {}\", pwd);\n\n                    // Cache the result\n                    let _ = WSL_ROOT_PATH_CACHE.set(Some(pwd.to_string()));\n                    return Some(pwd.to_string());\n                }\n                Err(e) => {\n                    tracing::error!(\"Failed to parse PowerShell pwd output as UTF-8: {}\", e);\n                }\n            }\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to execute PowerShell pwd command: {}\", e);\n        }\n    }\n\n    // Cache the failure result\n    let _ = WSL_ROOT_PATH_CACHE.set(None);\n    None\n}\n\n/// Convert WSL path to Windows UNC path for PowerShell\nasync fn wsl_to_windows_path(wsl_path: &std::path::Path) -> Option<String> {\n    let path_str = wsl_path.to_string_lossy();\n\n    // Relative paths work fine as-is in PowerShell\n    if !path_str.starts_with('/') {\n        tracing::debug!(\"Using relative path as-is: {}\", path_str);\n        return Some(path_str.to_string());\n    }\n\n    // Get cached WSL root path from PowerShell\n    if let Some(wsl_root) = get_wsl_root_path().await {\n        // Simply concatenate WSL root with the absolute path - PowerShell doesn't mind /\n        let windows_path = format!(\"{wsl_root}{path_str}\");\n        tracing::debug!(\"WSL path converted: {} -> {}\", path_str, windows_path);\n        Some(windows_path)\n    } else {\n        tracing::error!(\n            \"Failed to determine WSL root path for conversion: {}\",\n            path_str\n        );\n        None\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/oauth_credentials.rs",
    "content": "use std::path::PathBuf;\n\nuse chrono::{DateTime, Duration as ChronoDuration, Utc};\nuse serde::{Deserialize, Serialize};\nuse tokio::sync::RwLock;\n\n/// OAuth credentials containing the JWT tokens issued by the remote OAuth service.\n/// The `access_token` is short-lived; `refresh_token` allows minting a new pair.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Credentials {\n    pub access_token: Option<String>,\n    pub refresh_token: String,\n    pub expires_at: Option<DateTime<Utc>>,\n}\n\nimpl Credentials {\n    pub fn expires_soon(&self, leeway: ChronoDuration) -> bool {\n        match (self.access_token.as_ref(), self.expires_at.as_ref()) {\n            (Some(_), Some(exp)) => Utc::now() + leeway >= *exp,\n            _ => true,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct StoredCredentials {\n    refresh_token: String,\n}\n\nimpl From<StoredCredentials> for Credentials {\n    fn from(value: StoredCredentials) -> Self {\n        Self {\n            access_token: None,\n            refresh_token: value.refresh_token,\n            expires_at: None,\n        }\n    }\n}\n\n/// Service for managing OAuth credentials (JWT tokens) in memory and persistent storage.\n/// The token is loaded into memory on startup and persisted to disk on save.\npub struct OAuthCredentials {\n    path: PathBuf,\n    inner: RwLock<Option<Credentials>>,\n}\n\nimpl OAuthCredentials {\n    pub fn new(path: PathBuf) -> Self {\n        Self {\n            path,\n            inner: RwLock::new(None),\n        }\n    }\n\n    pub async fn load(&self) -> std::io::Result<()> {\n        let creds = self.load_from_file().await?.map(Credentials::from);\n        *self.inner.write().await = creds;\n        Ok(())\n    }\n\n    pub async fn save(&self, creds: &Credentials) -> std::io::Result<()> {\n        let stored = StoredCredentials {\n            refresh_token: creds.refresh_token.clone(),\n        };\n        self.save_to_file(&stored).await?;\n        *self.inner.write().await = Some(creds.clone());\n        Ok(())\n    }\n\n    pub async fn clear(&self) -> std::io::Result<()> {\n        let _ = std::fs::remove_file(&self.path);\n        *self.inner.write().await = None;\n        Ok(())\n    }\n\n    pub async fn get(&self) -> Option<Credentials> {\n        self.inner.read().await.clone()\n    }\n\n    async fn load_from_file(&self) -> std::io::Result<Option<StoredCredentials>> {\n        if !self.path.exists() {\n            return Ok(None);\n        }\n\n        let bytes = std::fs::read(&self.path)?;\n        match serde_json::from_slice::<StoredCredentials>(&bytes) {\n            Ok(creds) => Ok(Some(creds)),\n            Err(e) => {\n                tracing::warn!(?e, \"failed to parse credentials file, renaming to .bad\");\n                let bad = self.path.with_extension(\"bad\");\n                let _ = std::fs::rename(&self.path, bad);\n                Ok(None)\n            }\n        }\n    }\n\n    async fn save_to_file(&self, creds: &StoredCredentials) -> std::io::Result<()> {\n        let tmp = self.path.with_extension(\"tmp\");\n\n        let file = {\n            let mut opts = std::fs::OpenOptions::new();\n            opts.create(true).truncate(true).write(true);\n\n            #[cfg(unix)]\n            {\n                use std::os::unix::fs::OpenOptionsExt;\n                opts.mode(0o600);\n            }\n\n            opts.open(&tmp)?\n        };\n\n        serde_json::to_writer_pretty(&file, creds)?;\n        file.sync_all()?;\n        drop(file);\n\n        std::fs::rename(&tmp, &self.path)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/pr_monitor.rs",
    "content": "use std::time::Duration;\n\nuse api_types::{PullRequestStatus, UpsertPullRequestRequest};\nuse chrono::Utc;\nuse db::{\n    DBService,\n    models::{\n        merge::{Merge, MergeStatus, PrMerge},\n        workspace::{Workspace, WorkspaceError},\n    },\n};\nuse git_host::{GitHostError, GitHostProvider, GitHostService};\nuse serde_json::json;\nuse sqlx::error::Error as SqlxError;\nuse thiserror::Error;\nuse tokio::time::interval;\nuse tracing::{debug, error, info, warn};\n\nuse crate::services::{\n    analytics::AnalyticsContext, container::ContainerService, remote_client::RemoteClient,\n    remote_sync,\n};\n\n#[derive(Debug, Error)]\nenum PrMonitorError {\n    #[error(transparent)]\n    GitHostError(#[from] GitHostError),\n    #[error(transparent)]\n    WorkspaceError(#[from] WorkspaceError),\n    #[error(transparent)]\n    Sqlx(#[from] SqlxError),\n}\n\nimpl PrMonitorError {\n    fn is_environmental(&self) -> bool {\n        matches!(\n            self,\n            PrMonitorError::GitHostError(\n                GitHostError::CliNotInstalled { .. } | GitHostError::NotAGitRepository(_)\n            )\n        )\n    }\n}\n\n/// Service to monitor PRs and update task status when they are merged\npub struct PrMonitorService<C: ContainerService> {\n    db: DBService,\n    poll_interval: Duration,\n    analytics: Option<AnalyticsContext>,\n    container: C,\n    remote_client: Option<RemoteClient>,\n}\n\nimpl<C: ContainerService + Send + Sync + 'static> PrMonitorService<C> {\n    pub async fn spawn(\n        db: DBService,\n        analytics: Option<AnalyticsContext>,\n        container: C,\n        remote_client: Option<RemoteClient>,\n    ) -> tokio::task::JoinHandle<()> {\n        let service = Self {\n            db,\n            poll_interval: Duration::from_secs(60), // Check every minute\n            analytics,\n            container,\n            remote_client,\n        };\n        tokio::spawn(async move {\n            service.start().await;\n        })\n    }\n\n    async fn start(&self) {\n        info!(\n            \"Starting PR monitoring service with interval {:?}\",\n            self.poll_interval\n        );\n\n        let mut interval = interval(self.poll_interval);\n\n        loop {\n            interval.tick().await;\n            if let Err(e) = self.check_all_open_prs().await {\n                error!(\"Error checking open PRs: {}\", e);\n            }\n        }\n    }\n\n    /// Check all open PRs for updates with the provided GitHub token\n    async fn check_all_open_prs(&self) -> Result<(), PrMonitorError> {\n        let open_prs = Merge::get_open_prs(&self.db.pool).await?;\n\n        if open_prs.is_empty() {\n            debug!(\"No open PRs to check\");\n            return Ok(());\n        }\n\n        info!(\"Checking {} open PRs\", open_prs.len());\n\n        for pr_merge in open_prs {\n            if let Err(e) = self.check_pr_status(&pr_merge).await {\n                if e.is_environmental() {\n                    warn!(\n                        \"Skipping PR #{} for workspace {} due to environmental error: {}\",\n                        pr_merge.pr_info.number, pr_merge.workspace_id, e\n                    );\n                } else {\n                    error!(\n                        \"Error checking PR #{} for workspace {}: {}\",\n                        pr_merge.pr_info.number, pr_merge.workspace_id, e\n                    );\n                }\n            }\n        }\n        Ok(())\n    }\n\n    /// Check the status of a specific PR\n    async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> {\n        let git_host = GitHostService::from_url(&pr_merge.pr_info.url)?;\n        let pr_status = git_host.get_pr_status(&pr_merge.pr_info.url).await?;\n\n        debug!(\n            \"PR #{} status: {:?} (was open)\",\n            pr_merge.pr_info.number, pr_status.status\n        );\n\n        // Update the PR status in the database\n        if !matches!(&pr_status.status, MergeStatus::Open) {\n            // Update merge status with the latest information from git host\n            Merge::update_status(\n                &self.db.pool,\n                pr_merge.id,\n                pr_status.status.clone(),\n                pr_status.merge_commit_sha.clone(),\n            )\n            .await?;\n\n            self.sync_pr_to_remote(pr_merge, &pr_status.status, pr_status.merge_commit_sha)\n                .await;\n\n            // If the PR was merged, archive the workspace\n            if matches!(&pr_status.status, MergeStatus::Merged)\n                && let Some(workspace) =\n                    Workspace::find_by_id(&self.db.pool, pr_merge.workspace_id).await?\n            {\n                let open_pr_count =\n                    Merge::count_open_prs_for_workspace(&self.db.pool, workspace.id).await?;\n\n                if open_pr_count == 0 {\n                    info!(\n                        \"PR #{} was merged, archiving workspace {}\",\n                        pr_merge.pr_info.number, workspace.id\n                    );\n                    if !workspace.pinned\n                        && let Err(e) = self.container.archive_workspace(workspace.id).await\n                    {\n                        error!(\"Failed to archive workspace {}: {}\", workspace.id, e);\n                    }\n                } else {\n                    info!(\n                        \"PR #{} was merged, leaving workspace {} active with {} open PR(s)\",\n                        pr_merge.pr_info.number, workspace.id, open_pr_count\n                    );\n                }\n\n                // Track analytics event\n                if let Some(analytics) = &self.analytics {\n                    analytics.analytics_service.track_event(\n                        &analytics.user_id,\n                        \"pr_merged\",\n                        Some(json!({\n                            \"workspace_id\": workspace.id.to_string(),\n                        })),\n                    );\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Sync PR status to remote server\n    async fn sync_pr_to_remote(\n        &self,\n        pr_merge: &PrMerge,\n        status: &MergeStatus,\n        merge_commit_sha: Option<String>,\n    ) {\n        let Some(client) = &self.remote_client else {\n            return;\n        };\n\n        let pr_status = match status {\n            MergeStatus::Open => PullRequestStatus::Open,\n            MergeStatus::Merged => PullRequestStatus::Merged,\n            MergeStatus::Closed => PullRequestStatus::Closed,\n            MergeStatus::Unknown => return,\n        };\n\n        let merged_at = if matches!(status, MergeStatus::Merged) {\n            Some(Utc::now())\n        } else {\n            None\n        };\n\n        let client = client.clone();\n        let request = UpsertPullRequestRequest {\n            url: pr_merge.pr_info.url.clone(),\n            number: pr_merge.pr_info.number as i32,\n            status: pr_status,\n            merged_at,\n            merge_commit_sha,\n            target_branch_name: pr_merge.target_branch_name.clone(),\n            local_workspace_id: pr_merge.workspace_id,\n        };\n        tokio::spawn(async move {\n            remote_sync::sync_pr_to_remote(&client, request).await;\n        });\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/qa_repos.rs",
    "content": "//! QA Mode: Hardcoded repository management for testing\n//!\n//! This module provides two hardcoded QA repositories that are cloned\n//! to a persistent temp directory and returned as the only \"recent\" repos.\n\nuse std::{path::PathBuf, process::Command};\n\nuse once_cell::sync::Lazy;\nuse tracing::{info, warn};\nuse utils::command_ext::NoWindowExt;\n\nuse super::filesystem::{DirectoryEntry, FilesystemError};\n\n/// QA repository URLs and names\nconst QA_REPOS: &[(&str, &str)] = &[\n    (\"internal-qa-1\", \"https://github.com/BloopAI/internal-qa-1\"),\n    (\"internal-qa-2\", \"https://github.com/BloopAI/internal-qa-2\"),\n];\n\n/// Persistent directory for QA repos - survives server restarts\nstatic QA_REPOS_DIR: Lazy<PathBuf> = Lazy::new(|| {\n    let dir = utils::path::get_vibe_kanban_temp_dir().join(\"qa-repos\");\n    if let Err(e) = std::fs::create_dir_all(&dir) {\n        warn!(\"Failed to create QA repos directory: {}\", e);\n    }\n    info!(\"QA repos directory: {:?}\", dir);\n    dir\n});\n\n/// Get the list of QA repositories, cloning them if necessary.\n///\n/// This function is called instead of the normal filesystem git repo discovery\n/// when QA mode is enabled.\npub fn get_qa_repos() -> Result<Vec<DirectoryEntry>, FilesystemError> {\n    let base_dir = &*QA_REPOS_DIR;\n\n    // Ensure repos are cloned\n    clone_qa_repos_if_needed(base_dir);\n\n    // Build DirectoryEntry for each repo\n    let entries = QA_REPOS\n        .iter()\n        .filter_map(|(name, _url)| {\n            let repo_path = base_dir.join(name);\n            if repo_path.exists() && repo_path.join(\".git\").exists() {\n                let last_modified = std::fs::metadata(&repo_path)\n                    .ok()\n                    .and_then(|m| m.modified().ok())\n                    .map(|t| t.elapsed().unwrap_or_default().as_secs());\n\n                Some(DirectoryEntry {\n                    name: name.to_string(),\n                    path: repo_path,\n                    is_directory: true,\n                    is_git_repo: true,\n                    last_modified,\n                })\n            } else {\n                warn!(\"QA repo {} not found at {:?}\", name, repo_path);\n                None\n            }\n        })\n        .collect();\n\n    Ok(entries)\n}\n\n/// Clone QA repositories if they don't already exist\nfn clone_qa_repos_if_needed(base_dir: &std::path::Path) {\n    for (name, url) in QA_REPOS {\n        let repo_path = base_dir.join(name);\n\n        if repo_path.join(\".git\").exists() {\n            info!(\"QA repo {} already exists at {:?}\", name, repo_path);\n            continue;\n        }\n\n        info!(\"Cloning QA repo {} from {} to {:?}\", name, url, repo_path);\n\n        // Use git CLI for reliable TLS support (git2 has TLS issues)\n        let output = Command::new(\"git\")\n            .args([\"clone\", \"--depth\", \"1\", url, &repo_path.to_string_lossy()])\n            .no_window()\n            .output();\n\n        match output {\n            Ok(result) if result.status.success() => {\n                info!(\"Successfully cloned QA repo {}\", name);\n            }\n            Ok(result) => {\n                warn!(\n                    \"Failed to clone QA repo {}: {}\",\n                    name,\n                    String::from_utf8_lossy(&result.stderr)\n                );\n                // Try to clean up partial clone\n                let _ = std::fs::remove_dir_all(&repo_path);\n            }\n            Err(e) => {\n                warn!(\"Failed to run git clone for {}: {}\", name, e);\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_qa_repos_dir_is_persistent() {\n        let dir1 = &*QA_REPOS_DIR;\n        let dir2 = &*QA_REPOS_DIR;\n        assert_eq!(dir1, dir2);\n        assert!(dir1.ends_with(\"qa-repos\"));\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/queued_message.rs",
    "content": "use std::sync::Arc;\n\nuse chrono::{DateTime, Utc};\nuse dashmap::DashMap;\nuse db::models::scratch::DraftFollowUpData;\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n/// Represents a queued follow-up message for a session\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct QueuedMessage {\n    /// The session this message is queued for\n    pub session_id: Uuid,\n    /// The follow-up data (message + variant)\n    pub data: DraftFollowUpData,\n    /// Timestamp when the message was queued\n    pub queued_at: DateTime<Utc>,\n}\n\n/// Status of the queue for a session (for frontend display)\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"status\", rename_all = \"snake_case\")]\npub enum QueueStatus {\n    /// No message queued\n    Empty,\n    /// Message is queued and waiting for execution to complete\n    Queued { message: QueuedMessage },\n}\n\n/// In-memory service for managing queued follow-up messages.\n/// One queued message per session.\n#[derive(Clone)]\npub struct QueuedMessageService {\n    queue: Arc<DashMap<Uuid, QueuedMessage>>,\n}\n\nimpl QueuedMessageService {\n    pub fn new() -> Self {\n        Self {\n            queue: Arc::new(DashMap::new()),\n        }\n    }\n\n    /// Queue a message for a session. Replaces any existing queued message.\n    pub fn queue_message(&self, session_id: Uuid, data: DraftFollowUpData) -> QueuedMessage {\n        let queued = QueuedMessage {\n            session_id,\n            data,\n            queued_at: Utc::now(),\n        };\n        self.queue.insert(session_id, queued.clone());\n        queued\n    }\n\n    /// Cancel/remove a queued message for a session\n    pub fn cancel_queued(&self, session_id: Uuid) -> Option<QueuedMessage> {\n        self.queue.remove(&session_id).map(|(_, v)| v)\n    }\n\n    /// Get the queued message for a session (if any)\n    pub fn get_queued(&self, session_id: Uuid) -> Option<QueuedMessage> {\n        self.queue.get(&session_id).map(|r| r.clone())\n    }\n\n    /// Take (remove and return) the queued message for a session.\n    /// Used by finalization flow to consume the queued message.\n    pub fn take_queued(&self, session_id: Uuid) -> Option<QueuedMessage> {\n        self.queue.remove(&session_id).map(|(_, v)| v)\n    }\n\n    /// Check if a session has a queued message\n    pub fn has_queued(&self, session_id: Uuid) -> bool {\n        self.queue.contains_key(&session_id)\n    }\n\n    /// Get queue status for frontend display\n    pub fn get_status(&self, session_id: Uuid) -> QueueStatus {\n        match self.get_queued(session_id) {\n            Some(msg) => QueueStatus::Queued { message: msg },\n            None => QueueStatus::Empty,\n        }\n    }\n}\n\nimpl Default for QueuedMessageService {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/remote_client.rs",
    "content": "//! OAuth client for authorization-code handoffs with automatic retries.\n\nuse std::time::Duration;\n\nuse api_types::{\n    AcceptInvitationResponse, CreateInvitationRequest, CreateInvitationResponse,\n    CreateIssueAssigneeRequest, CreateIssueRelationshipRequest, CreateIssueRequest,\n    CreateIssueTagRequest, CreateOrganizationRequest, CreateOrganizationResponse,\n    CreateWorkspaceRequest, DeleteResponse, DeleteWorkspaceRequest, GetInvitationResponse,\n    GetOrganizationResponse, HandoffInitRequest, HandoffInitResponse, HandoffRedeemRequest,\n    HandoffRedeemResponse, Issue, IssueAssignee, IssueRelationship, IssueTag,\n    ListAttachmentsResponse, ListInvitationsResponse, ListIssueAssigneesResponse,\n    ListIssueRelationshipsResponse, ListIssueTagsResponse, ListIssuesResponse, ListMembersResponse,\n    ListOrganizationsResponse, ListProjectStatusesResponse, ListProjectsResponse,\n    ListPullRequestsResponse, ListTagsResponse, MutationResponse, Organization, ProfileResponse,\n    RevokeInvitationRequest, SearchIssuesRequest, Tag, TokenRefreshRequest, TokenRefreshResponse,\n    UpdateIssueRequest, UpdateMemberRoleRequest, UpdateMemberRoleResponse,\n    UpdateOrganizationRequest, UpdateWorkspaceRequest, UpsertPullRequestRequest, Workspace,\n};\nuse backon::{ExponentialBuilder, Retryable};\nuse chrono::Duration as ChronoDuration;\nuse reqwest::{Client, StatusCode};\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse tracing::warn;\nuse url::Url;\nuse utils::jwt::extract_expiration;\nuse uuid::Uuid;\n\nuse super::{auth::AuthContext, oauth_credentials::Credentials};\n\n#[derive(Debug, Clone, Error)]\npub enum RemoteClientError {\n    #[error(\"network error: {0}\")]\n    Transport(String),\n    #[error(\"timeout\")]\n    Timeout,\n    #[error(\"token refresh timed out\")]\n    TokenRefreshTimeout,\n    #[error(\"http {status}: {body}\")]\n    Http { status: u16, body: String },\n    #[error(\"api error: {0:?}\")]\n    Api(HandoffErrorCode),\n    #[error(\"unauthorized\")]\n    Auth,\n    #[error(\"json error: {0}\")]\n    Serde(String),\n    #[error(\"url error: {0}\")]\n    Url(String),\n    #[error(\"credentials storage error: {0}\")]\n    Storage(String),\n    #[error(\"invalid access token: {0}\")]\n    Token(String),\n}\n\nimpl RemoteClientError {\n    /// Returns true if the error is transient and should be retried.\n    pub fn should_retry(&self) -> bool {\n        match self {\n            Self::Transport(_) | Self::Timeout => true,\n            Self::Http { status, .. } => (500..=599).contains(status),\n            _ => false,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub enum HandoffErrorCode {\n    UnsupportedProvider,\n    InvalidReturnUrl,\n    InvalidChallenge,\n    ProviderError,\n    NotFound,\n    Expired,\n    AccessDenied,\n    InternalError,\n    Other(String),\n}\n\nfn map_error_code(code: Option<&str>) -> HandoffErrorCode {\n    match code.unwrap_or(\"internal_error\") {\n        \"unsupported_provider\" => HandoffErrorCode::UnsupportedProvider,\n        \"invalid_return_url\" => HandoffErrorCode::InvalidReturnUrl,\n        \"invalid_challenge\" => HandoffErrorCode::InvalidChallenge,\n        \"provider_error\" => HandoffErrorCode::ProviderError,\n        \"not_found\" => HandoffErrorCode::NotFound,\n        \"expired\" | \"expired_token\" => HandoffErrorCode::Expired,\n        \"access_denied\" => HandoffErrorCode::AccessDenied,\n        \"internal_error\" => HandoffErrorCode::InternalError,\n        other => HandoffErrorCode::Other(other.to_string()),\n    }\n}\n\n#[derive(Deserialize)]\nstruct ApiErrorResponse {\n    error: String,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct RequestTimeoutOptions {\n    timeout: Duration,\n    retry_on_timeout: bool,\n}\n\n/// HTTP client for the remote OAuth server with automatic retries.\npub struct RemoteClient {\n    base: Url,\n    http: Client,\n    auth_context: AuthContext,\n}\n\nimpl std::fmt::Debug for RemoteClient {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"RemoteClient\")\n            .field(\"base\", &self.base)\n            .field(\"http\", &self.http)\n            .field(\"auth_context\", &\"<present>\")\n            .finish()\n    }\n}\n\nimpl Clone for RemoteClient {\n    fn clone(&self) -> Self {\n        Self {\n            base: self.base.clone(),\n            http: self.http.clone(),\n            auth_context: self.auth_context.clone(),\n        }\n    }\n}\n\nimpl RemoteClient {\n    const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);\n    const TOKEN_REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_mins(5);\n    const TOKEN_REFRESH_LEEWAY_SECS: i64 = 20;\n\n    pub fn new(base_url: &str, auth_context: AuthContext) -> Result<Self, RemoteClientError> {\n        let base = Url::parse(base_url).map_err(|e| RemoteClientError::Url(e.to_string()))?;\n        let mut builder = Client::builder()\n            .timeout(Self::REQUEST_TIMEOUT)\n            .user_agent(concat!(\"remote-client/\", env!(\"CARGO_PKG_VERSION\")));\n\n        #[cfg(debug_assertions)]\n        {\n            builder = builder.danger_accept_invalid_certs(true);\n        }\n\n        let http = builder\n            .build()\n            .map_err(|e| RemoteClientError::Transport(e.to_string()))?;\n        Ok(Self {\n            base,\n            http,\n            auth_context,\n        })\n    }\n\n    /// Returns a valid access token, refreshing when it's about to expire.\n    fn require_token(\n        &self,\n    ) -> std::pin::Pin<\n        Box<dyn std::future::Future<Output = Result<String, RemoteClientError>> + Send + '_>,\n    > {\n        Box::pin(async move {\n            let leeway = ChronoDuration::seconds(Self::TOKEN_REFRESH_LEEWAY_SECS);\n            let creds = self\n                .auth_context\n                .get_credentials()\n                .await\n                .ok_or(RemoteClientError::Auth)?;\n\n            if let Some(token) = creds.access_token.as_ref()\n                && !creds.expires_soon(leeway)\n            {\n                return Ok(token.clone());\n            }\n\n            let refreshed = {\n                let _refresh_guard = self.auth_context.refresh_guard().await;\n                let latest = self\n                    .auth_context\n                    .get_credentials()\n                    .await\n                    .ok_or(RemoteClientError::Auth)?;\n                if let Some(token) = latest.access_token.as_ref()\n                    && !latest.expires_soon(leeway)\n                {\n                    return Ok(token.clone());\n                }\n\n                self.refresh_credentials(&latest).await\n            };\n\n            match refreshed {\n                Ok(updated) => updated.access_token.ok_or(RemoteClientError::Auth),\n                Err(RemoteClientError::Auth) => {\n                    let _ = self.auth_context.clear_credentials().await;\n                    Err(RemoteClientError::Auth)\n                }\n                Err(RemoteClientError::TokenRefreshTimeout) => {\n                    tracing::error!(\n                        \"Refresh token request timed out after {} minutes. Discarding the refresh token and forcing re-login.\",\n                        Self::TOKEN_REFRESH_REQUEST_TIMEOUT.as_secs() / 60\n                    );\n                    let _ = self.auth_context.clear_credentials().await;\n                    Err(RemoteClientError::TokenRefreshTimeout)\n                }\n                Err(err) => Err(err),\n            }\n        })\n    }\n\n    async fn refresh_credentials(\n        &self,\n        creds: &Credentials,\n    ) -> Result<Credentials, RemoteClientError> {\n        let response = self.refresh_token_request(&creds.refresh_token).await?;\n        let access_token = response.access_token;\n        let refresh_token = response.refresh_token;\n        let expires_at = extract_expiration(&access_token)\n            .map_err(|err| RemoteClientError::Token(err.to_string()))?;\n        let new_creds = Credentials {\n            access_token: Some(access_token),\n            refresh_token,\n            expires_at: Some(expires_at),\n        };\n        self.auth_context\n            .save_credentials(&new_creds)\n            .await\n            .map_err(|e| RemoteClientError::Storage(e.to_string()))?;\n        Ok(new_creds)\n    }\n\n    async fn refresh_token_request(\n        &self,\n        refresh_token: &str,\n    ) -> Result<TokenRefreshResponse, RemoteClientError> {\n        let request = TokenRefreshRequest {\n            refresh_token: refresh_token.to_string(),\n        };\n\n        let timeout_options = RequestTimeoutOptions {\n            timeout: Self::TOKEN_REFRESH_REQUEST_TIMEOUT,\n            retry_on_timeout: false,\n        };\n\n        self.post_public_with_timeout_options(\"/v1/tokens/refresh\", Some(&request), timeout_options)\n            .await\n            .map_err(|e| {\n                if matches!(e, RemoteClientError::Timeout) {\n                    RemoteClientError::TokenRefreshTimeout\n                } else {\n                    e\n                }\n            })\n            .map_err(|e| self.map_api_error(e))\n    }\n\n    /// Returns the base URL for the client.\n    pub fn base_url(&self) -> &str {\n        self.base.as_str()\n    }\n\n    /// Returns a valid access token for use-cases like maintaining a websocket connection.\n    pub async fn access_token(&self) -> Result<String, RemoteClientError> {\n        self.require_token().await\n    }\n\n    /// Initiates an authorization-code handoff for the given provider.\n    pub async fn handoff_init(\n        &self,\n        request: &HandoffInitRequest,\n    ) -> Result<HandoffInitResponse, RemoteClientError> {\n        self.post_public(\"/v1/oauth/web/init\", Some(request))\n            .await\n            .map_err(|e| self.map_api_error(e))\n    }\n\n    /// Redeems an application code for an access token.\n    pub async fn handoff_redeem(\n        &self,\n        request: &HandoffRedeemRequest,\n    ) -> Result<HandoffRedeemResponse, RemoteClientError> {\n        self.post_public(\"/v1/oauth/web/redeem\", Some(request))\n            .await\n            .map_err(|e| self.map_api_error(e))\n    }\n\n    /// Gets an invitation by token (public, no auth required).\n    pub async fn get_invitation(\n        &self,\n        invitation_token: &str,\n    ) -> Result<GetInvitationResponse, RemoteClientError> {\n        self.get_public(&format!(\"/v1/invitations/{invitation_token}\"))\n            .await\n    }\n\n    async fn send<B>(\n        &self,\n        method: reqwest::Method,\n        path: &str,\n        requires_auth: bool,\n        body: Option<&B>,\n    ) -> Result<reqwest::Response, RemoteClientError>\n    where\n        B: Serialize,\n    {\n        self.send_internal(method, path, requires_auth, body, None)\n            .await\n    }\n\n    async fn send_internal<B>(\n        &self,\n        method: reqwest::Method,\n        path: &str,\n        requires_auth: bool,\n        body: Option<&B>,\n        timeout_options: Option<RequestTimeoutOptions>,\n    ) -> Result<reqwest::Response, RemoteClientError>\n    where\n        B: Serialize,\n    {\n        self.send_internal_with_request(method, path, requires_auth, timeout_options, |req| {\n            if let Some(body) = body {\n                req.json(body)\n            } else {\n                req\n            }\n        })\n        .await\n    }\n\n    async fn send_internal_with_request<F>(\n        &self,\n        method: reqwest::Method,\n        path: &str,\n        requires_auth: bool,\n        timeout_options: Option<RequestTimeoutOptions>,\n        customize_request: F,\n    ) -> Result<reqwest::Response, RemoteClientError>\n    where\n        F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder,\n    {\n        let url = self\n            .base\n            .join(path)\n            .map_err(|e| RemoteClientError::Url(e.to_string()))?;\n\n        let retry_on_timeout = timeout_options.is_none_or(|o| o.retry_on_timeout);\n\n        let operation = || async {\n            let mut req = self\n                .http\n                .request(method.clone(), url.clone())\n                .header(\"X-Client-Version\", env!(\"CARGO_PKG_VERSION\"))\n                .header(\"X-Client-Type\", \"local-backend\");\n\n            if let Some(t) = timeout_options.map(|o| o.timeout) {\n                req = req.timeout(t);\n            }\n\n            if requires_auth {\n                let token = self.require_token().await?;\n                req = req.bearer_auth(token);\n            }\n\n            req = customize_request(req);\n\n            let res = req.send().await.map_err(map_reqwest_error)?;\n\n            match res.status() {\n                s if s.is_success() => Ok(res),\n                StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(RemoteClientError::Auth),\n                s => {\n                    let status = s.as_u16();\n                    let body = res.text().await.unwrap_or_default();\n                    Err(RemoteClientError::Http { status, body })\n                }\n            }\n        };\n\n        operation\n            .retry(\n                &ExponentialBuilder::default()\n                    .with_min_delay(Duration::from_millis(500))\n                    .with_max_delay(Duration::from_secs(2))\n                    .with_max_times(2)\n                    .with_jitter(),\n            )\n            .when(move |e: &RemoteClientError| {\n                if !e.should_retry() {\n                    return false;\n                }\n                retry_on_timeout || !matches!(e, RemoteClientError::Timeout)\n            })\n            .notify(|e, dur| {\n                warn!(\n                    \"Remote call failed, retrying after {:.2}s: {}\",\n                    dur.as_secs_f64(),\n                    e\n                )\n            })\n            .await\n    }\n\n    // Public endpoint helpers (no auth required)\n    async fn get_public<T>(&self, path: &str) -> Result<T, RemoteClientError>\n    where\n        T: for<'de> Deserialize<'de>,\n    {\n        let res = self\n            .send(reqwest::Method::GET, path, false, None::<&()>)\n            .await?;\n        res.json::<T>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    async fn post_public<T, B>(&self, path: &str, body: Option<&B>) -> Result<T, RemoteClientError>\n    where\n        T: for<'de> Deserialize<'de>,\n        B: Serialize,\n    {\n        let res = self.send(reqwest::Method::POST, path, false, body).await?;\n        res.json::<T>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    async fn post_public_with_timeout_options<T, B>(\n        &self,\n        path: &str,\n        body: Option<&B>,\n        timeout_options: RequestTimeoutOptions,\n    ) -> Result<T, RemoteClientError>\n    where\n        T: for<'de> Deserialize<'de>,\n        B: Serialize,\n    {\n        let res = self\n            .send_internal(\n                reqwest::Method::POST,\n                path,\n                false,\n                body,\n                Some(timeout_options),\n            )\n            .await?;\n        res.json::<T>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    // Authenticated endpoint helpers (require token)\n    async fn get_authed<T>(&self, path: &str) -> Result<T, RemoteClientError>\n    where\n        T: for<'de> Deserialize<'de>,\n    {\n        let res = self\n            .send(reqwest::Method::GET, path, true, None::<&()>)\n            .await?;\n        res.json::<T>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    pub async fn post_authed<T, B>(\n        &self,\n        path: &str,\n        body: Option<&B>,\n    ) -> Result<T, RemoteClientError>\n    where\n        T: for<'de> Deserialize<'de>,\n        B: Serialize,\n    {\n        let res = self.send(reqwest::Method::POST, path, true, body).await?;\n        res.json::<T>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    async fn patch_authed<T, B>(&self, path: &str, body: &B) -> Result<T, RemoteClientError>\n    where\n        T: for<'de> Deserialize<'de>,\n        B: Serialize,\n    {\n        let res = self\n            .send(reqwest::Method::PATCH, path, true, Some(body))\n            .await?;\n        res.json::<T>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    async fn delete_authed(&self, path: &str) -> Result<(), RemoteClientError> {\n        self.send(reqwest::Method::DELETE, path, true, None::<&()>)\n            .await?;\n        Ok(())\n    }\n\n    async fn delete_authed_with_body<B>(\n        &self,\n        path: &str,\n        body: &B,\n    ) -> Result<(), RemoteClientError>\n    where\n        B: Serialize,\n    {\n        self.send(reqwest::Method::DELETE, path, true, Some(body))\n            .await?;\n        Ok(())\n    }\n\n    fn map_api_error(&self, err: RemoteClientError) -> RemoteClientError {\n        if let RemoteClientError::Http { body, .. } = &err\n            && let Ok(api_err) = serde_json::from_str::<ApiErrorResponse>(body)\n        {\n            return RemoteClientError::Api(map_error_code(Some(&api_err.error)));\n        }\n        err\n    }\n\n    /// Fetches user profile.\n    pub async fn profile(&self) -> Result<ProfileResponse, RemoteClientError> {\n        self.get_authed(\"/v1/profile\").await\n    }\n\n    /// Revokes the session associated with the token.\n    pub async fn logout(&self) -> Result<(), RemoteClientError> {\n        self.delete_authed(\"/v1/oauth/logout\").await\n    }\n\n    /// Lists organizations for the authenticated user.\n    pub async fn list_organizations(&self) -> Result<ListOrganizationsResponse, RemoteClientError> {\n        self.get_authed(\"/v1/organizations\").await\n    }\n\n    /// Gets a specific organization by ID.\n    pub async fn get_organization(\n        &self,\n        org_id: Uuid,\n    ) -> Result<GetOrganizationResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/organizations/{org_id}\"))\n            .await\n    }\n\n    /// Creates a new organization.\n    pub async fn create_organization(\n        &self,\n        request: &CreateOrganizationRequest,\n    ) -> Result<CreateOrganizationResponse, RemoteClientError> {\n        self.post_authed(\"/v1/organizations\", Some(request)).await\n    }\n\n    /// Updates an organization's name.\n    pub async fn update_organization(\n        &self,\n        org_id: Uuid,\n        request: &UpdateOrganizationRequest,\n    ) -> Result<Organization, RemoteClientError> {\n        self.patch_authed(&format!(\"/v1/organizations/{org_id}\"), request)\n            .await\n    }\n\n    /// Deletes an organization.\n    pub async fn delete_organization(&self, org_id: Uuid) -> Result<(), RemoteClientError> {\n        self.delete_authed(&format!(\"/v1/organizations/{org_id}\"))\n            .await\n    }\n\n    /// Creates an invitation to an organization.\n    pub async fn create_invitation(\n        &self,\n        org_id: Uuid,\n        request: &CreateInvitationRequest,\n    ) -> Result<CreateInvitationResponse, RemoteClientError> {\n        self.post_authed(\n            &format!(\"/v1/organizations/{org_id}/invitations\"),\n            Some(request),\n        )\n        .await\n    }\n\n    /// Lists invitations for an organization.\n    pub async fn list_invitations(\n        &self,\n        org_id: Uuid,\n    ) -> Result<ListInvitationsResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/organizations/{org_id}/invitations\"))\n            .await\n    }\n\n    pub async fn revoke_invitation(\n        &self,\n        org_id: Uuid,\n        invitation_id: Uuid,\n    ) -> Result<(), RemoteClientError> {\n        let body = RevokeInvitationRequest { invitation_id };\n        self.send(\n            reqwest::Method::POST,\n            &format!(\"/v1/organizations/{org_id}/invitations/revoke\"),\n            true,\n            Some(&body),\n        )\n        .await?;\n        Ok(())\n    }\n\n    /// Accepts an invitation.\n    pub async fn accept_invitation(\n        &self,\n        invitation_token: &str,\n    ) -> Result<AcceptInvitationResponse, RemoteClientError> {\n        self.post_authed(\n            &format!(\"/v1/invitations/{invitation_token}/accept\"),\n            None::<&()>,\n        )\n        .await\n    }\n\n    /// Lists members of an organization.\n    pub async fn list_members(\n        &self,\n        org_id: Uuid,\n    ) -> Result<ListMembersResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/organizations/{org_id}/members\"))\n            .await\n    }\n\n    /// Removes a member from an organization.\n    pub async fn remove_member(\n        &self,\n        org_id: Uuid,\n        user_id: Uuid,\n    ) -> Result<(), RemoteClientError> {\n        self.delete_authed(&format!(\"/v1/organizations/{org_id}/members/{user_id}\"))\n            .await\n    }\n\n    /// Updates a member's role in an organization.\n    pub async fn update_member_role(\n        &self,\n        org_id: Uuid,\n        user_id: Uuid,\n        request: &UpdateMemberRoleRequest,\n    ) -> Result<UpdateMemberRoleResponse, RemoteClientError> {\n        self.patch_authed(\n            &format!(\"/v1/organizations/{org_id}/members/{user_id}/role\"),\n            request,\n        )\n        .await\n    }\n\n    /// Deletes a workspace on the remote server by its local workspace ID.\n    pub async fn delete_workspace(\n        &self,\n        local_workspace_id: Uuid,\n    ) -> Result<(), RemoteClientError> {\n        self.delete_authed_with_body(\n            \"/v1/workspaces\",\n            &DeleteWorkspaceRequest { local_workspace_id },\n        )\n        .await\n    }\n\n    /// Gets a workspace from the remote server by its local workspace ID.\n    pub async fn get_workspace_by_local_id(\n        &self,\n        local_workspace_id: Uuid,\n    ) -> Result<Workspace, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/workspaces/by-local-id/{local_workspace_id}\"))\n            .await\n    }\n\n    /// Checks if a workspace exists on the remote server.\n    pub async fn workspace_exists(\n        &self,\n        local_workspace_id: Uuid,\n    ) -> Result<bool, RemoteClientError> {\n        match self\n            .send(\n                reqwest::Method::HEAD,\n                &format!(\"/v1/workspaces/exists/{local_workspace_id}\"),\n                true,\n                None::<&()>,\n            )\n            .await\n        {\n            Ok(_) => Ok(true),\n            Err(RemoteClientError::Http { status: 404, .. }) => Ok(false),\n            Err(e) => Err(e),\n        }\n    }\n\n    /// Updates a workspace on the remote server.\n    pub async fn update_workspace(\n        &self,\n        local_workspace_id: Uuid,\n        name: Option<Option<String>>,\n        archived: Option<bool>,\n        files_changed: Option<i32>,\n        lines_added: Option<i32>,\n        lines_removed: Option<i32>,\n    ) -> Result<(), RemoteClientError> {\n        self.send(\n            reqwest::Method::PATCH,\n            \"/v1/workspaces\",\n            true,\n            Some(&UpdateWorkspaceRequest {\n                local_workspace_id,\n                name,\n                archived,\n                files_changed: files_changed.map(Some),\n                lines_added: lines_added.map(Some),\n                lines_removed: lines_removed.map(Some),\n            }),\n        )\n        .await?;\n        Ok(())\n    }\n\n    /// Triggers issue-status sync for a workspace that was merged locally without a PR.\n    pub async fn sync_issue_status_from_local_workspace_merge(\n        &self,\n        local_workspace_id: Uuid,\n    ) -> Result<(), RemoteClientError> {\n        self.send(\n            reqwest::Method::POST,\n            &format!(\"/v1/workspaces/{local_workspace_id}/sync_issue_status_from_local_merge\"),\n            true,\n            None::<&()>,\n        )\n        .await?;\n        Ok(())\n    }\n\n    /// Creates a workspace on the remote server, linking it to a local workspace and an issue.\n    pub async fn create_workspace(\n        &self,\n        request: CreateWorkspaceRequest,\n    ) -> Result<(), RemoteClientError> {\n        self.send(\n            reqwest::Method::POST,\n            \"/v1/workspaces\",\n            true,\n            Some(&request),\n        )\n        .await?;\n        Ok(())\n    }\n\n    // ── Issues ──────────────────────────────────────────────────────────\n\n    /// Lists issues for a project.\n    pub async fn list_issues(\n        &self,\n        project_id: Uuid,\n    ) -> Result<ListIssuesResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issues?project_id={project_id}\"))\n            .await\n    }\n\n    /// Searches issues for a project using the canonical JSON request shape.\n    pub async fn search_issues(\n        &self,\n        request: &SearchIssuesRequest,\n    ) -> Result<ListIssuesResponse, RemoteClientError> {\n        self.post_authed(\"/v1/issues/search\", Some(request)).await\n    }\n\n    /// Gets a single issue by ID.\n    pub async fn get_issue(&self, issue_id: Uuid) -> Result<Issue, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issues/{issue_id}\")).await\n    }\n\n    /// Creates a new issue.\n    pub async fn create_issue(\n        &self,\n        request: &CreateIssueRequest,\n    ) -> Result<MutationResponse<Issue>, RemoteClientError> {\n        self.post_authed(\"/v1/issues\", Some(request)).await\n    }\n\n    /// Updates an existing issue.\n    pub async fn update_issue(\n        &self,\n        issue_id: Uuid,\n        request: &UpdateIssueRequest,\n    ) -> Result<MutationResponse<Issue>, RemoteClientError> {\n        self.patch_authed(&format!(\"/v1/issues/{issue_id}\"), request)\n            .await\n    }\n\n    /// Deletes an issue.\n    pub async fn delete_issue(&self, issue_id: Uuid) -> Result<DeleteResponse, RemoteClientError> {\n        let res = self\n            .send(\n                reqwest::Method::DELETE,\n                &format!(\"/v1/issues/{issue_id}\"),\n                true,\n                None::<&()>,\n            )\n            .await?;\n        res.json::<DeleteResponse>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    // ── Issue Assignees ────────────────────────────────────────────────\n\n    /// Lists assignees for an issue.\n    pub async fn list_issue_assignees(\n        &self,\n        issue_id: Uuid,\n    ) -> Result<ListIssueAssigneesResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issue_assignees?issue_id={issue_id}\"))\n            .await\n    }\n\n    /// Gets a single issue assignee by ID.\n    pub async fn get_issue_assignee(\n        &self,\n        issue_assignee_id: Uuid,\n    ) -> Result<IssueAssignee, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issue_assignees/{issue_assignee_id}\"))\n            .await\n    }\n\n    /// Creates a new issue assignee.\n    pub async fn create_issue_assignee(\n        &self,\n        request: &CreateIssueAssigneeRequest,\n    ) -> Result<MutationResponse<IssueAssignee>, RemoteClientError> {\n        self.post_authed(\"/v1/issue_assignees\", Some(request)).await\n    }\n\n    /// Deletes an issue assignee.\n    pub async fn delete_issue_assignee(\n        &self,\n        issue_assignee_id: Uuid,\n    ) -> Result<DeleteResponse, RemoteClientError> {\n        let res = self\n            .send(\n                reqwest::Method::DELETE,\n                &format!(\"/v1/issue_assignees/{issue_assignee_id}\"),\n                true,\n                None::<&()>,\n            )\n            .await?;\n        res.json::<DeleteResponse>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    // ── Tags ───────────────────────────────────────────────────────────\n\n    /// Lists tags for a project.\n    pub async fn list_tags(&self, project_id: Uuid) -> Result<ListTagsResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/tags?project_id={project_id}\"))\n            .await\n    }\n\n    /// Gets a single tag by ID.\n    pub async fn get_tag(&self, tag_id: Uuid) -> Result<Tag, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/tags/{tag_id}\")).await\n    }\n\n    // ── Issue Tags ─────────────────────────────────────────────────────\n\n    /// Lists tags attached to an issue.\n    pub async fn list_issue_tags(\n        &self,\n        issue_id: Uuid,\n    ) -> Result<ListIssueTagsResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issue_tags?issue_id={issue_id}\"))\n            .await\n    }\n\n    /// Gets a single issue-tag relation by ID.\n    pub async fn get_issue_tag(&self, issue_tag_id: Uuid) -> Result<IssueTag, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issue_tags/{issue_tag_id}\"))\n            .await\n    }\n\n    /// Attaches a tag to an issue.\n    pub async fn create_issue_tag(\n        &self,\n        request: &CreateIssueTagRequest,\n    ) -> Result<MutationResponse<IssueTag>, RemoteClientError> {\n        self.post_authed(\"/v1/issue_tags\", Some(request)).await\n    }\n\n    /// Removes a tag from an issue.\n    pub async fn delete_issue_tag(\n        &self,\n        issue_tag_id: Uuid,\n    ) -> Result<DeleteResponse, RemoteClientError> {\n        let res = self\n            .send(\n                reqwest::Method::DELETE,\n                &format!(\"/v1/issue_tags/{issue_tag_id}\"),\n                true,\n                None::<&()>,\n            )\n            .await?;\n        res.json::<DeleteResponse>()\n            .await\n            .map_err(|e| RemoteClientError::Serde(e.to_string()))\n    }\n\n    // ── Issue Relationships ────────────────────────────────────────────\n\n    /// Lists relationships for an issue.\n    pub async fn list_issue_relationships(\n        &self,\n        issue_id: Uuid,\n    ) -> Result<ListIssueRelationshipsResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issue_relationships?issue_id={issue_id}\"))\n            .await\n    }\n\n    /// Creates a new issue relationship.\n    pub async fn create_issue_relationship(\n        &self,\n        request: &CreateIssueRelationshipRequest,\n    ) -> Result<MutationResponse<IssueRelationship>, RemoteClientError> {\n        self.post_authed(\"/v1/issue_relationships\", Some(request))\n            .await\n    }\n\n    /// Deletes an issue relationship.\n    pub async fn delete_issue_relationship(\n        &self,\n        relationship_id: Uuid,\n    ) -> Result<(), RemoteClientError> {\n        self.delete_authed(&format!(\"/v1/issue_relationships/{relationship_id}\"))\n            .await\n    }\n\n    // ── Remote Projects ─────────────────────────────────────────────────\n\n    /// Gets a single remote project by ID.\n    pub async fn get_remote_project(\n        &self,\n        project_id: Uuid,\n    ) -> Result<api_types::Project, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/projects/{project_id}\")).await\n    }\n\n    /// Lists projects for an organization.\n    pub async fn list_remote_projects(\n        &self,\n        organization_id: Uuid,\n    ) -> Result<ListProjectsResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/projects?organization_id={organization_id}\"))\n            .await\n    }\n\n    // ── Project Statuses ────────────────────────────────────────────────\n\n    /// Lists project statuses for a project (used for status name ↔ UUID mapping).\n    pub async fn list_project_statuses(\n        &self,\n        project_id: Uuid,\n    ) -> Result<ListProjectStatusesResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/project_statuses?project_id={project_id}\"))\n            .await\n    }\n\n    // ── Pull Requests ───────────────────────────────────────────────────\n\n    /// Upserts a pull request on the remote server.\n    /// Creates if not exists, updates if exists.\n    pub async fn upsert_pull_request(\n        &self,\n        request: UpsertPullRequestRequest,\n    ) -> Result<(), RemoteClientError> {\n        self.send(\n            reqwest::Method::PUT,\n            \"/v1/pull_requests\",\n            true,\n            Some(&request),\n        )\n        .await?;\n        Ok(())\n    }\n\n    /// Lists pull requests linked to an issue.\n    pub async fn list_pull_requests(\n        &self,\n        issue_id: Uuid,\n    ) -> Result<ListPullRequestsResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/pull_requests?issue_id={issue_id}\"))\n            .await\n    }\n\n    /// Lists attachments for an issue on the remote server.\n    pub async fn list_issue_attachments(\n        &self,\n        issue_id: Uuid,\n    ) -> Result<ListAttachmentsResponse, RemoteClientError> {\n        self.get_authed(&format!(\"/v1/issues/{issue_id}/attachments\"))\n            .await\n    }\n\n    /// Used for fetching from presigned Azure SAS URLs.\n    pub async fn download_from_url(&self, url: &str) -> Result<Vec<u8>, RemoteClientError> {\n        let res = self.http.get(url).send().await.map_err(map_reqwest_error)?;\n        if !res.status().is_success() {\n            return Err(RemoteClientError::Http {\n                status: res.status().as_u16(),\n                body: res.text().await.unwrap_or_default(),\n            });\n        }\n        let bytes = res\n            .bytes()\n            .await\n            .map_err(|e| RemoteClientError::Transport(e.to_string()))?;\n        Ok(bytes.to_vec())\n    }\n}\n\nfn map_reqwest_error(e: reqwest::Error) -> RemoteClientError {\n    if e.is_timeout() {\n        RemoteClientError::Timeout\n    } else {\n        RemoteClientError::Transport(e.to_string())\n    }\n}\n"
  },
  {
    "path": "crates/services/src/services/remote_sync.rs",
    "content": "use std::collections::HashSet;\n\nuse api_types::{PullRequestStatus, UpsertPullRequestRequest};\nuse db::models::{\n    merge::{Merge, MergeStatus},\n    workspace::Workspace,\n};\nuse git::GitService;\nuse sqlx::SqlitePool;\nuse tracing::{debug, error};\nuse uuid::Uuid;\n\nuse super::{\n    diff_stream::{self, DiffStats},\n    remote_client::{RemoteClient, RemoteClientError},\n};\n\nasync fn update_workspace_on_remote(\n    client: &RemoteClient,\n    workspace_id: Uuid,\n    name: Option<Option<String>>,\n    archived: Option<bool>,\n    stats: Option<&DiffStats>,\n) {\n    match client\n        .update_workspace(\n            workspace_id,\n            name,\n            archived,\n            stats.map(|s| s.files_changed as i32),\n            stats.map(|s| s.lines_added as i32),\n            stats.map(|s| s.lines_removed as i32),\n        )\n        .await\n    {\n        Ok(()) => {\n            debug!(\"Synced workspace {} to remote\", workspace_id);\n        }\n        Err(RemoteClientError::Auth) => {\n            debug!(\"Workspace {} sync skipped: not authenticated\", workspace_id);\n        }\n        Err(RemoteClientError::Http { status: 404, .. }) => {\n            debug!(\n                \"Workspace {} disappeared from remote before update, skipping sync\",\n                workspace_id\n            );\n        }\n        Err(e) => {\n            error!(\"Failed to sync workspace {} to remote: {}\", workspace_id, e);\n        }\n    }\n}\n\n/// Syncs workspace data to the remote server.\n/// First checks if the workspace exists on remote, then updates if it does.\npub async fn sync_workspace_to_remote(\n    client: &RemoteClient,\n    workspace_id: Uuid,\n    name: Option<Option<String>>,\n    archived: Option<bool>,\n    stats: Option<&DiffStats>,\n) {\n    // First check if workspace exists on remote\n    match client.workspace_exists(workspace_id).await {\n        Ok(false) => {\n            debug!(\n                \"Workspace {} not found on remote, skipping sync\",\n                workspace_id\n            );\n            return;\n        }\n        Err(RemoteClientError::Auth) => {\n            debug!(\"Workspace {} sync skipped: not authenticated\", workspace_id);\n            return;\n        }\n        Err(e) => {\n            error!(\n                \"Failed to check workspace {} existence on remote: {}\",\n                workspace_id, e\n            );\n            return;\n        }\n        Ok(true) => {}\n    }\n\n    // Workspace exists, proceed with update\n    update_workspace_on_remote(client, workspace_id, name, archived, stats).await;\n}\n\n/// Syncs issue status to remote for a workspace merged locally without a PR.\npub async fn sync_local_workspace_merge_to_remote(client: &RemoteClient, workspace_id: Uuid) {\n    match client\n        .sync_issue_status_from_local_workspace_merge(workspace_id)\n        .await\n    {\n        Ok(()) => {\n            debug!(\n                \"Synced local workspace merge status to remote for workspace {}\",\n                workspace_id\n            );\n        }\n        Err(RemoteClientError::Auth) => {\n            debug!(\n                \"Local workspace merge sync skipped for workspace {}: not authenticated\",\n                workspace_id\n            );\n        }\n        Err(RemoteClientError::Http { status: 404, .. }) => {\n            debug!(\n                \"Local workspace merge sync skipped for workspace {}: workspace not found on remote\",\n                workspace_id\n            );\n        }\n        Err(e) => {\n            error!(\n                \"Failed to sync local workspace merge status for workspace {}: {}\",\n                workspace_id, e\n            );\n        }\n    }\n}\n\nasync fn upsert_pr_on_remote(client: &RemoteClient, request: UpsertPullRequestRequest) {\n    let number = request.number;\n    let workspace_id = request.local_workspace_id;\n\n    // Workspace exists, proceed with PR upsert\n    match client.upsert_pull_request(request).await {\n        Ok(()) => {\n            debug!(\"Synced PR #{} to remote\", number);\n        }\n        Err(RemoteClientError::Auth) => {\n            debug!(\"PR #{} sync skipped: not authenticated\", number);\n        }\n        Err(RemoteClientError::Http { status: 404, .. }) => {\n            debug!(\n                \"PR #{} workspace {} not found on remote, skipping sync\",\n                number, workspace_id\n            );\n        }\n        Err(e) => {\n            error!(\"Failed to sync PR #{} to remote: {}\", number, e);\n        }\n    }\n}\n\n/// Syncs PR data to the remote server.\n/// First checks if the workspace exists on remote, then upserts the PR if it does.\npub async fn sync_pr_to_remote(client: &RemoteClient, request: UpsertPullRequestRequest) {\n    // First check if workspace exists on remote\n    match client.workspace_exists(request.local_workspace_id).await {\n        Ok(false) => {\n            debug!(\n                \"PR #{} workspace {} not found on remote, skipping sync\",\n                request.number, request.local_workspace_id\n            );\n            return;\n        }\n        Err(RemoteClientError::Auth) => {\n            debug!(\"PR #{} sync skipped: not authenticated\", request.number);\n            return;\n        }\n        Err(e) => {\n            error!(\n                \"Failed to check workspace {} existence on remote: {}\",\n                request.local_workspace_id, e\n            );\n            return;\n        }\n        Ok(true) => {}\n    }\n\n    upsert_pr_on_remote(client, request).await;\n}\n\nfn map_pr_status(status: &MergeStatus) -> PullRequestStatus {\n    match status {\n        MergeStatus::Open => PullRequestStatus::Open,\n        MergeStatus::Merged => PullRequestStatus::Merged,\n        MergeStatus::Closed => PullRequestStatus::Closed,\n        MergeStatus::Unknown => PullRequestStatus::Open,\n    }\n}\n\n/// Syncs all linked workspaces and their PRs to the remote server.\n/// Used after login to catch up on any changes made while logged out.\npub async fn sync_all_linked_workspaces(\n    client: &RemoteClient,\n    pool: &SqlitePool,\n    git: &GitService,\n) {\n    // Sync workspace stats\n    let workspaces = match Workspace::fetch_all(pool).await {\n        Ok(ws) => ws,\n        Err(e) => {\n            error!(\"Failed to fetch workspaces for post-login sync: {}\", e);\n            return;\n        }\n    };\n\n    let mut linked_workspace_ids = HashSet::new();\n\n    for workspace in &workspaces {\n        match client.workspace_exists(workspace.id).await {\n            Ok(true) => {\n                linked_workspace_ids.insert(workspace.id);\n            }\n            Ok(false) => {\n                debug!(\n                    \"Workspace {} not found on remote, skipping post-login sync\",\n                    workspace.id\n                );\n                continue;\n            }\n            Err(RemoteClientError::Auth) => {\n                debug!(\"Post-login workspace sync skipped: not authenticated\");\n                return;\n            }\n            Err(e) => {\n                error!(\n                    \"Failed to check workspace {} existence on remote during post-login sync: {}\",\n                    workspace.id, e\n                );\n                continue;\n            }\n        }\n\n        let stats = diff_stream::compute_diff_stats(pool, git, workspace).await;\n        update_workspace_on_remote(\n            client,\n            workspace.id,\n            workspace.name.clone().map(Some),\n            Some(workspace.archived),\n            stats.as_ref(),\n        )\n        .await;\n    }\n\n    if linked_workspace_ids.is_empty() {\n        debug!(\"Post-login workspace sync completed: no linked workspaces found\");\n        return;\n    }\n\n    // Sync all PR data\n    let pr_merges = match Merge::find_all_pr(pool).await {\n        Ok(prs) => prs,\n        Err(e) => {\n            error!(\"Failed to fetch PR merges for post-login sync: {}\", e);\n            return;\n        }\n    };\n\n    for pr_merge in pr_merges {\n        if !linked_workspace_ids.contains(&pr_merge.workspace_id) {\n            continue;\n        }\n\n        upsert_pr_on_remote(\n            client,\n            UpsertPullRequestRequest {\n                url: pr_merge.pr_info.url,\n                number: pr_merge.pr_info.number as i32,\n                status: map_pr_status(&pr_merge.pr_info.status),\n                merged_at: pr_merge.pr_info.merged_at,\n                merge_commit_sha: pr_merge.pr_info.merge_commit_sha,\n                target_branch_name: pr_merge.target_branch_name,\n                local_workspace_id: pr_merge.workspace_id,\n            },\n        )\n        .await;\n    }\n\n    debug!(\"Post-login workspace sync completed\");\n}\n"
  },
  {
    "path": "crates/services/src/services/repo.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse db::models::repo::{Repo as RepoModel, SearchMatchType, SearchResult};\nuse git::{GitService, GitServiceError};\nuse sqlx::SqlitePool;\nuse thiserror::Error;\nuse utils::path::expand_tilde;\nuse uuid::Uuid;\n\nuse super::file_search::{FileSearchCache, SearchQuery};\n\n#[derive(Debug, Error)]\npub enum RepoError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(\"Path does not exist: {0}\")]\n    PathNotFound(PathBuf),\n    #[error(\"Path is not a directory: {0}\")]\n    PathNotDirectory(PathBuf),\n    #[error(\"Path is not a git repository: {0}\")]\n    NotGitRepository(PathBuf),\n    #[error(\"Repository not found\")]\n    NotFound,\n    #[error(\"Directory already exists: {0}\")]\n    DirectoryAlreadyExists(PathBuf),\n    #[error(\"Git error: {0}\")]\n    Git(#[from] GitServiceError),\n    #[error(\"Invalid folder name: {0}\")]\n    InvalidFolderName(String),\n}\n\npub type Result<T> = std::result::Result<T, RepoError>;\n\n#[derive(Clone, Default)]\npub struct RepoService;\n\nimpl RepoService {\n    pub fn new() -> Self {\n        Self\n    }\n\n    pub fn validate_git_repo_path(&self, path: &Path) -> Result<()> {\n        if !path.exists() {\n            return Err(RepoError::PathNotFound(path.to_path_buf()));\n        }\n\n        if !path.is_dir() {\n            return Err(RepoError::PathNotDirectory(path.to_path_buf()));\n        }\n\n        if !path.join(\".git\").exists() {\n            return Err(RepoError::NotGitRepository(path.to_path_buf()));\n        }\n\n        Ok(())\n    }\n\n    pub fn normalize_path(&self, path: &str) -> std::io::Result<PathBuf> {\n        std::path::absolute(expand_tilde(path))\n    }\n\n    pub async fn register(\n        &self,\n        pool: &SqlitePool,\n        path: &str,\n        display_name: Option<&str>,\n    ) -> Result<RepoModel> {\n        let normalized_path = self.normalize_path(path)?;\n        self.validate_git_repo_path(&normalized_path)?;\n\n        let name = normalized_path\n            .file_name()\n            .map(|n| n.to_string_lossy().to_string())\n            .unwrap_or_else(|| \"unnamed\".to_string());\n\n        let display_name = display_name.unwrap_or(&name);\n\n        let repo = RepoModel::find_or_create(pool, &normalized_path, display_name).await?;\n        Ok(repo)\n    }\n\n    pub async fn find_by_id(&self, pool: &SqlitePool, repo_id: Uuid) -> Result<Option<RepoModel>> {\n        let repo = RepoModel::find_by_id(pool, repo_id).await?;\n        Ok(repo)\n    }\n\n    pub async fn get_by_id(&self, pool: &SqlitePool, repo_id: Uuid) -> Result<RepoModel> {\n        self.find_by_id(pool, repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)\n    }\n\n    pub async fn init_repo(\n        &self,\n        pool: &SqlitePool,\n        git: &GitService,\n        parent_path: &str,\n        folder_name: &str,\n    ) -> Result<RepoModel> {\n        if folder_name.is_empty()\n            || folder_name.contains('/')\n            || folder_name.contains('\\\\')\n            || folder_name == \".\"\n            || folder_name == \"..\"\n        {\n            return Err(RepoError::InvalidFolderName(folder_name.to_string()));\n        }\n\n        let normalized_parent = self.normalize_path(parent_path)?;\n        if !normalized_parent.exists() {\n            return Err(RepoError::PathNotFound(normalized_parent));\n        }\n        if !normalized_parent.is_dir() {\n            return Err(RepoError::PathNotDirectory(normalized_parent));\n        }\n\n        let repo_path = normalized_parent.join(folder_name);\n        if repo_path.exists() {\n            return Err(RepoError::DirectoryAlreadyExists(repo_path));\n        }\n\n        git.initialize_repo_with_main_branch(&repo_path)?;\n\n        let repo = RepoModel::find_or_create(pool, &repo_path, folder_name).await?;\n        Ok(repo)\n    }\n\n    pub async fn search_files(\n        &self,\n        cache: &FileSearchCache,\n        repositories: &[RepoModel],\n        query: &SearchQuery,\n    ) -> Result<Vec<SearchResult>> {\n        let query_str = query.q.trim();\n        if query_str.is_empty() || repositories.is_empty() {\n            return Ok(vec![]);\n        }\n\n        // Search in parallel and prefix paths with repo name\n        let search_futures: Vec<_> = repositories\n            .iter()\n            .map(|repo| {\n                let repo_name = repo.name.clone();\n                let repo_path = repo.path.clone();\n                let mode = query.mode.clone();\n                let query_str = query_str.to_string();\n                async move {\n                    let results = cache\n                        .search_repo(&repo_path, &query_str, mode)\n                        .await\n                        .unwrap_or_else(|e| {\n                            tracing::warn!(\"Search failed for repo {}: {}\", repo_name, e);\n                            vec![]\n                        });\n                    (repo_name, results)\n                }\n            })\n            .collect();\n\n        let repo_results = futures::future::join_all(search_futures).await;\n\n        let mut all_results: Vec<SearchResult> = repo_results\n            .into_iter()\n            .flat_map(|(repo_name, results)| {\n                results.into_iter().map(move |r| SearchResult {\n                    path: format!(\"{}/{}\", repo_name, r.path),\n                    is_file: r.is_file,\n                    match_type: r.match_type.clone(),\n                    score: r.score,\n                })\n            })\n            .collect();\n\n        all_results.sort_by(|a, b| {\n            let priority = |m: &SearchMatchType| match m {\n                SearchMatchType::FileName => 0,\n                SearchMatchType::DirectoryName => 1,\n                SearchMatchType::FullPath => 2,\n            };\n            priority(&a.match_type)\n                .cmp(&priority(&b.match_type))\n                .then_with(|| b.score.cmp(&a.score)) // Higher scores first\n        });\n\n        all_results.truncate(10);\n        Ok(all_results)\n    }\n}\n"
  },
  {
    "path": "crates/services/tests/filesystem_repo_discovery.rs",
    "content": "#[cfg(test)]\nmod filesystem_tests {\n    use std::{fs, path::Path};\n\n    use services::services::filesystem::FilesystemService;\n    use tempfile::TempDir;\n\n    /// Helper function to create a directory structure\n    fn create_dir_structure(base: &Path, path: &str) {\n        let full_path = base.join(path);\n        if let Some(parent) = full_path.parent() {\n            fs::create_dir_all(parent).unwrap();\n        }\n        fs::create_dir_all(&full_path).unwrap();\n    }\n\n    /// Helper function to create a git repository (just creates .git directory)\n    fn create_git_repo(base: &Path, path: &str) {\n        create_dir_structure(base, path);\n        let git_dir = base.join(path).join(\".git\");\n        fs::create_dir_all(&git_dir).unwrap();\n    }\n\n    #[tokio::test]\n    async fn test_list_git_repos_discovers_repos() {\n        let temp_dir = TempDir::new().unwrap();\n        let base_path = temp_dir.path();\n\n        // Create test structure:\n        // temp_dir/\n        //   ├── project1/ (.git)\n        //   ├── project2/ (.git)\n        //   ├── regular_folder/\n        //   └── nested/\n        //       └── deep_repo/ (.git)\n        create_git_repo(base_path, \"project1\");\n        create_git_repo(base_path, \"project2\");\n        create_dir_structure(base_path, \"regular_folder\");\n        let nested_path = base_path.join(\"nested\");\n        fs::create_dir_all(&nested_path).unwrap();\n        create_git_repo(&nested_path, \"deep_repo\");\n\n        let filesystem_service = FilesystemService::new();\n\n        // Test discovering repos with reasonable timeouts\n        let repos = filesystem_service\n            .list_git_repos(\n                Some(base_path.to_string_lossy().to_string()),\n                5000,    // 5 second timeout\n                10000,   // 10 second hard timeout\n                Some(3), // max depth 3\n            )\n            .await\n            .unwrap();\n\n        // Verify we found the git repositories\n        let repo_names: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();\n\n        assert!(repo_names.contains(&\"project1\".to_string()));\n        assert!(repo_names.contains(&\"project2\".to_string()));\n        assert!(repo_names.contains(&\"deep_repo\".to_string()));\n        assert!(!repo_names.contains(&\"regular_folder\".to_string()));\n\n        // Verify all discovered entries are marked as git repos\n        for repo in &repos {\n            assert!(repo.is_git_repo);\n            assert!(repo.is_directory);\n        }\n    }\n\n    #[tokio::test]\n    async fn test_list_git_repos_respects_skip_directories() {\n        let temp_dir = TempDir::new().unwrap();\n        let base_path = temp_dir.path();\n\n        // Create repos in directories that should be skipped\n        create_git_repo(base_path, \"node_modules/some_repo\");\n        create_git_repo(base_path, \"target/debug_repo\");\n        create_git_repo(base_path, \"build/build_repo\");\n\n        // Create repos that should be found\n        create_git_repo(base_path, \"src_repo\");\n        create_git_repo(base_path, \"my_project\");\n\n        let filesystem_service = FilesystemService::new();\n\n        let repos = filesystem_service\n            .list_git_repos(\n                Some(base_path.to_string_lossy().to_string()),\n                5000,\n                10000,\n                Some(3),\n            )\n            .await\n            .unwrap();\n\n        let repo_names: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();\n\n        // Should find the valid repos\n        assert!(repo_names.contains(&\"src_repo\".to_string()));\n        assert!(repo_names.contains(&\"my_project\".to_string()));\n\n        // Should skip repos in ignored directories\n        assert!(!repo_names.contains(&\"some_repo\".to_string()));\n        assert!(!repo_names.contains(&\"debug_repo\".to_string()));\n        assert!(!repo_names.contains(&\"build_repo\".to_string()));\n    }\n\n    #[tokio::test]\n    async fn test_list_git_repos_empty_directory() {\n        let temp_dir = TempDir::new().unwrap();\n        let base_path = temp_dir.path();\n\n        // Create empty directory with no git repos\n        create_dir_structure(base_path, \"empty_folder\");\n\n        let filesystem_service = FilesystemService::new();\n\n        let repos = filesystem_service\n            .list_git_repos(\n                Some(base_path.to_string_lossy().to_string()),\n                5000,\n                10000,\n                Some(2),\n            )\n            .await\n            .unwrap();\n\n        // Should return empty list\n        assert!(repos.is_empty());\n    }\n\n    #[tokio::test]\n    async fn test_list_git_repos_nonexistent_path() {\n        let filesystem_service = FilesystemService::new();\n\n        let result = filesystem_service\n            .list_git_repos(\n                Some(\"/nonexistent/path/that/does/not/exist\".to_string()),\n                1000,\n                2000,\n                Some(2),\n            )\n            .await;\n\n        // Should return an error for non-existent path\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_list_git_repos_with_max_depth_limit() {\n        let temp_dir = TempDir::new().unwrap();\n        let base_path = temp_dir.path();\n\n        // Create nested structure deeper than max depth\n        let deep_path = base_path.join(\"level1\").join(\"level2\").join(\"level3\");\n        fs::create_dir_all(&deep_path).unwrap();\n        create_git_repo(&deep_path, \"deep_repo\");\n        create_git_repo(base_path, \"shallow_repo\");\n\n        let filesystem_service = FilesystemService::new();\n\n        // Search with depth limit of 2\n        let repos = filesystem_service\n            .list_git_repos(\n                Some(base_path.to_string_lossy().to_string()),\n                5000,\n                10000,\n                Some(2), // Max depth 2 - should not find deep_repo\n            )\n            .await\n            .unwrap();\n\n        let repo_names: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();\n\n        // Should find shallow repo\n        assert!(repo_names.contains(&\"shallow_repo\".to_string()));\n\n        // Should not find deep repo due to depth limit\n        assert!(!repo_names.contains(&\"deep_repo\".to_string()));\n    }\n}\n"
  },
  {
    "path": "crates/tauri-app/Cargo.toml",
    "content": "[package]\nname = \"vibe-kanban-tauri\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[[bin]]\nname = \"vibe-kanban-tauri\"\npath = \"src/main.rs\"\n\n[build-dependencies]\ntauri-build = { version = \"2\", features = [] }\n\n[dependencies]\ntauri = { version = \"2\", features = [\"devtools\"] }\ntauri-plugin-shell = \"2\"\ntauri-plugin-opener = \"2\"\ntauri-plugin-updater = \"2\"\ntauri-plugin-notification = \"2\"\n\nserver = { path = \"../server\" }\nservices = { path = \"../services\" }\nutils = { path = \"../utils\" }\nasync-trait = { workspace = true }\ntokio = { workspace = true }\n\ntokio-util = { version = \"0.7\", features = [\"io\"] }\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true, features = [\"registry\"] }\nserde_json = { workspace = true }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nrustls = { workspace = true }\narboard = \"3.6.1\"\n\n[target.'cfg(target_os = \"macos\")'.dependencies]\nobjc2-web-kit = { version = \"0.3\", features = [\"WKWebView\"] }\n"
  },
  {
    "path": "crates/tauri-app/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>NSAppTransportSecurity</key>\n\t<dict>\n\t\t<key>NSAllowsLocalNetworking</key>\n\t\t<true/>\n\t\t<key>NSExceptionDomains</key>\n\t\t<dict>\n\t\t\t<key>localhost</key>\n\t\t\t<dict>\n\t\t\t\t<key>NSExceptionAllowsInsecureHTTPLoads</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>NSIncludesSubdomains</key>\n\t\t\t\t<true/>\n\t\t\t</dict>\n\t\t\t<key>127.0.0.1</key>\n\t\t\t<dict>\n\t\t\t\t<key>NSExceptionAllowsInsecureHTTPLoads</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>NSIncludesSubdomains</key>\n\t\t\t\t<true/>\n\t\t\t</dict>\n\t\t</dict>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "crates/tauri-app/build.rs",
    "content": "fn main() {\n    println!(\"cargo:rerun-if-env-changed=SENTRY_DSN\");\n    tauri_build::build();\n\n    #[cfg(target_os = \"windows\")]\n    fix_duplicate_version_resources();\n}\n\n/// Prevent CVTRES CVT1100 \"duplicate resource type:VERSION\" on Windows.\n///\n/// `tauri-winres` creates `{OUT_DIR}/resource.lib` (actually a `.res` file)\n/// and passes it to the linker via `cargo:rustc-link-arg-bins=`. Meanwhile,\n/// `codex-windows-sandbox` (a transitive dep) uses the `winres` crate which\n/// emits `cargo:rustc-link-lib=dylib=resource` + `cargo:rustc-link-search`.\n/// This tells the linker to search all LIBPATHs for `resource.lib` — including\n/// our own OUT_DIR. The linker loads the same VERSION resource twice and CVTRES\n/// fails before `/FORCE:MULTIPLE` can take effect.\n///\n/// Fix:\n/// 1. Copy `resource.lib` → `tauri_resource.lib` (preserving the real content)\n/// 2. Overwrite `resource.lib` with a valid empty `.res` file\n/// 3. Emit `cargo:rustc-link-arg-bins=tauri_resource.lib` for the real resource\n/// 4. Also overwrite `codex-windows-sandbox`'s `resource.lib`\n///\n/// Result: the original link-arg and LIBPATH search both find empty `.res`\n/// stubs, while our new link-arg provides the single copy of resources.\n#[cfg(target_os = \"windows\")]\nfn fix_duplicate_version_resources() {\n    let out_dir = match std::env::var(\"OUT_DIR\") {\n        Ok(d) => std::path::PathBuf::from(d),\n        Err(_) => return,\n    };\n\n    // Save the real resource under a unique name, then replace the original\n    // with an empty stub so duplicates from LIBPATH search contribute nothing.\n    let our_resource = out_dir.join(\"resource.lib\");\n    let renamed = out_dir.join(\"tauri_resource.lib\");\n    if our_resource.exists() {\n        if std::fs::copy(&our_resource, &renamed).is_ok() {\n            let _ = std::fs::write(&our_resource, empty_res_file());\n            println!(\"cargo:rustc-link-arg-bins={}\", renamed.display());\n        }\n    }\n\n    // Neutralize codex-windows-sandbox's resource.lib too\n    let build_dir = match out_dir.parent().and_then(|p| p.parent()) {\n        Some(d) => d.to_path_buf(),\n        None => return,\n    };\n    if let Ok(entries) = std::fs::read_dir(&build_dir) {\n        for entry in entries.flatten() {\n            let name = entry.file_name();\n            if name.to_string_lossy().starts_with(\"codex-windows-sandbox-\") {\n                let resource_lib = entry.path().join(\"out\").join(\"resource.lib\");\n                if resource_lib.exists() {\n                    let _ = std::fs::write(&resource_lib, empty_res_file());\n                }\n            }\n        }\n    }\n}\n\n/// A minimal valid `.res` file (COFF resource format) containing no resources.\n///\n/// The `.res` format starts with a 32-byte \"empty\" sentinel entry:\n///   - DataSize: 0x00000000 (4 bytes LE)\n///   - HeaderSize: 0x00000020 (4 bytes LE)\n///   - TYPE: 0xFFFF 0x0000 (ordinal zero)\n///   - NAME: 0xFFFF 0x0000 (ordinal zero)\n///   - DataVersion, MemoryFlags, LanguageId, Version, Characteristics: all zero\n///\n/// This is the standard header that `rc.exe` and CVTRES expect at the start of\n/// every `.res` file. A file containing only this header is treated as empty.\n#[cfg(target_os = \"windows\")]\nfn empty_res_file() -> Vec<u8> {\n    let mut buf = Vec::with_capacity(32);\n    buf.extend_from_slice(&0u32.to_le_bytes()); // DataSize = 0\n    buf.extend_from_slice(&0x20u32.to_le_bytes()); // HeaderSize = 32\n    buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); // TYPE: ordinal indicator\n    buf.extend_from_slice(&0x0000u16.to_le_bytes()); // TYPE: ordinal 0\n    buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); // NAME: ordinal indicator\n    buf.extend_from_slice(&0x0000u16.to_le_bytes()); // NAME: ordinal 0\n    buf.extend_from_slice(&0u32.to_le_bytes()); // DataVersion\n    buf.extend_from_slice(&0u16.to_le_bytes()); // MemoryFlags\n    buf.extend_from_slice(&0u16.to_le_bytes()); // LanguageId\n    buf.extend_from_slice(&0u32.to_le_bytes()); // Version\n    buf.extend_from_slice(&0u32.to_le_bytes()); // Characteristics\n    buf\n}\n"
  },
  {
    "path": "crates/tauri-app/capabilities/default.json",
    "content": "{\n  \"$schema\": \"https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema/capability.json\",\n  \"identifier\": \"default\",\n  \"description\": \"Default capabilities for Vibe Kanban desktop app\",\n  \"windows\": [\"main\"],\n  \"remote\": {\n    \"urls\": [\"http://localhost:*\", \"http://127.0.0.1:*\"]\n  },\n  \"permissions\": [\n    \"core:default\",\n    \"core:window:allow-start-dragging\",\n    \"core:webview:allow-set-webview-zoom\",\n    \"shell:allow-open\",\n    \"opener:default\",\n    \"updater:default\",\n    \"notification:default\"\n  ]\n}\n"
  },
  {
    "path": "crates/tauri-app/gen/schemas/acl-manifests.json",
    "content": "{\"core\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default core plugins set.\",\"permissions\":[\"core:path:default\",\"core:event:default\",\"core:window:default\",\"core:webview:default\",\"core:app:default\",\"core:image:default\",\"core:resources:default\",\"core:menu:default\",\"core:tray:default\"]},\"permissions\":{},\"permission_sets\":{},\"global_scope_schema\":null},\"core:app\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-version\",\"allow-name\",\"allow-tauri-version\",\"allow-identifier\",\"allow-bundle-type\",\"allow-register-listener\",\"allow-remove-listener\"]},\"permissions\":{\"allow-app-hide\":{\"identifier\":\"allow-app-hide\",\"description\":\"Enables the app_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"app_hide\"],\"deny\":[]}},\"allow-app-show\":{\"identifier\":\"allow-app-show\",\"description\":\"Enables the app_show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"app_show\"],\"deny\":[]}},\"allow-bundle-type\":{\"identifier\":\"allow-bundle-type\",\"description\":\"Enables the bundle_type command without any pre-configured scope.\",\"commands\":{\"allow\":[\"bundle_type\"],\"deny\":[]}},\"allow-default-window-icon\":{\"identifier\":\"allow-default-window-icon\",\"description\":\"Enables the default_window_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"default_window_icon\"],\"deny\":[]}},\"allow-fetch-data-store-identifiers\":{\"identifier\":\"allow-fetch-data-store-identifiers\",\"description\":\"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\"commands\":{\"allow\":[\"fetch_data_store_identifiers\"],\"deny\":[]}},\"allow-identifier\":{\"identifier\":\"allow-identifier\",\"description\":\"Enables the identifier command without any pre-configured scope.\",\"commands\":{\"allow\":[\"identifier\"],\"deny\":[]}},\"allow-name\":{\"identifier\":\"allow-name\",\"description\":\"Enables the name command without any pre-configured scope.\",\"commands\":{\"allow\":[\"name\"],\"deny\":[]}},\"allow-register-listener\":{\"identifier\":\"allow-register-listener\",\"description\":\"Enables the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[\"register_listener\"],\"deny\":[]}},\"allow-remove-data-store\":{\"identifier\":\"allow-remove-data-store\",\"description\":\"Enables the remove_data_store command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_data_store\"],\"deny\":[]}},\"allow-remove-listener\":{\"identifier\":\"allow-remove-listener\",\"description\":\"Enables the remove_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_listener\"],\"deny\":[]}},\"allow-set-app-theme\":{\"identifier\":\"allow-set-app-theme\",\"description\":\"Enables the set_app_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_app_theme\"],\"deny\":[]}},\"allow-set-dock-visibility\":{\"identifier\":\"allow-set-dock-visibility\",\"description\":\"Enables the set_dock_visibility command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_dock_visibility\"],\"deny\":[]}},\"allow-tauri-version\":{\"identifier\":\"allow-tauri-version\",\"description\":\"Enables the tauri_version command without any pre-configured scope.\",\"commands\":{\"allow\":[\"tauri_version\"],\"deny\":[]}},\"allow-version\":{\"identifier\":\"allow-version\",\"description\":\"Enables the version command without any pre-configured scope.\",\"commands\":{\"allow\":[\"version\"],\"deny\":[]}},\"deny-app-hide\":{\"identifier\":\"deny-app-hide\",\"description\":\"Denies the app_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"app_hide\"]}},\"deny-app-show\":{\"identifier\":\"deny-app-show\",\"description\":\"Denies the app_show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"app_show\"]}},\"deny-bundle-type\":{\"identifier\":\"deny-bundle-type\",\"description\":\"Denies the bundle_type command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"bundle_type\"]}},\"deny-default-window-icon\":{\"identifier\":\"deny-default-window-icon\",\"description\":\"Denies the default_window_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"default_window_icon\"]}},\"deny-fetch-data-store-identifiers\":{\"identifier\":\"deny-fetch-data-store-identifiers\",\"description\":\"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"fetch_data_store_identifiers\"]}},\"deny-identifier\":{\"identifier\":\"deny-identifier\",\"description\":\"Denies the identifier command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"identifier\"]}},\"deny-name\":{\"identifier\":\"deny-name\",\"description\":\"Denies the name command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"name\"]}},\"deny-register-listener\":{\"identifier\":\"deny-register-listener\",\"description\":\"Denies the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"register_listener\"]}},\"deny-remove-data-store\":{\"identifier\":\"deny-remove-data-store\",\"description\":\"Denies the remove_data_store command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_data_store\"]}},\"deny-remove-listener\":{\"identifier\":\"deny-remove-listener\",\"description\":\"Denies the remove_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_listener\"]}},\"deny-set-app-theme\":{\"identifier\":\"deny-set-app-theme\",\"description\":\"Denies the set_app_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_app_theme\"]}},\"deny-set-dock-visibility\":{\"identifier\":\"deny-set-dock-visibility\",\"description\":\"Denies the set_dock_visibility command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_dock_visibility\"]}},\"deny-tauri-version\":{\"identifier\":\"deny-tauri-version\",\"description\":\"Denies the tauri_version command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"tauri_version\"]}},\"deny-version\":{\"identifier\":\"deny-version\",\"description\":\"Denies the version command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"version\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:event\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-listen\",\"allow-unlisten\",\"allow-emit\",\"allow-emit-to\"]},\"permissions\":{\"allow-emit\":{\"identifier\":\"allow-emit\",\"description\":\"Enables the emit command without any pre-configured scope.\",\"commands\":{\"allow\":[\"emit\"],\"deny\":[]}},\"allow-emit-to\":{\"identifier\":\"allow-emit-to\",\"description\":\"Enables the emit_to command without any pre-configured scope.\",\"commands\":{\"allow\":[\"emit_to\"],\"deny\":[]}},\"allow-listen\":{\"identifier\":\"allow-listen\",\"description\":\"Enables the listen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"listen\"],\"deny\":[]}},\"allow-unlisten\":{\"identifier\":\"allow-unlisten\",\"description\":\"Enables the unlisten command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unlisten\"],\"deny\":[]}},\"deny-emit\":{\"identifier\":\"deny-emit\",\"description\":\"Denies the emit command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"emit\"]}},\"deny-emit-to\":{\"identifier\":\"deny-emit-to\",\"description\":\"Denies the emit_to command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"emit_to\"]}},\"deny-listen\":{\"identifier\":\"deny-listen\",\"description\":\"Denies the listen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"listen\"]}},\"deny-unlisten\":{\"identifier\":\"deny-unlisten\",\"description\":\"Denies the unlisten command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unlisten\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:image\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-from-bytes\",\"allow-from-path\",\"allow-rgba\",\"allow-size\"]},\"permissions\":{\"allow-from-bytes\":{\"identifier\":\"allow-from-bytes\",\"description\":\"Enables the from_bytes command without any pre-configured scope.\",\"commands\":{\"allow\":[\"from_bytes\"],\"deny\":[]}},\"allow-from-path\":{\"identifier\":\"allow-from-path\",\"description\":\"Enables the from_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"from_path\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-rgba\":{\"identifier\":\"allow-rgba\",\"description\":\"Enables the rgba command without any pre-configured scope.\",\"commands\":{\"allow\":[\"rgba\"],\"deny\":[]}},\"allow-size\":{\"identifier\":\"allow-size\",\"description\":\"Enables the size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"size\"],\"deny\":[]}},\"deny-from-bytes\":{\"identifier\":\"deny-from-bytes\",\"description\":\"Denies the from_bytes command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"from_bytes\"]}},\"deny-from-path\":{\"identifier\":\"deny-from-path\",\"description\":\"Denies the from_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"from_path\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-rgba\":{\"identifier\":\"deny-rgba\",\"description\":\"Denies the rgba command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"rgba\"]}},\"deny-size\":{\"identifier\":\"deny-size\",\"description\":\"Denies the size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"size\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:menu\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-append\",\"allow-prepend\",\"allow-insert\",\"allow-remove\",\"allow-remove-at\",\"allow-items\",\"allow-get\",\"allow-popup\",\"allow-create-default\",\"allow-set-as-app-menu\",\"allow-set-as-window-menu\",\"allow-text\",\"allow-set-text\",\"allow-is-enabled\",\"allow-set-enabled\",\"allow-set-accelerator\",\"allow-set-as-windows-menu-for-nsapp\",\"allow-set-as-help-menu-for-nsapp\",\"allow-is-checked\",\"allow-set-checked\",\"allow-set-icon\"]},\"permissions\":{\"allow-append\":{\"identifier\":\"allow-append\",\"description\":\"Enables the append command without any pre-configured scope.\",\"commands\":{\"allow\":[\"append\"],\"deny\":[]}},\"allow-create-default\":{\"identifier\":\"allow-create-default\",\"description\":\"Enables the create_default command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_default\"],\"deny\":[]}},\"allow-get\":{\"identifier\":\"allow-get\",\"description\":\"Enables the get command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get\"],\"deny\":[]}},\"allow-insert\":{\"identifier\":\"allow-insert\",\"description\":\"Enables the insert command without any pre-configured scope.\",\"commands\":{\"allow\":[\"insert\"],\"deny\":[]}},\"allow-is-checked\":{\"identifier\":\"allow-is-checked\",\"description\":\"Enables the is_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_checked\"],\"deny\":[]}},\"allow-is-enabled\":{\"identifier\":\"allow-is-enabled\",\"description\":\"Enables the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_enabled\"],\"deny\":[]}},\"allow-items\":{\"identifier\":\"allow-items\",\"description\":\"Enables the items command without any pre-configured scope.\",\"commands\":{\"allow\":[\"items\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-popup\":{\"identifier\":\"allow-popup\",\"description\":\"Enables the popup command without any pre-configured scope.\",\"commands\":{\"allow\":[\"popup\"],\"deny\":[]}},\"allow-prepend\":{\"identifier\":\"allow-prepend\",\"description\":\"Enables the prepend command without any pre-configured scope.\",\"commands\":{\"allow\":[\"prepend\"],\"deny\":[]}},\"allow-remove\":{\"identifier\":\"allow-remove\",\"description\":\"Enables the remove command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove\"],\"deny\":[]}},\"allow-remove-at\":{\"identifier\":\"allow-remove-at\",\"description\":\"Enables the remove_at command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_at\"],\"deny\":[]}},\"allow-set-accelerator\":{\"identifier\":\"allow-set-accelerator\",\"description\":\"Enables the set_accelerator command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_accelerator\"],\"deny\":[]}},\"allow-set-as-app-menu\":{\"identifier\":\"allow-set-as-app-menu\",\"description\":\"Enables the set_as_app_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_app_menu\"],\"deny\":[]}},\"allow-set-as-help-menu-for-nsapp\":{\"identifier\":\"allow-set-as-help-menu-for-nsapp\",\"description\":\"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_help_menu_for_nsapp\"],\"deny\":[]}},\"allow-set-as-window-menu\":{\"identifier\":\"allow-set-as-window-menu\",\"description\":\"Enables the set_as_window_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_window_menu\"],\"deny\":[]}},\"allow-set-as-windows-menu-for-nsapp\":{\"identifier\":\"allow-set-as-windows-menu-for-nsapp\",\"description\":\"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_windows_menu_for_nsapp\"],\"deny\":[]}},\"allow-set-checked\":{\"identifier\":\"allow-set-checked\",\"description\":\"Enables the set_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_checked\"],\"deny\":[]}},\"allow-set-enabled\":{\"identifier\":\"allow-set-enabled\",\"description\":\"Enables the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_enabled\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-text\":{\"identifier\":\"allow-set-text\",\"description\":\"Enables the set_text command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_text\"],\"deny\":[]}},\"allow-text\":{\"identifier\":\"allow-text\",\"description\":\"Enables the text command without any pre-configured scope.\",\"commands\":{\"allow\":[\"text\"],\"deny\":[]}},\"deny-append\":{\"identifier\":\"deny-append\",\"description\":\"Denies the append command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"append\"]}},\"deny-create-default\":{\"identifier\":\"deny-create-default\",\"description\":\"Denies the create_default command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_default\"]}},\"deny-get\":{\"identifier\":\"deny-get\",\"description\":\"Denies the get command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get\"]}},\"deny-insert\":{\"identifier\":\"deny-insert\",\"description\":\"Denies the insert command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"insert\"]}},\"deny-is-checked\":{\"identifier\":\"deny-is-checked\",\"description\":\"Denies the is_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_checked\"]}},\"deny-is-enabled\":{\"identifier\":\"deny-is-enabled\",\"description\":\"Denies the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_enabled\"]}},\"deny-items\":{\"identifier\":\"deny-items\",\"description\":\"Denies the items command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"items\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-popup\":{\"identifier\":\"deny-popup\",\"description\":\"Denies the popup command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"popup\"]}},\"deny-prepend\":{\"identifier\":\"deny-prepend\",\"description\":\"Denies the prepend command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"prepend\"]}},\"deny-remove\":{\"identifier\":\"deny-remove\",\"description\":\"Denies the remove command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove\"]}},\"deny-remove-at\":{\"identifier\":\"deny-remove-at\",\"description\":\"Denies the remove_at command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_at\"]}},\"deny-set-accelerator\":{\"identifier\":\"deny-set-accelerator\",\"description\":\"Denies the set_accelerator command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_accelerator\"]}},\"deny-set-as-app-menu\":{\"identifier\":\"deny-set-as-app-menu\",\"description\":\"Denies the set_as_app_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_app_menu\"]}},\"deny-set-as-help-menu-for-nsapp\":{\"identifier\":\"deny-set-as-help-menu-for-nsapp\",\"description\":\"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_help_menu_for_nsapp\"]}},\"deny-set-as-window-menu\":{\"identifier\":\"deny-set-as-window-menu\",\"description\":\"Denies the set_as_window_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_window_menu\"]}},\"deny-set-as-windows-menu-for-nsapp\":{\"identifier\":\"deny-set-as-windows-menu-for-nsapp\",\"description\":\"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_windows_menu_for_nsapp\"]}},\"deny-set-checked\":{\"identifier\":\"deny-set-checked\",\"description\":\"Denies the set_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_checked\"]}},\"deny-set-enabled\":{\"identifier\":\"deny-set-enabled\",\"description\":\"Denies the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_enabled\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-text\":{\"identifier\":\"deny-set-text\",\"description\":\"Denies the set_text command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_text\"]}},\"deny-text\":{\"identifier\":\"deny-text\",\"description\":\"Denies the text command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"text\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:path\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-resolve-directory\",\"allow-resolve\",\"allow-normalize\",\"allow-join\",\"allow-dirname\",\"allow-extname\",\"allow-basename\",\"allow-is-absolute\"]},\"permissions\":{\"allow-basename\":{\"identifier\":\"allow-basename\",\"description\":\"Enables the basename command without any pre-configured scope.\",\"commands\":{\"allow\":[\"basename\"],\"deny\":[]}},\"allow-dirname\":{\"identifier\":\"allow-dirname\",\"description\":\"Enables the dirname command without any pre-configured scope.\",\"commands\":{\"allow\":[\"dirname\"],\"deny\":[]}},\"allow-extname\":{\"identifier\":\"allow-extname\",\"description\":\"Enables the extname command without any pre-configured scope.\",\"commands\":{\"allow\":[\"extname\"],\"deny\":[]}},\"allow-is-absolute\":{\"identifier\":\"allow-is-absolute\",\"description\":\"Enables the is_absolute command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_absolute\"],\"deny\":[]}},\"allow-join\":{\"identifier\":\"allow-join\",\"description\":\"Enables the join command without any pre-configured scope.\",\"commands\":{\"allow\":[\"join\"],\"deny\":[]}},\"allow-normalize\":{\"identifier\":\"allow-normalize\",\"description\":\"Enables the normalize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"normalize\"],\"deny\":[]}},\"allow-resolve\":{\"identifier\":\"allow-resolve\",\"description\":\"Enables the resolve command without any pre-configured scope.\",\"commands\":{\"allow\":[\"resolve\"],\"deny\":[]}},\"allow-resolve-directory\":{\"identifier\":\"allow-resolve-directory\",\"description\":\"Enables the resolve_directory command without any pre-configured scope.\",\"commands\":{\"allow\":[\"resolve_directory\"],\"deny\":[]}},\"deny-basename\":{\"identifier\":\"deny-basename\",\"description\":\"Denies the basename command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"basename\"]}},\"deny-dirname\":{\"identifier\":\"deny-dirname\",\"description\":\"Denies the dirname command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"dirname\"]}},\"deny-extname\":{\"identifier\":\"deny-extname\",\"description\":\"Denies the extname command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"extname\"]}},\"deny-is-absolute\":{\"identifier\":\"deny-is-absolute\",\"description\":\"Denies the is_absolute command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_absolute\"]}},\"deny-join\":{\"identifier\":\"deny-join\",\"description\":\"Denies the join command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"join\"]}},\"deny-normalize\":{\"identifier\":\"deny-normalize\",\"description\":\"Denies the normalize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"normalize\"]}},\"deny-resolve\":{\"identifier\":\"deny-resolve\",\"description\":\"Denies the resolve command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"resolve\"]}},\"deny-resolve-directory\":{\"identifier\":\"deny-resolve-directory\",\"description\":\"Denies the resolve_directory command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"resolve_directory\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:resources\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-close\"]},\"permissions\":{\"allow-close\":{\"identifier\":\"allow-close\",\"description\":\"Enables the close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"close\"],\"deny\":[]}},\"deny-close\":{\"identifier\":\"deny-close\",\"description\":\"Denies the close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"close\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:tray\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-get-by-id\",\"allow-remove-by-id\",\"allow-set-icon\",\"allow-set-menu\",\"allow-set-tooltip\",\"allow-set-title\",\"allow-set-visible\",\"allow-set-temp-dir-path\",\"allow-set-icon-as-template\",\"allow-set-show-menu-on-left-click\"]},\"permissions\":{\"allow-get-by-id\":{\"identifier\":\"allow-get-by-id\",\"description\":\"Enables the get_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_by_id\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-remove-by-id\":{\"identifier\":\"allow-remove-by-id\",\"description\":\"Enables the remove_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_by_id\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-icon-as-template\":{\"identifier\":\"allow-set-icon-as-template\",\"description\":\"Enables the set_icon_as_template command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon_as_template\"],\"deny\":[]}},\"allow-set-menu\":{\"identifier\":\"allow-set-menu\",\"description\":\"Enables the set_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_menu\"],\"deny\":[]}},\"allow-set-show-menu-on-left-click\":{\"identifier\":\"allow-set-show-menu-on-left-click\",\"description\":\"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_show_menu_on_left_click\"],\"deny\":[]}},\"allow-set-temp-dir-path\":{\"identifier\":\"allow-set-temp-dir-path\",\"description\":\"Enables the set_temp_dir_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_temp_dir_path\"],\"deny\":[]}},\"allow-set-title\":{\"identifier\":\"allow-set-title\",\"description\":\"Enables the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title\"],\"deny\":[]}},\"allow-set-tooltip\":{\"identifier\":\"allow-set-tooltip\",\"description\":\"Enables the set_tooltip command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_tooltip\"],\"deny\":[]}},\"allow-set-visible\":{\"identifier\":\"allow-set-visible\",\"description\":\"Enables the set_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_visible\"],\"deny\":[]}},\"deny-get-by-id\":{\"identifier\":\"deny-get-by-id\",\"description\":\"Denies the get_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_by_id\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-remove-by-id\":{\"identifier\":\"deny-remove-by-id\",\"description\":\"Denies the remove_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_by_id\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-icon-as-template\":{\"identifier\":\"deny-set-icon-as-template\",\"description\":\"Denies the set_icon_as_template command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon_as_template\"]}},\"deny-set-menu\":{\"identifier\":\"deny-set-menu\",\"description\":\"Denies the set_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_menu\"]}},\"deny-set-show-menu-on-left-click\":{\"identifier\":\"deny-set-show-menu-on-left-click\",\"description\":\"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_show_menu_on_left_click\"]}},\"deny-set-temp-dir-path\":{\"identifier\":\"deny-set-temp-dir-path\",\"description\":\"Denies the set_temp_dir_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_temp_dir_path\"]}},\"deny-set-title\":{\"identifier\":\"deny-set-title\",\"description\":\"Denies the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title\"]}},\"deny-set-tooltip\":{\"identifier\":\"deny-set-tooltip\",\"description\":\"Denies the set_tooltip command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_tooltip\"]}},\"deny-set-visible\":{\"identifier\":\"deny-set-visible\",\"description\":\"Denies the set_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_visible\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:webview\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-get-all-webviews\",\"allow-webview-position\",\"allow-webview-size\",\"allow-internal-toggle-devtools\"]},\"permissions\":{\"allow-clear-all-browsing-data\":{\"identifier\":\"allow-clear-all-browsing-data\",\"description\":\"Enables the clear_all_browsing_data command without any pre-configured scope.\",\"commands\":{\"allow\":[\"clear_all_browsing_data\"],\"deny\":[]}},\"allow-create-webview\":{\"identifier\":\"allow-create-webview\",\"description\":\"Enables the create_webview command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_webview\"],\"deny\":[]}},\"allow-create-webview-window\":{\"identifier\":\"allow-create-webview-window\",\"description\":\"Enables the create_webview_window command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_webview_window\"],\"deny\":[]}},\"allow-get-all-webviews\":{\"identifier\":\"allow-get-all-webviews\",\"description\":\"Enables the get_all_webviews command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_all_webviews\"],\"deny\":[]}},\"allow-internal-toggle-devtools\":{\"identifier\":\"allow-internal-toggle-devtools\",\"description\":\"Enables the internal_toggle_devtools command without any pre-configured scope.\",\"commands\":{\"allow\":[\"internal_toggle_devtools\"],\"deny\":[]}},\"allow-print\":{\"identifier\":\"allow-print\",\"description\":\"Enables the print command without any pre-configured scope.\",\"commands\":{\"allow\":[\"print\"],\"deny\":[]}},\"allow-reparent\":{\"identifier\":\"allow-reparent\",\"description\":\"Enables the reparent command without any pre-configured scope.\",\"commands\":{\"allow\":[\"reparent\"],\"deny\":[]}},\"allow-set-webview-auto-resize\":{\"identifier\":\"allow-set-webview-auto-resize\",\"description\":\"Enables the set_webview_auto_resize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_auto_resize\"],\"deny\":[]}},\"allow-set-webview-background-color\":{\"identifier\":\"allow-set-webview-background-color\",\"description\":\"Enables the set_webview_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_background_color\"],\"deny\":[]}},\"allow-set-webview-focus\":{\"identifier\":\"allow-set-webview-focus\",\"description\":\"Enables the set_webview_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_focus\"],\"deny\":[]}},\"allow-set-webview-position\":{\"identifier\":\"allow-set-webview-position\",\"description\":\"Enables the set_webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_position\"],\"deny\":[]}},\"allow-set-webview-size\":{\"identifier\":\"allow-set-webview-size\",\"description\":\"Enables the set_webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_size\"],\"deny\":[]}},\"allow-set-webview-zoom\":{\"identifier\":\"allow-set-webview-zoom\",\"description\":\"Enables the set_webview_zoom command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_zoom\"],\"deny\":[]}},\"allow-webview-close\":{\"identifier\":\"allow-webview-close\",\"description\":\"Enables the webview_close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_close\"],\"deny\":[]}},\"allow-webview-hide\":{\"identifier\":\"allow-webview-hide\",\"description\":\"Enables the webview_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_hide\"],\"deny\":[]}},\"allow-webview-position\":{\"identifier\":\"allow-webview-position\",\"description\":\"Enables the webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_position\"],\"deny\":[]}},\"allow-webview-show\":{\"identifier\":\"allow-webview-show\",\"description\":\"Enables the webview_show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_show\"],\"deny\":[]}},\"allow-webview-size\":{\"identifier\":\"allow-webview-size\",\"description\":\"Enables the webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_size\"],\"deny\":[]}},\"deny-clear-all-browsing-data\":{\"identifier\":\"deny-clear-all-browsing-data\",\"description\":\"Denies the clear_all_browsing_data command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"clear_all_browsing_data\"]}},\"deny-create-webview\":{\"identifier\":\"deny-create-webview\",\"description\":\"Denies the create_webview command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_webview\"]}},\"deny-create-webview-window\":{\"identifier\":\"deny-create-webview-window\",\"description\":\"Denies the create_webview_window command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_webview_window\"]}},\"deny-get-all-webviews\":{\"identifier\":\"deny-get-all-webviews\",\"description\":\"Denies the get_all_webviews command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_all_webviews\"]}},\"deny-internal-toggle-devtools\":{\"identifier\":\"deny-internal-toggle-devtools\",\"description\":\"Denies the internal_toggle_devtools command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"internal_toggle_devtools\"]}},\"deny-print\":{\"identifier\":\"deny-print\",\"description\":\"Denies the print command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"print\"]}},\"deny-reparent\":{\"identifier\":\"deny-reparent\",\"description\":\"Denies the reparent command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"reparent\"]}},\"deny-set-webview-auto-resize\":{\"identifier\":\"deny-set-webview-auto-resize\",\"description\":\"Denies the set_webview_auto_resize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_auto_resize\"]}},\"deny-set-webview-background-color\":{\"identifier\":\"deny-set-webview-background-color\",\"description\":\"Denies the set_webview_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_background_color\"]}},\"deny-set-webview-focus\":{\"identifier\":\"deny-set-webview-focus\",\"description\":\"Denies the set_webview_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_focus\"]}},\"deny-set-webview-position\":{\"identifier\":\"deny-set-webview-position\",\"description\":\"Denies the set_webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_position\"]}},\"deny-set-webview-size\":{\"identifier\":\"deny-set-webview-size\",\"description\":\"Denies the set_webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_size\"]}},\"deny-set-webview-zoom\":{\"identifier\":\"deny-set-webview-zoom\",\"description\":\"Denies the set_webview_zoom command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_zoom\"]}},\"deny-webview-close\":{\"identifier\":\"deny-webview-close\",\"description\":\"Denies the webview_close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_close\"]}},\"deny-webview-hide\":{\"identifier\":\"deny-webview-hide\",\"description\":\"Denies the webview_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_hide\"]}},\"deny-webview-position\":{\"identifier\":\"deny-webview-position\",\"description\":\"Denies the webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_position\"]}},\"deny-webview-show\":{\"identifier\":\"deny-webview-show\",\"description\":\"Denies the webview_show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_show\"]}},\"deny-webview-size\":{\"identifier\":\"deny-webview-size\",\"description\":\"Denies the webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_size\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:window\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-get-all-windows\",\"allow-scale-factor\",\"allow-inner-position\",\"allow-outer-position\",\"allow-inner-size\",\"allow-outer-size\",\"allow-is-fullscreen\",\"allow-is-minimized\",\"allow-is-maximized\",\"allow-is-focused\",\"allow-is-decorated\",\"allow-is-resizable\",\"allow-is-maximizable\",\"allow-is-minimizable\",\"allow-is-closable\",\"allow-is-visible\",\"allow-is-enabled\",\"allow-title\",\"allow-current-monitor\",\"allow-primary-monitor\",\"allow-monitor-from-point\",\"allow-available-monitors\",\"allow-cursor-position\",\"allow-theme\",\"allow-is-always-on-top\",\"allow-internal-toggle-maximize\"]},\"permissions\":{\"allow-available-monitors\":{\"identifier\":\"allow-available-monitors\",\"description\":\"Enables the available_monitors command without any pre-configured scope.\",\"commands\":{\"allow\":[\"available_monitors\"],\"deny\":[]}},\"allow-center\":{\"identifier\":\"allow-center\",\"description\":\"Enables the center command without any pre-configured scope.\",\"commands\":{\"allow\":[\"center\"],\"deny\":[]}},\"allow-close\":{\"identifier\":\"allow-close\",\"description\":\"Enables the close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"close\"],\"deny\":[]}},\"allow-create\":{\"identifier\":\"allow-create\",\"description\":\"Enables the create command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create\"],\"deny\":[]}},\"allow-current-monitor\":{\"identifier\":\"allow-current-monitor\",\"description\":\"Enables the current_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"current_monitor\"],\"deny\":[]}},\"allow-cursor-position\":{\"identifier\":\"allow-cursor-position\",\"description\":\"Enables the cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"cursor_position\"],\"deny\":[]}},\"allow-destroy\":{\"identifier\":\"allow-destroy\",\"description\":\"Enables the destroy command without any pre-configured scope.\",\"commands\":{\"allow\":[\"destroy\"],\"deny\":[]}},\"allow-get-all-windows\":{\"identifier\":\"allow-get-all-windows\",\"description\":\"Enables the get_all_windows command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_all_windows\"],\"deny\":[]}},\"allow-hide\":{\"identifier\":\"allow-hide\",\"description\":\"Enables the hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"hide\"],\"deny\":[]}},\"allow-inner-position\":{\"identifier\":\"allow-inner-position\",\"description\":\"Enables the inner_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"inner_position\"],\"deny\":[]}},\"allow-inner-size\":{\"identifier\":\"allow-inner-size\",\"description\":\"Enables the inner_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"inner_size\"],\"deny\":[]}},\"allow-internal-toggle-maximize\":{\"identifier\":\"allow-internal-toggle-maximize\",\"description\":\"Enables the internal_toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"internal_toggle_maximize\"],\"deny\":[]}},\"allow-is-always-on-top\":{\"identifier\":\"allow-is-always-on-top\",\"description\":\"Enables the is_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_always_on_top\"],\"deny\":[]}},\"allow-is-closable\":{\"identifier\":\"allow-is-closable\",\"description\":\"Enables the is_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_closable\"],\"deny\":[]}},\"allow-is-decorated\":{\"identifier\":\"allow-is-decorated\",\"description\":\"Enables the is_decorated command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_decorated\"],\"deny\":[]}},\"allow-is-enabled\":{\"identifier\":\"allow-is-enabled\",\"description\":\"Enables the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_enabled\"],\"deny\":[]}},\"allow-is-focused\":{\"identifier\":\"allow-is-focused\",\"description\":\"Enables the is_focused command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_focused\"],\"deny\":[]}},\"allow-is-fullscreen\":{\"identifier\":\"allow-is-fullscreen\",\"description\":\"Enables the is_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_fullscreen\"],\"deny\":[]}},\"allow-is-maximizable\":{\"identifier\":\"allow-is-maximizable\",\"description\":\"Enables the is_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_maximizable\"],\"deny\":[]}},\"allow-is-maximized\":{\"identifier\":\"allow-is-maximized\",\"description\":\"Enables the is_maximized command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_maximized\"],\"deny\":[]}},\"allow-is-minimizable\":{\"identifier\":\"allow-is-minimizable\",\"description\":\"Enables the is_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_minimizable\"],\"deny\":[]}},\"allow-is-minimized\":{\"identifier\":\"allow-is-minimized\",\"description\":\"Enables the is_minimized command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_minimized\"],\"deny\":[]}},\"allow-is-resizable\":{\"identifier\":\"allow-is-resizable\",\"description\":\"Enables the is_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_resizable\"],\"deny\":[]}},\"allow-is-visible\":{\"identifier\":\"allow-is-visible\",\"description\":\"Enables the is_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_visible\"],\"deny\":[]}},\"allow-maximize\":{\"identifier\":\"allow-maximize\",\"description\":\"Enables the maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"maximize\"],\"deny\":[]}},\"allow-minimize\":{\"identifier\":\"allow-minimize\",\"description\":\"Enables the minimize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"minimize\"],\"deny\":[]}},\"allow-monitor-from-point\":{\"identifier\":\"allow-monitor-from-point\",\"description\":\"Enables the monitor_from_point command without any pre-configured scope.\",\"commands\":{\"allow\":[\"monitor_from_point\"],\"deny\":[]}},\"allow-outer-position\":{\"identifier\":\"allow-outer-position\",\"description\":\"Enables the outer_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"outer_position\"],\"deny\":[]}},\"allow-outer-size\":{\"identifier\":\"allow-outer-size\",\"description\":\"Enables the outer_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"outer_size\"],\"deny\":[]}},\"allow-primary-monitor\":{\"identifier\":\"allow-primary-monitor\",\"description\":\"Enables the primary_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"primary_monitor\"],\"deny\":[]}},\"allow-request-user-attention\":{\"identifier\":\"allow-request-user-attention\",\"description\":\"Enables the request_user_attention command without any pre-configured scope.\",\"commands\":{\"allow\":[\"request_user_attention\"],\"deny\":[]}},\"allow-scale-factor\":{\"identifier\":\"allow-scale-factor\",\"description\":\"Enables the scale_factor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"scale_factor\"],\"deny\":[]}},\"allow-set-always-on-bottom\":{\"identifier\":\"allow-set-always-on-bottom\",\"description\":\"Enables the set_always_on_bottom command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_always_on_bottom\"],\"deny\":[]}},\"allow-set-always-on-top\":{\"identifier\":\"allow-set-always-on-top\",\"description\":\"Enables the set_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_always_on_top\"],\"deny\":[]}},\"allow-set-background-color\":{\"identifier\":\"allow-set-background-color\",\"description\":\"Enables the set_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_background_color\"],\"deny\":[]}},\"allow-set-badge-count\":{\"identifier\":\"allow-set-badge-count\",\"description\":\"Enables the set_badge_count command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_badge_count\"],\"deny\":[]}},\"allow-set-badge-label\":{\"identifier\":\"allow-set-badge-label\",\"description\":\"Enables the set_badge_label command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_badge_label\"],\"deny\":[]}},\"allow-set-closable\":{\"identifier\":\"allow-set-closable\",\"description\":\"Enables the set_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_closable\"],\"deny\":[]}},\"allow-set-content-protected\":{\"identifier\":\"allow-set-content-protected\",\"description\":\"Enables the set_content_protected command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_content_protected\"],\"deny\":[]}},\"allow-set-cursor-grab\":{\"identifier\":\"allow-set-cursor-grab\",\"description\":\"Enables the set_cursor_grab command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_grab\"],\"deny\":[]}},\"allow-set-cursor-icon\":{\"identifier\":\"allow-set-cursor-icon\",\"description\":\"Enables the set_cursor_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_icon\"],\"deny\":[]}},\"allow-set-cursor-position\":{\"identifier\":\"allow-set-cursor-position\",\"description\":\"Enables the set_cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_position\"],\"deny\":[]}},\"allow-set-cursor-visible\":{\"identifier\":\"allow-set-cursor-visible\",\"description\":\"Enables the set_cursor_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_visible\"],\"deny\":[]}},\"allow-set-decorations\":{\"identifier\":\"allow-set-decorations\",\"description\":\"Enables the set_decorations command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_decorations\"],\"deny\":[]}},\"allow-set-effects\":{\"identifier\":\"allow-set-effects\",\"description\":\"Enables the set_effects command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_effects\"],\"deny\":[]}},\"allow-set-enabled\":{\"identifier\":\"allow-set-enabled\",\"description\":\"Enables the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_enabled\"],\"deny\":[]}},\"allow-set-focus\":{\"identifier\":\"allow-set-focus\",\"description\":\"Enables the set_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_focus\"],\"deny\":[]}},\"allow-set-focusable\":{\"identifier\":\"allow-set-focusable\",\"description\":\"Enables the set_focusable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_focusable\"],\"deny\":[]}},\"allow-set-fullscreen\":{\"identifier\":\"allow-set-fullscreen\",\"description\":\"Enables the set_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_fullscreen\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-ignore-cursor-events\":{\"identifier\":\"allow-set-ignore-cursor-events\",\"description\":\"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_ignore_cursor_events\"],\"deny\":[]}},\"allow-set-max-size\":{\"identifier\":\"allow-set-max-size\",\"description\":\"Enables the set_max_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_max_size\"],\"deny\":[]}},\"allow-set-maximizable\":{\"identifier\":\"allow-set-maximizable\",\"description\":\"Enables the set_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_maximizable\"],\"deny\":[]}},\"allow-set-min-size\":{\"identifier\":\"allow-set-min-size\",\"description\":\"Enables the set_min_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_min_size\"],\"deny\":[]}},\"allow-set-minimizable\":{\"identifier\":\"allow-set-minimizable\",\"description\":\"Enables the set_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_minimizable\"],\"deny\":[]}},\"allow-set-overlay-icon\":{\"identifier\":\"allow-set-overlay-icon\",\"description\":\"Enables the set_overlay_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_overlay_icon\"],\"deny\":[]}},\"allow-set-position\":{\"identifier\":\"allow-set-position\",\"description\":\"Enables the set_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_position\"],\"deny\":[]}},\"allow-set-progress-bar\":{\"identifier\":\"allow-set-progress-bar\",\"description\":\"Enables the set_progress_bar command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_progress_bar\"],\"deny\":[]}},\"allow-set-resizable\":{\"identifier\":\"allow-set-resizable\",\"description\":\"Enables the set_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_resizable\"],\"deny\":[]}},\"allow-set-shadow\":{\"identifier\":\"allow-set-shadow\",\"description\":\"Enables the set_shadow command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_shadow\"],\"deny\":[]}},\"allow-set-simple-fullscreen\":{\"identifier\":\"allow-set-simple-fullscreen\",\"description\":\"Enables the set_simple_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_simple_fullscreen\"],\"deny\":[]}},\"allow-set-size\":{\"identifier\":\"allow-set-size\",\"description\":\"Enables the set_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_size\"],\"deny\":[]}},\"allow-set-size-constraints\":{\"identifier\":\"allow-set-size-constraints\",\"description\":\"Enables the set_size_constraints command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_size_constraints\"],\"deny\":[]}},\"allow-set-skip-taskbar\":{\"identifier\":\"allow-set-skip-taskbar\",\"description\":\"Enables the set_skip_taskbar command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_skip_taskbar\"],\"deny\":[]}},\"allow-set-theme\":{\"identifier\":\"allow-set-theme\",\"description\":\"Enables the set_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_theme\"],\"deny\":[]}},\"allow-set-title\":{\"identifier\":\"allow-set-title\",\"description\":\"Enables the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title\"],\"deny\":[]}},\"allow-set-title-bar-style\":{\"identifier\":\"allow-set-title-bar-style\",\"description\":\"Enables the set_title_bar_style command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title_bar_style\"],\"deny\":[]}},\"allow-set-visible-on-all-workspaces\":{\"identifier\":\"allow-set-visible-on-all-workspaces\",\"description\":\"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_visible_on_all_workspaces\"],\"deny\":[]}},\"allow-show\":{\"identifier\":\"allow-show\",\"description\":\"Enables the show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"show\"],\"deny\":[]}},\"allow-start-dragging\":{\"identifier\":\"allow-start-dragging\",\"description\":\"Enables the start_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[\"start_dragging\"],\"deny\":[]}},\"allow-start-resize-dragging\":{\"identifier\":\"allow-start-resize-dragging\",\"description\":\"Enables the start_resize_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[\"start_resize_dragging\"],\"deny\":[]}},\"allow-theme\":{\"identifier\":\"allow-theme\",\"description\":\"Enables the theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"theme\"],\"deny\":[]}},\"allow-title\":{\"identifier\":\"allow-title\",\"description\":\"Enables the title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"title\"],\"deny\":[]}},\"allow-toggle-maximize\":{\"identifier\":\"allow-toggle-maximize\",\"description\":\"Enables the toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"toggle_maximize\"],\"deny\":[]}},\"allow-unmaximize\":{\"identifier\":\"allow-unmaximize\",\"description\":\"Enables the unmaximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unmaximize\"],\"deny\":[]}},\"allow-unminimize\":{\"identifier\":\"allow-unminimize\",\"description\":\"Enables the unminimize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unminimize\"],\"deny\":[]}},\"deny-available-monitors\":{\"identifier\":\"deny-available-monitors\",\"description\":\"Denies the available_monitors command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"available_monitors\"]}},\"deny-center\":{\"identifier\":\"deny-center\",\"description\":\"Denies the center command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"center\"]}},\"deny-close\":{\"identifier\":\"deny-close\",\"description\":\"Denies the close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"close\"]}},\"deny-create\":{\"identifier\":\"deny-create\",\"description\":\"Denies the create command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create\"]}},\"deny-current-monitor\":{\"identifier\":\"deny-current-monitor\",\"description\":\"Denies the current_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"current_monitor\"]}},\"deny-cursor-position\":{\"identifier\":\"deny-cursor-position\",\"description\":\"Denies the cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"cursor_position\"]}},\"deny-destroy\":{\"identifier\":\"deny-destroy\",\"description\":\"Denies the destroy command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"destroy\"]}},\"deny-get-all-windows\":{\"identifier\":\"deny-get-all-windows\",\"description\":\"Denies the get_all_windows command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_all_windows\"]}},\"deny-hide\":{\"identifier\":\"deny-hide\",\"description\":\"Denies the hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"hide\"]}},\"deny-inner-position\":{\"identifier\":\"deny-inner-position\",\"description\":\"Denies the inner_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"inner_position\"]}},\"deny-inner-size\":{\"identifier\":\"deny-inner-size\",\"description\":\"Denies the inner_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"inner_size\"]}},\"deny-internal-toggle-maximize\":{\"identifier\":\"deny-internal-toggle-maximize\",\"description\":\"Denies the internal_toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"internal_toggle_maximize\"]}},\"deny-is-always-on-top\":{\"identifier\":\"deny-is-always-on-top\",\"description\":\"Denies the is_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_always_on_top\"]}},\"deny-is-closable\":{\"identifier\":\"deny-is-closable\",\"description\":\"Denies the is_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_closable\"]}},\"deny-is-decorated\":{\"identifier\":\"deny-is-decorated\",\"description\":\"Denies the is_decorated command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_decorated\"]}},\"deny-is-enabled\":{\"identifier\":\"deny-is-enabled\",\"description\":\"Denies the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_enabled\"]}},\"deny-is-focused\":{\"identifier\":\"deny-is-focused\",\"description\":\"Denies the is_focused command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_focused\"]}},\"deny-is-fullscreen\":{\"identifier\":\"deny-is-fullscreen\",\"description\":\"Denies the is_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_fullscreen\"]}},\"deny-is-maximizable\":{\"identifier\":\"deny-is-maximizable\",\"description\":\"Denies the is_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_maximizable\"]}},\"deny-is-maximized\":{\"identifier\":\"deny-is-maximized\",\"description\":\"Denies the is_maximized command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_maximized\"]}},\"deny-is-minimizable\":{\"identifier\":\"deny-is-minimizable\",\"description\":\"Denies the is_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_minimizable\"]}},\"deny-is-minimized\":{\"identifier\":\"deny-is-minimized\",\"description\":\"Denies the is_minimized command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_minimized\"]}},\"deny-is-resizable\":{\"identifier\":\"deny-is-resizable\",\"description\":\"Denies the is_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_resizable\"]}},\"deny-is-visible\":{\"identifier\":\"deny-is-visible\",\"description\":\"Denies the is_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_visible\"]}},\"deny-maximize\":{\"identifier\":\"deny-maximize\",\"description\":\"Denies the maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"maximize\"]}},\"deny-minimize\":{\"identifier\":\"deny-minimize\",\"description\":\"Denies the minimize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"minimize\"]}},\"deny-monitor-from-point\":{\"identifier\":\"deny-monitor-from-point\",\"description\":\"Denies the monitor_from_point command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"monitor_from_point\"]}},\"deny-outer-position\":{\"identifier\":\"deny-outer-position\",\"description\":\"Denies the outer_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"outer_position\"]}},\"deny-outer-size\":{\"identifier\":\"deny-outer-size\",\"description\":\"Denies the outer_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"outer_size\"]}},\"deny-primary-monitor\":{\"identifier\":\"deny-primary-monitor\",\"description\":\"Denies the primary_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"primary_monitor\"]}},\"deny-request-user-attention\":{\"identifier\":\"deny-request-user-attention\",\"description\":\"Denies the request_user_attention command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"request_user_attention\"]}},\"deny-scale-factor\":{\"identifier\":\"deny-scale-factor\",\"description\":\"Denies the scale_factor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"scale_factor\"]}},\"deny-set-always-on-bottom\":{\"identifier\":\"deny-set-always-on-bottom\",\"description\":\"Denies the set_always_on_bottom command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_always_on_bottom\"]}},\"deny-set-always-on-top\":{\"identifier\":\"deny-set-always-on-top\",\"description\":\"Denies the set_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_always_on_top\"]}},\"deny-set-background-color\":{\"identifier\":\"deny-set-background-color\",\"description\":\"Denies the set_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_background_color\"]}},\"deny-set-badge-count\":{\"identifier\":\"deny-set-badge-count\",\"description\":\"Denies the set_badge_count command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_badge_count\"]}},\"deny-set-badge-label\":{\"identifier\":\"deny-set-badge-label\",\"description\":\"Denies the set_badge_label command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_badge_label\"]}},\"deny-set-closable\":{\"identifier\":\"deny-set-closable\",\"description\":\"Denies the set_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_closable\"]}},\"deny-set-content-protected\":{\"identifier\":\"deny-set-content-protected\",\"description\":\"Denies the set_content_protected command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_content_protected\"]}},\"deny-set-cursor-grab\":{\"identifier\":\"deny-set-cursor-grab\",\"description\":\"Denies the set_cursor_grab command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_grab\"]}},\"deny-set-cursor-icon\":{\"identifier\":\"deny-set-cursor-icon\",\"description\":\"Denies the set_cursor_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_icon\"]}},\"deny-set-cursor-position\":{\"identifier\":\"deny-set-cursor-position\",\"description\":\"Denies the set_cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_position\"]}},\"deny-set-cursor-visible\":{\"identifier\":\"deny-set-cursor-visible\",\"description\":\"Denies the set_cursor_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_visible\"]}},\"deny-set-decorations\":{\"identifier\":\"deny-set-decorations\",\"description\":\"Denies the set_decorations command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_decorations\"]}},\"deny-set-effects\":{\"identifier\":\"deny-set-effects\",\"description\":\"Denies the set_effects command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_effects\"]}},\"deny-set-enabled\":{\"identifier\":\"deny-set-enabled\",\"description\":\"Denies the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_enabled\"]}},\"deny-set-focus\":{\"identifier\":\"deny-set-focus\",\"description\":\"Denies the set_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_focus\"]}},\"deny-set-focusable\":{\"identifier\":\"deny-set-focusable\",\"description\":\"Denies the set_focusable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_focusable\"]}},\"deny-set-fullscreen\":{\"identifier\":\"deny-set-fullscreen\",\"description\":\"Denies the set_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_fullscreen\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-ignore-cursor-events\":{\"identifier\":\"deny-set-ignore-cursor-events\",\"description\":\"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_ignore_cursor_events\"]}},\"deny-set-max-size\":{\"identifier\":\"deny-set-max-size\",\"description\":\"Denies the set_max_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_max_size\"]}},\"deny-set-maximizable\":{\"identifier\":\"deny-set-maximizable\",\"description\":\"Denies the set_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_maximizable\"]}},\"deny-set-min-size\":{\"identifier\":\"deny-set-min-size\",\"description\":\"Denies the set_min_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_min_size\"]}},\"deny-set-minimizable\":{\"identifier\":\"deny-set-minimizable\",\"description\":\"Denies the set_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_minimizable\"]}},\"deny-set-overlay-icon\":{\"identifier\":\"deny-set-overlay-icon\",\"description\":\"Denies the set_overlay_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_overlay_icon\"]}},\"deny-set-position\":{\"identifier\":\"deny-set-position\",\"description\":\"Denies the set_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_position\"]}},\"deny-set-progress-bar\":{\"identifier\":\"deny-set-progress-bar\",\"description\":\"Denies the set_progress_bar command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_progress_bar\"]}},\"deny-set-resizable\":{\"identifier\":\"deny-set-resizable\",\"description\":\"Denies the set_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_resizable\"]}},\"deny-set-shadow\":{\"identifier\":\"deny-set-shadow\",\"description\":\"Denies the set_shadow command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_shadow\"]}},\"deny-set-simple-fullscreen\":{\"identifier\":\"deny-set-simple-fullscreen\",\"description\":\"Denies the set_simple_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_simple_fullscreen\"]}},\"deny-set-size\":{\"identifier\":\"deny-set-size\",\"description\":\"Denies the set_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_size\"]}},\"deny-set-size-constraints\":{\"identifier\":\"deny-set-size-constraints\",\"description\":\"Denies the set_size_constraints command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_size_constraints\"]}},\"deny-set-skip-taskbar\":{\"identifier\":\"deny-set-skip-taskbar\",\"description\":\"Denies the set_skip_taskbar command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_skip_taskbar\"]}},\"deny-set-theme\":{\"identifier\":\"deny-set-theme\",\"description\":\"Denies the set_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_theme\"]}},\"deny-set-title\":{\"identifier\":\"deny-set-title\",\"description\":\"Denies the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title\"]}},\"deny-set-title-bar-style\":{\"identifier\":\"deny-set-title-bar-style\",\"description\":\"Denies the set_title_bar_style command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title_bar_style\"]}},\"deny-set-visible-on-all-workspaces\":{\"identifier\":\"deny-set-visible-on-all-workspaces\",\"description\":\"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_visible_on_all_workspaces\"]}},\"deny-show\":{\"identifier\":\"deny-show\",\"description\":\"Denies the show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"show\"]}},\"deny-start-dragging\":{\"identifier\":\"deny-start-dragging\",\"description\":\"Denies the start_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"start_dragging\"]}},\"deny-start-resize-dragging\":{\"identifier\":\"deny-start-resize-dragging\",\"description\":\"Denies the start_resize_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"start_resize_dragging\"]}},\"deny-theme\":{\"identifier\":\"deny-theme\",\"description\":\"Denies the theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"theme\"]}},\"deny-title\":{\"identifier\":\"deny-title\",\"description\":\"Denies the title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"title\"]}},\"deny-toggle-maximize\":{\"identifier\":\"deny-toggle-maximize\",\"description\":\"Denies the toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"toggle_maximize\"]}},\"deny-unmaximize\":{\"identifier\":\"deny-unmaximize\",\"description\":\"Denies the unmaximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unmaximize\"]}},\"deny-unminimize\":{\"identifier\":\"deny-unminimize\",\"description\":\"Denies the unminimize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unminimize\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"notification\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set configures which\\nnotification features are by default exposed.\\n\\n#### Granted Permissions\\n\\nIt allows all notification related features.\\n\\n\",\"permissions\":[\"allow-is-permission-granted\",\"allow-request-permission\",\"allow-notify\",\"allow-register-action-types\",\"allow-register-listener\",\"allow-cancel\",\"allow-get-pending\",\"allow-remove-active\",\"allow-get-active\",\"allow-check-permissions\",\"allow-show\",\"allow-batch\",\"allow-list-channels\",\"allow-delete-channel\",\"allow-create-channel\",\"allow-permission-state\"]},\"permissions\":{\"allow-batch\":{\"identifier\":\"allow-batch\",\"description\":\"Enables the batch command without any pre-configured scope.\",\"commands\":{\"allow\":[\"batch\"],\"deny\":[]}},\"allow-cancel\":{\"identifier\":\"allow-cancel\",\"description\":\"Enables the cancel command without any pre-configured scope.\",\"commands\":{\"allow\":[\"cancel\"],\"deny\":[]}},\"allow-check-permissions\":{\"identifier\":\"allow-check-permissions\",\"description\":\"Enables the check_permissions command without any pre-configured scope.\",\"commands\":{\"allow\":[\"check_permissions\"],\"deny\":[]}},\"allow-create-channel\":{\"identifier\":\"allow-create-channel\",\"description\":\"Enables the create_channel command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_channel\"],\"deny\":[]}},\"allow-delete-channel\":{\"identifier\":\"allow-delete-channel\",\"description\":\"Enables the delete_channel command without any pre-configured scope.\",\"commands\":{\"allow\":[\"delete_channel\"],\"deny\":[]}},\"allow-get-active\":{\"identifier\":\"allow-get-active\",\"description\":\"Enables the get_active command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_active\"],\"deny\":[]}},\"allow-get-pending\":{\"identifier\":\"allow-get-pending\",\"description\":\"Enables the get_pending command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_pending\"],\"deny\":[]}},\"allow-is-permission-granted\":{\"identifier\":\"allow-is-permission-granted\",\"description\":\"Enables the is_permission_granted command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_permission_granted\"],\"deny\":[]}},\"allow-list-channels\":{\"identifier\":\"allow-list-channels\",\"description\":\"Enables the list_channels command without any pre-configured scope.\",\"commands\":{\"allow\":[\"list_channels\"],\"deny\":[]}},\"allow-notify\":{\"identifier\":\"allow-notify\",\"description\":\"Enables the notify command without any pre-configured scope.\",\"commands\":{\"allow\":[\"notify\"],\"deny\":[]}},\"allow-permission-state\":{\"identifier\":\"allow-permission-state\",\"description\":\"Enables the permission_state command without any pre-configured scope.\",\"commands\":{\"allow\":[\"permission_state\"],\"deny\":[]}},\"allow-register-action-types\":{\"identifier\":\"allow-register-action-types\",\"description\":\"Enables the register_action_types command without any pre-configured scope.\",\"commands\":{\"allow\":[\"register_action_types\"],\"deny\":[]}},\"allow-register-listener\":{\"identifier\":\"allow-register-listener\",\"description\":\"Enables the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[\"register_listener\"],\"deny\":[]}},\"allow-remove-active\":{\"identifier\":\"allow-remove-active\",\"description\":\"Enables the remove_active command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_active\"],\"deny\":[]}},\"allow-request-permission\":{\"identifier\":\"allow-request-permission\",\"description\":\"Enables the request_permission command without any pre-configured scope.\",\"commands\":{\"allow\":[\"request_permission\"],\"deny\":[]}},\"allow-show\":{\"identifier\":\"allow-show\",\"description\":\"Enables the show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"show\"],\"deny\":[]}},\"deny-batch\":{\"identifier\":\"deny-batch\",\"description\":\"Denies the batch command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"batch\"]}},\"deny-cancel\":{\"identifier\":\"deny-cancel\",\"description\":\"Denies the cancel command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"cancel\"]}},\"deny-check-permissions\":{\"identifier\":\"deny-check-permissions\",\"description\":\"Denies the check_permissions command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"check_permissions\"]}},\"deny-create-channel\":{\"identifier\":\"deny-create-channel\",\"description\":\"Denies the create_channel command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_channel\"]}},\"deny-delete-channel\":{\"identifier\":\"deny-delete-channel\",\"description\":\"Denies the delete_channel command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"delete_channel\"]}},\"deny-get-active\":{\"identifier\":\"deny-get-active\",\"description\":\"Denies the get_active command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_active\"]}},\"deny-get-pending\":{\"identifier\":\"deny-get-pending\",\"description\":\"Denies the get_pending command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_pending\"]}},\"deny-is-permission-granted\":{\"identifier\":\"deny-is-permission-granted\",\"description\":\"Denies the is_permission_granted command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_permission_granted\"]}},\"deny-list-channels\":{\"identifier\":\"deny-list-channels\",\"description\":\"Denies the list_channels command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"list_channels\"]}},\"deny-notify\":{\"identifier\":\"deny-notify\",\"description\":\"Denies the notify command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"notify\"]}},\"deny-permission-state\":{\"identifier\":\"deny-permission-state\",\"description\":\"Denies the permission_state command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"permission_state\"]}},\"deny-register-action-types\":{\"identifier\":\"deny-register-action-types\",\"description\":\"Denies the register_action_types command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"register_action_types\"]}},\"deny-register-listener\":{\"identifier\":\"deny-register-listener\",\"description\":\"Denies the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"register_listener\"]}},\"deny-remove-active\":{\"identifier\":\"deny-remove-active\",\"description\":\"Denies the remove_active command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_active\"]}},\"deny-request-permission\":{\"identifier\":\"deny-request-permission\",\"description\":\"Denies the request_permission command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"request_permission\"]}},\"deny-show\":{\"identifier\":\"deny-show\",\"description\":\"Denies the show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"show\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"opener\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\",\"permissions\":[\"allow-open-url\",\"allow-reveal-item-in-dir\",\"allow-default-urls\"]},\"permissions\":{\"allow-default-urls\":{\"identifier\":\"allow-default-urls\",\"description\":\"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\"commands\":{\"allow\":[],\"deny\":[]},\"scope\":{\"allow\":[{\"url\":\"mailto:*\"},{\"url\":\"tel:*\"},{\"url\":\"http://*\"},{\"url\":\"https://*\"}]}},\"allow-open-path\":{\"identifier\":\"allow-open-path\",\"description\":\"Enables the open_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"open_path\"],\"deny\":[]}},\"allow-open-url\":{\"identifier\":\"allow-open-url\",\"description\":\"Enables the open_url command without any pre-configured scope.\",\"commands\":{\"allow\":[\"open_url\"],\"deny\":[]}},\"allow-reveal-item-in-dir\":{\"identifier\":\"allow-reveal-item-in-dir\",\"description\":\"Enables the reveal_item_in_dir command without any pre-configured scope.\",\"commands\":{\"allow\":[\"reveal_item_in_dir\"],\"deny\":[]}},\"deny-open-path\":{\"identifier\":\"deny-open-path\",\"description\":\"Denies the open_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"open_path\"]}},\"deny-open-url\":{\"identifier\":\"deny-open-url\",\"description\":\"Denies the open_url command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"open_url\"]}},\"deny-reveal-item-in-dir\":{\"identifier\":\"deny-reveal-item-in-dir\",\"description\":\"Denies the reveal_item_in_dir command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"reveal_item_in_dir\"]}}},\"permission_sets\":{},\"global_scope_schema\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"anyOf\":[{\"properties\":{\"app\":{\"allOf\":[{\"$ref\":\"#/definitions/Application\"}],\"description\":\"An application to open this url with, for example: firefox.\"},\"url\":{\"description\":\"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\"type\":\"string\"}},\"required\":[\"url\"],\"type\":\"object\"},{\"properties\":{\"app\":{\"allOf\":[{\"$ref\":\"#/definitions/Application\"}],\"description\":\"An application to open this path with, for example: xdg-open.\"},\"path\":{\"description\":\"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\"type\":\"string\"}},\"required\":[\"path\"],\"type\":\"object\"}],\"definitions\":{\"Application\":{\"anyOf\":[{\"description\":\"Open in default application.\",\"type\":\"null\"},{\"description\":\"If true, allow open with any application.\",\"type\":\"boolean\"},{\"description\":\"Allow specific application to open with.\",\"type\":\"string\"}],\"description\":\"Opener scope application.\"}},\"description\":\"Opener scope entry.\",\"title\":\"OpenerScopeEntry\"}},\"shell\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\",\"permissions\":[\"allow-open\"]},\"permissions\":{\"allow-execute\":{\"identifier\":\"allow-execute\",\"description\":\"Enables the execute command without any pre-configured scope.\",\"commands\":{\"allow\":[\"execute\"],\"deny\":[]}},\"allow-kill\":{\"identifier\":\"allow-kill\",\"description\":\"Enables the kill command without any pre-configured scope.\",\"commands\":{\"allow\":[\"kill\"],\"deny\":[]}},\"allow-open\":{\"identifier\":\"allow-open\",\"description\":\"Enables the open command without any pre-configured scope.\",\"commands\":{\"allow\":[\"open\"],\"deny\":[]}},\"allow-spawn\":{\"identifier\":\"allow-spawn\",\"description\":\"Enables the spawn command without any pre-configured scope.\",\"commands\":{\"allow\":[\"spawn\"],\"deny\":[]}},\"allow-stdin-write\":{\"identifier\":\"allow-stdin-write\",\"description\":\"Enables the stdin_write command without any pre-configured scope.\",\"commands\":{\"allow\":[\"stdin_write\"],\"deny\":[]}},\"deny-execute\":{\"identifier\":\"deny-execute\",\"description\":\"Denies the execute command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"execute\"]}},\"deny-kill\":{\"identifier\":\"deny-kill\",\"description\":\"Denies the kill command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"kill\"]}},\"deny-open\":{\"identifier\":\"deny-open\",\"description\":\"Denies the open command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"open\"]}},\"deny-spawn\":{\"identifier\":\"deny-spawn\",\"description\":\"Denies the spawn command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"spawn\"]}},\"deny-stdin-write\":{\"identifier\":\"deny-stdin-write\",\"description\":\"Denies the stdin_write command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"stdin_write\"]}}},\"permission_sets\":{},\"global_scope_schema\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"args\":{\"allOf\":[{\"$ref\":\"#/definitions/ShellScopeEntryAllowedArgs\"}],\"description\":\"The allowed arguments for the command execution.\"},\"cmd\":{\"description\":\"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\"type\":\"string\"},\"name\":{\"description\":\"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\"type\":\"string\"}},\"required\":[\"cmd\",\"name\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"args\":{\"allOf\":[{\"$ref\":\"#/definitions/ShellScopeEntryAllowedArgs\"}],\"description\":\"The allowed arguments for the command execution.\"},\"name\":{\"description\":\"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\"type\":\"string\"},\"sidecar\":{\"description\":\"If this command is a sidecar command.\",\"type\":\"boolean\"}},\"required\":[\"name\",\"sidecar\"],\"type\":\"object\"}],\"definitions\":{\"ShellScopeEntryAllowedArg\":{\"anyOf\":[{\"description\":\"A non-configurable argument that is passed to the command in the order it was specified.\",\"type\":\"string\"},{\"additionalProperties\":false,\"description\":\"A variable that is set while calling the command from the webview API.\",\"properties\":{\"raw\":{\"default\":false,\"description\":\"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\\n\\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.\",\"type\":\"boolean\"},\"validator\":{\"description\":\"[regex] validator to require passed values to conform to an expected input.\\n\\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\\n\\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\\\w+` regex would be registered as `^https?://\\\\w+$`.\\n\\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>\",\"type\":\"string\"}},\"required\":[\"validator\"],\"type\":\"object\"}],\"description\":\"A command argument allowed to be executed by the webview API.\"},\"ShellScopeEntryAllowedArgs\":{\"anyOf\":[{\"description\":\"Use a simple boolean to allow all or disable all arguments to this command configuration.\",\"type\":\"boolean\"},{\"description\":\"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.\",\"items\":{\"$ref\":\"#/definitions/ShellScopeEntryAllowedArg\"},\"type\":\"array\"}],\"description\":\"A set of command arguments allowed to be executed by the webview API.\\n\\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.\"}},\"description\":\"Shell scope entry.\",\"title\":\"ShellScopeEntry\"}},\"updater\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\",\"permissions\":[\"allow-check\",\"allow-download\",\"allow-install\",\"allow-download-and-install\"]},\"permissions\":{\"allow-check\":{\"identifier\":\"allow-check\",\"description\":\"Enables the check command without any pre-configured scope.\",\"commands\":{\"allow\":[\"check\"],\"deny\":[]}},\"allow-download\":{\"identifier\":\"allow-download\",\"description\":\"Enables the download command without any pre-configured scope.\",\"commands\":{\"allow\":[\"download\"],\"deny\":[]}},\"allow-download-and-install\":{\"identifier\":\"allow-download-and-install\",\"description\":\"Enables the download_and_install command without any pre-configured scope.\",\"commands\":{\"allow\":[\"download_and_install\"],\"deny\":[]}},\"allow-install\":{\"identifier\":\"allow-install\",\"description\":\"Enables the install command without any pre-configured scope.\",\"commands\":{\"allow\":[\"install\"],\"deny\":[]}},\"deny-check\":{\"identifier\":\"deny-check\",\"description\":\"Denies the check command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"check\"]}},\"deny-download\":{\"identifier\":\"deny-download\",\"description\":\"Denies the download command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"download\"]}},\"deny-download-and-install\":{\"identifier\":\"deny-download-and-install\",\"description\":\"Denies the download_and_install command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"download_and_install\"]}},\"deny-install\":{\"identifier\":\"deny-install\",\"description\":\"Denies the install command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"install\"]}}},\"permission_sets\":{},\"global_scope_schema\":null}}"
  },
  {
    "path": "crates/tauri-app/gen/schemas/capabilities.json",
    "content": "{\"default\":{\"identifier\":\"default\",\"description\":\"Default capabilities for Vibe Kanban desktop app\",\"remote\":{\"urls\":[\"http://localhost:*\",\"http://127.0.0.1:*\"]},\"local\":true,\"windows\":[\"main\"],\"permissions\":[\"core:default\",\"core:window:allow-start-dragging\",\"core:webview:allow-set-webview-zoom\",\"shell:allow-open\",\"opener:default\",\"updater:default\",\"notification:default\"]}}"
  },
  {
    "path": "crates/tauri-app/gen/schemas/desktop-schema.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"CapabilityFile\",\n  \"description\": \"Capability formats accepted in a capability file.\",\n  \"anyOf\": [\n    {\n      \"description\": \"A single capability.\",\n      \"allOf\": [\n        {\n          \"$ref\": \"#/definitions/Capability\"\n        }\n      ]\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Capability\"\n      }\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"capabilities\"\n      ],\n      \"properties\": {\n        \"capabilities\": {\n          \"description\": \"The list of capabilities.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Capability\"\n          }\n        }\n      }\n    }\n  ],\n  \"definitions\": {\n    \"Capability\": {\n      \"description\": \"A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\\n\\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\\n\\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\\n\\n## Example\\n\\n```json { \\\"identifier\\\": \\\"main-user-files-write\\\", \\\"description\\\": \\\"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\\\", \\\"windows\\\": [ \\\"main\\\" ], \\\"permissions\\\": [ \\\"core:default\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] }, ], \\\"platforms\\\": [\\\"macOS\\\",\\\"windows\\\"] } ```\",\n      \"type\": \"object\",\n      \"required\": [\n        \"identifier\",\n        \"permissions\"\n      ],\n      \"properties\": {\n        \"identifier\": {\n          \"description\": \"Identifier of the capability.\\n\\n## Example\\n\\n`main-user-files-write`\",\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"description\": \"Description of what the capability is intended to allow on associated windows.\\n\\nIt should contain a description of what the grouped permissions should allow.\\n\\n## Example\\n\\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\",\n          \"default\": \"\",\n          \"type\": \"string\"\n        },\n        \"remote\": {\n          \"description\": \"Configure remote URLs that can use the capability permissions.\\n\\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\\n\\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\\n\\n## Example\\n\\n```json { \\\"urls\\\": [\\\"https://*.mydomain.dev\\\"] } ```\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/CapabilityRemote\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"local\": {\n          \"description\": \"Whether this capability is enabled for local app URLs or not. Defaults to `true`.\",\n          \"default\": true,\n          \"type\": \"boolean\"\n        },\n        \"windows\": {\n          \"description\": \"List of windows that are affected by this capability. Can be a glob pattern.\\n\\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\\n\\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\\n\\n## Example\\n\\n`[\\\"main\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"webviews\": {\n          \"description\": \"List of webviews that are affected by this capability. Can be a glob pattern.\\n\\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\\n\\n## Example\\n\\n`[\\\"sub-webview-one\\\", \\\"sub-webview-two\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"permissions\": {\n          \"description\": \"List of permissions attached to this capability.\\n\\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\\n\\n## Example\\n\\n```json [ \\\"core:default\\\", \\\"shell:allow-open\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] } ] ```\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/PermissionEntry\"\n          },\n          \"uniqueItems\": true\n        },\n        \"platforms\": {\n          \"description\": \"Limit which target platforms this capability applies to.\\n\\nBy default all platforms are targeted.\\n\\n## Example\\n\\n`[\\\"macOS\\\",\\\"windows\\\"]`\",\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"$ref\": \"#/definitions/Target\"\n          }\n        }\n      }\n    },\n    \"CapabilityRemote\": {\n      \"description\": \"Configuration for remote URLs that are associated with the capability.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"urls\"\n      ],\n      \"properties\": {\n        \"urls\": {\n          \"description\": \"Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\\n\\n## Examples\\n\\n- \\\"https://*.mydomain.dev\\\": allows subdomains of mydomain.dev - \\\"https://mydomain.dev/api/*\\\": allows any subpath of mydomain.dev/api\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    },\n    \"PermissionEntry\": {\n      \"description\": \"An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Reference a permission or permission set by identifier.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Identifier\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Reference a permission or permission set by identifier and extends its scope.\",\n          \"type\": \"object\",\n          \"allOf\": [\n            {\n              \"if\": {\n                \"properties\": {\n                  \"identifier\": {\n                    \"anyOf\": [\n                      {\n                        \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:default\",\n                        \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n                      },\n                      {\n                        \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-default-urls\",\n                        \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-path\",\n                        \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-url\",\n                        \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-path\",\n                        \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-url\",\n                        \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n                      }\n                    ]\n                  }\n                }\n              },\n              \"then\": {\n                \"properties\": {\n                  \"allow\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  },\n                  \"deny\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  }\n                }\n              },\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                }\n              }\n            },\n            {\n              \"if\": {\n                \"properties\": {\n                  \"identifier\": {\n                    \"anyOf\": [\n                      {\n                        \"description\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:default\",\n                        \"markdownDescription\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\"\n                      },\n                      {\n                        \"description\": \"Enables the execute command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-execute\",\n                        \"markdownDescription\": \"Enables the execute command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the kill command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-kill\",\n                        \"markdownDescription\": \"Enables the kill command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the open command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-open\",\n                        \"markdownDescription\": \"Enables the open command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the spawn command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-spawn\",\n                        \"markdownDescription\": \"Enables the spawn command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the stdin_write command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-stdin-write\",\n                        \"markdownDescription\": \"Enables the stdin_write command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the execute command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-execute\",\n                        \"markdownDescription\": \"Denies the execute command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the kill command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-kill\",\n                        \"markdownDescription\": \"Denies the kill command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-open\",\n                        \"markdownDescription\": \"Denies the open command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the spawn command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-spawn\",\n                        \"markdownDescription\": \"Denies the spawn command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the stdin_write command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-stdin-write\",\n                        \"markdownDescription\": \"Denies the stdin_write command without any pre-configured scope.\"\n                      }\n                    ]\n                  }\n                }\n              },\n              \"then\": {\n                \"properties\": {\n                  \"allow\": {\n                    \"items\": {\n                      \"title\": \"ShellScopeEntry\",\n                      \"description\": \"Shell scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"cmd\",\n                            \"name\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"cmd\": {\n                              \"description\": \"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"name\",\n                            \"sidecar\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            },\n                            \"sidecar\": {\n                              \"description\": \"If this command is a sidecar command.\",\n                              \"type\": \"boolean\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        }\n                      ]\n                    }\n                  },\n                  \"deny\": {\n                    \"items\": {\n                      \"title\": \"ShellScopeEntry\",\n                      \"description\": \"Shell scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"cmd\",\n                            \"name\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"cmd\": {\n                              \"description\": \"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"name\",\n                            \"sidecar\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            },\n                            \"sidecar\": {\n                              \"description\": \"If this command is a sidecar command.\",\n                              \"type\": \"boolean\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        }\n                      ]\n                    }\n                  }\n                }\n              },\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                }\n              }\n            },\n            {\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                },\n                \"allow\": {\n                  \"description\": \"Data that defines what is allowed by the scope.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                },\n                \"deny\": {\n                  \"description\": \"Data that defines what is denied by the scope. This should be prioritized by validation logic.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                }\n              }\n            }\n          ],\n          \"required\": [\n            \"identifier\"\n          ]\n        }\n      ]\n    },\n    \"Identifier\": {\n      \"description\": \"Permission identifier\",\n      \"oneOf\": [\n        {\n          \"description\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\",\n          \"type\": \"string\",\n          \"const\": \"core:default\",\n          \"markdownDescription\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\",\n          \"type\": \"string\",\n          \"const\": \"core:app:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\"\n        },\n        {\n          \"description\": \"Enables the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-hide\",\n          \"markdownDescription\": \"Enables the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-show\",\n          \"markdownDescription\": \"Enables the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-bundle-type\",\n          \"markdownDescription\": \"Enables the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-default-window-icon\",\n          \"markdownDescription\": \"Enables the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-identifier\",\n          \"markdownDescription\": \"Enables the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-name\",\n          \"markdownDescription\": \"Enables the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-data-store\",\n          \"markdownDescription\": \"Enables the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-listener\",\n          \"markdownDescription\": \"Enables the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-app-theme\",\n          \"markdownDescription\": \"Enables the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-dock-visibility\",\n          \"markdownDescription\": \"Enables the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-tauri-version\",\n          \"markdownDescription\": \"Enables the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-version\",\n          \"markdownDescription\": \"Enables the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-hide\",\n          \"markdownDescription\": \"Denies the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-show\",\n          \"markdownDescription\": \"Denies the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-bundle-type\",\n          \"markdownDescription\": \"Denies the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-default-window-icon\",\n          \"markdownDescription\": \"Denies the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-identifier\",\n          \"markdownDescription\": \"Denies the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-name\",\n          \"markdownDescription\": \"Denies the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-data-store\",\n          \"markdownDescription\": \"Denies the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-listener\",\n          \"markdownDescription\": \"Denies the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-app-theme\",\n          \"markdownDescription\": \"Denies the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-dock-visibility\",\n          \"markdownDescription\": \"Denies the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-tauri-version\",\n          \"markdownDescription\": \"Denies the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-version\",\n          \"markdownDescription\": \"Denies the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\",\n          \"type\": \"string\",\n          \"const\": \"core:event:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\"\n        },\n        {\n          \"description\": \"Enables the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit\",\n          \"markdownDescription\": \"Enables the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit-to\",\n          \"markdownDescription\": \"Enables the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-listen\",\n          \"markdownDescription\": \"Enables the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-unlisten\",\n          \"markdownDescription\": \"Enables the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit\",\n          \"markdownDescription\": \"Denies the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit-to\",\n          \"markdownDescription\": \"Denies the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-listen\",\n          \"markdownDescription\": \"Denies the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-unlisten\",\n          \"markdownDescription\": \"Denies the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\",\n          \"type\": \"string\",\n          \"const\": \"core:image:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\"\n        },\n        {\n          \"description\": \"Enables the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-bytes\",\n          \"markdownDescription\": \"Enables the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-path\",\n          \"markdownDescription\": \"Enables the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-rgba\",\n          \"markdownDescription\": \"Enables the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-size\",\n          \"markdownDescription\": \"Enables the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-bytes\",\n          \"markdownDescription\": \"Denies the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-path\",\n          \"markdownDescription\": \"Denies the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-rgba\",\n          \"markdownDescription\": \"Denies the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-size\",\n          \"markdownDescription\": \"Denies the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\"\n        },\n        {\n          \"description\": \"Enables the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-append\",\n          \"markdownDescription\": \"Enables the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-create-default\",\n          \"markdownDescription\": \"Enables the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-get\",\n          \"markdownDescription\": \"Enables the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-insert\",\n          \"markdownDescription\": \"Enables the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-checked\",\n          \"markdownDescription\": \"Enables the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-items\",\n          \"markdownDescription\": \"Enables the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-popup\",\n          \"markdownDescription\": \"Enables the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-prepend\",\n          \"markdownDescription\": \"Enables the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove\",\n          \"markdownDescription\": \"Enables the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove-at\",\n          \"markdownDescription\": \"Enables the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-accelerator\",\n          \"markdownDescription\": \"Enables the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-app-menu\",\n          \"markdownDescription\": \"Enables the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-window-menu\",\n          \"markdownDescription\": \"Enables the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-checked\",\n          \"markdownDescription\": \"Enables the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-text\",\n          \"markdownDescription\": \"Enables the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-text\",\n          \"markdownDescription\": \"Enables the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-append\",\n          \"markdownDescription\": \"Denies the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-create-default\",\n          \"markdownDescription\": \"Denies the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-get\",\n          \"markdownDescription\": \"Denies the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-insert\",\n          \"markdownDescription\": \"Denies the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-checked\",\n          \"markdownDescription\": \"Denies the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-items\",\n          \"markdownDescription\": \"Denies the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-popup\",\n          \"markdownDescription\": \"Denies the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-prepend\",\n          \"markdownDescription\": \"Denies the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove\",\n          \"markdownDescription\": \"Denies the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove-at\",\n          \"markdownDescription\": \"Denies the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-accelerator\",\n          \"markdownDescription\": \"Denies the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-app-menu\",\n          \"markdownDescription\": \"Denies the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-window-menu\",\n          \"markdownDescription\": \"Denies the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-checked\",\n          \"markdownDescription\": \"Denies the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-text\",\n          \"markdownDescription\": \"Denies the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-text\",\n          \"markdownDescription\": \"Denies the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\",\n          \"type\": \"string\",\n          \"const\": \"core:path:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\"\n        },\n        {\n          \"description\": \"Enables the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-basename\",\n          \"markdownDescription\": \"Enables the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-dirname\",\n          \"markdownDescription\": \"Enables the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-extname\",\n          \"markdownDescription\": \"Enables the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-is-absolute\",\n          \"markdownDescription\": \"Enables the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-join\",\n          \"markdownDescription\": \"Enables the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-normalize\",\n          \"markdownDescription\": \"Enables the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve\",\n          \"markdownDescription\": \"Enables the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve-directory\",\n          \"markdownDescription\": \"Enables the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-basename\",\n          \"markdownDescription\": \"Denies the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-dirname\",\n          \"markdownDescription\": \"Denies the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-extname\",\n          \"markdownDescription\": \"Denies the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-is-absolute\",\n          \"markdownDescription\": \"Denies the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-join\",\n          \"markdownDescription\": \"Denies the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-normalize\",\n          \"markdownDescription\": \"Denies the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve\",\n          \"markdownDescription\": \"Denies the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve-directory\",\n          \"markdownDescription\": \"Denies the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\"\n        },\n        {\n          \"description\": \"Enables the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-get-by-id\",\n          \"markdownDescription\": \"Enables the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-remove-by-id\",\n          \"markdownDescription\": \"Enables the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon-as-template\",\n          \"markdownDescription\": \"Enables the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-menu\",\n          \"markdownDescription\": \"Enables the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-temp-dir-path\",\n          \"markdownDescription\": \"Enables the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-tooltip\",\n          \"markdownDescription\": \"Enables the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-visible\",\n          \"markdownDescription\": \"Enables the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-get-by-id\",\n          \"markdownDescription\": \"Denies the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-remove-by-id\",\n          \"markdownDescription\": \"Denies the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon-as-template\",\n          \"markdownDescription\": \"Denies the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-menu\",\n          \"markdownDescription\": \"Denies the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-temp-dir-path\",\n          \"markdownDescription\": \"Denies the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-tooltip\",\n          \"markdownDescription\": \"Denies the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-visible\",\n          \"markdownDescription\": \"Denies the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\"\n        },\n        {\n          \"description\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-clear-all-browsing-data\",\n          \"markdownDescription\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview\",\n          \"markdownDescription\": \"Enables the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview-window\",\n          \"markdownDescription\": \"Enables the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-get-all-webviews\",\n          \"markdownDescription\": \"Enables the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-internal-toggle-devtools\",\n          \"markdownDescription\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-print\",\n          \"markdownDescription\": \"Enables the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-reparent\",\n          \"markdownDescription\": \"Enables the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-auto-resize\",\n          \"markdownDescription\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-background-color\",\n          \"markdownDescription\": \"Enables the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-focus\",\n          \"markdownDescription\": \"Enables the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-position\",\n          \"markdownDescription\": \"Enables the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-size\",\n          \"markdownDescription\": \"Enables the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-zoom\",\n          \"markdownDescription\": \"Enables the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-close\",\n          \"markdownDescription\": \"Enables the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-hide\",\n          \"markdownDescription\": \"Enables the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-position\",\n          \"markdownDescription\": \"Enables the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-show\",\n          \"markdownDescription\": \"Enables the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-size\",\n          \"markdownDescription\": \"Enables the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-clear-all-browsing-data\",\n          \"markdownDescription\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview\",\n          \"markdownDescription\": \"Denies the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview-window\",\n          \"markdownDescription\": \"Denies the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-get-all-webviews\",\n          \"markdownDescription\": \"Denies the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-internal-toggle-devtools\",\n          \"markdownDescription\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-print\",\n          \"markdownDescription\": \"Denies the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-reparent\",\n          \"markdownDescription\": \"Denies the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-auto-resize\",\n          \"markdownDescription\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-background-color\",\n          \"markdownDescription\": \"Denies the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-focus\",\n          \"markdownDescription\": \"Denies the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-position\",\n          \"markdownDescription\": \"Denies the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-size\",\n          \"markdownDescription\": \"Denies the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-zoom\",\n          \"markdownDescription\": \"Denies the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-close\",\n          \"markdownDescription\": \"Denies the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-hide\",\n          \"markdownDescription\": \"Denies the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-position\",\n          \"markdownDescription\": \"Denies the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-show\",\n          \"markdownDescription\": \"Denies the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-size\",\n          \"markdownDescription\": \"Denies the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\",\n          \"type\": \"string\",\n          \"const\": \"core:window:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\"\n        },\n        {\n          \"description\": \"Enables the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-available-monitors\",\n          \"markdownDescription\": \"Enables the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-center\",\n          \"markdownDescription\": \"Enables the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-create\",\n          \"markdownDescription\": \"Enables the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-current-monitor\",\n          \"markdownDescription\": \"Enables the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-cursor-position\",\n          \"markdownDescription\": \"Enables the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-destroy\",\n          \"markdownDescription\": \"Enables the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-get-all-windows\",\n          \"markdownDescription\": \"Enables the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-hide\",\n          \"markdownDescription\": \"Enables the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-position\",\n          \"markdownDescription\": \"Enables the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-size\",\n          \"markdownDescription\": \"Enables the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-internal-toggle-maximize\",\n          \"markdownDescription\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-always-on-top\",\n          \"markdownDescription\": \"Enables the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-closable\",\n          \"markdownDescription\": \"Enables the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-decorated\",\n          \"markdownDescription\": \"Enables the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-focused\",\n          \"markdownDescription\": \"Enables the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-fullscreen\",\n          \"markdownDescription\": \"Enables the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximizable\",\n          \"markdownDescription\": \"Enables the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximized\",\n          \"markdownDescription\": \"Enables the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimizable\",\n          \"markdownDescription\": \"Enables the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimized\",\n          \"markdownDescription\": \"Enables the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-resizable\",\n          \"markdownDescription\": \"Enables the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-visible\",\n          \"markdownDescription\": \"Enables the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-maximize\",\n          \"markdownDescription\": \"Enables the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-minimize\",\n          \"markdownDescription\": \"Enables the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-monitor-from-point\",\n          \"markdownDescription\": \"Enables the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-position\",\n          \"markdownDescription\": \"Enables the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-size\",\n          \"markdownDescription\": \"Enables the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-primary-monitor\",\n          \"markdownDescription\": \"Enables the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-request-user-attention\",\n          \"markdownDescription\": \"Enables the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-scale-factor\",\n          \"markdownDescription\": \"Enables the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-bottom\",\n          \"markdownDescription\": \"Enables the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-top\",\n          \"markdownDescription\": \"Enables the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-background-color\",\n          \"markdownDescription\": \"Enables the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-count\",\n          \"markdownDescription\": \"Enables the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-label\",\n          \"markdownDescription\": \"Enables the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-closable\",\n          \"markdownDescription\": \"Enables the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-content-protected\",\n          \"markdownDescription\": \"Enables the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-grab\",\n          \"markdownDescription\": \"Enables the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-icon\",\n          \"markdownDescription\": \"Enables the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-position\",\n          \"markdownDescription\": \"Enables the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-visible\",\n          \"markdownDescription\": \"Enables the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-decorations\",\n          \"markdownDescription\": \"Enables the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-effects\",\n          \"markdownDescription\": \"Enables the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focus\",\n          \"markdownDescription\": \"Enables the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focusable\",\n          \"markdownDescription\": \"Enables the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-fullscreen\",\n          \"markdownDescription\": \"Enables the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-max-size\",\n          \"markdownDescription\": \"Enables the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-maximizable\",\n          \"markdownDescription\": \"Enables the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-min-size\",\n          \"markdownDescription\": \"Enables the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-minimizable\",\n          \"markdownDescription\": \"Enables the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-overlay-icon\",\n          \"markdownDescription\": \"Enables the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-position\",\n          \"markdownDescription\": \"Enables the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-progress-bar\",\n          \"markdownDescription\": \"Enables the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-resizable\",\n          \"markdownDescription\": \"Enables the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-shadow\",\n          \"markdownDescription\": \"Enables the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-simple-fullscreen\",\n          \"markdownDescription\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size\",\n          \"markdownDescription\": \"Enables the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size-constraints\",\n          \"markdownDescription\": \"Enables the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-skip-taskbar\",\n          \"markdownDescription\": \"Enables the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-theme\",\n          \"markdownDescription\": \"Enables the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title-bar-style\",\n          \"markdownDescription\": \"Enables the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-dragging\",\n          \"markdownDescription\": \"Enables the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-resize-dragging\",\n          \"markdownDescription\": \"Enables the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-theme\",\n          \"markdownDescription\": \"Enables the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-title\",\n          \"markdownDescription\": \"Enables the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-toggle-maximize\",\n          \"markdownDescription\": \"Enables the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unmaximize\",\n          \"markdownDescription\": \"Enables the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unminimize\",\n          \"markdownDescription\": \"Enables the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-available-monitors\",\n          \"markdownDescription\": \"Denies the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-center\",\n          \"markdownDescription\": \"Denies the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-create\",\n          \"markdownDescription\": \"Denies the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-current-monitor\",\n          \"markdownDescription\": \"Denies the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-cursor-position\",\n          \"markdownDescription\": \"Denies the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-destroy\",\n          \"markdownDescription\": \"Denies the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-get-all-windows\",\n          \"markdownDescription\": \"Denies the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-hide\",\n          \"markdownDescription\": \"Denies the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-position\",\n          \"markdownDescription\": \"Denies the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-size\",\n          \"markdownDescription\": \"Denies the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-internal-toggle-maximize\",\n          \"markdownDescription\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-always-on-top\",\n          \"markdownDescription\": \"Denies the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-closable\",\n          \"markdownDescription\": \"Denies the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-decorated\",\n          \"markdownDescription\": \"Denies the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-focused\",\n          \"markdownDescription\": \"Denies the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-fullscreen\",\n          \"markdownDescription\": \"Denies the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximizable\",\n          \"markdownDescription\": \"Denies the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximized\",\n          \"markdownDescription\": \"Denies the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimizable\",\n          \"markdownDescription\": \"Denies the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimized\",\n          \"markdownDescription\": \"Denies the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-resizable\",\n          \"markdownDescription\": \"Denies the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-visible\",\n          \"markdownDescription\": \"Denies the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-maximize\",\n          \"markdownDescription\": \"Denies the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-minimize\",\n          \"markdownDescription\": \"Denies the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-monitor-from-point\",\n          \"markdownDescription\": \"Denies the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-position\",\n          \"markdownDescription\": \"Denies the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-size\",\n          \"markdownDescription\": \"Denies the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-primary-monitor\",\n          \"markdownDescription\": \"Denies the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-request-user-attention\",\n          \"markdownDescription\": \"Denies the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-scale-factor\",\n          \"markdownDescription\": \"Denies the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-bottom\",\n          \"markdownDescription\": \"Denies the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-top\",\n          \"markdownDescription\": \"Denies the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-background-color\",\n          \"markdownDescription\": \"Denies the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-count\",\n          \"markdownDescription\": \"Denies the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-label\",\n          \"markdownDescription\": \"Denies the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-closable\",\n          \"markdownDescription\": \"Denies the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-content-protected\",\n          \"markdownDescription\": \"Denies the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-grab\",\n          \"markdownDescription\": \"Denies the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-icon\",\n          \"markdownDescription\": \"Denies the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-position\",\n          \"markdownDescription\": \"Denies the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-visible\",\n          \"markdownDescription\": \"Denies the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-decorations\",\n          \"markdownDescription\": \"Denies the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-effects\",\n          \"markdownDescription\": \"Denies the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focus\",\n          \"markdownDescription\": \"Denies the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focusable\",\n          \"markdownDescription\": \"Denies the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-fullscreen\",\n          \"markdownDescription\": \"Denies the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-max-size\",\n          \"markdownDescription\": \"Denies the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-maximizable\",\n          \"markdownDescription\": \"Denies the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-min-size\",\n          \"markdownDescription\": \"Denies the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-minimizable\",\n          \"markdownDescription\": \"Denies the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-overlay-icon\",\n          \"markdownDescription\": \"Denies the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-position\",\n          \"markdownDescription\": \"Denies the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-progress-bar\",\n          \"markdownDescription\": \"Denies the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-resizable\",\n          \"markdownDescription\": \"Denies the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-shadow\",\n          \"markdownDescription\": \"Denies the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-simple-fullscreen\",\n          \"markdownDescription\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size\",\n          \"markdownDescription\": \"Denies the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size-constraints\",\n          \"markdownDescription\": \"Denies the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-skip-taskbar\",\n          \"markdownDescription\": \"Denies the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-theme\",\n          \"markdownDescription\": \"Denies the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title-bar-style\",\n          \"markdownDescription\": \"Denies the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-dragging\",\n          \"markdownDescription\": \"Denies the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-resize-dragging\",\n          \"markdownDescription\": \"Denies the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-theme\",\n          \"markdownDescription\": \"Denies the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-title\",\n          \"markdownDescription\": \"Denies the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-toggle-maximize\",\n          \"markdownDescription\": \"Denies the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unmaximize\",\n          \"markdownDescription\": \"Denies the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unminimize\",\n          \"markdownDescription\": \"Denies the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which\\nnotification features are by default exposed.\\n\\n#### Granted Permissions\\n\\nIt allows all notification related features.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-is-permission-granted`\\n- `allow-request-permission`\\n- `allow-notify`\\n- `allow-register-action-types`\\n- `allow-register-listener`\\n- `allow-cancel`\\n- `allow-get-pending`\\n- `allow-remove-active`\\n- `allow-get-active`\\n- `allow-check-permissions`\\n- `allow-show`\\n- `allow-batch`\\n- `allow-list-channels`\\n- `allow-delete-channel`\\n- `allow-create-channel`\\n- `allow-permission-state`\",\n          \"type\": \"string\",\n          \"const\": \"notification:default\",\n          \"markdownDescription\": \"This permission set configures which\\nnotification features are by default exposed.\\n\\n#### Granted Permissions\\n\\nIt allows all notification related features.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-is-permission-granted`\\n- `allow-request-permission`\\n- `allow-notify`\\n- `allow-register-action-types`\\n- `allow-register-listener`\\n- `allow-cancel`\\n- `allow-get-pending`\\n- `allow-remove-active`\\n- `allow-get-active`\\n- `allow-check-permissions`\\n- `allow-show`\\n- `allow-batch`\\n- `allow-list-channels`\\n- `allow-delete-channel`\\n- `allow-create-channel`\\n- `allow-permission-state`\"\n        },\n        {\n          \"description\": \"Enables the batch command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-batch\",\n          \"markdownDescription\": \"Enables the batch command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cancel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-cancel\",\n          \"markdownDescription\": \"Enables the cancel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the check_permissions command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-check-permissions\",\n          \"markdownDescription\": \"Enables the check_permissions command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-create-channel\",\n          \"markdownDescription\": \"Enables the create_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the delete_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-delete-channel\",\n          \"markdownDescription\": \"Enables the delete_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-get-active\",\n          \"markdownDescription\": \"Enables the get_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_pending command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-get-pending\",\n          \"markdownDescription\": \"Enables the get_pending command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_permission_granted command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-is-permission-granted\",\n          \"markdownDescription\": \"Enables the is_permission_granted command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the list_channels command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-list-channels\",\n          \"markdownDescription\": \"Enables the list_channels command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the notify command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-notify\",\n          \"markdownDescription\": \"Enables the notify command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the permission_state command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-permission-state\",\n          \"markdownDescription\": \"Enables the permission_state command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_action_types command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-register-action-types\",\n          \"markdownDescription\": \"Enables the register_action_types command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-remove-active\",\n          \"markdownDescription\": \"Enables the remove_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_permission command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-request-permission\",\n          \"markdownDescription\": \"Enables the request_permission command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the batch command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-batch\",\n          \"markdownDescription\": \"Denies the batch command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cancel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-cancel\",\n          \"markdownDescription\": \"Denies the cancel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the check_permissions command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-check-permissions\",\n          \"markdownDescription\": \"Denies the check_permissions command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-create-channel\",\n          \"markdownDescription\": \"Denies the create_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the delete_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-delete-channel\",\n          \"markdownDescription\": \"Denies the delete_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-get-active\",\n          \"markdownDescription\": \"Denies the get_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_pending command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-get-pending\",\n          \"markdownDescription\": \"Denies the get_pending command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_permission_granted command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-is-permission-granted\",\n          \"markdownDescription\": \"Denies the is_permission_granted command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the list_channels command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-list-channels\",\n          \"markdownDescription\": \"Denies the list_channels command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the notify command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-notify\",\n          \"markdownDescription\": \"Denies the notify command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the permission_state command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-permission-state\",\n          \"markdownDescription\": \"Denies the permission_state command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_action_types command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-register-action-types\",\n          \"markdownDescription\": \"Denies the register_action_types command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-remove-active\",\n          \"markdownDescription\": \"Denies the remove_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_permission command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-request-permission\",\n          \"markdownDescription\": \"Denies the request_permission command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n          \"type\": \"string\",\n          \"const\": \"opener:default\",\n          \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n        },\n        {\n          \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-default-urls\",\n          \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n        },\n        {\n          \"description\": \"Enables the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-path\",\n          \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-url\",\n          \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-reveal-item-in-dir\",\n          \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-path\",\n          \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-url\",\n          \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-reveal-item-in-dir\",\n          \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\",\n          \"type\": \"string\",\n          \"const\": \"shell:default\",\n          \"markdownDescription\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\"\n        },\n        {\n          \"description\": \"Enables the execute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-execute\",\n          \"markdownDescription\": \"Enables the execute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the kill command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-kill\",\n          \"markdownDescription\": \"Enables the kill command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-open\",\n          \"markdownDescription\": \"Enables the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the spawn command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-spawn\",\n          \"markdownDescription\": \"Enables the spawn command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the stdin_write command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-stdin-write\",\n          \"markdownDescription\": \"Enables the stdin_write command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the execute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-execute\",\n          \"markdownDescription\": \"Denies the execute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the kill command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-kill\",\n          \"markdownDescription\": \"Denies the kill command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-open\",\n          \"markdownDescription\": \"Denies the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the spawn command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-spawn\",\n          \"markdownDescription\": \"Denies the spawn command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the stdin_write command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-stdin-write\",\n          \"markdownDescription\": \"Denies the stdin_write command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\",\n          \"type\": \"string\",\n          \"const\": \"updater:default\",\n          \"markdownDescription\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\"\n        },\n        {\n          \"description\": \"Enables the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-check\",\n          \"markdownDescription\": \"Enables the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download\",\n          \"markdownDescription\": \"Enables the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download-and-install\",\n          \"markdownDescription\": \"Enables the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-install\",\n          \"markdownDescription\": \"Enables the install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-check\",\n          \"markdownDescription\": \"Denies the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download\",\n          \"markdownDescription\": \"Denies the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download-and-install\",\n          \"markdownDescription\": \"Denies the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-install\",\n          \"markdownDescription\": \"Denies the install command without any pre-configured scope.\"\n        }\n      ]\n    },\n    \"Value\": {\n      \"description\": \"All supported ACL values.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents a null JSON value.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"Represents a [`bool`].\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Represents a valid ACL [`Number`].\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Number\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Represents a [`String`].\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"Represents a list of other [`Value`]s.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        },\n        {\n          \"description\": \"Represents a map of [`String`] keys to [`Value`]s.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        }\n      ]\n    },\n    \"Number\": {\n      \"description\": \"A valid ACL number.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents an [`i64`].\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        {\n          \"description\": \"Represents a [`f64`].\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      ]\n    },\n    \"Target\": {\n      \"description\": \"Platform target.\",\n      \"oneOf\": [\n        {\n          \"description\": \"MacOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"macOS\"\n          ]\n        },\n        {\n          \"description\": \"Windows.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"windows\"\n          ]\n        },\n        {\n          \"description\": \"Linux.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"linux\"\n          ]\n        },\n        {\n          \"description\": \"Android.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"android\"\n          ]\n        },\n        {\n          \"description\": \"iOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iOS\"\n          ]\n        }\n      ]\n    },\n    \"Application\": {\n      \"description\": \"Opener scope application.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Open in default application.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"If true, allow open with any application.\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Allow specific application to open with.\",\n          \"type\": \"string\"\n        }\n      ]\n    },\n    \"ShellScopeEntryAllowedArg\": {\n      \"description\": \"A command argument allowed to be executed by the webview API.\",\n      \"anyOf\": [\n        {\n          \"description\": \"A non-configurable argument that is passed to the command in the order it was specified.\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"A variable that is set while calling the command from the webview API.\",\n          \"type\": \"object\",\n          \"required\": [\n            \"validator\"\n          ],\n          \"properties\": {\n            \"raw\": {\n              \"description\": \"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\\n\\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.\",\n              \"default\": false,\n              \"type\": \"boolean\"\n            },\n            \"validator\": {\n              \"description\": \"[regex] validator to require passed values to conform to an expected input.\\n\\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\\n\\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\\\w+` regex would be registered as `^https?://\\\\w+$`.\\n\\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>\",\n              \"type\": \"string\"\n            }\n          },\n          \"additionalProperties\": false\n        }\n      ]\n    },\n    \"ShellScopeEntryAllowedArgs\": {\n      \"description\": \"A set of command arguments allowed to be executed by the webview API.\\n\\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Use a simple boolean to allow all or disable all arguments to this command configuration.\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/ShellScopeEntryAllowedArg\"\n          }\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "crates/tauri-app/gen/schemas/macOS-schema.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"CapabilityFile\",\n  \"description\": \"Capability formats accepted in a capability file.\",\n  \"anyOf\": [\n    {\n      \"description\": \"A single capability.\",\n      \"allOf\": [\n        {\n          \"$ref\": \"#/definitions/Capability\"\n        }\n      ]\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Capability\"\n      }\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"capabilities\"\n      ],\n      \"properties\": {\n        \"capabilities\": {\n          \"description\": \"The list of capabilities.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Capability\"\n          }\n        }\n      }\n    }\n  ],\n  \"definitions\": {\n    \"Capability\": {\n      \"description\": \"A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\\n\\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\\n\\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\\n\\n## Example\\n\\n```json { \\\"identifier\\\": \\\"main-user-files-write\\\", \\\"description\\\": \\\"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\\\", \\\"windows\\\": [ \\\"main\\\" ], \\\"permissions\\\": [ \\\"core:default\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] }, ], \\\"platforms\\\": [\\\"macOS\\\",\\\"windows\\\"] } ```\",\n      \"type\": \"object\",\n      \"required\": [\n        \"identifier\",\n        \"permissions\"\n      ],\n      \"properties\": {\n        \"identifier\": {\n          \"description\": \"Identifier of the capability.\\n\\n## Example\\n\\n`main-user-files-write`\",\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"description\": \"Description of what the capability is intended to allow on associated windows.\\n\\nIt should contain a description of what the grouped permissions should allow.\\n\\n## Example\\n\\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\",\n          \"default\": \"\",\n          \"type\": \"string\"\n        },\n        \"remote\": {\n          \"description\": \"Configure remote URLs that can use the capability permissions.\\n\\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\\n\\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\\n\\n## Example\\n\\n```json { \\\"urls\\\": [\\\"https://*.mydomain.dev\\\"] } ```\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/CapabilityRemote\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"local\": {\n          \"description\": \"Whether this capability is enabled for local app URLs or not. Defaults to `true`.\",\n          \"default\": true,\n          \"type\": \"boolean\"\n        },\n        \"windows\": {\n          \"description\": \"List of windows that are affected by this capability. Can be a glob pattern.\\n\\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\\n\\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\\n\\n## Example\\n\\n`[\\\"main\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"webviews\": {\n          \"description\": \"List of webviews that are affected by this capability. Can be a glob pattern.\\n\\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\\n\\n## Example\\n\\n`[\\\"sub-webview-one\\\", \\\"sub-webview-two\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"permissions\": {\n          \"description\": \"List of permissions attached to this capability.\\n\\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\\n\\n## Example\\n\\n```json [ \\\"core:default\\\", \\\"shell:allow-open\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] } ] ```\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/PermissionEntry\"\n          },\n          \"uniqueItems\": true\n        },\n        \"platforms\": {\n          \"description\": \"Limit which target platforms this capability applies to.\\n\\nBy default all platforms are targeted.\\n\\n## Example\\n\\n`[\\\"macOS\\\",\\\"windows\\\"]`\",\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"$ref\": \"#/definitions/Target\"\n          }\n        }\n      }\n    },\n    \"CapabilityRemote\": {\n      \"description\": \"Configuration for remote URLs that are associated with the capability.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"urls\"\n      ],\n      \"properties\": {\n        \"urls\": {\n          \"description\": \"Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\\n\\n## Examples\\n\\n- \\\"https://*.mydomain.dev\\\": allows subdomains of mydomain.dev - \\\"https://mydomain.dev/api/*\\\": allows any subpath of mydomain.dev/api\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    },\n    \"PermissionEntry\": {\n      \"description\": \"An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Reference a permission or permission set by identifier.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Identifier\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Reference a permission or permission set by identifier and extends its scope.\",\n          \"type\": \"object\",\n          \"allOf\": [\n            {\n              \"if\": {\n                \"properties\": {\n                  \"identifier\": {\n                    \"anyOf\": [\n                      {\n                        \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:default\",\n                        \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n                      },\n                      {\n                        \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-default-urls\",\n                        \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-path\",\n                        \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-url\",\n                        \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-path\",\n                        \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-url\",\n                        \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n                      }\n                    ]\n                  }\n                }\n              },\n              \"then\": {\n                \"properties\": {\n                  \"allow\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  },\n                  \"deny\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  }\n                }\n              },\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                }\n              }\n            },\n            {\n              \"if\": {\n                \"properties\": {\n                  \"identifier\": {\n                    \"anyOf\": [\n                      {\n                        \"description\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:default\",\n                        \"markdownDescription\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\"\n                      },\n                      {\n                        \"description\": \"Enables the execute command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-execute\",\n                        \"markdownDescription\": \"Enables the execute command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the kill command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-kill\",\n                        \"markdownDescription\": \"Enables the kill command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the open command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-open\",\n                        \"markdownDescription\": \"Enables the open command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the spawn command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-spawn\",\n                        \"markdownDescription\": \"Enables the spawn command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the stdin_write command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:allow-stdin-write\",\n                        \"markdownDescription\": \"Enables the stdin_write command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the execute command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-execute\",\n                        \"markdownDescription\": \"Denies the execute command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the kill command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-kill\",\n                        \"markdownDescription\": \"Denies the kill command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-open\",\n                        \"markdownDescription\": \"Denies the open command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the spawn command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-spawn\",\n                        \"markdownDescription\": \"Denies the spawn command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the stdin_write command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"shell:deny-stdin-write\",\n                        \"markdownDescription\": \"Denies the stdin_write command without any pre-configured scope.\"\n                      }\n                    ]\n                  }\n                }\n              },\n              \"then\": {\n                \"properties\": {\n                  \"allow\": {\n                    \"items\": {\n                      \"title\": \"ShellScopeEntry\",\n                      \"description\": \"Shell scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"cmd\",\n                            \"name\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"cmd\": {\n                              \"description\": \"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"name\",\n                            \"sidecar\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            },\n                            \"sidecar\": {\n                              \"description\": \"If this command is a sidecar command.\",\n                              \"type\": \"boolean\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        }\n                      ]\n                    }\n                  },\n                  \"deny\": {\n                    \"items\": {\n                      \"title\": \"ShellScopeEntry\",\n                      \"description\": \"Shell scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"cmd\",\n                            \"name\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"cmd\": {\n                              \"description\": \"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"name\",\n                            \"sidecar\"\n                          ],\n                          \"properties\": {\n                            \"args\": {\n                              \"description\": \"The allowed arguments for the command execution.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/ShellScopeEntryAllowedArgs\"\n                                }\n                              ]\n                            },\n                            \"name\": {\n                              \"description\": \"The name for this allowed shell command configuration.\\n\\nThis name will be used inside of the webview API to call this command along with any specified arguments.\",\n                              \"type\": \"string\"\n                            },\n                            \"sidecar\": {\n                              \"description\": \"If this command is a sidecar command.\",\n                              \"type\": \"boolean\"\n                            }\n                          },\n                          \"additionalProperties\": false\n                        }\n                      ]\n                    }\n                  }\n                }\n              },\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                }\n              }\n            },\n            {\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                },\n                \"allow\": {\n                  \"description\": \"Data that defines what is allowed by the scope.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                },\n                \"deny\": {\n                  \"description\": \"Data that defines what is denied by the scope. This should be prioritized by validation logic.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                }\n              }\n            }\n          ],\n          \"required\": [\n            \"identifier\"\n          ]\n        }\n      ]\n    },\n    \"Identifier\": {\n      \"description\": \"Permission identifier\",\n      \"oneOf\": [\n        {\n          \"description\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\",\n          \"type\": \"string\",\n          \"const\": \"core:default\",\n          \"markdownDescription\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\",\n          \"type\": \"string\",\n          \"const\": \"core:app:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\"\n        },\n        {\n          \"description\": \"Enables the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-hide\",\n          \"markdownDescription\": \"Enables the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-show\",\n          \"markdownDescription\": \"Enables the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-bundle-type\",\n          \"markdownDescription\": \"Enables the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-default-window-icon\",\n          \"markdownDescription\": \"Enables the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-identifier\",\n          \"markdownDescription\": \"Enables the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-name\",\n          \"markdownDescription\": \"Enables the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-data-store\",\n          \"markdownDescription\": \"Enables the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-listener\",\n          \"markdownDescription\": \"Enables the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-app-theme\",\n          \"markdownDescription\": \"Enables the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-dock-visibility\",\n          \"markdownDescription\": \"Enables the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-tauri-version\",\n          \"markdownDescription\": \"Enables the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-version\",\n          \"markdownDescription\": \"Enables the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-hide\",\n          \"markdownDescription\": \"Denies the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-show\",\n          \"markdownDescription\": \"Denies the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-bundle-type\",\n          \"markdownDescription\": \"Denies the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-default-window-icon\",\n          \"markdownDescription\": \"Denies the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-identifier\",\n          \"markdownDescription\": \"Denies the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-name\",\n          \"markdownDescription\": \"Denies the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-data-store\",\n          \"markdownDescription\": \"Denies the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-listener\",\n          \"markdownDescription\": \"Denies the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-app-theme\",\n          \"markdownDescription\": \"Denies the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-dock-visibility\",\n          \"markdownDescription\": \"Denies the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-tauri-version\",\n          \"markdownDescription\": \"Denies the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-version\",\n          \"markdownDescription\": \"Denies the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\",\n          \"type\": \"string\",\n          \"const\": \"core:event:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\"\n        },\n        {\n          \"description\": \"Enables the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit\",\n          \"markdownDescription\": \"Enables the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit-to\",\n          \"markdownDescription\": \"Enables the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-listen\",\n          \"markdownDescription\": \"Enables the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-unlisten\",\n          \"markdownDescription\": \"Enables the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit\",\n          \"markdownDescription\": \"Denies the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit-to\",\n          \"markdownDescription\": \"Denies the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-listen\",\n          \"markdownDescription\": \"Denies the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-unlisten\",\n          \"markdownDescription\": \"Denies the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\",\n          \"type\": \"string\",\n          \"const\": \"core:image:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\"\n        },\n        {\n          \"description\": \"Enables the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-bytes\",\n          \"markdownDescription\": \"Enables the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-path\",\n          \"markdownDescription\": \"Enables the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-rgba\",\n          \"markdownDescription\": \"Enables the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-size\",\n          \"markdownDescription\": \"Enables the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-bytes\",\n          \"markdownDescription\": \"Denies the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-path\",\n          \"markdownDescription\": \"Denies the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-rgba\",\n          \"markdownDescription\": \"Denies the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-size\",\n          \"markdownDescription\": \"Denies the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\"\n        },\n        {\n          \"description\": \"Enables the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-append\",\n          \"markdownDescription\": \"Enables the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-create-default\",\n          \"markdownDescription\": \"Enables the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-get\",\n          \"markdownDescription\": \"Enables the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-insert\",\n          \"markdownDescription\": \"Enables the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-checked\",\n          \"markdownDescription\": \"Enables the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-items\",\n          \"markdownDescription\": \"Enables the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-popup\",\n          \"markdownDescription\": \"Enables the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-prepend\",\n          \"markdownDescription\": \"Enables the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove\",\n          \"markdownDescription\": \"Enables the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove-at\",\n          \"markdownDescription\": \"Enables the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-accelerator\",\n          \"markdownDescription\": \"Enables the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-app-menu\",\n          \"markdownDescription\": \"Enables the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-window-menu\",\n          \"markdownDescription\": \"Enables the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-checked\",\n          \"markdownDescription\": \"Enables the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-text\",\n          \"markdownDescription\": \"Enables the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-text\",\n          \"markdownDescription\": \"Enables the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-append\",\n          \"markdownDescription\": \"Denies the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-create-default\",\n          \"markdownDescription\": \"Denies the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-get\",\n          \"markdownDescription\": \"Denies the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-insert\",\n          \"markdownDescription\": \"Denies the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-checked\",\n          \"markdownDescription\": \"Denies the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-items\",\n          \"markdownDescription\": \"Denies the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-popup\",\n          \"markdownDescription\": \"Denies the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-prepend\",\n          \"markdownDescription\": \"Denies the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove\",\n          \"markdownDescription\": \"Denies the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove-at\",\n          \"markdownDescription\": \"Denies the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-accelerator\",\n          \"markdownDescription\": \"Denies the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-app-menu\",\n          \"markdownDescription\": \"Denies the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-window-menu\",\n          \"markdownDescription\": \"Denies the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-checked\",\n          \"markdownDescription\": \"Denies the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-text\",\n          \"markdownDescription\": \"Denies the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-text\",\n          \"markdownDescription\": \"Denies the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\",\n          \"type\": \"string\",\n          \"const\": \"core:path:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\"\n        },\n        {\n          \"description\": \"Enables the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-basename\",\n          \"markdownDescription\": \"Enables the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-dirname\",\n          \"markdownDescription\": \"Enables the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-extname\",\n          \"markdownDescription\": \"Enables the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-is-absolute\",\n          \"markdownDescription\": \"Enables the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-join\",\n          \"markdownDescription\": \"Enables the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-normalize\",\n          \"markdownDescription\": \"Enables the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve\",\n          \"markdownDescription\": \"Enables the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve-directory\",\n          \"markdownDescription\": \"Enables the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-basename\",\n          \"markdownDescription\": \"Denies the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-dirname\",\n          \"markdownDescription\": \"Denies the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-extname\",\n          \"markdownDescription\": \"Denies the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-is-absolute\",\n          \"markdownDescription\": \"Denies the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-join\",\n          \"markdownDescription\": \"Denies the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-normalize\",\n          \"markdownDescription\": \"Denies the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve\",\n          \"markdownDescription\": \"Denies the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve-directory\",\n          \"markdownDescription\": \"Denies the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\"\n        },\n        {\n          \"description\": \"Enables the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-get-by-id\",\n          \"markdownDescription\": \"Enables the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-remove-by-id\",\n          \"markdownDescription\": \"Enables the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon-as-template\",\n          \"markdownDescription\": \"Enables the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-menu\",\n          \"markdownDescription\": \"Enables the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-temp-dir-path\",\n          \"markdownDescription\": \"Enables the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-tooltip\",\n          \"markdownDescription\": \"Enables the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-visible\",\n          \"markdownDescription\": \"Enables the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-get-by-id\",\n          \"markdownDescription\": \"Denies the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-remove-by-id\",\n          \"markdownDescription\": \"Denies the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon-as-template\",\n          \"markdownDescription\": \"Denies the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-menu\",\n          \"markdownDescription\": \"Denies the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-temp-dir-path\",\n          \"markdownDescription\": \"Denies the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-tooltip\",\n          \"markdownDescription\": \"Denies the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-visible\",\n          \"markdownDescription\": \"Denies the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\"\n        },\n        {\n          \"description\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-clear-all-browsing-data\",\n          \"markdownDescription\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview\",\n          \"markdownDescription\": \"Enables the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview-window\",\n          \"markdownDescription\": \"Enables the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-get-all-webviews\",\n          \"markdownDescription\": \"Enables the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-internal-toggle-devtools\",\n          \"markdownDescription\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-print\",\n          \"markdownDescription\": \"Enables the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-reparent\",\n          \"markdownDescription\": \"Enables the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-auto-resize\",\n          \"markdownDescription\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-background-color\",\n          \"markdownDescription\": \"Enables the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-focus\",\n          \"markdownDescription\": \"Enables the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-position\",\n          \"markdownDescription\": \"Enables the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-size\",\n          \"markdownDescription\": \"Enables the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-zoom\",\n          \"markdownDescription\": \"Enables the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-close\",\n          \"markdownDescription\": \"Enables the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-hide\",\n          \"markdownDescription\": \"Enables the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-position\",\n          \"markdownDescription\": \"Enables the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-show\",\n          \"markdownDescription\": \"Enables the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-size\",\n          \"markdownDescription\": \"Enables the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-clear-all-browsing-data\",\n          \"markdownDescription\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview\",\n          \"markdownDescription\": \"Denies the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview-window\",\n          \"markdownDescription\": \"Denies the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-get-all-webviews\",\n          \"markdownDescription\": \"Denies the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-internal-toggle-devtools\",\n          \"markdownDescription\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-print\",\n          \"markdownDescription\": \"Denies the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-reparent\",\n          \"markdownDescription\": \"Denies the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-auto-resize\",\n          \"markdownDescription\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-background-color\",\n          \"markdownDescription\": \"Denies the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-focus\",\n          \"markdownDescription\": \"Denies the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-position\",\n          \"markdownDescription\": \"Denies the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-size\",\n          \"markdownDescription\": \"Denies the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-zoom\",\n          \"markdownDescription\": \"Denies the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-close\",\n          \"markdownDescription\": \"Denies the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-hide\",\n          \"markdownDescription\": \"Denies the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-position\",\n          \"markdownDescription\": \"Denies the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-show\",\n          \"markdownDescription\": \"Denies the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-size\",\n          \"markdownDescription\": \"Denies the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\",\n          \"type\": \"string\",\n          \"const\": \"core:window:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\"\n        },\n        {\n          \"description\": \"Enables the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-available-monitors\",\n          \"markdownDescription\": \"Enables the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-center\",\n          \"markdownDescription\": \"Enables the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-create\",\n          \"markdownDescription\": \"Enables the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-current-monitor\",\n          \"markdownDescription\": \"Enables the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-cursor-position\",\n          \"markdownDescription\": \"Enables the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-destroy\",\n          \"markdownDescription\": \"Enables the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-get-all-windows\",\n          \"markdownDescription\": \"Enables the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-hide\",\n          \"markdownDescription\": \"Enables the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-position\",\n          \"markdownDescription\": \"Enables the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-size\",\n          \"markdownDescription\": \"Enables the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-internal-toggle-maximize\",\n          \"markdownDescription\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-always-on-top\",\n          \"markdownDescription\": \"Enables the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-closable\",\n          \"markdownDescription\": \"Enables the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-decorated\",\n          \"markdownDescription\": \"Enables the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-focused\",\n          \"markdownDescription\": \"Enables the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-fullscreen\",\n          \"markdownDescription\": \"Enables the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximizable\",\n          \"markdownDescription\": \"Enables the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximized\",\n          \"markdownDescription\": \"Enables the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimizable\",\n          \"markdownDescription\": \"Enables the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimized\",\n          \"markdownDescription\": \"Enables the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-resizable\",\n          \"markdownDescription\": \"Enables the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-visible\",\n          \"markdownDescription\": \"Enables the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-maximize\",\n          \"markdownDescription\": \"Enables the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-minimize\",\n          \"markdownDescription\": \"Enables the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-monitor-from-point\",\n          \"markdownDescription\": \"Enables the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-position\",\n          \"markdownDescription\": \"Enables the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-size\",\n          \"markdownDescription\": \"Enables the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-primary-monitor\",\n          \"markdownDescription\": \"Enables the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-request-user-attention\",\n          \"markdownDescription\": \"Enables the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-scale-factor\",\n          \"markdownDescription\": \"Enables the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-bottom\",\n          \"markdownDescription\": \"Enables the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-top\",\n          \"markdownDescription\": \"Enables the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-background-color\",\n          \"markdownDescription\": \"Enables the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-count\",\n          \"markdownDescription\": \"Enables the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-label\",\n          \"markdownDescription\": \"Enables the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-closable\",\n          \"markdownDescription\": \"Enables the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-content-protected\",\n          \"markdownDescription\": \"Enables the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-grab\",\n          \"markdownDescription\": \"Enables the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-icon\",\n          \"markdownDescription\": \"Enables the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-position\",\n          \"markdownDescription\": \"Enables the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-visible\",\n          \"markdownDescription\": \"Enables the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-decorations\",\n          \"markdownDescription\": \"Enables the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-effects\",\n          \"markdownDescription\": \"Enables the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focus\",\n          \"markdownDescription\": \"Enables the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focusable\",\n          \"markdownDescription\": \"Enables the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-fullscreen\",\n          \"markdownDescription\": \"Enables the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-max-size\",\n          \"markdownDescription\": \"Enables the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-maximizable\",\n          \"markdownDescription\": \"Enables the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-min-size\",\n          \"markdownDescription\": \"Enables the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-minimizable\",\n          \"markdownDescription\": \"Enables the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-overlay-icon\",\n          \"markdownDescription\": \"Enables the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-position\",\n          \"markdownDescription\": \"Enables the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-progress-bar\",\n          \"markdownDescription\": \"Enables the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-resizable\",\n          \"markdownDescription\": \"Enables the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-shadow\",\n          \"markdownDescription\": \"Enables the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-simple-fullscreen\",\n          \"markdownDescription\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size\",\n          \"markdownDescription\": \"Enables the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size-constraints\",\n          \"markdownDescription\": \"Enables the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-skip-taskbar\",\n          \"markdownDescription\": \"Enables the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-theme\",\n          \"markdownDescription\": \"Enables the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title-bar-style\",\n          \"markdownDescription\": \"Enables the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-dragging\",\n          \"markdownDescription\": \"Enables the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-resize-dragging\",\n          \"markdownDescription\": \"Enables the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-theme\",\n          \"markdownDescription\": \"Enables the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-title\",\n          \"markdownDescription\": \"Enables the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-toggle-maximize\",\n          \"markdownDescription\": \"Enables the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unmaximize\",\n          \"markdownDescription\": \"Enables the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unminimize\",\n          \"markdownDescription\": \"Enables the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-available-monitors\",\n          \"markdownDescription\": \"Denies the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-center\",\n          \"markdownDescription\": \"Denies the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-create\",\n          \"markdownDescription\": \"Denies the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-current-monitor\",\n          \"markdownDescription\": \"Denies the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-cursor-position\",\n          \"markdownDescription\": \"Denies the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-destroy\",\n          \"markdownDescription\": \"Denies the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-get-all-windows\",\n          \"markdownDescription\": \"Denies the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-hide\",\n          \"markdownDescription\": \"Denies the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-position\",\n          \"markdownDescription\": \"Denies the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-size\",\n          \"markdownDescription\": \"Denies the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-internal-toggle-maximize\",\n          \"markdownDescription\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-always-on-top\",\n          \"markdownDescription\": \"Denies the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-closable\",\n          \"markdownDescription\": \"Denies the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-decorated\",\n          \"markdownDescription\": \"Denies the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-focused\",\n          \"markdownDescription\": \"Denies the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-fullscreen\",\n          \"markdownDescription\": \"Denies the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximizable\",\n          \"markdownDescription\": \"Denies the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximized\",\n          \"markdownDescription\": \"Denies the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimizable\",\n          \"markdownDescription\": \"Denies the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimized\",\n          \"markdownDescription\": \"Denies the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-resizable\",\n          \"markdownDescription\": \"Denies the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-visible\",\n          \"markdownDescription\": \"Denies the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-maximize\",\n          \"markdownDescription\": \"Denies the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-minimize\",\n          \"markdownDescription\": \"Denies the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-monitor-from-point\",\n          \"markdownDescription\": \"Denies the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-position\",\n          \"markdownDescription\": \"Denies the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-size\",\n          \"markdownDescription\": \"Denies the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-primary-monitor\",\n          \"markdownDescription\": \"Denies the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-request-user-attention\",\n          \"markdownDescription\": \"Denies the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-scale-factor\",\n          \"markdownDescription\": \"Denies the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-bottom\",\n          \"markdownDescription\": \"Denies the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-top\",\n          \"markdownDescription\": \"Denies the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-background-color\",\n          \"markdownDescription\": \"Denies the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-count\",\n          \"markdownDescription\": \"Denies the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-label\",\n          \"markdownDescription\": \"Denies the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-closable\",\n          \"markdownDescription\": \"Denies the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-content-protected\",\n          \"markdownDescription\": \"Denies the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-grab\",\n          \"markdownDescription\": \"Denies the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-icon\",\n          \"markdownDescription\": \"Denies the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-position\",\n          \"markdownDescription\": \"Denies the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-visible\",\n          \"markdownDescription\": \"Denies the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-decorations\",\n          \"markdownDescription\": \"Denies the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-effects\",\n          \"markdownDescription\": \"Denies the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focus\",\n          \"markdownDescription\": \"Denies the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focusable\",\n          \"markdownDescription\": \"Denies the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-fullscreen\",\n          \"markdownDescription\": \"Denies the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-max-size\",\n          \"markdownDescription\": \"Denies the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-maximizable\",\n          \"markdownDescription\": \"Denies the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-min-size\",\n          \"markdownDescription\": \"Denies the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-minimizable\",\n          \"markdownDescription\": \"Denies the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-overlay-icon\",\n          \"markdownDescription\": \"Denies the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-position\",\n          \"markdownDescription\": \"Denies the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-progress-bar\",\n          \"markdownDescription\": \"Denies the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-resizable\",\n          \"markdownDescription\": \"Denies the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-shadow\",\n          \"markdownDescription\": \"Denies the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-simple-fullscreen\",\n          \"markdownDescription\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size\",\n          \"markdownDescription\": \"Denies the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size-constraints\",\n          \"markdownDescription\": \"Denies the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-skip-taskbar\",\n          \"markdownDescription\": \"Denies the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-theme\",\n          \"markdownDescription\": \"Denies the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title-bar-style\",\n          \"markdownDescription\": \"Denies the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-dragging\",\n          \"markdownDescription\": \"Denies the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-resize-dragging\",\n          \"markdownDescription\": \"Denies the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-theme\",\n          \"markdownDescription\": \"Denies the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-title\",\n          \"markdownDescription\": \"Denies the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-toggle-maximize\",\n          \"markdownDescription\": \"Denies the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unmaximize\",\n          \"markdownDescription\": \"Denies the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unminimize\",\n          \"markdownDescription\": \"Denies the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which\\nnotification features are by default exposed.\\n\\n#### Granted Permissions\\n\\nIt allows all notification related features.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-is-permission-granted`\\n- `allow-request-permission`\\n- `allow-notify`\\n- `allow-register-action-types`\\n- `allow-register-listener`\\n- `allow-cancel`\\n- `allow-get-pending`\\n- `allow-remove-active`\\n- `allow-get-active`\\n- `allow-check-permissions`\\n- `allow-show`\\n- `allow-batch`\\n- `allow-list-channels`\\n- `allow-delete-channel`\\n- `allow-create-channel`\\n- `allow-permission-state`\",\n          \"type\": \"string\",\n          \"const\": \"notification:default\",\n          \"markdownDescription\": \"This permission set configures which\\nnotification features are by default exposed.\\n\\n#### Granted Permissions\\n\\nIt allows all notification related features.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-is-permission-granted`\\n- `allow-request-permission`\\n- `allow-notify`\\n- `allow-register-action-types`\\n- `allow-register-listener`\\n- `allow-cancel`\\n- `allow-get-pending`\\n- `allow-remove-active`\\n- `allow-get-active`\\n- `allow-check-permissions`\\n- `allow-show`\\n- `allow-batch`\\n- `allow-list-channels`\\n- `allow-delete-channel`\\n- `allow-create-channel`\\n- `allow-permission-state`\"\n        },\n        {\n          \"description\": \"Enables the batch command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-batch\",\n          \"markdownDescription\": \"Enables the batch command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cancel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-cancel\",\n          \"markdownDescription\": \"Enables the cancel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the check_permissions command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-check-permissions\",\n          \"markdownDescription\": \"Enables the check_permissions command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-create-channel\",\n          \"markdownDescription\": \"Enables the create_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the delete_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-delete-channel\",\n          \"markdownDescription\": \"Enables the delete_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-get-active\",\n          \"markdownDescription\": \"Enables the get_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_pending command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-get-pending\",\n          \"markdownDescription\": \"Enables the get_pending command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_permission_granted command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-is-permission-granted\",\n          \"markdownDescription\": \"Enables the is_permission_granted command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the list_channels command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-list-channels\",\n          \"markdownDescription\": \"Enables the list_channels command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the notify command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-notify\",\n          \"markdownDescription\": \"Enables the notify command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the permission_state command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-permission-state\",\n          \"markdownDescription\": \"Enables the permission_state command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_action_types command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-register-action-types\",\n          \"markdownDescription\": \"Enables the register_action_types command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-remove-active\",\n          \"markdownDescription\": \"Enables the remove_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_permission command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-request-permission\",\n          \"markdownDescription\": \"Enables the request_permission command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the batch command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-batch\",\n          \"markdownDescription\": \"Denies the batch command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cancel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-cancel\",\n          \"markdownDescription\": \"Denies the cancel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the check_permissions command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-check-permissions\",\n          \"markdownDescription\": \"Denies the check_permissions command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-create-channel\",\n          \"markdownDescription\": \"Denies the create_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the delete_channel command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-delete-channel\",\n          \"markdownDescription\": \"Denies the delete_channel command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-get-active\",\n          \"markdownDescription\": \"Denies the get_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_pending command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-get-pending\",\n          \"markdownDescription\": \"Denies the get_pending command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_permission_granted command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-is-permission-granted\",\n          \"markdownDescription\": \"Denies the is_permission_granted command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the list_channels command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-list-channels\",\n          \"markdownDescription\": \"Denies the list_channels command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the notify command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-notify\",\n          \"markdownDescription\": \"Denies the notify command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the permission_state command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-permission-state\",\n          \"markdownDescription\": \"Denies the permission_state command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_action_types command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-register-action-types\",\n          \"markdownDescription\": \"Denies the register_action_types command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_active command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-remove-active\",\n          \"markdownDescription\": \"Denies the remove_active command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_permission command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-request-permission\",\n          \"markdownDescription\": \"Denies the request_permission command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"notification:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n          \"type\": \"string\",\n          \"const\": \"opener:default\",\n          \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n        },\n        {\n          \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-default-urls\",\n          \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n        },\n        {\n          \"description\": \"Enables the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-path\",\n          \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-url\",\n          \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-reveal-item-in-dir\",\n          \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-path\",\n          \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-url\",\n          \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-reveal-item-in-dir\",\n          \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\",\n          \"type\": \"string\",\n          \"const\": \"shell:default\",\n          \"markdownDescription\": \"This permission set configures which\\nshell functionality is exposed by default.\\n\\n#### Granted Permissions\\n\\nIt allows to use the `open` functionality with a reasonable\\nscope pre-configured. It will allow opening `http(s)://`,\\n`tel:` and `mailto:` links.\\n\\n#### This default permission set includes:\\n\\n- `allow-open`\"\n        },\n        {\n          \"description\": \"Enables the execute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-execute\",\n          \"markdownDescription\": \"Enables the execute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the kill command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-kill\",\n          \"markdownDescription\": \"Enables the kill command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-open\",\n          \"markdownDescription\": \"Enables the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the spawn command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-spawn\",\n          \"markdownDescription\": \"Enables the spawn command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the stdin_write command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:allow-stdin-write\",\n          \"markdownDescription\": \"Enables the stdin_write command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the execute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-execute\",\n          \"markdownDescription\": \"Denies the execute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the kill command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-kill\",\n          \"markdownDescription\": \"Denies the kill command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-open\",\n          \"markdownDescription\": \"Denies the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the spawn command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-spawn\",\n          \"markdownDescription\": \"Denies the spawn command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the stdin_write command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"shell:deny-stdin-write\",\n          \"markdownDescription\": \"Denies the stdin_write command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\",\n          \"type\": \"string\",\n          \"const\": \"updater:default\",\n          \"markdownDescription\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\"\n        },\n        {\n          \"description\": \"Enables the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-check\",\n          \"markdownDescription\": \"Enables the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download\",\n          \"markdownDescription\": \"Enables the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download-and-install\",\n          \"markdownDescription\": \"Enables the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-install\",\n          \"markdownDescription\": \"Enables the install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-check\",\n          \"markdownDescription\": \"Denies the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download\",\n          \"markdownDescription\": \"Denies the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download-and-install\",\n          \"markdownDescription\": \"Denies the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-install\",\n          \"markdownDescription\": \"Denies the install command without any pre-configured scope.\"\n        }\n      ]\n    },\n    \"Value\": {\n      \"description\": \"All supported ACL values.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents a null JSON value.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"Represents a [`bool`].\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Represents a valid ACL [`Number`].\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Number\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Represents a [`String`].\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"Represents a list of other [`Value`]s.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        },\n        {\n          \"description\": \"Represents a map of [`String`] keys to [`Value`]s.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        }\n      ]\n    },\n    \"Number\": {\n      \"description\": \"A valid ACL number.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents an [`i64`].\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        {\n          \"description\": \"Represents a [`f64`].\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      ]\n    },\n    \"Target\": {\n      \"description\": \"Platform target.\",\n      \"oneOf\": [\n        {\n          \"description\": \"MacOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"macOS\"\n          ]\n        },\n        {\n          \"description\": \"Windows.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"windows\"\n          ]\n        },\n        {\n          \"description\": \"Linux.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"linux\"\n          ]\n        },\n        {\n          \"description\": \"Android.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"android\"\n          ]\n        },\n        {\n          \"description\": \"iOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iOS\"\n          ]\n        }\n      ]\n    },\n    \"Application\": {\n      \"description\": \"Opener scope application.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Open in default application.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"If true, allow open with any application.\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Allow specific application to open with.\",\n          \"type\": \"string\"\n        }\n      ]\n    },\n    \"ShellScopeEntryAllowedArg\": {\n      \"description\": \"A command argument allowed to be executed by the webview API.\",\n      \"anyOf\": [\n        {\n          \"description\": \"A non-configurable argument that is passed to the command in the order it was specified.\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"A variable that is set while calling the command from the webview API.\",\n          \"type\": \"object\",\n          \"required\": [\n            \"validator\"\n          ],\n          \"properties\": {\n            \"raw\": {\n              \"description\": \"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\\n\\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.\",\n              \"default\": false,\n              \"type\": \"boolean\"\n            },\n            \"validator\": {\n              \"description\": \"[regex] validator to require passed values to conform to an expected input.\\n\\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\\n\\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\\\w+` regex would be registered as `^https?://\\\\w+$`.\\n\\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>\",\n              \"type\": \"string\"\n            }\n          },\n          \"additionalProperties\": false\n        }\n      ]\n    },\n    \"ShellScopeEntryAllowedArgs\": {\n      \"description\": \"A set of command arguments allowed to be executed by the webview API.\\n\\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Use a simple boolean to allow all or disable all arguments to this command configuration.\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/ShellScopeEntryAllowedArg\"\n          }\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "crates/tauri-app/icons/android/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n  <background android:drawable=\"@color/ic_launcher_background\"/>\n</adaptive-icon>"
  },
  {
    "path": "crates/tauri-app/icons/android/values/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n  <color name=\"ic_launcher_background\">#fff</color>\n</resources>"
  },
  {
    "path": "crates/tauri-app/msi-template.wxs",
    "content": "<?if $(sys.BUILDARCH)=\"x64\" or $(sys.BUILDARCH)=\"arm64\"?>\n    <?define Win64 = \"yes\" ?>\n    <?define PlatformProgramFilesFolder = \"LocalAppDataFolder\" ?>\n<?else?>\n    <?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>\n<?endif?>\n\n<Wix xmlns=\"http://schemas.microsoft.com/wix/2006/wi\">\n    <Product\n            Id=\"*\"\n            Name=\"{{product_name}}\"\n            UpgradeCode=\"{{upgrade_code}}\"\n            Language=\"1033\"\n            Manufacturer=\"{{manufacturer}}\"\n            Version=\"{{version}}\">\n\n        <Package Id=\"*\"\n                 Keywords=\"Installer\"\n                 InstallerVersion=\"450\"\n                 Languages=\"0\"\n                 Compressed=\"yes\"\n                 InstallScope=\"perUser\"\n                 SummaryCodepage=\"1252\"/>\n\n        <MajorUpgrade\n                      DowngradeErrorMessage=\"A newer version of [ProductName] is already installed. Setup will now exit.\"\n                      AllowSameVersionUpgrades=\"yes\" />\n\n        <Media Id=\"1\" Cabinet=\"app.cab\" EmbedCab=\"yes\" />\n\n        <Icon Id=\"ProductIcon\" SourceFile=\"{{icon_path}}\"/>\n        <Property Id=\"ARPPRODUCTICON\" Value=\"ProductIcon\" />\n        <Property Id=\"ARPNOREPAIR\" Value=\"yes\" Secure=\"yes\" />\n\n        <Directory Id=\"TARGETDIR\" Name=\"SourceDir\">\n            <Directory Id=\"DesktopFolder\" Name=\"Desktop\">\n                <Component Id=\"ApplicationShortcutDesktop\" Guid=\"*\">\n                    <Shortcut Id=\"ApplicationDesktopShortcut\"\n                              Name=\"{{product_name}}\"\n                              Description=\"Runs {{product_name}}\"\n                              Target=\"[!Path]\"\n                              WorkingDirectory=\"INSTALLDIR\" />\n                    <RemoveFolder Id=\"DesktopFolder\" On=\"uninstall\" />\n                    <RegistryValue Root=\"HKCU\"\n                                   Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\"\n                                   Name=\"Desktop Shortcut\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n                </Component>\n            </Directory>\n            <Directory Id=\"$(var.PlatformProgramFilesFolder)\">\n                <Directory Id=\"ProgramsFolder\" Name=\"Programs\">\n                    <Directory Id=\"INSTALLDIR\" Name=\"{{product_name}}\"/>\n                </Directory>\n            </Directory>\n            <Directory Id=\"ProgramMenuFolder\">\n                <Directory Id=\"ApplicationProgramsFolder\" Name=\"{{product_name}}\"/>\n            </Directory>\n        </Directory>\n\n        <DirectoryRef Id=\"INSTALLDIR\">\n            <Component Id=\"Path\" Guid=\"{{path_component_guid}}\" Win64=\"$(var.Win64)\">\n                <File Id=\"Path\" Source=\"{{main_binary_path}}\" KeyPath=\"yes\"/>\n            </Component>\n        </DirectoryRef>\n\n        <DirectoryRef Id=\"ApplicationProgramsFolder\">\n            <Component Id=\"ApplicationShortcut\" Guid=\"*\">\n                <Shortcut Id=\"ApplicationStartMenuShortcut\"\n                          Name=\"{{product_name}}\"\n                          Description=\"Runs {{product_name}}\"\n                          Target=\"[!Path]\"\n                          Icon=\"ProductIcon\"\n                          WorkingDirectory=\"INSTALLDIR\" />\n                <RemoveFolder Id=\"ApplicationProgramsFolder\" On=\"uninstall\"/>\n                <RegistryValue Root=\"HKCU\"\n                               Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\"\n                               Name=\"Start Menu Shortcut\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n            </Component>\n        </DirectoryRef>\n\n        <UIRef Id=\"WixUI_Interactive\" />\n\n        <Feature Id=\"MainProgram\"\n                 Title=\"{{product_name}}\"\n                 Description=\"Installs {{product_name}}.\"\n                 Level=\"1\"\n                 ConfigurableDirectory=\"INSTALLDIR\"\n                 AllowAdvertise=\"no\"\n                 Display=\"expand\"\n                 Absent=\"disallow\">\n\n            <Feature Id=\"ShortcutsFeature\" Title=\"Shortcuts\" Level=\"1\">\n                <ComponentRef Id=\"Path\"/>\n                <ComponentRef Id=\"ApplicationShortcut\" />\n                <ComponentRef Id=\"ApplicationShortcutDesktop\" />\n            </Feature>\n        </Feature>\n    </Product>\n\n    <Fragment>\n        <UI Id=\"WixUI_Interactive\">\n            <TextStyle Id=\"WixUI_Font_Normal\" FaceName=\"Tahoma\" Size=\"8\" />\n            <TextStyle Id=\"WixUI_Font_Bigger\" FaceName=\"Tahoma\" Size=\"12\" />\n            <TextStyle Id=\"WixUI_Font_Title\" FaceName=\"Tahoma\" Size=\"9\" Bold=\"yes\" />\n\n            <Property Id=\"DefaultUIFont\" Value=\"WixUI_Font_Normal\" />\n            <Property Id=\"WixUI_Mode\" Value=\"Minimal\" />\n\n            <DialogRef Id=\"ErrorDlg\" />\n            <DialogRef Id=\"FatalError\" />\n            <DialogRef Id=\"FilesInUse\" />\n            <DialogRef Id=\"MsiRMFilesInUse\" />\n            <DialogRef Id=\"PrepareDlg\" />\n            <DialogRef Id=\"ProgressDlg\" />\n            <DialogRef Id=\"ResumeDlg\" />\n            <DialogRef Id=\"UserExit\" />\n            <DialogRef Id=\"WelcomeDlg\" />\n\n            <Publish Dialog=\"ExitDialog\" Control=\"Finish\" Event=\"EndDialog\" Value=\"Return\" Order=\"999\" />\n\n            <Publish Dialog=\"WelcomeDlg\" Control=\"Next\" Event=\"EndDialog\" Value=\"Return\">NOT Installed</Publish>\n            <Publish Dialog=\"WelcomeDlg\" Control=\"Next\" Event=\"NewDialog\" Value=\"VerifyReadyDlg\">Installed AND PATCH</Publish>\n\n            <Publish Dialog=\"VerifyReadyDlg\" Control=\"Back\" Event=\"NewDialog\" Value=\"MaintenanceTypeDlg\" />\n\n            <Publish Dialog=\"MaintenanceWelcomeDlg\" Control=\"Next\" Event=\"NewDialog\" Value=\"MaintenanceTypeDlg\" />\n\n            <Publish Dialog=\"MaintenanceTypeDlg\" Control=\"RepairButton\" Event=\"NewDialog\" Value=\"VerifyReadyDlg\" />\n            <Publish Dialog=\"MaintenanceTypeDlg\" Control=\"RemoveButton\" Event=\"NewDialog\" Value=\"VerifyReadyDlg\" />\n            <Publish Dialog=\"MaintenanceTypeDlg\" Control=\"Back\" Event=\"NewDialog\" Value=\"MaintenanceWelcomeDlg\" />\n\n            <InstallUISequence>\n                <Show Dialog=\"WelcomeDlg\" Before=\"ProgressDlg\" Condition=\"NOT Installed\" />\n            </InstallUISequence>\n\n            <Property Id=\"ARPNOMODIFY\" Value=\"1\" />\n        </UI>\n\n        <UIRef Id=\"WixUI_Common\" />\n    </Fragment>\n</Wix>\n"
  },
  {
    "path": "crates/tauri-app/splash/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Vibe Kanban</title>\n<style>\n  body {\n    margin: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    height: 100vh;\n    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n    background: #f2f2f2;\n    color: #666;\n  }\n  @media (prefers-color-scheme: dark) {\n    body { background: #212121; color: #888; }\n  }\n  .spinner {\n    width: 24px;\n    height: 24px;\n    border: 2px solid currentColor;\n    border-top-color: transparent;\n    border-radius: 50%;\n    animation: spin 0.8s linear infinite;\n    opacity: 0.5;\n  }\n  @keyframes spin { to { transform: rotate(360deg); } }\n</style>\n</head>\n<body>\n<div class=\"spinner\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "crates/tauri-app/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nuse std::{sync::Arc, time::Duration};\n\nuse async_trait::async_trait;\nuse services::services::{\n    config::load_config_from_file,\n    notification::{NotificationService, PushNotifier, set_global_push_notifier},\n};\n#[cfg(target_os = \"macos\")]\nuse tauri::Manager;\nuse tauri::{Emitter, Listener};\nuse tauri_plugin_notification::NotificationExt;\nuse tauri_plugin_opener::OpenerExt;\nuse tauri_plugin_updater::UpdaterExt;\nuse tokio::{sync::Mutex, time::sleep};\nuse tokio_util::sync::CancellationToken;\nuse tracing_subscriber::{EnvFilter, prelude::*};\nuse utils::{\n    assets::config_path,\n    sentry::{self as sentry_utils, SentrySource, sentry_layer},\n};\nuse uuid::Uuid;\n\nconst UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);\n\n/// Native push notifier using Tauri's notification plugin.\n/// Emits a `navigate-to-workspace` event so the frontend can navigate to the\n/// relevant workspace when the user clicks the notification and the app activates.\nstruct TauriNotifier {\n    app_handle: tauri::AppHandle,\n}\n\n#[tauri::command]\nasync fn show_system_notification(title: String, body: String) -> Result<(), String> {\n    let config = load_config_from_file(&config_path()).await;\n    let notification_service = NotificationService::new(Arc::new(tokio::sync::RwLock::new(config)));\n    notification_service.notify(&title, &body, None).await;\n    Ok(())\n}\n\n#[tauri::command]\nfn read_clipboard_text() -> Result<String, String> {\n    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;\n    clipboard.get_text().map_err(|e| e.to_string())\n}\n\n#[async_trait]\nimpl PushNotifier for TauriNotifier {\n    async fn send(&self, title: &str, message: &str, workspace_id: Option<Uuid>) {\n        if let Err(e) = self\n            .app_handle\n            .notification()\n            .builder()\n            .title(title)\n            .body(message)\n            .show()\n        {\n            tracing::warn!(\"Failed to send Tauri notification: {}\", e);\n        }\n\n        if let Some(id) = workspace_id {\n            let _ = self.app_handle.emit(\n                \"navigate-to-workspace\",\n                serde_json::json!({ \"workspaceId\": id.to_string() }),\n            );\n        }\n    }\n}\n\nfn main() {\n    // Install rustls crypto provider before any TLS operations\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .expect(\"Failed to install rustls crypto provider\");\n\n    let log_level = std::env::var(\"RUST_LOG\").unwrap_or_else(|_| \"info\".to_string());\n    let filter_string = format!(\n        \"warn,server={level},services={level},db={level},executors={level},deployment={level},local_deployment={level},utils={level},vibe_kanban_tauri={level}\",\n        level = log_level\n    );\n    let env_filter = EnvFilter::try_new(filter_string).expect(\"Failed to create tracing filter\");\n\n    sentry_utils::init_once(SentrySource::Desktop);\n\n    tracing_subscriber::registry()\n        .with(tracing_subscriber::fmt::layer().with_filter(env_filter))\n        .with(sentry_layer())\n        .init();\n\n    // Shared token so we can tell the server to shut down when the app quits.\n    let shutdown_token = Arc::new(CancellationToken::new());\n    let shutdown_token_for_event = shutdown_token.clone();\n\n    // Holds downloaded update bytes until the app exits or user restarts.\n    // Created here (outside setup) so the RunEvent::Exit handler can access it.\n    let pending_update: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));\n    let pending_for_setup = pending_update.clone();\n    let pending_for_exit = pending_update.clone();\n\n    let mut builder = tauri::Builder::default()\n        .plugin(tauri_plugin_shell::init())\n        .plugin(tauri_plugin_opener::init())\n        .plugin(tauri_plugin_notification::init())\n        .invoke_handler(tauri::generate_handler![\n            show_system_notification,\n            read_clipboard_text\n        ]);\n\n    // Only register the updater plugin in release builds — dev builds have a\n    // placeholder endpoint that fails config deserialization.\n    if !cfg!(debug_assertions) {\n        builder = builder.plugin(tauri_plugin_updater::Builder::new().build());\n    }\n\n    builder\n        .setup(move |app| {\n            if cfg!(debug_assertions) {\n                // Dev mode: frontend dev server (Vite) and backend are started\n                // externally. Use WebviewUrl::External so that macOS WKWebView\n                // renders with the same content scaling as the production build.\n                tracing::info!(\"Running in dev mode — using external frontend/backend servers\");\n                let window = create_window(\n                    app,\n                    tauri::WebviewUrl::External(\"http://localhost:3000\".parse().unwrap()),\n                )?;\n                #[cfg(target_os = \"macos\")]\n                disable_pinch_zoom(&window);\n                let _ = window;\n            } else {\n                // Production: start the Axum server first, then open the window\n                // once it's ready so the user never sees a blank/error page.\n                let app_handle = app.handle().clone();\n\n                // Register native Tauri notifications before the server starts.\n                set_global_push_notifier(Arc::new(TauriNotifier {\n                    app_handle: app_handle.clone(),\n                }));\n\n                let token = shutdown_token.clone();\n                tauri::async_runtime::spawn(async move {\n                    match server::startup::start().await {\n                        Ok(server_handle) => {\n                            let url = server_handle.url();\n\n                            // Create the window on the main thread — macOS\n                            // silently drops windows created from async tasks.\n                            let url_clone = url.clone();\n                            let create_handle = app_handle.clone();\n                            let _ = app_handle.run_on_main_thread(move || {\n                                let webview_url =\n                                    tauri::WebviewUrl::External(url_clone.parse().unwrap());\n                                match create_window(&create_handle, webview_url) {\n                                    Ok(window) => {\n                                        #[cfg(target_os = \"macos\")]\n                                        disable_pinch_zoom(&window);\n                                        let _ = window;\n                                    }\n                                    Err(e) => tracing::error!(\"Failed to create window: {e}\"),\n                                }\n                            });\n                            tracing::info!(\"Window opened at {url}\");\n\n                            // Wait for either the server to exit on its own or\n                            // the external shutdown token to be cancelled.\n                            let server_token = server_handle.shutdown_token();\n                            tauri::async_runtime::spawn(async move {\n                                token.cancelled().await;\n                                server_token.cancel();\n                            });\n\n                            if let Err(e) = server_handle.serve().await {\n                                tracing::error!(\"Server error: {e}\");\n                            }\n                        }\n                        Err(e) => {\n                            tracing::error!(\"Server failed to start: {e}\");\n                        }\n                    }\n                });\n\n                // Check for updates in the background on startup and then\n                // periodically. We only *download* the update here —\n                // installing it (which replaces the app bundle on disk) is\n                // deferred until the user exits or triggers a restart.\n                // Installing while the app is running causes a code-signature\n                // mismatch on macOS, which makes NSOpenPanel (and other XPC\n                // services) return NULL and crash the app.\n                // See tauri-apps/tauri#13047.\n                let update_handle = app.handle().clone();\n                let pending_for_download = pending_for_setup.clone();\n                tauri::async_runtime::spawn(async move {\n                    run_periodic_update_checks(update_handle, pending_for_download).await;\n                });\n\n                // Listen for restart request from frontend (after update downloaded).\n                // Install the previously downloaded bytes *now*, then restart.\n                let restart_handle = app.handle().clone();\n                let pending_for_install = pending_for_setup.clone();\n                app.listen(\"restart-app\", move |_| {\n                    let handle = restart_handle.clone();\n                    let pending = pending_for_install.clone();\n                    tauri::async_runtime::spawn(async move {\n                        install_pending_update(&handle, &pending).await;\n                        handle.restart();\n                    });\n                });\n            }\n\n            Ok(())\n        })\n        .on_window_event(move |window, event| {\n            match event {\n                tauri::WindowEvent::CloseRequested { api, .. } => {\n                    // Hide the window instead of closing it so the app keeps\n                    // running in the background (agents/processes stay alive).\n                    // The dock icon stays visible so users can click it to reopen.\n                    api.prevent_close();\n                    let _ = window.hide();\n                }\n                tauri::WindowEvent::Destroyed => {\n                    // Only fires on actual app exit (e.g. Cmd+Q).\n                    shutdown_token_for_event.cancel();\n                }\n                _ => {}\n            }\n        })\n        .build(tauri::generate_context!())\n        .expect(\"error while building tauri application\")\n        .run(move |_app, _event| {\n            // macOS: clicking the dock icon when the window is hidden should reopen it.\n            #[cfg(target_os = \"macos\")]\n            if let tauri::RunEvent::Reopen { .. } = _event {\n                show_window(_app);\n            }\n\n            // Install any pending update when the app exits (e.g. Cmd+Q)\n            // so the next launch uses the new version.\n            if let tauri::RunEvent::Exit = _event {\n                // block_on is safe here — we're on the main (AppKit) thread,\n                // not inside the tokio runtime.\n                tauri::async_runtime::block_on(install_pending_update(_app, &pending_for_exit));\n            }\n        });\n}\n\n/// Disable trackpad/touchpad pinch-to-zoom on macOS while keeping Cmd+/- zoom.\n/// WKWebView handles magnification at the native level — JS `preventDefault()`\n/// cannot block it.\n#[cfg(target_os = \"macos\")]\nfn disable_pinch_zoom(window: &tauri::WebviewWindow) {\n    let _ = window.with_webview(|webview| unsafe {\n        let wk: &objc2_web_kit::WKWebView = &*webview.inner().cast();\n        wk.setAllowsMagnification(false);\n    });\n}\n\n#[cfg(target_os = \"macos\")]\nfn show_window(app: &tauri::AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        let _ = window.show();\n        let _ = window.set_focus();\n    }\n}\n\nfn create_window<R: tauri::Runtime, M: tauri::Manager<R>>(\n    manager: &M,\n    url: tauri::WebviewUrl,\n) -> Result<tauri::WebviewWindow<R>, tauri::Error> {\n    let handle = manager.app_handle().clone();\n    let mut builder = tauri::WebviewWindowBuilder::new(manager, \"main\", url)\n        .title(\"Vibe Kanban\")\n        .inner_size(1280.0, 800.0)\n        .min_inner_size(800.0, 600.0)\n        .resizable(true)\n        .zoom_hotkeys_enabled(false)\n        .disable_drag_drop_handler();\n\n    // macOS: overlay title bar keeps traffic lights but removes title bar chrome,\n    // letting web content extend to the top of the window.\n    // Traffic lights are vertically centered within the navbar height (~28px).\n    #[cfg(target_os = \"macos\")]\n    {\n        builder = builder\n            .title_bar_style(tauri::TitleBarStyle::Overlay)\n            .hidden_title(true)\n            .traffic_light_position(tauri::LogicalPosition::new(8.0, 14.0));\n    }\n\n    // Windows/Linux: remove native decorations entirely.\n    #[cfg(not(target_os = \"macos\"))]\n    {\n        builder = builder.decorations(false);\n    }\n\n    builder\n        .on_new_window(move |url, _features| {\n            tracing::info!(\"New window requested for URL: {}\", url);\n            let url_str = url.to_string();\n            let _ = handle.opener().open_url(&url_str, None::<&str>);\n            tauri::webview::NewWindowResponse::Deny\n        })\n        .build()\n}\n\n/// Takes the pending update bytes (if any) and installs them.\n/// Requires a network call to re-fetch the `Update` metadata.\nasync fn install_pending_update(app: &tauri::AppHandle, pending: &Mutex<Option<Vec<u8>>>) {\n    let bytes = match pending.lock().await.take() {\n        Some(b) => b,\n        None => return,\n    };\n    tracing::info!(\"Installing pending update…\");\n    let updater = match app.updater() {\n        Ok(u) => u,\n        Err(e) => {\n            tracing::error!(\"Failed to init updater for install: {e}\");\n            return;\n        }\n    };\n    match updater.check().await {\n        Ok(Some(update)) => {\n            if let Err(e) = update.install(bytes) {\n                tracing::error!(\"Failed to install update: {e}\");\n            } else {\n                tracing::info!(\"Update installed, will apply on next launch\");\n            }\n        }\n        Ok(None) => {\n            tracing::warn!(\"Update no longer available when trying to install\");\n        }\n        Err(e) => {\n            tracing::error!(\"Failed to check for update during install: {e}\");\n        }\n    }\n}\n\nasync fn check_for_updates(app: tauri::AppHandle, pending_update: Arc<Mutex<Option<Vec<u8>>>>) {\n    let has_pending_update = pending_update.lock().await.is_some();\n    if has_pending_update {\n        tracing::info!(\"Update already downloaded; skipping update check\");\n        return;\n    }\n\n    let updater = match app.updater() {\n        Ok(updater) => updater,\n        Err(e) => {\n            tracing::warn!(\"Failed to initialize updater: {}\", e);\n            return;\n        }\n    };\n\n    match updater.check().await {\n        Ok(Some(update)) => {\n            tracing::info!(\n                \"Update available: {} -> {}\",\n                update.current_version,\n                update.version\n            );\n\n            let _ = app.emit(\n                \"update-available\",\n                serde_json::json!({\n                    \"currentVersion\": update.current_version.to_string(),\n                    \"newVersion\": update.version.to_string(),\n                    \"body\": update.body\n                }),\n            );\n\n            // Only *download* the update — do NOT install yet.\n            // Installing replaces the app bundle on disk which\n            // invalidates the code signature of the running process,\n            // causing macOS XPC services (NSOpenPanel etc.) to fail.\n            let new_version = update.version.to_string();\n            match update.download(|_, _| {}, || {}).await {\n                Ok(bytes) => {\n                    tracing::info!(\"Update {new_version} downloaded, waiting for user to restart\");\n                    *pending_update.lock().await = Some(bytes);\n                    let _ = app.emit(\n                        \"update-installed\",\n                        serde_json::json!({ \"newVersion\": new_version }),\n                    );\n                }\n                Err(e) => {\n                    tracing::error!(\"Failed to download update: {}\", e);\n                }\n            }\n        }\n        Ok(None) => {\n            tracing::info!(\"No updates available\");\n        }\n        Err(e) => {\n            tracing::warn!(\"Failed to check for updates: {}\", e);\n        }\n    }\n}\n\nasync fn run_periodic_update_checks(\n    app: tauri::AppHandle,\n    pending_update: Arc<Mutex<Option<Vec<u8>>>>,\n) {\n    check_for_updates(app.clone(), pending_update.clone()).await;\n\n    loop {\n        sleep(UPDATE_CHECK_INTERVAL).await;\n        check_for_updates(app.clone(), pending_update.clone()).await;\n    }\n}\n"
  },
  {
    "path": "crates/tauri-app/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json\",\n  \"productName\": \"Vibe Kanban\",\n  \"version\": \"0.1.33\",\n  \"identifier\": \"ai.bloop.vibe-kanban\",\n  \"build\": {\n    \"beforeDevCommand\": {\n      \"script\": \"concurrently \\\"pnpm run backend:dev:watch\\\" \\\"pnpm run local-web:dev\\\"\",\n      \"cwd\": \"../..\"\n    },\n    \"devUrl\": \"http://localhost:3000\",\n    \"frontendDist\": \"./splash\"\n  },\n  \"app\": {\n    \"windows\": [],\n    \"security\": {\n      \"csp\": \"default-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://localhost:* http://127.0.0.1:*; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*\"\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": \"all\",\n    \"windows\": {},\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ],\n    \"category\": \"DeveloperTool\",\n    \"shortDescription\": \"AI-powered development workspace\",\n    \"longDescription\": \"Vibe Kanban: Collaborative AI workspace for software development\",\n    \"createUpdaterArtifacts\": true\n  },\n  \"plugins\": {\n    \"updater\": {\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDMyN0ZCNDVCMDhFMDdEQjAKUldTd2ZlQUlXN1IvTWt1Z0ZmaWpsUUVGOHl3QUExRVVWL0crWkROSjViRUZSTXdUODN4WkFlQ1AK\",\n      \"endpoints\": [\n        \"__TAURI_UPDATE_ENDPOINT__\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "crates/trusted-key-auth/Cargo.toml",
    "content": "[package]\nname = \"trusted-key-auth\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\nbase64 = \"0.22\"\ned25519-dalek = \"2.2.0\"\nhkdf = \"0.12\"\nhmac = \"0.12\"\nhttp = \"1\"\nrand = { version = \"0.8\", features = [\"std\"] }\nserde = { workspace = true }\nserde_json = { workspace = true }\nsha2 = \"0.10\"\nspake2 = { version = \"0.5.0-pre.0\", features = [\"getrandom\"] }\nthiserror = { workspace = true }\ntokio = { workspace = true }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\n"
  },
  {
    "path": "crates/trusted-key-auth/src/error.rs",
    "content": "use thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum TrustedKeyAuthError {\n    #[error(\"Unauthorized\")]\n    Unauthorized,\n    #[error(\"Bad request: {0}\")]\n    BadRequest(String),\n    #[error(\"Forbidden: {0}\")]\n    Forbidden(String),\n    #[error(\"Too many requests: {0}\")]\n    TooManyRequests(String),\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n}\n"
  },
  {
    "path": "crates/trusted-key-auth/src/key_confirmation.rs",
    "content": "use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse hkdf::Hkdf;\nuse hmac::{Hmac, Mac};\nuse sha2::Sha256;\nuse uuid::Uuid;\n\nuse crate::error::TrustedKeyAuthError;\n\nconst KEY_CONFIRMATION_INFO: &[u8] = b\"key-confirmation\";\nconst CLIENT_PROOF_CONTEXT: &[u8] = b\"vk-spake2-client-proof-v2\";\nconst SERVER_PROOF_CONTEXT: &[u8] = b\"vk-spake2-server-proof-v2\";\n\ntype HmacSha256 = Hmac<Sha256>;\n\nfn derive_confirmation_key(shared_key: &[u8]) -> [u8; 32] {\n    let hk = Hkdf::<Sha256>::new(None, shared_key);\n    let mut output = [0u8; 32];\n    hk.expand(KEY_CONFIRMATION_INFO, &mut output)\n        .expect(\"32 bytes is valid for HKDF-SHA256\");\n    output\n}\n\n/// Verify the client's proof binding the browser's public key.\n/// Client proof = HMAC(confirmation_key, CLIENT_CONTEXT || enrollment_id || browser_pk)\npub fn verify_client_proof(\n    shared_key: &[u8],\n    enrollment_id: &Uuid,\n    browser_pk_bytes: &[u8],\n    provided_proof_b64: &str,\n) -> Result<(), TrustedKeyAuthError> {\n    let provided_proof = BASE64_STANDARD\n        .decode(provided_proof_b64)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n    let confirmation_key = derive_confirmation_key(shared_key);\n    let mut mac = HmacSha256::new_from_slice(&confirmation_key)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n    mac.update(CLIENT_PROOF_CONTEXT);\n    mac.update(enrollment_id.as_bytes());\n    mac.update(browser_pk_bytes);\n    mac.verify_slice(&provided_proof)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)\n}\n\n/// Build the server's proof binding both keys.\n/// Server proof = HMAC(confirmation_key, SERVER_CONTEXT || enrollment_id || browser_pk || server_pk)\npub fn build_server_proof(\n    shared_key: &[u8],\n    enrollment_id: &Uuid,\n    browser_pk_bytes: &[u8],\n    server_pk_bytes: &[u8],\n) -> Result<String, TrustedKeyAuthError> {\n    let confirmation_key = derive_confirmation_key(shared_key);\n    let mut mac = HmacSha256::new_from_slice(&confirmation_key)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n    mac.update(SERVER_PROOF_CONTEXT);\n    mac.update(enrollment_id.as_bytes());\n    mac.update(browser_pk_bytes);\n    mac.update(server_pk_bytes);\n    Ok(BASE64_STANDARD.encode(mac.finalize().into_bytes()))\n}\n\n#[cfg(test)]\nfn build_client_proof_base64(\n    shared_key: &[u8],\n    enrollment_id: &Uuid,\n    browser_pk_bytes: &[u8],\n) -> Result<String, TrustedKeyAuthError> {\n    let confirmation_key = derive_confirmation_key(shared_key);\n    let mut mac = HmacSha256::new_from_slice(&confirmation_key)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n    mac.update(CLIENT_PROOF_CONTEXT);\n    mac.update(enrollment_id.as_bytes());\n    mac.update(browser_pk_bytes);\n    Ok(BASE64_STANDARD.encode(mac.finalize().into_bytes()))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn roundtrip_client_proof() {\n        let shared_key = [9u8; 32];\n        let enrollment_id = Uuid::new_v4();\n        let browser_pk = [1u8; 32];\n\n        let proof_b64 =\n            build_client_proof_base64(&shared_key, &enrollment_id, &browser_pk).unwrap();\n\n        verify_client_proof(&shared_key, &enrollment_id, &browser_pk, &proof_b64).unwrap();\n    }\n\n    #[test]\n    fn reject_invalid_client_proof() {\n        let shared_key = [9u8; 32];\n        let enrollment_id = Uuid::new_v4();\n        let browser_pk = [1u8; 32];\n        let bad_proof_b64 = BASE64_STANDARD.encode([0u8; 32]);\n\n        assert!(\n            verify_client_proof(&shared_key, &enrollment_id, &browser_pk, &bad_proof_b64).is_err()\n        );\n    }\n\n    #[test]\n    fn server_proof_binds_both_keys() {\n        let shared_key = [11u8; 32];\n        let enrollment_id = Uuid::new_v4();\n        let browser_pk = [3u8; 32];\n        let server_pk = [4u8; 32];\n\n        let proof_b64 =\n            build_server_proof(&shared_key, &enrollment_id, &browser_pk, &server_pk).unwrap();\n\n        // Re-compute expected proof\n        let confirmation_key = derive_confirmation_key(&shared_key);\n        let mut mac = HmacSha256::new_from_slice(&confirmation_key).unwrap();\n        mac.update(SERVER_PROOF_CONTEXT);\n        mac.update(enrollment_id.as_bytes());\n        mac.update(&browser_pk);\n        mac.update(&server_pk);\n        let expected = BASE64_STANDARD.encode(mac.finalize().into_bytes());\n\n        assert_eq!(proof_b64, expected);\n    }\n\n    #[test]\n    fn different_keys_produce_different_proofs() {\n        let enrollment_id = Uuid::new_v4();\n        let browser_pk = [1u8; 32];\n        let server_pk = [2u8; 32];\n\n        let proof_a =\n            build_server_proof(&[5u8; 32], &enrollment_id, &browser_pk, &server_pk).unwrap();\n        let proof_b =\n            build_server_proof(&[6u8; 32], &enrollment_id, &browser_pk, &server_pk).unwrap();\n\n        assert_ne!(proof_a, proof_b);\n    }\n}\n"
  },
  {
    "path": "crates/trusted-key-auth/src/lib.rs",
    "content": "pub mod error;\npub mod key_confirmation;\npub mod refresh;\npub mod request_signature;\npub mod runtime;\npub mod spake2;\npub mod trusted_keys;\n"
  },
  {
    "path": "crates/trusted-key-auth/src/refresh.rs",
    "content": "use std::time::{SystemTime, UNIX_EPOCH};\n\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse ed25519_dalek::{Signature, Verifier, VerifyingKey};\nuse uuid::Uuid;\n\nuse crate::error::TrustedKeyAuthError;\n\npub const REFRESH_MAX_TIMESTAMP_DRIFT_SECS: i64 = 30;\n\npub fn build_refresh_message(timestamp: i64, nonce: &str, client_id: Uuid) -> String {\n    format!(\"v1|refresh|{timestamp}|{nonce}|{client_id}\")\n}\n\npub fn validate_refresh_timestamp(timestamp: i64) -> Result<(), TrustedKeyAuthError> {\n    let now = current_unix_timestamp()?;\n    let drift = now.saturating_sub(timestamp).abs();\n    if drift > REFRESH_MAX_TIMESTAMP_DRIFT_SECS {\n        return Err(TrustedKeyAuthError::Unauthorized);\n    }\n\n    Ok(())\n}\n\npub fn verify_refresh_signature(\n    public_key: &VerifyingKey,\n    message: &str,\n    signature_b64: &str,\n) -> Result<(), TrustedKeyAuthError> {\n    let signature_bytes = BASE64_STANDARD\n        .decode(signature_b64)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n    let signature =\n        Signature::from_slice(&signature_bytes).map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n\n    public_key\n        .verify(message.as_bytes(), &signature)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)\n}\n\nfn current_unix_timestamp() -> Result<i64, TrustedKeyAuthError> {\n    let duration = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n    i64::try_from(duration.as_secs()).map_err(|_| TrustedKeyAuthError::Unauthorized)\n}\n\n#[cfg(test)]\nmod tests {\n    use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\n    use ed25519_dalek::{Signer, SigningKey};\n\n    use super::*;\n\n    fn signing_key(seed: u8) -> SigningKey {\n        SigningKey::from_bytes(&[seed; 32])\n    }\n\n    #[test]\n    fn build_refresh_message_is_stable() {\n        let message = build_refresh_message(\n            1_700_000_000,\n            \"nonce-123\",\n            Uuid::parse_str(\"11111111-1111-1111-1111-111111111111\").unwrap(),\n        );\n        assert_eq!(\n            message,\n            \"v1|refresh|1700000000|nonce-123|11111111-1111-1111-1111-111111111111\"\n        );\n    }\n\n    #[test]\n    fn verify_refresh_signature_accepts_valid_signature() {\n        let signing_key = signing_key(9);\n        let client_id = Uuid::new_v4();\n        let message = build_refresh_message(1_700_000_000, \"nonce\", client_id);\n        let signature_b64 = BASE64_STANDARD.encode(signing_key.sign(message.as_bytes()).to_bytes());\n\n        verify_refresh_signature(&signing_key.verifying_key(), &message, &signature_b64).unwrap();\n    }\n\n    #[test]\n    fn verify_refresh_signature_rejects_invalid_signature() {\n        let trusted_key = signing_key(11);\n        let attacker_key = signing_key(13);\n        let client_id = Uuid::new_v4();\n        let message = build_refresh_message(1_700_000_000, \"nonce\", client_id);\n        let signature_b64 =\n            BASE64_STANDARD.encode(attacker_key.sign(message.as_bytes()).to_bytes());\n\n        assert!(\n            verify_refresh_signature(&trusted_key.verifying_key(), &message, &signature_b64)\n                .is_err()\n        );\n    }\n\n    #[test]\n    fn validate_refresh_timestamp_rejects_stale_values() {\n        let now = current_unix_timestamp().unwrap();\n        let stale = now - REFRESH_MAX_TIMESTAMP_DRIFT_SECS - 1;\n        assert!(validate_refresh_timestamp(stale).is_err());\n    }\n}\n"
  },
  {
    "path": "crates/trusted-key-auth/src/request_signature.rs",
    "content": "use std::{\n    path::Path,\n    time::{SystemTime, UNIX_EPOCH},\n};\n\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse ed25519_dalek::{Signature, VerifyingKey};\nuse http::{HeaderMap, Method};\nuse thiserror::Error;\n\nuse crate::trusted_keys::load_trusted_public_keys;\n\npub const SIGNATURE_HEADER: &str = \"x-vk-signature\";\npub const TIMESTAMP_HEADER: &str = \"x-vk-timestamp\";\npub const MAX_TIMESTAMP_DRIFT_SECONDS: i64 = 30;\n\n#[derive(Debug, Clone, Copy)]\npub struct VerifiedRequestSignature {\n    pub timestamp: i64,\n    pub now: i64,\n    pub drift_seconds: i64,\n    pub trusted_key_count: usize,\n}\n\n#[derive(Debug, Error)]\npub enum SignatureVerificationError {\n    #[error(\"missing or invalid x-vk-timestamp header\")]\n    InvalidTimestampHeader,\n    #[error(\"failed to read system clock\")]\n    ClockUnavailable,\n    #[error(\"timestamp is outside allowed drift\")]\n    TimestampOutOfDrift {\n        timestamp: i64,\n        now: i64,\n        drift_seconds: i64,\n        max_drift_seconds: i64,\n    },\n    #[error(\"missing or invalid x-vk-signature header\")]\n    InvalidSignatureHeader,\n    #[error(\"failed to load trusted Ed25519 public keys\")]\n    TrustedKeysUnavailable,\n    #[error(\"signature does not match any trusted key\")]\n    SignatureMismatch { trusted_key_count: usize },\n}\n\npub async fn verify_trusted_ed25519_signature(\n    headers: &HeaderMap,\n    method: &Method,\n    path: &str,\n    trusted_keys_path: &Path,\n) -> Result<VerifiedRequestSignature, SignatureVerificationError> {\n    let timestamp = parse_timestamp(headers)?;\n    let now = current_unix_timestamp().map_err(|_| SignatureVerificationError::ClockUnavailable)?;\n    let drift_seconds = now.saturating_sub(timestamp).abs();\n\n    if !timestamp_is_within_drift(timestamp, now) {\n        return Err(SignatureVerificationError::TimestampOutOfDrift {\n            timestamp,\n            now,\n            drift_seconds,\n            max_drift_seconds: MAX_TIMESTAMP_DRIFT_SECONDS,\n        });\n    }\n\n    let signature = parse_signature(headers)?;\n    let message = build_signed_message(timestamp, method, path);\n    let trusted_keys = load_trusted_public_keys(trusted_keys_path)\n        .await\n        .map_err(|_| SignatureVerificationError::TrustedKeysUnavailable)?;\n    let trusted_key_count = trusted_keys.len();\n\n    if !verify_signature(&message, &signature, &trusted_keys) {\n        return Err(SignatureVerificationError::SignatureMismatch { trusted_key_count });\n    }\n\n    Ok(VerifiedRequestSignature {\n        timestamp,\n        now,\n        drift_seconds,\n        trusted_key_count,\n    })\n}\n\nfn build_signed_message(timestamp: i64, method: &Method, path: &str) -> String {\n    format!(\"{timestamp}.{}.{}\", method.as_str(), path)\n}\n\nfn parse_timestamp(headers: &HeaderMap) -> Result<i64, SignatureVerificationError> {\n    let raw_timestamp = required_header(headers, TIMESTAMP_HEADER)\n        .ok_or(SignatureVerificationError::InvalidTimestampHeader)?;\n    raw_timestamp\n        .parse::<i64>()\n        .map_err(|_| SignatureVerificationError::InvalidTimestampHeader)\n}\n\nfn parse_signature(headers: &HeaderMap) -> Result<Signature, SignatureVerificationError> {\n    let raw_signature = required_header(headers, SIGNATURE_HEADER)\n        .ok_or(SignatureVerificationError::InvalidSignatureHeader)?;\n    parse_signature_base64(raw_signature)\n        .map_err(|_| SignatureVerificationError::InvalidSignatureHeader)\n}\n\nfn parse_signature_base64(raw_signature: &str) -> Result<Signature, SignatureVerificationError> {\n    let signature_bytes = BASE64_STANDARD\n        .decode(raw_signature)\n        .map_err(|_| SignatureVerificationError::InvalidSignatureHeader)?;\n    let signature_bytes: [u8; 64] = signature_bytes\n        .try_into()\n        .map_err(|_| SignatureVerificationError::InvalidSignatureHeader)?;\n    Ok(Signature::from_bytes(&signature_bytes))\n}\n\nfn required_header<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {\n    let value = headers.get(name)?;\n    let value = value.to_str().ok()?;\n    let value = value.trim();\n    if value.is_empty() {\n        return None;\n    }\n    Some(value)\n}\n\nfn timestamp_is_within_drift(timestamp: i64, now: i64) -> bool {\n    let drift = now.saturating_sub(timestamp).abs();\n    drift <= MAX_TIMESTAMP_DRIFT_SECONDS\n}\n\nfn current_unix_timestamp() -> Result<i64, SignatureVerificationError> {\n    let duration = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .map_err(|_| SignatureVerificationError::ClockUnavailable)?;\n    i64::try_from(duration.as_secs()).map_err(|_| SignatureVerificationError::ClockUnavailable)\n}\n\nfn verify_signature(message: &str, signature: &Signature, trusted_keys: &[VerifyingKey]) -> bool {\n    trusted_keys\n        .iter()\n        .any(|key| key.verify_strict(message.as_bytes(), signature).is_ok())\n}\n\n#[cfg(test)]\nmod tests {\n    use std::path::PathBuf;\n\n    use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\n    use ed25519_dalek::{Signer, SigningKey};\n    use http::{HeaderMap, HeaderValue, Method};\n    use tokio::fs;\n    use uuid::Uuid;\n\n    use super::*;\n\n    fn signing_key(seed: u8) -> SigningKey {\n        SigningKey::from_bytes(&[seed; 32])\n    }\n\n    #[test]\n    fn accepts_signature_from_trusted_key() {\n        let trusted_signing_key = signing_key(7);\n        let trusted_public_key = trusted_signing_key.verifying_key();\n\n        let timestamp = 1_700_000_000_i64;\n        let message = build_signed_message(timestamp, &Method::POST, \"/auth/signed-test\");\n        let signature = trusted_signing_key.sign(message.as_bytes());\n\n        assert!(verify_signature(\n            &message,\n            &signature,\n            &[trusted_public_key]\n        ));\n    }\n\n    #[test]\n    fn rejects_signature_from_untrusted_key() {\n        let trusted_signing_key = signing_key(11);\n        let untrusted_signing_key = signing_key(13);\n\n        let timestamp = 1_700_000_000_i64;\n        let message = build_signed_message(timestamp, &Method::POST, \"/auth/signed-test\");\n        let signature = untrusted_signing_key.sign(message.as_bytes());\n\n        assert!(!verify_signature(\n            &message,\n            &signature,\n            &[trusted_signing_key.verifying_key()]\n        ));\n    }\n\n    #[test]\n    fn rejects_stale_timestamps() {\n        let now = 1_700_000_000_i64;\n        assert!(timestamp_is_within_drift(now, now));\n        assert!(timestamp_is_within_drift(\n            now - MAX_TIMESTAMP_DRIFT_SECONDS,\n            now\n        ));\n        assert!(!timestamp_is_within_drift(\n            now - MAX_TIMESTAMP_DRIFT_SECONDS - 1,\n            now\n        ));\n    }\n\n    #[test]\n    fn rejects_malformed_signature() {\n        assert!(parse_signature_base64(\"not-base64\").is_err());\n\n        let short_signature = BASE64_STANDARD.encode([1_u8; 63]);\n        assert!(parse_signature_base64(&short_signature).is_err());\n    }\n\n    #[tokio::test]\n    async fn verifies_request_signature_end_to_end() {\n        let signing_key = signing_key(17);\n        let public_key_b64 = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes());\n        let trusted_keys_json = serde_json::json!({\n            \"clients\": [\n                {\n                    \"client_id\": Uuid::new_v4(),\n                    \"client_name\": \"Test Client\",\n                    \"client_browser\": \"Chrome\",\n                    \"client_os\": \"macOS\",\n                    \"client_device\": \"desktop\",\n                    \"public_key_b64\": public_key_b64\n                }\n            ]\n        })\n        .to_string();\n\n        let trusted_keys_path = temp_trusted_keys_path();\n        fs::write(&trusted_keys_path, trusted_keys_json)\n            .await\n            .unwrap();\n\n        let path = \"/api/auth/signed-test\";\n        let timestamp = current_unix_timestamp().unwrap();\n        let message = build_signed_message(timestamp, &Method::POST, path);\n        let signature_b64 = BASE64_STANDARD.encode(signing_key.sign(message.as_bytes()).to_bytes());\n\n        let mut headers = HeaderMap::new();\n        headers.insert(\n            TIMESTAMP_HEADER,\n            HeaderValue::from_str(&timestamp.to_string()).unwrap(),\n        );\n        headers.insert(\n            SIGNATURE_HEADER,\n            HeaderValue::from_str(&signature_b64).unwrap(),\n        );\n\n        let result =\n            verify_trusted_ed25519_signature(&headers, &Method::POST, path, &trusted_keys_path)\n                .await;\n        assert!(result.is_ok());\n\n        let _ = fs::remove_file(&trusted_keys_path).await;\n    }\n\n    fn temp_trusted_keys_path() -> PathBuf {\n        let mut path = std::env::temp_dir();\n        path.push(format!(\"vk-trusted-keys-{}.json\", Uuid::new_v4()));\n        path\n    }\n}\n"
  },
  {
    "path": "crates/trusted-key-auth/src/runtime.rs",
    "content": "use std::{\n    collections::HashMap,\n    path::PathBuf,\n    sync::Arc,\n    time::{Duration, Instant},\n};\n\nuse tokio::sync::RwLock;\nuse uuid::Uuid;\n\nuse crate::{\n    error::TrustedKeyAuthError,\n    trusted_keys::{\n        TrustedRelayClient, list_trusted_clients, remove_trusted_client, upsert_trusted_client,\n    },\n};\n\n#[derive(Clone)]\npub struct TrustedKeyAuthRuntime {\n    trusted_keys_path: PathBuf,\n    pake_enrollments: Arc<RwLock<HashMap<Uuid, PendingPakeEnrollment>>>,\n    enrollment_code: Arc<RwLock<Option<String>>>,\n    rate_limit_windows: Arc<RwLock<HashMap<String, Vec<Instant>>>>,\n    refresh_nonces: Arc<RwLock<HashMap<String, Instant>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct PendingPakeEnrollment {\n    shared_key: Vec<u8>,\n    created_at: Instant,\n}\n\nconst PAKE_ENROLLMENT_TTL: Duration = Duration::from_secs(5 * 60);\nconst REFRESH_NONCE_TTL: Duration = Duration::from_secs(2 * 60);\n\nimpl TrustedKeyAuthRuntime {\n    pub fn new(trusted_keys_path: PathBuf) -> Self {\n        Self {\n            trusted_keys_path,\n            pake_enrollments: Default::default(),\n            enrollment_code: Default::default(),\n            rate_limit_windows: Default::default(),\n            refresh_nonces: Default::default(),\n        }\n    }\n\n    pub async fn persist_trusted_client(\n        &self,\n        client: TrustedRelayClient,\n    ) -> Result<bool, TrustedKeyAuthError> {\n        upsert_trusted_client(&self.trusted_keys_path, client).await\n    }\n\n    pub async fn list_trusted_clients(\n        &self,\n    ) -> Result<Vec<TrustedRelayClient>, TrustedKeyAuthError> {\n        list_trusted_clients(&self.trusted_keys_path).await\n    }\n\n    pub async fn remove_trusted_client(\n        &self,\n        client_id: Uuid,\n    ) -> Result<bool, TrustedKeyAuthError> {\n        remove_trusted_client(&self.trusted_keys_path, client_id).await\n    }\n\n    pub async fn find_trusted_client(\n        &self,\n        client_id: Uuid,\n    ) -> Result<Option<TrustedRelayClient>, TrustedKeyAuthError> {\n        let clients = list_trusted_clients(&self.trusted_keys_path).await?;\n        Ok(clients\n            .into_iter()\n            .find(|client| client.client_id == client_id))\n    }\n\n    pub async fn store_pake_enrollment(&self, enrollment_id: Uuid, shared_key: Vec<u8>) {\n        self.pake_enrollments.write().await.insert(\n            enrollment_id,\n            PendingPakeEnrollment {\n                shared_key,\n                created_at: Instant::now(),\n            },\n        );\n    }\n\n    pub async fn take_pake_enrollment(&self, enrollment_id: &Uuid) -> Option<Vec<u8>> {\n        let mut enrollments = self.pake_enrollments.write().await;\n        let enrollment = enrollments.remove(enrollment_id)?;\n        if enrollment.created_at.elapsed() > PAKE_ENROLLMENT_TTL {\n            return None;\n        }\n        Some(enrollment.shared_key)\n    }\n\n    pub async fn get_or_set_enrollment_code(&self, new_code: String) -> String {\n        let mut enrollment_code = self.enrollment_code.write().await;\n        if let Some(existing_code) = enrollment_code.as_ref() {\n            return existing_code.clone();\n        }\n\n        *enrollment_code = Some(new_code.clone());\n        new_code\n    }\n\n    pub async fn consume_enrollment_code(&self, enrollment_code: &str) -> bool {\n        let mut stored_code = self.enrollment_code.write().await;\n        if stored_code.as_deref() != Some(enrollment_code) {\n            return false;\n        }\n\n        *stored_code = None;\n        true\n    }\n\n    pub async fn enforce_rate_limit(\n        &self,\n        bucket: &str,\n        max_requests: usize,\n        window: Duration,\n    ) -> Result<(), TrustedKeyAuthError> {\n        let now = Instant::now();\n        let mut windows = self.rate_limit_windows.write().await;\n        let entry = windows.entry(bucket.to_string()).or_default();\n        entry.retain(|timestamp| now.duration_since(*timestamp) <= window);\n\n        if entry.len() >= max_requests {\n            return Err(TrustedKeyAuthError::TooManyRequests(\n                \"Too many requests. Please wait and try again.\".to_string(),\n            ));\n        }\n\n        entry.push(now);\n        Ok(())\n    }\n\n    pub async fn claim_refresh_nonce(&self, nonce: &str) -> Result<(), TrustedKeyAuthError> {\n        let normalized = nonce.trim();\n        if normalized.is_empty() || normalized.len() > 128 {\n            return Err(TrustedKeyAuthError::Unauthorized);\n        }\n\n        let now = Instant::now();\n        let mut seen = self.refresh_nonces.write().await;\n        seen.retain(|_, inserted_at| now.duration_since(*inserted_at) <= REFRESH_NONCE_TTL);\n        if seen.contains_key(normalized) {\n            return Err(TrustedKeyAuthError::Unauthorized);\n        }\n\n        seen.insert(normalized.to_string(), now);\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn claim_refresh_nonce_rejects_replay() {\n        let runtime = TrustedKeyAuthRuntime::new(PathBuf::from(\"/tmp/unused-trusted-keys.json\"));\n        runtime.claim_refresh_nonce(\"nonce-1\").await.unwrap();\n\n        assert!(runtime.claim_refresh_nonce(\"nonce-1\").await.is_err());\n    }\n\n    #[tokio::test]\n    async fn claim_refresh_nonce_rejects_blank_values() {\n        let runtime = TrustedKeyAuthRuntime::new(PathBuf::from(\"/tmp/unused-trusted-keys.json\"));\n        assert!(runtime.claim_refresh_nonce(\"   \").await.is_err());\n    }\n}\n"
  },
  {
    "path": "crates/trusted-key-auth/src/spake2.rs",
    "content": "use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse rand::Rng;\nuse spake2::{Ed25519Group, Identity, Password, Spake2, SysRng, UnwrapErr};\n\nuse crate::error::TrustedKeyAuthError;\n\nconst SPAKE2_CLIENT_ID: &[u8] = b\"vibe-kanban-browser\";\nconst SPAKE2_SERVER_ID: &[u8] = b\"vibe-kanban-server\";\npub const ENROLLMENT_CODE_LENGTH: usize = 6;\nconst ENROLLMENT_CODE_CHARSET: &[u8] = b\"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n\n#[derive(Debug)]\npub struct Spake2StartOutcome {\n    pub enrollment_code: String,\n    pub shared_key: Vec<u8>,\n    pub server_message_b64: String,\n}\n\npub fn start_spake2_enrollment(\n    raw_enrollment_code: &str,\n    client_message_b64: &str,\n) -> Result<Spake2StartOutcome, TrustedKeyAuthError> {\n    let enrollment_code = normalize_enrollment_code(raw_enrollment_code)?;\n    let client_message = decode_base64(client_message_b64)\n        .map_err(|_| TrustedKeyAuthError::BadRequest(\"Invalid client_message_b64\".to_string()))?;\n\n    let password = Password::new(enrollment_code.as_bytes());\n    let id_a = Identity::new(SPAKE2_CLIENT_ID);\n    let id_b = Identity::new(SPAKE2_SERVER_ID);\n    let (server_state, server_message) =\n        Spake2::<Ed25519Group>::start_b_with_rng(&password, &id_a, &id_b, UnwrapErr(SysRng));\n\n    let shared_key = server_state\n        .finish(&client_message)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n\n    Ok(Spake2StartOutcome {\n        enrollment_code,\n        shared_key,\n        server_message_b64: BASE64_STANDARD.encode(server_message),\n    })\n}\n\npub fn generate_one_time_code() -> String {\n    let mut rng = rand::thread_rng();\n    let mut code = String::with_capacity(ENROLLMENT_CODE_LENGTH);\n    for _ in 0..ENROLLMENT_CODE_LENGTH {\n        let idx = rng.gen_range(0..ENROLLMENT_CODE_CHARSET.len());\n        code.push(ENROLLMENT_CODE_CHARSET[idx] as char);\n    }\n    code\n}\n\npub fn normalize_enrollment_code(raw_code: &str) -> Result<String, TrustedKeyAuthError> {\n    let code = raw_code.trim().to_ascii_uppercase();\n    if code.len() != ENROLLMENT_CODE_LENGTH {\n        return Err(TrustedKeyAuthError::BadRequest(format!(\n            \"Invalid enrollment code length. Expected {ENROLLMENT_CODE_LENGTH} characters.\"\n        )));\n    }\n\n    if !code\n        .bytes()\n        .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())\n    {\n        return Err(TrustedKeyAuthError::BadRequest(\n            \"Enrollment code must contain only A-Z and 0-9.\".to_string(),\n        ));\n    }\n\n    Ok(code)\n}\n\nfn decode_base64(input: &str) -> Result<Vec<u8>, TrustedKeyAuthError> {\n    BASE64_STANDARD\n        .decode(input)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn normalize_enrollment_code_accepts_valid_input() {\n        let normalized = normalize_enrollment_code(\"ab12z9\").unwrap();\n        assert_eq!(normalized, \"AB12Z9\");\n    }\n\n    #[test]\n    fn normalize_enrollment_code_rejects_invalid_characters() {\n        assert!(normalize_enrollment_code(\"AB!2Z9\").is_err());\n    }\n\n    #[test]\n    fn generate_one_time_code_uses_expected_charset_and_length() {\n        let code = generate_one_time_code();\n        assert_eq!(code.len(), ENROLLMENT_CODE_LENGTH);\n        assert!(\n            code.bytes()\n                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())\n        );\n    }\n}\n"
  },
  {
    "path": "crates/trusted-key-auth/src/trusted_keys.rs",
    "content": "use std::path::Path;\n\nuse base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};\nuse ed25519_dalek::VerifyingKey;\nuse serde::{Deserialize, Serialize};\nuse tokio::fs;\nuse uuid::Uuid;\n\nuse crate::error::TrustedKeyAuthError;\n\npub const TRUSTED_KEYS_FILE_NAME: &str = \"trusted_ed25519_public_keys.json\";\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct TrustedRelayClient {\n    pub client_id: Uuid,\n    pub client_name: String,\n    pub client_browser: String,\n    pub client_os: String,\n    pub client_device: String,\n    pub public_key_b64: String,\n}\n\n#[derive(Debug, Default, Deserialize, Serialize)]\nstruct TrustedRelayClientsFile {\n    clients: Vec<TrustedRelayClient>,\n}\n\npub async fn upsert_trusted_client(\n    trusted_keys_path: &Path,\n    client: TrustedRelayClient,\n) -> Result<bool, TrustedKeyAuthError> {\n    validate_client(&client)?;\n    let mut trusted_clients_file = read_trusted_clients_file(trusted_keys_path).await?;\n\n    if let Some(existing_client) = trusted_clients_file\n        .clients\n        .iter_mut()\n        .find(|existing_client| {\n            existing_client.client_id == client.client_id\n                || existing_client.public_key_b64 == client.public_key_b64\n        })\n    {\n        *existing_client = client;\n        write_trusted_clients_file(trusted_keys_path, &trusted_clients_file).await?;\n        return Ok(false);\n    }\n\n    trusted_clients_file.clients.push(client);\n    write_trusted_clients_file(trusted_keys_path, &trusted_clients_file).await?;\n    Ok(true)\n}\n\npub async fn load_trusted_public_keys(\n    trusted_keys_path: &Path,\n) -> Result<Vec<VerifyingKey>, TrustedKeyAuthError> {\n    let trusted_clients_file = read_trusted_clients_file(trusted_keys_path).await?;\n    if trusted_clients_file.clients.is_empty() {\n        return Err(TrustedKeyAuthError::Unauthorized);\n    }\n\n    let mut parsed_keys = Vec::with_capacity(trusted_clients_file.clients.len());\n    for client in &trusted_clients_file.clients {\n        let parsed_key = parse_public_key_base64(&client.public_key_b64)\n            .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n        parsed_keys.push(parsed_key);\n    }\n\n    Ok(parsed_keys)\n}\n\npub async fn list_trusted_clients(\n    trusted_keys_path: &Path,\n) -> Result<Vec<TrustedRelayClient>, TrustedKeyAuthError> {\n    Ok(read_trusted_clients_file(trusted_keys_path).await?.clients)\n}\n\npub async fn remove_trusted_client(\n    trusted_keys_path: &Path,\n    client_id: Uuid,\n) -> Result<bool, TrustedKeyAuthError> {\n    let mut trusted_clients_file = read_trusted_clients_file(trusted_keys_path).await?;\n    let previous_len = trusted_clients_file.clients.len();\n    trusted_clients_file\n        .clients\n        .retain(|client| client.client_id != client_id);\n\n    if trusted_clients_file.clients.len() == previous_len {\n        return Ok(false);\n    }\n\n    write_trusted_clients_file(trusted_keys_path, &trusted_clients_file).await?;\n    Ok(true)\n}\n\npub fn parse_public_key_base64(raw_public_key: &str) -> Result<VerifyingKey, TrustedKeyAuthError> {\n    let public_key_bytes = decode_base64(raw_public_key)?;\n    let public_key_bytes: [u8; 32] = public_key_bytes\n        .try_into()\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)?;\n    VerifyingKey::from_bytes(&public_key_bytes).map_err(|_| TrustedKeyAuthError::Unauthorized)\n}\n\nasync fn read_trusted_clients_file(\n    trusted_keys_path: &Path,\n) -> Result<TrustedRelayClientsFile, TrustedKeyAuthError> {\n    if !trusted_keys_path.exists() {\n        return Ok(TrustedRelayClientsFile::default());\n    }\n\n    let file_contents = fs::read_to_string(trusted_keys_path).await?;\n    if file_contents.trim().is_empty() {\n        return Ok(TrustedRelayClientsFile::default());\n    }\n\n    let trusted_clients_file: TrustedRelayClientsFile = serde_json::from_str(&file_contents)\n        .map_err(|error| {\n            TrustedKeyAuthError::BadRequest(format!(\"Trusted key file is invalid JSON: {error}\"))\n        })?;\n\n    for client in &trusted_clients_file.clients {\n        validate_client(client)?;\n    }\n\n    Ok(trusted_clients_file)\n}\n\nasync fn write_trusted_clients_file(\n    trusted_keys_path: &Path,\n    trusted_clients_file: &TrustedRelayClientsFile,\n) -> Result<(), TrustedKeyAuthError> {\n    let serialized = serde_json::to_string_pretty(trusted_clients_file).map_err(|error| {\n        TrustedKeyAuthError::BadRequest(format!(\"Failed to serialize trusted keys: {error}\"))\n    })?;\n    fs::write(trusted_keys_path, format!(\"{serialized}\\n\")).await?;\n    Ok(())\n}\n\nfn validate_client(client: &TrustedRelayClient) -> Result<(), TrustedKeyAuthError> {\n    if client.client_name.trim().is_empty() {\n        return Err(TrustedKeyAuthError::BadRequest(\n            \"Trusted key file contains invalid client name\".to_string(),\n        ));\n    }\n    if client.client_browser.trim().is_empty() {\n        return Err(TrustedKeyAuthError::BadRequest(\n            \"Trusted key file contains invalid client browser\".to_string(),\n        ));\n    }\n    if client.client_os.trim().is_empty() {\n        return Err(TrustedKeyAuthError::BadRequest(\n            \"Trusted key file contains invalid client OS\".to_string(),\n        ));\n    }\n    if client.client_device.trim().is_empty() {\n        return Err(TrustedKeyAuthError::BadRequest(\n            \"Trusted key file contains invalid client device\".to_string(),\n        ));\n    }\n\n    parse_public_key_base64(&client.public_key_b64).map_err(|_| {\n        TrustedKeyAuthError::BadRequest(\"Trusted key file contains invalid keys\".to_string())\n    })?;\n    Ok(())\n}\n\nfn decode_base64(input: &str) -> Result<Vec<u8>, TrustedKeyAuthError> {\n    BASE64_STANDARD\n        .decode(input)\n        .map_err(|_| TrustedKeyAuthError::Unauthorized)\n}\n\n#[cfg(test)]\nmod tests {\n    use std::path::PathBuf;\n\n    use ed25519_dalek::SigningKey;\n    use tokio::fs;\n    use uuid::Uuid;\n\n    use super::*;\n\n    fn test_public_key() -> VerifyingKey {\n        SigningKey::from_bytes(&[7; 32]).verifying_key()\n    }\n\n    #[test]\n    fn parse_public_key_base64_accepts_valid_key() {\n        let public_key = test_public_key();\n        let key_b64 = BASE64_STANDARD.encode(public_key.as_bytes());\n\n        let parsed = parse_public_key_base64(&key_b64).unwrap();\n        assert_eq!(parsed.as_bytes(), public_key.as_bytes());\n    }\n\n    #[tokio::test]\n    async fn can_upsert_list_and_remove_trusted_clients() {\n        let trusted_keys_path = temp_trusted_keys_path();\n        let key_b64 = BASE64_STANDARD.encode(test_public_key().as_bytes());\n        let client_id = Uuid::new_v4();\n\n        let inserted = upsert_trusted_client(\n            &trusted_keys_path,\n            TrustedRelayClient {\n                client_id,\n                client_name: \"Chrome on macOS (Desktop)\".to_string(),\n                client_browser: \"Chrome\".to_string(),\n                client_os: \"macOS\".to_string(),\n                client_device: \"desktop\".to_string(),\n                public_key_b64: key_b64.clone(),\n            },\n        )\n        .await\n        .unwrap();\n        assert!(inserted);\n\n        let clients = list_trusted_clients(&trusted_keys_path).await.unwrap();\n        assert_eq!(clients.len(), 1);\n        assert_eq!(clients[0].client_id, client_id);\n        assert_eq!(clients[0].public_key_b64, key_b64);\n\n        let removed = remove_trusted_client(&trusted_keys_path, client_id)\n            .await\n            .unwrap();\n        assert!(removed);\n        let clients = list_trusted_clients(&trusted_keys_path).await.unwrap();\n        assert!(clients.is_empty());\n\n        let _ = fs::remove_file(&trusted_keys_path).await;\n    }\n\n    fn temp_trusted_keys_path() -> PathBuf {\n        let mut path = std::env::temp_dir();\n        path.push(format!(\"vk-trusted-keys-{}.json\", Uuid::new_v4()));\n        path\n    }\n}\n"
  },
  {
    "path": "crates/utils/Cargo.toml",
    "content": "[package]\nname = \"utils\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\ntokio-util = { version = \"0.7\", features = [\"io\", \"codec\"] }\nbytes = \"1.0\"\nshlex = \"1.3.0\"\naxum = { workspace = true, features = [\"ws\"] }\nserde = { workspace = true }\nserde_json = { workspace = true }\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\nchrono = { version = \"0.4\", features = [\"serde\"] }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\nts-rs = { workspace = true }\nrust-embed = \"8.2\"\ndirectories = \"6.0.0\"\nopen = \"5.3.2\"\nregex = \"1.11.1\"\nsentry = { version = \"0.46.2\", default-features = false, features = [\"anyhow\", \"backtrace\", \"panic\", \"debug-images\", \"reqwest\", \"rustls\"] }\nsentry-tracing = { version = \"0.46.2\", default-features = false, features = [\"backtrace\"] }\njson-patch = \"2.0\"\njsonwebtoken = { version = \"10.2.0\", features = [\"rust_crypto\"] }\ntokio = { workspace = true }\nfutures = \"0.3.31\"\ntokio-stream = { version = \"0.1.17\", features = [\"sync\"] }\nshellexpand = \"3.1.1\"\nwhich = \"8.0.0\"\nsimilar = \"2\"\ngit2 = { workspace = true }\ndirs = \"5.0\"\nthiserror = { workspace = true }\ncommand-group = { version = \"5.0\", features = [\"with-tokio\"] }\n\n[target.'cfg(unix)'.dependencies]\nnix = { version = \"0.29\", features = [\"signal\", \"process\"] }\n\n[target.'cfg(windows)'.dependencies]\nwinreg = \"0.55\"\nwindows-sys = { version = \"0.61\", features = [\"Win32_System_Environment\"] }\n"
  },
  {
    "path": "crates/utils/src/approvals.rs",
    "content": "use chrono::{DateTime, Duration, Utc};\nuse serde::{Deserialize, Serialize};\nuse ts_rs::TS;\nuse uuid::Uuid;\n\npub const APPROVAL_TIMEOUT_SECONDS: i64 = 36000; // 10 hours\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ApprovalRequest {\n    pub id: String,\n    pub tool_name: String,\n    pub execution_process_id: Uuid,\n    pub created_at: DateTime<Utc>,\n    pub timeout_at: DateTime<Utc>,\n}\n\nimpl ApprovalRequest {\n    pub fn new(tool_name: String, execution_process_id: Uuid) -> Self {\n        let now = Utc::now();\n        Self {\n            id: Uuid::new_v4().to_string(),\n            tool_name,\n            execution_process_id,\n            created_at: now,\n            timeout_at: now + Duration::seconds(APPROVAL_TIMEOUT_SECONDS),\n        }\n    }\n}\n\n/// Status of a tool permission request (approve/deny for tool execution).\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"status\", rename_all = \"snake_case\")]\npub enum ApprovalStatus {\n    Pending,\n    Approved,\n    Denied {\n        #[ts(optional)]\n        reason: Option<String>,\n    },\n    TimedOut,\n}\n\n/// A question–answer pair. `answer` holds one or more selected labels/values.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct QuestionAnswer {\n    pub question: String,\n    pub answer: Vec<String>,\n}\n\n/// Status of a question answer request.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"status\", rename_all = \"snake_case\")]\npub enum QuestionStatus {\n    Answered { answers: Vec<QuestionAnswer> },\n    TimedOut,\n}\n\n// Tracks both approval and question answers requests\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"status\", rename_all = \"snake_case\")]\npub enum ApprovalOutcome {\n    Approved,\n    Denied {\n        #[ts(optional)]\n        reason: Option<String>,\n    },\n    Answered {\n        answers: Vec<QuestionAnswer>,\n    },\n    TimedOut,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\npub struct ApprovalResponse {\n    pub execution_process_id: Uuid,\n    pub status: ApprovalOutcome,\n}\n"
  },
  {
    "path": "crates/utils/src/assets.rs",
    "content": "use directories::ProjectDirs;\nuse rust_embed::RustEmbed;\n\nconst PROJECT_ROOT: &str = env!(\"CARGO_MANIFEST_DIR\");\n\npub fn asset_dir() -> std::path::PathBuf {\n    let path = if cfg!(debug_assertions) {\n        std::path::PathBuf::from(PROJECT_ROOT).join(\"../../dev_assets\")\n    } else {\n        prod_asset_dir_path()\n    };\n\n    // Ensure the directory exists\n    if !path.exists() {\n        std::fs::create_dir_all(&path).expect(\"Failed to create asset directory\");\n    }\n\n    path\n    // ✔ macOS → ~/Library/Application Support/MyApp\n    // ✔ Linux → ~/.local/share/myapp   (respects XDG_DATA_HOME)\n    // ✔ Windows → %APPDATA%\\Example\\MyApp\n}\n\npub fn prod_asset_dir_path() -> std::path::PathBuf {\n    ProjectDirs::from(\"ai\", \"bloop\", \"vibe-kanban\")\n        .expect(\"OS didn't give us a home directory\")\n        .data_dir()\n        .to_path_buf()\n}\n\npub fn config_path() -> std::path::PathBuf {\n    asset_dir().join(\"config.json\")\n}\n\npub fn profiles_path() -> std::path::PathBuf {\n    asset_dir().join(\"profiles.json\")\n}\n\npub fn credentials_path() -> std::path::PathBuf {\n    asset_dir().join(\"credentials.json\")\n}\n\npub fn trusted_keys_path() -> std::path::PathBuf {\n    asset_dir().join(\"trusted_ed25519_public_keys.json\")\n}\n\npub fn server_signing_key_path() -> std::path::PathBuf {\n    asset_dir().join(\"server_ed25519_signing_key\")\n}\n\n#[derive(RustEmbed)]\n#[folder = \"../../assets/sounds\"]\npub struct SoundAssets;\n\n#[derive(RustEmbed)]\n#[folder = \"../../assets/scripts\"]\npub struct ScriptAssets;\n"
  },
  {
    "path": "crates/utils/src/browser.rs",
    "content": "use crate::{command_ext::NoWindowExt, is_wsl2};\n\n/// Open URL in browser with WSL2 support\npub async fn open_browser(url: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {\n    if is_wsl2() {\n        // In WSL2, use PowerShell to open the browser\n        tokio::process::Command::new(\"powershell.exe\")\n            .arg(\"-Command\")\n            .arg(format!(\"Start-Process '{url}'\"))\n            .no_window()\n            .spawn()?;\n        Ok(())\n    } else {\n        // Use the standard open crate for other platforms\n        open::that(url).map_err(|e| e.into())\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/command_ext.rs",
    "content": "//! Extension traits to suppress console windows on Windows.\n//!\n//! On Windows, spawned child processes open a visible console window by\n//! default.  Call `.no_window()` before `.spawn()` or `.output()` to set\n//! the `CREATE_NO_WINDOW` creation flag and prevent this.\n//!\n//! On non-Windows platforms the methods are no-ops.\n\nuse command_group::{AsyncCommandGroup, AsyncGroupChild};\n\n#[cfg(windows)]\nconst CREATE_NO_WINDOW: u32 = 0x08000000;\n\n/// Adds a `.no_window()` builder method that suppresses the console window\n/// on Windows.  No-op on other platforms.\npub trait NoWindowExt {\n    fn no_window(&mut self) -> &mut Self;\n}\n\nimpl NoWindowExt for std::process::Command {\n    #[cfg(windows)]\n    fn no_window(&mut self) -> &mut Self {\n        use std::os::windows::process::CommandExt;\n        self.creation_flags(CREATE_NO_WINDOW)\n    }\n\n    #[cfg(not(windows))]\n    fn no_window(&mut self) -> &mut Self {\n        self\n    }\n}\n\nimpl NoWindowExt for tokio::process::Command {\n    #[cfg(windows)]\n    fn no_window(&mut self) -> &mut Self {\n        use std::os::windows::process::CommandExt;\n        self.creation_flags(CREATE_NO_WINDOW)\n    }\n\n    #[cfg(not(windows))]\n    fn no_window(&mut self) -> &mut Self {\n        self\n    }\n}\n\n/// Adds a `.group_spawn_no_window()` helper for command-group spawns that\n/// suppresses the console window on Windows. No-op on other platforms.\npub trait GroupSpawnNoWindowExt {\n    fn group_spawn_no_window(&mut self) -> std::io::Result<AsyncGroupChild>;\n}\n\nimpl GroupSpawnNoWindowExt for tokio::process::Command {\n    fn group_spawn_no_window(&mut self) -> std::io::Result<AsyncGroupChild> {\n        let mut group = self.group();\n        #[cfg(windows)]\n        group.creation_flags(CREATE_NO_WINDOW);\n        group.spawn()\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/diff.rs",
    "content": "use std::borrow::Cow;\n\nuse git2::{DiffOptions, Patch};\nuse serde::{Deserialize, Serialize};\nuse similar::TextDiff;\nuse ts_rs::TS;\nuse uuid::Uuid;\n\n// Structs compatible with props: https://github.com/MrWangJustToDo/git-diff-view\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\npub struct FileDiffDetails {\n    pub file_name: Option<String>,\n    pub content: Option<String>,\n}\n\n// Worktree diffs for the diffs tab: minimal, no hunks, optional full contents\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\npub struct Diff {\n    pub change: DiffChangeKind,\n    pub old_path: Option<String>,\n    pub new_path: Option<String>,\n    pub old_content: Option<String>,\n    pub new_content: Option<String>,\n    /// True when file contents are intentionally omitted (e.g., too large)\n    pub content_omitted: bool,\n    /// Optional precomputed stats for omitted content\n    pub additions: Option<usize>,\n    pub deletions: Option<usize>,\n    pub repo_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\npub enum DiffChangeKind {\n    Added,\n    Deleted,\n    Modified,\n    Renamed,\n    Copied,\n    PermissionChange,\n}\n\n// ==============================\n// Unified diff utility functions\n// ==============================\n\n/// Converts a replace diff to a list of unified diff hunks.\n/// Uses a context limit of 3 lines.\nfn create_unified_diff_hunks(old: &str, new: &str) -> Vec<String> {\n    let old = ensure_newline(old);\n    let new = ensure_newline(new);\n\n    let diff = TextDiff::from_lines(&old, &new);\n\n    // Generate unified diff with context\n    let unified_diff = diff\n        .unified_diff()\n        .context_radius(3)\n        .header(\"a\", \"b\")\n        .to_string();\n\n    extract_unified_diff_hunks(&unified_diff)\n}\n\n/// Creates a full unified diff with the file path in the header.\npub fn create_unified_diff(file_path: &str, old: &str, new: &str) -> String {\n    let hunks = create_unified_diff_hunks(old, new);\n    concatenate_diff_hunks(file_path, &hunks)\n}\n\n/// Compute addition/deletion counts between two text snapshots.\npub fn compute_line_change_counts(old: &str, new: &str) -> (usize, usize) {\n    let old = ensure_newline(old);\n    let new = ensure_newline(new);\n\n    let mut opts = DiffOptions::new();\n    opts.context_lines(0);\n\n    match Patch::from_buffers(old.as_bytes(), None, new.as_bytes(), None, Some(&mut opts))\n        .and_then(|patch| patch.line_stats())\n    {\n        Ok((_, adds, dels)) => (adds, dels),\n        Err(e) => {\n            tracing::error!(\"git2 diff failed: {}\", e);\n            (0, 0)\n        }\n    }\n}\n\n// ensure a line ends with a newline character\nfn ensure_newline(line: &str) -> Cow<'_, str> {\n    if line.ends_with('\\n') {\n        Cow::Borrowed(line)\n    } else {\n        let mut owned = line.to_owned();\n        owned.push('\\n');\n        Cow::Owned(owned)\n    }\n}\n\n/// Extracts unified diff hunks from a string containing a full unified diff.\n/// Tolerates non-diff lines and missing `@@`` hunk headers.\npub fn extract_unified_diff_hunks(unified_diff: &str) -> Vec<String> {\n    let lines = unified_diff.split_inclusive('\\n').collect::<Vec<_>>();\n\n    if !lines.iter().any(|l| l.starts_with(\"@@\")) {\n        // No @@ hunk headers: treat as a single hunk\n        let hunk = lines\n            .iter()\n            .copied()\n            .filter(|line| line.starts_with([' ', '+', '-']))\n            .collect::<String>();\n\n        let old_count = lines\n            .iter()\n            .filter(|line| line.starts_with(['-', ' ']))\n            .count();\n        let new_count = lines\n            .iter()\n            .filter(|line| line.starts_with(['+', ' ']))\n            .count();\n\n        return if hunk.is_empty() {\n            vec![]\n        } else {\n            vec![format!(\"@@ -1,{old_count} +1,{new_count} @@\\n{hunk}\")]\n        };\n    }\n\n    let mut hunks = vec![];\n    let mut current_hunk: Option<String> = None;\n\n    // Collect hunks starting with @@ headers\n    for line in lines {\n        if line.starts_with(\"@@\") {\n            // new hunk starts\n            if let Some(hunk) = current_hunk.take() {\n                // flush current hunk\n                if !hunk.is_empty() {\n                    hunks.push(hunk);\n                }\n            }\n            current_hunk = Some(line.to_string());\n        } else if let Some(ref mut hunk) = current_hunk {\n            if line.starts_with([' ', '+', '-']) {\n                // hunk content\n                hunk.push_str(line);\n            } else {\n                // unknown line, flush current hunk\n                if !hunk.is_empty() {\n                    hunks.push(hunk.clone());\n                }\n                current_hunk = None;\n            }\n        }\n    }\n    // we have reached the end. flush the last hunk if it exists\n    if let Some(hunk) = current_hunk\n        && !hunk.is_empty()\n    {\n        hunks.push(hunk);\n    }\n\n    // Fix hunk headers if they are empty @@\\n\n    hunks = fix_hunk_headers(hunks);\n\n    hunks\n}\n\n// Helper function to ensure valid hunk headers\nfn fix_hunk_headers(hunks: Vec<String>) -> Vec<String> {\n    if hunks.is_empty() {\n        return hunks;\n    }\n\n    let mut new_hunks = Vec::new();\n    // if hunk header is empty @@\\n, ten we need to replace it with a valid header\n    for hunk in hunks {\n        let mut lines = hunk\n            .split_inclusive('\\n')\n            .map(str::to_string)\n            .collect::<Vec<_>>();\n        if lines.len() < 2 {\n            // empty hunk, skip\n            continue;\n        }\n\n        let header = &lines[0];\n        if !header.starts_with(\"@@\") {\n            // no header, skip\n            continue;\n        }\n\n        if header.trim() == \"@@\" {\n            // empty header, replace with a valid one\n            lines.remove(0);\n            let old_count = lines\n                .iter()\n                .filter(|line| line.starts_with(['-', ' ']))\n                .count();\n            let new_count = lines\n                .iter()\n                .filter(|line| line.starts_with(['+', ' ']))\n                .count();\n            let new_header = format!(\"@@ -1,{old_count} +1,{new_count} @@\");\n            lines.insert(0, new_header);\n            new_hunks.push(lines.join(\"\"));\n        } else {\n            // valid header, keep as is\n            new_hunks.push(hunk);\n        }\n    }\n\n    new_hunks\n}\n\n/// Creates a full unified diff with the file path in the header,\npub fn concatenate_diff_hunks(file_path: &str, hunks: &[String]) -> String {\n    let mut unified_diff = String::new();\n\n    let header = format!(\"--- a/{file_path}\\n+++ b/{file_path}\\n\");\n\n    unified_diff.push_str(&header);\n\n    if !hunks.is_empty() {\n        let lines = hunks\n            .iter()\n            .flat_map(|hunk| hunk.lines())\n            .filter(|line| line.starts_with(\"@@ \") || line.starts_with([' ', '+', '-']))\n            .collect::<Vec<_>>();\n        unified_diff.push_str(lines.join(\"\\n\").as_str());\n        if !unified_diff.ends_with('\\n') {\n            unified_diff.push('\\n');\n        }\n    }\n\n    unified_diff\n}\n\n/// Normalizes a unified diff the format supported by the diff viewer,\npub fn normalize_unified_diff(file_path: &str, unified_diff: &str) -> String {\n    let hunks = extract_unified_diff_hunks(unified_diff);\n    concatenate_diff_hunks(file_path, &hunks)\n}\n"
  },
  {
    "path": "crates/utils/src/execution_logs.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse tokio::io::AsyncWriteExt;\nuse uuid::Uuid;\n\nuse crate::{assets::asset_dir, log_msg::LogMsg};\n\npub const EXECUTION_LOGS_DIRNAME: &str = \"sessions\";\n\npub fn process_logs_session_dir(session_id: Uuid) -> PathBuf {\n    resolve_process_logs_session_dir(&asset_dir(), session_id)\n}\n\npub fn process_log_file_path(session_id: Uuid, process_id: Uuid) -> PathBuf {\n    process_log_file_path_in_root(&asset_dir(), session_id, process_id)\n}\n\npub fn process_log_file_path_in_root(root: &Path, session_id: Uuid, process_id: Uuid) -> PathBuf {\n    resolve_process_logs_session_dir(root, session_id)\n        .join(\"processes\")\n        .join(format!(\"{}.jsonl\", process_id))\n}\n\npub struct ExecutionLogWriter {\n    path: PathBuf,\n    file: tokio::fs::File,\n}\n\nimpl ExecutionLogWriter {\n    pub async fn new(path: PathBuf) -> std::io::Result<Self> {\n        if let Some(parent) = path.parent() {\n            tokio::fs::create_dir_all(parent).await?;\n        }\n        let file = tokio::fs::OpenOptions::new()\n            .create(true)\n            .append(true)\n            .open(&path)\n            .await?;\n        Ok(Self { path, file })\n    }\n\n    pub async fn new_for_execution(session_id: Uuid, execution_id: Uuid) -> std::io::Result<Self> {\n        Self::new(process_log_file_path(session_id, execution_id)).await\n    }\n\n    pub fn path(&self) -> &Path {\n        &self.path\n    }\n\n    pub async fn append_jsonl_line(&mut self, jsonl_line: &str) -> std::io::Result<()> {\n        self.file.write_all(jsonl_line.as_bytes()).await\n    }\n}\n\npub async fn read_execution_log_file(path: &Path) -> std::io::Result<String> {\n    tokio::fs::read_to_string(path).await\n}\n\npub fn parse_log_jsonl_lossy(execution_id: Uuid, jsonl: &str) -> Vec<LogMsg> {\n    let mut messages = Vec::new();\n    let mut bad_lines = 0usize;\n\n    for line in jsonl.lines() {\n        if line.trim().is_empty() {\n            continue;\n        }\n\n        match serde_json::from_str::<LogMsg>(line) {\n            Ok(msg) => messages.push(msg),\n            Err(e) => {\n                bad_lines += 1;\n                if bad_lines <= 3 {\n                    tracing::warn!(\n                        \"Skipping unparsable log line for execution {}: {}\",\n                        execution_id,\n                        e\n                    );\n                }\n            }\n        }\n    }\n\n    if bad_lines > 3 {\n        tracing::warn!(\n            \"Skipped {} unparsable log lines for execution {}\",\n            bad_lines,\n            execution_id\n        );\n    }\n\n    messages\n}\n\nfn uuid_prefix2(id: Uuid) -> String {\n    let s = id.to_string();\n    s.chars().take(2).collect()\n}\n\nfn resolve_process_logs_session_dir(root: &Path, session_id: Uuid) -> PathBuf {\n    root.join(EXECUTION_LOGS_DIRNAME)\n        .join(uuid_prefix2(session_id))\n        .join(session_id.to_string())\n}\n"
  },
  {
    "path": "crates/utils/src/jwt.rs",
    "content": "use chrono::{DateTime, Utc};\nuse jsonwebtoken::dangerous::insecure_decode;\nuse serde::Deserialize;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error)]\npub enum TokenClaimsError {\n    #[error(\"failed to decode JWT: {0}\")]\n    Decode(#[from] jsonwebtoken::errors::Error),\n    #[error(\"missing `exp` claim in token\")]\n    MissingExpiration,\n    #[error(\"invalid `exp` value `{0}`\")]\n    InvalidExpiration(i64),\n    #[error(\"missing `sub` claim in token\")]\n    MissingSubject,\n    #[error(\"invalid `sub` value: {0}\")]\n    InvalidSubject(String),\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExpClaim {\n    exp: Option<i64>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SubClaim {\n    sub: Option<String>,\n}\n\n/// Extract the expiration timestamp from a JWT without verifying its signature.\npub fn extract_expiration(token: &str) -> Result<DateTime<Utc>, TokenClaimsError> {\n    let data = insecure_decode::<ExpClaim>(token)?;\n    let exp = data.claims.exp.ok_or(TokenClaimsError::MissingExpiration)?;\n    DateTime::from_timestamp(exp, 0).ok_or(TokenClaimsError::InvalidExpiration(exp))\n}\n\n/// Extract the subject (user ID) from a JWT without verifying its signature.\npub fn extract_subject(token: &str) -> Result<Uuid, TokenClaimsError> {\n    let data = insecure_decode::<SubClaim>(token)?;\n    let sub = data.claims.sub.ok_or(TokenClaimsError::MissingSubject)?;\n    Uuid::parse_str(&sub).map_err(|_| TokenClaimsError::InvalidSubject(sub))\n}\n"
  },
  {
    "path": "crates/utils/src/lib.rs",
    "content": "use std::{env, sync::OnceLock};\n\nuse directories::ProjectDirs;\n\npub mod approvals;\npub mod assets;\npub mod browser;\npub mod command_ext;\npub mod diff;\npub mod execution_logs;\npub mod jwt;\npub mod log_msg;\npub mod msg_store;\npub mod path;\npub mod port_file;\npub mod process;\npub mod response;\npub mod sentry;\npub mod shell;\npub mod stream_lines;\npub mod text;\npub mod tokio;\npub mod version;\n\n/// Cache for WSL2 detection result\nstatic WSL2_CACHE: OnceLock<bool> = OnceLock::new();\n\n/// Check if running in WSL2 (cached)\npub fn is_wsl2() -> bool {\n    *WSL2_CACHE.get_or_init(|| {\n        // Check for WSL environment variables\n        if std::env::var(\"WSL_DISTRO_NAME\").is_ok() || std::env::var(\"WSLENV\").is_ok() {\n            tracing::debug!(\"WSL2 detected via environment variables\");\n            return true;\n        }\n\n        // Check /proc/version for WSL2 signature\n        if let Ok(version) = std::fs::read_to_string(\"/proc/version\")\n            && (version.contains(\"WSL2\") || version.contains(\"microsoft\"))\n        {\n            tracing::debug!(\"WSL2 detected via /proc/version\");\n            return true;\n        }\n\n        tracing::debug!(\"WSL2 not detected\");\n        false\n    })\n}\n\npub fn cache_dir() -> std::path::PathBuf {\n    let proj = if cfg!(debug_assertions) {\n        ProjectDirs::from(\"ai\", \"bloop-dev\", env!(\"CARGO_PKG_NAME\"))\n            .expect(\"OS didn't give us a home directory\")\n    } else {\n        ProjectDirs::from(\"ai\", \"bloop\", env!(\"CARGO_PKG_NAME\"))\n            .expect(\"OS didn't give us a home directory\")\n    };\n\n    // ✔ macOS → ~/Library/Caches/MyApp\n    // ✔ Linux → ~/.cache/myapp (respects XDG_CACHE_HOME)\n    // ✔ Windows → %LOCALAPPDATA%\\Example\\MyApp\n    proj.cache_dir().to_path_buf()\n}\n\n// Get or create cached PowerShell script file\npub async fn get_powershell_script()\n-> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {\n    use std::io::Write;\n\n    let cache_dir = cache_dir();\n    let script_path = cache_dir.join(\"toast-notification.ps1\");\n\n    // Check if cached file already exists and is valid\n    if script_path.exists() {\n        // Verify file has content (basic validation)\n        if let Ok(metadata) = std::fs::metadata(&script_path)\n            && metadata.len() > 0\n        {\n            return Ok(script_path);\n        }\n    }\n\n    // File doesn't exist or is invalid, create it\n    let script_content = assets::ScriptAssets::get(\"toast-notification.ps1\")\n        .ok_or(\"Embedded PowerShell script not found: toast-notification.ps1\")?\n        .data;\n\n    // Ensure cache directory exists\n    std::fs::create_dir_all(&cache_dir)\n        .map_err(|e| format!(\"Failed to create cache directory: {e}\"))?;\n\n    let mut file = std::fs::File::create(&script_path)\n        .map_err(|e| format!(\"Failed to create PowerShell script file: {e}\"))?;\n\n    file.write_all(&script_content)\n        .map_err(|e| format!(\"Failed to write PowerShell script data: {e}\"))?;\n\n    drop(file); // Ensure file is closed\n\n    Ok(script_path)\n}\n"
  },
  {
    "path": "crates/utils/src/log_msg.rs",
    "content": "use axum::{extract::ws::Message, response::sse::Event};\nuse json_patch::Patch;\nuse serde::{Deserialize, Serialize};\n\npub const EV_STDOUT: &str = \"stdout\";\npub const EV_STDERR: &str = \"stderr\";\npub const EV_JSON_PATCH: &str = \"json_patch\";\npub const EV_SESSION_ID: &str = \"session_id\";\npub const EV_MESSAGE_ID: &str = \"message_id\";\npub const EV_READY: &str = \"ready\";\npub const EV_FINISHED: &str = \"finished\";\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub enum LogMsg {\n    Stdout(String),\n    Stderr(String),\n    JsonPatch(Patch),\n    SessionId(String),\n    MessageId(String),\n    Ready,\n    Finished,\n}\n\nimpl LogMsg {\n    pub fn name(&self) -> &'static str {\n        match self {\n            LogMsg::Stdout(_) => EV_STDOUT,\n            LogMsg::Stderr(_) => EV_STDERR,\n            LogMsg::JsonPatch(_) => EV_JSON_PATCH,\n            LogMsg::SessionId(_) => EV_SESSION_ID,\n            LogMsg::MessageId(_) => EV_MESSAGE_ID,\n            LogMsg::Ready => EV_READY,\n            LogMsg::Finished => EV_FINISHED,\n        }\n    }\n\n    pub fn to_sse_event(&self) -> Event {\n        match self {\n            LogMsg::Stdout(s) => Event::default().event(EV_STDOUT).data(s.clone()),\n            LogMsg::Stderr(s) => Event::default().event(EV_STDERR).data(s.clone()),\n            LogMsg::JsonPatch(patch) => {\n                let data = serde_json::to_string(patch).unwrap_or_else(|_| \"[]\".to_string());\n                Event::default().event(EV_JSON_PATCH).data(data)\n            }\n            LogMsg::SessionId(s) => Event::default().event(EV_SESSION_ID).data(s.clone()),\n            LogMsg::MessageId(s) => Event::default().event(EV_MESSAGE_ID).data(s.clone()),\n            LogMsg::Ready => Event::default().event(EV_READY).data(\"\"),\n            LogMsg::Finished => Event::default().event(EV_FINISHED).data(\"\"),\n        }\n    }\n\n    /// Convert LogMsg to WebSocket message with proper error handling\n    pub fn to_ws_message(&self) -> Result<Message, serde_json::Error> {\n        let json = serde_json::to_string(self)?;\n        Ok(Message::Text(json.into()))\n    }\n\n    /// Convert LogMsg to WebSocket message with fallback error handling\n    ///\n    /// This method mirrors the behavior of the original logmsg_to_ws function\n    /// but with better error handling than unwrap().\n    pub fn to_ws_message_unchecked(&self) -> Message {\n        // Finished and Ready use special JSON formats for frontend compatibility\n        let json = match self {\n            LogMsg::Ready => r#\"{\"Ready\":true}\"#.to_string(),\n            LogMsg::Finished => r#\"{\"finished\":true}\"#.to_string(),\n            _ => serde_json::to_string(self)\n                .unwrap_or_else(|_| r#\"{\"error\":\"serialization_failed\"}\"#.to_string()),\n        };\n\n        Message::Text(json.into())\n    }\n\n    /// Rough size accounting for your byte‑budgeted history.\n    pub fn approx_bytes(&self) -> usize {\n        const OVERHEAD: usize = 8;\n        match self {\n            LogMsg::Stdout(s) => EV_STDOUT.len() + s.len() + OVERHEAD,\n            LogMsg::Stderr(s) => EV_STDERR.len() + s.len() + OVERHEAD,\n            LogMsg::JsonPatch(patch) => {\n                let json_len = serde_json::to_string(patch).map(|s| s.len()).unwrap_or(2);\n                EV_JSON_PATCH.len() + json_len + OVERHEAD\n            }\n            LogMsg::SessionId(s) => EV_SESSION_ID.len() + s.len() + OVERHEAD,\n            LogMsg::MessageId(s) => EV_MESSAGE_ID.len() + s.len() + OVERHEAD,\n            LogMsg::Ready => EV_READY.len() + OVERHEAD,\n            LogMsg::Finished => EV_FINISHED.len() + OVERHEAD,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/msg_store.rs",
    "content": "use std::{\n    collections::VecDeque,\n    sync::{Arc, RwLock},\n};\n\nuse axum::response::sse::Event;\nuse futures::{StreamExt, TryStreamExt, future};\nuse tokio::{sync::broadcast, task::JoinHandle};\nuse tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};\n\nuse crate::{log_msg::LogMsg, stream_lines::LinesStreamExt};\n\n// 100 MB Limit\nconst HISTORY_BYTES: usize = 100000 * 1024;\n\n#[derive(Clone)]\nstruct StoredMsg {\n    msg: LogMsg,\n    bytes: usize,\n}\n\nstruct Inner {\n    history: VecDeque<StoredMsg>,\n    total_bytes: usize,\n}\n\npub struct MsgStore {\n    inner: RwLock<Inner>,\n    sender: broadcast::Sender<LogMsg>,\n}\n\nimpl Default for MsgStore {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl MsgStore {\n    pub fn new() -> Self {\n        let (sender, _) = broadcast::channel(100000);\n        Self {\n            inner: RwLock::new(Inner {\n                history: VecDeque::with_capacity(32),\n                total_bytes: 0,\n            }),\n            sender,\n        }\n    }\n\n    pub fn push(&self, msg: LogMsg) {\n        let _ = self.sender.send(msg.clone()); // live listeners\n        let bytes = msg.approx_bytes();\n\n        let mut inner = self.inner.write().unwrap();\n        while inner.total_bytes.saturating_add(bytes) > HISTORY_BYTES {\n            if let Some(front) = inner.history.pop_front() {\n                inner.total_bytes = inner.total_bytes.saturating_sub(front.bytes);\n            } else {\n                break;\n            }\n        }\n        inner.history.push_back(StoredMsg { msg, bytes });\n        inner.total_bytes = inner.total_bytes.saturating_add(bytes);\n    }\n\n    // Convenience\n    pub fn push_stdout<S: Into<String>>(&self, s: S) {\n        self.push(LogMsg::Stdout(s.into()));\n    }\n\n    pub fn push_stderr<S: Into<String>>(&self, s: S) {\n        self.push(LogMsg::Stderr(s.into()));\n    }\n    pub fn push_patch(&self, patch: json_patch::Patch) {\n        self.push(LogMsg::JsonPatch(patch));\n    }\n\n    pub fn push_session_id(&self, session_id: String) {\n        self.push(LogMsg::SessionId(session_id));\n    }\n\n    pub fn push_message_id(&self, id: String) {\n        self.push(LogMsg::MessageId(id));\n    }\n\n    pub fn push_finished(&self) {\n        self.push(LogMsg::Finished);\n    }\n\n    pub fn get_receiver(&self) -> broadcast::Receiver<LogMsg> {\n        self.sender.subscribe()\n    }\n\n    pub fn get_history(&self) -> Vec<LogMsg> {\n        self.inner\n            .read()\n            .unwrap()\n            .history\n            .iter()\n            .map(|s| s.msg.clone())\n            .collect()\n    }\n\n    /// History then live, as `LogMsg`.\n    pub fn history_plus_stream(\n        &self,\n    ) -> futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>> {\n        let (history, rx) = (self.get_history(), self.get_receiver());\n\n        let hist = futures::stream::iter(history.into_iter().map(Ok::<_, std::io::Error>));\n        let live = BroadcastStream::new(rx).filter_map(|res| async move {\n            match res {\n                Ok(msg) => Some(Ok(msg)),\n                Err(BroadcastStreamRecvError::Lagged(n)) => {\n                    tracing::error!(\n                        skipped = n,\n                        \"MsgStore broadcast lagged. {n} messages dropped for this subscriber\"\n                    );\n                    None\n                }\n            }\n        });\n\n        Box::pin(hist.chain(live))\n    }\n\n    pub fn stdout_chunked_stream(\n        &self,\n    ) -> futures::stream::BoxStream<'static, Result<String, std::io::Error>> {\n        self.history_plus_stream()\n            .take_while(|res| future::ready(!matches!(res, Ok(LogMsg::Finished))))\n            .filter_map(|res| async move {\n                match res {\n                    Ok(LogMsg::Stdout(s)) => Some(Ok(s)),\n                    _ => None,\n                }\n            })\n            .boxed()\n    }\n\n    pub fn stdout_lines_stream(\n        &self,\n    ) -> futures::stream::BoxStream<'static, std::io::Result<String>> {\n        self.stdout_chunked_stream().lines()\n    }\n\n    pub fn stderr_chunked_stream(\n        &self,\n    ) -> futures::stream::BoxStream<'static, Result<String, std::io::Error>> {\n        self.history_plus_stream()\n            .take_while(|res| future::ready(!matches!(res, Ok(LogMsg::Finished))))\n            .filter_map(|res| async move {\n                match res {\n                    Ok(LogMsg::Stderr(s)) => Some(Ok(s)),\n                    _ => None,\n                }\n            })\n            .boxed()\n    }\n\n    pub fn stderr_lines_stream(\n        &self,\n    ) -> futures::stream::BoxStream<'static, std::io::Result<String>> {\n        self.stderr_chunked_stream().lines()\n    }\n\n    /// Same stream but mapped to `Event` for SSE handlers.\n    pub fn sse_stream(&self) -> futures::stream::BoxStream<'static, Result<Event, std::io::Error>> {\n        self.history_plus_stream()\n            .map_ok(|m| m.to_sse_event())\n            .boxed()\n    }\n\n    /// Forward a stream of typed log messages into this store.\n    pub fn spawn_forwarder<S, E>(self: Arc<Self>, stream: S) -> JoinHandle<()>\n    where\n        S: futures::Stream<Item = Result<LogMsg, E>> + Send + 'static,\n        E: std::fmt::Display + Send + 'static,\n    {\n        tokio::spawn(async move {\n            tokio::pin!(stream);\n\n            while let Some(next) = stream.next().await {\n                match next {\n                    Ok(msg) => self.push(msg),\n                    Err(e) => self.push(LogMsg::Stderr(format!(\"stream error: {e}\"))),\n                }\n            }\n        })\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/path.rs",
    "content": "use std::path::{Path, PathBuf};\n\n/// Directory name for storing attachments in worktrees\npub const VIBE_ATTACHMENTS_DIR: &str = \".vibe-attachments\";\n\n/// Directories that should always be skipped regardless of gitignore.\n/// .git is not in .gitignore but should never be watched.\npub const ALWAYS_SKIP_DIRS: &[&str] = &[\".git\", \"node_modules\"];\n\n/// Convert absolute paths to relative paths based on worktree path\n/// This is a robust implementation that handles symlinks and edge cases\npub fn make_path_relative(path: &str, worktree_path: &str) -> String {\n    tracing::trace!(\"Making path relative: {} -> {}\", path, worktree_path);\n\n    let path_obj = normalize_macos_private_alias(Path::new(&path));\n    let worktree_path_obj = normalize_macos_private_alias(Path::new(worktree_path));\n\n    // If path is already relative, return as is\n    if path_obj.is_relative() {\n        return path.to_string();\n    }\n\n    if let Ok(relative_path) = path_obj.strip_prefix(&worktree_path_obj) {\n        let result = relative_path.to_string_lossy().to_string();\n        tracing::trace!(\"Successfully made relative: '{}' -> '{}'\", path, result);\n        if result.is_empty() {\n            return \".\".to_string();\n        }\n        return result;\n    }\n\n    if !path_obj.exists() || !worktree_path_obj.exists() {\n        return path.to_string();\n    }\n\n    // canonicalize may fail if paths don't exist\n    let canonical_path = std::fs::canonicalize(&path_obj);\n    let canonical_worktree = std::fs::canonicalize(&worktree_path_obj);\n\n    match (canonical_path, canonical_worktree) {\n        (Ok(canon_path), Ok(canon_worktree)) => {\n            tracing::debug!(\n                \"Trying canonical path resolution: '{}' -> '{}', '{}' -> '{}'\",\n                path,\n                canon_path.display(),\n                worktree_path,\n                canon_worktree.display()\n            );\n\n            match canon_path.strip_prefix(&canon_worktree) {\n                Ok(relative_path) => {\n                    let result = relative_path.to_string_lossy().to_string();\n                    tracing::debug!(\n                        \"Successfully made relative with canonical paths: '{}' -> '{}'\",\n                        path,\n                        result\n                    );\n                    if result.is_empty() {\n                        return \".\".to_string();\n                    }\n                    result\n                }\n                Err(e) => {\n                    tracing::debug!(\n                        \"Failed to make canonical path relative: '{}' relative to '{}', error: {}, returning original\",\n                        canon_path.display(),\n                        canon_worktree.display(),\n                        e\n                    );\n                    path.to_string()\n                }\n            }\n        }\n        _ => {\n            tracing::debug!(\n                \"Could not canonicalize paths (paths may not exist): '{}', '{}', returning original\",\n                path,\n                worktree_path\n            );\n            path.to_string()\n        }\n    }\n}\n\n/// Normalize macOS prefix /private/var/ and /private/tmp/ to their public aliases without resolving paths.\n/// This allows prefix normalization to work when the full paths don't exist.\npub fn normalize_macos_private_alias<P: AsRef<Path>>(p: P) -> PathBuf {\n    let p = p.as_ref();\n    if cfg!(target_os = \"macos\")\n        && let Some(s) = p.to_str()\n    {\n        if s == \"/private/var\" {\n            return PathBuf::from(\"/var\");\n        }\n        if let Some(rest) = s.strip_prefix(\"/private/var/\") {\n            return PathBuf::from(format!(\"/var/{rest}\"));\n        }\n        if s == \"/private/tmp\" {\n            return PathBuf::from(\"/tmp\");\n        }\n        if let Some(rest) = s.strip_prefix(\"/private/tmp/\") {\n            return PathBuf::from(format!(\"/tmp/{rest}\"));\n        }\n    }\n    p.to_path_buf()\n}\n\npub fn get_vibe_kanban_temp_dir() -> std::path::PathBuf {\n    let dir_name = if cfg!(debug_assertions) {\n        \"vibe-kanban-dev\"\n    } else {\n        \"vibe-kanban\"\n    };\n\n    if cfg!(target_os = \"macos\") {\n        // macOS already uses /var/folders/... which is persistent storage\n        std::env::temp_dir().join(dir_name)\n    } else if cfg!(target_os = \"linux\") {\n        // Linux: use /var/tmp instead of /tmp to avoid RAM usage\n        std::path::PathBuf::from(\"/var/tmp\").join(dir_name)\n    } else {\n        // Windows and other platforms: use temp dir with vibe-kanban subdirectory\n        std::env::temp_dir().join(dir_name)\n    }\n}\n\n/// Expand leading ~ to user's home directory.\npub fn expand_tilde(path_str: &str) -> std::path::PathBuf {\n    shellexpand::tilde(path_str).as_ref().into()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_make_path_relative() {\n        // Test with relative path (should remain unchanged)\n        assert_eq!(\n            make_path_relative(\"src/main.rs\", \"/tmp/test-worktree\"),\n            \"src/main.rs\"\n        );\n\n        // Test with absolute path (should become relative if possible)\n        let test_worktree = \"/tmp/test-worktree\";\n        let absolute_path = format!(\"{test_worktree}/src/main.rs\");\n        let result = make_path_relative(&absolute_path, test_worktree);\n        assert_eq!(result, \"src/main.rs\");\n\n        // Test with path outside worktree (should return original)\n        assert_eq!(\n            make_path_relative(\"/other/path/file.js\", \"/tmp/test-worktree\"),\n            \"/other/path/file.js\"\n        );\n    }\n\n    #[cfg(target_os = \"macos\")]\n    #[test]\n    fn test_make_path_relative_macos_private_alias() {\n        // Simulate a worktree under /var with a path reported under /private/var\n        let worktree = \"/var/folders/zz/abc123/T/vibe-kanban-dev/worktrees/vk-test\";\n        let path_under_private = format!(\n            \"/private/var{}/hello-world.txt\",\n            worktree.strip_prefix(\"/var\").unwrap()\n        );\n        assert_eq!(\n            make_path_relative(&path_under_private, worktree),\n            \"hello-world.txt\"\n        );\n\n        // Also handle the inverse: worktree under /private and path under /var\n        let worktree_private = format!(\"/private{worktree}\");\n        let path_under_var = format!(\"{worktree}/hello-world.txt\");\n        assert_eq!(\n            make_path_relative(&path_under_var, &worktree_private),\n            \"hello-world.txt\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/port_file.rs",
    "content": "use std::{env, path::PathBuf};\n\nuse serde::{Deserialize, Serialize};\nuse tokio::fs;\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct PortInfo {\n    pub main_port: u16,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub preview_proxy_port: Option<u16>,\n}\n\npub async fn write_port_file(port: u16) -> std::io::Result<PathBuf> {\n    write_port_file_with_proxy(port, None).await\n}\n\npub async fn write_port_file_with_proxy(\n    main_port: u16,\n    preview_proxy_port: Option<u16>,\n) -> std::io::Result<PathBuf> {\n    let dir = env::temp_dir().join(\"vibe-kanban\");\n    let path = dir.join(\"vibe-kanban.port\");\n    let port_info = PortInfo {\n        main_port,\n        preview_proxy_port,\n    };\n    let content = serde_json::to_string(&port_info)\n        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;\n    tracing::debug!(\"Writing ports {:?} to {:?}\", port_info, path);\n    fs::create_dir_all(&dir).await?;\n    fs::write(&path, content).await?;\n    Ok(path)\n}\n\npub async fn read_port_file(app_name: &str) -> std::io::Result<u16> {\n    read_port_info(app_name).await.map(|info| info.main_port)\n}\n\npub async fn read_port_info(app_name: &str) -> std::io::Result<PortInfo> {\n    let dir = env::temp_dir().join(app_name);\n    let path = dir.join(format!(\"{app_name}.port\"));\n    tracing::debug!(\"Reading port from {:?}\", path);\n\n    let content = fs::read_to_string(&path).await?;\n\n    if let Ok(port_info) = serde_json::from_str::<PortInfo>(&content) {\n        return Ok(port_info);\n    }\n\n    let port: u16 = content\n        .trim()\n        .parse()\n        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;\n\n    Ok(PortInfo {\n        main_port: port,\n        preview_proxy_port: None,\n    })\n}\n"
  },
  {
    "path": "crates/utils/src/process.rs",
    "content": "use command_group::AsyncGroupChild;\n#[cfg(unix)]\nuse nix::{\n    sys::signal::{Signal, killpg},\n    unistd::{Pid, getpgid},\n};\n#[cfg(unix)]\nuse tokio::time::Duration;\n\npub async fn kill_process_group(child: &mut AsyncGroupChild) -> std::io::Result<()> {\n    // hit the whole process group, not just the leader\n    #[cfg(unix)]\n    {\n        if let Some(pid) = child.inner().id() {\n            let pgid = getpgid(Some(Pid::from_raw(pid as i32)))\n                .map_err(|e| std::io::Error::other(e.to_string()))?;\n\n            for sig in [Signal::SIGINT, Signal::SIGTERM, Signal::SIGKILL] {\n                tracing::info!(\"Sending {:?} to process group {}\", sig, pgid);\n                if let Err(e) = killpg(pgid, sig) {\n                    tracing::warn!(\n                        \"Failed to send signal {:?} to process group {}: {}\",\n                        sig,\n                        pgid,\n                        e\n                    );\n                }\n                tracing::info!(\"Waiting 2s for process group {} to exit\", pgid);\n                tokio::time::sleep(Duration::from_secs(2)).await;\n                if child.inner().try_wait()?.is_some() {\n                    tracing::info!(\"Process group {} exited after {:?}\", pgid, sig);\n                    break;\n                }\n            }\n        }\n    }\n\n    let _ = child.kill().await;\n    let _ = child.wait().await;\n    Ok(())\n}\n"
  },
  {
    "path": "crates/utils/src/response.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\n#[derive(Debug, Serialize, Deserialize, TS)]\npub struct ApiResponse<T, E = T> {\n    success: bool,\n    data: Option<T>,\n    error_data: Option<E>,\n    message: Option<String>,\n}\n\nimpl<T, E> ApiResponse<T, E> {\n    /// Creates a successful response, with `data` and no message.\n    pub fn success(data: T) -> Self {\n        ApiResponse {\n            success: true,\n            data: Some(data),\n            message: None,\n            error_data: None,\n        }\n    }\n\n    /// Creates an error response, with `message` and no data.\n    pub fn error(message: &str) -> Self {\n        ApiResponse {\n            success: false,\n            data: None,\n            message: Some(message.to_string()),\n            error_data: None,\n        }\n    }\n    /// Creates an error response, with no `data`, no `message`, but with arbitrary `error_data`.\n    pub fn error_with_data(data: E) -> Self {\n        ApiResponse {\n            success: false,\n            data: None,\n            error_data: Some(data),\n            message: None,\n        }\n    }\n\n    /// Returns true if the response was successful.\n    pub fn is_success(&self) -> bool {\n        self.success\n    }\n\n    /// Consumes the response and returns the data if present.\n    pub fn into_data(self) -> Option<T> {\n        self.data\n    }\n\n    /// Returns a reference to the error message if present.\n    pub fn message(&self) -> Option<&str> {\n        self.message.as_deref()\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/sentry.rs",
    "content": "use std::sync::OnceLock;\n\nuse sentry_tracing::{EventFilter, SentryLayer};\nuse tracing::Level;\n\nstatic INIT_GUARD: OnceLock<sentry::ClientInitGuard> = OnceLock::new();\n\n#[derive(Clone, Copy, Debug)]\npub enum SentrySource {\n    Backend,\n    Desktop,\n    Mcp,\n    Remote,\n}\n\nimpl SentrySource {\n    fn tag(self) -> &'static str {\n        match self {\n            SentrySource::Backend => \"backend\",\n            SentrySource::Desktop => \"desktop\",\n            SentrySource::Mcp => \"mcp\",\n            SentrySource::Remote => \"remote\",\n        }\n    }\n\n    fn dsn(self) -> Option<String> {\n        let value = match self {\n            SentrySource::Remote => option_env!(\"SENTRY_DSN_REMOTE\")\n                .map(|s| s.to_string())\n                .or_else(|| std::env::var(\"SENTRY_DSN_REMOTE\").ok()),\n            _ => option_env!(\"SENTRY_DSN\")\n                .map(|s| s.to_string())\n                .or_else(|| std::env::var(\"SENTRY_DSN\").ok()),\n        };\n        value.filter(|s| !s.is_empty())\n    }\n}\n\nfn environment() -> &'static str {\n    if cfg!(debug_assertions) {\n        \"dev\"\n    } else {\n        \"production\"\n    }\n}\n\npub fn init_once(source: SentrySource) {\n    let Some(dsn) = source.dsn() else {\n        return;\n    };\n\n    INIT_GUARD.get_or_init(|| {\n        sentry::init((\n            dsn,\n            sentry::ClientOptions {\n                release: sentry::release_name!(),\n                environment: Some(environment().into()),\n                ..Default::default()\n            },\n        ))\n    });\n\n    sentry::configure_scope(|scope| {\n        scope.set_tag(\"source\", source.tag());\n    });\n}\n\npub fn configure_user_scope(user_id: &str, username: Option<&str>, email: Option<&str>) {\n    let mut sentry_user = sentry::User {\n        id: Some(user_id.to_string()),\n        ..Default::default()\n    };\n\n    if let Some(username) = username {\n        sentry_user.username = Some(username.to_string());\n    }\n\n    if let Some(email) = email {\n        sentry_user.email = Some(email.to_string());\n    }\n\n    sentry::configure_scope(|scope| {\n        scope.set_user(Some(sentry_user));\n    });\n}\n\npub fn sentry_layer<S>() -> SentryLayer<S>\nwhere\n    S: tracing::Subscriber,\n    S: for<'a> tracing_subscriber::registry::LookupSpan<'a>,\n{\n    SentryLayer::default()\n        .span_filter(|meta| {\n            matches!(\n                *meta.level(),\n                Level::DEBUG | Level::INFO | Level::WARN | Level::ERROR\n            )\n        })\n        .event_filter(|meta| match *meta.level() {\n            Level::ERROR => EventFilter::Event,\n            Level::DEBUG | Level::INFO | Level::WARN => EventFilter::Breadcrumb,\n            Level::TRACE => EventFilter::Ignore,\n        })\n}\n"
  },
  {
    "path": "crates/utils/src/shell.rs",
    "content": "//! Cross-platform shell command utilities\n\nuse std::{\n    collections::HashSet,\n    env::{join_paths, split_paths},\n    ffi::{OsStr, OsString},\n    path::{Path, PathBuf},\n};\n\nuse crate::tokio::block_on;\n\n/// Returns the appropriate shell command and argument for the current platform.\n///\n/// Returns (shell_program, shell_arg) where:\n/// - Windows: (\"cmd\", \"/C\")\n/// - Unix-like: (\"sh\", \"-c\") or (\"bash\", \"-c\") if available\npub fn get_shell_command() -> (String, &'static str) {\n    if cfg!(windows) {\n        (\"cmd\".into(), \"/C\")\n    } else {\n        UnixShell::current_shell().get_shell_command()\n    }\n}\n\n/// Returns the path to an interactive shell for the current platform.\n/// Used for spawning PTY sessions.\n///\n/// On Windows, prefers PowerShell if available, falling back to cmd.exe.\n/// On Unix, returns the user's configured shell from $SHELL.\npub async fn get_interactive_shell() -> PathBuf {\n    if cfg!(windows) {\n        // Prefer PowerShell if available, fall back to cmd.exe\n        if let Some(powershell) = resolve_executable_path(\"powershell.exe\").await {\n            powershell\n        } else {\n            PathBuf::from(\"cmd.exe\")\n        }\n    } else {\n        UnixShell::current_shell().path().to_path_buf()\n    }\n}\n\n/// Resolve an executable by name, falling back to a refreshed PATH if needed.\n///\n/// The search order is:\n/// 1. Explicit paths (absolute or containing a separator).\n/// 2. The current process PATH via `which`.\n/// 3. A platform-specific refresh of PATH (login shell on Unix, PowerShell on Windows),\n///    after which we re-run the `which` lookup and update the process PATH for future calls.\npub async fn resolve_executable_path(executable: &str) -> Option<PathBuf> {\n    if executable.trim().is_empty() {\n        return None;\n    }\n\n    let path = Path::new(executable);\n    if path.is_absolute() && path.is_file() {\n        return Some(path.to_path_buf());\n    }\n\n    if let Some(found) = which(executable).await {\n        return Some(found);\n    }\n\n    if refresh_path().await\n        && let Some(found) = which(executable).await\n    {\n        return Some(found);\n    }\n\n    None\n}\n\npub fn resolve_executable_path_blocking(executable: &str) -> Option<PathBuf> {\n    block_on(resolve_executable_path(executable))\n}\n\n/// Merge two PATH strings into a single, de-duplicated PATH.\n///\n/// - Keeps the order of entries from `primary`.\n/// - Appends only *unseen* entries from `secondary`.\n/// - Ignores empty components.\n/// - Returns a platform-correct PATH string (using the OS separator).\npub fn merge_paths(primary: impl AsRef<OsStr>, secondary: impl AsRef<OsStr>) -> OsString {\n    let mut seen = HashSet::<PathBuf>::new();\n    let mut merged = Vec::<PathBuf>::new();\n\n    for p in split_paths(primary.as_ref()).chain(split_paths(secondary.as_ref())) {\n        if !p.as_os_str().is_empty() && seen.insert(p.clone()) {\n            merged.push(p);\n        }\n    }\n\n    join_paths(merged).unwrap_or_default()\n}\n\nasync fn refresh_path() -> bool {\n    let Some(refreshed) = get_fresh_path().await else {\n        return false;\n    };\n    let existing = std::env::var_os(\"PATH\").unwrap_or_default();\n    let refreshed_os = OsString::from(&refreshed);\n    let merged = merge_paths(&existing, refreshed_os);\n    if merged == existing {\n        return false;\n    }\n    tracing::debug!(?existing, ?refreshed, ?merged, \"Refreshed PATH\");\n    unsafe {\n        std::env::set_var(\"PATH\", &merged);\n    }\n    true\n}\n\nasync fn which(executable: &str) -> Option<PathBuf> {\n    let executable = executable.to_string();\n    tokio::task::spawn_blocking(move || which::which(executable))\n        .await\n        .ok()\n        .and_then(|result| result.ok())\n}\n\n#[derive(Debug, Clone, PartialEq)]\npub enum UnixShell {\n    Zsh(PathBuf),\n    Bash(PathBuf),\n    Sh(PathBuf),\n    Other(PathBuf),\n}\n\nimpl UnixShell {\n    pub fn path(&self) -> &Path {\n        match self {\n            UnixShell::Zsh(p) | UnixShell::Bash(p) | UnixShell::Sh(p) | UnixShell::Other(p) => p,\n        }\n    }\n    pub fn login(&self) -> bool {\n        matches!(self, UnixShell::Zsh(_) | UnixShell::Bash(_))\n    }\n    pub fn config_file(&self) -> Option<PathBuf> {\n        let home = dirs::home_dir()?;\n        let config_file = match self {\n            UnixShell::Zsh(_) => Some(home.join(\".zshrc\")),\n            UnixShell::Bash(_) => Some(home.join(\".bashrc\")),\n            UnixShell::Sh(_) | UnixShell::Other(_) => None,\n        };\n        config_file.filter(|p| p.is_file())\n    }\n\n    pub fn source_command(&self) -> Option<String> {\n        if let Some(source_file) = self.config_file()\n            && let Ok(escaped_source_file) =\n                shlex::try_quote(source_file.to_string_lossy().as_ref())\n        {\n            Some(format!(\"source {escaped_source_file}\"))\n        } else {\n            None\n        }\n    }\n    pub fn current_shell() -> UnixShell {\n        if let Ok(shell) = std::env::var(\"SHELL\")\n            && let Some(shell) = UnixShell::from_path(Path::new(&shell))\n        {\n            return shell;\n        }\n        UnixShell::Sh(PathBuf::from(\"/bin/sh\"))\n    }\n    pub fn from_path(path: &Path) -> Option<UnixShell> {\n        if path.is_absolute() && path.is_file() {\n            let path_buf = path.to_path_buf();\n            if path.file_name() == Some(OsStr::new(\"zsh\")) {\n                Some(UnixShell::Zsh(path_buf))\n            } else if path.file_name() == Some(OsStr::new(\"bash\")) {\n                Some(UnixShell::Bash(path_buf))\n            } else if path.file_name() == Some(OsStr::new(\"sh\")) {\n                Some(UnixShell::Sh(path_buf))\n            } else {\n                Some(UnixShell::Other(path_buf))\n            }\n        } else {\n            None\n        }\n    }\n    pub fn get_shell_command(&self) -> (String, &'static str) {\n        (self.path().to_string_lossy().into_owned(), \"-c\")\n    }\n}\n\n#[cfg(not(windows))]\nasync fn get_fresh_path() -> Option<String> {\n    use std::{process::Stdio, time::Duration};\n\n    use tokio::process::Command;\n\n    async fn run(shell: &UnixShell) -> Option<String> {\n        let mut cmd = Command::new(shell.path());\n        if shell.login() {\n            cmd.arg(\"-l\");\n        }\n        if let Some(source_command) = shell.source_command() {\n            cmd.arg(\"-c\")\n                .arg(format!(\"{source_command}; printf '%s' \\\"$PATH\\\"\"));\n        } else {\n            cmd.arg(\"-c\").arg(\"printf '%s' \\\"$PATH\\\"\");\n        }\n        cmd.env(\"TERM\", \"dumb\")\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .kill_on_drop(true);\n\n        const PATH_REFRESH_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);\n\n        let child = cmd.spawn().ok()?;\n        let output = match tokio::time::timeout(\n            PATH_REFRESH_COMMAND_TIMEOUT,\n            child.wait_with_output(),\n        )\n        .await\n        {\n            Ok(Ok(output)) => output,\n            Ok(Err(err)) => {\n                tracing::debug!(\n                    shell = %shell.path().display(),\n                    ?err,\n                    \"Failed to retrieve PATH from login shell\"\n                );\n                return None;\n            }\n            Err(_) => {\n                tracing::warn!(\n                    shell = %shell.path().display(),\n                    timeout_secs = PATH_REFRESH_COMMAND_TIMEOUT.as_secs(),\n                    \"Timed out retrieving PATH from login shell\"\n                );\n                return None;\n            }\n        };\n\n        if !output.status.success() {\n            return None;\n        }\n        let path = String::from_utf8(output.stdout).ok()?.trim().to_string();\n        if path.is_empty() { None } else { Some(path) }\n    }\n\n    let mut paths = Vec::new();\n\n    let current_shell = UnixShell::current_shell();\n    if let Some(path) = run(&current_shell).await {\n        paths.push(path);\n    }\n\n    let shells: Vec<UnixShell> = [\"/bin/zsh\", \"/bin/bash\", \"/bin/sh\"]\n        .into_iter()\n        .filter_map(|p| UnixShell::from_path(Path::new(p)))\n        .collect();\n    for shell in shells {\n        if !(shell == current_shell)\n            && let Some(path) = run(&shell).await\n        {\n            paths.push(path);\n        }\n    }\n\n    if paths.is_empty() {\n        return None;\n    }\n\n    paths\n        .into_iter()\n        .map(OsString::from)\n        .reduce(|a, b| merge_paths(&a, &b))\n        .map(|merged| merged.to_string_lossy().into_owned())\n}\n\n#[cfg(windows)]\nasync fn get_fresh_path() -> Option<String> {\n    tokio::task::spawn_blocking(get_fresh_path_blocking)\n        .await\n        .ok()\n        .flatten()\n}\n\n#[cfg(windows)]\nfn get_fresh_path_blocking() -> Option<String> {\n    use std::{\n        ffi::{OsStr, OsString},\n        os::windows::ffi::{OsStrExt, OsStringExt},\n    };\n\n    use winreg::{HKEY, RegKey, enums::*};\n\n    // Expand %VARS% for registry PATH entries\n    fn expand_env_vars(input: &OsStr) -> OsString {\n        use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW;\n\n        let wide: Vec<u16> = input.encode_wide().chain(Some(0)).collect();\n        unsafe {\n            let needed = ExpandEnvironmentStringsW(wide.as_ptr(), std::ptr::null_mut(), 0);\n            if needed == 0 {\n                return input.to_os_string();\n            }\n            let mut buf = vec![0u16; needed as usize];\n            let written = ExpandEnvironmentStringsW(wide.as_ptr(), buf.as_mut_ptr(), needed);\n            if written == 0 {\n                return input.to_os_string();\n            }\n            // written includes the trailing NUL when it fits\n            OsString::from_wide(&buf[..(written as usize).saturating_sub(1)])\n        }\n    }\n\n    fn read_registry_path(root: HKEY, subkey: &str) -> Option<OsString> {\n        let key = RegKey::predef(root)\n            .open_subkey_with_flags(subkey, KEY_READ)\n            .ok()?;\n        key.get_value::<String, _>(\"Path\").ok().map(OsString::from)\n    }\n\n    let mut paths: Vec<OsString> = Vec::new();\n\n    if let Some(user_path) = read_registry_path(HKEY_CURRENT_USER, \"Environment\") {\n        paths.push(expand_env_vars(&user_path));\n    }\n\n    if let Some(machine_path) = read_registry_path(\n        HKEY_LOCAL_MACHINE,\n        r\"System\\CurrentControlSet\\Control\\Session Manager\\Environment\",\n    ) {\n        paths.push(expand_env_vars(&machine_path));\n    }\n\n    if paths.is_empty() {\n        return None;\n    }\n\n    paths\n        .into_iter()\n        .map(OsString::from)\n        .reduce(|a, b| merge_paths(&a, &b))\n        .map(|merged| merged.to_string_lossy().into_owned())\n}\n"
  },
  {
    "path": "crates/utils/src/stream_lines.rs",
    "content": "use bytes::Bytes;\nuse futures::{Stream, StreamExt, TryStreamExt};\nuse tokio_util::{\n    codec::{FramedRead, LinesCodec},\n    io::StreamReader,\n};\n\n/// Extension trait for converting chunked string streams to line streams.\npub trait LinesStreamExt: Stream<Item = Result<String, std::io::Error>> + Sized {\n    /// Convert a chunked string stream to a line stream.\n    fn lines(self) -> futures::stream::BoxStream<'static, std::io::Result<String>>\n    where\n        Self: Send + 'static,\n    {\n        let reader = StreamReader::new(self.map(|result| result.map(Bytes::from)));\n        FramedRead::new(reader, LinesCodec::new())\n            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))\n            .boxed()\n    }\n}\n\nimpl<S> LinesStreamExt for S where S: Stream<Item = Result<String, std::io::Error>> {}\n"
  },
  {
    "path": "crates/utils/src/text.rs",
    "content": "use regex::Regex;\nuse uuid::Uuid;\n\npub fn git_branch_id(input: &str) -> String {\n    // 1. lowercase\n    let lower = input.to_lowercase();\n\n    // 2. replace non-alphanumerics with hyphens\n    let re = Regex::new(r\"[^a-z0-9]+\").unwrap();\n    let slug = re.replace_all(&lower, \"-\");\n\n    // 3. trim extra hyphens\n    let trimmed = slug.trim_matches('-');\n\n    // 4. take up to 16 chars, then trim trailing hyphens again\n    let cut: String = trimmed.chars().take(16).collect();\n    cut.trim_end_matches('-').to_string()\n}\n\npub fn short_uuid(u: &Uuid) -> String {\n    // to_simple() gives you a 32-char hex string with no hyphens\n    let full = u.simple().to_string();\n    full.chars().take(4).collect() // grab the first 4 chars\n}\n\npub fn truncate_to_char_boundary(content: &str, max_len: usize) -> &str {\n    if content.len() <= max_len {\n        return content;\n    }\n\n    let cutoff = content\n        .char_indices()\n        .map(|(idx, _)| idx)\n        .chain(std::iter::once(content.len()))\n        .take_while(|&idx| idx <= max_len)\n        .last()\n        .unwrap_or(0);\n\n    debug_assert!(content.is_char_boundary(cutoff));\n    &content[..cutoff]\n}\n\n#[cfg(test)]\nmod tests {\n\n    #[test]\n    fn test_truncate_to_char_boundary() {\n        use super::truncate_to_char_boundary;\n\n        let input = \"a\".repeat(10);\n        assert_eq!(truncate_to_char_boundary(&input, 7), \"a\".repeat(7));\n\n        let input = \"hello world\";\n        assert_eq!(truncate_to_char_boundary(input, input.len()), input);\n\n        let input = \"🔥🔥🔥\"; // each fire emoji is 4 bytes\n        assert_eq!(truncate_to_char_boundary(input, 5), \"🔥\");\n        assert_eq!(truncate_to_char_boundary(input, 3), \"\");\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/tokio.rs",
    "content": "use std::{future::Future, sync::OnceLock};\n\nuse tokio::runtime::{Builder, Handle, Runtime, RuntimeFlavor};\n\nfn rt() -> &'static Runtime {\n    static RT: OnceLock<Runtime> = OnceLock::new();\n    RT.get_or_init(|| {\n        Builder::new_multi_thread()\n            .enable_all()\n            .build()\n            .expect(\"failed to build global Tokio runtime\")\n    })\n}\n\n/// Run an async future from sync code safely.\n/// If already inside a Tokio runtime, it will use that runtime.\npub fn block_on<F, T>(fut: F) -> T\nwhere\n    F: Future<Output = T> + Send,\n    T: Send,\n{\n    match Handle::try_current() {\n        // Already inside a Tokio runtime\n        Ok(h) => match h.runtime_flavor() {\n            // Use block_in_place so other tasks keep running.\n            RuntimeFlavor::MultiThread => tokio::task::block_in_place(|| rt().block_on(fut)),\n            // Spawn a new thread to avoid freezing a single-thread runtime.\n            RuntimeFlavor::CurrentThread | _ => std::thread::scope(|s| {\n                s.spawn(|| rt().block_on(fut))\n                    .join()\n                    .expect(\"thread panicked\")\n            }),\n        },\n        // Outside Tokio: block normally.\n        Err(_) => rt().block_on(fut),\n    }\n}\n"
  },
  {
    "path": "crates/utils/src/version.rs",
    "content": "/// The current application version from Cargo.toml\npub const APP_VERSION: &str = env!(\"CARGO_PKG_VERSION\");\n"
  },
  {
    "path": "crates/workspace-manager/Cargo.toml",
    "content": "[package]\nname = \"workspace-manager\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\ndb = { path = \"../db\" }\ngit = { path = \"../git\" }\nutils = { path = \"../utils\" }\nworktree-manager = { path = \"../worktree-manager\" }\nsqlx = \"0.8.6\"\ntokio = { workspace = true }\nthiserror = { workspace = true }\ntracing = { workspace = true }\nuuid = { version = \"1.0\", features = [\"v4\", \"serde\"] }\n"
  },
  {
    "path": "crates/workspace-manager/src/lib.rs",
    "content": "mod workspace_manager;\n\npub use workspace_manager::{\n    ManagedWorkspace, RepoWorkspaceInput, RepoWorktree, WorkspaceDeletionContext, WorkspaceError,\n    WorkspaceManager, WorktreeContainer,\n};\n"
  },
  {
    "path": "crates/workspace-manager/src/workspace_manager.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse db::{\n    DBService,\n    models::{\n        file::WorkspaceAttachment,\n        repo::{Repo, RepoError},\n        requests::WorkspaceRepoInput,\n        session::Session,\n        workspace::Workspace as DbWorkspace,\n        workspace_repo::{CreateWorkspaceRepo, RepoWithTargetBranch, WorkspaceRepo},\n    },\n};\nuse git::{GitService, GitServiceError};\nuse thiserror::Error;\nuse tracing::{debug, error, info, warn};\nuse uuid::Uuid;\nuse worktree_manager::{WorktreeCleanup, WorktreeError, WorktreeManager};\n\n#[derive(Debug, Clone)]\npub struct RepoWorkspaceInput {\n    pub repo: Repo,\n    pub target_branch: String,\n}\n\nimpl RepoWorkspaceInput {\n    pub fn new(repo: Repo, target_branch: String) -> Self {\n        Self {\n            repo,\n            target_branch,\n        }\n    }\n}\n\n#[derive(Debug, Error)]\npub enum WorkspaceError {\n    #[error(transparent)]\n    Database(#[from] sqlx::Error),\n    #[error(transparent)]\n    Repo(#[from] RepoError),\n    #[error(transparent)]\n    Worktree(#[from] WorktreeError),\n    #[error(transparent)]\n    GitService(#[from] GitServiceError),\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"Workspace not found\")]\n    WorkspaceNotFound,\n    #[error(\"Repository already attached to workspace\")]\n    RepoAlreadyAttached,\n    #[error(\"Branch '{branch}' does not exist in repository '{repo_name}'\")]\n    BranchNotFound { repo_name: String, branch: String },\n    #[error(\"No repositories provided\")]\n    NoRepositories,\n    #[error(\"Partial workspace creation failed: {0}\")]\n    PartialCreation(String),\n}\n\n/// Info about a single repo's worktree within a workspace\n#[derive(Debug, Clone)]\npub struct RepoWorktree {\n    pub repo_id: Uuid,\n    pub repo_name: String,\n    pub source_repo_path: PathBuf,\n    pub worktree_path: PathBuf,\n}\n\n/// A container directory holding worktrees for all project repos\n#[derive(Debug, Clone)]\npub struct WorktreeContainer {\n    pub workspace_dir: PathBuf,\n    pub worktrees: Vec<RepoWorktree>,\n}\n\n#[derive(Debug, Clone)]\npub struct WorkspaceDeletionContext {\n    pub workspace_id: Uuid,\n    pub branch_name: String,\n    pub workspace_dir: Option<PathBuf>,\n    pub repositories: Vec<Repo>,\n    pub repo_paths: Vec<PathBuf>,\n    pub session_ids: Vec<Uuid>,\n}\n\n#[derive(Clone)]\npub struct ManagedWorkspace {\n    pub workspace: DbWorkspace,\n    pub repos: Vec<RepoWithTargetBranch>,\n    db: DBService,\n}\n\nimpl ManagedWorkspace {\n    fn new(db: DBService, workspace: DbWorkspace, repos: Vec<RepoWithTargetBranch>) -> Self {\n        Self {\n            workspace,\n            repos,\n            db,\n        }\n    }\n\n    async fn attach_repository(&self, repo: &WorkspaceRepoInput) -> Result<(), sqlx::Error> {\n        let create_repo = CreateWorkspaceRepo {\n            repo_id: repo.repo_id,\n            target_branch: repo.target_branch.clone(),\n        };\n\n        WorkspaceRepo::create_many(\n            &self.db.pool,\n            self.workspace.id,\n            std::slice::from_ref(&create_repo),\n        )\n        .await\n        .map(|_| ())\n    }\n\n    async fn refresh(&mut self) -> Result<(), WorkspaceError> {\n        self.workspace = DbWorkspace::find_by_id(&self.db.pool, self.workspace.id)\n            .await?\n            .ok_or(WorkspaceError::WorkspaceNotFound)?;\n        self.repos = WorkspaceRepo::find_repos_with_target_branch_for_workspace(\n            &self.db.pool,\n            self.workspace.id,\n        )\n        .await?;\n        Ok(())\n    }\n\n    pub async fn add_repository(\n        &mut self,\n        repo_ref: &WorkspaceRepoInput,\n        git: &GitService,\n    ) -> Result<(), WorkspaceError> {\n        let repo = Repo::find_by_id(&self.db.pool, repo_ref.repo_id)\n            .await?\n            .ok_or(RepoError::NotFound)?;\n\n        if !git.check_branch_exists(&repo.path, &repo_ref.target_branch)? {\n            return Err(WorkspaceError::BranchNotFound {\n                repo_name: repo.name,\n                branch: repo_ref.target_branch.clone(),\n            });\n        }\n\n        if WorkspaceRepo::find_by_workspace_and_repo_id(\n            &self.db.pool,\n            self.workspace.id,\n            repo_ref.repo_id,\n        )\n        .await?\n        .is_some()\n        {\n            return Err(WorkspaceError::RepoAlreadyAttached);\n        }\n\n        self.attach_repository(repo_ref).await?;\n        self.refresh().await?;\n        Ok(())\n    }\n\n    pub async fn associate_attachments(&self, attachment_ids: &[Uuid]) -> Result<(), sqlx::Error> {\n        if attachment_ids.is_empty() {\n            return Ok(());\n        }\n\n        WorkspaceAttachment::associate_many_dedup(&self.db.pool, self.workspace.id, attachment_ids)\n            .await\n    }\n\n    pub async fn prepare_deletion_context(&self) -> Result<WorkspaceDeletionContext, sqlx::Error> {\n        let repositories =\n            WorkspaceRepo::find_repos_for_workspace(&self.db.pool, self.workspace.id).await?;\n        let session_ids = Session::find_by_workspace_id(&self.db.pool, self.workspace.id)\n            .await?\n            .into_iter()\n            .map(|session| session.id)\n            .collect::<Vec<_>>();\n        let repo_paths = repositories\n            .iter()\n            .map(|repo| repo.path.clone())\n            .collect::<Vec<_>>();\n\n        Ok(WorkspaceDeletionContext {\n            workspace_id: self.workspace.id,\n            branch_name: self.workspace.branch.clone(),\n            workspace_dir: self.workspace.container_ref.clone().map(PathBuf::from),\n            repositories,\n            repo_paths,\n            session_ids,\n        })\n    }\n\n    pub async fn delete_record(&self) -> Result<u64, sqlx::Error> {\n        DbWorkspace::delete(&self.db.pool, self.workspace.id).await\n    }\n}\n\n#[derive(Clone)]\npub struct WorkspaceManager {\n    db: DBService,\n}\n\nimpl WorkspaceManager {\n    pub fn new(db: DBService) -> Self {\n        Self { db }\n    }\n\n    pub async fn load_managed_workspace(\n        &self,\n        workspace: DbWorkspace,\n    ) -> Result<ManagedWorkspace, sqlx::Error> {\n        let repos =\n            WorkspaceRepo::find_repos_with_target_branch_for_workspace(&self.db.pool, workspace.id)\n                .await?;\n        Ok(ManagedWorkspace::new(self.db.clone(), workspace, repos))\n    }\n\n    pub fn spawn_workspace_deletion_cleanup(\n        context: WorkspaceDeletionContext,\n        delete_branches: bool,\n    ) {\n        tokio::spawn(async move {\n            let WorkspaceDeletionContext {\n                workspace_id,\n                branch_name,\n                workspace_dir,\n                repositories,\n                repo_paths,\n                session_ids,\n            } = context;\n\n            for session_id in session_ids {\n                if let Err(e) = Self::remove_session_process_logs(session_id).await {\n                    warn!(\n                        \"Failed to remove filesystem process logs for session {}: {}\",\n                        session_id, e\n                    );\n                }\n            }\n\n            if let Some(workspace_dir) = workspace_dir {\n                info!(\n                    \"Starting background cleanup for workspace {} at {}\",\n                    workspace_id,\n                    workspace_dir.display()\n                );\n\n                if let Err(e) = Self::cleanup_workspace(&workspace_dir, &repositories).await {\n                    error!(\n                        \"Background workspace cleanup failed for {} at {}: {}\",\n                        workspace_id,\n                        workspace_dir.display(),\n                        e\n                    );\n                } else {\n                    info!(\n                        \"Background cleanup completed for workspace {}\",\n                        workspace_id\n                    );\n                }\n            }\n\n            if delete_branches {\n                let git_service = GitService::new();\n                for repo_path in repo_paths {\n                    match git_service.delete_branch(&repo_path, &branch_name) {\n                        Ok(()) => {\n                            info!(\"Deleted branch '{}' from repo {:?}\", branch_name, repo_path);\n                        }\n                        Err(e) => {\n                            warn!(\n                                \"Failed to delete branch '{}' from repo {:?}: {}\",\n                                branch_name, repo_path, e\n                            );\n                        }\n                    }\n                }\n            }\n        });\n    }\n\n    async fn remove_session_process_logs(session_id: Uuid) -> Result<(), std::io::Error> {\n        let dir = utils::execution_logs::process_logs_session_dir(session_id);\n        match tokio::fs::remove_dir_all(&dir).await {\n            Ok(()) => Ok(()),\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),\n            Err(e) => Err(e),\n        }\n    }\n\n    /// Create a workspace with worktrees for all repositories.\n    /// On failure, rolls back any already-created worktrees.\n    pub async fn create_workspace(\n        workspace_dir: &Path,\n        repos: &[RepoWorkspaceInput],\n        branch_name: &str,\n    ) -> Result<WorktreeContainer, WorkspaceError> {\n        if repos.is_empty() {\n            return Err(WorkspaceError::NoRepositories);\n        }\n\n        info!(\n            \"Creating workspace at {} with {} repositories\",\n            workspace_dir.display(),\n            repos.len()\n        );\n\n        tokio::fs::create_dir_all(workspace_dir).await?;\n\n        let mut created_worktrees: Vec<RepoWorktree> = Vec::new();\n\n        for input in repos {\n            let worktree_path = workspace_dir.join(&input.repo.name);\n\n            debug!(\n                \"Creating worktree for repo '{}' at {}\",\n                input.repo.name,\n                worktree_path.display()\n            );\n\n            match WorktreeManager::create_worktree(\n                &input.repo.path,\n                branch_name,\n                &worktree_path,\n                &input.target_branch,\n                true,\n            )\n            .await\n            {\n                Ok(()) => {\n                    created_worktrees.push(RepoWorktree {\n                        repo_id: input.repo.id,\n                        repo_name: input.repo.name.clone(),\n                        source_repo_path: input.repo.path.clone(),\n                        worktree_path,\n                    });\n                }\n                Err(e) => {\n                    error!(\n                        \"Failed to create worktree for repo '{}': {}. Rolling back...\",\n                        input.repo.name, e\n                    );\n\n                    // Rollback: cleanup all worktrees we've created so far\n                    Self::cleanup_created_worktrees(&created_worktrees).await;\n\n                    // Also remove the workspace directory if it's empty\n                    if let Err(cleanup_err) = tokio::fs::remove_dir(workspace_dir).await {\n                        debug!(\n                            \"Could not remove workspace dir during rollback: {}\",\n                            cleanup_err\n                        );\n                    }\n\n                    return Err(WorkspaceError::PartialCreation(format!(\n                        \"Failed to create worktree for repo '{}': {}\",\n                        input.repo.name, e\n                    )));\n                }\n            }\n        }\n\n        info!(\n            \"Successfully created workspace with {} worktrees\",\n            created_worktrees.len()\n        );\n\n        Ok(WorktreeContainer {\n            workspace_dir: workspace_dir.to_path_buf(),\n            worktrees: created_worktrees,\n        })\n    }\n\n    /// Ensure all worktrees in a workspace exist (for cold restart scenarios)\n    pub async fn ensure_workspace_exists(\n        workspace_dir: &Path,\n        repos: &[RepoWorkspaceInput],\n        branch_name: &str,\n    ) -> Result<(), WorkspaceError> {\n        if repos.is_empty() {\n            return Err(WorkspaceError::NoRepositories);\n        }\n\n        // Try legacy migration first (single repo projects only)\n        // Old layout had worktree directly at workspace_dir; new layout has it at workspace_dir/{repo_name}\n        if repos.len() == 1 && Self::migrate_legacy_worktree(workspace_dir, &repos[0].repo).await? {\n            return Ok(());\n        }\n\n        if !workspace_dir.exists() {\n            tokio::fs::create_dir_all(workspace_dir).await?;\n        }\n\n        let git = GitService::new();\n\n        for input in repos {\n            let repo = &input.repo;\n            let worktree_path = workspace_dir.join(&repo.name);\n\n            debug!(\n                \"Ensuring worktree exists for repo '{}' at {}\",\n                repo.name,\n                worktree_path.display()\n            );\n\n            if git.check_branch_exists(&repo.path, branch_name)? {\n                WorktreeManager::ensure_worktree_exists(&repo.path, branch_name, &worktree_path)\n                    .await?;\n            } else {\n                info!(\n                    \"Workspace branch '{}' missing in repo '{}'; creating from target branch '{}'\",\n                    branch_name, repo.name, input.target_branch\n                );\n                WorktreeManager::create_worktree(\n                    &repo.path,\n                    branch_name,\n                    &worktree_path,\n                    &input.target_branch,\n                    true,\n                )\n                .await?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Clean up all worktrees in a workspace\n    pub async fn cleanup_workspace(\n        workspace_dir: &Path,\n        repos: &[Repo],\n    ) -> Result<(), WorkspaceError> {\n        info!(\"Cleaning up workspace at {}\", workspace_dir.display());\n\n        let cleanup_data: Vec<WorktreeCleanup> = repos\n            .iter()\n            .map(|repo| {\n                let worktree_path = workspace_dir.join(&repo.name);\n                WorktreeCleanup::new(worktree_path, Some(repo.path.clone()))\n            })\n            .collect();\n\n        WorktreeManager::batch_cleanup_worktrees(&cleanup_data).await?;\n\n        // Remove the workspace directory itself\n        if workspace_dir.exists()\n            && let Err(e) = tokio::fs::remove_dir_all(workspace_dir).await\n        {\n            debug!(\n                \"Could not remove workspace directory {}: {}\",\n                workspace_dir.display(),\n                e\n            );\n        }\n\n        Ok(())\n    }\n\n    /// Get the base directory for workspaces (same as worktree base dir)\n    pub fn get_workspace_base_dir() -> PathBuf {\n        WorktreeManager::get_worktree_base_dir()\n    }\n\n    /// Migrate a legacy single-worktree layout to the new workspace layout.\n    /// Old layout: workspace_dir IS the worktree\n    /// New layout: workspace_dir contains worktrees at workspace_dir/{repo_name}\n    ///\n    /// Returns Ok(true) if migration was performed, Ok(false) if no migration needed.\n    pub async fn migrate_legacy_worktree(\n        workspace_dir: &Path,\n        repo: &Repo,\n    ) -> Result<bool, WorkspaceError> {\n        let expected_worktree_path = workspace_dir.join(&repo.name);\n\n        // Detect old-style: workspace_dir exists AND has .git file (worktree marker)\n        // AND expected new location doesn't exist\n        let git_file = workspace_dir.join(\".git\");\n        let is_old_style = workspace_dir.exists()\n            && git_file.exists()\n            && git_file.is_file() // .git file = worktree, .git dir = main repo\n            && !expected_worktree_path.exists();\n\n        if !is_old_style {\n            return Ok(false);\n        }\n\n        info!(\n            \"Detected legacy worktree at {}, migrating to new layout\",\n            workspace_dir.display()\n        );\n\n        // Move old worktree to temp location (can't move into subdirectory of itself)\n        let temp_name = format!(\n            \"{}-migrating\",\n            workspace_dir\n                .file_name()\n                .map(|n| n.to_string_lossy())\n                .unwrap_or_default()\n        );\n        let temp_path = workspace_dir.with_file_name(temp_name);\n\n        WorktreeManager::move_worktree(&repo.path, workspace_dir, &temp_path).await?;\n\n        // Create new workspace directory\n        tokio::fs::create_dir_all(workspace_dir).await?;\n\n        // Move worktree to final location using git worktree move\n        WorktreeManager::move_worktree(&repo.path, &temp_path, &expected_worktree_path).await?;\n\n        if temp_path.exists() {\n            let _ = tokio::fs::remove_dir_all(&temp_path).await;\n        }\n\n        info!(\n            \"Successfully migrated legacy worktree to {}\",\n            expected_worktree_path.display()\n        );\n\n        Ok(true)\n    }\n\n    /// Helper to cleanup worktrees during rollback\n    async fn cleanup_created_worktrees(worktrees: &[RepoWorktree]) {\n        for worktree in worktrees {\n            let cleanup = WorktreeCleanup::new(\n                worktree.worktree_path.clone(),\n                Some(worktree.source_repo_path.clone()),\n            );\n\n            if let Err(e) = WorktreeManager::cleanup_worktree(&cleanup).await {\n                error!(\n                    \"Failed to cleanup worktree '{}' during rollback: {}\",\n                    worktree.repo_name, e\n                );\n            }\n        }\n    }\n\n    pub async fn cleanup_orphan_workspaces(&self) {\n        if std::env::var(\"DISABLE_WORKTREE_CLEANUP\").is_ok() {\n            info!(\n                \"Orphan workspace cleanup is disabled via DISABLE_WORKTREE_CLEANUP environment variable\"\n            );\n            return;\n        }\n\n        // Always clean up the default directory\n        let default_dir = WorktreeManager::get_default_worktree_base_dir();\n        self.cleanup_orphans_in_directory(&default_dir).await;\n\n        // Also clean up custom directory if it's different from the default\n        let current_dir = Self::get_workspace_base_dir();\n        if current_dir != default_dir {\n            self.cleanup_orphans_in_directory(&current_dir).await;\n        }\n    }\n\n    async fn cleanup_orphans_in_directory(&self, workspace_base_dir: &Path) {\n        if !workspace_base_dir.exists() {\n            debug!(\n                \"Workspace base directory {} does not exist, skipping orphan cleanup\",\n                workspace_base_dir.display()\n            );\n            return;\n        }\n\n        let entries = match std::fs::read_dir(workspace_base_dir) {\n            Ok(entries) => entries,\n            Err(e) => {\n                error!(\n                    \"Failed to read workspace base directory {}: {}\",\n                    workspace_base_dir.display(),\n                    e\n                );\n                return;\n            }\n        };\n\n        for entry in entries {\n            let entry = match entry {\n                Ok(entry) => entry,\n                Err(e) => {\n                    warn!(\"Failed to read directory entry: {}\", e);\n                    continue;\n                }\n            };\n\n            let path = entry.path();\n            if !path.is_dir() {\n                continue;\n            }\n\n            let workspace_path_str = path.to_string_lossy().to_string();\n            if let Ok(false) =\n                DbWorkspace::container_ref_exists(&self.db.pool, &workspace_path_str).await\n            {\n                info!(\"Found orphaned workspace: {}\", workspace_path_str);\n                if let Err(e) = Self::cleanup_workspace_without_repos(&path).await {\n                    error!(\n                        \"Failed to remove orphaned workspace {}: {}\",\n                        workspace_path_str, e\n                    );\n                } else {\n                    info!(\n                        \"Successfully removed orphaned workspace: {}\",\n                        workspace_path_str\n                    );\n                }\n            }\n        }\n    }\n\n    async fn cleanup_workspace_without_repos(workspace_dir: &Path) -> Result<(), WorkspaceError> {\n        info!(\n            \"Cleaning up orphaned workspace at {}\",\n            workspace_dir.display()\n        );\n\n        let entries = match std::fs::read_dir(workspace_dir) {\n            Ok(entries) => entries,\n            Err(e) => {\n                debug!(\n                    \"Cannot read workspace directory {}, attempting direct removal: {}\",\n                    workspace_dir.display(),\n                    e\n                );\n                return tokio::fs::remove_dir_all(workspace_dir)\n                    .await\n                    .map_err(WorkspaceError::Io);\n            }\n        };\n\n        for entry in entries.filter_map(|e| e.ok()) {\n            let path = entry.path();\n            if path.is_dir()\n                && let Err(e) = WorktreeManager::cleanup_suspected_worktree(&path).await\n            {\n                warn!(\"Failed to cleanup suspected worktree: {}\", e);\n            }\n        }\n\n        if workspace_dir.exists()\n            && let Err(e) = tokio::fs::remove_dir_all(workspace_dir).await\n        {\n            debug!(\n                \"Could not remove workspace directory {}: {}\",\n                workspace_dir.display(),\n                e\n            );\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/worktree-manager/Cargo.toml",
    "content": "[package]\nname = \"worktree-manager\"\nversion = \"0.1.33\"\nedition = \"2024\"\n\n[dependencies]\ngit = { path = \"../git\" }\nutils = { path = \"../utils\" }\ntokio = { workspace = true }\nthiserror = { workspace = true }\ntracing = { workspace = true }\ngit2 = { workspace = true }\ndunce = \"1.0\"\n\n[dev-dependencies]\ntempfile = \"3.21\"\n"
  },
  {
    "path": "crates/worktree-manager/src/lib.rs",
    "content": "mod worktree_manager;\n\npub use worktree_manager::{WorktreeCleanup, WorktreeError, WorktreeManager};\n"
  },
  {
    "path": "crates/worktree-manager/src/worktree_manager.rs",
    "content": "use std::{\n    collections::HashMap,\n    fs,\n    path::{Path, PathBuf},\n    sync::{Arc, LazyLock, Mutex, OnceLock},\n};\n\nstatic WORKSPACE_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();\n\nuse git::{GitService, GitServiceError};\nuse git2::{Error as GitError, Repository};\nuse thiserror::Error;\nuse tracing::{debug, info, trace};\nuse utils::{path::normalize_macos_private_alias, shell::resolve_executable_path};\n\n// Global synchronization for worktree creation to prevent race conditions\nstatic WORKTREE_CREATION_LOCKS: LazyLock<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =\n    LazyLock::new(|| Mutex::new(HashMap::new()));\n\n#[derive(Debug, Clone)]\npub struct WorktreeCleanup {\n    pub worktree_path: PathBuf,\n    pub git_repo_path: Option<PathBuf>,\n}\n\nimpl WorktreeCleanup {\n    pub fn new(worktree_path: PathBuf, git_repo_path: Option<PathBuf>) -> Self {\n        Self {\n            worktree_path,\n            git_repo_path,\n        }\n    }\n}\n\n#[derive(Debug, Error)]\npub enum WorktreeError {\n    #[error(transparent)]\n    Git(#[from] GitError),\n    #[error(transparent)]\n    GitService(#[from] GitServiceError),\n    #[error(\"Git CLI error: {0}\")]\n    GitCli(String),\n    #[error(\"Task join error: {0}\")]\n    TaskJoin(String),\n    #[error(\"Invalid path: {0}\")]\n    InvalidPath(String),\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"Branch not found: {0}\")]\n    BranchNotFound(String),\n    #[error(\"Repository error: {0}\")]\n    Repository(String),\n}\n\npub struct WorktreeManager;\n\nimpl WorktreeManager {\n    pub fn set_workspace_dir_override(path: PathBuf) {\n        let _ = WORKSPACE_DIR_OVERRIDE.set(path);\n    }\n\n    /// Create a worktree with a new branch\n    pub async fn create_worktree(\n        repo_path: &Path,\n        branch_name: &str,\n        worktree_path: &Path,\n        base_branch: &str,\n        create_branch: bool,\n    ) -> Result<(), WorktreeError> {\n        if create_branch {\n            let repo_path_owned = repo_path.to_path_buf();\n            let branch_name_owned = branch_name.to_string();\n            let base_branch_owned = base_branch.to_string();\n\n            tokio::task::spawn_blocking(move || {\n                let repo = Repository::open(&repo_path_owned)?;\n                let base_branch_ref =\n                    GitService::find_branch(&repo, &base_branch_owned)?.into_reference();\n                repo.branch(\n                    &branch_name_owned,\n                    &base_branch_ref.peel_to_commit()?,\n                    false,\n                )?;\n                Ok::<(), GitServiceError>(())\n            })\n            .await\n            .map_err(|e| WorktreeError::TaskJoin(format!(\"Task join error: {e}\")))??;\n        }\n\n        Self::ensure_worktree_exists(repo_path, branch_name, worktree_path).await\n    }\n\n    /// Ensure worktree exists, recreating if necessary with proper synchronization\n    /// This is the main entry point for ensuring a worktree exists and prevents race conditions\n    pub async fn ensure_worktree_exists(\n        repo_path: &Path,\n        branch_name: &str,\n        worktree_path: &Path,\n    ) -> Result<(), WorktreeError> {\n        let path_str = worktree_path.to_string_lossy().to_string();\n\n        // Get or create a lock for this specific worktree path\n        let lock = {\n            let mut locks = WORKTREE_CREATION_LOCKS.lock().unwrap();\n            locks\n                .entry(path_str.clone())\n                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))\n                .clone()\n        };\n\n        // Acquire the lock for this specific worktree path\n        let _guard = lock.lock().await;\n\n        // Check if worktree already exists and is properly set up\n        if Self::is_worktree_properly_set_up(repo_path, worktree_path).await? {\n            trace!(\"Worktree already properly set up at path: {}\", path_str);\n            return Ok(());\n        }\n\n        // If worktree doesn't exist or isn't properly set up, recreate it\n        info!(\"Worktree needs recreation at path: {}\", path_str);\n        Self::recreate_worktree_internal(repo_path, branch_name, worktree_path).await\n    }\n\n    /// Internal worktree recreation function (always recreates)\n    async fn recreate_worktree_internal(\n        repo_path: &Path,\n        branch_name: &str,\n        worktree_path: &Path,\n    ) -> Result<(), WorktreeError> {\n        let path_str = worktree_path.to_string_lossy().to_string();\n        let branch_name_owned = branch_name.to_string();\n        let worktree_path_owned = worktree_path.to_path_buf();\n\n        info!(\n            \"Creating worktree {} at path {}\",\n            branch_name_owned, path_str\n        );\n\n        // Step 1: Comprehensive cleanup of existing worktree and metadata (non-blocking)\n        Self::comprehensive_worktree_cleanup_async(repo_path, &worktree_path_owned).await?;\n\n        // Step 2: Ensure parent directory exists (non-blocking)\n        if let Some(parent) = worktree_path_owned.parent() {\n            let parent_path = parent.to_path_buf();\n            tokio::task::spawn_blocking(move || std::fs::create_dir_all(&parent_path))\n                .await\n                .map_err(|e| WorktreeError::TaskJoin(format!(\"Task join error: {e}\")))?\n                .map_err(WorktreeError::Io)?;\n        }\n\n        // Step 3: Create the worktree with retry logic for metadata conflicts (non-blocking)\n        Self::create_worktree_with_retry(\n            repo_path,\n            &branch_name_owned,\n            &worktree_path_owned,\n            &path_str,\n        )\n        .await\n    }\n\n    /// Check if a worktree is properly set up (filesystem + git metadata)\n    async fn is_worktree_properly_set_up(\n        repo_path: &Path,\n        worktree_path: &Path,\n    ) -> Result<bool, WorktreeError> {\n        let repo_path = repo_path.to_path_buf();\n        let worktree_path = worktree_path.to_path_buf();\n\n        tokio::task::spawn_blocking(move || -> Result<bool, WorktreeError> {\n            // Check 1: Filesystem path must exist\n            if !worktree_path.exists() {\n                return Ok(false);\n            }\n\n            // Check 2: Worktree must be registered in git metadata using find_worktree\n            let repo = Repository::open(&repo_path).map_err(WorktreeError::Git)?;\n            let Some(worktree_name) =\n                Self::find_worktree_git_internal_name(&repo_path, &worktree_path)?\n            else {\n                // Directory exists but not registered in git metadata - needs recreation\n                return Ok(false);\n            };\n\n            // Try to find the worktree - if it exists and is valid, we're good\n            match repo.find_worktree(&worktree_name) {\n                Ok(_) => Ok(true),\n                Err(_) => Ok(false),\n            }\n        })\n        .await\n        .map_err(|e| WorktreeError::TaskJoin(format!(\"{e}\")))?\n    }\n\n    fn find_worktree_git_internal_name(\n        git_repo_path: &Path,\n        worktree_path: &Path,\n    ) -> Result<Option<String>, WorktreeError> {\n        fn canonicalize_for_compare(path: &Path) -> PathBuf {\n            dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())\n        }\n\n        let worktree_root = canonicalize_for_compare(&normalize_macos_private_alias(worktree_path));\n        let worktree_metadata_path = Self::get_worktree_metadata_path(git_repo_path)?;\n        let worktree_metadata_folders = match fs::read_dir(&worktree_metadata_path) {\n            Ok(read_dir) => read_dir\n                .filter_map(|entry| entry.ok())\n                .collect::<Vec<fs::DirEntry>>(),\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),\n            Err(e) => {\n                return Err(WorktreeError::Repository(format!(\n                    \"Failed to read worktree metadata directory at {}: {}\",\n                    worktree_metadata_path.display(),\n                    e\n                )));\n            }\n        };\n        // read the worktrees/*/gitdir and see which one matches the worktree_path\n        for entry in worktree_metadata_folders {\n            let gitdir_path = entry.path().join(\"gitdir\");\n            if gitdir_path.exists()\n                && let Ok(gitdir_content) = fs::read_to_string(&gitdir_path)\n                && normalize_macos_private_alias(Path::new(gitdir_content.trim()))\n                    .parent()\n                    .map(canonicalize_for_compare)\n                    .is_some_and(|p| p == worktree_root)\n            {\n                return Ok(Some(entry.file_name().to_string_lossy().to_string()));\n            }\n        }\n        Ok(None)\n    }\n\n    fn get_worktree_metadata_path(git_repo_path: &Path) -> Result<PathBuf, WorktreeError> {\n        let repo = Repository::open(git_repo_path).map_err(WorktreeError::Git)?;\n        Ok(repo.commondir().join(\"worktrees\"))\n    }\n\n    /// Comprehensive cleanup of worktree path and metadata to prevent \"path exists\" errors (blocking)\n    fn comprehensive_worktree_cleanup(\n        repo: &Repository,\n        worktree_path: &Path,\n    ) -> Result<(), WorktreeError> {\n        let worktree_display_name = worktree_path.to_string_lossy().to_string();\n        debug!(\"Performing cleanup for worktree: {worktree_display_name}\");\n\n        let git_repo_path = Self::get_git_repo_path(repo)?;\n\n        // Step 1: Use GitService to remove the worktree registration (force) if present\n        // The Git CLI is more robust than libgit2 for mutable worktree operations\n        let git_service = GitService::new();\n        if let Err(e) = git_service.remove_worktree(&git_repo_path, worktree_path, true) {\n            debug!(\"git worktree remove non-fatal error: {}\", e);\n        }\n\n        // Step 2: Always force cleanup metadata directory (proactive cleanup)\n        if let Err(e) = Self::force_cleanup_worktree_metadata(&git_repo_path, worktree_path) {\n            debug!(\"Metadata cleanup failed (non-fatal): {}\", e);\n        }\n\n        // Step 3: Clean up physical worktree directory if it exists\n        if worktree_path.exists() {\n            debug!(\n                \"Removing existing worktree directory: {}\",\n                worktree_path.display()\n            );\n            std::fs::remove_dir_all(worktree_path).map_err(WorktreeError::Io)?;\n        }\n\n        // Step 4: Good-practice to clean up any other stale admin entries\n        if let Err(e) = git_service.prune_worktrees(&git_repo_path) {\n            debug!(\"git worktree prune non-fatal error: {}\", e);\n        }\n\n        debug!(\"Comprehensive cleanup completed for worktree: {worktree_display_name}\",);\n        Ok(())\n    }\n\n    /// Async version of comprehensive cleanup to avoid blocking the main runtime\n    async fn comprehensive_worktree_cleanup_async(\n        git_repo_path: &Path,\n        worktree_path: &Path,\n    ) -> Result<(), WorktreeError> {\n        let git_repo_path_owned = git_repo_path.to_path_buf();\n        let worktree_path_owned = worktree_path.to_path_buf();\n\n        // First, try to open the repository to see if it exists\n        let repo_result = tokio::task::spawn_blocking({\n            let git_repo_path = git_repo_path_owned.clone();\n            move || Repository::open(&git_repo_path)\n        })\n        .await;\n\n        match repo_result {\n            Ok(Ok(repo)) => {\n                // Repository exists, perform comprehensive cleanup\n                tokio::task::spawn_blocking(move || {\n                    Self::comprehensive_worktree_cleanup(&repo, &worktree_path_owned)\n                })\n                .await\n                .map_err(|e| WorktreeError::TaskJoin(format!(\"Task join error: {e}\")))?\n            }\n            Ok(Err(e)) => {\n                // Repository doesn't exist (likely deleted project), fall back to simple cleanup\n                debug!(\n                    \"Failed to open repository at {:?}: {}. Falling back to simple cleanup for worktree at {}\",\n                    git_repo_path_owned,\n                    e,\n                    worktree_path_owned.display()\n                );\n                Self::simple_worktree_cleanup(&worktree_path_owned).await?;\n                Ok(())\n            }\n            Err(e) => Err(WorktreeError::TaskJoin(format!(\"{e}\"))),\n        }\n    }\n\n    /// Create worktree with retry logic in non-blocking manner\n    async fn create_worktree_with_retry(\n        git_repo_path: &Path,\n        branch_name: &str,\n        worktree_path: &Path,\n        path_str: &str,\n    ) -> Result<(), WorktreeError> {\n        let git_repo_path = git_repo_path.to_path_buf();\n        let branch_name = branch_name.to_string();\n        let worktree_path = worktree_path.to_path_buf();\n        let path_str = path_str.to_string();\n\n        tokio::task::spawn_blocking(move || -> Result<(), WorktreeError> {\n            // Prefer git CLI for worktree add to inherit sparse-checkout semantics\n            let git_service = GitService::new();\n            match git_service.add_worktree(&git_repo_path, &worktree_path, &branch_name, false) {\n                Ok(()) => {\n                    if !worktree_path.exists() {\n                        return Err(WorktreeError::Repository(format!(\n                            \"Worktree creation reported success but path {path_str} does not exist\"\n                        )));\n                    }\n                    info!(\n                        \"Successfully created worktree {} at {} (git CLI)\",\n                        branch_name, path_str\n                    );\n                    Ok(())\n                }\n                Err(e) => {\n                    tracing::warn!(\n                        \"git worktree add failed; attempting metadata cleanup and retry: {}\",\n                        e\n                    );\n                    // Force cleanup metadata and try one more time\n                    Self::force_cleanup_worktree_metadata(&git_repo_path, &worktree_path)?;\n                    // Clean up physical directory if it exists\n                    // Needed if previous attempt failed after directory creation\n                    if worktree_path.exists() {\n                        std::fs::remove_dir_all(&worktree_path).map_err(WorktreeError::Io)?;\n                    }\n                    if let Err(e2) = git_service.add_worktree(\n                        &git_repo_path,\n                        &worktree_path,\n                        &branch_name,\n                        false,\n                    ) {\n                        return Err(WorktreeError::GitService(e2));\n                    }\n                    if !worktree_path.exists() {\n                        return Err(WorktreeError::Repository(format!(\n                            \"Worktree creation reported success but path {path_str} does not exist\"\n                        )));\n                    }\n                    info!(\n                        \"Successfully created worktree {} at {} after metadata cleanup (git CLI)\",\n                        branch_name, path_str\n                    );\n                    Ok(())\n                }\n            }\n        })\n        .await\n        .map_err(|e| WorktreeError::TaskJoin(format!(\"{e}\")))?\n    }\n\n    /// Get the git repository path\n    fn get_git_repo_path(repo: &Repository) -> Result<PathBuf, WorktreeError> {\n        repo.workdir()\n            .ok_or_else(|| {\n                WorktreeError::Repository(\"Repository has no working directory\".to_string())\n            })?\n            .to_str()\n            .ok_or_else(|| {\n                WorktreeError::InvalidPath(\"Repository path is not valid UTF-8\".to_string())\n            })\n            .map(PathBuf::from)\n    }\n\n    /// Force cleanup worktree metadata directory\n    fn force_cleanup_worktree_metadata(\n        git_repo_path: &Path,\n        worktree_path: &Path,\n    ) -> Result<(), WorktreeError> {\n        if let Some(worktree_name) =\n            Self::find_worktree_git_internal_name(git_repo_path, worktree_path)?\n        {\n            let git_worktree_metadata_path =\n                Self::get_worktree_metadata_path(git_repo_path)?.join(worktree_name);\n\n            if git_worktree_metadata_path.exists() {\n                debug!(\n                    \"Force removing git worktree metadata: {}\",\n                    git_worktree_metadata_path.display()\n                );\n                std::fs::remove_dir_all(&git_worktree_metadata_path)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Clean up multiple worktrees\n    pub async fn batch_cleanup_worktrees(data: &[WorktreeCleanup]) -> Result<(), WorktreeError> {\n        for cleanup_data in data {\n            tracing::debug!(\"Cleaning up worktree: {:?}\", cleanup_data.worktree_path);\n\n            if let Err(e) = Self::cleanup_worktree(cleanup_data).await {\n                tracing::error!(\"Failed to cleanup worktree: {}\", e);\n            }\n        }\n        Ok(())\n    }\n\n    /// Clean up a worktree path and its git metadata (non-blocking)\n    /// If git_repo_path is None, attempts to infer it from the worktree itself\n    pub async fn cleanup_worktree(worktree: &WorktreeCleanup) -> Result<(), WorktreeError> {\n        let path_str = worktree.worktree_path.to_string_lossy().to_string();\n\n        // Get the same lock to ensure we don't interfere with creation\n        let lock = {\n            let mut locks = WORKTREE_CREATION_LOCKS.lock().unwrap();\n            locks\n                .entry(path_str.clone())\n                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))\n                .clone()\n        };\n\n        let _guard = lock.lock().await;\n\n        // Try to determine the git repo path if not provided\n        let resolved_repo_path = if let Some(repo_path) = &worktree.git_repo_path {\n            Some(repo_path.to_path_buf())\n        } else {\n            Self::infer_git_repo_path(&worktree.worktree_path).await\n        };\n\n        if let Some(repo_path) = resolved_repo_path {\n            Self::comprehensive_worktree_cleanup_async(&repo_path, &worktree.worktree_path).await?;\n        } else {\n            // Can't determine repo path, just clean up the worktree directory\n            debug!(\n                \"Cannot determine git repo path for worktree {}, performing simple cleanup\",\n                path_str\n            );\n            Self::simple_worktree_cleanup(&worktree.worktree_path).await?;\n        }\n\n        Ok(())\n    }\n\n    /// Try to infer the git repository path from a worktree\n    async fn infer_git_repo_path(worktree_path: &Path) -> Option<PathBuf> {\n        // Try using git rev-parse --git-common-dir from within the worktree\n        let worktree_path_owned = worktree_path.to_path_buf();\n\n        let git_path = resolve_executable_path(\"git\").await?;\n\n        use utils::command_ext::NoWindowExt;\n        let output = tokio::process::Command::new(git_path)\n            .args([\"rev-parse\", \"--git-common-dir\"])\n            .current_dir(&worktree_path_owned)\n            .no_window()\n            .output()\n            .await\n            .ok()?;\n\n        if output.status.success() {\n            let git_common_dir = String::from_utf8(output.stdout).ok()?.trim().to_string();\n\n            // git-common-dir gives us the path to the .git directory\n            // We need the working directory (parent of .git)\n            let git_dir_path = Path::new(&git_common_dir);\n            if git_dir_path.file_name() == Some(std::ffi::OsStr::new(\".git\")) {\n                git_dir_path.parent()?.to_str().map(PathBuf::from)\n            } else {\n                // In case of bare repo or unusual setup, use the git-common-dir as is\n                Some(PathBuf::from(git_common_dir))\n            }\n        } else {\n            None\n        }\n    }\n\n    /// Simple worktree cleanup when we can't determine the main repo\n    async fn simple_worktree_cleanup(worktree_path: &Path) -> Result<(), WorktreeError> {\n        let worktree_path_owned = worktree_path.to_path_buf();\n\n        tokio::task::spawn_blocking(move || -> Result<(), WorktreeError> {\n            if worktree_path_owned.exists() {\n                std::fs::remove_dir_all(&worktree_path_owned).map_err(WorktreeError::Io)?;\n                info!(\n                    \"Removed worktree directory: {}\",\n                    worktree_path_owned.display()\n                );\n            }\n            Ok(())\n        })\n        .await\n        .map_err(|e| WorktreeError::TaskJoin(format!(\"{e}\")))?\n    }\n\n    /// Move a worktree to a new location\n    pub async fn move_worktree(\n        repo_path: &Path,\n        old_path: &Path,\n        new_path: &Path,\n    ) -> Result<(), WorktreeError> {\n        let repo_path = repo_path.to_path_buf();\n        let old_path = old_path.to_path_buf();\n        let new_path = new_path.to_path_buf();\n\n        tokio::task::spawn_blocking(move || {\n            let git_service = GitService::new();\n            git_service\n                .move_worktree(&repo_path, &old_path, &new_path)\n                .map_err(WorktreeError::GitService)\n        })\n        .await\n        .map_err(|e| WorktreeError::TaskJoin(format!(\"{e}\")))?\n    }\n\n    /// Get the base directory for vibe-kanban worktrees\n    pub fn get_worktree_base_dir() -> std::path::PathBuf {\n        if let Some(override_path) = WORKSPACE_DIR_OVERRIDE.get() {\n            // Always use app-owned subdirectory within custom path for safety.\n            // This ensures orphan cleanup never touches user's existing folders.\n            return override_path.join(\".vibe-kanban-workspaces\");\n        }\n        Self::get_default_worktree_base_dir()\n    }\n\n    /// Get the default base directory (ignoring any override)\n    pub fn get_default_worktree_base_dir() -> std::path::PathBuf {\n        utils::path::get_vibe_kanban_temp_dir().join(\"worktrees\")\n    }\n\n    pub async fn cleanup_suspected_worktree(path: &Path) -> Result<bool, WorktreeError> {\n        let git_marker = path.join(\".git\");\n        if !git_marker.exists() || !git_marker.is_file() {\n            return Ok(false);\n        }\n\n        debug!(\"Cleaning up suspected worktree at {}\", path.display());\n        let cleanup = WorktreeCleanup::new(path.to_path_buf(), None);\n        Self::cleanup_worktree(&cleanup).await?;\n        Ok(true)\n    }\n}\n\n#[tokio::test]\nasync fn create_worktree_when_repo_path_is_a_worktree() {\n    use tempfile::TempDir;\n    let td = TempDir::new().unwrap();\n\n    let repo_path = td.path().join(\"repo\");\n    let git_service = GitService::new();\n    git_service\n        .initialize_repo_with_main_branch(&repo_path)\n        .unwrap();\n\n    let base_worktree_path = td.path().join(\"wt-base\");\n    WorktreeManager::create_worktree(\n        &repo_path,\n        \"wt-base-branch\",\n        &base_worktree_path,\n        \"main\",\n        true,\n    )\n    .await\n    .unwrap();\n    assert!(base_worktree_path.join(\".git\").is_file());\n\n    let child_worktree_path = td.path().join(\"wt-child\");\n    WorktreeManager::create_worktree(\n        &base_worktree_path,\n        \"wt-child-branch\",\n        &child_worktree_path,\n        \"main\",\n        true,\n    )\n    .await\n    .unwrap();\n    assert!(child_worktree_path.join(\".git\").is_file());\n\n    // Regression: repo_path itself is a worktree (so `.git` is a file), but metadata lookup still works.\n    WorktreeManager::ensure_worktree_exists(\n        &base_worktree_path,\n        \"wt-child-branch\",\n        &child_worktree_path,\n    )\n    .await\n    .unwrap();\n}\n"
  },
  {
    "path": "dev_assets_seed/config.json",
    "content": "{\n  \"theme\": \"light\",\n  \"executor\": {\n    \"type\": \"claude\"\n  },\n  \"disclaimer_acknowledged\": true,\n  \"onboarding_acknowledged\": true,\n  \"sound_alerts\": true,\n  \"sound_file\": \"abstract-sound4\",\n  \"push_notifications\": true,\n  \"editor\": {\n    \"editor_type\": \"VS_CODE\",\n    \"custom_command\": null\n  },\n  \"github\": {\n    \"token\": \"\",\n    \"default_pr_base\": \"main\"\n  }\n}"
  },
  {
    "path": "docs/.mintignore",
    "content": "AGENTS.md\nCLAUDE.md\n"
  },
  {
    "path": "docs/AGENTS.md",
    "content": "# Mintlify technical writing rule\n\nYou are an AI writing assistant specialised in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices.\n\n## Working relationship\n- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so\n- ALWAYS ask for clarification rather than making assumptions\n- NEVER lie, guess, or make up information\n\n## Project context\n- Format: MDX files with YAML frontmatter\n- Config: docs.json for navigation, theme, settings\n- Components: Mintlify components\n\n## Core writing principles\n\n### Language and style requirements\n\n- Use clear, direct language appropriate for technical audiences\n- Write in second person (\"you\") for instructions and procedures\n- Use active voice over passive voice\n- Employ present tense for current states, future tense for outcomes\n- Avoid jargon unless necessary and define terms when first used\n- Maintain consistent terminology throughout all documentation\n- Keep sentences concise whilst providing necessary context\n- Use parallel structure in lists, headings, and procedures\n- Use British English spelling and grammar\n\n### Content organisation standards\n\n- Lead with the most important information (inverted pyramid structure)\n- Use progressive disclosure: basic concepts before advanced ones\n- Break complex procedures into numbered steps\n- Include prerequisites and context before instructions\n- Provide expected outcomes for each major step\n- Use descriptive, keyword-rich headings for navigation and SEO\n- Group related information logically with clear section breaks\n- Make content evergreen when possible\n- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason\n- Check existing patterns for consistency\n\n### User-centred approach\n\n- Focus on user goals and outcomes rather than system features\n- Anticipate common questions and address them proactively\n- Include troubleshooting for likely failure points\n- Write for scannability with clear headings, lists, and white space\n- Include verification steps to confirm success\n\n### Frontmatter requirements for pages\n- title: Clear, descriptive page title\n- description: Concise summary for SEO/navigation\n\n### Do not\n- Skip frontmatter on any MDX file\n- Use absolute URLs for internal links\n- Include untested code examples\n- Make assumptions - always ask for clarification\n\n## Mintlify component reference\n\n### docs.json\n\n- Refer to the [docs.json schema](https://mintlify.com/docs.json) when building the docs.json file and site navigation\n\n### Callout components\n\n#### Note - Additional helpful information\n\n<Note>\nSupplementary information that supports the main content without interrupting flow\n</Note>\n\n#### Tip - Best practices and pro tips\n\n<Tip>\nExpert advice, shortcuts, or best practices that enhance user success\n</Tip>\n\n#### Warning - Important cautions\n\n<Warning>\nCritical information about potential issues, breaking changes, or destructive actions\n</Warning>\n\n#### Info - Neutral contextual information\n\n<Info>\nBackground information, context, or neutral announcements\n</Info>\n\n#### Check - Success confirmations\n\n<Check>\nPositive confirmations, successful completions, or achievement indicators\n</Check>\n\n### Code components\n\n#### Single code block\n\nExample of a single code block:\n\n```javascript config.js\nconst apiConfig = {\n  baseURL: 'https://api.example.com',\n  timeout: 5000,\n  headers: {\n    'Authorisation': `Bearer ${process.env.API_TOKEN}`\n  }\n};\n```\n\n#### Code group with multiple languages\n\nExample of a code group:\n\n<CodeGroup>\n```javascript Node.js\nconst response = await fetch('/api/endpoint', {\n  headers: { Authorisation: `Bearer ${apiKey}` }\n});\n```\n\n```python Python\nimport requests\nresponse = requests.get('/api/endpoint',\n  headers={'Authorisation': f'Bearer {api_key}'})\n```\n\n```curl cURL\ncurl -X GET '/api/endpoint' \\\n  -H 'Authorisation: Bearer YOUR_API_KEY'\n```\n</CodeGroup>\n\n#### Request/response examples\n\nExample of request/response documentation:\n\n<RequestExample>\n```bash cURL\ncurl -X POST 'https://api.example.com/users' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\": \"John Doe\", \"email\": \"john@example.com\"}'\n```\n</RequestExample>\n\n<ResponseExample>\n```json Success\n{\n  \"id\": \"user_123\",\n  \"name\": \"John Doe\",\n  \"email\": \"john@example.com\",\n  \"created_at\": \"2024-01-15T10:30:00Z\"\n}\n```\n</ResponseExample>\n\n### Structural components\n\n#### Steps for procedures\n\nExample of step-by-step instructions:\n\n<Steps>\n<Step title=\"Install dependencies\">\n  Run `npm install` to install required packages.\n\n  <Check>\n  Verify installation by running `npm list`.\n  </Check>\n</Step>\n\n<Step title=\"Configure environment\">\n  Create a `.env` file with your API credentials.\n\n  ```bash\n  API_KEY=your_api_key_here\n  ```\n\n  <Warning>\n  Never commit API keys to version control.\n  </Warning>\n</Step>\n</Steps>\n\n#### Tabs for alternative content\n\nExample of tabbed content:\n\n<Tabs>\n<Tab title=\"macOS\">\n  ```bash\n  brew install node\n  npm install -g package-name\n  ```\n</Tab>\n\n<Tab title=\"Windows\">\n  ```powershell\n  choco install nodejs\n  npm install -g package-name\n  ```\n</Tab>\n\n<Tab title=\"Linux\">\n  ```bash\n  sudo apt install nodejs npm\n  npm install -g package-name\n  ```\n</Tab>\n</Tabs>\n\n#### Accordions for collapsible content\n\nExample of accordion groups:\n\n<AccordionGroup>\n<Accordion title=\"Troubleshooting connection issues\">\n  - **Firewall blocking**: Ensure ports 80 and 443 are open\n  - **Proxy configuration**: Set HTTP_PROXY environment variable\n  - **DNS resolution**: Try using 8.8.8.8 as DNS server\n</Accordion>\n\n<Accordion title=\"Advanced configuration\">\n  ```javascript\n  const config = {\n    performance: { cache: true, timeout: 30000 },\n    security: { encryption: 'AES-256' }\n  };\n  ```\n</Accordion>\n</AccordionGroup>\n\n### Cards and columns for emphasising information\n\nExample of cards and card groups:\n\n<Card title=\"Getting started guide\" icon=\"rocket\" href=\"/quickstart\">\nComplete walkthrough from installation to your first API call in under 10 minutes.\n</Card>\n\n<CardGroup cols={2}>\n<Card title=\"Authentication\" icon=\"key\" href=\"/auth\">\n  Learn how to authenticate requests using API keys or JWT tokens.\n</Card>\n\n<Card title=\"Rate limiting\" icon=\"clock\" href=\"/rate-limits\">\n  Understand rate limits and best practices for high-volume usage.\n</Card>\n</CardGroup>\n\n### API documentation components\n\n#### Parameter fields\n\nExample of parameter documentation:\n\n<ParamField path=\"user_id\" type=\"string\" required>\nUnique identifier for the user. Must be a valid UUID v4 format.\n</ParamField>\n\n<ParamField body=\"email\" type=\"string\" required>\nUser's email address. Must be valid and unique within the system.\n</ParamField>\n\n<ParamField query=\"limit\" type=\"integer\" default=\"10\">\nMaximum number of results to return. Range: 1-100.\n</ParamField>\n\n<ParamField header=\"Authorisation\" type=\"string\" required>\nBearer token for API authentication. Format: `Bearer YOUR_API_KEY`\n</ParamField>\n\n#### Response fields\n\nExample of response field documentation:\n\n<ResponseField name=\"user_id\" type=\"string\" required>\nUnique identifier assigned to the newly created user.\n</ResponseField>\n\n<ResponseField name=\"created_at\" type=\"timestamp\">\nISO 8601 formatted timestamp of when the user was created.\n</ResponseField>\n\n<ResponseField name=\"permissions\" type=\"array\">\nList of permission strings assigned to this user.\n</ResponseField>\n\n#### Expandable nested fields\n\nExample of nested field documentation:\n\n<ResponseField name=\"user\" type=\"object\">\nComplete user object with all associated data.\n\n<Expandable title=\"User properties\">\n  <ResponseField name=\"profile\" type=\"object\">\n  User profile information including personal details.\n\n  <Expandable title=\"Profile details\">\n    <ResponseField name=\"first_name\" type=\"string\">\n    User's first name as entered during registration.\n    </ResponseField>\n\n    <ResponseField name=\"avatar_url\" type=\"string | null\">\n    URL to user's profile picture. Returns null if no avatar is set.\n    </ResponseField>\n  </Expandable>\n  </ResponseField>\n</Expandable>\n</ResponseField>\n\n### Media and advanced components\n\n#### Frames for images\n\nWrap all images in frames:\n\n<Frame>\n<img src=\"/images/dashboard.png\" alt=\"Main dashboard showing analytics overview\" />\n</Frame>\n\n<Frame caption=\"The analytics dashboard provides real-time insights\">\n<img src=\"/images/analytics.png\" alt=\"Analytics dashboard with charts\" />\n</Frame>\n\n#### Videos\n\nUse the HTML video element for self-hosted video content:\n\n<video\n  controls\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"link-to-your-video.com\"\n></video>\n\nEmbed YouTube videos using iframe elements:\n\n<iframe\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"https://www.youtube.com/embed/4KzFe50RQkQ\"\n  title=\"YouTube video player\"\n  frameBorder=\"0\"\n  allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n  allowFullScreen\n></iframe>\n\n#### Tooltips\n\nExample of tooltip usage:\n\n<Tooltip tip=\"Application Programming Interface - protocols for building software\">\nAPI\n</Tooltip>\n\n#### Updates\n\nUse updates for changelogs:\n\n<Update label=\"Version 2.1.0\" description=\"Released March 15, 2024\">\n## New features\n- Added bulk user import functionality\n- Improved error messages with actionable suggestions\n\n## Bug fixes\n- Fixed pagination issue with large datasets\n- Resolved authentication timeout problems\n</Update>\n\n## Required page structure\n\nEvery documentation page must begin with YAML frontmatter:\n\n```yaml\n---\ntitle: \"Clear, specific, keyword-rich title\"\ndescription: \"Concise description explaining page purpose and value\"\n---\n```\n\n## Content quality standards\n\n### Code examples requirements\n\n- Always include complete, runnable examples that users can copy and execute\n- Show proper error handling and edge case management\n- Use realistic data instead of placeholder values\n- Include expected outputs and results for verification\n- Test all code examples thoroughly before publishing\n- Specify language and include filename when relevant\n- Add explanatory comments for complex logic\n- Never include real API keys or secrets in code examples\n\n\n### Accessibility requirements\n\n- Include descriptive alt text for all images and diagrams\n- Use specific, actionable link text instead of \"click here\"\n- Ensure proper heading hierarchy starting with H2\n- Provide keyboard navigation considerations\n- Use sufficient colour contrast in examples and visuals\n- Structure content for easy scanning with headers and lists\n\n## Component selection logic\n\n- Use **Steps** for procedures and sequential instructions\n- Use **Tabs** for platform-specific content or alternative approaches\n- Use **CodeGroup** when showing the same concept in multiple programming languages\n- Use **Accordions** for progressive disclosure of information\n- Use **RequestExample/ResponseExample** specifically for API endpoint documentation\n- Use **ParamField** for API parameters, **ResponseField** for API responses\n- Use **Expandable** for nested object properties or hierarchical information\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Mintlify Starter Kit\n\n**[Mintlify Quickstart Guide](https://starter.mintlify.com/quickstart)**\n\n## Development\n\nInstall the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command:\n\n```\nnpm i -g mint\n```\n\nRun the following command at the root of your documentation, where your `docs.json` is located:\n\n```\nmint dev\n```\n\nView your local preview at `http://localhost:3000`.\n\n## Publishing changes\n\nInstall our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch.\n\n## Need help?\n\n### Troubleshooting\n\n- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI.\n- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`.\n\n### Resources\n- [Mintlify documentation](https://mintlify.com/docs)\n- [Mintlify community](https://mintlify.com/community)\n"
  },
  {
    "path": "docs/agents/amp.mdx",
    "content": "---\ntitle: \"Amp\"\ndescription: \"Set up Amp code completion agent\"\nicon: https://www.vibekanban.com/images/logos/amp-logo.svg\n---\n\n<Steps>\n<Step title=\"Run Amp\">\n  ```bash\n  npx -y @sourcegraph/amp\n  ```\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  Complete the authentication flow as prompted. For more details, see the [Amp Owner's Manual](https://ampcode.com/manual#install).\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select Amp when creating task attempts.\n</Step>\n</Steps>\n"
  },
  {
    "path": "docs/agents/ccr.mdx",
    "content": "---\ntitle: \"CCR (Claude Code Router)\"\ndescription: \"Set up CCR to orchestrate multiple Claude Code models\"\nicon: https://www.vibekanban.com/images/logos/claude.svg#\"\n---\n\nCCR (Claude Code Router) lets you route coding prompts across different LLM providers and models, and select specialised models for specific tasks like long context, background work, or image understanding.\n\n<Warning>\nCCR is not affiliated with, endorsed by, or connected to Claude Code or Anthropic. It is a third-party tool.\n</Warning>\n\n## Installation\nCCR is available via `npx` – no separate installation required.\n\n```bash\nnpx -y @musistudio/claude-code-router ui\n```\n\nThis launches the CCR local UI where you configure providers and models.\n\n## Authentication\nAuthenticate and configure CCR outside of Vibe Kanban. Follow the instructions on the CCR GitHub repo:\n\n- GitHub: https://github.com/musistudio/claude-code-router\n\nYou'll add providers, set API keys, and register model names in the CCR UI or via CCR's JSON configuration (see the CCR repo for the schema and file location).\n\n## Configure CCR (Providers and Models)\n\nConfigure CCR either via the UI or JSON config.\n\nIn the CCR UI (`npx -y @musistudio/claude-code-router ui`):\n\n1) Add providers\n- Choose a provider (e.g., `openrouter`, `deepseek`, etc.).\n- Enter the required API key(s) and settings for that provider.\n\n2) Add models\n- For each provider, register the model identifier (e.g., `moonshotai/kimi-k2-0905`, `deepseek-chat`).\n- CCR supports configuring different models for specific cases:\n  - `default`: general coding\n  - `background`: lightweight/background operations\n  - `think`: models that support \"thinking\" modes\n  - `longContext`: very long inputs/files\n  - `webSearch`: models that support web/tool use\n  - `image`: models with vision capabilities\n\nNote: not all models support web search, thinking, or images. Choose models accordingly in the CCR UI.\n\n### Configure via JSON (optional)\nCCR can also be configured via its JSON configuration file. Refer to the CCR GitHub documentation for the exact schema, keys, and file location. Define providers (with API keys) and map the model cases (`default`, `background`, `think`, `longContext`, `webSearch`, `image`) to specific provider/model pairs.\n\n### Example: OpenRouter provider configured in CCR UI\n\n<Frame>\n<img src=\"/images/ccr-openrouter-ui.png\" alt=\"OpenRouter configured in CCR UI\" />\n</Frame>\n\n### Example: CCR model mapping (default/background/think/etc.)\n\n<Frame>\n<img src=\"/images/ccr-config-example.png\" alt=\"CCR models configuration example\" />\n</Frame>\n\n## Configure Vibe Kanban\n\nVibe Kanban does not ship a default configuration for CCR. Add configurations to the existing Claude Code agent:\n\n1) Open the \"Coding Agent Configurations\" page.\n2) Add a new configuration for the Claude Code agent (or edit an existing one).\n3) Enable the `claude_code_router` checkbox.\n4) Optionally set a model string to target a specific CCR provider/model.\n\nSee the [Agent Profiles & Variants](/settings/agent-configurations) guide for managing agent configurations.\n\nModel string format: `<provider>,<model-name>`\n\nExamples:\n```text\nopenrouter,moonshotai/kimi-k2-0905\ndeepseek,deepseek-chat\n```\n\nTips:\n- Create multiple configurations if you want easy switching between different models.\n- Leave the model string empty if you want CCR to use its own routing based on your CCR UI configuration (e.g., its `default`/`longContext`/etc. mappings).\n\n### Example: Claude Code agent configuration in Vibe Kanban\n\n<Frame>\n<img src=\"/images/vk-ccr-agent-config.png\" alt=\"Claude Code agent configuration in Vibe Kanban\" />\n</Frame>\n\n## Using CCR in Vibe Kanban\n\nWhen creating a Task Attempt, select the coding agent and configuration: choose the Claude Code agent, then pick one of your CCR-enabled configurations.\n\n## Troubleshooting\n\n- Authentication errors: verify your API keys/provider settings in CCR (via UI or JSON config).\n- Model not found: confirm the model identifier is correct for the chosen provider.\n- Missing features (webSearch/think/image): switch to a model that supports the capability and update your CCR mapping (via UI or JSON config).\n"
  },
  {
    "path": "docs/agents/claude-code.mdx",
    "content": "---\ntitle: \"Claude Code\"\ndescription: \"Set up Anthropic's Claude Code agent\"\nicon: https://www.vibekanban.com/images/logos/claude.svg#\"\n---\n\n<Steps>\n<Step title=\"Run Claude Code\">\n  ```bash\n  npx -y @anthropic-ai/claude-code\n  ```\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  Complete the authentication flow as prompted. For more details, see the [Claude Code documentation](https://docs.claude.com/en/docs/claude-code/quickstart).\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select Claude Code when creating task attempts.\n</Step>\n</Steps>\n"
  },
  {
    "path": "docs/agents/cursor-cli.mdx",
    "content": "---\ntitle: \"Cursor Agent CLI\"\ndescription: \"Set up Cursor's command-line agent\"\nicon: https://www.vibekanban.com/images/logos/cursor-logo-light.png\n---\n\n<Steps>\n<Step title=\"Install Cursor Agent CLI\">\n  ```bash\n  curl https://cursor.com/install -fsS | bash\n  ```\n\n  Verify installation with `cursor-agent --version`. For more details, see the [official installation guide](https://docs.cursor.com/en/cli/installation).\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  Sign in with `cursor-agent login` (opens a browser). You can also set the `CURSOR_API_KEY` environment variable. Full instructions: https://docs.cursor.com/en/cli/reference/authentication\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select Cursor Agent CLI when creating task attempts.\n</Step>\n</Steps>\n"
  },
  {
    "path": "docs/agents/droid.mdx",
    "content": "---\ntitle: \"Factory Droid\"\ndescription: \"Set up Factory AI's Droid coding agent\"\nicon: https://www.vibekanban.com/images/logos/factory-ai-logo-light.png\n---\n\n<Steps>\n<Step title=\"Install Droid CLI\">\n  <Tabs>\n  <Tab title=\"macOS / Linux\">\n  ```bash\n  curl -fsSL https://app.factory.ai/cli | sh\n  ```\n  </Tab>\n\n  <Tab title=\"Windows\">\n  ```powershell\n  irm https://app.factory.ai/cli/windows | iex\n  ```\n  </Tab>\n  </Tabs>\n\n  For the latest installation instructions and platform-specific guidance, visit the [Factory AI CLI documentation](https://factory.ai/product/cli).\n</Step>\n\n<Step title=\"Authenticate with Factory AI\">\n  To use Droid in Vibe Kanban, you must first authenticate:\n\n  1. Run `droid` to launch the interactive CLI\n  2. Use the `/login` command within Droid to authenticate\n\n  Alternatively, generate an API key from [Factory Settings](https://app.factory.ai/settings/api-keys) and set it as an environment variable:\n\n  ```bash\n  export FACTORY_API_KEY=fk-...\n  ```\n\n  For detailed authentication instructions, see the [Factory AI documentation](https://docs.factory.ai/factory-cli/getting-started/overview).\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select Factory Droid when creating task attempts.\n</Step>\n</Steps>\n\n## Configuration Options\n\nDroid supports several configuration options in Vibe Kanban:\n\n- **Autonomy Level**: Controls permission level for file and system operations\n  - `normal`: Read-only mode – safe for reviewing planned changes without execution\n  - `low`: Basic file creation/editing while blocking system changes\n  - `medium`: Development operations with recoverable side effects\n  - `high`: Production operations with security implications or major side effects\n  - `skip-permissions-unsafe`: Bypasses all permission checks (use only in isolated environments) (default)\n\n- **Model**: Specify which model to use\n\n- **Reasoning Effort**: Control the reasoning depth\n  - `off`: Minimal reasoning\n  - `low`: Light reasoning\n  - `medium`: Balanced reasoning\n  - `high`: Deep reasoning for complex tasks\n\nThese options can be configured when creating agent configurations in Vibe Kanban.\n"
  },
  {
    "path": "docs/agents/gemini-cli.mdx",
    "content": "---\ntitle: \"Gemini CLI\"\ndescription: \"Set up Google Gemini CLI\"\nicon: https://www.vibekanban.com/images/logos/gemini-logo.svg\n---\n\n<Steps>\n<Step title=\"Run Gemini CLI\">\n  ```bash\n  npx -y @google/gemini-cli\n  ```\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  Complete the authentication flow as prompted. For more details, see [geminicli.com](https://geminicli.com/).\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select Gemini CLI when creating task attempts.\n</Step>\n</Steps>\n"
  },
  {
    "path": "docs/agents/github-copilot.mdx",
    "content": "---\ntitle: \"GitHub Copilot\"\ndescription: \"Set up GitHub Copilot CLI\"\nicon: https://www.vibekanban.com/images/logos/github-copilot-logo.svg\n---\n\n<Steps>\n<Step title=\"Run GitHub Copilot\">\n  ```bash\n  npx -y @github/copilot\n  ```\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  When prompted, follow the on-screen instructions to authenticate using the `/login` command. For more details, see the [GitHub Copilot CLI documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-cli).\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select GitHub Copilot when creating task attempts.\n</Step>\n</Steps>\n"
  },
  {
    "path": "docs/agents/openai-codex.mdx",
    "content": "---\ntitle: \"OpenAI Codex\"\ndescription: \"Set up OpenAI Codex integration\"\nicon: https://www.vibekanban.com/images/logos/openai-logo.svg\n---\n\n<Steps>\n<Step title=\"Run OpenAI Codex\">\n  ```bash\n  npx -y @openai/codex\n  ```\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  Complete the authentication flow as prompted. Follow the authentication instructions from the [OpenAI help centre](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan) to use Codex with your ChatGPT plan.\n\n  Alternatively, Codex can be used via the OpenAI API by setting the `OPENAI_API_KEY` environment variable. For more details, see the [OpenAI documentation](https://developers.openai.com/codex/pricing/#use-an-openai-api-key)\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select OpenAI Codex when creating task attempts.\n</Step>\n</Steps>\n\n## Custom Configuration Directory\n\nBy default, Codex stores its configuration and session data in `~/.codex`. If you have configured a custom location using the `CODEX_HOME` environment variable, Vibe Kanban will automatically detect and use that location.\n\n```bash\n# Example: Using a project-specific Codex configuration\nexport CODEX_HOME=/path/to/custom/codex\nnpx vibe-kanban\n```\n\nThis is useful for:\n- Project-specific Codex profiles\n- Separating work and personal configurations\n- Custom automation setups\n"
  },
  {
    "path": "docs/agents/opencode.mdx",
    "content": "---\ntitle: \"OpenCode\"\ndescription: \"Set up SST's OpenCode\"\nicon: https://www.vibekanban.com/images/logos/opencode-light.svg\n---\n\n<Steps>\n<Step title=\"Run OpenCode\">\n  ```bash\n  npx -y opencode-ai\n  ```\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  Complete the authentication flow as prompted. For more details, see the [OpenCode GitHub page](https://github.com/sst/opencode).\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select OpenCode when creating task attempts.\n</Step>\n</Steps>\n"
  },
  {
    "path": "docs/agents/qwen-code.mdx",
    "content": "---\ntitle: \"Qwen Code\"\ndescription: \"Set up Qwen code-focused assistant\"\nicon: https://www.vibekanban.com/images/logos/qwen-logo.png#\"\n---\n\n<Steps>\n<Step title=\"Run Qwen Code\">\n  ```bash\n  npx -y @qwen-code/qwen-code\n  ```\n</Step>\n\n<Step title=\"Follow the login instructions\">\n  Complete the authentication flow as prompted. For more details, see the [official Qwen documentation](https://github.com/QwenLM/qwen-code).\n</Step>\n\n<Step title=\"Start Vibe Kanban\">\n  Once authenticated, launch Vibe Kanban:\n\n  ```bash\n  npx vibe-kanban\n  ```\n\n  You can now select Qwen Code when creating task attempts.\n</Step>\n</Steps>\n"
  },
  {
    "path": "docs/browser-testing.mdx",
    "content": "---\ntitle: \"Browser Testing\"\ndescription: \"Preview your app in the built-in browser, inspect components, and debug with full devtools\"\n---\n\nThe built-in preview browser lets you test your application, inspect components, and open full devtools — all without leaving the workspace.\n\n## 1. Configure a dev server\n\nIf no dev server script is configured, the preview panel shows a setup prompt. Click **Edit Dev Server Script** to open the script dialog.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-no-script.png\" alt=\"Preview panel showing prompt to set up a dev server script\" />\n</Frame>\n\nEnter the command that starts your dev server (e.g. `npm run dev`, `pnpm dev`, `yarn dev`) and click **Save and Test** to verify it works.\n\n<Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-preview-script-dialog.png\" alt=\"Script editor dialog with dev server command and Save and Test button\" />\n</Frame>\n\n<Info>\nYou can also configure dev server scripts from **Settings** → **Repos** → select your repository → **Dev Server Script**. See [Projects & Repositories](/settings/projects-repositories) for details.\n</Info>\n\n## 2. Start the dev server\n\nOnce you've saved a script, click **Start dev server** in the preview panel to launch it.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-dev-server-start.png\" alt=\"Preview panel showing the Start dev server button\" />\n</Frame>\n\nLogs stream in real-time in the **Log** panel (highlighted in red) on the right side of the workspace. Look for the URL output (e.g. `http://localhost:3000`) — Vibe Kanban detects this automatically and loads it in the preview.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-dev-server-running.png\" alt=\"Dev server log output streaming in real-time in the Log panel\" />\n</Frame>\n\n## 3. The preview browser\n\nOnce the dev server starts, your application loads in the built-in browser. Here's an overview of the preview toolbar and what each control does.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-browser-annotated.png\" alt=\"Annotated preview browser showing the toolbar controls numbered 1 through 7\" />\n</Frame>\n\n1. **Back / Forward** — navigate between pages\n2. **Inspect** (crosshair icon) — enter click-to-component mode\n3. **DevTools** (terminal icon) — toggle the built-in devtools panel\n4. **URL bar** — shows the current page address; type a URL to navigate manually\n5. **Page actions** — submit, copy the URL, open in a new tab, and refresh\n6. **Device modes** — switch between Desktop, Mobile, and Responsive views\n7. **Pause** — pause or resume the dev server\n\n## 4. Switch device modes\n\nYou can test your application across different viewport sizes without resizing your browser window. The device mode buttons in the toolbar let you switch instantly.\n\n| Mode | What it does |\n|------|--------------|\n| **Desktop** | Full-width browser view |\n| **Mobile** | Phone frame at 390×844, with device chrome |\n| **Responsive** | Drag the edges to set a custom width and height |\n\n**Mobile mode** wraps your application in a phone frame so you can see exactly how it looks on a mobile device.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-mobile.png\" alt=\"Mobile device mode showing the application inside a phone frame at 390 by 844 pixels\" />\n</Frame>\n\n**Responsive mode** lets you drag the edges of the preview to test any custom width and height.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-responsive.png\" alt=\"Responsive device mode with a custom viewport width\" />\n</Frame>\n\n## 5. Inspect components\n\nThe preview browser includes a built-in click-to-component feature. Click **Select element as context** in the toolbar to enter inspect mode, hover over any element to highlight it, then click to select.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-inspect.png\" alt=\"Inspect mode showing the full flow: activating inspect, hovering over a component, and the selected context appearing in the chat\" />\n</Frame>\n\nWhen you click a component, inspect mode exits and the component's details are sent to the chat as context — you can then ask your coding agent questions about that specific component. This works automatically with React, Vue, Svelte, Astro, and plain HTML — no packages to install.\n\n## 6. Open DevTools\n\nClick the terminal icon in the toolbar to toggle the built-in devtools panel. The devtools open at the bottom of the preview and give you a full debugging console without leaving the workspace.\n\n<Frame>\n  <img src=\"/images/workspaces-preview-devtools.png\" alt=\"Eruda devtools panel open at the bottom of the preview browser showing the Console tab\" />\n</Frame>\n\nThe devtools give you access to:\n\n- **Console** — view logs, warnings, errors, and run JavaScript\n- **Elements** — inspect and modify the DOM tree\n- **Network** — monitor requests and responses\n- **Resources** — view cookies, localStorage, and sessionStorage\n- **Sources** — view the page source code\n- **Info** — check the page URL, user agent, viewport size, and device details\n\n<Info>\nDevTools are powered by [Eruda](https://github.com/liriliri/eruda), a mobile-friendly debugging console. They run inside the preview iframe, so they reflect exactly what your application sees.\n</Info>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"Preview shows a blank page\">\n- Check the log output for errors — the dev server may still be starting\n- Verify the correct URL is detected in the preview address bar\n- Try refreshing the preview with the refresh button\n</Accordion>\n\n<Accordion title=\"Preview doesn't update after changes\">\n- Check the logs for build errors\n- Try a hard refresh (`Cmd/Ctrl + Shift + R`)\n- Restart the dev server with the play/pause button\n- Verify your framework supports hot module replacement (HMR)\n</Accordion>\n\n<Accordion title=\"Port conflict error\">\nAnother process is using the same port. Find and stop it, or change the port in your project config. Run `lsof -i :3000` (replace 3000 with your port) to see what's using it.\n</Accordion>\n\n<Accordion title=\"Inspect mode doesn't detect components\">\n- Make sure the dev server is running a **development** build (production builds strip component metadata)\n- For React, ensure the app has loaded before entering inspect mode\n- The HTML fallback always works — if you see tag names instead of component names, the framework adapter couldn't detect the framework\n</Accordion>\n</AccordionGroup>\n\n## Next steps\n\n<CardGroup cols={2}>\n  <Card title=\"Changes panel\" icon=\"code-compare\" href=\"/workspaces/changes\">\n    Review diffs and provide feedback to your coding agent\n  </Card>\n\n  <Card title=\"Projects & Repositories\" icon=\"gear\" href=\"/settings/projects-repositories\">\n    Configure setup, cleanup, and dev server scripts per repository\n  </Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/cloud/authentication.mdx",
    "content": "---\ntitle: \"Authentication\"\ndescription: \"Sign in with GitHub or Google and manage your sessions\"\n---\n\nVibe Kanban Cloud uses OAuth authentication, allowing you to sign in with your existing GitHub or Google account instead of creating a new password.\n\n## How Authentication Works\n\n<Info>\n**What is OAuth?**\n\nOAuth is a secure way to sign in to applications using accounts you already have. When you click \"Sign in with GitHub\", you're redirected to GitHub to confirm you want to allow Vibe Kanban access. GitHub then tells Vibe Kanban who you are, without Vibe Kanban ever seeing your GitHub password.\n</Info>\n\n### The Sign-In Flow\n\n<Steps>\n<Step title=\"Click Sign In\">\n  On the login page, click **Sign in with GitHub** or **Sign in with Google**.\n\n  <Frame>\n    <img src=\"/images/cloud/sign-in-dialog.png\" alt=\"Sign in dialog with GitHub and Google options\" />\n  </Frame>\n</Step>\n\n<Step title=\"Authorise on Provider\">\n  You'll be redirected to GitHub or Google. If you're not already logged in there, you'll need to log in first.\n\n\n  Click **Authorize** (GitHub) or **Allow** (Google) to grant Vibe Kanban access.\n</Step>\n\n<Step title=\"Redirected Back\">\n  After authorising, you're automatically redirected back to Vibe Kanban and signed in.\n</Step>\n</Steps>\n\n### What Vibe Kanban Can Access\n\nWhen you authorise Vibe Kanban, it only requests minimal permissions:\n\n| Provider | Access Granted |\n|----------|----------------|\n| **GitHub** | Your public profile (name, email, avatar) |\n| **Google** | Your basic profile (name, email, avatar) |\n\n<Info>\nVibe Kanban **cannot**:\n- Access your private repositories (unless you grant additional permissions later)\n- Post on your behalf\n- Change your account settings\n- See your password\n</Info>\n\n## Signing In\n\n### First-Time Sign In\n\nThe first time you sign in:\n\n1. Click a sign-in button (GitHub or Google)\n2. Authorise the application on the provider's website\n3. A personal organisation is automatically created for you\n\n\n### Returning Sign In\n\nFor subsequent sign-ins:\n\n1. Click the same sign-in button you used before\n2. If you're already logged into the provider, you'll be signed in automatically\n3. You'll land on your organisation's dashboard\n\n<Tip>\n**Tip:** If you're already signed into GitHub or Google in your browser, clicking the sign-in button will log you in almost instantly without any prompts.\n</Tip>\n\n## Signing Out\n\nTo sign out:\n\n1. Click your **profile icon** in the bottom of the left sidebar\n2. Click **Sign out**\n\n<Frame>\n  <img src=\"/images/cloud/user-menu.png\" alt=\"User menu showing Sign out option\" />\n</Frame>\n\n\n<Warning>\nSigning out only affects the current browser. If you're signed in on multiple devices, you'll remain signed in on those devices.\n</Warning>\n\n### Signing Out of All Devices\n\nCurrently, there's no way to sign out of all devices at once. If you need to revoke all sessions (e.g., if you suspect unauthorised access):\n\n1. Go to your OAuth provider's settings:\n   - **GitHub:** [github.com/settings/applications](https://github.com/settings/applications)\n   - **Google:** [myaccount.google.com/permissions](https://myaccount.google.com/permissions)\n2. Find \"Vibe Kanban Cloud\" and revoke access\n3. All sessions will be invalidated\n\n## Multiple Accounts\n\n### Using Different Providers\n\nYou can sign in with either GitHub or Google - they're treated as separate accounts. If you sign in with GitHub, then later sign in with Google, you'll have two separate accounts.\n\n<Warning>\n**Account linking is not currently supported.** If you want to use both GitHub and Google, pick one and stick with it to avoid having duplicate accounts.\n</Warning>\n\n### Switching Accounts\n\nTo switch to a different account:\n\n1. Sign out of your current account\n2. Sign in with the different provider or account\n\nIf you need to sign in with a different GitHub/Google account than the one your browser remembers:\n\n1. Sign out of Vibe Kanban\n2. Go to the provider's website (github.com or google.com)\n3. Sign out there\n4. Return to Vibe Kanban and sign in - you'll be prompted to log in to the provider\n\n## Security Best Practices\n\n<CardGroup cols={2}>\n<Card title=\"Use a strong provider password\" icon=\"lock\">\n  Your Vibe Kanban security depends on your GitHub/Google account security. Use a strong, unique password.\n</Card>\n\n<Card title=\"Enable 2FA on your provider\" icon=\"shield-check\">\n  Enable two-factor authentication on GitHub or Google for extra security.\n</Card>\n\n<Card title=\"Sign out on shared computers\" icon=\"right-from-bracket\">\n  Always sign out when using a shared or public computer.\n</Card>\n\n<Card title=\"Review authorised apps periodically\" icon=\"clipboard-check\">\n  Periodically check what apps have access to your GitHub/Google account and revoke any you don't recognise.\n</Card>\n</CardGroup>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"'Invalid redirect URI' error\">\n**Problem:** After clicking sign in, you see an error about invalid redirect URI.\n\n**Cause:** The callback URL in your OAuth app doesn't match.\n\n**Solution:**\n1. Check your OAuth app settings\n2. Ensure the callback URL is exactly:\n   - GitHub: `https://<your-public-base-url>/v1/oauth/github/callback`\n   - Google: `https://<your-public-base-url>/v1/oauth/google/callback`\n3. No trailing slashes, exact capitalisation\n</Accordion>\n\n<Accordion title=\"'Access denied' error\">\n**Problem:** The provider shows \"access denied\" or similar.\n\n**Cause:** You clicked \"Deny\" instead of \"Authorize\", or your organisation has OAuth app restrictions.\n\n**Solution:**\n1. Try again and click \"Authorize\" or \"Allow\"\n2. If you're part of a GitHub organisation with app restrictions, ask your admin to approve Vibe Kanban\n</Accordion>\n\n<Accordion title=\"Signed in as wrong account\">\n**Problem:** You're signed in with the wrong GitHub/Google account.\n\n**Solution:**\n1. Sign out of Vibe Kanban\n2. Go to github.com or google.com and sign out there\n3. Sign in to the correct account on the provider\n4. Return to Vibe Kanban and sign in\n</Accordion>\n\n<Accordion title=\"Session expired unexpectedly\">\n**Problem:** You keep getting signed out.\n\n**Possible causes:**\n- Server was restarted (invalidates all sessions)\n- JWT secret was changed\n- You've been inactive for more than 7 days\n\n**Solution:**\nSimply sign in again. If it keeps happening, check if the server is restarting frequently.\n</Accordion>\n\n<Accordion title=\"Can't sign in after revoking access\">\n**Problem:** You revoked Vibe Kanban's access on GitHub/Google and now can't sign in.\n\n**Solution:**\nJust sign in again - you'll be prompted to re-authorise the application.\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Getting Started](/cloud/getting-started) - Initial setup including OAuth app creation\n- [Team Members](/cloud/team-members) - Managing user access within your organisation\n"
  },
  {
    "path": "docs/cloud/customisation.mdx",
    "content": "---\ntitle: \"Customising Your Board\"\ndescription: \"Configure columns, colours, and display settings\"\n---\n\nEvery team works differently. Customise your kanban board to match your workflow by adding columns, changing colours, renaming statuses, and controlling what's visible.\n\n## Display Settings\n\nAccess display settings to customise your board columns:\n\n1. Click the **Display Settings** button (<Icon icon=\"sliders\" />) in the filter bar\n2. The settings panel opens showing all your columns\n\n<Frame>\n  <img style={{maxHeight: \"400px\"}} src=\"/images/cloud/display-settings.png\" alt=\"Display Settings panel showing visible columns with toggles\" />\n</Frame>\n\n## Managing Columns\n\n### Adding a New Column\n\n<Steps>\n<Step title=\"Open Display Settings\">\n  Click the **Display Settings** button (<Icon icon=\"sliders\" />) in the filter bar.\n</Step>\n\n<Step title=\"Click Add column\">\n  Click the **+ Add column** button at the bottom of the list.\n</Step>\n\n<Step title=\"Enter the status name\">\n  Type a name for your new status (e.g., \"In Review\", \"Blocked\", \"Testing\").\n</Step>\n\n<Step title=\"Save changes\">\n  Click **Save** to apply your changes. The new column appears on your board.\n</Step>\n</Steps>\n\nNew columns are assigned a random colour. You can change it after creation.\n\n### Renaming a Column\n\n<Steps>\n<Step title=\"Open Display Settings\">\n  Click the **Display Settings** button (<Icon icon=\"sliders\" />) in the filter bar.\n</Step>\n\n<Step title=\"Click the column name\">\n  Click on the name of the column you want to rename.\n</Step>\n\n<Step title=\"Type the new name\">\n  Enter the new name and click outside the field or press Enter.\n</Step>\n\n<Step title=\"Save changes\">\n  Click **Save** to apply your changes.\n</Step>\n</Steps>\n\n<Info>\nRenaming a column doesn't affect the issues in it. All issues keep their current status - only the display name changes.\n</Info>\n\n### Changing Column Colours\n\nEach column has a colour that appears in the column header and on issue cards.\n\n<Steps>\n<Step title=\"Open Display Settings\">\n  Click the **Display Settings** button (<Icon icon=\"sliders\" />) in the filter bar.\n</Step>\n\n<Step title=\"Click the colour dot\">\n  Click the coloured circle next to the column name.\n\n</Step>\n\n<Step title=\"Select a colour\">\n  Choose from the preset colours.\n</Step>\n\n<Step title=\"Save changes\">\n  Click **Save** to apply the new colour.\n</Step>\n</Steps>\n\n### Reordering Columns\n\nChange the order columns appear on your board:\n\n<Steps>\n<Step title=\"Open Display Settings\">\n  Click the **Display Settings** button (<Icon icon=\"sliders\" />) in the filter bar.\n</Step>\n\n<Step title=\"Drag the column\">\n  Click and hold the drag handle (<Icon icon=\"grip-vertical\" />) next to a column.\n</Step>\n\n<Step title=\"Move to new position\">\n  Drag the column up or down to its new position.\n</Step>\n\n<Step title=\"Save changes\">\n  Click **Save** to apply the new order.\n</Step>\n</Steps>\n\n<Tip>\nArrange columns to match your workflow from left to right - typically starting with \"To Do\" on the left and ending with \"Done\" on the right.\n</Tip>\n\n### Hiding Columns\n\nHide columns you don't use regularly to keep your board clean:\n\n<Steps>\n<Step title=\"Open Display Settings\">\n  Click the **Display Settings** button (<Icon icon=\"sliders\" />) in the filter bar.\n</Step>\n\n<Step title=\"Toggle visibility\">\n  Click the toggle switch next to the column you want to hide. When the toggle is off, the column is hidden.\n</Step>\n\n<Step title=\"Save changes\">\n  Click **Save** to hide the column.\n</Step>\n</Steps>\n\n**Hidden columns:**\n- Don't appear on the board\n- Still exist - issues in them are preserved\n- Can be viewed using the \"All\" status tab\n- Can be unhidden at any time\n\n<Info>\nHiding a column is useful for \"Done\" statuses. You can hide completed work to focus on active issues, but still access them when needed via the status tabs.\n</Info>\n\n### Deleting Columns\n\nRemove columns you no longer need:\n\n<Steps>\n<Step title=\"Open Display Settings\">\n  Click the **Display Settings** button (<Icon icon=\"sliders\" />) in the filter bar.\n</Step>\n\n<Step title=\"Click the delete button\">\n  Click the **×** button next to the column you want to delete.\n</Step>\n\n<Step title=\"Save changes\">\n  Click **Save** to remove the column.\n</Step>\n</Steps>\n\n<Warning>\n**You cannot delete a column that contains issues.** Move all issues to another column first, then delete the empty column.\n</Warning>\n\n## Managing Tags\n\nTags help categorise and filter issues. Tags are created inline when you add them to issues.\n\n### Creating Tags\n\nTo create a new tag:\n\n1. Open any issue\n2. Click the **Tags** field (or the **+** button next to existing tags)\n3. Type the name of your new tag\n4. If the tag doesn't exist, you'll see a **Create** option\n5. Select a colour for the tag\n6. Click to create and apply the tag\n\n<Frame>\n  <img style={{maxHeight: \"120px\"}} src=\"/images/cloud/tags-field.png\" alt=\"Tags field showing a bug tag with + button to add more tags\" />\n</Frame>\n\n<Info>\nTags are shared across all issues in the project. Once created, a tag can be added to any issue.\n</Info>\n\n### Using Tags\n\n- Click the **Tags** field on any issue to add or remove tags\n- Use the tag filter in the filter bar to find issues with specific tags\n- Tags appear as coloured labels on issue cards\n\n**To remove a tag from an issue**, click the tag - it will show a strikethrough indicating it will be removed.\n\n<Frame>\n  <img style={{maxHeight: \"120px\"}} src=\"/images/cloud/tag-removal.png\" alt=\"Tag with strikethrough indicating removal\" />\n</Frame>\n\n## Best Practices\n\n<CardGroup cols={2}>\n<Card title=\"Keep it simple\" icon=\"circle-check\">\n  Start with fewer columns. You can always add more later. Too many columns creates confusion about where issues should go.\n</Card>\n\n<Card title=\"Use clear names\" icon=\"tag\">\n  Column names should be obvious to everyone on the team. \"In Progress\" is clearer than \"WIP\" or \"Active\".\n</Card>\n\n<Card title=\"Hide, don't delete\" icon=\"eye-slash\">\n  If you're not sure about a column, hide it instead of deleting. You can unhide it if you need it later.\n</Card>\n\n<Card title=\"Consistent colours\" icon=\"palette\">\n  Use consistent colour meanings across projects. For example, always use red for blocked/urgent statuses.\n</Card>\n</CardGroup>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"Can't delete a column\">\nThe column probably has issues in it. You must move all issues to another column before deleting. Check:\n1. Open Display Settings - it shows the issue count next to each status\n2. Use the \"All\" status tab to see all issues including hidden ones\n</Accordion>\n\n<Accordion title=\"Changes didn't save\">\nMake sure you clicked **Save** after making changes in Display Settings. Changes aren't applied until you save.\n</Accordion>\n\n<Accordion title=\"Column order is wrong\">\nOpen Display Settings and drag columns to reorder them. Remember to click **Save** after reordering.\n</Accordion>\n\n<Accordion title=\"Can't find a column\">\nIt might be hidden. Open Display Settings and check the visibility toggle for each column. Or use the \"All\" status tab to see issues in hidden columns.\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Kanban Board](/cloud/kanban-board) - Using the board interface\n- [Issues](/cloud/issues) - Creating and managing issues\n- [Filtering & Sorting](/cloud/filtering) - Finding issues with tags and filters\n"
  },
  {
    "path": "docs/cloud/filtering.mdx",
    "content": "---\ntitle: \"Filtering & Sorting\"\ndescription: \"Find issues quickly with search, filters, and sorting\"\n---\n\nAs your project grows, you'll need ways to find specific issues quickly. The filter bar provides powerful search, filtering, and sorting capabilities to help you focus on what matters.\n\n\n## The Filter Bar\n\nThe filter bar sits above your kanban board and contains:\n\n- **Search field** - Find issues by text\n- **Filter dropdowns** - Filter by priority, assignee, tags\n- **Sort dropdown** - Change how issues are ordered\n- **Clear All button** - Reset all filters\n\n## Searching\n\nType in the search field to find issues by title.\n\n\n### How Search Works\n\n- Search matches against issue **titles only**\n- Search is **case-insensitive** (\"Login\" matches \"login\")\n- Search finds **partial matches** (\"log\" matches \"Login flow\")\n- Results update as you type\n\n### Tips for Effective Search\n\n- Search for keywords in titles\n- Use the Simple ID to find a specific issue (e.g., \"TASK-123\")\n- Combine search with filters for precise results\n\n<Tip>\nIf you can't find an issue, check that your filters aren't hiding it. Click **Clear All** to reset filters and search again.\n</Tip>\n\n## Filtering\n\nFilters let you show only issues that match certain criteria.\n\n### Priority Filter\n\nFilter issues by priority level:\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/filter-priority.png\" alt=\"Priority filter dropdown showing Urgent, High, Medium, and Low options\" />\n</Frame>\n\n| Priority | Colour | Description |\n|----------|--------|-------------|\n| **Urgent** | Red | Needs immediate attention |\n| **High** | Orange | Important, do soon |\n| **Medium** | Yellow | Normal priority |\n| **Low** | Grey | Can wait |\n\n**To filter by priority:**\n1. Click the **Priority** dropdown\n2. Select one or more priority levels\n3. Only matching issues are shown\n\n### Assignee Filter\n\nFilter issues by who's assigned:\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/filter-assignee.png\" alt=\"Assignee filter dropdown showing team members\" />\n</Frame>\n\n**Options:**\n- **Specific team members** - Show issues assigned to selected people\n- **Unassigned** - Show issues with no assignee\n\n**To filter by assignee:**\n1. Click the **Assignee** dropdown\n2. Select one or more team members, or \"Unassigned\"\n3. Only matching issues are shown\n\n<Info>\nIf you select multiple assignees, issues assigned to **any** of them will show (OR logic). An issue assigned to both Alice and Bob shows when filtering for either.\n</Info>\n\n### Tags Filter\n\nFilter issues by tags:\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/filter-tags.png\" alt=\"Tags filter dropdown showing available tags\" />\n</Frame>\n\n**To filter by tags:**\n1. Click the **Tags** dropdown\n2. Select one or more tags\n3. Only issues with at least one selected tag are shown\n\n### Combining Filters\n\nYou can use multiple filters at once. When you do:\n\n- Filters within the same category use **OR** logic\n  - Priority: Urgent OR High → shows both\n- Filters across categories use **AND** logic\n  - Priority: Urgent AND Assignee: Alice → shows urgent issues assigned to Alice\n\n**Example:** To find high-priority bugs assigned to you:\n1. Set Priority to \"High\" and \"Urgent\"\n2. Set Assignee to yourself\n3. Set Tags to \"Bug\"\n\n## Sorting\n\nControl the order of issues within each column.\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/sort-dropdown.png\" alt=\"Sort dropdown showing Manual, Priority, Created, Updated, and Title options\" />\n</Frame>\n\n### Sort Options\n\n| Sort By | Description |\n|---------|-------------|\n| **Manual** | Your custom order (drag to reorder) |\n| **Priority** | Urgent first, then High, Medium, Low |\n| **Created** | Newest or oldest first |\n| **Updated** | Recently changed first |\n| **Title** | Alphabetical order |\n\n### Sort Direction\n\nClick the sort direction button to toggle between:\n- **Ascending** (↑) - A→Z, oldest first, low→urgent\n- **Descending** (↓) - Z→A, newest first, urgent→low\n\n### Manual Sort Mode\n\nWhen sort is set to **Manual**:\n- You can drag issues to reorder them within columns\n- Your custom order is saved and shared with the team\n- This is the default mode for most workflows\n\n<Warning>\nIf you switch away from Manual sort, you **cannot** drag to reorder issues. The order is determined by the selected sort field.\n</Warning>\n\n## Clearing Filters\n\n### Clear All\n\nClick the **Clear All** button to reset all filters and search at once. This shows all issues in the project.\n\n\n### Clear Individual Filters\n\nTo clear a single filter:\n1. Click the filter dropdown\n2. Deselect all options\n\nTo clear the search field, click the **×** button inside the search box.\n\n## Filter Persistence\n\nYour filter and sort settings are saved for your session:\n- Filters persist when you navigate away and return\n- Filters reset when you switch projects\n- Filters reset when you sign out\n\n<Info>\nFilters are personal - they don't affect what other team members see. Everyone can have their own filter settings.\n</Info>\n\n## Common Filter Scenarios\n\n<AccordionGroup>\n<Accordion title=\"Show only my assigned issues\">\n1. Click the **Assignee** dropdown\n2. Select your name\n3. Only your issues are shown across all columns\n</Accordion>\n\n<Accordion title=\"Find urgent issues that need attention\">\n1. Click the **Priority** dropdown\n2. Select \"Urgent\"\n3. Optionally, also select \"High\" to include high-priority items\n</Accordion>\n\n<Accordion title=\"Review all bugs\">\n1. Click the **Tags** dropdown\n2. Select the \"Bug\" tag (or your equivalent)\n3. All bug issues are shown\n</Accordion>\n\n<Accordion title=\"Find unassigned work to pick up\">\n1. Click the **Assignee** dropdown\n2. Select \"Unassigned\"\n3. Browse available issues and assign yourself to one\n</Accordion>\n\n<Accordion title=\"See recently updated issues\">\n1. Click the **Sort** dropdown\n2. Select \"Updated\"\n3. Set direction to descending (newest first)\n4. Recently changed issues appear at the top of each column\n</Accordion>\n</AccordionGroup>\n\n## Tips for Efficient Filtering\n\n<CardGroup cols={2}>\n<Card title=\"Use search for known issues\" icon=\"magnifying-glass\">\n  If you know the issue ID or a word in the title, search is the fastest way to find it.\n</Card>\n\n<Card title=\"Filter by assignee in standups\" icon=\"users\">\n  During team standups, each person can filter to their assignments to give quick updates.\n</Card>\n\n<Card title=\"Combine filters for precision\" icon=\"filter\">\n  Use multiple filters together to narrow down exactly what you're looking for.\n</Card>\n\n<Card title=\"Reset filters when stuck\" icon=\"rotate-left\">\n  If you can't find something, click Clear All. You might have forgotten about an active filter.\n</Card>\n</CardGroup>\n\n## Related Documentation\n\n- [Kanban Board](/cloud/kanban-board) - Using the board interface\n- [Issues](/cloud/issues) - Understanding issue properties\n- [Customising Your Board](/cloud/customisation) - Setting up tags and statuses\n"
  },
  {
    "path": "docs/cloud/getting-started.mdx",
    "content": "---\ntitle: \"Getting Started with Cloud\"\ndescription: \"Sign in and start collaborating with your team\"\nsidebarTitle: \"Getting Started\"\n---\n\nVibe Kanban Cloud lets you collaborate with your team in real-time. Your data syncs to the cloud, so everyone sees the same projects, issues, and updates.\n\n<Info>\n**Already using Vibe Kanban locally?** Cloud uses the same interface you're familiar with. The difference is your data syncs to the cloud and you can invite team members.\n</Info>\n\n## Step 1: Launch Vibe Kanban\n\nIf you haven't already, start Vibe Kanban:\n\n```bash\nnpx vibe-kanban\n```\n\nThe application opens in your browser at `http://localhost:...`.\n\n## Step 2: Sign In\n\nTo access Cloud features, you need to sign in.\n\n<Steps>\n<Step title=\"Click Login\">\n  Click the **Login** button in the sidebar or when prompted.\n</Step>\n\n<Step title=\"Choose a sign-in method\">\n  Select how you want to sign in:\n  - **Sign in with GitHub** - Use your GitHub account\n  - **Sign in with Google** - Use your Google account\n\n  <Frame>\n    <img src=\"/images/cloud/sign-in-dialog.png\" alt=\"Sign in dialog with GitHub and Google options\" />\n  </Frame>\n</Step>\n\n<Step title=\"Authorise the application\">\n  You'll be redirected to GitHub or Google. Sign in if needed, then click **Authorize** or **Allow** to grant Vibe Kanban access.\n\n  <Info>\n  Vibe Kanban only requests access to your basic profile (name, email, avatar). It cannot access your private repositories or post on your behalf.\n  </Info>\n</Step>\n\n<Step title=\"Return to Vibe Kanban\">\n  After authorising, you're automatically redirected back and signed in.\n</Step>\n</Steps>\n\n## Step 3: Your Personal Organisation\n\nWhen you sign in for the first time, a **personal organisation** is automatically created for you, along with an **Initial Project** to get you started. This is your private workspace.\n\n<Info>\nPersonal organisations are just for you - you cannot invite other members. To collaborate with a team, create a new organisation (see Step 5).\n</Info>\n\n## Step 4: Create Your First Project\n\nNow create a project to organise your work.\n\n<Steps>\n<Step title=\"Click New Project\">\n  Click the **+ New Project** button in the sidebar.\n</Step>\n\n<Step title=\"Enter project details\">\n  - **Name** - What you're working on (e.g., \"Mobile App\", \"Website Redesign\")\n  - **Colour** - Pick a colour to identify the project\n\n  <Frame>\n    <img style={{maxHeight: \"300px\"}} src=\"/images/cloud/create-project-dialog.png\" alt=\"Create Project dialog with name and colour fields\" />\n  </Frame>\n</Step>\n\n<Step title=\"Click Create\">\n  Your project is created with a kanban board ready to use.\n\n  <Frame>\n    <img src=\"/images/cloud/project-kanban-board.png\" alt=\"Project kanban board with default columns\" />\n  </Frame>\n</Step>\n</Steps>\n\n## Step 5: Create a Team Organisation (Optional)\n\nTo collaborate with others, create a new organisation and invite team members.\n\n<Steps>\n<Step title=\"Open the user menu\">\n  Click your **profile icon** in the bottom of the left sidebar.\n</Step>\n\n<Step title=\"Create a new organisation\">\n  Click **+ Create organization** and enter your team or company name.\n\n  <Frame>\n    <img style={{maxHeight: \"300px\"}} src=\"/images/cloud/create-organization-dialog.png\" alt=\"Create Organization dialog\" />\n  </Frame>\n</Step>\n\n<Step title=\"Invite team members\">\n  Open Organisation Settings (click the gear icon next to your org name), then:\n  - Click **Invite Member**\n  - Enter your teammate's email address\n  - Select their role (**Member** or **Admin**)\n  - Click **Send Invitation**\n\n  <Frame>\n    <img style={{maxHeight: \"300px\"}} src=\"/images/cloud/invite-member-dialog.png\" alt=\"Invite Member dialog\" />\n  </Frame>\n</Step>\n</Steps>\n\nThey'll receive an email with a link to join your organisation.\n\n## You're Ready!\n\nYou now have:\n- ✅ A Cloud account\n- ✅ An organisation for your team\n- ✅ A project with a kanban board\n\n**Next steps:**\n- [Create issues](/cloud/issues) to track your work\n- [Learn the kanban board](/cloud/kanban-board) to manage tasks\n- [Customise your board](/cloud/customisation) with columns and tags\n- [Invite more team members](/cloud/team-members) to collaborate\n\n## Signing Out\n\nTo sign out:\n1. Click your **profile icon** in the bottom of the left sidebar\n2. Click **Sign out**\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/user-menu.png\" alt=\"User menu showing Sign out option\" />\n</Frame>\n\nYour data remains safe in the cloud. Sign in again anytime to continue where you left off.\n\n## Switching Between Local and Cloud\n\nYou can use both local projects and Cloud projects:\n\n| Local Projects | Cloud Projects |\n|----------------|----------------|\n| Data stored on your machine | Data synced to cloud |\n| Only you can access | Team members can collaborate |\n| No sign-in required | Requires sign-in |\n| Works offline | Requires internet connection |\n\nBoth appear in the same Vibe Kanban interface. Local projects show in one section, Cloud projects in another.\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"I can't sign in\">\n**Possible causes:**\n- Pop-up blocker preventing the OAuth window\n- Network connectivity issues\n\n**Solutions:**\n1. Disable pop-up blocker for localhost\n2. Check your internet connection\n3. Try a different browser\n</Accordion>\n\n<Accordion title=\"I don't see Cloud features\">\n**Possible causes:**\n- You're not signed in\n\n**Solutions:**\n1. Click **Login** in the sidebar\n2. Complete the sign-in process\n</Accordion>\n\n<Accordion title=\"My teammate can't see our projects\">\n**Possible causes:**\n- They haven't accepted the invitation\n- They signed in with a different email\n\n**Solutions:**\n1. Check if the invitation is still pending in your Members settings\n2. Resend the invitation\n3. Verify they're using the email address you invited\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Authentication](/cloud/authentication) - More about signing in and sessions\n- [Organisations](/cloud/organizations) - Managing your organisation\n- [Team Members](/cloud/team-members) - Inviting and managing collaborators\n"
  },
  {
    "path": "docs/cloud/index.mdx",
    "content": "---\ntitle: \"Vibe Kanban Cloud\"\ndescription: \"Collaborate with your team in real-time with Cloud features\"\nsidebarTitle: \"Overview\"\n---\n\n## What is Vibe Kanban Cloud?\n\n**Vibe Kanban Cloud** adds team collaboration to Vibe Kanban. Your projects, issues, and progress sync to the cloud so your entire team stays in sync.\n\n### Key Benefits\n\n<CardGroup cols={2}>\n<Card title=\"Team Collaboration\" icon=\"users\">\n  Invite team members and work together on the same projects. Everyone sees updates in real-time.\n</Card>\n\n<Card title=\"Work From Anywhere\" icon=\"globe\">\n  Your data syncs to the cloud. Access your projects from any computer by signing in.\n</Card>\n\n<Card title=\"Real-Time Sync\" icon=\"bolt\">\n  Changes appear instantly for all team members. No need to refresh or manually sync.\n</Card>\n\n<Card title=\"Same Interface\" icon=\"desktop\">\n  Cloud uses the same Vibe Kanban interface you already know. No new tools to learn.\n</Card>\n</CardGroup>\n\n### Cloud vs Local\n\n| Feature | Local | Cloud |\n|---------|-------|-------|\n| **Data storage** | On your computer | Synced to cloud |\n| **Team access** | Only you | Invite team members |\n| **Sign-in required** | No | Yes (GitHub or Google) |\n| **Real-time collaboration** | No | Yes |\n| **Works offline** | Yes | Requires internet |\n\n<Info>\nYou can use both Local and Cloud projects in the same Vibe Kanban installation. They appear in separate sections of the sidebar.\n</Info>\n\n## How It Works\n\n1. **Run Vibe Kanban** as usual with `npx vibe-kanban`\n2. **Sign in** with your GitHub or Google account (a personal organisation is created automatically)\n3. **Create projects** in your personal workspace\n4. **Create a team organisation** if you want to collaborate (optional)\n5. **Invite team members** via email and collaborate in real-time\n\nYour local projects remain untouched. Cloud projects are stored separately and sync automatically.\n\n## Key Concepts\n\n### Organisations\n\nAn **organisation** is your team's shared space. It contains:\n- Team members with different roles (Admin, Member)\n- Projects that everyone can access\n- Shared settings\n\nThink of it as your company or team in Vibe Kanban.\n\n### Projects\n\nA **project** is a kanban board for a specific initiative. Each project has:\n- Customisable status columns\n- Issues to track work\n- Tags for categorisation\n\n### Issues\n\nAn **issue** is a single piece of work. Unlike local tasks, Cloud issues have:\n- Simple IDs (like `TASK-123`) for easy reference\n- Multiple assignees\n- Priority levels\n- Comments for discussion\n- Sub-issues for breaking down work\n\n## Features\n\n### Team Collaboration\n- Invite members via email\n- Role-based permissions (Admin and Member)\n- See who's assigned to what\n\n### Real-Time Sync\n- Changes appear instantly for everyone\n- No conflicts or manual merging\n- Always up to date\n\n### Advanced Kanban\n- Drag-and-drop issues between columns\n- Custom status columns with colours\n- Filter by priority, assignee, or tags\n- Multiple sort options\n\n### GitHub Integration\n- Link pull requests to issues\n- Review code directly in Vibe Kanban\n- Track PR status\n\n## Getting Started\n\n<Steps>\n<Step title=\"Run Vibe Kanban\">\n  ```bash\n  npx vibe-kanban\n  ```\n</Step>\n\n<Step title=\"Sign in\">\n  Click **Login** and sign in with GitHub or Google. A personal organisation is created automatically.\n</Step>\n\n<Step title=\"Create a project\">\n  Click **+ New Project** and start adding issues.\n</Step>\n\n<Step title=\"Create team organisation (optional)\">\n  To collaborate, create a new organisation from the user menu and invite members by email.\n</Step>\n</Steps>\n\n<Card title=\"Full Getting Started Guide\" icon=\"rocket\" href=\"/cloud/getting-started\">\n  Step-by-step instructions for setting up Cloud\n</Card>\n\n## Learn More\n\n<CardGroup cols={2}>\n<Card title=\"Issues\" icon=\"circle-dot\" href=\"/cloud/issues\">\n  Create and manage issues to track your work\n</Card>\n\n<Card title=\"Kanban Board\" icon=\"columns\" href=\"/cloud/kanban-board\">\n  Use the board to visualise and manage progress\n</Card>\n\n<Card title=\"Team Members\" icon=\"user-plus\" href=\"/cloud/team-members\">\n  Invite colleagues and manage permissions\n</Card>\n\n<Card title=\"Customisation\" icon=\"palette\" href=\"/cloud/customisation\">\n  Configure columns, colours, and tags\n</Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/cloud/issues.mdx",
    "content": "---\ntitle: \"Issues\"\ndescription: \"Create, edit, and manage issues to track your work\"\n---\n\nIssues are the individual work items in your project - bugs to fix, features to build, tasks to complete. Each issue lives on your kanban board and can be assigned to team members, tagged, prioritised, and tracked through your workflow.\n\n<Frame>\n  <img src=\"/images/cloud/issue-overview.png\" alt=\"Kanban board with New Issue panel showing issue creation form\" />\n</Frame>\n\n## What is an Issue?\n\nAn **issue** represents a single piece of work. It has:\n\n- **Title** - A short description of what needs to be done\n- **Description** - Detailed information, requirements, or context\n- **Status** - Which column it's in (To Do, In Progress, Done, etc.)\n- **Priority** - How urgent it is (Urgent, High, Medium, Low)\n- **Assignees** - Who's responsible for the work\n- **Tags** - Labels for categorisation and filtering\n- **Simple ID** - A unique identifier like `TASK-123` for easy reference\n\n\n## Creating Issues\n\n### From a Column\n\nTo create an issue in a specific column:\n\n1. Click the **+** button at the top of any status column\n2. The New Issue panel opens with that status pre-selected\n3. Fill in the issue details and click **Create Task**\n\n<Frame>\n  <img src=\"/images/cloud/column-add-button.png\" alt=\"Column header showing the + button to create a new issue\" />\n</Frame>\n\n### From the Header\n\nTo create an issue using the header button:\n\n<Frame>\n  <img src=\"/images/cloud/header-add-button.png\" alt=\"Header showing the + button next to status tabs for creating a new issue\" />\n</Frame>\n\n<Steps>\n<Step title=\"Open the create panel\">\n  Click the **+** button in the header next to the status tabs.\n</Step>\n\n<Step title=\"Enter the title\">\n  Type a clear, descriptive title. The title field is focused automatically.\n</Step>\n\n<Step title=\"Set the status\">\n  Choose which column the issue should start in.\n</Step>\n\n<Step title=\"Set priority (optional)\">\n  Select a priority level (default is no priority):\n\n  | Priority | When to Use |\n  |----------|-------------|\n  | **Urgent** | Needs immediate attention, blocking other work |\n  | **High** | Important, should be done soon |\n  | **Medium** | Normal priority |\n  | **Low** | Nice to have, can wait |\n</Step>\n\n<Step title=\"Assign team members (optional)\">\n  Click the assignee field and select one or more team members. You can assign multiple people to the same issue.\n</Step>\n\n<Step title=\"Add tags (optional)\">\n  Select tags to categorise the issue. Tags help with filtering and organisation.\n</Step>\n\n<Step title=\"Add description (optional)\">\n  Write a detailed description using the rich text editor. You can format text, add lists, code blocks, and links.\n</Step>\n\n<Step title=\"Create the issue\">\n  Click **Create Task** to save the issue. The issue will appear on the board in the selected status column.\n</Step>\n</Steps>\n\n### Create with Workspace\n\nIf you want to immediately start working on an issue with AI assistance:\n\n<Frame>\n  <img style={{maxHeight: \"400px\"}} src=\"/images/cloud/issue-create-form.png\" alt=\"New Issue panel showing Create draft workspace immediately toggle\" />\n</Frame>\n\n1. When creating an issue, enable **Create draft workspace immediately**\n2. The issue is created with a linked [workspace](/workspaces/index)\n3. You can start working with coding agents right away\n\nSee [Workspaces](/workspaces/index) to learn more about working with coding agents.\n\n## Editing Issues\n\n### Opening an Issue\n\nClick any issue card on the board to open the issue panel. The panel slides in from the right side.\n\n\n### Auto-Save\n\nChanges to the title and description **save automatically** as you type. You don't need to click a save button - just edit and your changes are saved.\n\n<Info>\nAuto-save has a small delay (about half a second) to avoid saving every keystroke. If you close the panel immediately after typing, wait a moment for the save to complete.\n</Info>\n\n### Editing Properties\n\nProperties like status, priority, assignees, and tags save **immediately** when you change them:\n\n| Property | How to Edit |\n|----------|-------------|\n| **Status** | Click the status dropdown and select a new status, or drag the card to another column |\n| **Priority** | Click the priority dropdown and select a level |\n| **Assignees** | Click the assignees field, select/deselect team members |\n| **Tags** | Click the tags field, select/deselect tags |\n\n### Editing the Description\n\nThe description uses a rich text editor with formatting options:\n\n- **Bold**, *italic*, ~~strikethrough~~\n- Bullet lists and numbered lists\n- Code blocks for technical content\n- Links to external resources\n\n\n## Issue Sections\n\nThe issue panel has collapsible sections for additional information:\n\n### Workspaces Section\n\nShows development workspaces linked to this issue. [Workspaces](/workspaces/index) are where coding agents do their work.\n\n<Frame>\n  <img style={{maxHeight: \"150px\"}} src=\"/images/cloud/workspaces-section.png\" alt=\"Workspaces section showing a linked active workspace\" />\n</Frame>\n\nEach linked workspace shows:\n- **Status** - Active, idle, or completed\n- **Age** - How long ago it was created\n- **PR status** - Whether a pull request has been created\n\n**To link a workspace:**\n\nClick the **+** button in the Workspaces section header to create a new workspace or link an existing one.\n\n<Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/cloud/link-workspace-dropdown.png\" alt=\"Link workspace dropdown showing Create new workspace and existing workspaces\" />\n</Frame>\n\n**To unlink a workspace:**\n\nClick the **⋯** menu on the workspace and select **Unlink from issue**.\n\n<Frame>\n  <img style={{maxHeight: \"200px\"}} src=\"/images/cloud/workspace-menu-unlink.png\" alt=\"Workspace menu showing Unlink from issue and Delete workspace options\" />\n</Frame>\n\n### Sub-Issues Section\n\nIssues can have child issues (subtasks) for breaking down work:\n\n<Frame>\n  <img style={{maxHeight: \"120px\"}} src=\"/images/cloud/sub-issues-section.png\" alt=\"Sub-issues section showing No sub-issues message with add button\" />\n</Frame>\n\n- **Add Sub-Issue** - Create a new child issue\n- **Link Existing Issue** - Make an existing issue a subtask\n- **View Parent** - If this is a sub-issue, see its parent\n\n### Comments Section\n\nDiscussion thread for the issue:\n\n\n- **Add Comment** - Share updates, ask questions, or provide feedback\n- **React** - Add emoji reactions to comments\n- **Edit/Delete** - Modify or remove your own comments\n\n## Sub-Issues (Subtasks)\n\nBreak large issues into smaller, manageable pieces using sub-issues.\n\n### Creating a Sub-Issue\n\n<Steps>\n<Step title=\"Open the parent issue\">\n  Click the issue that will be the parent.\n</Step>\n\n<Step title=\"Expand the Sub-Issues section\">\n  Click the **Sub-Issues** section header to expand it.\n</Step>\n\n<Step title=\"Click Add Sub-Issue\">\n  Click the **+** button in the Sub-Issues section header. A dropdown appears with options to create a new issue or link an existing one.\n\n  <Frame>\n    <img style={{maxHeight: \"250px\"}} src=\"/images/cloud/add-sub-issue-dropdown.png\" alt=\"Add Sub-issue dropdown showing Create new issue and existing issues to link\" />\n  </Frame>\n</Step>\n\n<Step title=\"Create or link\">\n  - Click **Create new issue** to create a new sub-issue\n  - Or select an existing issue from the list to link it as a sub-issue\n</Step>\n</Steps>\n\n### Viewing the Parent Issue\n\nWhen viewing a sub-issue, you'll see a **Parent** link below the issue properties. Click it to navigate to the parent issue.\n\n<Frame>\n  <img style={{maxHeight: \"250px\"}} src=\"/images/cloud/sub-issue-parent-link.png\" alt=\"Sub-issue showing Parent: ISS-1 link to navigate to parent issue\" />\n</Frame>\n\n### Sub-Issue Behaviour\n\n- Sub-issues appear on the board just like regular issues\n- They can have their own status, priority, and assignees\n- Completing all sub-issues doesn't automatically complete the parent\n- Sub-issues can't have their own sub-issues (only one level deep)\n\n## Issue Actions\n\nIssue Actions provide quick access to common operations on an issue. You can access them in two ways:\n\n### From the Issue Panel\n\nClick the **More** button (three dots) in the top-right corner of the issue panel.\n\n<Frame>\n  <img style={{maxHeight: \"150px\"}} src=\"/images/cloud/issue-panel-more-button.png\" alt=\"Issue panel header showing the three dots More button\" />\n</Frame>\n\n### From the Command Bar\n\nOpen the command bar with `Cmd/Ctrl + K`, then select **Issue Actions**.\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/command-panel-issue-actions.png\" alt=\"Command panel showing Issue Actions option\" />\n</Frame>\n\n### Available Actions\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/issue-actions-menu.png\" alt=\"Issue Actions menu showing all available actions with keyboard shortcuts\" />\n</Frame>\n\n| Action | Shortcut | Description |\n|--------|----------|-------------|\n| **Create Issue** | `I C` | Create a new issue |\n| **Change Status** | `I S` | Move issue to a different status |\n| **Change Priority** | `I P` | Set or change priority level |\n| **Change Assignees** | `I A` | Add or remove assignees |\n| **Make Sub-issue of** | `I M` | Make this issue a child of another |\n| **Add Sub-issue** | `I B` | Add a sub-issue to this issue |\n| **Link Workspace** | `I W` | Connect a workspace to this issue |\n| **Duplicate Issue** | `I D` | Create a copy of this issue |\n| **Delete Issue** | - | Permanently delete this issue |\n\n## Copying Issue Links\n\nTo share an issue with someone:\n\n1. Open the issue panel\n2. Click the **Copy Link** button (<Icon icon=\"link\" />) in the panel header\n3. The issue URL is copied to your clipboard\n\nShare this link with team members - they can click it to go directly to that issue.\n\n## Selecting Multiple Issues\n\nYou can select multiple issues to perform bulk operations, similar to how you'd select files in a file manager.\n\n### Selection Methods\n\n| Method | Action |\n|--------|--------|\n| **Checkbox** | Hover over the left edge of a list row to reveal its checkbox, then click |\n| **Cmd/Ctrl + Click** | Toggle an individual issue in or out of the selection |\n| **Shift + Click** | Select a range of issues between the last selected issue and the clicked issue |\n| **X** | Toggle the currently open issue in or out of the selection |\n| **Shift + J / Shift + ↓** | Extend the selection downward by one issue |\n| **Shift + K / Shift + ↑** | Extend the selection upward by one issue |\n| **Cmd/Ctrl + A** | Select all visible issues |\n| **Escape** | Clear the selection |\n\n<Info>\nSelection works in both the [kanban board](/cloud/kanban-board) and [list view](/cloud/list-view). In list view, checkboxes appear on hover for each row. In the kanban board, use modifier keys or keyboard shortcuts to select cards.\n</Info>\n\n### Bulk Action Bar\n\nWhen two or more issues are selected, a floating action bar appears at the bottom of the screen. From this bar you can:\n\n| Action | Description |\n|--------|-------------|\n| **Status** | Change the status of all selected issues at once |\n| **Priority** | Set the same priority across all selected issues |\n| **Assignee** | Assign or change assignees for all selected issues |\n| **Delete** | Permanently delete all selected issues |\n\nClick the **X** button on the action bar or press **Escape** to clear the selection.\n\n<Warning>\nBulk delete is permanent and cannot be undone. All selected issues, their comments, and their history will be removed.\n</Warning>\n\n<Tip>\nUse filters to narrow down your view before selecting issues. For example, filter by a specific status, then press **Cmd/Ctrl + A** to select all matching issues for a bulk status change.\n</Tip>\n\n## Deleting Issues\n\nTo delete a single issue:\n\n1. Open the issue panel\n2. Click the **More** button (three dots) to open Issue Actions\n3. Select **Delete Issue**\n4. Confirm the deletion\n\nTo delete multiple issues, select them and click **Delete** in the bulk action bar.\n\n<Warning>\nDeleting an issue is permanent. The issue, its comments, and its history are removed. Sub-issues are not deleted - they become standalone issues.\n</Warning>\n\n## Issue Simple IDs\n\nEvery issue has a **Simple ID** - a short, unique identifier like `TASK-123`. This makes it easy to reference issues in conversations, commits, and documentation.\n\nThe Simple ID is shown:\n- On issue cards on the board\n- In the issue panel header\n- In the URL when viewing an issue\n\n<Tip>\nUse Simple IDs in commit messages (e.g., \"Fix login bug TASK-123\") to create a clear connection between code and issues.\n</Tip>\n\n## Best Practices\n\n<CardGroup cols={2}>\n<Card title=\"Write clear titles\" icon=\"heading\">\n  Issue titles should describe what needs to be done, not the problem. \"Add password reset flow\" is better than \"Users can't reset password\".\n</Card>\n\n<Card title=\"Use priority wisely\" icon=\"flag\">\n  Reserve \"Urgent\" for genuine emergencies. If everything is urgent, nothing is.\n</Card>\n\n<Card title=\"Assign deliberately\" icon=\"user\">\n  Assign issues to people who will actually work on them. Unassigned issues are fine for backlog items.\n</Card>\n\n<Card title=\"Keep descriptions updated\" icon=\"file-lines\">\n  As requirements change or you learn more, update the description. It's the source of truth for what needs to be done.\n</Card>\n</CardGroup>\n\n## Related Documentation\n\n- [Kanban Board](/cloud/kanban-board) - Moving and organising issues on the board\n- [Filtering & Sorting](/cloud/filtering) - Finding issues quickly\n- [Customising Your Board](/cloud/customisation) - Configuring statuses and tags\n- [Workspaces](/workspaces/index) - Working with coding agents\n"
  },
  {
    "path": "docs/cloud/kanban-board.mdx",
    "content": "---\ntitle: \"Kanban View\"\ndescription: \"Visualise and manage your work with the kanban board\"\n---\n\nThe kanban board is your primary view for managing work. Issues are organised into columns representing different stages of your workflow, and you can drag and drop them as work progresses.\n\n<Frame>\n  <img src=\"/images/cloud/project-kanban-board.png\" alt=\"Kanban board showing columns with issue cards\" />\n</Frame>\n\n## Board Layout\n\nThe kanban board consists of:\n\n- **Filter Bar** - Search, filter, and sort your issues (top)\n- **Status Columns** - Vertical columns representing workflow stages\n- **Issue Cards** - Individual work items within columns\n- **Column Headers** - Show status name with colour indicator\n\n### Status Columns\n\nEach column represents a status in your workflow. By default, projects have six statuses:\n\n| Column | Purpose |\n|--------|---------|\n| **To do** | Ready to start |\n| **In progress** | Currently being worked on |\n| **In review** | Waiting for code review |\n| **Done** | Completed |\n\nTwo additional statuses are hidden by default: **Backlog** (for ideas and future work) and **Cancelled** (for issues that won't be done). You can access them via the status tabs above the board.\n\nYou can [customise these columns](/cloud/customisation) - add new ones, rename them, change colours, or hide/show ones you need.\n\n### Issue Cards\n\nEach card shows key information at a glance:\n\n- **Simple ID** - The issue identifier (e.g., ISS-1)\n- **Title** - What needs to be done\n- **Priority** - Colour-coded indicator (if set)\n- **Assignees** - Avatar(s) of assigned team members\n- **Tags** - Coloured labels (if any)\n\nClick any card to open the full [issue panel](/cloud/issues).\n\n## Drag and Drop\n\nMove issues between columns and reorder them within columns using drag and drop.\n\n<Frame>\n  <img src=\"/images/cloud/drag-drop.png\" alt=\"Dragging an issue card between columns\" />\n</Frame>\n\n### Moving Between Columns\n\nTo change an issue's status:\n\n1. Click and hold an issue card\n2. Drag it to the target column\n3. Release to drop it\n\n\nThe issue's status updates immediately, and all team members see the change in real-time.\n\n### Reordering Within a Column\n\nTo change the order of issues within a column:\n\n1. Make sure you're in **Manual** sort mode (check the sort dropdown in the filter bar)\n2. Click and hold an issue card\n3. Drag it up or down within the same column\n4. Release to drop it in the new position\n\n<Info>\nReordering only works when sort mode is set to **Manual**. If you're sorting by Priority, Created, or another field, the order is determined automatically and you can't drag to reorder.\n</Info>\n\n### Drag Indicators\n\nWhile dragging, you'll see visual feedback:\n\n- **Cursor changes** - Shows you're in drag mode\n- **Drop target** - Highlights where the card will land\n- **Card shadow** - Shows the card being moved\n\n## Status Tabs\n\nAbove the board, tabs let you switch between views:\n\n<Frame>\n  <img src=\"/images/cloud/status-tabs.png\" alt=\"Status tabs showing Active, All, Backlog, and Cancelled\" />\n</Frame>\n\n| Tab | View | Shows |\n|-----|------|-------|\n| **Active** | Kanban | Kanban columns for visible statuses |\n| **All** | [List View](/cloud/list-view) | All issues including hidden statuses |\n| **Backlog**, **Cancelled**, etc. | [List View](/cloud/list-view) | Issues in that specific hidden status |\n\n<Info>\nHidden status tabs (like Backlog or Cancelled) only appear if you've hidden those statuses in [Display Settings](/cloud/customisation). This is useful for keeping your board clean while still having quick access to archived or backlogged items.\n</Info>\n\n## Multi-Select\n\nSelect multiple issue cards to perform bulk actions directly from the board.\n\n### How to Select\n\n- **Cmd/Ctrl + Click** a card to toggle it in or out of the selection\n- **Shift + Click** a card to select a range from the last selected card\n- **X** to toggle the currently open issue\n- **Shift + J / ↓** or **Shift + K / ↑** to extend the selection by one issue\n- **Cmd/Ctrl + A** to select all visible issues\n- **Escape** to clear the selection\n\nSelected cards are highlighted with an accent ring. While issues are selected, drag and drop is disabled to prevent accidental moves.\n\nWhen two or more issues are selected, a **bulk action bar** appears at the bottom of the screen. You can change the status, priority, or assignees of all selected issues at once, or delete them. See [Selecting Multiple Issues](/cloud/issues#selecting-multiple-issues) for full details.\n\n## Working with the Board\n\n### Opening an Issue\n\nClick any issue card to open the issue panel on the right side of the screen. The panel shows full details and lets you edit the issue.\n\n### Quick Status Change\n\nInstead of opening an issue, you can change its status by dragging it to another column. This is the fastest way to update many issues.\n\n### Keyboard Shortcuts\n\n| Shortcut | Action |\n|----------|--------|\n| `X` | Toggle current issue selection |\n| `Shift + J / ↓` | Extend selection down |\n| `Shift + K / ↑` | Extend selection up |\n| `Cmd/Ctrl + A` | Select all issues |\n| `Escape` | Clear selection / close the issue panel |\n| `Cmd/Ctrl + K` | Open the command bar for quick actions |\n\n## Real-Time Collaboration\n\nThe board updates in real-time as team members make changes:\n\n- **Issue moved** - Cards animate to their new position\n- **Issue created** - New cards appear automatically\n- **Issue updated** - Changes to titles, priorities, etc. appear instantly\n- **Issue deleted** - Cards disappear from the board\n\nYou don't need to refresh the page - everything syncs automatically.\n\n<Info>\nIf you and a teammate move the same issue at the same time, the last action wins. The board will show the final position after both actions complete.\n</Info>\n\n## Empty Columns\n\nColumns with no issues show an empty state. You can:\n\n- Drag issues into empty columns\n- Use the **+** button to create a new issue in that column\n- [Hide the status](/cloud/customisation) in Display Settings if you don't need it on the board\n\n<Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/cloud/empty-column.png\" alt=\"Empty column showing the + button to create a new issue\" />\n</Frame>\n\n## Best Practices\n\n<CardGroup cols={2}>\n<Card title=\"Limit work in progress\" icon=\"gauge-high\">\n  Don't have too many issues in \"In Progress\". It's better to finish work than to start new work. Consider adding WIP limits to your process.\n</Card>\n\n<Card title=\"Review regularly\" icon=\"calendar-check\">\n  Use the board in daily standups to review what's in progress and identify blockers. The visual layout makes status updates quick.\n</Card>\n\n<Card title=\"Keep columns meaningful\" icon=\"columns\">\n  Each column should represent a real stage in your workflow. If issues skip a column, you probably don't need it.\n</Card>\n\n<Card title=\"Use filters for focus\" icon=\"filter\">\n  When the board gets busy, use filters to focus on what matters - your assigned issues, high priority items, or specific tags.\n</Card>\n</CardGroup>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"I can't reorder issues within a column\">\nMake sure you're in **Manual** sort mode. Click the sort dropdown in the filter bar and select \"Manual\". If you're sorting by Priority, Created, or another field, the order is fixed.\n</Accordion>\n\n<Accordion title=\"Issues aren't appearing\">\nCheck your filters - you might have active filters hiding some issues. Click **Clear All** in the filter bar to reset filters and see all issues.\n</Accordion>\n\n<Accordion title=\"A column is missing\">\nThe column might be hidden. Go to [Display Settings](/cloud/customisation) and check if the status is set to hidden. Toggle it back to visible.\n</Accordion>\n\n<Accordion title=\"Changes aren't saving\">\nCheck your internet connection. The board requires an active connection to sync changes. If you're offline, changes won't be saved until you reconnect.\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [List View](/cloud/list-view) - Alternative tabular view for issues\n- [Issues](/cloud/issues) - Creating and editing issues\n- [Filtering & Sorting](/cloud/filtering) - Finding issues quickly\n- [Customising Your Board](/cloud/customisation) - Configuring columns and display\n"
  },
  {
    "path": "docs/cloud/list-view.mdx",
    "content": "---\ntitle: \"List View\"\ndescription: \"View and manage all your issues in a tabular format\"\n---\n\nThe list view provides a tabular way to see all your issues, including those in hidden statuses. It's ideal for reviewing many issues at once and getting a complete overview of your project.\n\n<Frame>\n  <img src=\"/images/cloud/list-view.png\" alt=\"List view showing issues grouped by status\" />\n</Frame>\n\n## Accessing List View\n\nTo switch to list view, click the **All** tab in the header above the board.\n\n<Frame>\n  <img src=\"/images/cloud/status-tabs.png\" alt=\"Status tabs showing Active, All, Backlog, and Cancelled\" />\n</Frame>\n\n| Tab | What it shows |\n|-----|---------------|\n| **All** | All issues across all statuses (including hidden) |\n| **Backlog**, **Cancelled**, etc. | Issues in that specific hidden status only |\n\nTo return to the kanban view, click the **Active** tab.\n\n## List Layout\n\nIssues in the list view are grouped by status. Each status section shows:\n\n- **Status name** with colour indicator\n- **Issue count** for that status\n- **Collapsible sections** - click to expand or collapse\n\n<Frame>\n  <img src=\"/images/cloud/list-layout.png\" alt=\"List view showing status sections with issues grouped by status\" />\n</Frame>\n\n### Issue Row Details\n\nEach row in the list shows:\n\n- **Simple ID** - The issue identifier (e.g., ISS-1)\n- **Title** - The issue title\n- **Workspace indicator** - Shows if the issue has linked workspaces\n- **Age** - How long ago the issue was created (e.g., \"12d\" for 12 days)\n\nClick any row to open the full [issue panel](/cloud/issues).\n\n## Multi-Select in List View\n\nList view supports multi-select for bulk operations. Each row has a checkbox that appears when you hover over its left edge.\n\n### Selection Methods\n\n- **Checkbox** - Hover over the left edge of a row to reveal the checkbox, then click to select\n- **Cmd/Ctrl + Click** a row to toggle it in or out of the selection\n- **Shift + Click** a row to select a range from the last selected row\n- **X** to toggle the currently open issue\n- **Shift + J / ↓** or **Shift + K / ↑** to extend the selection by one row\n- **Cmd/Ctrl + A** to select all visible issues\n- **Escape** to clear the selection\n\nSelected rows are highlighted. When two or more issues are selected, the **bulk action bar** appears at the bottom of the screen with options to change status, priority, assignees, or delete. See [Selecting Multiple Issues](/cloud/issues#selecting-multiple-issues) for full details.\n\n## When to Use List View\n\nList view is best for:\n\n<CardGroup cols={2}>\n<Card title=\"Reviewing all issues\" icon=\"list\">\n  See every issue in your project at once, including hidden statuses like Backlog and Cancelled.\n</Card>\n\n<Card title=\"Finding specific issues\" icon=\"magnifying-glass\">\n  Combined with search and filters, quickly locate issues across all statuses.\n</Card>\n\n<Card title=\"Bulk operations\" icon=\"clipboard-list\">\n  Select multiple issues with checkboxes to change status, priority, or assignees in bulk.\n</Card>\n\n<Card title=\"Accessing hidden statuses\" icon=\"eye\">\n  View and manage issues in Backlog, Cancelled, or other hidden statuses.\n</Card>\n</CardGroup>\n\n## Filtering and Sorting\n\nThe filter bar works the same in list view as in kanban view:\n\n- **Search** - Find issues by title\n- **Priority** - Filter by priority level\n- **Assignee** - Filter by team member\n- **Tags** - Filter by tags\n- **Sort** - Change the order of issues\n\nSee [Filtering & Sorting](/cloud/filtering) for more details.\n\n## Related Documentation\n\n- [Kanban View](/cloud/kanban-board) - The default board view with columns\n- [Issues](/cloud/issues) - Creating and editing issues\n- [Filtering & Sorting](/cloud/filtering) - Finding issues quickly\n"
  },
  {
    "path": "docs/cloud/migration.mdx",
    "content": "---\ntitle: \"Migration from Legacy Local Projects\"\ndescription: \"Upgrade your local projects to Cloud for team collaboration\"\n---\n\nIf you've been using Vibe Kanban locally, you can migrate your existing projects to Cloud to unlock team collaboration, real-time sync, and access from any device.\n\n## Why Migrate?\n\nMoving your projects to Cloud gives you:\n\n<CardGroup cols={2}>\n<Card title=\"Cloud Storage\" icon=\"cloud\">\n  Access your projects from anywhere. Your data syncs automatically across devices.\n</Card>\n\n<Card title=\"Team Collaboration\" icon=\"users\">\n  Invite teammates to your organisation and assign work to team members.\n</Card>\n\n<Card title=\"Comments\" icon=\"message\">\n  Add comments to issues to keep discussions in context with the work.\n</Card>\n\n<Card title=\"Sub-Issues\" icon=\"sitemap\">\n  Break down complex work into smaller, manageable pieces.\n</Card>\n\n<Card title=\"GitHub Integration\" icon=\"github\">\n  Link pull requests directly to issues for seamless code tracking.\n</Card>\n\n<Card title=\"Tags & Priorities\" icon=\"tags\">\n  Organise work with custom tags and priority levels.\n</Card>\n</CardGroup>\n\n## Before You Start\n\nBefore migrating, make sure you have:\n\n1. **A Cloud account** - Sign in with GitHub or Google (a personal organisation is created automatically)\n2. **Local projects** - Projects stored on your machine that you want to migrate\n\n<Info>\nMigration copies your data to Cloud - your local projects remain unchanged. You can continue using them locally if needed.\n</Info>\n\n## Starting the Migration\n\n### From the Project Banner\n\nWhen viewing a local project that hasn't been migrated, you'll see a banner prompting you to migrate:\n\n<Frame>\n  <img src=\"/images/cloud/migration-prompt-banner.png\" alt=\"Migrate this project to the cloud banner with Learn more button\" />\n</Frame>\n\nClick **Learn more** to open the migration wizard.\n\n## Migration Steps\n\nThe migration wizard guides you through four steps:\n\n### Step 1: Introduction\n\nThe first screen explains what Cloud offers and what will happen during migration.\n\n<Frame>\n  <img src=\"/images/cloud/migration-introduction.png\" alt=\"Migration introduction screen showing Cloud benefits\" />\n</Frame>\n\n- If you're **not signed in**, click **Sign In** to authenticate with GitHub or Google\n- If you're **already signed in**, click **Continue** to proceed\n\nWhen you click Sign In, you'll see the authentication dialog:\n\n<Frame>\n  <img src=\"/images/cloud/sign-in-dialog.png\" alt=\"Sign in dialog with GitHub and Google options\" />\n</Frame>\n\n### Step 2: Choose Projects\n\nSelect which local projects you want to migrate:\n\n<Frame>\n  <img src=\"/images/cloud/migration-choose-projects.png\" alt=\"Choose projects screen showing project list and organisation selector\" />\n</Frame>\n\n<Steps>\n<Step title=\"Review your projects\">\n  You'll see a list of all your local projects with their names and IDs.\n</Step>\n\n<Step title=\"Select projects to migrate\">\n  Click the checkbox next to each project you want to migrate. Use **Select All** to choose everything at once.\n\n  <Frame>\n    <img src=\"/images/cloud/migration-project-selected.png\" alt=\"Project selected for migration with Migrate button\" />\n  </Frame>\n\n  Projects you've already migrated are shown separately with a **View** link to open them in Cloud.\n</Step>\n\n<Step title=\"Choose target organisation\">\n  If you have multiple organisations, select which one should receive the migrated projects.\n</Step>\n\n<Step title=\"Continue\">\n  Click **Continue** to proceed to the migration step.\n</Step>\n</Steps>\n\n<Tip>\nYou can migrate projects in batches. If you have many projects, consider migrating a few first to verify everything works as expected.\n</Tip>\n\n### Step 3: Migrate\n\nThis step performs the actual migration:\n\n1. Click **Start Migration** to begin\n2. Wait while your data is transferred to Cloud\n3. You'll see a summary report when complete\n\n<Frame>\n  <img src=\"/images/cloud/migration-complete.png\" alt=\"Migration summary showing projects and tasks migrated\" />\n</Frame>\n\n| Item | Description |\n|------|-------------|\n| **Projects** | How many projects were migrated vs. total |\n| **Tasks** | How many issues/tasks were migrated |\n| **PR Merges** | How many pull request links were migrated |\n| **Warnings** | Any issues encountered during migration |\n\n<Warning>\nDon't close the browser or navigate away while migration is in progress. This could result in incomplete data transfer.\n</Warning>\n\nClick **Continue** to proceed to the finish screen.\n\n### Step 4: Finish\n\nThe finish screen shows your migrated projects with quick access links:\n\n<Frame>\n  <img src=\"/images/cloud/migration-finish.png\" alt=\"Finish screen showing migrated projects with View links\" />\n</Frame>\n\n- Click **View** next to any project to open it in Cloud\n- Click **Migrate More Projects** to migrate additional projects\n\n## What Gets Migrated\n\nThe migration transfers the following data:\n\n### Projects\n- Project name\n- Project colour\n- All project settings\n\n### Issues (Tasks)\n- Issue titles and descriptions\n- Status (which column they're in)\n- Any existing metadata\n\n### Pull Request Links\n- Connections between issues and GitHub PRs\n- PR status information\n\n## What Doesn't Get Migrated\n\nSome data is created fresh in Cloud:\n\n- **Team members** - You'll need to invite teammates after migration\n- **Comments** - Local projects don't have comments, so there's nothing to migrate\n- **Tags** - You'll set these up in your Cloud project\n- **Priorities** - Issues will need priorities assigned after migration\n\n## After Migration\n\nWhen you return to your local project, you'll see a banner indicating it's been migrated to Cloud:\n\n<Frame>\n  <img src=\"/images/cloud/migration-banner.png\" alt=\"Project migrated to Cloud banner with View project button\" />\n</Frame>\n\nClick **View project** to open the Cloud version with all collaboration features.\n\nOnce your projects are in Cloud:\n\n### Invite Your Team\n\n1. Go to your [organisation settings](/cloud/organizations)\n2. [Invite team members](/cloud/team-members) via email\n3. They'll receive an invitation to join\n\n### Set Up Your Board\n\n1. Open a migrated project\n2. [Add tags](/cloud/customisation) for categorisation\n3. [Set priorities](/cloud/issues) on important issues\n4. [Customise columns](/cloud/customisation) if needed\n\n### Connect GitHub (Optional)\n\nIf you want PR tracking:\n\n1. Go to organisation settings\n2. [Install the GitHub App](/integrations/github-integration)\n3. Enable code review for your repositories\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"Migration failed with an error\">\nCheck your internet connection and try again. If the problem persists:\n\n1. Refresh the page\n2. Sign out and sign back in\n3. Try migrating fewer projects at once\n\nIf you continue to have issues, check the error message for specific details.\n</Accordion>\n\n<Accordion title=\"Some projects weren't migrated\">\nThe migration report shows how many projects were skipped. Common reasons:\n\n- **Duplicate names** - A project with the same name already exists in the target organisation\n- **Invalid data** - The project has corrupted or incomplete data\n\nTry renaming the local project and migrating again.\n</Accordion>\n\n<Accordion title=\"Tasks are missing after migration\">\nCheck the following:\n\n1. **Filters** - Make sure you don't have active filters hiding issues\n2. **Columns** - Check if issues are in a hidden status column\n3. **Migration report** - Review the report to see if tasks were skipped\n\nIf tasks were skipped, there may have been data issues. Check the warnings in the migration report.\n</Accordion>\n\n<Accordion title=\"I want to migrate to a different organisation\">\nYou can run the migration wizard again and select a different target organisation. Note that this will create duplicate projects if you migrate the same projects twice.\n</Accordion>\n\n<Accordion title=\"Can I undo the migration?\">\nMigration copies data to Cloud - it doesn't delete your local projects. Your local data remains unchanged, so there's nothing to \"undo\".\n\nIf you want to remove migrated projects from Cloud, you can delete them from the Cloud project list.\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Getting Started](/cloud/getting-started) - Set up your Cloud account\n- [Organisations](/cloud/organizations) - Managing your organisation\n- [Team Members](/cloud/team-members) - Inviting teammates\n- [Projects](/cloud/projects) - Working with Cloud projects\n"
  },
  {
    "path": "docs/cloud/organizations.mdx",
    "content": "---\ntitle: \"Organisations\"\ndescription: \"Create and manage organisations to group your team and projects\"\n---\n\nOrganisations are the top-level container in Vibe Kanban Cloud. They group your team members and projects together.\n\n## What is an Organisation?\n\nAn **organisation** represents your team, company, or group. It contains:\n\n- **Projects** - Your kanban boards for managing tasks\n- **Members** - People who can access the organisation\n- **Settings** - Configuration that applies to all projects\n\n\n### Organisation Structure\n\nAn organisation acts as a container that holds two main things:\n\n**Members** are the people who belong to your organisation. Each member has a role:\n- **Admins** have full control - they can invite/remove members, change settings, and delete the organisation\n- **Members** can work on all projects but cannot manage organisation settings\n\n**Projects** are your kanban boards. Each project contains tasks, statuses, and tags. All members of an organisation can see and work on all projects within it.\n\n<Tip>\nThink of an organisation like a company: the company has employees (members) and departments or initiatives (projects). Everyone in the company can see what each department is working on.\n</Tip>\n\n## Personal Organisation\n\nWhen you sign in for the first time, a **personal organisation** is automatically created for you, along with an **Initial Project**. This is your private workspace.\n\nPersonal organisations:\n- Are created automatically on first sign-in\n- Come with an \"Initial Project\" ready to use\n- Cannot have additional members invited\n- Cannot be deleted\n- Are only accessible by you\n\n<Info>\nTo collaborate with others, you need to create a new (non-personal) organisation.\n</Info>\n\n## Creating a Team Organisation\n\nTo collaborate with team members, create a new organisation:\n\n<Steps>\n<Step title=\"Open the user menu\">\n  Click your **profile icon** in the bottom of the left sidebar.\n</Step>\n\n<Step title=\"Click Create organization\">\n  Select **+ Create organization** from the menu.\n</Step>\n\n<Step title=\"Enter details and create\">\n  Fill in the organisation details:\n\n  <Frame>\n    <img src=\"/images/cloud/create-organization-dialog.png\" alt=\"Create New Organization dialog\" />\n  </Frame>\n\n  - **Organization Name** - Your team or company name (e.g., \"Acme Corporation\")\n  - **Slug** - URL-friendly identifier, auto-generated from the name (lowercase, numbers, hyphens only)\n\n  Click **Create Organization** to finish. An **Initial Project** is automatically created in your new organisation.\n</Step>\n</Steps>\n\n<Info>\nYou can be a member of multiple organisations. Use the user menu to switch between them.\n</Info>\n\n## Organisation Settings\n\nAccess organisation settings by clicking the **settings icon** (<Icon icon=\"gear\" />) next to your organisation name in the user menu.\n\n<Frame>\n  <img src=\"/images/cloud/user-menu-settings.png\" alt=\"User menu showing settings icon next to organisation\" />\n</Frame>\n\nFor detailed information about all organisation settings, see the [Organisation Settings](/settings/organization-settings) page.\n\nFrom Organisation Settings you can:\n- **Select organisation** - Switch between organisations you belong to\n- **Create organisation** - Create a new organisation\n- **Manage members** - View, invite, and remove members (Admin only)\n- **Manage invitations** - View and revoke pending invitations (Admin only)\n- **Delete organisation** - Permanently delete the organisation (Admin only)\n\n## Switching Organisations\n\nIf you're a member of multiple organisations:\n\n<Steps>\n<Step title=\"Open the user menu\">\n  Click your **profile icon** in the bottom of the left sidebar.\n</Step>\n\n<Step title=\"Select the organisation\">\n  Click on the organisation you want to switch to from the list.\n</Step>\n</Steps>\n\nThe page will refresh to show the selected organisation's projects and data.\n\n## Leaving an Organisation\n\nIf you want to leave an organisation you're a member of:\n\n<Steps>\n<Step title=\"Go to Settings\">\n  Open the organisation settings.\n</Step>\n\n<Step title=\"Find Leave Organisation\">\n  Scroll down to the **Danger Zone** section.\n</Step>\n\n<Step title=\"Click Leave\">\n  Click **Leave Organisation** and confirm.\n</Step>\n</Steps>\n\n<Warning>\n**You cannot leave if you're the only admin.** Transfer ownership to another member first, or delete the organisation.\n</Warning>\n\n## Deleting an Organisation\n\n<Warning>\n**This action is permanent and cannot be undone.** All projects, tasks, and data will be deleted.\n</Warning>\n\nOnly organisation admins can delete an organisation. Personal organisations cannot be deleted.\n\n<Frame>\n  <img src=\"/images/cloud/danger-zone.png\" alt=\"Danger Zone section with Delete Organization button\" />\n</Frame>\n\n<Steps>\n<Step title=\"Go to Settings\">\n  Open the organisation settings.\n</Step>\n\n<Step title=\"Find Danger Zone\">\n  Scroll down to the **Danger Zone** section.\n</Step>\n\n<Step title=\"Confirm deletion\">\n  Click **Delete** and confirm when prompted.\n</Step>\n</Steps>\n\n<Info>\nBefore deleting, consider:\n- Exporting any data you need\n- Removing all members (they'll lose access immediately upon deletion)\n- This cannot be recovered\n</Info>\n\n## Organisation Roles\n\nMembers can have one of two roles:\n\n| Role | Capabilities |\n|------|--------------|\n| **Admin** | Full access - manage members, settings, and can delete the organisation |\n| **Member** | Can view and work on projects, but cannot manage organisation settings or members |\n\n### Role Permissions\n\n| Action | Admin | Member |\n|--------|-------|--------|\n| View projects | ✅ | ✅ |\n| Create projects | ✅ | ✅ |\n| Edit project settings | ✅ | ✅ |\n| Create/edit tasks | ✅ | ✅ |\n| Invite members | ✅ | ❌ |\n| Remove members | ✅ | ❌ |\n| Change member roles | ✅ | ❌ |\n| Edit organisation settings | ✅ | ❌ |\n| Delete organisation | ✅ | ❌ |\n\n<Info>\nThe person who creates an organisation is automatically an Admin. There must always be at least one Admin.\n</Info>\n\n## Best Practices\n\n<CardGroup cols={2}>\n<Card title=\"One org per team/company\" icon=\"building\">\n  Create one organisation for your team or company. Don't create separate orgs for each project.\n</Card>\n\n<Card title=\"Use descriptive names\" icon=\"tag\">\n  Choose organisation names that clearly identify the team. \"Engineering\" is better than \"Org 1\".\n</Card>\n\n<Card title=\"Have multiple admins\" icon=\"users\">\n  Ensure at least two people are admins in case one leaves or is unavailable.\n</Card>\n\n<Card title=\"Regular member audits\" icon=\"clipboard-check\">\n  Periodically review who has access and remove members who no longer need it.\n</Card>\n</CardGroup>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"Can't create an organisation\">\n**Problem:** The create organisation button doesn't appear or doesn't work.\n\n**Possible causes:**\n- You're already in the organisation creation flow\n- Network connectivity issues\n\n**Solution:**\nRefresh the page and try again. If the issue persists, check your browser's console for errors.\n</Accordion>\n\n<Accordion title=\"Can't see an organisation I should have access to\">\n**Problem:** An organisation you were invited to doesn't appear in your list.\n\n**Possible causes:**\n- The invitation is still pending (check your email)\n- You signed in with a different account\n- You were removed from the organisation\n\n**Solution:**\n1. Check your email for an invitation link\n2. Verify you're signed in with the correct account\n3. Ask an admin of the organisation to check your membership\n</Accordion>\n\n<Accordion title=\"Can't leave the organisation\">\n**Problem:** The \"Leave Organisation\" button is disabled.\n\n**Cause:** You're the only admin.\n\n**Solution:**\n1. Promote another member to admin first\n2. Then you can leave\n3. Or delete the organisation if no one else needs it\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Team Members](/cloud/team-members) - Inviting and managing organisation members\n- [Getting Started](/cloud/getting-started) - Initial setup of Vibe Kanban Cloud\n"
  },
  {
    "path": "docs/cloud/projects.mdx",
    "content": "---\ntitle: \"Projects\"\ndescription: \"Create and manage projects to organise your work\"\n---\n\nProjects are where your work lives. Each project contains a kanban board with issues, statuses, and tags that help you organise and track progress.\n\n<Frame>\n  <img src=\"/images/cloud/project-kanban-board.png\" alt=\"Project kanban board showing columns and issues\" />\n</Frame>\n\n## What is a Project?\n\nA **project** is a container for related work within your organisation. Think of it as a dedicated space for a specific initiative, product, or team goal.\n\nEach project has:\n- **Issues** - Individual work items (bugs, features, tasks)\n- **Statuses** - Columns on your kanban board (To Do, In Progress, Done, etc.)\n- **Tags** - Labels for categorising issues\n- **Team access** - All organisation members can view and work on project issues\n\n## Project Sidebar\n\nThe left sidebar provides quick access to your projects and workspaces.\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/sidebar-workspaces.png\" alt=\"Project sidebar showing Workspaces icon, project icons, and add button\" />\n</Frame>\n\n### Sidebar Elements\n\n| Element | Description |\n|---------|-------------|\n| **Workspaces icon** | Top icon - opens the Workspaces view for coding agents |\n| **Project icons** | Square buttons showing project abbreviations (e.g., \"VI\" for vibe-kanban) |\n| **+ button** | Create a new project |\n\n### Project Icons\n\nEach project appears as a square button with a two-letter abbreviation of the project name. The currently selected project is highlighted.\n\n<Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/cloud/sidebar-project-tooltip.png\" alt=\"Hovering over project icon shows full project name\" />\n</Frame>\n\nHover over any project icon to see the full project name in a tooltip.\n\n## Creating a Project\n\n<Steps>\n<Step title=\"Open your organisation\">\n  Sign in and select your organisation from the sidebar or organisation switcher.\n</Step>\n\n<Step title=\"Click Create Project\">\n  Click the **+ New Project** button in the sidebar or on the projects page.\n\n</Step>\n\n<Step title=\"Enter project details\">\n  Fill in the project information:\n\n  <Frame>\n    <img style={{maxHeight: \"300px\"}} src=\"/images/cloud/create-project-dialog.png\" alt=\"Create Project dialog with name and colour fields\" />\n  </Frame>\n\n  | Field | Description |\n  |-------|-------------|\n  | **Name** | A clear name for your project (e.g., \"Mobile App\", \"Q1 Marketing\") |\n  | **Colour** | Click the colour swatch to choose a colour for this project |\n</Step>\n\n<Step title=\"Click Create\">\n  Click **Create Project** to create your project. You'll be taken to your new project's kanban board.\n</Step>\n</Steps>\n\n## Default Statuses\n\nNew projects come with six default status columns:\n\n| Status | Purpose | Visible by Default |\n|--------|---------|-------------------|\n| **Backlog** | Ideas and future work | Hidden |\n| **To do** | Issues ready to start | ✓ |\n| **In progress** | Issues currently being worked on | ✓ |\n| **In review** | Waiting for code review | ✓ |\n| **Done** | Completed issues | ✓ |\n| **Cancelled** | Issues that won't be done | Hidden |\n\n<Info>\nHidden statuses don't appear as columns on the board, but you can still access issues in them via the status tabs (e.g., \"Backlog\" or \"Cancelled\" tabs).\n</Info>\n\n<Frame>\n  <img src=\"/images/cloud/project-kanban-board.png\" alt=\"Project kanban board showing columns and issues\" />\n</Frame>\n\nYou can customise these statuses - rename them, change colours, add new ones, or hide ones you don't use. See [Customising Your Board](/cloud/customisation) for details.\n\n## Project Settings\n\nAccess project settings by clicking the gear icon (<Icon icon=\"gear\" />) next to the project name.\n\n<Frame>\n  <img style={{maxHeight: \"250px\"}} src=\"/images/cloud/project-settings-icon.png\" alt=\"Project header showing settings gear icon\" />\n</Frame>\n\n### Renaming a Project\n\n1. Open project settings\n2. Edit the **Project name** field\n3. Click **Save**\n\n### Changing Project Colour\n\n1. Open project settings\n2. Click the colour selector\n3. Choose a new colour\n4. Click **Save**\n\n<Frame>\n  <img style={{maxHeight: \"200px\"}} src=\"/images/cloud/edit-project-form.png\" alt=\"Edit project form with name and colour picker\" />\n</Frame>\n\nThe colour appears in the sidebar and helps you quickly identify projects.\n\n<Tip>\nYou can also manage projects from **Settings** → **Remote Projects**. See [Remote Projects Settings](/settings/remote-projects) for more details.\n</Tip>\n\n## Switching Between Projects\n\nYou have several ways to switch projects:\n\n**From the sidebar:**\n- Click any project name in the sidebar to open it\n\n**From the project dropdown:**\n- Click the current project name in the header\n- Select a different project from the dropdown\n\n\n## Deleting a Project\n\n<Warning>\nDeleting a project is permanent and cannot be undone. All issues, comments, and data within the project will be deleted.\n</Warning>\n\nTo delete a project, go to **Settings** → **Remote Projects**:\n\n<Steps>\n<Step title=\"Open Remote Projects settings\">\n  Go to **Settings** and select **Remote Projects** from the sidebar.\n</Step>\n\n<Step title=\"Find the project\">\n  Select the organisation and locate the project you want to delete.\n</Step>\n\n<Step title=\"Open the menu\">\n  Hover over the project and click the **⋯** (three dots) menu.\n\n  <Frame>\n    <img style={{maxHeight: \"150px\"}} src=\"/images/cloud/project-menu-button.png\" alt=\"Project row showing three-dots menu button\" />\n  </Frame>\n</Step>\n\n<Step title=\"Click Delete\">\n  Select **Delete** from the menu and confirm when prompted.\n\n  <Frame>\n    <img style={{maxHeight: \"150px\"}} src=\"/images/cloud/project-delete-menu.png\" alt=\"Dropdown menu with Delete option\" />\n  </Frame>\n</Step>\n</Steps>\n\n<Info>\nBefore deleting, consider whether you might need any of the issues or their history. There's no way to recover a deleted project.\n</Info>\n\nFor more details on managing projects, see [Remote Projects Settings](/settings/remote-projects).\n\n## Best Practices\n\n<CardGroup cols={2}>\n<Card title=\"One project per initiative\" icon=\"folder\">\n  Create separate projects for distinct initiatives rather than putting everything in one project. This keeps your boards focused and manageable.\n</Card>\n\n<Card title=\"Use descriptive names\" icon=\"tag\">\n  Choose project names that clearly describe what the project is about. \"Mobile App v2\" is better than \"Project A\".\n</Card>\n\n<Card title=\"Consistent status columns\" icon=\"columns\">\n  Try to use similar status columns across projects so team members can switch between projects without confusion.\n</Card>\n\n<Card title=\"Archive completed work\" icon=\"box-archive\">\n  Once issues are done, you can hide the Done column or create an Archive status to keep your board clean.\n</Card>\n</CardGroup>\n\n## Related Documentation\n\n- [Issues](/cloud/issues) - Creating and managing issues\n- [Kanban Board](/cloud/kanban-board) - Using the board interface\n- [Customising Your Board](/cloud/customisation) - Configuring columns and display settings\n- [Remote Projects Settings](/settings/remote-projects) - Managing projects from Settings\n"
  },
  {
    "path": "docs/cloud/team-members.mdx",
    "content": "---\ntitle: \"Team Members\"\ndescription: \"Invite colleagues and manage team access to your organisation\"\n---\n\nCollaborate with your team by inviting members to your organisation. Members can view projects, work on tasks, and receive real-time updates.\n\n## Inviting Team Members\n\nOnly organisation **Admins** can invite new members.\n\n<Steps>\n<Step title=\"Go to Organisation Settings\">\n  Click your **profile icon** in the bottom of the left sidebar, then click the **gear icon** (<Icon icon=\"gear\" />) next to your organisation name.\n</Step>\n\n<Step title=\"Click Invite Member\">\n  Click the **Invite Member** button in the top-right corner.\n</Step>\n\n<Step title=\"Enter their email\">\n  Enter the email address of the person you want to invite.\n\n\n  <Info>\n  Use the email they'll sign in with (their GitHub or Google email). If you're unsure, ask them which email is associated with their GitHub/Google account.\n  </Info>\n</Step>\n\n<Step title=\"Select a role\">\n  Choose their role:\n  - **Member** - Can work on projects but can't manage organisation settings\n  - **Admin** - Full access including member management\n\n  <Tip>\n  Start with **Member** role. You can always promote them to Admin later if needed.\n  </Tip>\n</Step>\n\n<Step title=\"Send invitation\">\n  Click **Send Invitation**. They'll receive an email with instructions to join.\n\n  <Frame>\n    <img src=\"/images/cloud/invite-member-dialog.png\" alt=\"Invite Member dialog with email and role fields\" />\n  </Frame>\n</Step>\n</Steps>\n\n### What Happens After Inviting\n\n1. The invitee receives an email with an invitation link\n2. They click the link and sign in (or create an account)\n3. They're automatically added to your organisation\n4. They appear in your members list\n\n\n<Warning>\n**Invitation links expire after 7 days.** If the invitee doesn't accept in time, you'll need to send a new invitation.\n</Warning>\n\n## Viewing Pending Invitations\n\nPending invitations are shown in Organisation Settings above the Members list. Only Admins can see pending invitations.\n\nEach pending invitation shows:\n- **Email address** - Who was invited\n- **Expiry** - Invitations expire after 7 days\n- **Revoke button** - Cancel the invitation if needed\n\n## Managing Members\n\n### Viewing Members\n\nAll current members are listed in Organisation Settings under the **Members** section.\n\n<Frame>\n  <img src=\"/images/cloud/members-section.png\" alt=\"Members section showing member list with Invite Member button\" />\n</Frame>\n\nEach member shows:\n- **Name** - Their display name and avatar from GitHub/Google\n- **Role** - Admin or Member\n\n### Changing a Member's Role\n\nTo promote a member to Admin or demote an Admin to Member:\n\n<Steps>\n<Step title=\"Find the member\">\n  Open Organisation Settings and locate the person in the Members list.\n</Step>\n\n<Step title=\"Click the role dropdown\">\n  Click on their current role badge to open the dropdown.\n</Step>\n\n<Step title=\"Select the new role\">\n  Choose **Admin** or **Member**. The change takes effect immediately.\n</Step>\n</Steps>\n\n<Warning>\n**You cannot demote yourself.** If you're the only admin and want to demote yourself, first promote another member to admin.\n</Warning>\n\n### Removing a Member\n\nTo remove someone from your organisation:\n\n<Steps>\n<Step title=\"Find the member\">\n  Open Organisation Settings and locate the person in the Members list.\n</Step>\n\n<Step title=\"Click Remove\">\n  Click the **Remove** button next to their name.\n</Step>\n\n<Step title=\"Confirm removal\">\n  Confirm the removal when prompted.\n</Step>\n</Steps>\n\n<Info>\n**What happens when you remove someone:**\n- They immediately lose access to the organisation\n- They cannot see any projects or data\n- Their past activity (comments, task updates) remains but shows their name\n- They can be re-invited later if needed\n</Info>\n\n## Member Roles Explained\n\n### Admin Role\n\nAdmins have full control over the organisation:\n\n| Capability | Description |\n|------------|-------------|\n| ✅ All project access | View, create, edit all projects |\n| ✅ Task management | Create, edit, delete tasks |\n| ✅ Invite members | Send invitations to new members |\n| ✅ Remove members | Remove people from the organisation |\n| ✅ Change roles | Promote members to admin or demote |\n| ✅ Organisation settings | Edit name, configure settings |\n| ✅ Delete organisation | Permanently delete the organisation |\n\n### Member Role\n\nMembers can work on projects but cannot manage the organisation:\n\n| Capability | Description |\n|------------|-------------|\n| ✅ All project access | View, create, edit all projects |\n| ✅ Task management | Create, edit, delete tasks |\n| ❌ Invite members | Cannot send invitations |\n| ❌ Remove members | Cannot remove people |\n| ❌ Change roles | Cannot change anyone's role |\n| ❌ Organisation settings | Cannot edit organisation settings |\n| ❌ Delete organisation | Cannot delete the organisation |\n\n<Info>\nBoth roles have **equal access to projects and tasks**. The difference is only in organisation management capabilities.\n</Info>\n\n## Transferring Ownership\n\nIf you need to transfer full ownership of an organisation (e.g., you're leaving the company):\n\n<Steps>\n<Step title=\"Promote the new owner to Admin\">\n  Ensure the person taking over has the Admin role.\n</Step>\n\n<Step title=\"(Optional) Demote yourself\">\n  If you're staying as a regular member, another admin can demote you.\n</Step>\n\n<Step title=\"Leave the organisation\">\n  If you're leaving entirely, you can now leave the organisation from settings.\n</Step>\n</Steps>\n\n<Warning>\n**There must always be at least one Admin.** The system won't allow the last admin to be demoted or removed.\n</Warning>\n\n## Best Practices\n\n<CardGroup cols={2}>\n<Card title=\"Least privilege\" icon=\"shield\">\n  Give people Member role by default. Only promote to Admin those who need to manage the organisation.\n</Card>\n\n<Card title=\"Multiple admins\" icon=\"users\">\n  Have at least two Admins. If one is unavailable, the other can still manage the organisation.\n</Card>\n\n<Card title=\"Prompt removal\" icon=\"user-minus\">\n  Remove members promptly when they leave your team or company to maintain security.\n</Card>\n\n<Card title=\"Verify emails\" icon=\"envelope\">\n  Before inviting, confirm you have the correct email - the one associated with their GitHub/Google account.\n</Card>\n</CardGroup>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"Invitee didn't receive the email\">\n**Problem:** The person you invited says they didn't get an email.\n\n**Solutions:**\n1. Ask them to check their spam/junk folder\n2. Verify you entered the correct email address\n3. Revoke the old invitation and send a new one\n4. Have them check if emails from your domain are blocked\n</Accordion>\n\n<Accordion title=\"Invitation link doesn't work\">\n**Problem:** Clicking the invitation link shows an error.\n\n**Possible causes:**\n- The invitation expired (after 7 days)\n- The invitation was cancelled\n- The link was copied incorrectly\n\n**Solution:**\nSend a new invitation from the Members settings page.\n</Accordion>\n\n<Accordion title=\"Member can't see projects\">\n**Problem:** A new member says they can't see any projects.\n\n**Possible causes:**\n- They signed in with a different account than the one invited\n- There's a sync delay\n\n**Solutions:**\n1. Ask them to refresh the page\n2. Verify they're signed in with the correct account (check the email in their profile)\n3. Check the members list to confirm they appear\n</Accordion>\n\n<Accordion title=\"Can't invite more members\">\n**Problem:** The invite button is disabled or shows an error.\n\n**Possible causes:**\n- You're not an Admin\n- You've reached a plan limit (if applicable)\n\n**Solutions:**\n1. Check your role - only Admins can invite\n2. Check if there are billing/plan limitations\n</Accordion>\n\n<Accordion title=\"Removed member still has access\">\n**Problem:** Someone you removed says they can still access the organisation.\n\n**Possible causes:**\n- Browser cache\n- They have a different account that's still a member\n\n**Solutions:**\n1. Ask them to clear their browser cache and refresh\n2. Check if they might be signed in with a different account\n3. Verify they're actually removed in your members list\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Organisations](/cloud/organizations) - Creating and managing organisations\n- [Authentication](/cloud/authentication) - How sign-in works\n"
  },
  {
    "path": "docs/cloud/troubleshooting.mdx",
    "content": "---\ntitle: \"Troubleshooting\"\ndescription: \"Solutions for common issues with Vibe Kanban Cloud\"\n---\n\nThis page covers common issues you might encounter with Vibe Kanban Cloud and how to resolve them.\n\n## Sign-In Issues\n\n<AccordionGroup>\n<Accordion title=\"I can't sign in\">\n**Possible causes:**\n- Pop-up blocker preventing the OAuth window\n- Network connectivity issues\n- Browser extensions interfering\n\n**Solutions:**\n1. Disable pop-up blocker for localhost\n2. Check your internet connection\n3. Try disabling browser extensions temporarily\n4. Try a different browser\n5. Clear your browser cache and cookies\n</Accordion>\n\n<Accordion title=\"OAuth window doesn't open\">\n**Possible causes:**\n- Pop-up blocker is active\n- Browser security settings\n\n**Solutions:**\n1. Look for a blocked pop-up notification in your browser's address bar\n2. Allow pop-ups for `localhost`\n3. Try right-clicking the sign-in button and selecting \"Open in new tab\"\n</Accordion>\n\n<Accordion title=\"'Access denied' error from GitHub/Google\">\n**Possible causes:**\n- You clicked \"Deny\" instead of \"Authorize\"\n- Your organisation has app restrictions (GitHub)\n\n**Solutions:**\n1. Try again and click **Authorize** or **Allow**\n2. If using a GitHub organisation account, ask your admin to approve the app\n</Accordion>\n\n<Accordion title=\"Signed in as the wrong account\">\n**Possible causes:**\n- Your browser is logged into a different GitHub/Google account\n\n**Solutions:**\n1. Sign out of Vibe Kanban\n2. Go to [github.com](https://github.com) or [google.com](https://google.com) and sign out\n3. Sign in with the correct account\n4. Return to Vibe Kanban and sign in again\n</Accordion>\n\n<Accordion title=\"Session keeps expiring\">\n**Possible causes:**\n- Network issues preventing token refresh\n- Browser blocking cookies\n\n**Solutions:**\n1. Check your internet connection\n2. Ensure cookies are enabled for localhost\n3. Try signing out and back in\n</Accordion>\n</AccordionGroup>\n\n## Organisation Issues\n\n<AccordionGroup>\n<Accordion title=\"I can't create an organisation\">\n**Possible causes:**\n- Network connectivity issues\n- You're not signed in\n\n**Solutions:**\n1. Ensure you're signed in (check for your avatar in the corner)\n2. Check your internet connection\n3. Try refreshing the page\n</Accordion>\n\n<Accordion title=\"I can't see an organisation I was invited to\">\n**Possible causes:**\n- You haven't accepted the invitation yet\n- You signed in with a different email\n- The invitation expired\n\n**Solutions:**\n1. Check your email for an invitation link and click it\n2. Verify you're signed in with the email that was invited\n3. Ask the organisation admin to resend the invitation\n</Accordion>\n\n<Accordion title=\"I can't leave an organisation\">\n**Possible causes:**\n- You're the only admin\n\n**Solutions:**\n1. Promote another member to admin first\n2. Then you can leave\n3. Or delete the organisation if no one else needs it\n</Accordion>\n</AccordionGroup>\n\n## Team Member Issues\n\n<AccordionGroup>\n<Accordion title=\"Invitation email not received\">\n**Possible causes:**\n- Email went to spam/junk folder\n- Wrong email address entered\n- Email delivery delay\n\n**Solutions:**\n1. Check spam/junk folder\n2. Verify the email address is correct in the pending invitations list\n3. Resend the invitation\n4. Try a different email address\n</Accordion>\n\n<Accordion title=\"Invitation link doesn't work\">\n**Possible causes:**\n- Invitation expired (after 7 days)\n- Invitation was cancelled\n- Link was copied incorrectly\n\n**Solutions:**\n1. Ask for a new invitation to be sent\n2. Make sure you're clicking the full link from the email\n</Accordion>\n\n<Accordion title=\"New member can't see projects\">\n**Possible causes:**\n- They signed in with a different email\n- Browser cache issue\n\n**Solutions:**\n1. Verify they're signed in with the invited email (check their profile)\n2. Have them refresh the page\n3. Have them sign out and back in\n</Accordion>\n</AccordionGroup>\n\n## Project and Board Issues\n\n<AccordionGroup>\n<Accordion title=\"Projects not loading\">\n**Possible causes:**\n- Network connectivity issues\n- Sync delay\n\n**Solutions:**\n1. Check your internet connection\n2. Refresh the page\n3. Sign out and back in\n</Accordion>\n\n<Accordion title=\"Changes not syncing\">\n**Possible causes:**\n- Internet connection lost\n- Temporary sync issue\n\n**Solutions:**\n1. Check your internet connection\n2. Refresh the page - your changes should still be there\n3. If changes were lost, you may need to re-enter them\n</Accordion>\n\n<Accordion title=\"I can't delete a status column\">\n**Possible causes:**\n- The column has issues in it\n\n**Solutions:**\n1. Move all issues to another column first\n2. Check for issues in hidden views (use \"All\" status tab)\n3. Then delete the empty column\n</Accordion>\n\n<Accordion title=\"Drag and drop not working\">\n**Possible causes:**\n- Sort mode is not set to \"Manual\"\n- Browser issue\n\n**Solutions:**\n1. Check the sort dropdown - select \"Manual\" to enable drag and drop\n2. Try refreshing the page\n3. Try a different browser\n</Accordion>\n</AccordionGroup>\n\n## Issue Problems\n\n<AccordionGroup>\n<Accordion title=\"Can't find an issue\">\n**Possible causes:**\n- Active filters hiding the issue\n- Issue is in a hidden column\n\n**Solutions:**\n1. Click **Clear All** in the filter bar to reset filters\n2. Use the \"All\" status tab to see issues in hidden columns\n3. Try searching for the issue ID or title\n</Accordion>\n\n<Accordion title=\"Changes to an issue aren't saving\">\n**Possible causes:**\n- Network issue\n- You closed the panel too quickly\n\n**Solutions:**\n1. Wait a moment after editing before closing the panel (auto-save has a short delay)\n2. Check your internet connection\n3. Refresh and check if the change was saved\n</Accordion>\n\n<Accordion title=\"Can't assign someone to an issue\">\n**Possible causes:**\n- They're not a member of the organisation\n\n**Solutions:**\n1. Invite them to the organisation first\n2. Once they accept, they'll appear in the assignee list\n</Accordion>\n</AccordionGroup>\n\n## GitHub Integration Issues\n\n<AccordionGroup>\n<Accordion title=\"GitHub integration not working\">\n**Possible causes:**\n- GitHub App not installed\n- Repository not connected\n\n**Solutions:**\n1. Go to Organisation Settings → GitHub Integration\n2. Install the GitHub App if not already installed\n3. Ensure the repository has access granted\n</Accordion>\n\n<Accordion title=\"Pull requests not linking to issues\">\n**Possible causes:**\n- Branch name doesn't include issue ID\n- PR was created before integration was set up\n\n**Solutions:**\n1. Include the issue ID in your branch name (e.g., `TASK-123-feature`)\n2. Or include the issue ID in the PR title\n3. New PRs should link automatically\n</Accordion>\n\n<Accordion title=\"Code review not available\">\n**Possible causes:**\n- Code review not enabled for the repository\n\n**Solutions:**\n1. Go to Organisation Settings → GitHub Integration\n2. Enable \"Code Review\" for the repository\n</Accordion>\n</AccordionGroup>\n\n## General Issues\n\n<AccordionGroup>\n<Accordion title=\"Page won't load\">\n**Solutions:**\n1. Refresh the page\n2. Clear browser cache\n3. Try a different browser\n4. Restart Vibe Kanban (`npx vibe-kanban`)\n</Accordion>\n\n<Accordion title=\"Everything is slow\">\n**Possible causes:**\n- Network connectivity issues\n- Many browser tabs open\n\n**Solutions:**\n1. Check your internet connection\n2. Close unnecessary browser tabs\n3. Refresh the page\n</Accordion>\n\n<Accordion title=\"Data seems out of date\">\n**Solutions:**\n1. Refresh the page to force a sync\n2. Check your internet connection\n3. Sign out and back in\n</Accordion>\n</AccordionGroup>\n\n## Getting Help\n\nIf you can't resolve an issue:\n\n1. **Check existing issues:** [github.com/BloopAI/vibe-kanban/issues](https://github.com/BloopAI/vibe-kanban/issues)\n\n2. **Report a bug:** Create a new issue with:\n   - What you were trying to do\n   - What happened instead\n   - Your browser and operating system\n\n<Card title=\"Report an Issue\" icon=\"github\" href=\"https://github.com/BloopAI/vibe-kanban/issues/new\">\n  Open a new issue on GitHub for bugs or feature requests\n</Card>\n"
  },
  {
    "path": "docs/configuration-customisation/agent-configurations.mdx",
    "content": "---\ntitle: \"Agent Profiles & Configuration\"\ndescription: \"Configure and customise coding agent variants with different settings for planning, models, and sandbox permissions\"\n---\n\nAgent profiles let you define multiple named variants for each supported coding agent. Variants capture configuration differences like planning mode, model choice, and sandbox permissions that you can quickly select when creating attempts.\n\n<Info>\nAgent profiles are used throughout Vibe Kanban wherever agents run: onboarding, default settings, attempt creation, and follow-ups.\n</Info>\n\n## Configuration Access\n\nYou can configure agent profiles in two ways through Settings → Agents:\n\n<Tabs>\n<Tab title=\"Form Editor\">\n  Use the guided interface with form fields for each agent setting.\n\n  <Frame>\n  <img src=\"/images/coding-agent-configurations.png\" alt=\"Agent configuration form editor interface\" />\n  </Frame>\n</Tab>\n\n<Tab title=\"JSON Editor\">\n  Edit the underlying `profiles.json` file directly for advanced configurations.\n\n  <Frame>\n  <img src=\"/images/coding-agent-configurations-json.png\" alt=\"JSON editor for agent configurations\" />\n  </Frame>\n</Tab>\n</Tabs>\n\n<Note>\nThe configuration page displays the exact file path where your settings are stored. Vibe Kanban saves only your overrides whilst preserving built-in defaults.\n</Note>\n\n## Configuration Structure\n\nThe profiles configuration uses a JSON structure with an `executors` object containing agent variants:\n\n```json profiles.json\n{\n  \"executors\": {\n    \"CLAUDE_CODE\": {\n      \"DEFAULT\": { \"CLAUDE_CODE\": { \"dangerously_skip_permissions\": true } },\n      \"PLAN\":    { \"CLAUDE_CODE\": { \"plan\": true } },\n      \"ROUTER\":  { \"CLAUDE_CODE\": { \"claude_code_router\": true, \"dangerously_skip_permissions\": true } }\n    },\n    \"GEMINI\": {\n      \"DEFAULT\": { \"GEMINI\": { \"model\": \"default\", \"yolo\": true } },\n      \"FLASH\":   { \"GEMINI\": { \"model\": \"flash\",   \"yolo\": true } }\n    },\n    \"CODEX\": {\n      \"DEFAULT\": { \"CODEX\": { \"sandbox\": \"danger-full-access\" } },\n      \"HIGH\":    { \"CODEX\": { \"sandbox\": \"danger-full-access\", \"model_reasoning_effort\": \"high\" } }\n    }\n  }\n}\n```\n\n<AccordionGroup>\n<Accordion title=\"Structure Rules\">\n  - **Variant names**: Case-insensitive and normalised to SCREAMING_SNAKE_CASE\n  - **DEFAULT variant**: Reserved and always present for each agent\n  - **Custom variants**: Add new variants like `PLAN`, `FLASH`, `HIGH` as needed\n  - **Built-in protection**: Cannot remove built-in executors, but can override values\n</Accordion>\n\n<Accordion title=\"Configuration Inheritance\">\n  - Your custom settings override built-in defaults\n  - Built-in configurations remain available as fallbacks\n  - Each variant contains a complete configuration object for its agent\n</Accordion>\n</AccordionGroup>\n\n## Agent Configuration Options\n\n<Tabs>\n<Tab title=\"CLAUDE_CODE\">\n  <ParamField path=\"plan\" type=\"boolean\">\n  Enable planning mode for complex tasks\n  </ParamField>\n\n  <ParamField path=\"claude_code_router\" type=\"boolean\">\n  Route requests across multiple Claude Code instances\n  </ParamField>\n\n  <ParamField path=\"dangerously_skip_permissions\" type=\"boolean\">\n  Skip permission prompts (use with caution)\n  </ParamField>\n\n  [View full CLI reference →](https://docs.anthropic.com/en/docs/claude-code/cli-reference#cli-flags)\n</Tab>\n\n<Tab title=\"GEMINI\">\n  <ParamField path=\"model\" type=\"string\">\n  Choose model variant: `\"default\"` or `\"flash\"`\n  </ParamField>\n\n  <ParamField path=\"yolo\" type=\"boolean\">\n  Run without confirmations\n  </ParamField>\n\n  [View full CLI reference →](https://google-gemini.github.io/gemini-cli/)\n</Tab>\n\n<Tab title=\"AMP\">\n  <ParamField path=\"dangerously_allow_all\" type=\"boolean\">\n  Allow all actions without restrictions (unsafe)\n  </ParamField>\n\n  [View full documentation →](https://ampcode.com/manual#cli)\n</Tab>\n\n<Tab title=\"CODEX\">\n  <ParamField path=\"sandbox\" type=\"string\">\n  Execution environment: `\"read-only\"`, `\"workspace-write\"`, or `\"danger-full-access\"`\n  </ParamField>\n\n  <ParamField path=\"approval\" type=\"string\">\n  Approval level: `\"untrusted\"`, `\"on-failure\"`, `\"on-request\"`, or `\"never\"`\n  </ParamField>\n\n  <ParamField path=\"model_reasoning_effort\" type=\"string\">\n  Reasoning depth: `\"low\"`, `\"medium\"`, or `\"high\"`\n  </ParamField>\n\n  <ParamField path=\"model_reasoning_summary\" type=\"string\">\n  Summary style: `\"auto\"`, `\"concise\"`, `\"detailed\"`, or `\"none\"`\n  </ParamField>\n\n  [View full documentation →](https://github.com/openai/codex)\n</Tab>\n\n<Tab title=\"CURSOR\">\n  <ParamField path=\"force\" type=\"boolean\">\n  Force execution without confirmation\n  </ParamField>\n\n  <ParamField path=\"model\" type=\"string\">\n  Specify model to use\n  </ParamField>\n\n  [View full CLI reference →](https://docs.cursor.com/en/cli/reference/parameters)\n</Tab>\n\n<Tab title=\"OPENCODE\">\n  <ParamField path=\"model\" type=\"string\">\n  Specify model to use\n  </ParamField>\n\n  <ParamField path=\"agent\" type=\"string\">\n  Choose agent type\n  </ParamField>\n\n  [View full documentation →](https://opencode.ai/docs/cli/#flags-1)\n</Tab>\n\n<Tab title=\"QWEN_CODE\">\n  <ParamField path=\"yolo\" type=\"boolean\">\n  Run without confirmations\n  </ParamField>\n\n  [View full documentation →](https://qwenlm.github.io/qwen-code-docs/en/cli/index)\n</Tab>\n\n<Tab title=\"DROID\">\n  <ParamField path=\"autonomy\" type=\"string\">\n  Permission level: `\"normal\"`, `\"low\"`, `\"medium\"`, `\"high\"`, or `\"skip-permissions-unsafe\"`\n  </ParamField>\n\n  <ParamField path=\"model\" type=\"string\">\n  Specify which model to use\n  </ParamField>\n\n  <ParamField path=\"reasoning_effort\" type=\"string\">\n  Reasoning depth: `\"off\"`, `\"low\"`, `\"medium\"`, or `\"high\"`\n  </ParamField>\n\n  [View full documentation →](https://docs.factory.ai/factory-cli/getting-started/overview)\n</Tab>\n</Tabs>\n\n### Universal Options\n\nThese options work across multiple agent types:\n\n<ParamField path=\"append_prompt\" type=\"string | null\">\nText appended to the system prompt\n</ParamField>\n\n<ParamField path=\"base_command_override\" type=\"string | null\">\nOverride the underlying CLI command\n</ParamField>\n\n<ParamField path=\"additional_params\" type=\"string[] | null\">\nAdditional CLI arguments to pass\n</ParamField>\n\n<Warning>\nOptions prefixed with \"dangerously_\" bypass safety confirmations and can perform destructive actions. Use with extreme caution.\n</Warning>\n\n## Using Agent Configurations\n\n<CardGroup cols={2}>\n<Card title=\"Default Configuration\" icon=\"gear\">\n  Set your default agent and variant in **Settings → General → Default Agent Configuration** for consistent behaviour across all attempts.\n</Card>\n\n<Card title=\"Per-Attempt Selection\" icon=\"rocket\">\n  Override defaults when creating attempts by selecting different agent/variant combinations in the attempt dialogue.\n</Card>\n</CardGroup>\n\n## Related Configuration\n\n<Note>\nMCP (Model Context Protocol) servers are configured separately under **Settings → MCP Servers** but work alongside agent profiles to extend functionality.\n</Note>\n\n<CardGroup cols={2}>\n<Card title=\"MCP Server Configuration\" icon=\"server\" href=\"/integrations/mcp-server-configuration\">\n  Configure MCP servers within Vibe Kanban for your coding agents\n</Card>\n\n<Card title=\"Vibe Kanban MCP Server\" icon=\"plug\" href=\"/integrations/vibe-kanban-mcp-server\">\n  Connect external MCP clients to Vibe Kanban's MCP server\n</Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/configuration-customisation/creating-task-tags.mdx",
    "content": "---\ntitle: \"Creating Task Tags\"\ndescription: \"Create reusable text snippets that can be quickly inserted into task descriptions using @mentions. Task tags are available globally across all projects.\"\n---\n\n## What are task tags?\n\nTask tags are reusable text snippets that you can quickly insert into task descriptions by typing `@` followed by the tag name. When you select a tag, its content is automatically inserted at your cursor position.\n\n<Tip>\nTask tags use snake_case naming (no spaces allowed). For example: `bug_report`, `feature_request`, or `code_review_checklist`.\n</Tip>\n\n## Managing task tags\n\nAccess task tags from **Settings → General → Task Tags**. Tags are available globally across all projects in your workspace.\n\n<Frame>\n<img src=\"/images/screenshot-task-tags-manager.png\" alt=\"Task tags management interface showing the tag list with names and content\" />\n</Frame>\n\n<Steps>\n<Step title=\"Create a new tag\">\n  Click **Add Tag** to create a new task tag.\n\n  <Frame>\n  <img src=\"/images/screenshot-create-task-tag.png\" alt=\"Create task tag dialogue showing tag name and content fields\" />\n  </Frame>\n\n  - **Tag name**: Use snake_case without spaces (e.g., `acceptance_criteria`)\n  - **Content**: The text that will be inserted when the tag is used\n</Step>\n\n<Step title=\"Edit existing tags\">\n  Click the edit icon (✏️) next to any tag to modify its name or content.\n</Step>\n\n<Step title=\"Remove unwanted tags\">\n  Click the delete icon (🗑️) to remove tags you no longer need.\n\n  <Warning>\n  Deleting a tag does not affect existing tasks that already have the tag's content inserted.\n  </Warning>\n</Step>\n</Steps>\n\n## Using task tags\n\nInsert task tags into task descriptions and follow-up messages using @mention autocomplete.\n\n<Steps>\n<Step title=\"Trigger autocomplete\">\n  When creating or editing a task description, type `@` to trigger the autocomplete dropdown.\n\n  <Frame>\n  <img src=\"/images/screenshot-task-tag-autocomplete.png\" alt=\"Autocomplete dropdown showing available tags after typing @ symbol\" />\n  </Frame>\n</Step>\n\n<Step title=\"Search and select\">\n  Continue typing to filter tags by name, then:\n  - Click on a tag to select it\n  - Use arrow keys to navigate and press Enter to select\n  - Press Escape to close the dropdown\n\n  <Check>\n  The tag's content is automatically inserted at your cursor position, replacing the @query.\n  </Check>\n</Step>\n</Steps>\n\n## Common use cases\n\n<AccordionGroup>\n<Accordion title=\"Bug report templates\">\nCreate a `bug_report` tag with standardised bug reporting fields:\n\n```\n**Description:**\n\n**Steps to reproduce:**\n1. \n2. \n3. \n\n**Expected behaviour:**\n\n**Actual behaviour:**\n\n**Environment:**\n```\n</Accordion>\n\n<Accordion title=\"Acceptance criteria checklists\">\nCreate an `acceptance_criteria` tag for feature requirements:\n\n```\n**Acceptance criteria:**\n- [ ] Functionality works as specified\n- [ ] Unit tests added\n- [ ] Documentation updated\n- [ ] Accessibility requirements met\n- [ ] Performance benchmarks passed\n```\n</Accordion>\n\n<Accordion title=\"Code review guidelines\">\nCreate a `code_review` tag with review checklist items:\n\n```\n**Code review checklist:**\n- [ ] Code follows project conventions\n- [ ] Tests cover edge cases\n- [ ] No security vulnerabilities introduced\n- [ ] Performance impact assessed\n- [ ] Documentation is clear\n```\n</Accordion>\n</AccordionGroup>\n\n<Tip>\nTask tags work in all text fields that support the @mention feature, including task descriptions and follow-up messages, making it easy to maintain consistency across your tasks.\n</Tip>\n"
  },
  {
    "path": "docs/configuration-customisation/global-settings.mdx",
    "content": "---\ntitle: \"Global Settings\"\ndescription: \"Configure application-wide settings including themes, agents, and more\"\nsidebarTitle: \"Settings\"\n---\n\nYou can configure application-wide settings via the **Settings** page. To access it, click the ⚙️ icon in the sidebar or select \"Settings\" from the top-right menu.\n\n<Frame>\n<img src=\"/images/vk-settings.png\" alt=\"Vibe Kanban global settings page showing theme options, agent configuration, and settings\" />\n</Frame>\n\n## Themes\n\nSwitch between light and dark themes to suit your preference.\n\n## Default Agent Configuration\n\nChoose the default agent and variant for new task attempts. This profile is pre-selected when creating new task attempts and follow-ups.\n\n1. **Select an agent** (e.g., Claude Code, Gemini CLI, Codex)\n2. **Choose a variant** if available (e.g., Default, Plan, Router)\n\n<Tip>\nYou can override the default agent configuration per attempt in the create attempt dialog.\n</Tip>\n\n## Editor Integration\n\nConfigure integration with your preferred code editor for a seamless development workflow.\n\n### Selecting Your Editor\n\nChoose from various supported editors:\n- **VS Code** - Microsoft's popular code editor\n- **Cursor** - VSCode fork with AI-native features\n- **Windsurf** - VSCode fork optimised for collaborative development\n- **Antigravity** - Google's AI-native code editor\n- **Neovim**, **Emacs**, **Sublime Text** - Other popular editors\n- **Custom** - Use a custom shell command\n\n### Remote SSH Configuration\n\n<Frame>\n<img src=\"/images/vk-editor-ssh.png\" alt=\"Vibe Kanban settings editor section showing ssh configuration options.\" />\n</Frame>\n\nWhen running Vibe Kanban on a remote server (e.g., accessed via Cloudflare tunnel, ngrok, or as a systemctl service), you can configure VSCode-based editors to open projects via SSH instead of assuming localhost.\n\nThis feature is available for **VS Code**, **Cursor**, and **Windsurf** editors.\n\n#### When to Use Remote SSH\n\nEnable remote SSH configuration when:\n- Vibe Kanban runs on a remote server (VPS, cloud instance, etc.)\n- You access the web UI through a tunnel or reverse proxy\n- Your code files are on a different machine than your browser\n- You want your local editor to connect to the remote server via SSH\n\n#### Configuration Fields\n\n1. **Remote SSH Host** (Optional)\n   - The hostname or IP address of your remote server\n   - Examples: `example.com`, `192.168.1.100`, `my-server`\n   - Must be accessible via SSH from your local machine\n\n2. **Remote SSH User** (Optional)\n   - The SSH username for connecting to the remote server\n   - If not specified, SSH will use your default user or SSH config\n\n#### How It Works\n\nWhen remote SSH is configured, clicking \"Open in VSCode\" (or Cursor/Windsurf):\n1. Generates a special protocol URL like: `vscode://vscode-remote/ssh-remote+user@host/path/to/project`\n2. Opens in your default browser, which launches your local editor\n3. Your editor connects to the remote server via SSH\n4. The project or task worktree opens in the remote context\n\nThis works for both project-level and task worktree opening.\n\n#### Prerequisites\n\n- SSH access configured between your local machine and remote server\n- SSH keys or credentials set up (no password prompts)\n- VSCode Remote-SSH extension installed (or equivalent for Cursor/Windsurf)\n- The remote server path must be accessible via SSH\n\n<Tip>\nTest your SSH connection first with `ssh user@host` to ensure it works without prompting for passwords.\n</Tip>\n\n## Git Configuration\n\nConfigure git branch naming preferences.\n\n### Branch Prefix\n\nSet a prefix for auto-generated branch names (e.g., `vk` results in `vk/task-name`). Leave empty for no prefix.\n\n## Notifications\n\nToggle sound effects and push notifications to stay informed about task status changes.\n\n## Telemetry\n\nEnable or disable telemetry data collection to help improve Vibe Kanban.\n\n## Task Tags\n\nManage global task tags to accelerate task creation across all projects. Task tags allow you to define reusable text snippets that can be inserted into task descriptions using @mentions.\n\n<Card title=\"Learn more about task tags\" icon=\"tag\" href=\"/configuration-customisation/creating-task-tags\">\n  Complete guide to creating and managing task tags\n</Card>\n\n## Agent Settings (Profiles & Variants)\n\nDefine and customise agent variants under **Settings → Agents**. Variants let you maintain multiple configurations for the same agent (for example, a Claude Code \"PLAN\" variant).\n\n<Card title=\"Agent Profiles & Variants\" icon=\"robot\" href=\"/configuration-customisation/agent-configurations\">\n  Detailed guide with examples for configuring agent variants\n</Card>\n\n## Safety & Disclaimers\n\nManage acknowledgments and reset options for onboarding, safety disclaimers, and telemetry notices.\n\n- **Onboarding**: Reset the onboarding process to rerun the initial setup.\n- **Safety Disclaimer**: Reset or review the safety disclaimer prompt.\n- **Telemetry Notice**: Reset or review the telemetry data collection acknowledgment.\n"
  },
  {
    "path": "docs/configuration-customisation/keyboard-shortcuts.mdx",
    "content": "---\ntitle: \"Keyboard Shortcuts\"\ndescription: \"Keyboard shortcuts available in the Workspaces UI\"\n---\n\nThe Workspaces UI provides comprehensive keyboard navigation through the **Keyboard Shortcuts** dialog.\n\n## Accessing Keyboard Shortcuts\n\n| Shortcut | Action |\n|----------|--------|\n| `⌘/Ctrl + /` | Open Keyboard Shortcuts dialog |\n\n<Info>\nSequential shortcuts require pressing the first key, then the second within 500ms.\n</Info>\n\n## Quick Actions\n\n| Shortcut | Action |\n|----------|--------|\n| `?` | Show keyboard shortcuts help |\n| `Esc` | Close/cancel |\n| `C` | Create new task |\n| `D` | Delete selected |\n| `/` | Focus search |\n\n## Modifiers\n\n| Shortcut | Action |\n|----------|--------|\n| `⌘/Ctrl + K` | Open command bar |\n| `⌘/Ctrl + E` | Format inline code |\n| `⌘/Ctrl + Enter` | Send message |\n\n## Navigation\n\n| Shortcut | Action |\n|----------|--------|\n| `J` | Move down |\n| `K` | Move up |\n| `H` | Move left |\n| `L` | Move right |\n\n## Issue Selection\n\nThese shortcuts are available on the kanban board and list view.\n\n| Shortcut | Action |\n|----------|--------|\n| `X` | Toggle current issue selection |\n| `Shift + J` / `Shift + ↓` | Extend selection down |\n| `Shift + K` / `Shift + ↑` | Extend selection up |\n| `⌘/Ctrl + A` | Select all visible issues |\n| `Esc` | Clear selection |\n\n## Go To (G ...)\n\n| Shortcut | Action |\n|----------|--------|\n| `G S` | Go to Settings |\n| `G N` | Go to New Workspace |\n\n## Workspace (W ...)\n\n| Shortcut | Action |\n|----------|--------|\n| `W D` | Duplicate workspace |\n| `W R` | Rename workspace |\n| `W P` | Pin/Unpin workspace |\n| `W A` | Archive workspace |\n| `W X` | Delete workspace |\n\n## View (V ...)\n\n| Shortcut | Action |\n|----------|--------|\n| `V C` | Toggle Changes panel |\n| `V L` | Toggle Logs panel |\n| `V P` | Toggle Preview panel |\n| `V S` | Toggle Left Sidebar |\n| `V H` | Toggle Chat panel |\n\n## Git (X ...)\n\nThese shortcuts are available when inside a workspace.\n\n| Shortcut | Action |\n|----------|--------|\n| `X P` | Create Pull Request |\n| `X M` | Merge branch |\n| `X R` | Rebase branch |\n| `X U` | Push changes |\n\n## Yank (Y ...)\n\nThese shortcuts are available when inside a workspace.\n\n| Shortcut | Action |\n|----------|--------|\n| `Y P` | Copy path |\n| `Y L` | Copy raw logs |\n\n## Toggle (T ...)\n\nThese shortcuts are available when inside a workspace.\n\n| Shortcut | Action |\n|----------|--------|\n| `T D` | Toggle dev server |\n| `T W` | Toggle line wrapping |\n\n## Run (R ...)\n\nThese shortcuts are available when inside a workspace.\n\n| Shortcut | Action |\n|----------|--------|\n| `R S` | Run setup script |\n| `R C` | Run cleanup script |\n"
  },
  {
    "path": "docs/core-features/completing-a-task.mdx",
    "content": "---\ntitle: \"Completing a Task\"\ndescription: \"Learn how to complete tasks by rebasing, merging, and managing pull requests directly from Vibe Kanban\"\nsidebarTitle: \"Completing a Task\"\n---\n\nWhen your task is finished, Vibe Kanban provides integrated git operations to keep your branch up-to-date and merge your work back into the base branch.\n\n## Git Operations Header\n\nAt the top of the diff view, you'll see important branch information and actions:\n\n<Frame>\n<img src=\"/images/vk-diff-header.png\" alt=\"Diff header showing branch information and action buttons\" />\n</Frame>\n\n**Branch Information:**\n- **Task branch**: The branch your task is working on\n- **Target branch**: The branch you'll merge into (with cog button to change it)\n- **Commits ahead**: Number of commits your branch has that aren't in the target\n- **Commits behind**: Number of commits in the target that you don't have (enables rebase button when >0)\n\n**Actions:**\n- **Merge**: Merge your changes into the target branch\n- **Create PR**: Create a pull request on GitHub\n- **Rebase**: Update your branch with the latest changes from the target branch\n\n## Rebase\n\nClick **Rebase** to update your branch with the latest changes from the target branch. This keeps your branch up-to-date and maintains a clean history.\n\nIf conflicts occur, see [Resolving Rebase Conflicts](/core-features/resolving-rebase-conflicts).\n\n## Merge\n\nClick **Merge** to integrate your completed work into the target branch. Your task will automatically move to the **Done** column. The branch remains until you manually delete it.\n\n<Tip>\nIf you're working with GitHub, consider creating a pull request instead of merging directly. This allows for team review and CI checks.\n</Tip>\n\n## Pull Request Management\n\n### Creating a Pull Request\n\nClick **Create PR** to create a pull request on GitHub. The title and description are auto-populated from your task details.\n\n<Frame>\n<img src=\"/images/vk-pr-open.png\" alt=\"Header showing disabled Push button after pull request creation\" />\n</Frame>\n\nAfter creating the PR, the button changes to **Push** (initially disabled until you make more changes).\n\n### Updating a Pull Request\n\nWhen you continue working after creating a PR, the **Push** button becomes enabled. Click it to push your latest changes to the pull request.\n\n<Frame>\n<img src=\"/images/vk-pr-with-push.png\" alt=\"Header showing enabled Push button with new changes ready\" />\n</Frame>\n\nWhen your PR is merged on GitHub, your task automatically moves to **Done**.\n\n## Related Documentation\n\n- [Resolving Rebase Conflicts](/core-features/resolving-rebase-conflicts) - Handle conflicts during rebasing\n- [GitHub Integration](/integrations/github-integration) - Set up GitHub CLI integration\n- [Creating Projects](/core-features/creating-projects) - Configure base branches and project settings\n"
  },
  {
    "path": "docs/core-features/creating-projects.mdx",
    "content": "---\ntitle: \"Creating Projects\"\ndescription: \"Learn how to create and configure projects in Vibe Kanban\"\n---\n\nBefore you can create tasks and execute coding agents, you must create a project.\n\n<Frame>\n<img src=\"/images/vk-create-project.png\" alt=\"Create project dialog showing options to create from existing git repository or blank project\" />\n</Frame>\n\n## Creating Your Project\nClick the **Create Project** button to choose from two options:\n- **From existing git repository**: Browse your file system and select from a list of git repositories sorted by recent activity\n- **Create blank project**: Generate a new git repository from scratch\nEach project represents a git repository. After creation, you can configure it with setup scripts, dev server scripts, and other settings.\n\n\n<Note>\nAfter creating a project, you need to press the settings button in the top right to configure project scripts and settings.\n</Note>\n\n## Project Settings\n\nOnce you've created a project, you can access the project settings by clicking the settings button in the top right corner. From here, you can configure various aspects of your project.\n\n### Setup Scripts\n\nSetup scripts will be run before the coding agent is executed. This is useful for installing dependencies, for example you might run `npm install` or `cargo build`. This will save you time as your agent won't need to figure out that these commands haven't already been run.\n\n<Note>\nEach time a coding agent is executed it runs in a [git worktree](https://git-scm.com/docs/git-worktree) which is unlikely to contain your dependencies, configs, .env etc.\n</Note>\n\n### Dev Server Scripts\n\nThe dev server script is run when you press the \"Start Dev Server\" button from the [Preview](/core-features/testing-your-application) section. It's useful for quickly reviewing work after a coding agent has run.\n\n### Cleanup Scripts\n\nCleanup scripts run after a coding agent finishes it's turn. You can use these to tidy up the workspace, remove temporary files, or perform any post-execution cleanup. For example, you might run `npm run format` to ensure your code is formatted correctly. Treat it like a git pre-commit hook.\n\n\n### Copy Files\n\nComma-separated list of files to copy from the original project directory to the worktree. These files will be copied after the worktree is created but before the setup script runs. Useful for environment-specific files like `.env`, configuration files, and local settings.\n\n<Warning>\nMake sure these files are gitignored or they could get committed!\n</Warning>\n\n\n"
  },
  {
    "path": "docs/core-features/creating-tasks.mdx",
    "content": "---\ntitle: \"Creating Tasks\"\ndescription: \"Learn how to create and manage tasks on your kanban board, including using templates, starting coding agents, and understanding task states\"\n---\n\n<Frame>\n<img src=\"/images/vk-task-dialog.png\" alt=\"Task creation interface showing Add Task button and form fields\" />\n</Frame>\n\nAfter creating a project, add tasks by clicking the **plus (+) icon** in the top right of your project kanban page, or by using the keyboard shortcut **`c`**.\n\nYou have two options when creating a task:\n\n- **Create Task**: Adds the task to your kanban board without starting a coding agent\n- **Create & Start**: Creates the task and immediately starts it with your default coding agent and current branch\n\n## Using Task Tags\n\n<Frame>\n<img src=\"/images/screenshot-task-tag-autocomplete.png\" alt=\"Task tag autocomplete dropdown showing available tags after typing @ symbol\" />\n</Frame>\n\nWhen adding a task, you can insert reusable text snippets using task tags:\n\n1. Type `@` in the task description or follow-up message\n2. Start typing the tag name to filter available tags\n3. Select a tag from the dropdown to insert its content\n\n<Note>\nTask tags save time by providing reusable text snippets for common task structures. Learn more in the [Task Tags](/configuration-customisation/creating-task-tags) guide.\n</Note>\n\n## Starting an Existing Task\n\n<Frame>\n<img src=\"/images/vk-new-task-attempt.png\" alt=\"Task attempt creation dialog showing agent profile and variant selection options\" />\n</Frame>\n\nWhen you open a task that hasn't been attempted yet, you'll see the task title, description, and a list of attempts showing \"no attempts yet\". Click the **plus (+) button** to create a task attempt and configure:\n\n- **Agent profile**: Choose from available agents (e.g., CLAUDE_CODE, GEMINI, CODEX). Your default configuration from Settings is pre-selected.\n- **Variant**: If your selected agent has variants, pick the appropriate one (e.g., DEFAULT, PLAN).\n- **Base branch**: Specify which branch the agent should work from. Your current branch is selected by default.\n\n<Tip>\nUse **Create & Start** to add the task and immediately create a task attempt with your default settings in one action.\n</Tip>\n\n<Note>\nTo monitor your task as it executes, see [Monitoring Task Execution](/core-features/monitoring-task-execution). To understand when you might need multiple attempts, see [New Task Attempts](/core-features/new-task-attempts).\n</Note>\n\n## Creating Tasks via MCP Clients\n\n<Warning>\nThis is not the typical method for creating tasks but can be valuable for bulk task creation, migrating from other systems, using an AI assistant with extra project context, or for coding agents that want to create new tasks.\n</Warning>\n\nTasks can also be created programmatically using coding agents or MCP (Model Context Protocol) clients such as Claude Desktop or Raycast. This approach is particularly useful for:\n\n- **Bulk task creation** based on existing data or project specifications\n- **Migration from other systems** like Linear, GitHub Issues, or Jira\n- **Automated task generation** from project plans or requirements documents\n\n<Tip>\nFor detailed setup instructions and examples, see the [Vibe Kanban MCP Server](/integrations/vibe-kanban-mcp-server) documentation.\n</Tip>\n\n### Example MCP Task Creation\n\nOnce configured with an MCP client, you can create multiple tasks from a project description:\n\n```\nI need to implement user authentication with:\n- Email/password registration\n- Login with session management  \n- Password reset functionality\n- Email verification\n- Protected route middleware\n\nPlease create individual tasks for each component.\n```\n\nThe MCP client will automatically generate structured tasks in your Vibe Kanban project based on this description.\n\n## Understanding Task Columns\n\nTasks begin in the \"To do\" column and move automatically based on their progress:\n\n| Action | Column |\n|--------|---------|\n| Task created | To do |\n| Task attempt started | In Progress |\n| Task attempt completed (success or failure) | In Review |\n| Task attempt merged | Done |\n| PR merged on GitHub | Done |\n\n<Info>\nYou can manually drag tasks between columns, but this won't trigger any functionality. Task movement is primarily driven by coding agent actions and GitHub integration (which polls every 60 seconds).\n</Info>\n"
  },
  {
    "path": "docs/core-features/monitoring-task-execution.mdx",
    "content": "---\ntitle: \"Monitoring Task Execution\"\ndescription: \"Learn how to monitor coding agent execution with real-time logs, approvals, and interactive controls\"\nsidebarTitle: \"Monitoring Task Execution\"\n---\n\nWhen you start a task, the main panel provides real-time visibility into everything happening during execution. Watch as agents think through problems, take actions, and respond to your feedback.\n\n<Frame>\n<img src=\"/images/vk-agent-log-main.png\" alt=\"Task execution showing real-time agent logs with actions and responses\" />\n</Frame>\n\n## What Happens During Execution\n\nWhen you start a task attempt, Vibe Kanban orchestrates several steps to create an isolated environment and execute your task:\n\n1. **Git Worktree Creation**: An isolated environment is created for this task attempt\n2. **Setup Scripts Run**: Any setup script defined in your project settings runs automatically\n3. **Agent Execution**: The coding agent processes your task using the title and description  \n4. **Real-time Monitoring**: Watch progress through streaming logs in the task interface\n5. **Follow-up Questions**: Continue the conversation after execution to refine results\n\n### Git Worktrees\n\nVibe Kanban uses Git worktrees to create isolated environments for each task attempt. These environments are ephemeral and automatically cleaned up after execution completes.\n\n<Info>\nWorktrees ensure task attempts don't interfere with each other or your main working directory.\n</Info>\n\n## Execution Flow\n\n### 1. Setup Script\n\nThe first log you'll see is your project's setup script running (if configured). This installs dependencies and prepares the environment before the agent starts working.\n\n### 2. Task Sent to Agent\n\nYour task title and description are sent to the agent. You'll see this as the initial message that kicks off the work.\n\n### 3. Real-Time Actions\n\nAs the agent works, each action appears in real-time:\n\n- **Reasoning**: The agent's thought process as it analyses your task\n- **Commands**: Shell commands being executed\n- **File operations**: Files being created, modified, or deleted\n- **Tool usage**: API calls, searches, and other tool invocations\n- **Responses**: Agent messages and status updates\n\n**Expandable actions**: Click on file change actions to expand them and see exactly which part of the file was modified.\n\n### 4. Action Approvals\n\n<Note>\nApprovals are currently supported for Codex, with Claude Code coming soon.\n</Note>\n\nWhen an agent takes an action that requires human approval, a row appears below the action with approve/deny buttons.\n\n<Frame>\n<img src=\"/images/vk-approval.png\" alt=\"Approval prompt showing tick and cross buttons to approve or deny an agent action\" />\n</Frame>\n\nClick the tick to approve or the cross to deny the action. The agent will proceed or adjust based on your decision.\n\n### 5. Cleanup Script\n\nAfter every agent turn, your cleanup script runs (if configured). This is useful for running linters, formatters, or other post-execution tasks.\n\n### 6. Commit Messages\n\nVibe Kanban generates commit messages based on the last message sent by the agent. These automated messages may not always be the most descriptive.\n\n<Tip>\nWhen merging to your base branch, use GitHub's \"squash & merge\" option to rewrite commits with a summary of what actually changed. Alternatively, ask your coding agent to clean up commits manually.\n</Tip>\n\n## Interacting During Execution\n\n### Keyboard Shortcuts\n\n- **Cmd/Ctrl + Enter**: Send a message to the agent\n- **Enter**: Create a new line in the message field\n- **Shift + Tab**: Switch agent profile (e.g., from PLAN to DEFAULT)\n\n### Viewing Task Details\n\nClick the task title in the top left to navigate to the task view.\n\n<Frame>\n<img src=\"/images/vk-task-title.png\" alt=\"Task title in top left corner, click to view full task details\" />\n</Frame>\n\nThis lets you:\n- See the full task description\n- Edit the task title or description\n- View all task attempts\n\n### Editing Previous Messages\n\n<Note>\nMessage editing is supported by Claude Code, Amp, Codex, Gemini, and Qwen.\n</Note>\n\n<Frame>\n<img src=\"/images/vk-edit-message.png\" alt=\"Editing a previous message with options to save or cancel changes\" />\n</Frame>\n\nWhen supported by your coding agent, you can edit previous messages in the conversation. This will revert the agent's work to that point and replay the conversation with your edited message.\n\n<Warning>\nEditing a message reverts all subsequent agent work. Use this carefully when you need to correct or clarify earlier instructions.\n</Warning>\n\n## Viewing Processes\n\nClick the triple dot icon in the top right and select **View Processes** to see all running and completed processes.\n\n<Frame>\n<img src=\"/images/vk-processes-dropdown.png\" alt=\"Processes dropdown showing coding agent and development server processes\" />\n</Frame>\n\nThis shows:\n- Coding agent sessions\n- Development servers\n- Build scripts\n- Any other running processes\n\nEach process displays its status and execution timeline. Click any process to view its specific output logs.\n\n<Tip>\nFor development server logs, the recommended way to view them is through [Testing Your Application](/core-features/testing-your-application) where you can see logs alongside the live preview.\n</Tip>\n\n## Related Documentation\n\n- [Testing Your Application](/core-features/testing-your-application) - Test your application with live preview and dev server logs\n- [Reviewing Code Changes](/core-features/reviewing-code-changes) - Review the changes agents make\n- [Creating Projects](/core-features/creating-projects) - Configure setup and cleanup scripts\n- [Agent Configurations](/configuration-customisation/agent-configurations) - Customise agent behaviour and profiles\n"
  },
  {
    "path": "docs/core-features/new-task-attempts.mdx",
    "content": "---\ntitle: \"New Task Attempts\"\ndescription: \"Understand when and why to create multiple task attempts for fresh restarts with different configurations.\"\nsidebarTitle: \"New Task Attempts\"\n---\n\n<Frame>\n<img src=\"/images/vk-create-new-attempt.png\" alt=\"Create new task attempt dialog showing configuration options\" />\n</Frame>\n\nA task attempt represents a single session with a coding agent against a task. Most tasks only need one attempt, but you may need additional attempts for fresh restarts.\n\n## When to Create New Task Attempts\n\nCreate a new task attempt when you want to:\n\n- **Start from scratch** with a different approach after an unsuccessful attempt\n- **Try a different coding agent** (e.g., switching from Claude to Codex)\n- **Use a different agent profile or variant** for specialised behaviour\n- **Work from a different base branch** to incorporate recent changes\n- **Reset the conversation context** for a completely fresh start\n\n<Tip>\nMost users will only need one attempt per task. Only create additional attempts if the first approach didn't work as expected.\n</Tip>\n\n## Creating Additional Attempts\n\nTo create a new task attempt for an existing task:\n\n<Steps>\n<Step title=\"Navigate to the task\">\n  Open the task that needs a fresh attempt.\n</Step>\n\n<Step title=\"Click Create New Attempt\">\n  Click the triple dot icon in the top right of the task, then select **Create New Attempt**.\n</Step>\n\n<Step title=\"Configure the attempt\">\n  Choose your agent profile, variant, and base branch. These can be different from previous attempts.\n</Step>\n\n<Step title=\"Start execution\">\n  Click **Create Attempt** to begin a fresh execution with the new configuration.\n</Step>\n</Steps>\n\n## Impact on Subtasks\n\n<Warning>\nCreating new task attempts affects subtasks. Subtasks are linked to specific task attempts, not tasks themselves.\n</Warning>\n\nWhen you create a new task attempt:\n\n- **Existing subtasks** remain linked to their original parent attempt\n- **New subtasks** created from the new attempt will use the new attempt's branch as their base\n\n<Info>\nFor more details about how subtasks work with task attempts, see [Creating Subtasks](/core-features/subtasks).\n</Info>\n"
  },
  {
    "path": "docs/core-features/resolving-rebase-conflicts.mdx",
    "content": "---\ntitle: \"Resolving Rebase Conflicts\"\ndescription: \"Learn how to handle rebase conflicts when your base branch has advanced, using either manual resolution or automatic conflict resolution with coding agents.\"\nsidebarTitle: \"Resolving Rebase Conflicts\"\n---\n\n## When You See \"Rebase Conflicts\"\n\nAfter clicking the rebase button, if your changes conflict with the base branch, your task status changes to \"Rebase conflicts\" and a conflict resolution banner appears.\n\n<Frame>\n<img src=\"/images/vk-rebase-conflicts-top.png\" alt=\"Task showing rebase conflicts status with conflict resolution options\" />\n</Frame>\n\nThe conflict banner provides three options to resolve the situation:\n\n<Frame>\n<img src=\"/images/vk-rebase-banner.png\" alt=\"Conflict resolution banner showing the three available options\" />\n</Frame>\n\n- **Resolve Conflicts** - Auto-generate resolution instructions for the coding agent\n- **Open in Editor** - Manually edit conflicted files\n- **Abort Rebase** - Cancel and return to previous state\n\n## Resolving Conflicts Automatically\n\nThe simplest solution is to let the coding agent resolve conflicts automatically:\n\n1. Click **Resolve Conflicts** from the conflict banner to generate specific instructions tailored to your conflict situation and insert them into the follow-up message area.\n\n2. Review the generated instructions and click **Resolve Conflicts** (the Send button changes to this) to have the agent analyse the conflicted files and complete the rebase automatically.\n\n<Frame>\n<img src=\"/images/vk-rebase-conflicts-prompt.png\" alt=\"Conflict resolution banner with auto-generated instructions in the follow-up field\" />\n</Frame>\n\nOnce the agent completes the resolution, your task status will show *n* commits ahead and the **Merge** button becomes available again.\n\n## Manual Resolution (Alternative)\n\nIf you prefer to resolve conflicts manually, you have two options:\n\n**For single files:** Use **Open in Editor** from the conflict banner to edit one conflicted file at a time. After resolving and refreshing the page, you can press the button again for the next file.\n\n**For multiple files (recommended):** Click the triple dot icon at the top right of the task and select **Open in [Your IDE]** to open all worktree files in your chosen IDE, where you can resolve all conflicts at once.\n\n<Steps>\n<Step title=\"Open your IDE\">\n  Click the triple dot icon at the top right and select **Open in [Your IDE]** to access all worktree files, or use **Open in Editor** from the banner for individual files.\n</Step>\n\n<Step title=\"Edit conflicted files\">\n  Resolve merge markers in each file:\n  \n  ```diff\n  <<<<<<< HEAD (your changes)\n  function newFeature() {\n    return \"new implementation\";\n  }\n  =======\n  function oldFeature() {\n    return \"existing implementation\";\n  }\n  >>>>>>> main (base branch changes)\n  ```\n</Step>\n\n<Step title=\"Continue the rebase\">\n  After editing all conflicts, stage and continue:\n  \n  ```bash\n  git add .\n  git rebase --continue\n  ```\n</Step>\n</Steps>\n\n<Tip>\nAutomatic resolution works best for most conflicts. Use manual resolution only when you need precise control over the merge decisions.\n</Tip>\n\n## Aborting a Rebase\n\nIf you need to cancel the rebase entirely, click **Abort Rebase** to return to the \"Rebase needed\" state. You can then try rebasing again or create a new task attempt from the updated base branch.\n\n## Rebasing onto a Different Base Branch\n\nIf you've changed the base branch of your task attempt and see commits unrelated to your changes, you can use `git rebase --onto` to rebase only your work onto the new base:\n\n```bash\ngit rebase <last-commit-before-your-work> --onto <new-base>\n```\n\n### Example Scenario\n\nYou accidentally created a task attempt from the `develop` branch, but it should have been based on `main`. After changing the base branch to `main` in the task settings, you see commits from `develop` that aren't part of your work:\n\n```bash\n# Find the last commit before your work started (e.g., in the git log)\n# Then rebase only your commits onto main\ngit rebase 64d504c94d076070d17affd3f84be63b34515445 --onto main\n```\n\nThis command takes your commits (everything after the specified commit hash) and replays them onto `main`, excluding the unrelated commits from `develop`.\n\n<Warning>\nUse `git rebase --onto` carefully. Make sure you identify the correct commit hash—the last commit that isn't part of your current task. Consider creating a backup branch first: `git branch backup-branch`.\n</Warning>\n"
  },
  {
    "path": "docs/core-features/reviewing-code-changes.mdx",
    "content": "---\ntitle: \"Reviewing Code Changes\"\ndescription: \"Learn how to review and provide feedback on code changes made by coding agents\"\n---\n\nWhen a coding agent completes a task, it automatically moves to the **In Review** column. This is where you can examine the changes, provide feedback, and ensure the implementation meets your requirements.\n\n<video\n  controls\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-code-review-3.mp4\"\n></video>\n\n## Opening the Code Review Interface\n\n<Steps>\n<Step title=\"Access the task\">\n  Click on any task in the **In Review** column to open it.\n</Step>\n\n<Step title=\"View the diffs\">\n  Click the **Diff icon** to view all the code changes made by the agent.\n</Step>\n</Steps>\n\n## Adding Review Comments\n\n### Line-Specific Comments\n\nTo provide feedback on specific lines of code:\n\n<Steps>\n<Step title=\"Locate the line\">\n  Find the line you want to comment on in the diffs view.\n</Step>\n\n<Step title=\"Add a comment\">\n  Click the **plus icon** (+) at the beginning of the line to create a review comment.\n\n  <Frame>\n  <img src=\"/images/add-line-comment.png\" alt=\"Plus icon for adding line comments\" />\n  </Frame>\n</Step>\n\n<Step title=\"Write your feedback\">\n  Enter your comment in the text field that appears. You can provide suggestions, ask questions, or request changes.\n</Step>\n</Steps>\n\n### Multiple Comments Across Files\n\nYou can create several review comments across different files in the same review:\n\n- Add comments to multiple lines within a single file\n- Switch between different changed files and add comments to each\n- All comments will be collected and submitted together as part of your review\n\n<Note>\nReview comments are not submitted individually. They are collected and sent as a complete review when you submit your feedback.\n</Note>\n\n## Submitting Your Review\n\n<Steps>\n<Step title=\"Submit the review\">\n  Click the **Send** button to send all your feedback to the coding agent.\n\n  <Info>\n  All comments are combined into a single message for the coding agent to address.\n  </Info>\n</Step>\n\n<Step title=\"Task moves back to In Progress\">\n  Once submitted, the task returns to the **In Progress** column where the agent will address your feedback and implement the requested changes.\n</Step>\n</Steps>\n\n\n"
  },
  {
    "path": "docs/core-features/subtasks.mdx",
    "content": "---\ntitle: \"Creating Subtasks\"\ndescription: \"Learn how to create and manage subtasks to break down complex work into smaller, manageable pieces\"\n---\n\nSubtasks allow you to break down complex tasks into smaller, more manageable pieces. Each subtask is linked to a specific task attempt and inherits the same project and branch context.\n\n## Creating Subtasks\n\n<Frame>\n<img src=\"/images/vk-create-subtask-button.png\" alt=\"Current attempt toolbar showing the Create Subtask button with GitFork icon\" />\n</Frame>\n\nTo create a subtask from an existing task attempt:\n\n<Steps>\n<Step title=\"Navigate to the task attempt\">\n  Open the task you want to create subtasks for.\n</Step>\n\n<Step title=\"Click Create Subtask\">\n  Click the triple dot icon in the top right of the task, then select **Create Subtask**.\n</Step>\n\n<Step title=\"Fill in subtask details\">\n  The task creation dialog opens with the parent task attempt and base branch automatically set. Add your subtask title and description.\n</Step>\n\n<Step title=\"Save the subtask\">\n  Click **Save** to create the subtask. It will appear as a new task on your kanban board.\n</Step>\n</Steps>\n\n<Note>\nWhen you create a subtask, it automatically inherits the base branch from its parent task attempt, ensuring consistency in your development workflow.\n</Note>\n\n## Viewing Tasks with Subtasks\n\n<Frame>\n<img src=\"/images/screenshot-task-with-subtasks.png\" alt=\"Task view showing a parent task with its associated subtasks listed in the Task Relationships panel\" />\n</Frame>\n\nWhen viewing a parent task, you can see its subtasks in the **Task Relationships** panel. This collapsible section shows:\n\n- **Child Tasks** with a count (e.g., \"CHILD TASKS (1)\")\n- Individual subtask titles with links to view them\n- Easy navigation between parent and child tasks\n\nThis helps you track progress across all related work items and understand the task hierarchy at a glance.\n\n## Viewing Subtask Details\n\n<Frame>\n<img src=\"/images/screenshot-subtask-parent-info.png\" alt=\"Subtask detail view showing parent task information in the Task Relationships panel\" />\n</Frame>\n\nWhen viewing a subtask, the **Task Relationships** panel displays:\n\n- **Parent Task** section showing the parent task title\n- Direct link to navigate to the parent task\n- Clear visual indication that this is a child task\n- Context about the parent-child relationship\n\nThe subtask also shows its own **Create Subtask** button, allowing you to create nested subtasks if needed.\n\n## How Subtasks Work\n\nSubtasks in Vibe Kanban follow these key principles:\n\n### Git Branching Workflow\n\nSubtasks create their own feature branches that can work independently while maintaining connection to the parent task:\n\n```mermaid\ngitGraph\n    commit id: \"main\"\n    branch feature/parent-task\n    checkout feature/parent-task\n    commit id: \"Parent Task Start\"\n    commit id: \"Initial work\"\n\n    branch feature/subtask-1\n    checkout feature/subtask-1\n    commit id: \"Subtask 1: Backend API\"\n    commit id: \"API implementation\"\n    commit id: \"API tests\"\n\n    checkout feature/parent-task\n    branch feature/subtask-2\n    checkout feature/subtask-2\n    commit id: \"Subtask 2: Frontend UI\"\n    commit id: \"Component creation\"\n    commit id: \"UI styling\"\n\n    checkout feature/parent-task\n    branch feature/subtask-3\n    checkout feature/subtask-3\n    commit id: \"Subtask 3: Integration\"\n    commit id: \"Connect API to UI\"\n\n    checkout feature/parent-task\n    merge feature/subtask-1\n    merge feature/subtask-2\n    merge feature/subtask-3\n    commit id: \"Parent Task Complete\"\n\n    checkout main\n    merge feature/parent-task\n```\n\n### Parent-Child Relationships\n\n- Subtasks are linked to specific **task attempts**, not just tasks\n- Each subtask knows which attempt created it\n- Multiple subtasks can be created from the same parent attempt\n\n### Branch Inheritance\n\n- Subtasks automatically inherit the base branch from their parent attempt\n- This ensures subtasks work within the same development context\n- You can modify the branch when creating the subtask if needed\n\n### Independent Task Lifecycle\n\n- Subtasks appear as regular tasks on your kanban board\n- Each subtask has its own lifecycle (To do → In Progress → In Review → Done)\n- Subtasks can have their own task attempts and coding agents\n"
  },
  {
    "path": "docs/core-features/testing-your-application.mdx",
    "content": "---\ntitle: \"Testing Your Application\"\ndescription: \"Live preview your web applications with embedded browser viewing and precise component selection for seamless development workflows\"\nsidebarTitle: \"Testing Your Application\"\n---\n\n<video\n  controls\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-companion-demo-3.mp4\"\n></video>\n\n## Overview\n\nPreview Mode provides an embedded browser experience within Vibe Kanban, allowing you to test and iterate on your web applications without leaving the development environment. This feature eliminates the need to switch between your browser and Vibe Kanban by providing live preview capabilities and precise component selection tools.\n\n**Key Benefits:**\n- **Embedded viewing**: See your application running directly in Vibe Kanban\n- **Precise component selection**: Click to select specific UI components for targeted feedback\n- **Dev Server Logs**: Monitor development server output with expandable/collapsible logs at the bottom\n- **Seamless workflows**: No context switching between tools\n\n## Setting Up Preview Mode\n\n<Steps>\n<Step title=\"Configure Development Server Script\">\n  Navigate to your project settings and configure the development server script that starts your local development environment.\n\n  **Common examples:**\n  - `npm run dev` (Vite, Next.js)\n  - `npm start` (Create React App)\n  - `yarn dev` (Yarn projects)\n  - `pnpm dev` (PNPM projects)\n\n  <Frame>\n  <img src=\"/images/preview-mode-dev-script-config.png\" alt=\"Development server script configuration interface\" />\n  </Frame>\n\n  <Info>\n  You may also need to configure a setup script (e.g., `npm install`) to install dependencies before the development server starts. Configure this in project settings under Setup Scripts.\n  </Info>\n\n  <Tip>\n  Ensure your development server prints the URL (e.g., `http://localhost:3000`) to stdout/stderr for automatic detection.\n  </Tip>\n</Step>\n\n<Step title=\"Install Web Companion\">\n  For precise component selection, install the `vibe-kanban-web-companion` package in your application.\n\n  <Info>\n  **Recommended**: Use the \"Install companion automatically\" button in the Preview tab to have Vibe Kanban create a task that installs and configures the companion for you.\n  \n  <Frame>\n  <img src=\"/images/preview-mode-install-companion-button.png\" alt=\"Install companion automatically button in Preview tab\" />\n  </Frame>\n  </Info>\n\n  **Manual Installation:**\n\n  Add the dependency to your project:\n  ```bash\n  npm install vibe-kanban-web-companion\n  ```\n\n  Then add the companion to your application:\n\n  <Tabs>\n  <Tab title=\"Create React App\">\n    Add to your `src/index.js` or `src/index.tsx`:\n    ```jsx\n    import { VibeKanbanWebCompanion } from 'vibe-kanban-web-companion';\n    import React from 'react';\n    import ReactDOM from 'react-dom/client';\n    import App from './App';\n\n    const root = ReactDOM.createRoot(document.getElementById('root'));\n    root.render(\n      <React.StrictMode>\n        <VibeKanbanWebCompanion />\n        <App />\n      </React.StrictMode>\n    );\n    ```\n  </Tab>\n\n  <Tab title=\"Next.js\">\n    Add to your `pages/_app.js` or `pages/_app.tsx`:\n    ```jsx\n    import { VibeKanbanWebCompanion } from 'vibe-kanban-web-companion'\n    import type { AppProps } from 'next/app'\n\n    function MyApp({ Component, pageProps }: AppProps) {\n      return (\n        <>\n          <VibeKanbanWebCompanion />\n          <Component {...pageProps} />\n        </>\n      )\n    }\n    ```\n  </Tab>\n\n  <Tab title=\"Vite\">\n    Add to your `src/main.jsx` or `src/main.tsx`:\n    ```jsx\n    import { VibeKanbanWebCompanion } from \"vibe-kanban-web-companion\";\n    import React from \"react\";\n    import ReactDOM from \"react-dom/client\";\n    import App from \"./App\";\n\n    ReactDOM.createRoot(document.getElementById(\"root\")).render(\n      <React.StrictMode>\n        <VibeKanbanWebCompanion />\n        <App />\n      </React.StrictMode>\n    );\n    ```\n  </Tab>\n  </Tabs>\n\n  <Note>\n  The Web Companion is automatically tree-shaken from production builds, so it only runs in development mode.\n  </Note>\n</Step>\n\n<Step title=\"Start Development Server\">\n  In the Preview section, click the **Start Dev Server** button to start your development server.\n\n  <Frame>\n  <img src=\"/images/preview-mode-start-dev-server.png\" alt=\"Starting development server from task interface\" />\n  </Frame>\n\n  The system will:\n  - Launch your configured development script\n  - Detect the URL of your website and load it\n</Step>\n</Steps>\n\n## Using Preview Mode\n\n### Accessing the Preview\n\nOnce your development server is running and a URL is detected:\n\n1. **Click the Preview button** (eye icon) in the task interface\n2. **View embedded application** in the iframe\n3. **Interact with your app** directly within Vibe Kanban\n\n<Frame>\n<img src=\"/images/vk-preview-interface.png\" alt=\"Preview mode showing embedded application with toolbar controls\" />\n</Frame>\n\n### Preview Toolbar Controls\n\nThe preview toolbar provides essential controls for managing your preview experience:\n\n<Frame>\n<img src=\"/images/vk-preview-toolbar.png\" alt=\"Preview toolbar showing refresh, copy URL, open in browser, and stop server controls\" />\n</Frame>\n\n- **Refresh**: Reload the preview iframe\n- **Copy URL**: Copy the development server URL to clipboard\n- **Open in Browser**: Open the application in your default browser\n- **Stop Dev Server**: Stop the running development server\n\n### Dev Server Logs\n\nAt the bottom of the Preview panel, you'll find Dev Server Logs that can be expanded or collapsed. These logs show real-time output from your development server, making it easy to monitor server activity, errors, and debugging information without leaving the preview.\n\n<Frame>\n<img src=\"/images/vk-dev-server-logs.png\" alt=\"Dev Server Logs showing expandable/collapsible log output at bottom of preview\" />\n</Frame>\n\n### Component Selection\n\nWhen the Web Companion is installed, you can precisely select UI components for targeted feedback:\n\n<Steps>\n<Step title=\"Activate Selection Mode\">\n  Click the floating Vibe Kanban companion button in the bottom-right corner of your application to activate component selection mode.\n\n  <Frame>\n  <img src=\"/images/vk-component-selection.png\" alt=\"Component selection interface showing selectable elements highlighted\" />\n  </Frame>\n</Step>\n\n<Step title=\"Choose Component Depth\">\n  When you click a component, Vibe Kanban shows a hierarchy of components from innermost to outermost. Select the appropriate level for your feedback:\n\n  - **Inner components**: For specific UI elements (buttons, inputs)\n  - **Outer components**: For broader sections (cards, layouts)\n\n  <Frame>\n  <img src=\"/images/preview-mode-component-depth.png\" alt=\"Component depth selection showing hierarchy of selectable components\" />\n  </Frame>\n</Step>\n\n<Step title=\"Provide Targeted Feedback\">\n  After selecting a component, write your follow-up message. The coding agent will receive:\n  - **Precise DOM selector** information\n  - **Component hierarchy** and source file locations\n  - **Your specific instructions** about what to change\n\n  <Check>\n  No need to describe \"the button in the top right\" - the agent knows exactly which component you mean!\n  </Check>\n</Step>\n</Steps>\n\n## Troubleshooting\n\nIf the preview doesn't load automatically, ensure your development server prints the URL to stdout/stderr for automatic detection.\n\nSupported URL formats:\n- `http://localhost:3000`\n- `https://localhost:3000`\n- `http://127.0.0.1:3000`\n- `http://0.0.0.0:5173`\n\n<Note>\nURLs using `0.0.0.0` or `::` are automatically converted to `localhost` for embedding.\n</Note>\n\n## Related Documentation\n\n- [New Task Attempts](/core-features/new-task-attempts) - Learn about task attempt lifecycle\n- [Reviewing Code Changes](/core-features/reviewing-code-changes) - Analyse and review code modifications\n- [Configuration & Customisation](/configuration-customisation/global-settings) - Customise Vibe Kanban settings\n"
  },
  {
    "path": "docs/docs.json",
    "content": "{\n  \"$schema\": \"https://mintlify.com/docs.json\",\n  \"theme\": \"mint\",\n  \"name\": \"Vibe Kanban\",\n  \"description\": \"A kanban board for developers to track coding tasks with AI coding agents\",\n  \"appearance\": {\n    \"default\": \"light\"\n  },\n  \"colors\": {\n    \"primary\": \"#000000\",\n    \"light\": \"#fefefe\",\n    \"dark\": \"#121212\"\n  },\n  \"background\": {\n    \"color\": {\n      \"light\": \"#FAF9F5\",\n      \"dark\": \"#2F2F2D\"\n    }\n  },\n  \"favicon\": \"/logo/v-192.png\",\n  \"navigation\": {\n    \"groups\": [\n      {\n        \"group\": \"Getting started\",\n        \"pages\": [\n          \"index\",\n          \"getting-started\",\n          \"browser-testing\",\n          \"issue-management\",\n          \"reviewing-code\",\n          \"remote-access\",\n          {\n            \"group\": \"Supported Coding Agents\",\n            \"expanded\": true,\n            \"pages\": [\n              \"supported-coding-agents\",\n              \"agents/claude-code\",\n              \"agents/openai-codex\",\n              \"agents/github-copilot\",\n              \"agents/gemini-cli\",\n              \"agents/amp\",\n              \"agents/cursor-cli\",\n              \"agents/opencode\",\n              \"agents/droid\",\n              \"agents/ccr\",\n              \"agents/qwen-code\"\n            ]\n          }\n        ]\n      },\n      {\n        \"group\": \"Workspaces\",\n        \"pages\": [\n          \"workspaces/index\",\n          \"workspaces/creating-workspaces\",\n          \"workspaces/managing-workspaces\",\n          \"workspaces/repositories\",\n          \"workspaces/sessions\",\n          \"workspaces/chat-interface\",\n          \"workspaces/slash-commands\",\n          \"workspaces/interface\",\n          \"workspaces/command-bar\",\n          \"workspaces/multi-repo-sessions\",\n          \"workspaces/changes\",\n          \"workspaces/git-operations\"\n        ]\n      },\n      {\n        \"group\": \"Cloud\",\n        \"pages\": [\n          \"cloud/index\",\n          \"cloud/getting-started\",\n          \"cloud/migration\",\n          \"cloud/authentication\",\n          \"cloud/organizations\",\n          \"cloud/team-members\",\n          \"cloud/projects\",\n          \"cloud/issues\",\n          \"cloud/kanban-board\",\n          \"cloud/list-view\",\n          \"cloud/filtering\",\n          \"cloud/customisation\",\n          \"cloud/troubleshooting\"\n        ]\n      },\n      {\n        \"group\": \"Settings\",\n        \"pages\": [\n          \"settings/index\",\n          {\n            \"group\": \"General\",\n            \"pages\": [\n              \"settings/general\",\n              \"settings/creating-task-tags\"\n            ]\n          },\n          \"settings/projects-repositories\",\n          \"settings/organization-settings\",\n          \"settings/remote-projects\",\n          \"settings/agent-configurations\",\n          \"settings/mcp-servers\"\n        ]\n      },\n      {\n        \"group\": \"Self-Hosting\",\n        \"pages\": [\n          \"self-hosting/local-development\",\n          \"self-hosting/deploy-docker\"\n        ]\n      },\n      {\n        \"group\": \"Integrations\",\n        \"pages\": [\n          \"integrations/github-integration\",\n          \"integrations/azure-repos-integration\",\n          \"integrations/vscode-extension\",\n          \"integrations/mcp-server-configuration\",\n          \"integrations/vibe-kanban-mcp-server\"\n        ]\n      },\n      {\n        \"group\": \"Help\",\n        \"pages\": [\n          \"troubleshooting\",\n          \"responsible-disclosure\"\n        ]\n      }\n    ]\n  },\n  \"logo\": {\n    \"light\": \"/logo/light.svg\",\n    \"dark\": \"/logo/dark.svg\"\n  },\n  \"navbar\": {\n    \"links\": [\n      {\n        \"label\": \"GitHub\",\n        \"href\": \"https://github.com/BloopAI/vibe-kanban\"\n      }\n    ],\n    \"primary\": {\n      \"type\": \"button\",\n      \"label\": \"Get Started\",\n      \"href\": \"https://vibekanban.com/docs\"\n    }\n  },\n  \"contextual\": {\n    \"options\": [\n      \"copy\",\n      \"view\",\n      \"chatgpt\",\n      \"claude\",\n      \"perplexity\",\n      \"mcp\",\n      \"cursor\",\n      \"vscode\"\n    ]\n  },\n  \"integrations\": {\n    \"posthog\": {\n      \"apiKey\": \"phc_V5XpxUvgOfEWk38iGHr0Kve2oZTmWFjDe3mIwTCXzx0\",\n      \"apiHost\": \"https://eu.i.posthog.com\"\n    }\n  },\n  \"redirects\": [\n    {\n      \"source\": \"/workspaces/preview\",\n      \"destination\": \"/browser-testing\"\n    },\n    {\n      \"source\": \"/remote-control\",\n      \"destination\": \"/remote-access\"\n    }\n  ],\n  \"footer\": {\n    \"socials\": {\n      \"github\": \"https://github.com/BloopAI/vibe-kanban\",\n      \"x\": \"https://x.com/vibekanban\"\n    }\n  }\n}"
  },
  {
    "path": "docs/frontend-ui-library-refactor-audit.md",
    "content": "# Frontend UI Package Refactor Audit\n\nDate: 2026-02-21\n\n## Scope\n\n- Source audited: `packages/local-web/src/components/**/*.tsx`\n- Total components: 280\n- Objective: identify what should move first into a new pnpm UI library package.\n\n## Current Frontend Structure (Component View)\n\n| Area | Components | Extract now | Extract later | Keep app |\n| --- | ---: | ---: | ---: | ---: |\n| `ui-new/primitives` | 73 | 16 | 8 | 49 |\n| `ui-new/containers` | 45 | 0 | 0 | 45 |\n| `ui-new/dialogs` | 33 | 0 | 0 | 33 |\n| `ui-new/views` | 32 | 0 | 0 | 32 |\n| `dialogs` | 27 | 0 | 0 | 27 |\n| `ui/wysiwyg` | 20 | 0 | 20 | 0 |\n| `ui` | 17 | 11 | 5 | 1 |\n| `tasks` | 10 | 0 | 0 | 10 |\n| `NormalizedConversation` | 7 | 0 | 0 | 7 |\n| `root` | 3 | 0 | 0 | 3 |\n| `common` | 2 | 0 | 0 | 2 |\n| `ide` | 2 | 0 | 0 | 2 |\n| `org` | 2 | 0 | 0 | 2 |\n| `ui-new/scope` | 2 | 0 | 0 | 2 |\n| `ui/table` | 2 | 0 | 2 | 0 |\n| `agents` | 1 | 0 | 0 | 1 |\n| `settings` | 1 | 0 | 0 | 1 |\n| `ui-new/terminal` | 1 | 0 | 0 | 1 |\n\n## Refactor Status Legend\n\n- `extract-now`: move in first `@vibe/ui` package wave.\n- `extract-later`: reusable, but move only after API/dependency decoupling.\n- `keep-app`: stay in frontend app package (feature/domain/integration UI).\n\n## Recommended First Extraction Set\n\n- Start with `extract-now` components from `components/ui` and selected `components/ui-new/primitives`.\n- Keep all `containers`, `views`, and feature `dialogs` in the app package.\n- Treat `components/ui/wysiwyg` as a separate future package (`@vibe/editor-ui`) after `@vibe/ui` lands.\n\n## Full Component Map\n\n### ui-new/primitives\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui-new/primitives/Accordion.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/AppBar.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/AppBarButton.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/AppBarSocialLink.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/AppBarUserPopover.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/AutoResizeTextarea.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/ChatBoxBase.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/CollapsibleSectionHeader.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/stores. |\n| `ui-new/primitives/ColorPicker.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/Command.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/CommandBar.tsx` | `extract-later` | `@vibe/ui` | Potentially reusable but needs API cleanup. Requires decoupling from @/components/*. |\n| `ui-new/primitives/CommentCard.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/ContextBar.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/ContextUsageGauge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/CreateChatBox.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/Dialog.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. |\n| `ui-new/primitives/Dropdown.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. |\n| `ui-new/primitives/EmojiPicker.tsx` | `extract-later` | `@vibe/ui` | Potentially reusable but needs API cleanup. Requires decoupling from @/contexts. |\n| `ui-new/primitives/ErrorAlert.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/GoogleLogo.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/IconButton.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/IconButtonGroup.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/InputField.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/KanbanAssignee.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/KanbanBadge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/MultiSelectCommandBar.tsx` | `extract-later` | `@vibe/ui` | Potentially reusable but needs API cleanup. |\n| `ui-new/primitives/MultiSelectDropdown.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/OAuthButtons.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/Popover.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. |\n| `ui-new/primitives/PrBadge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/PrimaryButton.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/PriorityIcon.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/ProcessListItem.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/PropertyDropdown.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/RelationshipBadge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/RepoCard.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/RunningDots.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/SearchableDropdown.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/SearchableTagDropdown.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/SessionChatBox.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/SplitButton.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/StatusDot.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/SubIssueRow.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/SyncErrorIndicator.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/TodoProgressPopup.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/Toggle.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/Toolbar.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. |\n| `ui-new/primitives/Tooltip.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. |\n| `ui-new/primitives/UserAvatar.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/ViewNavTabs.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/WorkspaceSummary.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. |\n| `ui-new/primitives/conversation/ChatAggregatedDiffEntries.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatAggregatedToolEntries.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatApprovalCard.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatAssistantMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatCollapsedThinking.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatEntryContainer.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatErrorMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatFileEntry.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatMarkdown.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatScriptEntry.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatScriptPlaceholder.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatSubagentEntry.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatSystemMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatThinkingMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatTodoList.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatToolSummary.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ChatUserMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/PierreConversationDiff.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/conversation/ToolStatusDot.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. |\n| `ui-new/primitives/model-selector/ModelList.tsx` | `keep-app` | `frontend-app` | Model/provider domain UI. |\n| `ui-new/primitives/model-selector/ModelProviderIcon.tsx` | `keep-app` | `frontend-app` | Model/provider domain UI. |\n| `ui-new/primitives/model-selector/ModelSelectorPopover.tsx` | `keep-app` | `frontend-app` | Model/provider domain UI. |\n\n### ui-new/containers\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui-new/containers/AppBarUserPopoverContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ChangesPanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ColorPickerContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/CommentWidgetLine.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ContextBarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ConversationListContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/CopyButton.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/CreateChatBoxContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/CreateModeRepoPickerBar.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/FileTreeContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/GitHubCommentRenderer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/GitPanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/IssueCommentsSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/IssueRelationshipsSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/IssueSubIssuesSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/IssueWorkspacesSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/KanbanContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/KanbanIssuePanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/LogsContentContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/MigrateChooseProjectsContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/MigrateFinishContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/MigrateIntroductionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/MigrateLayout.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/MigrateMigrateContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ModelSelectorContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/NavbarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/NewDisplayConversationEntry.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/PierreDiffCard.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/PreviewBrowserContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/PreviewControlsContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ProcessListContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ProjectRightSidebarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/RemoteIssueLink.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/ReviewCommentRenderer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/RightSidebar.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/SearchableDropdownContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/SearchableTagDropdownContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/SessionChatBoxContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/SharedAppLayout.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/TerminalPanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/VirtualizedProcessLogs.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/WorkspaceNotesContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/WorkspacesLayout.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/WorkspacesMainContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n| `ui-new/containers/WorkspacesSidebarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. |\n\n### ui-new/dialogs\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui-new/dialogs/AssigneeSelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/ChangeTargetDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/CommandBarDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/ConfirmDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/CreateRepoDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/DeleteWorkspaceDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/ErrorDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/GuideDialogShell.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/KanbanFiltersDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/KeyboardShortcutsDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/ProjectsGuideDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/RebaseDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/RebaseInProgressDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/RenameWorkspaceDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/ResolveConflictsDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/SelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/SettingsDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/WorkspaceSelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/WorkspacesGuideDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/selections/ProjectSelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/AgentsSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/ExecutorConfigForm.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/GeneralSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/McpSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/OrganizationsSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/RemoteProjectsSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/ReposSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/SettingsComponents.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/SettingsDirtyContext.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/SettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/rjsf/Fields.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/rjsf/Templates.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n| `ui-new/dialogs/settings/rjsf/Widgets.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. |\n\n### ui-new/views\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui-new/views/ChangesPanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/FileTree.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/FileTreeNode.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/FileTreeSearchBar.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/GitPanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueCommentsSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueListRow.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueListSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueListView.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssuePropertyRow.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueRelationshipsSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueSubIssuesSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueTagsRow.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueWorkspaceCard.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/IssueWorkspacesSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/KanbanBoard.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/KanbanCardContent.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/KanbanFilterBar.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/KanbanIssuePanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/MigrateChooseProjects.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/MigrateFinish.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/MigrateIntroduction.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/MigrateMigrate.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/MigrateSidebar.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/Navbar.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/PreviewBrowser.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/PreviewControls.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/PreviewNavigation.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/PriorityFilterDropdown.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/TerminalPanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/WorkspacesMain.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n| `ui-new/views/WorkspacesSidebar.tsx` | `keep-app` | `frontend-app` | Feature view composition. |\n\n### dialogs\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `dialogs/CreateWorkspaceFromPrDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/auth/GhCliSetupDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/git/ForcePushDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/global/OAuthDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/global/ReleaseNotesDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/org/CreateOrganizationDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/org/CreateRemoteProjectDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/org/DeleteRemoteProjectDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/org/InviteMemberDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/scripts/ScriptFixerDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/settings/CreateConfigurationDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/settings/DeleteConfigurationDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/shared/ConfirmDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/shared/FolderPickerDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/shared/LoginRequiredPrompt.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/ChangeTargetBranchDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/CreatePRDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/EditBranchNameDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/EditorSelectionDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/GitActionsDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/PrCommentsDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/RebaseDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/RestoreLogsDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/StartReviewDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/TagEditDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/tasks/ViewProcessesDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `dialogs/wysiwyg/ImagePreviewDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n\n### ui/wysiwyg\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui/wysiwyg/context/task-attempt-context.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/context/typeahead-open-context.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/lib/create-decorator-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/nodes/component-info-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/nodes/image-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/hooks, @/components/*. |\n| `ui/wysiwyg/nodes/pr-comment-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/clickable-code-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/code-block-shortcut-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/code-highlight-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/component-info-keyboard-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/components/*, @/contexts, @/lib/*api, @/stores. |\n| `ui/wysiwyg/plugins/image-keyboard-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/keyboard-commands-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/markdown-sync-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/paste-markdown-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/read-only-link-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/slash-command-typeahead-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/contexts, @/hooks. |\n| `ui/wysiwyg/plugins/static-toolbar-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n| `ui/wysiwyg/plugins/toolbar-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/contexts. |\n| `ui/wysiwyg/plugins/typeahead-menu-components.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. |\n\n### ui\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui/alert.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/auto-expanding-textarea.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/badge.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/button.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/card.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/checkbox.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/dialog.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/keyboard. |\n| `ui/dropdown-menu.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/contexts. |\n| `ui/input.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/label.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/loader.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/pr-comment-card.tsx` | `keep-app` | `frontend-app` | PR domain card component. |\n| `ui/select.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/contexts. |\n| `ui/switch.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/textarea.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. |\n| `ui/tooltip.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/contexts. |\n| `ui/wysiwyg.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/vscode. |\n\n### tasks\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `tasks/AgentSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/BranchSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/ConfigSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/RepoBranchSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/RepoSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/TaskDetails/ProcessLogsViewer.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/TaskDetails/ProcessesTab.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/Toolbar/GitOperations.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/UserAvatar.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `tasks/VariantSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n\n### NormalizedConversation\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `NormalizedConversation/DisplayConversationEntry.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `NormalizedConversation/EditDiffRenderer.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `NormalizedConversation/FileChangeRenderer.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `NormalizedConversation/FileContentView.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `NormalizedConversation/PendingApprovalEntry.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `NormalizedConversation/RetryEditorInline.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `NormalizedConversation/UserMessage.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n\n### root\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ConfigProvider.tsx` | `keep-app` | `frontend-app` | App bootstrap/provider concern. |\n| `TagManager.tsx` | `keep-app` | `frontend-app` | App bootstrap/provider concern. |\n| `ThemeProvider.tsx` | `keep-app` | `frontend-app` | App bootstrap/provider concern. |\n\n### common\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `common/ProfileVariantBadge.tsx` | `keep-app` | `frontend-app` | App-specific utility presentation component. |\n| `common/RawLogText.tsx` | `keep-app` | `frontend-app` | App-specific utility presentation component. |\n\n### ide\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ide/IdeIcon.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `ide/OpenInIdeButton.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n\n### org\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `org/MemberListItem.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n| `org/PendingInvitationItem.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n\n### ui-new/scope\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui-new/scope/NewDesignScope.tsx` | `keep-app` | `frontend-app` | Runtime integration (scope, terminal, keyboard, IDE). |\n| `ui-new/scope/VSCodeScope.tsx` | `keep-app` | `frontend-app` | Runtime integration (scope, terminal, keyboard, IDE). |\n\n### ui/table\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui/table/data-table.tsx` | `extract-later` | `@vibe/ui` | Reusable table building blocks; defer until core package is stable. |\n| `ui/table/table.tsx` | `extract-later` | `@vibe/ui` | Reusable table building blocks; defer until core package is stable. |\n\n### agents\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `agents/AgentIcon.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n\n### settings\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `settings/ExecutorProfileSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. |\n\n### ui-new/terminal\n\n| Component | Status | Target package | Notes |\n| --- | --- | --- | --- |\n| `ui-new/terminal/XTermInstance.tsx` | `keep-app` | `frontend-app` | Runtime integration (scope, terminal, keyboard, IDE). |\n\n## Summary Counts\n\n- extract-now: 27\n- extract-later: 35\n- keep-app: 218\n\n## Suggested Package Split\n\n- `packages/ui`: design tokens, core primitives, small reusable composed controls.\n- `packages/editor-ui` (later): WYSIWYG/editor-specific nodes and plugins.\n- `frontend`: feature views/containers/dialogs and integration-heavy components.\n"
  },
  {
    "path": "docs/getting-started.mdx",
    "content": "---\ntitle: \"Get Started\"\ndescription: \"Launch Vibe Kanban, connect a coding agent, and go from zero to pull request\"\nsidebarTitle: \"Get Started\"\n---\n\nVibe Kanban keeps you organised while running multiple coding agents in parallel by streamlining how you plan and review their work.\n\n## 1. Launch Vibe Kanban\n\nStart the Vibe Kanban client and open the UI in your browser:\n\n\n```bash\nnpx vibe-kanban\n```\n\n## 2. Confirm your preferences\n\nThe first time you run Vibe Kanban, you'll be asked to set your preferred:\n\n- Coding agent\n- IDE\n- Notification preferences\n\n<Info>\nThese preferences can be changed at any time from the settings dialog\n</Info>\n\n<Frame>\n  <img src=\"/images/onboarding-basic-setup.png\" alt=\"Placeholder image for preferences setup: coding agent, IDE, and sound notifications.\" />\n</Frame>\n\n## 3. Sign-in to Vibe Kanban\n\nYou can use a GitHub or Google account.\n\n<Frame>\n  <img src=\"/images/onboarding-sign-in.png\" alt=\"Vibe Kanban onboarding screen showing Sign in to continue with GitHub and Google buttons\" />\n</Frame>\n\n<Info>\nIf you want to skip sign-in for now, click **More options** → **I understand, continue without signing in**. You'll still be able to create workspaces, but the kanban board, issues, and team features will be unavailable.\n</Info>\n\n## 4. Navigate the kanban board\n\nAfter you sign in, we automatically create a personal organisation and an initial project for you, and take you straight there.\n\n<Frame>\n  <img src=\"/images/onboarding-projects.png\" alt=\"The projects page\" />\n</Frame>\n\n**Navigating the kanban board:**\n\n1. The app bar, used for navigating between projects, the workspaces page (we'll come onto this later) and user settings\n2. Issues appear as cards on the kanban board\n3. The 'new issue' button, for creating issues\n4. The right hand panel where details for the currently selected or draft issue is shown\n\n## 5. Create an issue\n\n**Issues** are a core concept of Vibe Kanban, they represent a bug, feature or piece of work to be done. At a minimum, issues consist of a title and description, but you can also add priorities, tags and even connect issues together with parent/child relationships.\n\n<Frame>\n  <img src=\"/images/onboarding-create-issue.png\" alt=\"Create issue\" />\n</Frame>\n\nWhen you've filled out the details press 'create issue'.\n\n## 6. Create a workspace\n\n**Workspaces** are another core concept of Vibe Kanban, they represent a space to work on an issue with a coding agent. When you create a workspace, Vibe Kanban automatically creates git worktrees for your selected repositories, and launches your coding agent.\n\n<Frame>\n  <img src=\"/images/onboarding-create-workspace.png\" alt=\"The create workspace button\" />\n</Frame>\n\nTo create a workspace, make sure the issue you created is selected and click the 'create' button in the workspaces window.\n\n<Frame>\n  <img src=\"/images/onboarding-workspaces-repo.png\" alt=\"Workspaces repos\" />\n</Frame>\n\nWhen you create a workspace, you'll need to specify repositories you'd like to work on, as well as the branches of those repositories to base the git worktrees on.\n\n<Frame>\n  <img src=\"/images/onboarding-workspaces-prompt.png\" alt=\"Workspaces prompt\" />\n</Frame>\n\nYou'll also need to specify your desired coding agent configuration (e.g. model, effort level, plan mode).\n\n<Frame>\n  <img src=\"/images/onboarding-workspaces-logs.png\" alt=\"Workspaces logs\" />\n</Frame>\n\nUpon creation, the coding agent will immediately begin executing with the given prompt.\n\n<Info>\n  You can connect multiple workspaces to an issue, this is useful for working on larger features and allows you to run multiple coding agents in parallel.\n\n  Workspaces don't *have* to be connected to an issue, which is useful for quick actions like asking questions about a codebase.\n</Info>\n\n## 7. Reviewing a workspace\n\nSo far we've been viewing the workspace side-by-side with our kanban board. However, if we want more room to review the code changes or test them in a browser, we can open the workspace in the **workspaces view**.\n\n<Frame>\n  <img src=\"/images/onboarding-workspaces-open.png\" alt=\"Workspaces open\" />\n</Frame>\n\nTo access the workspaces view, click the **Open Workspace** button.\n\n<Frame>\n  <img src=\"/images/onboarding-workspaces-page.png\" alt=\"Workspaces page\" />\n</Frame>\n\nDepending on what you'd like to do, you can view code changes or preview changes to websites in a browser using the floating navigation buttons (3).\n\nYou can also navigate back to either your project (1) or the parent issue (2).\n\n## 8. Merging a workspace\n\nWhen you're ready to merge the changes in a workspace, you can either open a GitHub pull request or merge the workspace branch locally.\n\n<Frame>\n  <img src=\"/images/onboarding-workspaces-merge.png\" alt=\"Workspaces merge\" />\n</Frame>\n\n## Next steps\n\n<CardGroup cols={2}>\n  <Card title=\"Previewing changes\" icon=\"browser\" href=\"/workspaces/preview\">\n    Set up a dev server, preview your app, and click-to-component to jump straight to source\n  </Card>\n\n  <Card title=\"Setup and cleanup scripts\" icon=\"terminal\" href=\"/settings/projects-repositories\">\n    Automate dependency installs, builds, and teardown so every workspace starts clean\n  </Card>\n\n  <Card title=\"Reviewing code changes\" icon=\"code-compare\" href=\"/workspaces/changes\">\n    Review diffs, prompt the agent with feedback, and iterate before merging\n  </Card>\n\n  <Card title=\"Working with sub-issues\" icon=\"diagram-subtask\" href=\"/core-features/subtasks\">\n    Break large features into smaller pieces and track progress across sub-issues\n  </Card>\n</CardGroup>"
  },
  {
    "path": "docs/index.mdx",
    "content": "---\ntitle: \"Vibe Kanban\"\ndescription: \"Plan and review the work of AI agents faster, ship more\"\nsidebarTitle: \"Home\"\n---\n\nIn a world where software engineers spend most of their time planning and reviewing coding agents, the most impactful way to ship more is to get faster at planning and review.\n\nVibe Kanban is built for this. Use kanban issues to plan work, either privately or with your team. When you’re ready to begin, create workspaces where coding agents can execute.\n\nWe're not a coding agent, but work seamlessly with a handful of the most popular options like Claude Code, Codex, Gemini CLI and OpenCode.\n\n```bash\nnpx vibe-kanban\n```\n\nOne command. Describe the work, review the diff, ship it.\n\n<Frame>\n  <img src=\"/images/onboarding-workspaces-logs.png\" alt=\"Vibe Kanban workspace showing the logs panel with command output from running processes\" />\n</Frame>"
  },
  {
    "path": "docs/integrations/azure-repos-integration.mdx",
    "content": "---\ntitle: \"Azure Repos Integration\"\ndescription: \"Connect to Azure Repos to create pull requests and manage your workflow directly from Vibe Kanban\"\n---\n\nVibe Kanban integrates with Azure Repos to let you create pull requests directly from your task attempts. This integration relies on the [Azure CLI (`az`)](https://learn.microsoft.com/en-us/cli/azure/) with the Azure DevOps extension being installed and authenticated on your system.\n\n## Setup\n\nBefore you can create pull requests from Vibe Kanban, you need to install and configure the Azure CLI manually.\n\n### Install Azure CLI\n\nFollow the [official installation instructions](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) for your operating system:\n\n- **macOS**: `brew install azure-cli`\n- **Windows**: Download and run the MSI installer from the Azure CLI documentation\n- **Linux**: Use your distribution's package manager or the install script\n\n### Install the Azure DevOps Extension\n\nThe Azure DevOps extension adds repository and pull request commands to the Azure CLI. Run the following command:\n\n```bash\naz extension add --name azure-devops\n```\n\n### Authenticate\n\n1.  **Sign in to Azure**: Run the following command and follow the prompts to authenticate via the web browser:\n    ```bash\n    az login\n    ```\n\n2.  **Configure defaults** (optional but recommended): Set your default organisation and project to avoid specifying them with each command:\n    ```bash\n    az devops configure --defaults organization=https://dev.azure.com/{your-org} project={your-project}\n    ```\n\n## Supported URL Formats\n\nVibe Kanban supports both modern and legacy Azure DevOps URL formats:\n\n- **Modern**: `https://dev.azure.com/{org}/{project}/_git/{repo}`\n- **Legacy**: `https://{org}.visualstudio.com/{project}/_git/{repo}`\n\nBoth HTTPS and SSH remote URLs are supported.\n\n## Creating a Pull Request\n\nOnce the Azure CLI is ready, you can create pull requests directly from a task:\n\n1.  Open a task that has changes you want to merge.\n2.  Click the **Create PR** button.\n3.  A dialog will appear pre-filled with:\n    *   **Title**: Derived from the task title.\n    *   **Description**: Derived from the task description.\n    *   **Base Branch**: The target branch for your changes (defaults to the repository's default branch or the one specified in the attempt).\n4.  Click **Create** to open the PR on Azure Repos.\n\nIf the operation is successful, the task status will update, and a link to the new Pull Request will be available.\n"
  },
  {
    "path": "docs/integrations/github-integration.mdx",
    "content": "---\ntitle: \"GitHub Integration\"\ndescription: \"Connect to GitHub to create pull requests and manage your workflow directly from Vibe Kanban\"\n---\n\nVibe Kanban integrates with GitHub to let you create pull requests directly from your task attempts. This integration relies on the [GitHub CLI (`gh`)](https://cli.github.com/) being installed and authenticated on your system.\n\n## Setup\n\nThere is no need to configure GitHub in the Vibe Kanban settings manually. The integration is designed to work out-of-the-box if you have the GitHub CLI installed.\n\n### Automatic Setup\nWhen you attempt to create your first Pull Request, Vibe Kanban will check if the GitHub CLI is available. If it's not found or not authenticated, you will be guided through the setup process:\n\n1.  **On macOS**: You will see a dialog offering to install the GitHub CLI via Homebrew automatically.\n2.  **On Windows/Linux**: You will be provided with instructions to install the CLI manually.\n\n### Manual Setup\nYou can also set up the integration manually in your terminal before using Vibe Kanban:\n\n1.  **Install GitHub CLI**: Follow the [official installation instructions](https://github.com/cli/cli#installation) for your operating system.\n2.  **Authenticate**: Run the following command in your terminal and follow the prompts:\n    ```bash\n    gh auth login\n    ```\n    Select **GitHub.com**, choose **HTTPS** or **SSH** as your preferred protocol, and complete the login via the web browser.\n\n## Creating a Pull Request\n\nOnce the GitHub CLI is ready, you can create pull requests directly from a task:\n\n1.  Open a task that has changes you want to merge.\n2.  Click the **Create PR** button.\n3.  A dialog will appear pre-filled with:\n    *   **Title**: Derived from the task title.\n    *   **Description**: Derived from the task description.\n    *   **Base Branch**: The target branch for your changes (defaults to the repository's default branch or the one specified in the attempt).\n4.  Click **Create** to open the PR on GitHub.\n\nIf the operation is successful, the task status will update, and a link to the new Pull Request will be available.\n"
  },
  {
    "path": "docs/integrations/mcp-server-configuration.mdx",
    "content": "---\ntitle: \"Connecting MCP Servers\"\ndescription: \"Configure MCP (Model Context Protocol) servers to enhance your coding agents within Vibe Kanban with additional tools and capabilities.\"\n---\n\n\n<Note>\nThis page covers configuring MCP servers **within** Vibe Kanban for your coding agents. For connecting external MCP clients to Vibe Kanban's MCP server, see the [Vibe Kanban MCP Server](/integrations/vibe-kanban-mcp-server) guide.\n</Note>\n\n## Overview\n\nMCP servers provide additional functionality to coding agents through standardized protocols. You can configure different MCP servers for each coding agent in Vibe Kanban, giving them access to specialized tools like browser automation, access to remote logs, error tracking via Sentry, or documentation from Notion.\n\n## Accessing MCP Server Configuration\n\n1. Navigate to **Settings** in the Vibe Kanban interface\n2. Click on **MCP Servers** in the settings sidebar\n3. Select the coding agent you want to configure MCP servers for from the **Agent** dropdown\n\n<Frame>\n<img src=\"/images/vk-mcp-server-config.png\" alt=\"MCP Server configuration page showing agent selection, JSON configuration, and popular servers\" />\n</Frame>\n\n## Popular MCP Servers\n\nVibe Kanban provides one-click installation for popular MCP servers. Click a card to insert a pre-configured MCP server into the JSON configuration.\n\n<Frame>\n<img src=\"/images/settings-popular-mcp-servers.png\" alt=\"Popular MCP servers including Vibe Kanban, Context7, Playwright, Exa, Chrome DevTools, and Dev Manager\" />\n</Frame>\n\n## Adding Custom MCP Servers\n\nYou can also add your own MCP servers by configuring them manually:\n\n<Steps>\n<Step title=\"Select Coding Agent\">\nChoose the coding agent you want to configure MCP servers for from the **Agent** dropdown.\n</Step>\n\n<Step title=\"Update Server Configuration JSON\">\nIn the **Server Configuration (JSON)** editor, add your custom MCP server configuration. The JSON will show the current configuration for the selected agent, and you can modify it to include additional servers.\n\nExample addition:\n```json\n{\n  \"mcpServers\": {\n    \"existing_server\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"some-existing-server\"]\n    },\n    \"my_custom_server\": {\n      \"command\": \"node\",\n      \"args\": [\"/path/to/my-server.js\"]\n    }\n  }\n}\n```\n</Step>\n\n<Step title=\"Save and Test\">\nAfter updating the JSON configuration:\n\n1. Click **Save** to apply changes\n2. Test the configuration by using the agent with MCP functionality\n3. Check agent logs for any connection issues\n\n<Check>\nThese changes update the global configuration file of the coding agent and will persist even if you stop using Vibe Kanban.\n</Check>\n</Step>\n</Steps>\n\n## Best Practices\n\n<Tip>\n**Server Selection**: Choose MCP servers that complement your coding agent's primary function. For example, use Playwright for agents focused on web development.\n</Tip>\n\n<Tip>\n**Limit MCP Servers**: Avoid adding too many MCP servers to a single coding agent. Too many servers and tools will degrade the effectiveness of coding agents by overwhelming them with options.\n</Tip>\n\n## Next Steps\n\n- Explore the [Agent Configurations](/settings/agent-configurations) guide for advanced agent setup\n- Check out [Supported Coding Agents](/supported-coding-agents) for agent-specific features\n"
  },
  {
    "path": "docs/integrations/vibe-kanban-mcp-server.mdx",
    "content": "---\ntitle: \"Vibe Kanban MCP Server\"\ndescription: \"Configure the Vibe Kanban MCP server\"\n---\n\nVibe Kanban exposes a local MCP (Model Context Protocol) server, allowing you to manage organisations, projects, issues, workspaces, and repositories from external MCP clients like Claude Desktop, Raycast, or even coding agents running within Vibe Kanban itself.\n\n<Note>\nThis page covers connecting **external MCP clients** to Vibe Kanban's MCP server. For configuring MCP servers **within** Vibe Kanban for your coding agents, see the [MCP Server Configuration](/integrations/mcp-server-configuration) guide.\n</Note>\n\n<Info>\nVibe Kanban's MCP server is **local-only** - it runs on your computer and can only be accessed by applications installed locally. It cannot be accessed via publicly accessible URLs.\n</Info>\n\n<video\n  controls\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"https://vkcdn.britannio.dev/vk-mcp-server.mp4\"\n></video>\n\n## Setting Up MCP Integration\n\n### Option 1: Using the Web Interface\n\nThis works if you're adding the Vibe Kanban MCP server to any [supported coding agent](/supported-coding-agents) **within** Vibe Kanban.\n\n1. In Vibe Kanban Settings, navigate to the \"MCP Servers\" page\n2. In the \"Popular servers\" section, click on the Vibe Kanban card\n3. Click the `Save Settings` button\n\n<Frame>\n<img src=\"/images/vk-mcp-server-config.png\" alt=\"MCP Servers configuration page showing how to add Vibe Kanban MCP server\" />\n</Frame>\n\n### Option 2: Manual Configuration\n\nYou can manually add the MCP server to your coding agent's configuration. The exact syntax will depend on your coding agent or MCP client.\n\nAdd the following configuration to your agent's MCP servers configuration:\n\n```json\n{\n  \"mcpServers\": {\n    \"vibe_kanban\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"vibe-kanban@latest\", \"--mcp\"]\n    }\n  }\n}\n```\n\n`--mcp` launches the local MCP stdio server. Any additional arguments after `--mcp` are passed through to the `vibe-kanban-mcp` binary.\n\n## Available MCP Tools\n\nThe Vibe Kanban MCP server provides tools for managing organisations, projects, issues, workspaces, and task execution.\n\n<Note>\nMany tools accept an optional `project_id` or `organization_id` parameter. When running inside a workspace linked to a remote project, these are inferred automatically from context and can be omitted. The exception is `list_projects`, which always requires an explicit `organization_id`.\n</Note>\n\n### Context\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `get_context` | Get current workspace context (only available within an active workspace session) | None | None | Project, issue, and workspace metadata |\n\n### Organisation Operations\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `list_organizations` | List all available organisations | None | None | List of organisations with IDs, names, and slugs |\n| `list_org_members` | List members of an organisation | None | `organization_id` | List of members with user IDs, roles, and profile info |\n\n### Project Operations\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `list_projects` | List projects in an organisation | `organization_id` | None | List of projects with IDs and names |\n\n### Issue Management\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `list_issues` | List issues in a project | None | `project_id`<br/>`status`<br/>`priority`<br/>`search`<br/>`simple_id`<br/>`parent_issue_id`<br/>`assignee_user_id`<br/>`tag_id`<br/>`tag_name`<br/>`limit`<br/>`offset` | Paginated list of issues with PR info |\n| `create_issue` | Create a new issue | `title` | `project_id`<br/>`description`<br/>`priority`<br/>`parent_issue_id` | Created issue ID |\n| `get_issue` | Get detailed issue information | `issue_id` | None | Full issue details with tags, relationships, sub-issues, and PRs |\n| `update_issue` | Update an existing issue | `issue_id` | `title`<br/>`description`<br/>`status`<br/>`priority`<br/>`parent_issue_id` | Updated issue details |\n| `delete_issue` | Delete an issue | `issue_id` | None | Deletion confirmation |\n| `list_issue_priorities` | List allowed priority values | None | None | List of priorities: urgent, high, medium, low |\n\n<Tip>\nFor `update_issue`, the `parent_issue_id` field supports three states: omit it entirely to leave the parent unchanged, pass `null` to un-nest the issue from its parent, or pass a UUID to set a new parent.\n</Tip>\n\n### Issue Assignees\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `list_issue_assignees` | List assignees for an issue | `issue_id` | None | List of assignees with user IDs |\n| `assign_issue` | Assign a user to an issue | `issue_id`<br/>`user_id` | None | Issue assignee ID |\n| `unassign_issue` | Remove an assignee from an issue | `issue_assignee_id` | None | Unassignment confirmation |\n\n### Issue Tags\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `list_tags` | List tags for a project | None | `project_id` | List of tags with IDs, names, and colours |\n| `list_issue_tags` | List tags attached to an issue | `issue_id` | None | List of issue-tag relations |\n| `add_issue_tag` | Attach a tag to an issue | `issue_id`<br/>`tag_id` | None | Issue-tag relation ID |\n| `remove_issue_tag` | Remove a tag from an issue | `issue_tag_id` | None | Removal confirmation |\n\n### Issue Relationships\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `create_issue_relationship` | Create a relationship between two issues | `issue_id`<br/>`related_issue_id`<br/>`relationship_type` | None | Relationship ID |\n| `delete_issue_relationship` | Delete a relationship between issues | `relationship_id` | None | Deletion confirmation |\n\nSupported relationship types: `blocking`, `related`, `has_duplicate`.\n\n### Repository Management\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `list_repos` | List all repositories | None | None | List of repositories with IDs and names |\n| `get_repo` | Get repository details including scripts | `repo_id` | None | Repository info with setup, cleanup, and dev server scripts |\n| `update_setup_script` | Update a repository's setup script | `repo_id`<br/>`script` | None | Update confirmation |\n| `update_cleanup_script` | Update a repository's cleanup script | `repo_id`<br/>`script` | None | Update confirmation |\n| `update_dev_server_script` | Update a repository's dev server script | `repo_id`<br/>`script` | None | Update confirmation |\n\n### Workspace Management\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `list_workspaces` | List local workspaces | None | `archived`<br/>`pinned`<br/>`branch`<br/>`name_search`<br/>`limit`<br/>`offset` | Paginated list of workspaces |\n| `update_workspace` | Update a workspace's properties | None | `workspace_id`<br/>`archived`<br/>`pinned`<br/>`name` | Updated workspace details |\n| `delete_workspace` | Delete a local workspace | None | `workspace_id`<br/>`delete_remote`<br/>`delete_branches` | Deletion confirmation |\n| `link_workspace_issue` | Link a workspace to a remote issue | `workspace_id`<br/>`issue_id` | None | Link confirmation |\n\n### Workspace Sessions\n\n| Tool | Purpose | Required Parameters | Optional Parameters | Returns |\n|------|---------|-------------------|-------------------|---------|\n| `start_workspace` | Create a workspace and start its first coding-agent session | `name`<br/>`executor`<br/>`repositories` | `prompt`<br/>`variant`<br/>`issue_id` | Workspace ID |\n| `create_session` | Create a session in an existing workspace | None | `workspace_id`<br/>`executor` | Session summary |\n| `list_sessions` | List sessions for a workspace | None | `workspace_id` | Session list |\n| `run_session_prompt` | Run a coding-agent prompt inside an existing session | `session_id`<br/>`prompt` | None | Execution details |\n| `get_execution` | Inspect execution status and final message | `execution_id` | None | Execution details |\n\nThe `repositories` parameter is an array of objects with:\n- `repo_id`: The repository ID (UUID)\n- `branch`: The branch for this repository\n\nWhen `issue_id` is provided, the workspace is automatically linked to the remote issue. If `prompt` is omitted, the linked issue's title and description are used as the workspace prompt.\n\n### Supported Executors\n\nWhen using `start_workspace`, the following executors are supported (case-insensitive, accepts hyphens or underscores):\n\n- `claude-code` / `CLAUDE_CODE`\n- `amp` / `AMP`\n- `gemini` / `GEMINI`\n- `codex` / `CODEX`\n- `opencode` / `OPENCODE`\n- `cursor_agent` / `CURSOR_AGENT`\n- `qwen-code` / `QWEN_CODE`\n- `copilot` / `COPILOT`\n- `droid` / `DROID`\n\n## Using the MCP Server\n\nOnce you have the MCP server configured, you can leverage it to streamline your project planning and execution workflow:\n\n1. **Plan Your Work**: Describe a large feature or project to your MCP client\n2. **Request Issue Creation**: At the end of your description, simply add \"then turn this plan into issues\"\n3. **Automatic Issue Generation**: Your MCP client will use the Vibe Kanban MCP server to automatically create structured issues in your project\n4. **Start Workspaces**: Use `start_workspace` to programmatically begin work on issues with specific coding agents\n\n## Example Usage\n\n### Planning and Issue Creation\n\n```\nI need to build a user authentication system with the following features:\n- User registration with email validation\n- Login/logout functionality\n- Password reset capability\n- Session management\n- Protected routes\n\nThen turn this plan into issues.\n```\n\nYour MCP client will use the `create_issue` tool to break this down into individual issues and add them to your Vibe Kanban project automatically.\n\n### Starting a Workspace Session\n\nAfter issues are created, you can start work on them programmatically:\n\n```\nStart working on the user registration issue using Claude Code on the main branch.\n```\n\nYour MCP client will use the `start_workspace` tool with parameters like:\n```json\n{\n  \"name\": \"User registration with email validation\",\n  \"executor\": \"claude-code\",\n  \"repositories\": [\n    {\n      \"repo_id\": \"987fcdeb-51a2-3d4e-b678-426614174001\",\n      \"branch\": \"main\"\n    }\n  ],\n  \"issue_id\": \"123e4567-e89b-12d3-a456-426614174000\"\n}\n```\n\nThis creates a new workspace, links it to the issue, generates a feature branch, and starts the coding agent in an isolated environment.\n\n### Complete Workflow Example\n\n```\n1. List organisations to find the organisation ID\n2. List projects in the organisation to find the project ID\n3. List issues in the project filtered by status\n4. Create a new issue for \"Add user profile page\"\n5. Assign a team member to the issue\n6. Start a workspace session for the issue using Amp on the develop branch\n```\n\nEach step uses the appropriate MCP tool (`list_organizations`, `list_projects`, `list_issues`, `create_issue`, `assign_issue`, `start_workspace`) to manage the complete workflow from planning to execution.\n\n### Internal Coding Agents (Within Vibe Kanban)\n\nA powerful workflow involves using coding agents within Vibe Kanban that are also connected to the Vibe Kanban MCP server:\n\n1. **Create a Planning Issue**: Create an issue with a custom agent profile configured with a planning prompt. See [Agent Configurations](/settings/agent-configurations) for details on creating custom profiles.\n2. **Explore and Plan**: The coding agent explores the codebase and develops a comprehensive plan\n3. **Generate Issues**: Ask the coding agent to \"create a series of individual issues for this plan\"\n4. **Automatic Population**: The agent uses the MCP server to populate individual issues directly in the Vibe Kanban interface\n\nThis creates a seamless workflow where high-level planning automatically generates actionable issues in your project board.\n\n## Installation Instructions for MCP Clients\n\n### Raycast Example\n\nRaycast is a popular MCP client that can connect to Vibe Kanban's MCP server. Here's how to configure it:\n\nFor complete Raycast MCP configuration details, see the [official Raycast MCP documentation](https://manual.raycast.com/model-context-protocol).\n\n<Tabs>\n<Tab title=\"Step 1: Open MCP Server Installer\">\n<Frame>\n<img src=\"/images/vk-raycast-mcp-part-1.png\" alt=\"Raycast MCP configuration - adding Vibe Kanban server\" />\n</Frame>\n\nConfigure the Vibe Kanban MCP server in Raycast by adding the server details.\n</Tab>\n\n<Tab title=\"Step 2: Supply Command\">\n<Frame>\n<img src=\"/images/vk-raycast-mcp-part-2.png\" alt=\"Raycast MCP configuration - server successfully added\" />\n</Frame>\n\nOnce configured, you'll see the Vibe Kanban MCP server listed and ready to use in Raycast.\n</Tab>\n</Tabs>\n\n<Note>\nSimilar configuration steps apply to other MCP clients like Claude Desktop, VS Code with MCP extensions, or any custom MCP client implementations.\n</Note>\n"
  },
  {
    "path": "docs/integrations/vscode-extension.mdx",
    "content": "---\ntitle: \"VSCode Extension Integration\"\ndescription: \"Complete guide to using the Vibe Kanban extension with VSCode, Cursor, and Windsurf\"\n---\n\nThe Vibe Kanban VSCode extension brings task management directly into your IDE, providing seamless integration with logs, diffs, and process monitoring. This extension works with VSCode and popular forks including Cursor and Windsurf.\n\n<video\n  controls\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"https://vkcdn.britannio.dev/vk-ide-extension-light.mp4\"\n></video>\n\n## Installation\n\n<Tabs>\n<Tab title=\"VSCode\">\nInstall directly from the Visual Studio Code Marketplace:\n\n<Card title=\"VSCode Marketplace\" icon=\"download\" href=\"https://marketplace.visualstudio.com/items?itemName=bloop.vibe-kanban\">\n  Install the official Vibe Kanban extension for VSCode\n</Card>\n\nAlternatively, search for the extension ID in VSCode:\n1. Open VSCode\n2. Press `Ctrl+Shift+X` (Windows/Linux) or `Cmd+Shift+X` (macOS) to open Extensions\n3. Search for `@id:bloop.vibe-kanban`\n4. Click **Install**\n</Tab>\n\n<Tab title=\"Cursor\">\nFor Cursor, use the Open VSX Registry:\n\n<Card title=\"Open VSX Registry\" icon=\"download\" href=\"https://open-vsx.org/extension/bloop/vibe-kanban\">\n  Install from Open VSX for Cursor compatibility\n</Card>\n\n<Tip>\nSince deeplinking from Open VSX doesn't work reliably in Cursor, the easiest installation method is searching by extension ID within the IDE.\n</Tip>\n\n**Installation Steps**:\n1. Open Cursor\n2. Open the Extensions panel\n3. Search for `@id:bloop.vibe-kanban`\n4. Install the extension\n</Tab>\n\n<Tab title=\"Windsurf\">\nFor Windsurf, use the Open VSX Registry:\n\n<Card title=\"Open VSX Registry\" icon=\"download\" href=\"https://open-vsx.org/extension/bloop/vibe-kanban\">\n  Install from Open VSX for Windsurf compatibility\n</Card>\n\n<Tip>\nSince deeplinking from Open VSX doesn't work reliably in Windsurf, the easiest installation method is searching by extension ID within the IDE.\n</Tip>\n\n**Installation Steps**:\n1. Open Windsurf\n2. Open the Extensions panel\n3. Search for `@id:bloop.vibe-kanban`\n4. Install the extension\n</Tab>\n</Tabs>\n\n## Features\n\nThe extension provides an integrated workspace view with three main components:\n\n### Logs View\n- List of task attempts for the current task\n- Agent steps performed in each task attempt\n\n### Diffs View  \n- Side-by-side comparison of code changes\n- Inline commenting and review capabilities\n\n### Processes View\n- Monitor running task processes\n- Process status indicators (running, completed, failed)\n\n### Task Management\n- **Task Iteration**: Continue iterating on the current ongoing task\n- **Status Updates**: Real-time task status synchronization\n\n## Usage Workflow\n\n<Steps>\n<Step title=\"Start a task in Vibe Kanban\">\n  Navigate to your project's kanban board and create or select an existing task.\n</Step>\n\n<Step title=\"Open task\">\n  Click on the task to open its detailed view.\n</Step>\n\n<Step title=\"Launch IDE integration\">\n  Click the **\"Open in VSCode\"**, **\"Open in Cursor\"**, or **\"Open in Windsurf\"** button depending on your preferred IDE.\n  \n  <Note>\n  The button text will reflect your editor choice configured in Vibe Kanban settings.\n  </Note>\n</Step>\n\n<Step title=\"Work in your IDE\">\n  Your IDE will open in the task's dedicated worktree with the extension UI populated with:\n  - Current task context\n  - Real-time logs\n  - Code diffs\n  - Process monitoring\n</Step>\n</Steps>\n\n## Troubleshooting\n\n### Empty Extension UI\n\n**Problem**: The extension UI appears empty or shows no task information.\n\n**Solution**: Ensure you're working within a worktree created by a Vibe Kanban task.\n\n<Warning>\nThe extension can only display task information when VSCode is opened in a directory that corresponds to an active Vibe Kanban task worktree.\n</Warning>\n\n**Steps to resolve**:\n1. Verify you opened VSCode through the \"Open in [IDE]\" button from a Vibe Kanban task\n2. Check that you're in the correct directory/worktree\n3. If the issue persists, restart your IDE and try the workflow again\n\n### Extension Not Loading\n\n**Problem**: The Vibe Kanban extension doesn't appear in your IDE.\n\n**Solutions**:\n- Verify the extension is installed by searching `@id:bloop.vibe-kanban` in Extensions\n- Restart your IDE after installation\n- Check that you're using a compatible IDE version\n- For Cursor/Windsurf users, ensure you installed from Open VSX Registry\n\n## Best Practices\n\n<Tip>\n**Workflow Optimization**: Always start tasks from the Vibe Kanban web interface before opening in your IDE to ensure proper context and worktree setup.\n</Tip>\n\n<Tip>\n**Performance**: Close unused IDE windows to reduce resource usage.\n</Tip>\n\n## Supported IDEs\n\n| IDE | Status | Installation Method |\n|-----|--------|-------------------|\n| **VSCode** | ✅ Fully Supported | VSCode Marketplace |\n| **Cursor** | ✅ Fully Supported | Open VSX Registry |  \n| **Windsurf** | ✅ Fully Supported | Open VSX Registry |\n\n<Info>\nThe extension is designed to work with any VSCode-compatible editor. If you're using a different VSCode fork, try installing from Open VSX Registry using the extension ID `bloop.vibe-kanban`.\n</Info>\n"
  },
  {
    "path": "docs/issue-management.mdx",
    "content": "---\ntitle: \"Issue Management\"\ndescription: \"Create, organise, and track issues from first draft to done on the kanban board\"\n---\n\nIssues are the fundamental unit of work in Vibe Kanban. They represent a bug, feature, or piece of work to be done — and they become the prompt your coding agent receives when you create a workspace. This guide walks you through the full issue lifecycle.\n\n## 1. Create your first issue\n\nYou can create an issue from the **+** button on any column header, or from the **New Issue** button in the filter bar.\n\n<Frame>\n  <img src=\"/images/issue-mgmt-create-button.png\" alt=\"Kanban board showing the New Issue button in the header and the plus button on a column\" />\n</Frame>\n\nUse the **Team** and **Personal** tabs to switch between all project issues and only those assigned to you.\n\n<Frame>\n  <img src=\"/images/issue-mgmt-team-personal.png\" alt=\"Filter bar showing the Team and Personal tabs\" />\n</Frame>\n\nGive your issue a clear, specific title that describes the outcome you want. The issue panel opens on the right where you can fill in the details.\n\n<Frame>\n  <img src=\"/images/issue-mgmt-create-panel.png\" alt=\"New issue panel with title field and description editor\" />\n</Frame>\n\n## 2. Write an effective description\n\nThe description is where you provide context for your coding agent. Use the rich text editor to format your instructions with bold, italic, lists, and inline code. You can also use markdown shortcuts for headings (`#`) and links (`[text](url)`).\n\n<Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/issue-mgmt-description.png\" alt=\"Issue description editor with formatted text including headings and code blocks\" />\n</Frame>\n\nA good description makes the difference between an agent that delivers exactly what you need and one that goes off track.\n\n| Element | Weak example | Strong example |\n|---------|-------------|----------------|\n| **Title** | \"Fix bug\" | \"Fix login timeout on slow connections\" |\n| **Description** | \"It's broken\" | \"Users on 3G see a timeout after 5 s. Expected: graceful retry with exponential backoff.\" |\n\n<Tip>\nYour issue description becomes the prompt your coding agent receives. The more specific you are, the better the result.\n</Tip>\n\n## 3. Set priority, assignee, and tags\n\nUse priority to signal urgency, assign the issue to a team member, and add tags to categorise your work.\n\n<Frame>\n  <img src=\"/images/issue-mgmt-priority-tags.png\" alt=\"Issue panel showing the priority dropdown and tags selector\" />\n</Frame>\n\n| Priority | When to use |\n|----------|-------------|\n| **Urgent** | Production is down or users are blocked |\n| **High** | Important work that should be picked up next |\n| **Medium** | Standard work in the current sprint |\n| **Low** | Nice-to-have improvements or future ideas |\n\n<Info>\nTags are project-specific. You can manage them from the tag selector when editing an issue.\n</Info>\n\n## 4. Manage issues on the kanban board\n\nDrag and drop issues between columns to change their status, or within a column to reorder them.\n\n<Frame>\n  <img src=\"/images/issue-mgmt-kanban-board.png\" alt=\"Kanban board showing issues being dragged between columns\" />\n</Frame>\n\n| Column | What it means |\n|--------|---------------|\n| **To do** | Work that hasn't started yet |\n| **In progress** | An agent or person is actively working on this |\n| **In review** | Work is done and waiting for your review |\n| **Done** | Completed and verified |\n\nThe **Backlog** and **Cancelled** columns are hidden by default — switch to the **All** tab to see them.\n\n<Frame>\n  <img src=\"/images/issue-mgmt-all-tab.png\" alt=\"All tab selected showing Backlog and Cancelled statuses alongside the default columns\" />\n</Frame>\n\n<Tip>\nTo reorder issues within a column, make sure sorting is set to **Manual**. Other sort modes (Priority, Created, Updated) override manual ordering.\n</Tip>\n\n## 5. Break work down with sub-issues\n\nLarge features are easier to manage when you break them into smaller, agent-sized tasks. Sub-issues let you create parent/child relationships between issues.\n\nTo add a sub-issue, open the parent issue and scroll to the **Sub-Issues** section. You have two options: click the **+** button to create a new sub-issue, or click the link button to connect an existing issue as a child.\n\n<Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/issue-mgmt-sub-issues.png\" alt=\"Sub-issues section showing child issues with mixed statuses\" />\n</Frame>\n\nKey rules for sub-issues:\n\n- Each sub-issue has its own **independent status** — completing all children does not auto-complete the parent\n- Sub-issues show a **Parent** link so you can navigate back to the parent issue\n\n## 6. Connect issues to workspaces\n\nA workspace is where your coding agent does the actual work. Open an issue and scroll to the **Workspaces** section. You have two options: click the **+** button to create a new workspace, or click the link button to connect an existing one.\n\n<Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/issue-mgmt-link-workspace.png\" alt=\"Issue panel showing the Workspaces section with Create and link buttons\" />\n</Frame>\n\n<Info>\nYou can connect multiple workspaces to a single issue — useful for running agents in parallel on different parts of the same feature.\n</Info>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"Issue doesn't appear on the board\">\n- Check your active filters — click **Clear All** to reset them\n- The issue might be in **Backlog** or **Cancelled** — switch to the **All** tab to see hidden statuses\n</Accordion>\n\n<Accordion title=\"Can't drag issues to reorder them\">\nSort mode must be set to **Manual**. If sorting is set to Priority, Created, or another mode, drag-to-reorder is disabled. Change the sort option in the board header.\n</Accordion>\n\n<Accordion title=\"Description changes aren't saving\">\nChanges auto-save after a brief delay. Check your internet connection if changes aren't persisting, and try refreshing the page.\n</Accordion>\n</AccordionGroup>\n\n## Next steps\n\n<CardGroup cols={2}>\n  <Card title=\"Filtering & Sorting\" icon=\"filter\" href=\"/cloud/filtering\">\n    Find issues quickly with search, filters, and sorting\n  </Card>\n\n  <Card title=\"Kanban Board\" icon=\"table-columns\" href=\"/cloud/kanban-board\">\n    Learn more about the board layout and collaboration\n  </Card>\n\n  <Card title=\"Workspaces\" icon=\"terminal\" href=\"/workspaces/index\">\n    Understand how workspaces connect to issues\n  </Card>\n</CardGroup>\n\n"
  },
  {
    "path": "docs/remote-access.mdx",
    "content": "---\ntitle: \"Remote Access\"\ndescription: \"Access your local Vibe Kanban instance from another device\"\n---\n\nRemote Access allows you to access a host instance of Vibe Kanban from another device, like your mobile phone.\n\n# 1. Launch Vibe Kanban on the host device\n\nMake sure you've started Vibe Kanban and logged in.\n\n# 2. Generate a pairing code\n\nOpen the settings dialog, navigate to the `Remote Access` tab and click `Show pairing code`:\n\n<Frame>\n  <img src=\"/images/remote-access-generate-pairing-code-settings.png\" alt=\"Remote Access settings page on the host device with the Show pairing code button\" />\n</Frame>\n\nMake a note of the pairing code:\n\n<Frame>\n  <img src=\"/images/remote-access-generate-pairing-code-modal.png\" alt=\"Pairing code modal on the host device showing the generated code\" />\n</Frame>\n\n# 3. Log into Vibe Kanban cloud\n\nOn the client device where you'd now like to access your host machine from, navigate to [cloud.vibekanban.com](https://cloud.vibekanban.com) in a browser and log in.\n\n# 4. Pair your client with your host\n\nOn your client, navigate to `Remote Access` and click `Link a host`. Select the relevant host from the dropdown:\n\n<Frame>\n  <img style={{maxHeight: \"500px\"}} src=\"/images/remote-access-pair-host-mobile-form.png\" alt=\"Remote Access page on mobile showing the Pair host form with host selection and pairing code input\" />\n</Frame>\n\nEnter the pairing code you generated earlier and click `Pair host`:\n\n<Frame>\n  <img style={{maxHeight: \"500px\"}} src=\"/images/remote-access-pair-host-mobile-select.png\" alt=\"Remote Access page on mobile with the host dropdown opened before pairing\" />\n</Frame>\n\n# 5. Access workspaces from your host device\n\nYou can now exit the settings page. You should see the paired host, clicking on the host will list the host's workspaces.\n\n<Frame>\n  <img style={{maxHeight: \"500px\"}} src=\"/images/remote-access-cloud-mobile.png\" alt=\"Vibe Kanban cloud remote access page on mobile showing a paired host and workspaces\" />\n</Frame>\n"
  },
  {
    "path": "docs/responsible-disclosure.mdx",
    "content": "---\ntitle: \"Responsible Disclosure\"\ndescription: \"How to report security vulnerabilities to Vibe Kanban responsibly\"\n---\n\n**Last updated:** *February 28, 2026*\n\nAt **Vibe Kanban**, we take the security of our platform and the safety of\nour customers' data seriously. We welcome responsible reports of potential\nsecurity vulnerabilities to help us identify and resolve issues quickly and\nsecurely.\n\n## How to Report a Security Issue\n\nIf you believe you've discovered a vulnerability in Vibe Kanban that falls\nwithin scope, please send an email to:\n\n[**security@bloop.com**](mailto:security@bloop.com)\n\nWhen submitting a report, include the following where possible:\n\n- **Summary** of the vulnerability and its potential impact\n- **Steps to reproduce** the issue (screenshots or clear descriptions help)\n- **Environment details** (OS, browser, device, etc.)\n- **Proof-of-concept code** or any relevant exploit details\n\nUpon receipt of your report, we will:\n\n1. Acknowledge it in a timely manner.\n2. Investigate and triage the issue.\n3. Communicate with you for clarification or retesting if needed.\n4. Work to remediate the issue and keep you updated.\n\n## What's In Scope\n\nThe following services and assets are currently in scope for responsible\ndisclosure:\n\n- https://cloud.vibekanban.com (remote server)\n- https://relay.vibekanban.com (relay server)\n\nIn most cases, we will only reward the following types of vulnerabilities:\n- Arbitrary code execution\n- SQL injection\n\nIf you are unsure whether something is in scope, please contact us before\ntesting.\n\n## What's Out of Scope\n\nTo ensure everyone's safety and to focus on issues that genuinely affect our\nusers, the following are considered out of scope:\n\n- Automated scanning without prior coordination\n- Social engineering targeting Vibe Kanban personnel\n- Rate-limiting or missing headers that do not lead to material harm\n- Brute force or denial-of-service attacks\n- Attacks requiring physical access to systems or interception of another\n  user's network traffic\n- Theoretical vulnerabilities without a practical proof of exploitability\n\n## Please Do Not\n\n- Access or modify any data that does not belong to you\n- Disrupt our services or cause downtime\n- Share details of the issue publicly before we have had a chance to fix it\n\n## Report Format Recommendations\n\nTo help us diagnose issues efficiently, reports should include:\n\n- A clear **summary and title** of the issue\n- Affected URL(s) or components\n- Exact **steps to reproduce**, including screenshots where appropriate\n- Environment and version details\n- Proof-of-concept code or payloads\n\n## Safe Harbour & Recognition\n\nWe respect the efforts of security researchers who act in good faith and follow\nthis Responsible Disclosure policy. Provided you comply with this policy,\n**Vibe Kanban will not pursue legal action** against individuals reporting\nvulnerabilities responsibly.\n\nResearchers who submit valid and impactful reports may also receive\n**recognition** or other discretionary rewards, at Vibe Kanban's sole\ndiscretion.\n\n## Confidentiality\n\nAll information you share with us as part of your report will be handled\nconfidentially. We will not disclose sensitive details publicly before\nremediation, and we will coordinate with you if public acknowledgement is\nplanned.\n\n## Bounty & Rewards\n\nVibe Kanban may offer monetary rewards for qualifying vulnerability reports.\n\nFor critical, well-documented disclosures that demonstrate clear impact\n(such as remote code execution), we may pay up to $5,000 USD per vulnerability.\n\nRewards are determined at our sole discretion and depend on factors such as:\n\n- Severity and impact of the issue\n- Quality and clarity of the report\n- Reproducibility\n- Whether the vulnerability is previously unknown\n\nNot all reports will qualify for a reward."
  },
  {
    "path": "docs/reviewing-code.mdx",
    "content": "---\ntitle: \"Reviewing Code\"\ndescription: \"Review diffs, add inline comments, and send feedback to your coding agent\"\n---\n\nAfter a coding agent finishes work, you review its changes, leave inline comments, and send the agent back to address them. This review-feedback-fix loop repeats until you are satisfied.\n\n## 1. Open the changes panel\n\nThere are three ways to open the changes panel:\n\n- Click the **Toggle Changes Panel** button in the navbar\n- Click the changes icon in the **app bar**\n- Open the [command bar](/workspaces/command-bar) from its icon in the navbar or with `Cmd/Ctrl + K`, then search for \"Show Changes Panel\"\n\n<Frame>\n  <img src=\"/images/reviewing-code-agent-idle.png\" alt=\"Workspace showing the Toggle Changes Panel button in the navbar\" />\n</Frame>\n\nThe changes panel shows the diff viewer in the centre and the file tree on the right.\n\n<Frame>\n  <img src=\"/images/reviewing-code-changes-panel.png\" alt=\"Changes panel showing the file tree on the left and diff viewer on the right\" />\n</Frame>\n\n## 2. Navigate the file tree\n\nThe file tree shows all files that were added, modified, or deleted. Click any file to load its diff in the viewer.\n\n<Frame>\n  <img src=\"/images/reviewing-code-file-tree.png\" alt=\"File tree with folders expanded and search box visible\" />\n</Frame>\n\nUse the search box at the top to filter files by name. The toggle button in the search bar lets you expand or collapse all folders at once.\n\n<Tip>\nStart with the files you care most about — the main feature file or the entry point — rather than reviewing alphabetically. This gives you context for the rest of the changes.\n</Tip>\n\n## 3. Read diffs\n\nThe diff viewer uses colour coding to show what changed: green for additions, red for deletions, and grey for unchanged context lines.\n\nYou can switch between two view modes:\n\n| Mode | Best for | How to switch |\n|------|----------|---------------|\n| **Unified** | Quick scanning, small changes | Diff view toggle in the navbar |\n| **Side-by-Side** | Large refactors, comparing old vs new | Same toggle, or `Cmd/Ctrl + K` → \"Switch to Side-by-Side View\" |\n\n<Frame>\n  <img src=\"/images/reviewing-code-unified.png\" alt=\"Unified diff view showing interleaved additions and deletions\" />\n</Frame>\n\n<Frame>\n  <img src=\"/images/reviewing-code-side-by-side.png\" alt=\"Side-by-side diff view showing additions in green and deletions in red\" />\n</Frame>\n\nYou can also switch views from the [command bar](/workspaces/command-bar) (`Cmd/Ctrl + K` → \"Switch to Side-by-Side View\").\n\n## 4. Add inline comments\n\nTo leave feedback on a specific line, hover over it in the diff and click the comment icon that appears. Write your comment and submit it.\n\n<Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/reviewing-code-inline-comment.png\" alt=\"Inline comment being written on a diff line with the Add Review Comment button\" />\n</Frame>\n\nGood comments are specific and actionable. Here are some examples:\n\n| Comment type | Example |\n|-------------|---------|\n| **Request a change** | \"This endpoint should validate the user ID before querying the database.\" |\n| **Ask a question** | \"Why did you choose a Map here instead of a plain object?\" |\n| **Provide context** | \"This function is called from the auth middleware — it needs to handle expired tokens.\" |\n\n<Warning>\nComments are not sent individually. They are collected and submitted together when you send a message in the chat.\n</Warning>\n\n## 5. Send feedback to the agent\n\nAfter adding your comments, send them to the agent. You can include an optional message for extra context, or just send the comments on their own. A badge shows how many review comments will be included.\n\n<Frame>\n  <img src=\"/images/reviewing-code-agent-response.png\" alt=\"Chat showing review comments being sent to the agent\" />\n</Frame>\n\nThe agent sees all inline comments as context and works through them.\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"The changes panel is empty\">\n- The agent may not have made any changes yet — check the workspace status\n- If all changes were committed and pushed, the panel resets. Check the Git section for the latest commit.\n</Accordion>\n\n<Accordion title=\"I can't see my inline comments after sending\">\nComments are consumed when you send a message. They become part of the chat history. Add new comments for the next review round.\n</Accordion>\n\n<Accordion title=\"The agent didn't address my comment\">\nBe more specific. Instead of \"this is wrong\", explain what is wrong and suggest a fix. Reference exact line numbers or variable names so the agent knows exactly where to look.\n</Accordion>\n\n</AccordionGroup>\n\n## Next steps\n\n<CardGroup cols={2}>\n  <Card title=\"Issue Management\" icon=\"list-check\" href=\"/issue-management\">\n    Create and organise issues on the kanban board\n  </Card>\n\n  <Card title=\"Browser Testing\" icon=\"browser\" href=\"/browser-testing\">\n    Preview your app in the built-in browser\n  </Card>\n\n  <Card title=\"Changes Panel\" icon=\"code-compare\" href=\"/workspaces/changes\">\n    Full reference for diff viewer and comment features\n  </Card>\n\n  <Card title=\"Git Operations\" icon=\"code-branch\" href=\"/workspaces/git-operations\">\n    Create pull requests, rebase, merge, and manage branches\n  </Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/self-hosting/deploy-docker.mdx",
    "content": "---\ntitle: \"Deploy with Docker Compose\"\ndescription: \"Deploy Vibe Kanban Cloud on any server using Docker Compose\"\n---\n\nThis guide covers deploying Vibe Kanban Cloud on any Linux server using Docker Compose. This approach works with any cloud provider (AWS, DigitalOcean, Hetzner, etc.) or on-premises server.\n\n## Prerequisites\n\n- A Linux server with:\n  - **Docker** and **Docker Compose** v2.0+ installed\n  - **2GB RAM** minimum (4GB recommended)\n  - **10GB disk space**\n  - A **domain name** pointing to your server\n- **SSL certificate** (we'll use Caddy for automatic HTTPS)\n- OAuth credentials from [GitHub](https://github.com/settings/developers) or [Google](https://console.cloud.google.com/apis/credentials)\n\n## Step 1: Prepare Your Server\n\nSSH into your server and install Docker if not already installed:\n\n```bash\n# Install Docker (Ubuntu/Debian)\ncurl -fsSL https://get.docker.com | sh\nsudo usermod -aG docker $USER\n\n# Log out and back in for group changes to take effect\n```\n\nClone the Vibe Kanban repository:\n\n```bash\ngit clone https://github.com/BloopAI/vibe-kanban.git\ncd vibe-kanban\n```\n\n## Step 2: Configure OAuth\n\nUpdate your OAuth application callback URLs to use your domain:\n\n<Tabs>\n<Tab title=\"GitHub\">\n- **Authorization callback URL**: `https://your-domain.com/v1/oauth/github/callback`\n</Tab>\n\n<Tab title=\"Google\">\n- **Authorized redirect URI**: `https://your-domain.com/v1/oauth/google/callback`\n</Tab>\n</Tabs>\n\n## Step 3: Create Environment File\n\nGenerate a secure JWT secret:\n\n```bash\nopenssl rand -base64 48\n```\n\nCreate `.env.remote` in the repository root:\n\n```env .env.remote\n# Required secrets\nVIBEKANBAN_REMOTE_JWT_SECRET=<your_generated_jwt_secret>\nELECTRIC_ROLE_PASSWORD=<secure_password_for_electric>\nDB_PASSWORD=<secure_database_password>\n\n# Your domain\nDOMAIN=your-domain.com\n\n# Relay API base URL (required if you enable relay/tunnel)\nVITE_RELAY_API_BASE_URL=https://relay.your-domain.com\n\n# OAuth — configure at least one provider. Leave the other empty or remove it.\nGITHUB_OAUTH_CLIENT_ID=your_github_client_id\nGITHUB_OAUTH_CLIENT_SECRET=your_github_client_secret\nGOOGLE_OAUTH_CLIENT_ID=\nGOOGLE_OAUTH_CLIENT_SECRET=\n\n# Email (optional — leave empty to disable invitation emails)\nLOOPS_EMAIL_API_KEY=\n```\n\n## Step 4: Create Production Docker Compose\n\nCreate `docker-compose.prod.yml` in the `crates/remote` directory:\n\n```yaml docker-compose.prod.yml\nservices:\n  caddy:\n    image: caddy:2-alpine\n    restart: unless-stopped\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n    environment:\n      DOMAIN: ${DOMAIN}\n    volumes:\n      - ./Caddyfile:/etc/caddy/Caddyfile\n      - caddy_data:/data\n      - caddy_config:/config\n    depends_on:\n      - remote-server\n\n  remote-db:\n    image: postgres:16-alpine\n    command: [\"postgres\", \"-c\", \"wal_level=logical\"]\n    restart: unless-stopped\n    environment:\n      POSTGRES_DB: remote\n      POSTGRES_USER: remote\n      POSTGRES_PASSWORD: ${DB_PASSWORD:-remote}\n    volumes:\n      - remote-db-data:/var/lib/postgresql/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U remote -d remote\"]\n      interval: 5s\n      timeout: 5s\n      retries: 5\n      start_period: 5s\n\n  electric:\n    image: electricsql/electric:1.3.3\n    working_dir: /app\n    restart: unless-stopped\n    environment:\n      DATABASE_URL: postgresql://electric_sync:${ELECTRIC_ROLE_PASSWORD}@remote-db:5432/remote?sslmode=disable\n      PG_PROXY_PORT: 65432\n      LOGICAL_PUBLISHER_HOST: electric\n      AUTH_MODE: insecure\n      ELECTRIC_INSECURE: true\n      ELECTRIC_MANUAL_TABLE_PUBLISHING: true\n      ELECTRIC_USAGE_REPORTING: false\n      ELECTRIC_FEATURE_FLAGS: allow_subqueries,tagged_subqueries\n    volumes:\n      - electric-data:/app/persistent\n    depends_on:\n      remote-db:\n        condition: service_healthy\n      remote-server:\n        condition: service_healthy\n\n  remote-server:\n    build:\n      context: ../..\n      dockerfile: crates/remote/Dockerfile\n      args:\n        VITE_RELAY_API_BASE_URL: ${VITE_RELAY_API_BASE_URL:-}\n    restart: unless-stopped\n    depends_on:\n      remote-db:\n        condition: service_healthy\n    environment:\n      RUST_LOG: info,remote=info\n      SERVER_DATABASE_URL: postgres://remote:${DB_PASSWORD:-remote}@remote-db:5432/remote\n      SERVER_LISTEN_ADDR: 0.0.0.0:8081\n      ELECTRIC_URL: http://electric:3000\n      SERVER_PUBLIC_BASE_URL: https://${DOMAIN}\n      GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}\n      GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}\n      GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}\n      GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}\n      VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET}\n      ELECTRIC_ROLE_PASSWORD: ${ELECTRIC_ROLE_PASSWORD}\n      LOOPS_EMAIL_API_KEY: ${LOOPS_EMAIL_API_KEY:-}\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://127.0.0.1:8081/v1/health\"]\n      interval: 5s\n      timeout: 5s\n      retries: 10\n      start_period: 10s\n\nvolumes:\n  remote-db-data:\n  electric-data:\n  caddy_data:\n  caddy_config:\n```\n\n## Step 5: Create Caddyfile\n\nCreate a `Caddyfile` in the `crates/remote` directory for automatic HTTPS (core app/API):\n\n```text Caddyfile\n{$DOMAIN} {\n    reverse_proxy remote-server:8081\n}\n```\n\n<Info>\nThis base deployment serves the main Cloud app/API only. Relay/tunnel support is optional and requires additional relay routing plus wildcard DNS/TLS for your relay domain.\n</Info>\n\n## Step 6: Deploy\n\n```bash\ncd crates/remote\n\n# Build and start all services\ndocker compose --env-file ../../.env.remote -f docker-compose.prod.yml up -d --build\n\n# View logs\ndocker compose -f docker-compose.prod.yml logs -f\n```\n\n<Info>\nThe first build takes 10-15 minutes. Subsequent deployments are faster as Docker caches the build layers.\n</Info>\n\n## Step 7: Verify Deployment\n\n1. Open `https://your-domain.com` in your browser\n2. You should see the Vibe Kanban Cloud login page\n3. Sign in with your OAuth provider\n4. Create your first organisation and project\n\n## Optional: Enable Relay/Tunnel in Production\n\nRelay/tunnel support requires:\n\n1. A running `relay-server` service\n2. Reverse proxy routing for both `relay.your-domain.com` and `*.relay.your-domain.com`\n3. A wildcard certificate for `*.relay.your-domain.com` (or equivalent managed TLS at your edge)\n4. `VITE_RELAY_API_BASE_URL` set to your public relay API base URL before building `remote-server`\n\n### Add relay-server to docker compose\n\n```yaml docker-compose.prod.yml\n  relay-server:\n    build:\n      context: ../..\n      dockerfile: crates/relay-tunnel/Dockerfile\n    restart: unless-stopped\n    depends_on:\n      remote-db:\n        condition: service_healthy\n    environment:\n      RUST_LOG: info\n      SERVER_DATABASE_URL: postgres://remote:${DB_PASSWORD:-remote}@remote-db:5432/remote\n      RELAY_LISTEN_ADDR: 0.0.0.0:8082\n      VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET}\n```\n\n### Add relay proxy routes\n\nYour reverse proxy must route:\n\n- `relay.your-domain.com` -> `relay-server:8082`\n- `*.relay.your-domain.com` -> `relay-server:8082`\n\n<Warning>\nStandard ACME HTTP challenge does not issue wildcard certificates. For wildcard relay hostnames, use a DNS-based ACME challenge or another edge provider that can terminate wildcard TLS certificates.\n</Warning>\n\n## Updating\n\nTo update to a new version:\n\n```bash\ncd vibe-kanban\ngit pull origin main\n\ncd crates/remote\ndocker compose --env-file ../../.env.remote -f docker-compose.prod.yml up -d --build\n```\n\n## Backup and Restore\n\n### Backup Database\n\n```bash\ndocker compose -f docker-compose.prod.yml exec remote-db \\\n  pg_dump -U remote remote > backup_$(date +%Y%m%d).sql\n```\n\n### Restore Database\n\n```bash\ndocker compose -f docker-compose.prod.yml exec -T remote-db \\\n  psql -U remote remote < backup_20240101.sql\n```\n\n## Monitoring\n\n### View Logs\n\n```bash\n# All services\ndocker compose -f docker-compose.prod.yml logs -f\n\n# Specific service\ndocker compose -f docker-compose.prod.yml logs -f remote-server\n```\n\n### Check Service Health\n\n```bash\ndocker compose -f docker-compose.prod.yml ps\n```\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"SSL certificate issues\">\nCaddy automatically obtains SSL certificates from Let's Encrypt. Ensure:\n- Your domain's DNS is correctly pointing to your server\n- Ports 80 and 443 are open in your firewall\n- Your domain is correctly set in the environment\n</Accordion>\n\n<Accordion title=\"Database connection refused\">\nThe server may start before the database is ready. Check:\n\n```bash\ndocker compose -f docker-compose.prod.yml logs remote-db\ndocker compose -f docker-compose.prod.yml restart remote-server\n```\n</Accordion>\n\n<Accordion title=\"ElectricSQL fails to connect\">\nElectricSQL requires the `electric_sync` database user, which the Remote Server creates automatically on first startup. If ElectricSQL cannot connect:\n\n1. Check that the Remote Server started successfully and ran its migrations\n2. Verify `ELECTRIC_ROLE_PASSWORD` matches in both your `.env.remote` and the Electric service config\n3. Restart ElectricSQL after the Remote Server is healthy:\n\n```bash\ndocker compose -f docker-compose.prod.yml restart electric\n```\n</Accordion>\n\n<Accordion title=\"Out of memory errors\">\nIf the build fails with memory errors, you may need a server with more RAM or add swap:\n\n```bash\nsudo fallocate -l 2G /swapfile\nsudo chmod 600 /swapfile\nsudo mkswap /swapfile\nsudo swapon /swapfile\n```\n</Accordion>\n</AccordionGroup>\n"
  },
  {
    "path": "docs/self-hosting/local-development.mdx",
    "content": "---\ntitle: \"Local Development\"\ndescription: \"Run Vibe Kanban Cloud locally\"\n---\n\nThis guide walks you through setting up a local Vibe Kanban Cloud instance for development and testing.\n\n## Prerequisites\n\nBefore you begin, ensure you have:\n\n- **Docker** and **Docker Compose** installed\n- **Git** to clone the repository\n- **Node.js 20+** and **pnpm** (for running the desktop client)\n- A **GitHub** or **Google** OAuth application\n\n## Step 1: Clone the Repository\n\n```bash\ngit clone https://github.com/BloopAI/vibe-kanban.git\ncd vibe-kanban\n```\n\n## Step 2: Create OAuth Application\n\nYou need at least one OAuth provider. Choose GitHub, Google, or both.\n\n<Tabs>\n<Tab title=\"GitHub OAuth\">\n\n1. Go to [GitHub Developer Settings](https://github.com/settings/developers)\n2. Click **New OAuth App**\n3. Fill in the details:\n   - **Application name**: Vibe Kanban Local\n   - **Homepage URL**: `http://localhost:3000`\n   - **Authorization callback URL**: `http://localhost:3000/v1/oauth/github/callback`\n4. Click **Register application**\n5. Copy the **Client ID**\n6. Click **Generate a new client secret** and copy it\n\n</Tab>\n\n<Tab title=\"Google OAuth\">\n\n1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)\n2. Create a new project or select an existing one\n3. Click **Create Credentials** → **OAuth client ID**\n4. Select **Web application**\n5. Add authorized redirect URI: `http://localhost:3000/v1/oauth/google/callback`\n6. Click **Create**\n7. Copy the **Client ID** and **Client Secret**\n\n</Tab>\n</Tabs>\n\n## Step 3: Configure Environment\n\nCreate a `.env.remote` file in `crates/remote/`:\n\n```bash\n# Generate a secure JWT secret\nopenssl rand -base64 48\n```\n\nCopy the output and create your `.env.remote`:\n\n```env .env.remote\n# Required - JWT secret for authentication\nVIBEKANBAN_REMOTE_JWT_SECRET=<paste_your_generated_secret_here>\n\n# Optional - Password for ElectricSQL database role (electric_sync user)\nELECTRIC_ROLE_PASSWORD=\n\n# OAuth — configure at least one provider. Leave the other empty or remove it.\nGITHUB_OAUTH_CLIENT_ID=your_github_client_id\nGITHUB_OAUTH_CLIENT_SECRET=your_github_client_secret\n\n# Google OAuth (leave empty if not using Google login)\nGOOGLE_OAUTH_CLIENT_ID=\nGOOGLE_OAUTH_CLIENT_SECRET=\n\n# Relay (required for relay/tunnel features)\n# For plain HTTP local dev:\nVITE_RELAY_API_BASE_URL=http://localhost:8082\n\n# Email invitations (optional — leave empty to disable)\nLOOPS_EMAIL_API_KEY=\n```\n\nFor production or self-hosting on a server, add `PUBLIC_BASE_URL` (your public URL, e.g. `https://kanban.example.com`) and `REMOTE_SERVER_PORTS=0.0.0.0:3000:8081` so the server is reachable from other hosts. Defaults keep local dev unchanged.\n\n<Warning>\nNever commit `.env.remote` to version control. It's already in `.gitignore`.\n</Warning>\n\n## Step 4: Start the Stack\n\nFrom the `crates/remote` directory, start all services:\n\n```bash\ncd crates/remote\ndocker compose --env-file .env.remote -f docker-compose.yml up --build\n```\n\nOr from the repo root:\n\n```bash\npnpm run remote:dev\n```\n\nThis starts:\n- **PostgreSQL** on port 5433 (external) / 5432 (internal)\n- **ElectricSQL** on port 3000 (internal only, used by Remote Server for real-time sync)\n- **Remote Server** on port 3000 (external) / 8081 (internal)\n- **Relay Server** on port 8082 (external and internal)\n\n<Warning>\nThe remote server binds to `127.0.0.1:3000` only - it's not accessible from other machines. For production, use a reverse proxy.\n</Warning>\nWait until you see health checks passing:\n\n```\nremote-server-1  | INFO remote: Server listening on 0.0.0.0:8081\n```\n\n## Step 5: Access the Web Interface\n\nOpen [http://localhost:3000](http://localhost:3000) in your browser. You should see the Vibe Kanban Cloud login page.\n\nSign in with your configured OAuth provider (GitHub or Google).\n\n## Step 6: Connect the Desktop Client (Optional)\n\nTo use the desktop client with your local server:\n\n```bash\n# In a new terminal, from the repository root\nexport VK_SHARED_API_BASE=http://localhost:3000\n\npnpm install\npnpm run dev\n```\n\nThe desktop client will now connect to your local Cloud instance instead of the hosted version.\n\nTo test relay/tunnel mode end-to-end, add:\n\n```bash\nexport VK_SHARED_API_BASE=https://localhost:3001\nexport VK_SHARED_RELAY_API_BASE=https://relay.localhost:3001\nexport VK_TUNNEL=1\n```\n\nThis mode requires local HTTPS + Caddy routing (next step).\n\n## Step 7: Optional Local HTTPS + Caddy (required for tunnel-mode testing)\n\nCreate a Caddy config that routes:\n- `localhost:3001` -> remote server (`127.0.0.1:3000`)\n- `relay.localhost:3001` and `*.relay.localhost:3001` -> relay server (`127.0.0.1:8082`)\n\n```bash\ncaddy run --config - --adapter caddyfile <<'EOF'\nlocalhost:3001, relay.localhost:3001, *.relay.localhost:3001 {\n  tls internal\n\n  @relay host relay.localhost *.relay.localhost\n  handle @relay {\n    reverse_proxy 127.0.0.1:8082\n  }\n\n  @app expression `{http.request.host} == \"localhost:3001\" || {http.request.host} == \"localhost\"`\n  handle @app {\n    reverse_proxy 127.0.0.1:3000\n  }\n\n  respond \"not found\" 404\n}\nEOF\n```\n\nIf you use this HTTPS setup, update OAuth callback URLs to:\n- GitHub: `https://localhost:3001/v1/oauth/github/callback`\n- Google: `https://localhost:3001/v1/oauth/google/callback`\n\n## Stopping the Stack\n\nTo stop all services:\n\n```bash\ndocker compose down\n```\n\nTo stop and remove all data (fresh start):\n\n```bash\ndocker compose down -v\n```\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"Database connection errors\">\nEnsure the database is healthy before the server starts:\n\n```bash\n# Check database status\ndocker compose ps\n\n# View database logs\ndocker compose logs remote-db\n```\n</Accordion>\n\n<Accordion title=\"OAuth callback errors\">\nVerify your OAuth callback URLs match exactly for your setup:\n- HTTP local stack:\n  - GitHub: `http://localhost:3000/v1/oauth/github/callback`\n  - Google: `http://localhost:3000/v1/oauth/google/callback`\n- HTTPS + Caddy:\n  - GitHub: `https://localhost:3001/v1/oauth/github/callback`\n  - Google: `https://localhost:3001/v1/oauth/google/callback`\n</Accordion>\n\n<Accordion title=\"ElectricSQL fails to connect\">\nElectricSQL requires the `electric_sync` database user, which is created automatically by the Remote Server on first startup. If ElectricSQL fails to connect:\n\n1. Ensure the Remote Server has started successfully and run its migrations\n2. Check that `ELECTRIC_ROLE_PASSWORD` is set in your `.env.remote`\n3. Restart the stack — ElectricSQL will retry the connection\n\n```bash\ndocker compose --env-file .env.remote -f docker-compose.yml restart electric\n```\n</Accordion>\n\n<Accordion title=\"Port already in use\">\nIf port 3000 is in use, you can change it in `docker-compose.yml`:\n\n```yaml\nports:\n  - \"127.0.0.1:3001:8081\"  # Change 3001 to your preferred port\n```\n\nUpdate your OAuth callback URLs accordingly.\n</Accordion>\n\n<Accordion title=\"Relay health endpoint returns HTML instead of JSON\">\n**Problem:** `curl -sk https://relay.localhost:3001/health` returns HTML, and relay/tunnel fails.\n\n**Cause:** Caddy routed relay hostnames to the remote app (`:3000`) instead of relay server (`:8082`).\n\n**Solution:**\n1. Use host-specific routing for `relay.localhost` and `*.relay.localhost`\n2. Verify:\n   - `curl -sk https://relay.localhost:3001/health` returns `{\"status\":\"ok\"}`\n   - `curl -sk https://localhost:3001/v1/health` returns remote server health JSON\n</Accordion>\n</AccordionGroup>\n\n## Next Steps\n\nOnce you have local development working, you can:\n\n- Deploy to Fly.io for production (coming soon)\n- Deploy with Docker Compose on your own server (coming soon)\n"
  },
  {
    "path": "docs/settings/agent-configurations.mdx",
    "content": "---\ntitle: \"Agent Profiles & Configuration\"\ndescription: \"Create reusable agent configurations with custom settings for planning mode, model selection, and permission levels that you can quickly switch between when creating task attempts\"\n---\n\n<Info>\nAgent profiles are configured using a form-based interface. Select an agent and create or edit configuration variants to customise behaviour.\n</Info>\n\n<Info>\nAgent profiles are used throughout Vibe Kanban wherever agents run: onboarding, default settings, attempt creation, and follow-ups. The default configuration appears pre-selected in the agent dropdown in the chat input.\n</Info>\n\n## Common Use Cases\n\n| Use Case | Example Configuration |\n|----------|----------------------|\n| **Fast iteration** | Disable planning mode, use faster model |\n| **Complex tasks** | Enable planning mode, use advanced model |\n| **Autonomous work** | Skip permission prompts (use with caution) |\n| **Code review** | Enable approvals for all changes |\n| **Multi-instance** | Enable Claude Code Router for parallel work |\n\n## Key Concepts\n\nUnderstanding these concepts helps you configure agents effectively:\n\n<AccordionGroup>\n<Accordion title=\"Planning Mode\">\nWhen enabled, the agent first creates a detailed plan before writing code. This adds an extra step where you review and approve the approach before implementation begins. Useful for complex tasks where you want to validate the strategy, but adds overhead for simple changes.\n</Accordion>\n\n<Accordion title=\"Permission Prompts\">\nAgents request permission before performing potentially destructive actions like deleting files, running shell commands, or modifying system configurations. Skipping these prompts (`dangerously_skip_permissions`) allows fully autonomous operation but removes safety guardrails.\n</Accordion>\n\n<Accordion title=\"Claude Code Router\">\nDistributes requests across multiple Claude Code instances running in parallel. Useful when working on several tasks simultaneously or when you want to reduce wait times by load-balancing across instances.\n</Accordion>\n\n<Accordion title=\"Sandbox Modes (Codex)\">\nControls what the agent can access:\n- **read-only** - Can read files but not modify anything\n- **workspace-write** - Can modify files within the project directory\n- **danger-full-access** - Unrestricted file system access\n</Accordion>\n\n<Accordion title=\"Approval Levels\">\nDetermines when the agent pauses for your confirmation:\n- **untrusted** - Asks before every action\n- **on-failure** - Only asks when something goes wrong\n- **on-request** - Only asks when explicitly requested\n- **never** - Fully autonomous (no confirmations)\n</Accordion>\n\n<Accordion title=\"Reasoning Effort\">\nControls how much computational effort the model spends \"thinking\" before responding. Higher reasoning produces more thorough analysis but takes longer and uses more tokens. Lower reasoning is faster but may miss nuances in complex problems.\n</Accordion>\n</AccordionGroup>\n\n## Configuration Access\n\nAccess agent profiles via **Settings → Agents** in the Workspaces UI.\n\n<Frame>\n<img src=\"/images/settings-agents.png\" alt=\"Agent configuration form editor interface with finder-style layout\" />\n</Frame>\n\nThe interface uses a finder-style two-column layout:\n\n- **Left column** - Lists all available agents\n- **Right column** - Lists configurations for the selected agent\n\n## Managing Configurations\n\n### Creating a New Configuration\n\n1. Click the **+** button in the Configurations column\n\n<Frame>\n<img src=\"/images/settings-agents-create-button.png\" alt=\"Configuration column showing the + button with Create new tooltip\" />\n</Frame>\n\n2. Enter a **Configuration Name** (e.g., PRODUCTION, DEVELOPMENT)\n3. Optionally select a configuration to **Clone from**, or start blank\n4. Click **Create Configuration**\n\n<Frame>\n<img src=\"/images/settings-agents-create-dialog.png\" alt=\"Create New Configuration dialog with name field and clone option\" />\n</Frame>\n\n### Editing a Configuration\n\n1. Select an agent from the left column\n2. Select a configuration from the right column\n3. Edit the settings in the form below\n4. Click **Save** in the bottom bar\n\n### Deleting a Configuration\n\n1. Select the configuration to delete\n2. Click the delete button\n3. Confirm deletion\n\n<Warning>\nYou cannot delete the only configuration for an agent. Each agent must have at least one configuration.\n</Warning>\n\n### Setting a Default Configuration\n\nClick the **Default** badge next to a configuration to make it the default for that agent. The default configuration:\n\n- Appears pre-selected in the agent dropdown when creating new attempts\n- Is used for follow-up messages in workspaces\n- Shows in the chat input toolbar as the current agent/variant\n\n## Agent Configuration Options\n\n<Tabs>\n<Tab title=\"CLAUDE_CODE\">\n  <ParamField path=\"plan\" type=\"boolean\">\n  Enable planning mode for complex tasks\n  </ParamField>\n\n  <ParamField path=\"claude_code_router\" type=\"boolean\">\n  Route requests across multiple Claude Code instances\n  </ParamField>\n\n  <ParamField path=\"dangerously_skip_permissions\" type=\"boolean\">\n  Skip permission prompts (use with caution)\n  </ParamField>\n\n  [View full CLI reference →](https://docs.anthropic.com/en/docs/claude-code/cli-reference#cli-flags)\n</Tab>\n\n<Tab title=\"GEMINI\">\n  <ParamField path=\"model\" type=\"string\">\n  Choose model variant: `\"default\"` or `\"flash\"`\n  </ParamField>\n\n  <ParamField path=\"yolo\" type=\"boolean\">\n  Run without confirmations\n  </ParamField>\n\n  [View full CLI reference →](https://google-gemini.github.io/gemini-cli/)\n</Tab>\n\n<Tab title=\"AMP\">\n  <ParamField path=\"dangerously_allow_all\" type=\"boolean\">\n  Allow all actions without restrictions (unsafe)\n  </ParamField>\n\n  [View full documentation →](https://ampcode.com/manual#cli)\n</Tab>\n\n<Tab title=\"CODEX\">\n  <ParamField path=\"sandbox\" type=\"string\">\n  Execution environment: `\"read-only\"`, `\"workspace-write\"`, or `\"danger-full-access\"`\n  </ParamField>\n\n  <ParamField path=\"approval\" type=\"string\">\n  Approval level: `\"untrusted\"`, `\"on-failure\"`, `\"on-request\"`, or `\"never\"`\n  </ParamField>\n\n  <ParamField path=\"model_reasoning_effort\" type=\"string\">\n  Reasoning depth: `\"low\"`, `\"medium\"`, or `\"high\"`\n  </ParamField>\n\n  <ParamField path=\"model_reasoning_summary\" type=\"string\">\n  Summary style: `\"auto\"`, `\"concise\"`, `\"detailed\"`, or `\"none\"`\n  </ParamField>\n\n  [View full documentation →](https://github.com/openai/codex)\n</Tab>\n\n<Tab title=\"CURSOR\">\n  <ParamField path=\"force\" type=\"boolean\">\n  Force execution without confirmation\n  </ParamField>\n\n  <ParamField path=\"model\" type=\"string\">\n  Specify model to use\n  </ParamField>\n\n  [View full CLI reference →](https://docs.cursor.com/en/cli/reference/parameters)\n</Tab>\n\n<Tab title=\"OPENCODE\">\n  <ParamField path=\"model\" type=\"string\">\n  Specify model to use\n  </ParamField>\n\n  <ParamField path=\"agent\" type=\"string\">\n  Choose agent type\n  </ParamField>\n\n  [View full documentation →](https://opencode.ai/docs/cli/#flags-1)\n</Tab>\n\n<Tab title=\"QWEN_CODE\">\n  <ParamField path=\"yolo\" type=\"boolean\">\n  Run without confirmations\n  </ParamField>\n\n  [View full documentation →](https://qwenlm.github.io/qwen-code-docs/en/cli/index)\n</Tab>\n\n<Tab title=\"DROID\">\n  <ParamField path=\"autonomy\" type=\"string\">\n  Permission level: `\"normal\"`, `\"low\"`, `\"medium\"`, `\"high\"`, or `\"skip-permissions-unsafe\"`\n  </ParamField>\n\n  <ParamField path=\"model\" type=\"string\">\n  Specify which model to use\n  </ParamField>\n\n  <ParamField path=\"reasoning_effort\" type=\"string\">\n  Reasoning depth: `\"off\"`, `\"low\"`, `\"medium\"`, or `\"high\"`\n  </ParamField>\n\n  [View full documentation →](https://docs.factory.ai/factory-cli/getting-started/overview)\n</Tab>\n</Tabs>\n\n### Universal Options\n\nThese options work across multiple agent types:\n\n<ParamField path=\"append_prompt\" type=\"string | null\">\nText appended to the system prompt\n</ParamField>\n\n<Warning>\nOptions prefixed with \"dangerously_\" bypass safety confirmations and can perform destructive actions. Use with extreme caution.\n</Warning>\n\n## Environment Variables\n\nEach agent profile can define environment variables that are injected into the agent process at launch. This is useful for pointing an agent at a non-default API endpoint, supplying API keys, or running Claude Code against third-party providers.\n\nSet environment variables in the **Environment Variables** section of the agent configuration form.\n\n<Frame>\n<img src=\"/images/settings-agents-env-vars.png\" alt=\"Agent configuration form showing the Environment Variables section with KEY and value inputs\" />\n</Frame>\n\n<Info>\nProfile environment variables override any variables already set in your shell. This means you can keep your default Anthropic credentials for normal use and create separate profiles that point to different providers — each profile is fully isolated.\n</Info>\n\n### Using Third-Party Providers with Claude Code\n\nMany providers expose an OpenAI-compatible or Anthropic-compatible API. You can use them with Claude Code by creating a dedicated profile with the right environment variables.\n\n<Tabs>\n<Tab title=\"Z.ai (GLM)\">\nCreate a profile named **GLM** (or any name) and set:\n\n| Variable | Value |\n|----------|-------|\n| `ANTHROPIC_BASE_URL` | `https://api.z.ai/api/anthropic` |\n| `ANTHROPIC_AUTH_TOKEN` | Your Z.ai API key |\n\nZ.ai automatically maps Claude models to GLM equivalents, so no model override is needed.\n\nSee the [Z.ai manual setup guide](https://docs.z.ai/devpack/tool/claude#manual-configuration) for details.\n</Tab>\n\n<Tab title=\"OpenRouter\">\nCreate a profile named **OPENROUTER** and set:\n\n| Variable | Value |\n|----------|-------|\n| `ANTHROPIC_BASE_URL` | `https://openrouter.ai/api` |\n| `ANTHROPIC_AUTH_TOKEN` | Your OpenRouter API key |\n| `ANTHROPIC_API_KEY` | `\"\"` (empty string) |\n\nSetting `ANTHROPIC_API_KEY` to an empty string prevents Claude Code from falling back to Anthropic's servers.\n\nSee the [OpenRouter Claude Code integration guide](https://openrouter.ai/docs/guides/guides/claude-code-integration) for details.\n</Tab>\n\n<Tab title=\"Custom / Self-Hosted\">\nFor any Anthropic-compatible endpoint, refer to your provider's documentation for the required environment variables. Common variables include:\n\n| Variable | Purpose |\n|----------|---------|\n| `ANTHROPIC_BASE_URL` | Provider's API endpoint |\n| `ANTHROPIC_AUTH_TOKEN` | Authentication token |\n| `ANTHROPIC_API_KEY` | API key (set to `\"\"` if using `AUTH_TOKEN` instead) |\n</Tab>\n</Tabs>\n\n<Tip>\nYour original Anthropic configuration remains untouched. Select the **GLM**, **OPENROUTER**, or custom profile from the agent dropdown when creating a workspace to use the third-party provider, and switch back to the **DEFAULT** profile for normal Anthropic usage.\n</Tip>\n\n## Using Agent Configurations\n\nOnce configured, your agent variants appear in the chat input toolbar, allowing quick switching between configurations.\n\n<Frame>\n<img src=\"/images/workspaces-agent-selection.png\" alt=\"Chat input showing agent variant dropdown with Default, Approvals, Opus, and Plan options\" />\n</Frame>\n\n<CardGroup cols={2}>\n<Card title=\"Default Configuration\" icon=\"gear\">\n  Set your default agent and variant in **Settings → General → Default Coding Agent** for consistent behaviour across all attempts.\n</Card>\n\n<Card title=\"Per-Attempt Selection\" icon=\"rocket\">\n  Override defaults when creating attempts by selecting different agent/variant combinations in the attempt dialogue.\n</Card>\n</CardGroup>\n\n## Related Configuration\n\n<Note>\nMCP (Model Context Protocol) servers are configured separately under **Settings → MCP Servers** but work alongside agent profiles to extend functionality.\n</Note>\n\n<Card title=\"Connecting MCP Servers\" icon=\"server\" href=\"/settings/mcp-servers\">\n  Configure MCP servers within Vibe Kanban for your coding agents\n</Card>\n"
  },
  {
    "path": "docs/settings/creating-task-tags.mdx",
    "content": "---\ntitle: \"Creating Tags\"\ndescription: \"Create reusable text snippets that can be quickly inserted into workspace prompts using @mentions. Tags are available globally across all projects.\"\n---\n\n## What are tags?\n\nTags are reusable text snippets that you can quickly insert into workspace prompts by typing `@` followed by the tag name. When you select a tag, its content is automatically inserted at your cursor position.\n\n<Tip>\nTags use snake_case naming (no spaces allowed). For example: `bug_report`, `feature_request`, or `code_review_checklist`.\n</Tip>\n\n## Managing tags\n\nAccess tags from **Settings → General → Tags**. Tags are available globally across all projects in your workspace.\n\n<Frame>\n<img src=\"/images/settings-tags.png\" alt=\"Tags management interface showing the tag list with names and content\" />\n</Frame>\n\n<Steps>\n<Step title=\"Create a new tag\">\n  Click **Add Tag** to create a new tag.\n\n  <Frame>\n  <img src=\"/images/settings-create-task-tag.png\" alt=\"Create tag dialogue showing tag name and content fields\" />\n  </Frame>\n\n  - **Tag name**: Use snake_case without spaces (e.g., `acceptance_criteria`)\n  - **Content**: The text that will be inserted when the tag is used\n</Step>\n\n<Step title=\"Edit existing tags\">\n  Click the edit icon (✏️) next to any tag to modify its name or content.\n</Step>\n\n<Step title=\"Remove unwanted tags\">\n  Click the delete icon (🗑️) to remove tags you no longer need.\n\n  <Warning>\n  Deleting a tag does not affect existing tasks that already have the tag's content inserted.\n  </Warning>\n</Step>\n</Steps>\n\n## Using tags\n\nInsert tags into workspace prompts and follow-up messages using @mention autocomplete.\n\n<Steps>\n<Step title=\"Trigger autocomplete\">\n  When writing a prompt, type `@` to trigger the autocomplete dropdown.\n\n  <Frame>\n  <img src=\"/images/settings-task-tag-autocomplete.png\" alt=\"Autocomplete dropdown showing available tags after typing @ symbol\" />\n  </Frame>\n</Step>\n\n<Step title=\"Search and select\">\n  Continue typing to filter tags by name, then:\n  - Click on a tag to select it\n  - Use arrow keys to navigate and press Enter to select\n  - Press Escape to close the dropdown\n\n  <Check>\n  The tag's content is automatically inserted at your cursor position, replacing the @query.\n  </Check>\n</Step>\n</Steps>\n\n## Common use cases\n\n<AccordionGroup>\n<Accordion title=\"Bug report templates\">\nCreate a `bug_report` tag with standardised bug reporting fields:\n\n```\n**Description:**\n\n**Steps to reproduce:**\n1.\n2.\n3.\n\n**Expected behaviour:**\n\n**Actual behaviour:**\n\n**Environment:**\n```\n</Accordion>\n\n<Accordion title=\"Acceptance criteria checklists\">\nCreate an `acceptance_criteria` tag for feature requirements:\n\n```\n**Acceptance criteria:**\n- [ ] Functionality works as specified\n- [ ] Unit tests added\n- [ ] Documentation updated\n- [ ] Accessibility requirements met\n- [ ] Performance benchmarks passed\n```\n</Accordion>\n\n<Accordion title=\"Code review guidelines\">\nCreate a `code_review` tag with review checklist items:\n\n```\n**Code review checklist:**\n- [ ] Code follows project conventions\n- [ ] Tests cover edge cases\n- [ ] No security vulnerabilities introduced\n- [ ] Performance impact assessed\n- [ ] Documentation is clear\n```\n</Accordion>\n</AccordionGroup>\n\n<Tip>\nTags work in all text fields that support the @mention feature, including workspace prompts and follow-up messages, making it easy to maintain consistency across your tasks.\n</Tip>\n"
  },
  {
    "path": "docs/settings/general.mdx",
    "content": "---\ntitle: \"Overview\"\ndescription: \"Configure appearance, default agent, editor, git, and notification preferences\"\n---\n\nThe General tab contains application-wide settings for appearance, default agent, editor configuration, git preferences, and notifications.\n\n<Frame>\n<img src=\"/images/settings-general.png\" alt=\"General settings tab showing appearance, default agent, and editor options\" />\n</Frame>\n\n## Appearance\n\nCustomise how the application looks and feels:\n\n- **Theme** - Choose between Light and Dark colour schemes\n- **Language** - Select your preferred language (Browser Default follows your system language)\n\n## Default Coding Agent\n\n<Frame>\n<img src=\"/images/settings-default-coding-agent.png\" alt=\"Default Coding Agent section showing agent and variant dropdowns\" />\n</Frame>\n\nSet the coding agent that will be used by default when creating new task attempts or follow-ups.\n\n- **Agent** - Select your preferred coding agent (e.g., Claude Code, Gemini CLI, Codex). This determines which AI assistant handles your coding tasks.\n- **Variant** - Choose a configuration variant for the selected agent (e.g., Default, Opus, Approvals). Variants contain different settings like planning mode, model selection, or permission levels.\n\nThe selected agent and variant appear pre-selected in the attempt creation dialog, saving you time when starting new tasks.\n\n<Tip>\nYou can override the default agent configuration per attempt in the create attempt dialog. The default is just a convenience for your most common workflow.\n</Tip>\n\n## Editor\n\n<Frame>\n<img src=\"/images/settings-editor.png\" alt=\"Editor settings showing Editor Type dropdown and Remote SSH Host input\" />\n</Frame>\n\nConfigure your code editing experience.\n\n### Selecting Your Editor\n\nChoose from various supported editors:\n\n- **VS Code** - Microsoft's popular code editor\n- **Cursor** - VSCode fork with AI-native features\n- **Windsurf** - VSCode fork optimised for collaborative development\n- **Zed** - High-performance code editor\n- **Antigravity** - Google's AI-native code editor\n- **Neovim**, **Emacs**, **Sublime Text** - Other popular editors\n- **Custom** - Use a custom shell command\n\n### Custom Editor Example\n\nWhen selecting **Custom**, you can specify any shell command to open files. The command receives the file or directory path as an argument.\n\n```bash\n# Example: Open with IntelliJ IDEA\nidea\n\n# Example: Open with Sublime Text (custom path)\n/Applications/Sublime\\ Text.app/Contents/SharedSupport/bin/subl\n\n# Example: Open with a custom script\n~/scripts/open-editor.sh\n```\n\n### Opening Your Editor\n\n<Frame>\n<img src=\"/images/settings-open-in-ide.png\" alt=\"Open in IDE option in the context bar\" />\n</Frame>\n\nOnce configured, you can open your editor from several places:\n\n- **Context bar** - Click your IDE logo icon in the floating context bar to open the workspace\n- **Command bar** - Press `Cmd/Ctrl + K` and select \"Open in IDE\"\n\n<Tip>\nThe context bar shows your configured IDE's logo. For more details, see the [Workspaces Interface Guide](/workspaces/interface).\n</Tip>\n\n## Remote SSH Configuration\n\nWhen running Vibe Kanban on a remote server (e.g., accessed via Cloudflare tunnel, ngrok, or as a systemctl service), you can configure VSCode-based editors to open projects via SSH instead of assuming localhost.\n\nThis feature is available for **VS Code**, **Cursor**, and **Windsurf** editors.\n\n### When to Use Remote SSH\n\nEnable remote SSH configuration when:\n- Vibe Kanban runs on a remote server (VPS, cloud instance, etc.)\n- You access the web UI through a tunnel or reverse proxy\n- Your code files are on a different machine than your browser\n- You want your local editor to connect to the remote server via SSH\n\n### Configuration Fields\n\n- **Remote SSH Host** (Optional) - The hostname or IP address of your remote server (e.g., `example.com`, `192.168.1.100`, `my-server`). Must be accessible via SSH from your local machine.\n\n### How It Works\n\nWhen remote SSH is configured, clicking \"Open in Editor\" (or Cursor/Windsurf):\n1. Generates a special protocol URL like: `vscode://vscode-remote/ssh-remote+user@host/path/to/project`\n2. Opens in your default browser, which launches your local editor\n3. Your editor connects to the remote server via SSH\n4. The project or task worktree opens in the remote context\n\nThis works for both project-level and task worktree opening.\n\n### Prerequisites\n\n- SSH access configured between your local machine and remote server\n- SSH keys or credentials set up (no password prompts)\n- VSCode Remote-SSH extension installed (or equivalent for Cursor/Windsurf)\n- The remote server path must be accessible via SSH\n\n<Tip>\nTest your SSH connection first with `ssh user@host` to ensure it works without prompting for passwords.\n</Tip>\n\n## Git\n\n<Frame>\n<img src=\"/images/settings-git.png\" alt=\"Git settings showing Branch Prefix and Workspace Directory fields\" />\n</Frame>\n\nConfigure git branch naming and workspace storage preferences.\n\n### Branch Prefix\n\nSet a prefix for auto-generated branch names. When you create a new workspace or task, Vibe Kanban automatically creates a git branch for your changes.\n\n| Prefix Setting | Example Branch Name |\n|----------------|---------------------|\n| `vk` | `vk/1a2b-implement-auth` |\n| `feature` | `feature/1a2b-implement-auth` |\n| *(empty)* | `1a2b-implement-auth` |\n\n<Tip>\nUse a prefix that matches your team's branching conventions (e.g., `feature`, `fix`, or your initials).\n</Tip>\n\n### Workspace Directory\n\nSpecify where Vibe Kanban stores workspace data. Workspaces are created in a `.vibe-kanban-workspaces` subdirectory within this path.\n\n- **Default location** - Leave empty to use the system default (typically your home directory)\n- **Custom location** - Set a specific path if you prefer workspaces on a different drive or directory\n\n<Warning>\nChanges to Workspace Directory require an app restart. Existing workspaces remain in their original location.\n</Warning>\n\n## Notifications\n\n<Frame>\n<img src=\"/images/settings-notifications.png\" alt=\"Notifications settings showing sound effects and push notifications toggles\" />\n</Frame>\n\nConfigure how Vibe Kanban alerts you about task progress and status changes.\n\n- **Sound Effects** - Play audio notifications when tasks complete, need attention, or encounter errors. Useful when working with multiple tasks or when Vibe Kanban runs in a background tab.\n- **Push Notifications** - Receive browser notifications even when Vibe Kanban isn't in focus. Requires browser permission when first enabled.\n\n<Tip>\nEnable notifications if you frequently run long-running tasks and want to be alerted when they complete or need your attention.\n</Tip>\n\n## Telemetry\n\nEnable or disable telemetry data collection to help improve Vibe Kanban.\n\n## Message Input\n\nChoose the keyboard shortcut to send messages in the chat input (`Enter` or `⌘/Ctrl + Enter`).\n\n## Tags\n\n<Frame>\n<img src=\"/images/settings-tags.png\" alt=\"Tags section showing task tags table with tag names, content, and actions\" />\n</Frame>\n\nCreate reusable text snippets that can be inserted into workspace prompts using `@tag_name`.\n\n- **Add Tag +** - Create a new tag\n- **Tag Name** - The @mention name for the tag\n- **Content** - The text that will be inserted\n- **Actions** - Edit or delete existing tags\n\n<Card title=\"Learn more about tags\" icon=\"tag\" href=\"/settings/creating-task-tags\">\n  Complete guide to creating and managing tags\n</Card>\n\n"
  },
  {
    "path": "docs/settings/index.mdx",
    "content": "---\ntitle: \"Settings Overview\"\ndescription: \"Configure application-wide settings including themes, editor, agents, and more\"\nsidebarTitle: \"Overview\"\n---\n\nThe Settings dialog provides a streamlined, tabbed interface for configuring Vibe Kanban.\n\n## Accessing Settings\n\n<Frame>\n<img src=\"/images/settings-accessing.png\" alt=\"Workspaces navbar showing the Settings gear icon with tooltip\" />\n</Frame>\n\n- Click the **Settings** icon (<Icon icon=\"gear\" />) in the Workspaces navbar\n- Press `Cmd/Ctrl + K` and select **Settings**\n\n## Settings Tabs\n\n<Frame>\n<img src=\"/images/settings-general.png\" alt=\"Settings dialog showing the tabbed interface with General, Projects, Repositories, Organization Settings, Agents, and MCP Servers tabs\" />\n</Frame>\n\nThe settings dialog is organised into the following tabs:\n\n| Tab | Description |\n|-----|-------------|\n| **General** | Appearance, default agent, editor, git, and notifications |\n| **Projects** | Project-specific settings |\n| **Repositories** | Repository scripts (dev server, setup, cleanup) |\n| **Organization Settings** | Organisation members, invitations, and settings |\n| **Remote Projects** | Cloud-synced projects across organisations |\n| **Agents** | Agent profiles and variants |\n| **MCP Servers** | Model Context Protocol server configuration |\n\n## General\n\nConfigure appearance, default agent, editor, git preferences, notifications, and tags.\n\n<Card title=\"General Settings\" icon=\"gear\" href=\"/settings/general\">\n  Complete guide to general settings including appearance, editor configuration, and more\n</Card>\n\n## Projects & Repositories\n\nConfigure project-specific settings and repository scripts including dev server, setup, and cleanup commands.\n\n<Card title=\"Projects & Repositories\" icon=\"folder-tree\" href=\"/settings/projects-repositories\">\n  Configure project settings and repository scripts\n</Card>\n\n## Organization Settings\n\nManage your Cloud organisations, invite team members, and configure organisation-level settings.\n\n<Card title=\"Organisation Settings\" icon=\"buildings\" href=\"/settings/organization-settings\">\n  Manage members, invitations, and organisation settings\n</Card>\n\n## Remote Projects\n\nView and manage all your Cloud-synced projects across organisations. Create, edit, and delete projects from a centralised interface.\n\n<Card title=\"Remote Projects\" icon=\"cloud\" href=\"/settings/remote-projects\">\n  Manage Cloud projects across all your organisations\n</Card>\n\n## Agents\n\n<Frame>\n<img src=\"/images/settings-agents.png\" alt=\"Agents tab showing agent list, configuration variants, and settings options\" />\n</Frame>\n\nDefine and customise agent variants using a form-based interface. The Agents tab uses a finder-style two-column layout:\n\n- **Left column (Agents)** - Available coding agents (Claude Code, Codex, Opencode, Cursor Agent, etc.)\n- **Right column (Configurations)** - Different variants for the selected agent (Default, Opus, Approvals, Plan, etc.)\n\n<Card title=\"Agent Profiles & Variants\" icon=\"robot\" href=\"/settings/agent-configurations\">\n  Detailed guide with examples for configuring agent variants\n</Card>\n\n## MCP Servers\n\n<Frame>\n<img src=\"/images/settings-mcp.png\" alt=\"MCP Servers tab showing agent selection, JSON configuration, and popular servers\" />\n</Frame>\n\nConfigure Model Context Protocol servers to extend coding agent capabilities with custom tools and resources.\n\n- **Agent** - Choose which agent to configure MCP servers for (e.g., Claude Code)\n- **Server Configuration (JSON)** - Edit the MCP server configuration directly in JSON format\n- **Popular servers** - Click a card to insert a pre-configured MCP server into the JSON\n\n<Card title=\"Connecting MCP Servers\" icon=\"server\" href=\"/settings/mcp-servers\">\n  Configure MCP servers within Vibe Kanban for your coding agents\n</Card>\n\n## Related Documentation\n\n- [Workspaces](/workspaces/index) - The Workspaces UI where settings are accessed\n"
  },
  {
    "path": "docs/settings/mcp-servers.mdx",
    "content": "---\ntitle: \"Connecting MCP Servers\"\ndescription: \"Configure MCP (Model Context Protocol) servers to enhance your coding agents within Vibe Kanban with additional tools and capabilities.\"\n---\n\n<Note>\nThis page covers configuring MCP servers **within** Vibe Kanban for your coding agents. For connecting external MCP clients to Vibe Kanban's MCP server, see the [Vibe Kanban MCP Server](/integrations/vibe-kanban-mcp-server) guide.\n</Note>\n\n## Overview\n\nMCP servers provide additional functionality to coding agents through standardized protocols. You can configure different MCP servers for each coding agent in Vibe Kanban, giving them access to specialized tools like browser automation, access to remote logs, error tracking via Sentry, or documentation from Notion.\n\n## Accessing MCP Server Configuration\n\n1. Navigate to **Settings** in the Workspaces navbar\n2. Click on **MCP Servers** tab\n3. Select the coding agent you want to configure MCP servers for\n\n<Frame>\n<img src=\"/images/settings-mcp.png\" alt=\"MCP Server configuration page showing agent selection, JSON configuration, and popular servers\" />\n</Frame>\n\n## Popular MCP Servers\n\nVibe Kanban provides one-click installation for popular MCP servers. Click a card to insert a pre-configured MCP server into the JSON configuration.\n\n<Frame>\n<img src=\"/images/settings-popular-mcp-servers.png\" alt=\"Popular MCP servers including Vibe Kanban, Context7, Playwright, Exa, Chrome DevTools, and Dev Manager\" />\n</Frame>\n\n## Adding Custom MCP Servers\n\nYou can also add your own MCP servers by configuring them manually:\n\n<Steps>\n<Step title=\"Select Coding Agent\">\nChoose the coding agent you want to configure MCP servers for from the Agent dropdown.\n</Step>\n\n<Step title=\"Update Server Configuration JSON\">\nIn the Server Configuration JSON editor, add your custom MCP server configuration. The JSON will show the current configuration for the selected agent, and you can modify it to include additional servers.\n\nExample addition:\n```json\n{\n  \"mcpServers\": {\n    \"existing_server\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"some-existing-server\"]\n    },\n    \"my_custom_server\": {\n      \"command\": \"node\",\n      \"args\": [\"/path/to/my-server.js\"]\n    }\n  }\n}\n```\n</Step>\n\n<Step title=\"Save and Test\">\nAfter updating the JSON configuration:\n\n1. Click \"Save Settings\" to apply changes\n2. Test the configuration by using the agent with MCP functionality\n3. Check agent logs for any connection issues\n\n<Check>\nThese changes update the global configuration file of the coding agent and will persist even if you stop using Vibe Kanban.\n</Check>\n</Step>\n</Steps>\n\n## Best Practices\n\n<Tip>\n**Server Selection**: Choose MCP servers that complement your coding agent's primary function. For example, use Playwright for agents focused on web development.\n</Tip>\n\n<Tip>\n**Limit MCP Servers**: Avoid adding too many MCP servers to a single coding agent. Too many servers and tools will degrade the effectiveness of coding agents by overwhelming them with options.\n</Tip>\n\n## Next Steps\n\n- Explore the [Agent Configurations](/settings/agent-configurations) guide for advanced agent setup\n- Check out [Supported Coding Agents](/supported-coding-agents) for agent-specific features\n"
  },
  {
    "path": "docs/settings/organization-settings.mdx",
    "content": "---\ntitle: \"Organisation Settings\"\ndescription: \"Manage your organisation, members, and invitations\"\n---\n\nThe Organisation Settings page lets you manage your Cloud organisations, invite team members, and configure organisation-level settings.\n\n<Frame>\n  <img src=\"/images/cloud/organization-settings-page.png\" alt=\"Organization Settings page showing members and settings\" />\n</Frame>\n\n## Accessing Organisation Settings\n\nTo open Organisation Settings:\n\n1. Click your **profile icon** in the bottom of the left sidebar\n2. Click the **settings icon** (<Icon icon=\"gear\" />) next to your organisation name\n\n<Frame>\n  <img src=\"/images/cloud/user-menu-settings.png\" alt=\"User menu showing settings icon next to organisation\" />\n</Frame>\n\n## Organisation Selection\n\nAt the top of the settings page, you can:\n\n- **Switch organisations** - Use the dropdown to select a different organisation\n- **Create organisation** - Click **Create Organization +** to create a new organisation\n\n<Frame>\n  <img src=\"/images/cloud/organization-switcher.png\" alt=\"Organization switcher dropdown showing multiple organisations\" />\n</Frame>\n\n<Info>\nIf you're a member of multiple organisations, switching here changes which organisation's settings you're viewing and editing.\n</Info>\n\n### Personal Organisations\n\nWhen you first sign up, a personal organisation is created for you with an **Initial Project**. Personal organisations have some restrictions:\n\n- Cannot invite additional members\n- Cannot be deleted\n- Are only accessible by you\n\nTo collaborate with others, create a new organisation using the **Create Organization +** button:\n\n<Frame>\n  <img src=\"/images/cloud/create-organization-dialog.png\" alt=\"Create New Organization dialog\" />\n</Frame>\n\nEnter your organisation name and slug (URL-friendly identifier), then click **Create Organization**. An **Initial Project** is automatically created in your new organisation.\n\n## Pending Invitations\n\n<Note>\nThis section is only visible to **Admins** and only for non-personal organisations.\n</Note>\n\nThe Pending Invitations section shows all invitations that have been sent but not yet accepted:\n\n- **Email address** - Who was invited\n- **Status** - Pending acceptance\n- **Expiry** - Invitations expire after 7 days\n- **Revoke** - Cancel the invitation if needed\n\n### Revoking an Invitation\n\nTo revoke a pending invitation:\n\n1. Find the invitation in the list\n2. Click the **Revoke** button\n3. The invitation link will no longer work\n\n## Members\n\nThe Members section shows everyone who has access to the organisation:\n\n<Frame>\n  <img src=\"/images/cloud/members-section.png\" alt=\"Members section showing member list with Invite Member button\" />\n</Frame>\n\n| Column | Description |\n|--------|-------------|\n| **Name** | Member's display name and avatar |\n| **Role** | Admin or Member |\n| **Actions** | Role change, remove (Admin only) |\n\n### Member Roles\n\n| Role | Permissions |\n|------|-------------|\n| **Admin** | Full access - manage members, invitations, settings, delete organisation |\n| **Member** | Access projects and issues, cannot manage organisation settings |\n\n### Changing a Member's Role\n\n<Note>\nOnly Admins can change member roles.\n</Note>\n\n1. Find the member in the list\n2. Click the role dropdown next to their name\n3. Select the new role (Admin or Member)\n4. The change takes effect immediately\n\n### Removing a Member\n\n<Warning>\nRemoving a member revokes their access to all projects in the organisation immediately.\n</Warning>\n\n1. Find the member in the list\n2. Click the **Remove** button\n3. Confirm the removal when prompted\n\n### Inviting New Members\n\nTo invite someone to your organisation:\n\n1. Click the **Invite Member** button\n2. Enter their email address\n3. Select their role (Admin or Member)\n4. Click **Send Invitation**\n\n<Frame>\n  <img src=\"/images/cloud/invite-member-dialog.png\" alt=\"Invite Member dialog with email and role fields\" />\n</Frame>\n\nThey'll receive an email with a link to join. The invitation expires after 7 days.\n\n## Billing\n\n<Note>\nThis section is only visible to **Admins** of non-personal organisations.\n</Note>\n\nThe Billing section provides a link to manage your organisation's billing and subscription in the Vibe Kanban account portal.\n\nClick **Manage Billing** to open the billing portal in a new tab where you can:\n- View your current plan\n- Update payment methods\n- View invoices and billing history\n\n## Danger Zone\n\n<Warning>\nActions in the Danger Zone are destructive and cannot be undone.\n</Warning>\n\n<Frame>\n  <img src=\"/images/cloud/danger-zone.png\" alt=\"Danger Zone section with Delete Organization button\" />\n</Frame>\n\n### Deleting an Organisation\n\n<Note>\nOnly Admins can delete organisations. Personal organisations cannot be deleted.\n</Note>\n\nTo delete an organisation:\n\n1. Scroll to the **Danger Zone** section\n2. Click **Delete**\n3. Confirm the deletion when prompted\n4. All projects, issues, and data will be permanently deleted\n\n<Warning>\nDeleting an organisation removes:\n- All projects in the organisation\n- All issues and their history\n- All team member access\n- All pending invitations\n\nThis action cannot be undone.\n</Warning>\n\n## Related Documentation\n\n- [Organisations](/cloud/organizations) - Creating and understanding organisations\n- [Team Members](/cloud/team-members) - Inviting and managing team members\n- [Projects](/cloud/projects) - Managing projects within organisations\n"
  },
  {
    "path": "docs/settings/projects-repositories.mdx",
    "content": "---\ntitle: \"Projects & Repositories\"\ndescription: \"Configure project-specific settings and repository scripts in the Settings dialog\"\n---\n\n## Projects\n\n<Frame>\n<img src=\"/images/settings-projects.png\" alt=\"Projects tab showing project selection and project-specific settings\" />\n</Frame>\n\nThe Projects tab allows you to configure settings specific to individual projects.\n\n### Accessing Project Settings\n\n1. Open **Settings** from the Workspaces navbar\n2. Select the **Projects** tab\n3. Choose a project from the dropdown to view and modify its settings\n\n<Tip>\nProject settings override global settings where applicable.\n</Tip>\n\n## Repositories\n\n<Frame>\n<img src=\"/images/settings-repositories.png\" alt=\"Repositories tab showing repository selection, general settings, and scripts configuration\" />\n</Frame>\n\nThe Repositories tab allows you to configure scripts that run when a repository is used in workspaces.\n\n### Accessing Repository Settings\n\n1. Open **Settings** from the Workspaces navbar\n2. Select the **Repositories** tab\n3. Choose a repository from the dropdown to view and modify its settings\n\n### General Settings\n\nConfigure basic repository information:\n\n- **Display Name** - A friendly name for this repository\n- **Repository Path** - The local path to the repository\n\n## Scripts & Configuration\n\nConfigure dev server, setup, and cleanup scripts for this repository. These scripts run whenever the repository is used in any workspace, ensuring a consistent development environment.\n\n### Dev Server Script\n\nCommand to start your development server. This enables the built-in preview browser in Workspaces, allowing you to see your application running as you make changes.\n\n**Common examples:**\n\n| Framework | Command |\n|-----------|---------|\n| Vite | `npm run dev` |\n| Next.js | `npm run dev` |\n| Create React App | `npm start` |\n| Django | `python manage.py runserver` |\n| Rails | `rails server` |\n\n<Tip>\nThe dev server must output its URL (e.g., `http://localhost:3000`) to stdout for Vibe Kanban to detect and display it in the preview panel.\n</Tip>\n\n<Card title=\"Browser Testing\" icon=\"eye\" href=\"/browser-testing\">\n  Learn how to use the built-in preview browser with your dev server\n</Card>\n\n### Setup Script\n\nCommands that run **before** the coding agent starts working. Use this to prepare the development environment.\n\n**Why use a setup script?**\n\n- **Install dependencies** - Ensure all packages are installed before the agent modifies code\n- **Build prerequisites** - Compile shared libraries or generate files the agent needs\n- **Environment preparation** - Set up databases, pull Docker images, or configure services\n\n**Examples:**\n\n```bash\n# Node.js project\nnpm install\n\n# Python project\npip install -r requirements.txt\n\n# Multiple commands\nnpm install && npm run build:deps\n\n# Rust project\ncargo fetch\n```\n\n<Info>\nSetup scripts run in the repository's root directory. They execute once when the workspace starts, not on every agent message.\n</Info>\n\n### Cleanup Script\n\nCommands that run **when a workspace closes**. Use this to clean up resources and stop background processes.\n\n**Why use a cleanup script?**\n\n- **Stop services** - Terminate background processes that might conflict with other workspaces\n- **Free resources** - Release database connections, Docker containers, or other resources\n- **Clean temporary files** - Remove build artifacts or cache files\n- **Format code** - Run formatters to ensure consistent code style after agent changes\n\n**Examples:**\n\n| Use Case | Command |\n|----------|---------|\n| Stop Docker containers | `docker compose down` |\n| Kill background processes | `pkill -f \"node server.js\"` |\n| Clean build artifacts | `rm -rf dist/ .cache/` |\n| Stop PostgreSQL | `pg_ctl stop -D /usr/local/var/postgres` |\n| Kill process on port | `lsof -ti:3000 \\| xargs kill -9 2>/dev/null` |\n\n**Code formatting examples:**\n\n| Language/Tool | Command |\n|---------------|---------|\n| JavaScript/TypeScript (Prettier) | `npx prettier --write .` |\n| JavaScript/TypeScript (ESLint) | `npx eslint --fix .` |\n| Rust (cargo fmt) | `cargo fmt` |\n| Rust (Clippy) | `cargo clippy --fix --allow-dirty` |\n| Python (Black) | `black .` |\n| Python (Ruff) | `ruff check --fix .` |\n| Go | `go fmt ./...` |\n\n**Combining multiple commands:**\n\n```bash\n# Chain commands with &&\ndocker compose down && rm -rf tmp/\n\n# Format code after agent changes\nnpx prettier --write . && npx eslint --fix .\n\n# Rust formatting and linting\ncargo fmt && cargo clippy --fix --allow-dirty\n\n# Use || true to ignore failures\npkill -f \"node server.js\" || true\nrm -rf .cache/ || true\n```\n\n<Warning>\nCleanup scripts should be idempotent—safe to run even if the resources don't exist. Use `|| true` to prevent failures when there's nothing to clean up.\n</Warning>\n\n## Best Practices\n\n<AccordionGroup>\n<Accordion title=\"Keep scripts fast\">\nLong-running setup scripts delay workspace startup. Install dependencies in setup, but avoid lengthy build processes unless necessary.\n</Accordion>\n\n<Accordion title=\"Use relative paths\">\nScripts run from the repository root. Use relative paths or environment variables rather than hardcoded absolute paths.\n</Accordion>\n\n<Accordion title=\"Handle errors gracefully\">\nAdd `|| true` to commands that might fail but shouldn't block the workspace:\n```bash\nnpm install || true\n```\n</Accordion>\n\n<Accordion title=\"Test scripts locally\">\nRun your scripts manually in a terminal before configuring them in Vibe Kanban to ensure they work correctly.\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n<CardGroup cols={2}>\n<Card title=\"Browser Testing\" icon=\"eye\" href=\"/browser-testing\">\n  Learn how the dev server integrates with the preview panel\n</Card>\n\n<Card title=\"Multi-Repository Sessions\" icon=\"folder-tree\" href=\"/workspaces/multi-repo-sessions\">\n  Working with multiple repositories in a single workspace\n</Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/settings/remote-projects.mdx",
    "content": "---\ntitle: \"Remote Projects\"\ndescription: \"Manage your Cloud projects across organisations\"\n---\n\nThe Remote Projects settings page lets you view and manage all your Cloud-synced projects across your organisations.\n\n<Frame>\n  <img src=\"/images/cloud/remote-projects-settings.png\" alt=\"Remote Projects settings showing organisations and projects\" />\n</Frame>\n\n## Accessing Remote Projects Settings\n\nTo open Remote Projects settings:\n\n1. Open **Settings** (click the gear icon or press the settings shortcut)\n2. Select **Remote Projects** from the sidebar\n\n<Note>\nYou must be signed in to access Remote Projects. If you're not signed in, you'll see a prompt to sign in first.\n</Note>\n\n## Understanding the Layout\n\nThe Remote Projects page has a two-column layout:\n\n| Column | Description |\n|--------|-------------|\n| **Organisations** | Lists all organisations you belong to. Personal organisations are marked with a \"Personal\" badge. |\n| **Projects** | Shows projects in the selected organisation. Click the **+** button to create a new project. |\n\nWhen you select a project, an edit form appears below where you can modify the project details.\n\n## Viewing Projects\n\n<Steps>\n<Step title=\"Select an organisation\">\n  Click on an organisation in the left column to view its projects.\n</Step>\n\n<Step title=\"Browse projects\">\n  Projects appear in the right column with their colour indicator. Click any project to select it.\n</Step>\n</Steps>\n\n## Creating a Project\n\nTo create a new project in an organisation:\n\n<Steps>\n<Step title=\"Select the organisation\">\n  Click on the organisation where you want to create the project.\n</Step>\n\n<Step title=\"Click the + button\">\n  Click the **+** button in the Projects column header.\n</Step>\n\n<Step title=\"Enter project details\">\n  In the dialog that appears:\n\n  <Frame>\n    <img src=\"/images/cloud/create-project-dialog.png\" alt=\"Create Project dialog with name and colour fields\" />\n  </Frame>\n\n  - **Project Name** - Enter a name for your project\n  - **Project Colour** - Click the colour swatch to choose a colour\n</Step>\n\n<Step title=\"Create the project\">\n  Click **Create Project** to create the project. It will appear in the projects list.\n</Step>\n</Steps>\n\n## Editing a Project\n\nTo edit an existing project:\n\n<Steps>\n<Step title=\"Select the project\">\n  Click on the project you want to edit. An edit form appears below the two-column picker.\n</Step>\n\n<Step title=\"Make changes\">\n  Update the project details:\n\n  <Frame>\n    <img src=\"/images/cloud/edit-project-form.png\" alt=\"Edit project form with name and colour picker\" />\n  </Frame>\n\n  - **Project Name** - Change the display name\n  - **Project Colour** - Select a different colour from the palette\n</Step>\n\n<Step title=\"Save changes\">\n  A save bar appears at the bottom when you have unsaved changes. Click **Save** to apply your changes, or **Discard** to revert.\n</Step>\n</Steps>\n\n<Info>\nChanges sync automatically to all team members in the organisation.\n</Info>\n\n## Deleting a Project\n\n<Warning>\nDeleting a project permanently removes all its issues, comments, and data. This cannot be undone.\n</Warning>\n\nTo delete a project:\n\n<Steps>\n<Step title=\"Find the project\">\n  Select the organisation and locate the project you want to delete.\n</Step>\n\n<Step title=\"Open the menu\">\n  Hover over the project and click the **⋯** (three dots) menu that appears.\n\n  <Frame>\n    <img src=\"/images/cloud/project-menu-button.png\" alt=\"Project row showing three-dots menu button\" />\n  </Frame>\n</Step>\n\n<Step title=\"Click Delete\">\n  Select **Delete** from the menu.\n\n  <Frame>\n    <img src=\"/images/cloud/project-delete-menu.png\" alt=\"Dropdown menu with Delete option\" />\n  </Frame>\n</Step>\n\n<Step title=\"Confirm deletion\">\n  Confirm the deletion when prompted. The project and all its data will be permanently removed.\n</Step>\n</Steps>\n\n## Switching Between Organisations\n\nIf you belong to multiple organisations, you can quickly switch between them:\n\n1. Click on a different organisation in the left column\n2. The projects list updates to show that organisation's projects\n3. If you have unsaved changes, you'll be prompted to discard them first\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"I don't see any organisations\">\nMake sure you're signed in. If you just signed up, a personal organisation should have been created automatically.\n\nIf you still don't see any organisations:\n1. Sign out and sign back in\n2. Check your internet connection\n3. Try refreshing the page\n</Accordion>\n\n<Accordion title=\"I can't create a project\">\nCheck the following:\n- You must have an organisation selected\n- You need to be signed in\n- Check your internet connection\n\nIf you're trying to create a project in someone else's organisation, you may not have permission.\n</Accordion>\n\n<Accordion title=\"My changes aren't saving\">\nThe save bar appears when you have unsaved changes. Make sure to click **Save** before:\n- Selecting a different project\n- Selecting a different organisation\n- Closing the settings dialog\n\nIf save fails, check your internet connection and try again.\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Projects](/cloud/projects) - Working with projects on the kanban board\n- [Organisation Settings](/settings/organization-settings) - Managing organisation members\n- [Organisations](/cloud/organizations) - Understanding organisations\n"
  },
  {
    "path": "docs/supported-coding-agents.mdx",
    "content": "---\ntitle: \"Supported Coding Agents\"\ndescription: \"Complete guide to all coding agents supported by Vibe Kanban, including installation and authentication instructions\"\n---\n\nVibe Kanban integrates with a variety of coding agents. Each agent requires installation and authentication before use. Select your preferred agent when creating task attempts.\n\n## Available Agents\n\n<CardGroup cols={2}>\n<Card title=\"Claude Code\" icon=\"https://www.vibekanban.com/images/logos/claude.svg\" href=\"/agents/claude-code\">\nClaude Code CLI\n</Card>\n\n<Card title=\"OpenAI Codex\" icon=\"https://www.vibekanban.com/images/logos/openai-logo.svg\" href=\"/agents/openai-codex\">\nOpenAI Codex CLI\n</Card>\n\n<Card title=\"GitHub Copilot\" icon=\"https://www.vibekanban.com/images/logos/github-copilot-logo.svg\" href=\"/agents/github-copilot\">\nGitHub Copilot CLI\n</Card>\n\n<Card title=\"Gemini CLI\" icon=\"https://www.vibekanban.com/images/logos/gemini-logo.svg\" href=\"/agents/gemini-cli\">\nGoogle Gemini CLI\n</Card>\n\n<Card title=\"Amp\" icon=\"https://www.vibekanban.com/images/logos/amp-logo.svg\" href=\"/agents/amp\">\nAmp Code\n</Card>\n\n<Card title=\"Cursor Agent CLI\" icon=\"https://www.vibekanban.com/images/logos/cursor-logo-light.png\" href=\"/agents/cursor-cli\">\nCursor Agent CLI \n</Card>\n\n<Card title=\"OpenCode\" icon=\"https://www.vibekanban.com/images/logos/opencode-light.svg\" href=\"/agents/opencode\">\nSST OpenCode\n</Card>\n\n<Card title=\"Droid CLI\" icon=\"https://www.vibekanban.com/images/logos/factory-ai-logo-light.png\" href=\"/agents/droid\">\nFactory Droid\n</Card>\n\n<Card title=\"Claude Code Router\" icon=\"https://www.vibekanban.com/images/logos/claude.svg#\" href=\"/agents/ccr\">\nClaude Code Router - orchestrate multiple models\n</Card>\n\n<Card title=\"Qwen Code\" icon=\"https://www.vibekanban.com/images/logos/qwen-logo.png#\" href=\"/agents/qwen-code\">\nQwen Code CLI\n</Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/troubleshooting.mdx",
    "content": "---\ntitle: \"Troubleshooting\"\ndescription: \"Common issues and solutions when using Vibe Kanban\"\n---\n\n## Agent Reports Empty Codebase\n\nIf your coding agent reports that the codebase is empty when you create a new task, you may have Git's sparse-checkout feature enabled in your repository.\n\n**Solution:**\n\nRun the following command in the root directory of your repository:\n\n```bash\ngit sparse-checkout disable\n```\n\nAfter disabling sparse-checkout, create a new task and try again.\n\n## Enabling Debug Logs\n\nIf you need more detailed logs to help debug an issue, you can enable debug-level logging by setting the `RUST_LOG` environment variable.\n\n**Usage:**\n\n```bash\nRUST_LOG=debug npx vibe-kanban\n```\n\nThis will provide verbose logging output that can help identify the root cause of issues.\n\n## DANGER: Wiping Your Database \n\nIf you encounter irrecoverable errors and need to completely wipe your Vibe Kanban database, you can delete the application data directory for your operating system. \n\n<Warning>\nThis action is irreversible and will result in the loss of ALL your tasks and settings. Make sure to back up any important information before proceeding.\n</Warning>\n\n**Delete the following directory based on your OS:**\n\n<Tabs>\n<Tab title=\"macOS\">\n```bash\n~/Library/Application Support/ai.bloop.vibe-kanban/\n```\n</Tab>\n\n<Tab title=\"Linux\">\n```bash\n~/.local/share/vibe-kanban/\n```\n</Tab>\n\n<Tab title=\"Windows\">\n```\n%APPDATA%\\bloop\\vibe-kanban\\\n```\n\nTypically: `C:\\Users\\<username>\\AppData\\Roaming\\bloop\\vibe-kanban\\`\n</Tab>\n</Tabs>\n\nAfter deleting the application data directory, restart Vibe Kanban to reset with an empty database and default settings.\n"
  },
  {
    "path": "docs/workspaces/changes.mdx",
    "content": "---\ntitle: \"Changes Panel\"\ndescription: \"Review code modifications and provide feedback to agents\"\n---\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-changes-panel.png\" alt=\"Changes panel showing file tree and side-by-side diff view with code changes\" />\n</Frame>\n\nThe changes panel lets you review all code modifications in your workspace and provide feedback to agents.\n\n## Why Review Changes?\n\nBefore committing or creating a PR, you should always review what the agent changed:\n\n- **Verify correctness** - Does the code do what you asked?\n- **Check for issues** - Are there bugs, security problems, or style violations?\n- **Understand the approach** - Learn what the agent did so you can explain it to reviewers\n- **Provide feedback** - Ask the agent to fix problems before committing\n\n<Warning>\n**Never blindly trust agent output.** Always review changes before merging. Agents can make mistakes, introduce bugs, or misunderstand requirements.\n</Warning>\n\n## Opening Changes\n\n- Click the **Changes** toggle (<Icon icon=\"code-compare\" />) in the navbar\n- Click the changes icon in the context bar\n- Use the command bar: `Cmd/Ctrl + K` → \"Toggle Changes Panel\"\n\n## File Tree\n\nThe changes panel displays a hierarchical file tree:\n\n- **Folders**: Expand/collapse to navigate\n- **Files**: Click to view the diff\n- **Search**: Filter files by name\n- **Expand/Collapse All**: Quick navigation buttons\n\n## Diff Viewer\n\nView code changes with syntax highlighting:\n\n| Feature | Description |\n|---------|-------------|\n| **Additions** | Green highlighting for new lines |\n| **Deletions** | Red highlighting for removed lines |\n| **Context** | Surrounding unchanged lines for reference |\n| **Line numbers** | Original and new line numbers |\n\n## Diff View Modes\n\nToggle between two display modes:\n\n| Mode | Description |\n|------|-------------|\n| **Unified** | Inline view with changes interleaved |\n| **Side-by-Side** | Original and modified shown in parallel columns |\n\nSwitch modes using the diff view toggle in the navbar (when Changes panel is open) or via command bar: `Cmd/Ctrl + K` → \"Toggle Diff View Mode\".\n\n## Diff Options\n\nCustomise the diff display:\n\n| Option | Description |\n|--------|-------------|\n| **Wrap Lines** | Enable/disable line wrapping for long lines |\n| **Ignore Whitespace** | Show/hide whitespace-only changes |\n| **Expand All** | Expand all collapsed diff sections |\n| **Collapse All** | Collapse all diff sections |\n\nAccess these via the command bar's Diff Options page.\n\n## Giving Feedback with Comments\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-inline-comments.png\" alt=\"Changes panel showing inline comments on code with Add Review Comment button\" />\n</Frame>\n\nAdd comments directly on code changes to provide feedback to agents.\n\n### Adding Comments\n\n1. Hover over a line in the diff\n2. Click the comment icon that appears\n3. Write your feedback\n4. Submit the comment\n\n### Comment Uses\n\n- **Request changes**: Ask the agent to modify specific code\n- **Ask questions**: Get explanations about implementation choices\n- **Provide context**: Share additional requirements or constraints\n- **Approve sections**: Indicate code that looks good\n\n### Agent Response to Comments\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-review-comments-response.png\" alt=\"Chat showing agent responding to review comments by restoring a file\" />\n</Frame>\n\nWhen you send a message with review comments, the agent sees your feedback and acts on it. In this example, the agent restores a file after the user requested the change be reverted.\n\n<Info>\nComments are visible to the agent in subsequent messages, helping guide further development.\n</Info>\n\n## GitHub Integration\n\nWhen your workspace is linked to a pull request:\n\n### Viewing GitHub Comments\n\n<Frame>\n<img style={{maxHeight: \"200px\"}} src=\"/images/workspaces-show-github-comments.png\" alt=\"Changes panel showing GitHub icon button with Show GitHub comments tooltip\" />\n</Frame>\n\n- Click the **GitHub icon** in the changes panel toolbar to toggle GitHub comments\n- Comments from the PR appear inline with the diff\n- Badge shows comment count per file\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-github-comments-inline.png\" alt=\"GitHub review comment displayed inline in the diff view\" />\n</Frame>\n\n<Note>\nOnly submitted review comments are shown. Pending reviews (not yet submitted on GitHub) won't appear until you submit the review.\n</Note>\n\n### Linking to a PR\n\nWorkspaces automatically link when:\n\n- You create a PR from the workspace\n- The workspace branch matches an existing PR\n\n<Tip>\nReview GitHub comments alongside your changes to address PR feedback efficiently.\n</Tip>\n\n## Review Workflow\n\nFollow this workflow to effectively review and refine agent changes:\n\n<Steps>\n<Step title=\"Wait for agent to finish\">\nLet the agent complete its work. The status in the sidebar will change from \"Running\" to \"Idle\".\n</Step>\n\n<Step title=\"Open the Changes panel\">\nClick the Changes icon in the navbar or use `Cmd/Ctrl + K` → \"Toggle Changes Panel\".\n</Step>\n\n<Step title=\"Review each file\">\nClick through each modified file in the file tree. Look for:\n- **Logic errors** - Does the code do what you intended?\n- **Missing pieces** - Are there unhandled edge cases?\n- **Code quality** - Is the code readable and maintainable?\n- **Security issues** - Any obvious vulnerabilities?\n</Step>\n\n<Step title=\"Add comments on issues\">\nFor any problems you find, hover over the line and click the comment icon. Write specific feedback:\n\n**Good comment:** \"This API endpoint should validate the user ID before querying the database to prevent unauthorized access.\"\n\n**Bad comment:** \"This is wrong, fix it.\"\n</Step>\n\n<Step title=\"Send feedback to agent\">\nType a message in the chat (e.g., \"Please address the review comments\") and send it. The agent will see your inline comments and make corrections.\n</Step>\n\n<Step title=\"Re-review and repeat\">\nAfter the agent addresses feedback, review the new changes. Repeat until you're satisfied.\n</Step>\n\n<Step title=\"Create PR when ready\">\nOnce changes look good, create a pull request using the Git panel or command bar.\n</Step>\n</Steps>\n\n<Tip>\n**Be specific in comments.** Instead of \"this is wrong\", explain what's wrong and ideally suggest a fix. The more context you give, the better the agent can correct the issue.\n</Tip>\n\n## Related Documentation\n\n- [Browser Testing](/browser-testing) - Test your application with the built-in browser\n- [Interface Guide](/workspaces/interface) - Overview of workspace panels\n- [Git Operations](/workspaces/git-operations) - Create PRs and manage branches\n"
  },
  {
    "path": "docs/workspaces/chat-interface.mdx",
    "content": "---\ntitle: \"Chat Interface\"\ndescription: \"Interact with coding agents through the conversation panel\"\n---\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-conversation.png\" alt=\"Chat interface showing conversation with coding agent\" />\n</Frame>\n\nThe chat interface is your primary way of communicating with coding agents. It supports rich message formatting, file attachments, approval workflows, and multiple conversation sessions.\n\n## Message Types\n\nThe chat displays different types of messages, each with distinct styling:\n\n### User Messages\n\nYour messages appear with an edit option. Click the pencil icon to modify and resend a message.\n\n### Agent Messages\n\nAgent responses are rendered with full markdown support, including:\n- Code blocks with syntax highlighting\n- Tables and lists\n- Links and formatted text\n\n### System Messages\n\nInformational messages from the system, such as model initialisation or status updates.\n\n### Error Messages\n\nError messages appear in red with a warning icon. Click to expand and see full error details.\n\n## Chat Input\n\n### Writing Messages\n\n<Frame>\n<img style={{maxHeight: \"250px\"}} src=\"/images/workspaces-writing-messages.png\" alt=\"Chat input formatting toolbar showing bold, italic, underline, strikethrough, and code buttons\" />\n</Frame>\n\nThe chat input supports rich text editing:\n\n- **Bold** - `Cmd/Ctrl + B` or click **B**\n- **Italic** - `Cmd/Ctrl + I` or click **I**\n- **Underline** - `Cmd/Ctrl + U` or click **U**\n- **Strikethrough** - Click **S**\n- **Code** - Click the code button or wrap with backticks\n\n### Attaching Images\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-attach-images.png\" alt=\"File picker dialog for attaching images to chat messages\" />\n</Frame>\n\nYou can attach images to your messages:\n\n1. **Click the attachment button** (paperclip icon) in the toolbar\n2. **Drag and drop** images directly into the chat\n3. Images are uploaded and referenced in your message\n\n<Tip>\nAttach screenshots to show the agent exactly what you're working on or what issue you're experiencing.\n</Tip>\n\n### File Mentions\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-file-mentions.png\" alt=\"File mentions typeahead showing matching files when typing @\" />\n</Frame>\n\nType `@` to mention files from your repository. The chat provides typeahead suggestions showing:\n\n- **Filename** in bold\n- **Full path** below each file\n- Matches update as you type\n\nSelect a file to include it as context for the agent.\n\n## Sending Messages\n\n### Send Actions\n\n| Action | Shortcut | Description |\n|--------|----------|-------------|\n| **Send** | `Cmd/Ctrl + Enter` | Send message immediately |\n| **Queue** | Click Queue button | Queue message while agent is running |\n| **Stop** | Click Stop button | Stop the current agent execution |\n\n### Execution States\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-running-state.png\" alt=\"Chat interface in running state showing Queue and Stop buttons\" />\n</Frame>\n\nThe chat interface shows different states:\n\n| State | Description |\n|-------|-------------|\n| **Idle** | Ready to send a new message |\n| **Running** | Agent is actively working |\n| **Queued** | Your message is queued for when agent finishes |\n| **Sending** | Message is being sent |\n\n<Info>\nWhen the agent is running, you can queue a follow-up message instead of waiting for it to finish.\n</Info>\n\n## Agent Selection\n\n### Choosing an Agent\n\nSelect which coding agent to use from the **Agent** dropdown in the chat toolbar. Available agents depend on your configuration.\n\n### Variants\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-agent-selection.png\" alt=\"Variants dropdown showing Default, Approvals, Opus, and Plan options with Customise button\" />\n</Frame>\n\nSome agents support different variants or profiles. Use the variant selector to choose:\n\n- Different model configurations\n- Custom system prompts\n- Specialised behaviours\n\nClick **Customise** to configure agent settings.\n\n## Approval Workflow\n\nSome agents (especially with planning mode enabled) ask for your approval before making changes. This gives you control over what gets implemented.\n\n### Why Approvals Exist\n\n- **Prevent unwanted changes** - Review the plan before code is written\n- **Catch misunderstandings early** - Ensure the agent understood your request\n- **Guide the approach** - Steer the agent toward your preferred solution\n\n### Reviewing Plans\n\nWhen the agent needs approval, you'll see an approval card in the chat:\n\n1. **Read the plan summary** - What the agent intends to do\n2. **Expand for details** - Click to see the full plan with specific files and changes\n3. **Choose an action:**\n   - **Approve** - Agent proceeds with the plan\n   - **Request Changes** - Provide feedback for revision\n\n### Requesting Changes\n\nIf the plan isn't quite right:\n\n1. Type your feedback in the chat input explaining what should be different\n2. Click **Request Changes**\n3. The agent revises the plan based on your feedback\n4. Review the new plan and approve or request more changes\n\n<Tip>\n**Be specific in your feedback.** Instead of \"that's not right\", say \"don't modify the database schema - just add the validation to the existing User model.\"\n</Tip>\n\n<Warning>\n**Approval timeouts:** If you don't respond to an approval request, it may timeout. You'll need to send a new message to restart the task.\n</Warning>\n\n### Disabling Approvals\n\nIf you want the agent to work autonomously without asking for approval:\n1. Use an agent variant without planning mode\n2. Or configure `dangerously_skip_permissions` in agent settings (use with caution)\n\n## Editing Messages\n\nYou can edit and resend previous messages:\n\n<Frame>\n<img style={{maxHeight: \"250px\"}} src=\"/images/workspaces-edit-message-1.png\" alt=\"User message showing edit pencil icon in the top right corner\" />\n</Frame>\n\n<Steps>\n<Step title=\"Click the Edit Button\">\n  Click the pencil icon on any of your messages.\n</Step>\n\n<Step title=\"Modify Your Message\">\n  The message content appears in an editable text area. Make your changes.\n\n  <Frame>\n  <img style={{maxHeight: \"250px\"}} src=\"/images/workspaces-edit-message-2.png\" alt=\"Edit mode showing message in editable text area with Cancel and Retry buttons\" />\n  </Frame>\n</Step>\n\n<Step title=\"Submit the Edit\">\n  Click **Retry** to resend the modified message. The conversation continues from that point. Click **Cancel** to discard your changes.\n</Step>\n</Steps>\n\n<Warning>\nEditing a message creates a new branch in the conversation. Subsequent messages after the edited one will be replaced.\n</Warning>\n\n## Status Indicators\n\n### Token Usage\n\n<Frame>\n<img style={{maxHeight: \"200px\"}} src=\"/images/workspaces-token-usage.png\" alt=\"Context gauge showing 13% usage with 27K of 200K tokens used\" />\n</Frame>\n\nThe context gauge shows how much of the agent's context window is used. Understanding this helps you get better results.\n\n#### What are Tokens?\n\n**Tokens** are how AI models measure text. Roughly:\n- 1 token ≈ 4 characters or ¾ of a word\n- A 200K context window can hold about 150,000 words\n\nThe context window includes everything the agent \"remembers\": your messages, its responses, code it has read, and file contents.\n\n#### Why Token Usage Matters\n\nWhen usage is high:\n- Agent may \"forget\" earlier parts of the conversation\n- Responses may become less accurate\n- Agent might re-read files it already read\n- Complex reasoning may suffer\n\n#### Usage Levels\n\n| Colour | Usage | What to Do |\n|--------|-------|------------|\n| Grey | 0-30% | All good, continue working |\n| Default | 30-60% | Normal usage, keep going |\n| Orange | 60-80% | Consider starting a new session soon |\n| Red | 80%+ | Start a new session - agent is near its limit |\n\n<Tip>\n**Pro tip:** When starting a new session due to high token usage, briefly summarise what was accomplished. The new agent won't know what happened before.\n</Tip>\n\n### Tasks Progress\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-tasks-panel.png\" alt=\"Tasks panel showing 5/5 completed with progress bar and list of individual tasks\" />\n</Frame>\n\nWhen the agent breaks down work into tasks, a progress indicator appears in the chat toolbar:\n\n- **Progress bar** showing completion percentage\n- **Task count** (e.g., \"5/5 completed\")\n- **Individual tasks** with checkmarks when complete\n\nClick the indicator to expand the full task list showing each step the agent is working through.\n\n### File Changes\n\nThe chat shows file modification summaries:\n\n- **Green** numbers indicate lines added\n- **Red** numbers indicate lines removed\n- Click to view the file in the Changes panel\n\n## Tool Outputs\n\n### Command Execution\n\nWhen the agent runs commands, you'll see:\n\n- **Terminal icon** for bash commands\n- **Exit code** for completed commands\n- **Fix Script** button if a command fails\n\nClick to view full output in the logs panel.\n\n### Search Results\n\nSearch operations show summarised results. Click to expand and see full search output.\n\n## Review Comments\n\nWhen you add inline comments in the Changes panel:\n\n1. A banner appears showing the comment count\n2. Comments are automatically included when you send a message\n3. Click **Clear** to remove pending comments\n\n## Keyboard Shortcuts\n\n| Shortcut | Action |\n|----------|--------|\n| `Cmd/Ctrl + Enter` | Send message (or contextual action) |\n| `Cmd/Ctrl + B` | Bold text |\n| `Cmd/Ctrl + I` | Italic text |\n| `Escape` | Cancel current action |\n\n## Related Documentation\n\n- [Slash Commands](/workspaces/slash-commands) - Quick actions with slash commands\n- [Sessions](/workspaces/sessions) - Managing multiple conversation sessions\n- [Interface Guide](/workspaces/interface) - Overview of the workspace layout\n- [Command Bar](/workspaces/command-bar) - Quick actions and shortcuts\n"
  },
  {
    "path": "docs/workspaces/command-bar.mdx",
    "content": "---\ntitle: \"Command Bar\"\ndescription: \"Navigate and control your workspace with keyboard shortcuts\"\n---\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-command-bar.png\" alt=\"Command bar showing search field and list of available commands\" />\n</Frame>\n\nThe command bar is the central hub for navigating and controlling your workspace. Access every action quickly without leaving the keyboard.\n\n## Opening the Command Bar\n\n| Platform | Shortcut |\n|----------|----------|\n| **Mac** | `Cmd + K` |\n| **Windows/Linux** | `Ctrl + K` |\n\nYou can also click the command bar icon in the navbar.\n\n## Available Commands\n\n### Quick Actions\n\nAccess these from the root command bar page:\n\n| Command | Description |\n|---------|-------------|\n| New Workspace | Create a new workspace |\n| Open in IDE | Open the workspace in your configured editor |\n| Copy Path | Copy the workspace path to clipboard |\n| Toggle Dev Server | Start or stop the dev server |\n| Open in Old UI | Switch to the classic kanban interface |\n| Feedback | Send feedback about Workspaces |\n| Workspaces Guide | Open the onboarding guide |\n| Settings | Open application settings |\n\n### Workspace Actions\n\nManage the current workspace:\n\n| Command | Description |\n|---------|-------------|\n| Start Review | Begin a code review session |\n| Rename Workspace | Change the workspace name |\n| Duplicate Workspace | Create a copy of the workspace |\n| Pin/Unpin Workspace | Toggle pinned status |\n| Archive/Unarchive | Move to/from archive |\n| Delete Workspace | Permanently delete the workspace |\n| Run Setup Script | Execute the repository setup script |\n| Run Cleanup Script | Execute the repository cleanup script |\n\n### Git Actions\n\nPerform git operations:\n\n| Command | Description |\n|---------|-------------|\n| Create Pull Request | Open PR creation dialog |\n| Merge | Pull target branch changes into your working branch |\n| Rebase | Rebase your working branch onto target branch |\n| Change Target Branch | Switch the merge target |\n| Push | Push commits to remote (when applicable) |\n\n<Tip>\nGit commands are context-aware and only appear when they're applicable to the current workspace state.\n</Tip>\n\n### View Options\n\nControl panel visibility:\n\n| Command | Description |\n|---------|-------------|\n| Toggle Left Sidebar | Show/hide workspace list |\n| Toggle Chat Panel | Show/hide conversation |\n| Toggle Right Sidebar | Show/hide details |\n| Toggle Changes Panel | Show/hide code changes |\n| Toggle Logs Panel | Show/hide process logs |\n| Toggle Preview Panel | Show/hide browser preview |\n\n### Diff Options\n\nCustomise the diff viewer (available when changes panel is visible):\n\n| Command | Description |\n|---------|-------------|\n| Toggle Diff View Mode | Switch between unified and side-by-side |\n| Toggle Wrap Lines | Enable/disable line wrapping |\n| Toggle Ignore Whitespace | Show/hide whitespace changes |\n| Expand All Diffs | Expand all collapsed diffs |\n| Collapse All Diffs | Collapse all expanded diffs |\n\n### Repository Actions\n\nFor workspaces with multiple repositories, manage individual repos:\n\n| Command | Description |\n|---------|-------------|\n| Copy Repo Path | Copy specific repository path |\n| Open Repo in IDE | Open just this repository |\n| Repository Settings | Configure repository options |\n| Create PR (repo) | Create PR for specific repo |\n| Merge (repo) | Merge specific repository |\n| Rebase (repo) | Rebase specific repository |\n| Change Target Branch (repo) | Change target for specific repo |\n\n## Keyboard Shortcuts\n\n### Global Shortcuts\n\n| Shortcut | Action |\n|----------|--------|\n| `Cmd/Ctrl + K` | Open command bar |\n| `Escape` | Close command bar or dialog |\n\n## Command Bar Navigation\n\nThe command bar organises commands into pages:\n\n1. **Root** - Quick actions and navigation to other pages\n2. **Workspace Actions** - Workspace management commands\n3. **Git Actions** - Version control operations\n4. **View Options** - Panel visibility toggles\n5. **Diff Options** - Diff viewer settings\n6. **Repo Actions** - Per-repository commands (multi-repo workspaces)\n\nUse the search field to filter commands across all pages, or navigate to specific pages for categorised access.\n\n<Info>\nStart typing to search for any command. The command bar uses fuzzy matching, so you don't need to type the exact command name.\n</Info>\n\n## Power User Tips\n\n<AccordionGroup>\n<Accordion title=\"Most useful commands to memorize\">\nThese commands will speed up your workflow significantly:\n\n| Shortcut | What It Does |\n|----------|--------------|\n| `Cmd/Ctrl + K` → `n` → Enter | New workspace |\n| `Cmd/Ctrl + K` → `pr` → Enter | Create pull request |\n| `Cmd/Ctrl + K` → `changes` → Enter | Toggle changes panel |\n| `Cmd/Ctrl + K` → `preview` → Enter | Toggle preview panel |\n| `Cmd/Ctrl + K` → `ide` → Enter | Open in your IDE |\n</Accordion>\n\n<Accordion title=\"Fuzzy search tricks\">\nThe command bar uses fuzzy matching - you can type partial words or abbreviations:\n\n- `nw` matches \"**N**ew **W**orkspace\"\n- `cpr` matches \"**C**reate **P**ull **R**equest\"\n- `tog prev` matches \"**Tog**gle **Prev**iew Panel\"\n- `reb` matches \"**Reb**ase\"\n</Accordion>\n\n<Accordion title=\"Quick panel toggling\">\nUse the command bar to quickly show/hide panels without moving your mouse:\n\n1. Press `Cmd/Ctrl + K`\n2. Type `toggle`\n3. See all toggle options\n4. Select the panel you want to show/hide\n</Accordion>\n\n<Accordion title=\"Context-aware commands\">\nSome commands only appear when relevant:\n\n- **Push** only shows when you have unpushed commits\n- **Rebase** only shows when you're behind target\n- **Repo Actions** only shows in multi-repo workspaces\n- **Diff Options** only shows when Changes panel is visible\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Interface Guide](/workspaces/interface) - Learn about the workspace layout and panels\n- [Git Operations](/workspaces/git-operations) - Detailed guide to git commands\n- [General Settings](/settings/general) - Application settings and preferences\n"
  },
  {
    "path": "docs/workspaces/creating-workspaces.mdx",
    "content": "---\ntitle: \"Creating Workspaces\"\ndescription: \"Learn how to create and configure workspaces for your development tasks\"\n---\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-create-new.png\" alt=\"Create workspace view showing project selection, repository list, and task input\" />\n</Frame>\n\nA workspace is your task execution environment where you work with coding agents. Each workspace can include one or more repositories and supports multiple conversation sessions.\n\n## What Happens When You Create a Workspace\n\nUnderstanding what happens behind the scenes helps you work more effectively:\n\n<Steps>\n<Step title=\"Git worktree is created\">\nVibe Kanban creates a **git worktree** - a separate working directory with its own branch. This keeps your changes isolated from your main codebase. Your original repository remains untouched.\n</Step>\n\n<Step title=\"Working branch is created\">\nA new branch is created based on your target branch (e.g., `main`). The branch name is auto-generated based on your task (e.g., `vk/abc123-add-login-page`).\n</Step>\n\n<Step title=\"Agent session starts\">\nA coding agent is initialised and ready to receive your instructions. The agent can read files, make changes, and run commands within your workspace.\n</Step>\n\n<Step title=\"Setup scripts run (if configured)\">\nIf your repository has setup scripts configured (e.g., `npm install`), they run automatically to prepare the environment.\n</Step>\n</Steps>\n\n<Info>\n**Where do workspaces live?** Worktrees are created in a `.vibe-kanban-workspaces` directory (configurable in Settings → General → Workspace Directory). Each workspace gets its own folder.\n</Info>\n\n## Creating a New Workspace\n\n<Steps>\n<Step title=\"Open the Create View\">\n  Click the **+** button at the top of the workspace sidebar, or use the command bar (`Cmd/Ctrl + K`) and select **New Workspace**.\n\n  <Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-sidebar-create-button.png\" alt=\"Workspace sidebar showing the plus button for creating new workspaces\" />\n  </Frame>\n</Step>\n\n<Step title=\"Select a Project\">\n  Choose a project from the **Project** dropdown in the right panel. Projects group related repositories together.\n\n  <Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-select-project.png\" alt=\"Project dropdown showing selected project\" />\n  </Frame>\n\n  <Note>\n  If you haven't created a project yet, see [Creating a New Project](#creating-a-new-project) below.\n  </Note>\n</Step>\n\n<Step title=\"Add Repositories\">\n  <Frame>\n  <img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-add-repo.png\" alt=\"Repository selection showing selected repo with branch dropdown and list of available repositories\" />\n  </Frame>\n\n  Select which repositories to include in your workspace:\n\n  - **Recent repositories** - Click any repo from the list to add it\n  - **Browse repos on disk** - Find repositories not in the recent list\n  - **Create new repo on disk** - Initialise a new git repository\n\n  <Tip>\n  You can add multiple repositories to a single workspace if your task spans multiple codebases. Each repository maintains independent git state.\n  </Tip>\n</Step>\n\n<Step title=\"Set Target Branches\">\n  For each selected repository, set the **target branch** - this is the branch your changes will eventually be merged into (e.g., `main` or `develop`).\n\n  Click the branch dropdown next to each repository to change the target branch.\n\n  <Info>\n  **Target branch vs Working branch - what's the difference?**\n\n  - **Target branch** = Where your changes will eventually be merged (e.g., `main`). You set this.\n  - **Working branch** = Where your changes are made (e.g., `vk/abc123-task-name`). Auto-created from target.\n\n  Your changes are made on the working branch and don't affect the target until you create and merge a PR.\n  </Info>\n</Step>\n\n<Step title=\"Describe Your Task\">\n  In the chat input at the bottom, describe what you want to accomplish. Be specific about:\n\n  - What feature or fix you need\n  - Any constraints or requirements\n  - Files or areas of the codebase to focus on\n\n  <Tip>\n  Clear, detailed task descriptions help the agent understand your requirements and produce better results.\n  </Tip>\n</Step>\n\n<Step title=\"Select an Agent\">\n  Choose which coding agent to use from the **Agent** dropdown. Available agents depend on your configuration.\n\n  <Note>\n  See [Agent Configurations](/settings/agent-configurations) for details on setting up different agents.\n  </Note>\n</Step>\n\n<Step title=\"Create the Workspace\">\n  Click **Create** to start the workspace. The agent will begin working on your task immediately.\n</Step>\n</Steps>\n\n## Creating a New Project\n\nIf you need to create a new project before setting up your workspace:\n\n<Steps>\n<Step title=\"Open the Project Dropdown\">\n  In the create workspace view, click the **Project** dropdown.\n\n  <Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-project-dropdown.png\" alt=\"Project dropdown showing Create new project option and list of existing projects\" />\n  </Frame>\n</Step>\n\n<Step title=\"Select Create New Project\">\n  Click **+ Create new project** at the top of the dropdown list.\n</Step>\n\n<Step title=\"Enter Project Details\">\n  <Frame>\n  <img style={{maxHeight: \"250px\"}} src=\"/images/workspaces-create-project.png\" alt=\"Create Project dialog with name field\" />\n  </Frame>\n\n  Enter a name for your project and click **Create**.\n</Step>\n\n<Step title=\"Continue with Workspace Creation\">\n  Your new project is automatically selected. Continue adding repositories and configuring your workspace.\n</Step>\n</Steps>\n\n<Note>\nAfter creating a project, you can configure additional settings like setup scripts, dev server scripts, and cleanup scripts in the project settings. See [Projects & Repositories](/settings/projects-repositories) for more details.\n</Note>\n\n## Workspace Settings\n\nOnce a workspace is created, you can configure additional settings:\n\n### Working Branch\n\nThe workspace automatically creates a working branch for your changes. You can view and change this in the **Git** section of the details sidebar.\n\n### Dev Server\n\nIf your project has a dev server script configured, you can start it using:\n- The **Play** icon (<Icon icon=\"play\" />) in the context bar\n- The command bar: `Cmd/Ctrl + K` → **Start Dev Server**\n\n<Note>\nConfigure dev server scripts in your project settings. See [Projects & Repositories](/settings/projects-repositories) for setup instructions.\n</Note>\n\n### Workspace Notes\n\nUse the **Notes** section in the details sidebar to document important information about the workspace - requirements, decisions, or anything you want to remember.\n\n## Duplicating a Workspace\n\nTo create a copy of an existing workspace:\n\n1. Open the command bar (`Cmd/Ctrl + K`)\n2. Go to **Workspace Actions**\n3. Select **Duplicate Workspace**\n\nThe duplicate includes the same repositories and branch configuration but starts with a fresh conversation.\n\n## Archiving Workspaces\n\nWhen you're done with a workspace, archive it to keep your workspace list clean:\n\n**From the navbar:**\n- Click the **Archive** button (<Icon icon=\"box-archive\" />) in the top left of the navbar\n\n**From the command bar:**\n1. Press `Cmd/Ctrl + K`\n2. Go to **Workspace Actions** → **Archive**\n\nArchived workspaces can be viewed by clicking **View Archive** at the bottom of the sidebar.\n\n<Tip>\nUse the **Pin** feature to keep important active workspaces at the top of your list.\n</Tip>\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"I can't see my repository in the list\">\n**Possible causes:**\n- The repository hasn't been added to a project yet\n- The folder isn't a git repository (no `.git` folder)\n- The path isn't accessible\n\n**Solutions:**\n1. Click **Browse repos on disk** to manually locate the repository\n2. Ensure the folder contains a `.git` directory\n3. Check that Vibe Kanban has permission to access the folder\n</Accordion>\n\n<Accordion title=\"Workspace creation fails\">\n**Possible causes:**\n- Git worktree creation failed (usually due to uncommitted changes in the original repo)\n- Branch name conflict\n- Disk space issues\n\n**Solutions:**\n1. Commit or stash any uncommitted changes in your original repository\n2. Try a different target branch\n3. Check available disk space (worktrees require space for a full copy of tracked files)\n</Accordion>\n\n<Accordion title=\"Agent doesn't start after creating workspace\">\n**Possible causes:**\n- Agent isn't installed or configured\n- API key issues\n- Network connectivity problems\n\n**Solutions:**\n1. Check that your agent (e.g., Claude Code) is installed: run the CLI command manually in terminal\n2. Verify API keys are configured in Settings → Agents\n3. Check your internet connection\n</Accordion>\n\n<Accordion title=\"Setup script fails\">\n**Possible causes:**\n- Script has errors\n- Missing dependencies\n- Wrong working directory\n\n**Solutions:**\n1. Test the script manually in terminal first\n2. Check the Logs panel for error messages\n3. Ensure paths in the script are relative to the repository root\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Interface Guide](/workspaces/interface) - Learn about the workspace layout and panels\n- [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working with multiple repositories\n- [Sessions](/workspaces/sessions) - Creating and managing conversation sessions\n- [Command Bar](/workspaces/command-bar) - Quick actions and keyboard shortcuts\n"
  },
  {
    "path": "docs/workspaces/git-operations.mdx",
    "content": "---\ntitle: \"Git Operations\"\ndescription: \"Create pull requests, merge, rebase, and manage branches in Workspaces\"\n---\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-git-panel.png\" alt=\"Git section in the details sidebar showing repository status, branches, and commit information\" />\n</Frame>\n\nWorkspaces provide integrated git operations for managing your code changes.\n\n## Git Basics for Workspaces\n\nIf you're not familiar with git, here's what you need to know:\n\n<AccordionGroup>\n<Accordion title=\"What is a branch?\">\nA **branch** is a separate line of development. Think of it as a copy of your code where you can make changes without affecting the original.\n\nIn Workspaces:\n- **Target branch** (e.g., `main`) - The \"original\" you'll eventually merge back into\n- **Working branch** (e.g., `vk/abc123-task`) - Your copy where changes happen\n</Accordion>\n\n<Accordion title=\"What is a commit?\">\nA **commit** is a saved snapshot of your changes. It's like a save point in a video game - you can always go back to it.\n\nIn Workspaces, commits happen automatically as the agent works, or you can commit manually through the terminal.\n</Accordion>\n\n<Accordion title=\"What is a pull request (PR)?\">\nA **pull request** is a request to merge your changes from your working branch into the target branch. It:\n- Shows all your changes in one place\n- Lets teammates review your code\n- Runs automated tests (CI)\n- Provides a history of what was changed and why\n</Accordion>\n\n<Accordion title=\"What is rebasing?\">\n**Rebasing** updates your working branch with the latest changes from the target branch. It's like saying \"pretend I started my work from the current state of main, not the old state.\"\n\n**When to rebase:**\n- Before creating a PR (to avoid conflicts)\n- When the target branch has new commits\n- When prompted by Vibe Kanban\n</Accordion>\n\n<Accordion title=\"What is merging?\">\n**Merging** combines your changes with the target branch. In Workspaces, this typically happens through a PR on GitHub after code review.\n\nThe \"Merge\" action in Workspaces pulls the target branch INTO your working branch (the opposite direction of a PR merge).\n</Accordion>\n</AccordionGroup>\n\n## Repository Status\n\nThe Git section in the right sidebar shows:\n\n| Information | Description |\n|-------------|-------------|\n| **Repository name** | Current repository with status |\n| **Current branch** | Your working branch |\n| **Target branch** | Branch you'll merge into |\n| **Uncommitted changes** | Number of modified files |\n| **Ahead/Behind** | Commits relative to target branch |\n| **Conflict indicator** | Shows when merge conflicts exist |\n\n## Creating Pull Requests\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-create-pr.png\" alt=\"Create pull request dialog with title, description, and draft mode options\" />\n</Frame>\n\nCreate PRs directly from your workspace.\n\n### Creating a PR\n\n**From the Git panel:**\n1. Click **Open pull request** in the Git section of the right sidebar\n2. Fill in the PR details\n\n**From the command bar:**\n1. Press `Cmd/Ctrl + K`\n2. Select **Create Pull Request**\n\n**PR details:**\n   - **Auto-generate PR description with AI** - Let AI write the description based on your changes\n   - **Title** - PR title (auto-filled from task name)\n   - **Description** - Optional details about the changes\n   - **Base Branch** - The branch to merge into\n   - **Create as draft** - Mark as draft PR\n4. Click **Create PR**\n\nOnce created, your PR appears on GitHub with your commits, description, and CI checks running.\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-github-pr.png\" alt=\"GitHub pull request page showing the created PR with commits, checks, and comments\" />\n</Frame>\n\n### Draft PRs\n\nEnable **Draft** mode when:\n\n- Work is still in progress\n- You want early feedback before completion\n- CI checks should run but reviewers shouldn't merge yet\n\n### Multi-Repo PRs\n\nFor workspaces with multiple repositories:\n\n- Create PRs for each repo separately\n- Use the Repo Actions in the command bar\n- Reference related PRs in descriptions\n\n<Tip>\nCreate PRs early to get CI feedback and enable team visibility into your progress.\n</Tip>\n\n## Merging Changes\n\nPull the latest changes from the target branch into your working branch.\n\n### Merge Process\n\n**From the Git panel:**\n1. Click the dropdown arrow next to **Open pull request**\n2. Select **Merge**\n\n**From the command bar:**\n1. Press `Cmd/Ctrl + K` and select **Merge**\n\nTarget branch changes are merged into your working branch after confirmation.\n\n### Before Merging\n\nThe workspace checks if you're behind the target branch:\n\n- **Up to date**: Merge proceeds normally\n- **Behind target**: You'll be prompted to rebase first\n\n<Warning>\nAlways ensure CI checks pass and code reviews are complete before merging.\n</Warning>\n\n## Rebasing\n\nKeep your branch up to date with the target branch.\n\n### Rebase Process\n\n**From the Git panel:**\n1. Click the target branch dropdown (e.g., **main**)\n2. Select **Rebase** from the menu\n\n**From the command bar:**\n1. Press `Cmd/Ctrl + K` and select **Rebase**\n\nYour commits are replayed on top of the target branch after confirmation.\n\n### Handling Conflicts\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-conflict-dialog.png\" alt=\"Conflict resolution dialog showing list of conflicting files with resolve and abort options\" />\n</Frame>\n\nIf conflicts occur during rebase:\n\n1. The workspace shows a conflict resolution dialog\n2. List of conflicting files is displayed\n3. Resolve conflicts in your editor\n4. Mark files as resolved\n5. Continue or abort the rebase\n\n### When to Rebase\n\n- Before creating a pull request\n- When the target branch has new commits\n- Before merging to ensure a clean history\n- When prompted due to being behind target\n\n## Changing Target Branch\n\nSwitch the branch you're merging into.\n\n### Changing the Target\n\n**From the Git panel:**\n1. Click the target branch dropdown (e.g., **main**) in the Git section\n2. Select a new target branch from the list\n\n**From the command bar:**\n1. Press `Cmd/Ctrl + K` and select **Change Target Branch**\n2. Choose the new target branch\n\nThe workspace updates to show the new ahead/behind status.\n\n### Use Cases\n\n- Switch from `develop` to `main` for release\n- Target a feature branch instead of main\n- Correct an incorrectly set target\n\n## Pushing Changes\n\nPush your commits to the remote repository.\n\n### When Push is Available\n\nThe Push command appears when:\n\n- You have unpushed commits\n- A pull request is open for the branch\n\n### Pushing\n\n**From the Git panel:**\n- Click the **Push** button when it appears (shows when you have unpushed commits)\n\n**From the command bar:**\n- Press `Cmd/Ctrl + K` and select **Push**\n\nCommits are pushed to the remote repository.\n\n<Info>\nPush is contextual - it only appears when there are changes to push and a PR exists.\n</Info>\n\n## Multi-Repository Git Operations\n\nFor workspaces with multiple repositories, manage each repo independently.\n\n### Per-Repository Actions\n\nAccess via command bar's **Repo Actions** page:\n\n| Action | Description |\n|--------|-------------|\n| Create PR | Create PR for specific repository |\n| Merge | Merge specific repository only |\n| Rebase | Rebase specific repository |\n| Change Target | Change target for specific repo |\n\n### Coordinating Changes\n\nWhen working across repos:\n\n1. Make related changes in each repository\n2. Create linked PRs referencing each other\n3. Merge in the correct order based on dependencies\n4. Verify integration after merging\n\n## Conflict Resolution\n\nWhen git conflicts occur:\n\n### Conflict Dialog\n\nThe workspace displays a conflict resolution dialog showing:\n\n- List of conflicting files\n- Options to resolve or abort\n\n### Resolution Steps\n\n1. Open conflicting files in your editor\n2. Resolve the conflicts manually\n3. Save the resolved files\n4. Return to the workspace\n5. Continue the operation\n\n### Aborting\n\nIf you can't resolve conflicts:\n\n- Click **Abort** in the conflict dialog\n- The operation is cancelled\n- Your branch returns to its previous state\n\n<Warning>\nUnresolved conflicts block git operations. Always resolve or abort before continuing work.\n</Warning>\n\n## Best Practices\n\n### Branch Hygiene\n\n- Rebase regularly to stay current with target\n- Create PRs early for visibility\n- Use descriptive branch names\n- Delete merged branches\n\n### PR Workflow\n\n1. Complete your changes\n2. Review diffs in the changes panel\n3. Rebase if behind target\n4. Create a PR (draft if work continues)\n5. Address review feedback\n6. Merge when approved\n\n### Multi-Repo Coordination\n\n- Plan cross-repo changes upfront\n- Document dependencies between PRs\n- Merge in dependency order\n- Test integration thoroughly\n\n## Troubleshooting\n\n<AccordionGroup>\n<Accordion title=\"'You have uncommitted changes' error\">\n**What it means:** You have modified files that haven't been committed.\n\n**Solutions:**\n1. Let the agent finish its current work (it may be about to commit)\n2. Check the Changes panel to see what's modified\n3. Commit the changes using the terminal: `git add . && git commit -m \"WIP\"`\n4. Or stash them: `git stash`\n</Accordion>\n\n<Accordion title=\"'Branch is behind target' warning\">\n**What it means:** The target branch (e.g., `main`) has new commits that your working branch doesn't have.\n\n**Solution:** Rebase your branch to include the new changes:\n1. Open command bar (`Cmd/Ctrl + K`)\n2. Select **Rebase**\n3. Resolve any conflicts if prompted\n</Accordion>\n\n<Accordion title=\"Merge conflicts during rebase\">\n**What it means:** Your changes and the target branch's changes modify the same lines of code.\n\n**Solutions:**\n1. Vibe Kanban shows a conflict dialog listing affected files\n2. Open each conflicting file in your editor\n3. Look for conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)\n4. Choose which version to keep (or combine them)\n5. Save the file and mark it as resolved\n6. Continue the rebase\n\n<Tip>\nFor complex conflicts, you can ask the agent to help resolve them. Start a new message describing the conflict.\n</Tip>\n</Accordion>\n\n<Accordion title=\"PR creation fails\">\n**Possible causes:**\n- Not authenticated with GitHub\n- No changes to create a PR from\n- Branch doesn't exist on remote\n\n**Solutions:**\n1. Check GitHub integration in Settings → Integrations\n2. Ensure you have committed changes\n3. Try pushing the branch first using the terminal\n</Accordion>\n\n<Accordion title=\"Can't push to remote\">\n**Possible causes:**\n- No remote configured\n- Authentication issues\n- Remote branch is protected\n\n**Solutions:**\n1. Verify remote is configured: `git remote -v` in terminal\n2. Check GitHub authentication in Settings\n3. For protected branches, create a PR instead of pushing directly\n</Accordion>\n</AccordionGroup>\n\n## Related Documentation\n\n- [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working with multiple repositories\n- [Command Bar](/workspaces/command-bar) - Git commands and shortcuts\n- [GitHub Integration](/integrations/github-integration) - GitHub setup and features\n"
  },
  {
    "path": "docs/workspaces/index.mdx",
    "content": "---\ntitle: \"Workspaces Overview\"\ndescription: \"A redesigned task execution experience with multi-repo support, multiple agent sessions, and an integrated preview browser\"\nsidebarTitle: \"Overview\"\n---\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-overview.png\" alt=\"Workspaces UI showing the four-panel layout with workspace sidebar, conversation, changes panel, and details sidebar\" />\n</Frame>\n\nThe Workspaces UI is a complete redesign of the task execution experience, providing an IDE-like interface optimised for AI-assisted development workflows.\n\n## What is a Workspace?\n\nA **workspace** is an isolated environment for completing a coding task. Think of it as a dedicated \"project room\" where you:\n\n1. **Work on a specific task** - Each workspace focuses on one goal (e.g., \"Add user authentication\")\n2. **Chat with coding agents** - AI assistants that write and modify code for you\n3. **Review changes** - See exactly what code was added, modified, or deleted\n4. **Test your application** - Built-in preview browser to see changes in action\n5. **Create pull requests** - Submit your changes for review when ready\n\n<Info>\n**Why use Workspaces instead of coding manually?**\n\nWorkspaces let you describe what you want in plain English, and AI coding agents do the implementation. You review the changes, provide feedback, and iterate until the task is complete. It's like having a junior developer who works instantly and never gets tired.\n</Info>\n\n## Key Concepts\n\n| Concept | Description | Example |\n|---------|-------------|---------|\n| **Workspace** | A task execution environment where you work with agents | \"Add user authentication\" |\n| **Repository** | A git repository included in a workspace | `frontend`, `backend` |\n| **Session** | A conversation thread with a coding agent | Claude Code session for implementing a feature |\n\n**How they relate:**\n- A **workspace** is your working environment for a specific task\n- A workspace can contain one or more **repositories** (each with independent git state)\n- Within a workspace, you can have multiple **sessions** to chat with different agents or start fresh conversations\n\nFor example, you create a workspace for \"Add authentication\" that includes both `frontend` and `backend` repositories. Within that workspace, you might have one session for implementing the backend API and another session for the frontend UI.\n\n## Key Features\n\n<CardGroup cols={2}>\n<Card title=\"Multi-Repository Support\" icon=\"folder-tree\" href=\"/workspaces/multi-repo-sessions\">\n  Work across multiple repositories within a single workspace. Reference code from one repo while implementing changes in another.\n</Card>\n\n<Card title=\"Multiple Sessions\" icon=\"messages\" href=\"/workspaces/multi-repo-sessions\">\n  Run multiple agent conversations simultaneously. Work around token limits by starting fresh sessions while keeping context.\n</Card>\n\n<Card title=\"Browser Testing\" icon=\"browser\" href=\"/browser-testing\">\n  Built-in browser preview without leaving the workspace. Test across desktop, mobile, and custom viewport sizes.\n</Card>\n\n<Card title=\"Changes Panel\" icon=\"code-compare\" href=\"/workspaces/changes\">\n  Review code modifications with syntax-highlighted diffs. Add inline comments to provide feedback to agents.\n</Card>\n\n<Card title=\"Integrated Terminal\" icon=\"rectangle-terminal\" href=\"/workspaces/interface#terminal-section\">\n  Built-in terminal for running commands directly in your workspace. New to the Workspaces UI.\n</Card>\n\n<Card title=\"Workspace Notes\" icon=\"note-sticky\" href=\"/workspaces/interface#notes-section\">\n  Notes for each workspace to document important information. New to the Workspaces UI.\n</Card>\n\n<Card title=\"Command Bar\" icon=\"command\" href=\"/workspaces/command-bar\">\n  Central navigation hub for quick actions. Access every workspace action with keyboard shortcuts.\n</Card>\n</CardGroup>\n\n## Common Questions\n\n<AccordionGroup>\n<Accordion title=\"What happens to my code when I create a workspace?\">\nWhen you create a workspace, Vibe Kanban:\n\n1. Creates a **git worktree** - a separate working directory linked to a new branch\n2. Your original repository stays untouched\n3. All changes happen in the worktree on a new branch\n4. Nothing is pushed to remote until you explicitly create a PR\n\n**Your main branch is safe.** The workspace isolates all changes until you're ready to merge.\n</Accordion>\n\n<Accordion title=\"Can I work on multiple tasks at once?\">\nYes. Each workspace is completely independent:\n\n- Create as many workspaces as you need\n- Each has its own branch, changes, and sessions\n- Switch between workspaces using the sidebar\n- Agents in different workspaces don't interfere with each other\n</Accordion>\n\n<Accordion title=\"What if the agent makes a mistake?\">\nYou have full control:\n\n- **Review changes** in the Changes panel before committing\n- **Add comments** on specific lines to request fixes\n- **Edit messages** to retry with different instructions\n- **Start a new session** if the conversation goes off track\n- **Delete the workspace** if you want to start completely fresh\n\nThe agent only modifies files in your workspace - it cannot push code or merge without your explicit action.\n</Accordion>\n\n<Accordion title=\"How do I know when the agent is done?\">\nWatch for these indicators:\n\n- **Status in sidebar** changes from \"Running\" to \"Idle\"\n- **Chat shows completion** message or asks for next steps\n- **Changes panel** shows all modifications made\n\nIf the agent needs your input (approval, clarification), you'll see a \"Needs Attention\" indicator with a raised hand icon.\n</Accordion>\n\n<Accordion title=\"What's the difference between a Project and a Workspace?\">\n- **Project** = A container that groups related repositories (configured once in Settings)\n- **Workspace** = A task execution environment for a specific coding task\n\nThink of it this way: A Project is like a team folder that contains your repos. A Workspace is a task you're working on using those repos.\n</Accordion>\n</AccordionGroup>\n\n"
  },
  {
    "path": "docs/workspaces/interface.mdx",
    "content": "---\ntitle: \"Interface Guide\"\ndescription: \"Understanding the Workspaces four-panel layout and navigation\"\n---\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-interface-overview.png\" alt=\"Workspaces interface showing the four-panel layout with labels for each section\" />\n</Frame>\n\nThe Workspaces UI uses a flexible four-panel layout designed for efficient AI-assisted development workflows.\n\n## Panel Overview\n\n| Panel | Position | Purpose |\n|-------|----------|---------|\n| **Workspace Sidebar** | Left edge | List of all workspaces with status indicators |\n| **Conversation Panel** | Left main | Chat with coding agents, session management |\n| **Context Panel** | Right main | Changes, logs, or preview (toggleable) |\n| **Details Sidebar** | Right edge | Git status, terminal, notes |\n\n## Navbar\n\nThe navbar at the top of the workspace provides quick access to common actions.\n\n### Left Section\n\n| Icon | Action | Description |\n|:----:|--------|-------------|\n| <Icon icon=\"box-archive\" /> | Archive Workspace | Move workspace to/from archive |\n| <Icon icon=\"arrow-right-from-bracket\" /> | Open in Old UI | Switch to the classic interface |\n\n### Right Section - Panel Controls\n\n| Icon | Action | Description |\n|:----:|--------|-------------|\n| <Icon icon=\"sidebar\" /> | Toggle Left Sidebar | Show/hide the workspace list |\n| <Icon icon=\"comments\" /> | Toggle Chat Panel | Show/hide the conversation panel |\n| <Icon icon=\"code-compare\" /> | Toggle Changes | Show/hide the changes panel |\n| <Icon icon=\"terminal\" /> | Toggle Logs | Show/hide the logs panel |\n| <Icon icon=\"desktop\" /> | Toggle Preview | Show/hide the preview panel |\n| <Icon icon=\"sidebar-flip\" /> | Toggle Right Sidebar | Show/hide the details sidebar |\n\n### Right Section - Utilities\n\n| Icon | Action | Description |\n|:----:|--------|-------------|\n| <Icon icon=\"list\" /> | Command Bar | Open the command bar |\n| <Icon icon=\"bullhorn\" /> | Feedback | Send feedback about Workspaces |\n| <Icon icon=\"circle-question\" /> | Workspaces Guide | Open the onboarding guide |\n| <Icon icon=\"gear\" /> | Settings | Open application settings |\n\n<Tip>\nWhen the Changes panel is open, additional diff controls appear for toggling between side-by-side and inline views.\n</Tip>\n\n## Workspace Sidebar\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-sidebar.png\" alt=\"Workspace sidebar in flat list layout showing workspaces with timestamps and change counts\" />\n</Frame>\n\nThe left sidebar displays all your workspaces at a glance.\n\nSwitch to accordion layout to group workspaces by status:\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-sidebar-accordion.png\" alt=\"Workspace sidebar in accordion layout grouped by Needs Attention, Idle, and Running\" />\n</Frame>\n\n### Status Indicators\n\nEach workspace shows its current state:\n\n| Indicator | Meaning |\n|-----------|---------|\n| **Running** | Agent is actively processing |\n| **Idle** | Waiting for input |\n| **Needs Attention** | Pending approval required (raised hand icon) |\n| **Pinned** | Workspace pinned to top of list |\n| **Dev Server** | Blue indicator when dev server is running |\n| **PR Status** | Badge showing linked pull request status |\n\n### Workspace Actions\n\n- **Search**: Filter workspaces by name or branch\n- **Pin**: Keep important workspaces at the top\n- **Archive**: Move completed workspaces out of the main list\n- **Layout toggle**: Switch between flat list and accordion (grouped by status)\n\n### Creating a New Workspace\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-create-new.png\" alt=\"Create Workspace view showing project selection, repositories panel, and task description input\" />\n</Frame>\n\nClick the **+** button at the top of the sidebar to create a new workspace. The interface switches to create mode:\n\n1. **Select a project** from the Project dropdown in the right sidebar\n2. **Add repositories** - click repos from the \"Add Repositories\" list or browse for repos on disk\n3. **Set target branch** - each selected repo shows its target branch (click to change)\n4. **Describe your task** in the chat input at the bottom\n5. **Select an agent** (e.g., Claude Code) and variant\n6. Click **Create** to start the workspace\n\n<Note>\nFor detailed instructions, see [Creating Workspaces](/workspaces/creating-workspaces) and [Repositories](/workspaces/repositories).\n</Note>\n\n## Conversation Panel\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-conversation.png\" alt=\"Conversation panel showing chat history with coding agent and session switcher\" />\n</Frame>\n\nThe left main panel is where you interact with coding agents.\n\n### Chat Interface\n\n- View the full conversation history with the agent\n- Send messages and follow-up instructions\n- Rich text support for formatting\n- Approval workflows for reviewing agent plans\n- Agent and variant selection\n\nSee [Chat Interface](/workspaces/chat-interface) for the complete guide.\n\n### Session Dropdown\n\nThe chat box toolbar includes a session dropdown that lets you:\n\n- View all sessions in the workspace\n- Switch between sessions (shows \"Latest\" or timestamp)\n- Create a new session by selecting \"New Session\"\n\n<Tip>\nCreate multiple sessions to work around conversation token limits or to run different agents in parallel.\n</Tip>\n\nSee [Sessions](/workspaces/sessions) for more details on managing multiple sessions.\n\n### Chat Shortcuts\n\n| Shortcut | Action |\n|----------|--------|\n| `Cmd/Ctrl + Enter` | Send message |\n| `Shift + Cmd/Ctrl + Enter` | Alternative send mode |\n| `Cmd/Ctrl + B` | Bold text |\n| `Cmd/Ctrl + I` | Italic text |\n| `Cmd/Ctrl + U` | Underline text |\n\n## Context Panel\n\nThe right main panel toggles between three views:\n\n### Changes View\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-changes-panel.png\" alt=\"Changes panel showing file tree and diff viewer with code changes\" />\n</Frame>\n\nDisplays all modified files with inline diffs:\n\n- **File tree**: Hierarchical view of changed files\n- **Search**: Filter files by name\n- **Diff viewer**: See code changes with syntax highlighting\n- **Comments**: Add inline comments for agent feedback\n\n### Logs View\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-logs-panel.png\" alt=\"Logs panel showing process output with tabs for different processes\" />\n</Frame>\n\nShows process execution logs:\n\n- **Process tabs**: Switch between different running processes\n- **Log output**: View stdout/stderr in real-time\n- **Search logs**: Filter log content\n\n### Preview View\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-preview-panel.png\" alt=\"Preview panel showing built-in browser with dev server output\" />\n</Frame>\n\nBuilt-in browser for testing your application:\n\n- **Dev server tabs**: Multiple dev servers supported\n- **Device modes**: Desktop, mobile, custom viewport\n- **Process logs**: View dev server output\n\nToggle between views using the navbar buttons or the [command bar](/workspaces/command-bar).\n\n## Details Sidebar\n\nThe right sidebar provides quick access to workspace details.\n\n### Git Section\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-git-panel.png\" alt=\"Git section showing repository, branch, and pull request options\" />\n</Frame>\n\nAlways visible, showing:\n\n- Current repository and branch\n- Target branch for merging\n- Uncommitted changes count\n- Commits ahead/behind target\n- Quick access to [git operations](/workspaces/git-operations)\n\nSee [Repositories](/workspaces/repositories) for details on managing repositories and branches.\n\n### Terminal Section\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-terminal.png\" alt=\"Integrated terminal in the details sidebar showing command output\" />\n</Frame>\n\n<Note>\nThe integrated terminal is a new feature exclusive to the Workspaces UI and is not available in the classic interface.\n</Note>\n\nThe expandable terminal lets you run commands directly in your workspace:\n\n- **Full terminal emulation** powered by xterm.js\n- **Run any command** - git, npm, build scripts, etc.\n- **Persistent state** - terminal persists across panel toggles\n- **Expandable** - collapse when not needed, expand when you need it\n\n### Notes Section\n\n<Frame>\n<img style={{maxHeight: \"250px\"}} src=\"/images/workspaces-notes.png\" alt=\"Notes section in the details sidebar showing rich text editor with workspace notes\" />\n</Frame>\n\n<Note>\nWorkspace notes are a new feature exclusive to the Workspaces UI and are not available in the classic interface.\n</Note>\n\nThe expandable notes section lets you document important information:\n\n- **Auto-save** - Notes save automatically as you type\n- **Per-workspace** - Each workspace has its own notes\n- **Persistent** - Notes are preserved across sessions\n\n## Context Bar\n\n<Frame>\n<img style={{maxHeight: \"150px\"}} src=\"/images/workspaces-context-bar.png\" alt=\"Context bar floating toolbar showing quick action buttons\" />\n</Frame>\n\nA floating toolbar that provides quick access to common actions:\n\n| Icon | Action | Description |\n|:----:|--------|-------------|\n| *IDE logo* | Open in IDE | Launch workspace in your configured editor (icon shows your IDE) |\n| <Icon icon=\"copy\" /> | Copy Path | Copy the workspace path to clipboard |\n| <Icon icon=\"play\" /> | Toggle Dev Server | Start or stop the development server |\n| <Icon icon=\"desktop\" /> | Toggle Preview | Show or hide the preview panel |\n| <Icon icon=\"code-branch\" /> | Toggle Changes | Show or hide the changes panel |\n\n<Tip>\nThe context bar is draggable - position it wherever works best for your workflow.\n</Tip>\n\n## Resizing Panels\n\nDrag the separators between panels to adjust their proportions. Your layout preferences are saved per workspace.\n\n## Toggling Panels\n\nShow or hide panels to focus on what matters:\n\n- Use the navbar toggle buttons\n- Use the [command bar](/workspaces/command-bar) with `Cmd/Ctrl + K`\n- View Options commands let you toggle any panel\n\n<Info>\nPanel states are remembered, so your preferred layout is restored when you return to a workspace.\n</Info>\n\n## Related Documentation\n\n- [Creating Workspaces](/workspaces/creating-workspaces) - Step-by-step guide to creating workspaces\n- [Repositories](/workspaces/repositories) - Managing repositories and branches\n- [Sessions](/workspaces/sessions) - Working with multiple conversation sessions\n- [Chat Interface](/workspaces/chat-interface) - Complete guide to the conversation panel\n- [Command Bar](/workspaces/command-bar) - Master keyboard shortcuts and quick actions\n- [Browser Testing](/browser-testing) - Built-in browser for testing your application\n- [Changes Panel](/workspaces/changes) - Review code modifications and provide feedback\n- [Git Operations](/workspaces/git-operations) - Create PRs, merge, rebase, and manage branches\n"
  },
  {
    "path": "docs/workspaces/managing-workspaces.mdx",
    "content": "---\ntitle: \"Managing Workspaces\"\ndescription: \"Archive, delete, and manage disk space for your workspaces\"\n---\n\nLearn how to organise your workspace list, free up disk space, and understand what happens to your code when you archive or delete workspaces.\n\n<Note>\n**Workspace Actions** are available from both the command bar and the workspace sidebar. These actions require an active workspace - they won't appear on the create workspace page.\n</Note>\n\n## Accessing Workspace Actions\n\nYou can access workspace actions in two ways:\n\n**From the sidebar:**\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-sidebar-actions.png\" alt=\"Workspace sidebar showing the More actions button on hover\" />\n</Frame>\n\nHover over a workspace in the sidebar and click the **...** (More actions) button to see available actions.\n\n**From the command bar:**\n1. Press `Cmd/Ctrl + K`\n2. Go to **Workspace Actions**\n3. Select the action you want\n\n<Frame>\n<img style={{maxHeight: \"400px\"}} src=\"/images/workspaces-actions-menu.png\" alt=\"Workspace Actions menu showing available actions like Rename, Duplicate, Pin, Archive, and Delete\" />\n</Frame>\n\n## Pinning Workspaces\n\nKeep important workspaces at the top of your list by selecting **Pin Workspace** from the workspace actions.\n\nPinned workspaces appear at the top of the sidebar regardless of their last activity time.\n\n## Archiving Workspaces\n\nWhen you're done with a workspace but might want to return to it later, archive it to keep your workspace list clean.\n\nSelect **Archive** from the workspace actions, or click the **Archive** button (<Icon icon=\"box-archive\" />) in the top left of the navbar.\n\nArchived workspaces can be viewed by clicking **View Archive** at the bottom of the sidebar.\n\n<Info>\n**Archiving preserves everything.** Your conversation history, sessions, notes, and worktree files all remain intact. You can unarchive at any time to continue working.\n</Info>\n\n## Deleting Workspaces\n\nTo permanently delete a workspace, select **Delete Workspace** from the workspace actions.\n\n<Warning>\nDeleting a workspace is permanent and cannot be undone. Make sure you've pushed any changes you want to keep before deleting.\n</Warning>\n\n### What Gets Deleted\n\nWhen you delete a workspace:\n\n| What | Deleted? | Notes |\n|------|----------|-------|\n| **Workspace data** | Yes | Conversation history, sessions, notes |\n| **Git worktree** | Yes | The working directory and its files |\n| **Git branch** | No | The branch remains in your repository |\n| **Commits** | No | All commits are preserved in the repository |\n| **Original repository** | No | Your source repository is never touched |\n\n<Info>\n**Your code is safe.** Deleting a workspace removes the worktree copy, but all commits remain in your original repository. If you've pushed to remote or created a PR, your work is preserved.\n</Info>\n\n### Archive vs Delete\n\n| Action | Worktree | Conversation | Can Restore? |\n|--------|----------|--------------|--------------|\n| **Archive** | Kept on disk | Preserved | Yes - unarchive anytime |\n| **Delete** | Removed | Deleted | No - permanent |\n\n**Use Archive when:**\n- You might return to this work later\n- You want to keep the conversation history\n- You're cleaning up your workspace list but not done with the task\n\n**Use Delete when:**\n- The task is complete and merged\n- You want to free up disk space\n- You don't need the conversation history\n\n## Disk Space and Worktree Location\n\nEach workspace creates a **git worktree** - a separate working directory with a full copy of your repository's tracked files. Multiple workspaces for large repositories can consume significant disk space.\n\n### Where Worktrees Are Stored\n\nWorktrees are stored in a platform-specific directory by default:\n\n| Platform | Default Location |\n|----------|-----------------|\n| **macOS** | `/var/folders/.../T/vibe-kanban/worktrees` (system temp) |\n| **Linux** | `/var/tmp/vibe-kanban/worktrees` |\n| **Windows** | `%TEMP%\\vibe-kanban\\worktrees` |\n\nYou can configure a custom location in **Settings → General → Workspace Directory**. When set, worktrees are stored in `{your-path}/.vibe-kanban-workspaces`.\n\n### Checking Disk Usage\n\n<Tabs>\n<Tab title=\"macOS\">\n```bash\ndu -sh $TMPDIR/vibe-kanban/worktrees\n```\n</Tab>\n<Tab title=\"Linux\">\n```bash\ndu -sh /var/tmp/vibe-kanban/worktrees\n```\n</Tab>\n<Tab title=\"Windows\">\n```powershell\nGet-ChildItem \"$env:TEMP\\vibe-kanban\\worktrees\" -Recurse | Measure-Object -Property Length -Sum\n```\n</Tab>\n</Tabs>\n\n### Freeing Up Space\n\nTo reduce disk usage:\n\n1. **Delete completed workspaces** - Use Delete (not Archive) for tasks that are finished and merged\n2. **Review archived workspaces** - Archived workspaces still consume disk space\n3. **Configure cleanup scripts** - Add cleanup scripts to remove build artifacts (like `node_modules`) when workspaces close\n\n<Tip>\nLarge directories like `node_modules`, `target/`, or `build/` are copied into each worktree. Consider adding cleanup scripts to remove these when you're done with a workspace.\n</Tip>\n\n## Manual Worktree Deletion\n\nIf you manually delete a worktree directory (e.g., using `rm -rf` in terminal), Vibe Kanban will **automatically recreate it** the next time you open that workspace. The worktree is recreated from the branch's last commit.\n\n<Warning>\n**Uncommitted changes will be lost.** If you had staged or unstaged changes that weren't committed, they cannot be recovered. Always commit your work before manually deleting worktree directories.\n</Warning>\n\n## Orphan Cleanup\n\nVibe Kanban automatically cleans up \"orphaned\" worktrees on startup - these are worktree directories that no longer have a matching workspace in the database (e.g., if the app crashed during deletion).\n\nThis cleanup runs automatically, so you don't need to manually manage stale worktree directories.\n\n## Renaming Workspaces\n\nTo rename a workspace, select **Rename Workspace** from the workspace actions and enter the new name.\n\n## Duplicating Workspaces\n\nTo create a copy of an existing workspace, select **Duplicate Workspace** from the workspace actions.\n\nThe duplicate includes the same repositories and branch configuration but starts with a fresh conversation.\n\n## Related Documentation\n\n- [Creating Workspaces](/workspaces/creating-workspaces) - Setting up new workspaces\n- [Interface Guide](/workspaces/interface) - Understanding the workspace layout\n- [Command Bar](/workspaces/command-bar) - Quick actions and keyboard shortcuts\n"
  },
  {
    "path": "docs/workspaces/multi-repo-sessions.mdx",
    "content": "---\ntitle: \"Multi-Repo & Sessions\"\ndescription: \"Work with multiple repositories and agent sessions in a single workspace\"\n---\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-multi-repo.png\" alt=\"Workspace with multiple repositories showing the repository panel with two repos\" />\n</Frame>\n\nWorkspaces support working across multiple repositories and running multiple agent sessions simultaneously.\n\n## Multi-Repository Support\n\nAdd multiple repositories to a single workspace to work on cross-repo tasks.\n\n### Adding Repositories\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-add-repo.png\" alt=\"Repository selection showing selected repo with branch dropdown and list of available repositories to add\" />\n</Frame>\n\nWhen creating a new workspace, add multiple repositories from the create view:\n\n1. Click the **+** button in the workspace sidebar\n2. Select your **project** from the dropdown\n3. Click repositories from the **Add Repositories** list to include them\n4. Set the **target branch** for each selected repository\n5. Each repo maintains independent git state\n\n### Working Across Repos\n\nWith multiple repositories in a workspace:\n\n- **Reference code** from one repo while implementing changes in another\n- **Implement coordinated changes** across multiple codebases\n- **Manage git operations** independently per repository\n- **View changes** from all repos in a unified diff view\n\n### Repository Panel\n\nEach repository in the workspace shows:\n\n| Information | Description |\n|-------------|-------------|\n| Repository name | With status indicator |\n| Current branch | The working branch |\n| Target branch | Branch to merge into |\n| Changes count | Uncommitted modifications |\n| Commits ahead/behind | Relative to target branch |\n\n### Per-Repository Actions\n\nAccess repository-specific actions via the [command bar](/workspaces/command-bar):\n\n- **Open Repo in IDE** - Open just this repository\n- **Copy Repo Path** - Copy the repository path\n- **Repository Settings** - Configure repo options\n- **Git Operations** - PR, merge, rebase per repo\n\n<Tip>\nUse the command bar's Repo Actions page to quickly switch between repositories when performing git operations.\n</Tip>\n\n## Multiple Sessions\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-sessions.png\" alt=\"Session dropdown in chat box toolbar showing multiple sessions\" />\n</Frame>\n\nCreate multiple agent conversation sessions within a single workspace.\n\n### Why Use Multiple Sessions?\n\n- **Token limits**: Start fresh sessions when conversations get long\n- **Parallel work**: Run different tasks or agents simultaneously\n- **Code review**: Start a dedicated review session\n- **Experimentation**: Try different approaches in separate sessions\n\n### Creating a New Session\n\n1. Click the session dropdown in the chat box toolbar\n2. Select **New Session** from the dropdown\n3. Select the agent to use for the new session\n4. Start your conversation\n\n### Switching Between Sessions\n\nThe session dropdown in the chat box toolbar shows all sessions:\n\n- **Latest** - the most recent session\n- Older sessions show their creation timestamp\n- Click any session to switch to it\n- Each session maintains its own conversation history\n\n### Session Indicators\n\n| Indicator | Meaning |\n|-----------|---------|\n| **Running** | Agent is actively processing |\n| **Idle** | Session waiting for input |\n| **Agent icon** | Shows which agent is assigned |\n\n### Running Multiple Agents\n\nDifferent sessions can use different coding agents:\n\n1. Create a new session\n2. Select a different agent (Claude Code, Gemini CLI, etc.)\n3. Both sessions can run in parallel\n4. Switch between them to monitor progress\n\n<Info>\nEach session operates independently. Changes made by one agent are visible to other sessions through the shared workspace files.\n</Info>\n\n## Example: Full-Stack Feature\n\nHere's a practical workflow for implementing a feature across frontend and backend repositories:\n\n<Steps>\n<Step title=\"Create workspace with both repos\">\nCreate a new workspace, select your project, and add both `frontend` and `backend` repositories. Set target branches to `main` for both.\n</Step>\n\n<Step title=\"Describe the full task\">\nIn your initial message, describe the complete feature:\n\n```\nAdd a user profile page:\n- Backend: Create GET /api/users/:id endpoint returning user data\n- Frontend: Add /profile/:id route with a UserProfile component\n- The frontend should fetch from the backend endpoint\n```\n</Step>\n\n<Step title=\"Agent works across repos\">\nThe agent reads code from both repositories and makes coordinated changes. It understands the relationship between frontend and backend.\n</Step>\n\n<Step title=\"Test integration\">\nStart the dev server (if configured for one repo, you may need to start the other manually via terminal). Test that the frontend correctly calls the backend.\n</Step>\n\n<Step title=\"Create separate PRs\">\nCreate a PR for each repository:\n1. Open command bar (`Cmd/Ctrl + K`)\n2. Go to **Repo Actions**\n3. Select **Create PR** for the backend repo\n4. Repeat for the frontend repo\n5. In PR descriptions, reference the related PR\n</Step>\n\n<Step title=\"Merge in order\">\nMerge backend first (since frontend depends on it), then merge frontend.\n</Step>\n</Steps>\n\n## Best Practices\n\n### Multi-Repo Workflows\n\n- **Start with a plan**: Describe the cross-repo changes needed upfront\n- **Coordinate commits**: Ensure related changes are committed together\n- **Test integration**: Use the preview to verify repos work together\n- **Create linked PRs**: Reference related PRs across repositories (e.g., \"Related to frontend#123\")\n\n### Session Management\n\n- **One focus per session**: Keep each session focused on a specific goal\n- **Document in notes**: Use workspace notes to track which session does what\n- **Limit concurrent sessions**: Too many active sessions can be confusing\n- **Review before switching**: Check the changes panel when switching sessions\n\n<Warning>\n**File conflicts:** When multiple sessions modify the same files, the last write wins. Review the Changes panel carefully to ensure nothing was overwritten unexpectedly.\n</Warning>\n\n## Related Documentation\n\n- [Interface Guide](/workspaces/interface) - Understanding the workspace layout\n- [Git Operations](/workspaces/git-operations) - Managing git across repositories\n- [Command Bar](/workspaces/command-bar) - Quick actions for repo management\n"
  },
  {
    "path": "docs/workspaces/repositories.mdx",
    "content": "---\ntitle: \"Repositories\"\ndescription: \"Add and manage repositories within your workspaces\"\n---\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-add-repo.png\" alt=\"Repository selection showing selected repo with branch dropdown and list of available repositories\" />\n</Frame>\n\nRepositories are the codebases you work with inside a workspace. Each workspace can include one or more repositories, and each repository maintains its own independent git state.\n\n## How Repositories Work in Workspaces\n\nWhen you add a repository to a workspace, Vibe Kanban creates a **git worktree** - a separate working directory linked to your repository.\n\n<AccordionGroup>\n<Accordion title=\"What is a git worktree?\">\nA **git worktree** is a git feature that lets you have multiple working directories for the same repository, each on a different branch.\n\n**Why this matters for you:**\n- Your original repository folder stays exactly as it was\n- The workspace gets its own folder with its own branch\n- You can have multiple workspaces working on different features simultaneously\n- Switching between workspaces doesn't require stashing or committing\n\n**Where worktrees are stored:** In the `.vibe-kanban-workspaces` directory (configurable in Settings).\n</Accordion>\n\n<Accordion title=\"What is 'independent git state'?\">\nEach repository in a workspace has its own:\n\n- **Working branch** - The branch where changes are made\n- **Target branch** - The branch you'll merge into\n- **Commit history** - Commits made in this workspace\n- **Staged/unstaged changes** - Independent of other workspaces\n\nThis means you can have multiple workspaces modifying the same repository on different branches without conflicts.\n</Accordion>\n</AccordionGroup>\n\n## Adding Repositories\n\nWhen creating or editing a workspace, you can add repositories from several sources:\n\n### Recent Repositories\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-recent-repos.png\" alt=\"Recent repositories list showing previously used repos\" />\n</Frame>\n\nYour recently used repositories appear at the top of the list. Click any repository to add it to the workspace.\n\n### Browse Repos on Disk\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-browse-repos.png\" alt=\"Select Git Repository dialog with file browser and path input\" />\n</Frame>\n\nClick **Browse repos on disk** to find repositories that aren't in your recent list. You can:\n\n- **Enter path manually** - Type the full path and click **Go**\n- **Search current directory** - Filter folders by name\n- **Navigate folders** - Click folder names to browse, use home and up buttons\n- **Select Current** - Use the current directory as the repository\n\nFolders containing git repositories are marked with a **git repo** badge.\n\n### Create New Repo on Disk\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-create-repo.png\" alt=\"Create New Repository dialog with name and location fields\" />\n</Frame>\n\nClick **Create new repo on disk** to initialise a new git repository:\n\n1. Enter a **Name** for the repository\n2. Choose a **Location** on disk (click the folder icon to browse)\n3. Click **Create Repository**\n\nThis creates a new folder with an initialised git repository, ready for use in your workspace.\n\n## Target Branches\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-target-branch.png\" alt=\"Branch dropdown showing search and list of available branches\" />\n</Frame>\n\nEach repository in a workspace has a **target branch** - the branch your changes will eventually be merged into (typically `main`, `master`, or `develop`).\n\n### Setting the Target Branch\n\n1. Click the branch dropdown next to the repository name\n2. Search or scroll to find the branch you want to target\n3. Select the branch - the workspace creates a working branch based on this target\n\nThe dropdown shows:\n- **Current** badge - indicates your current branch\n- **Local branches** - branches on your machine\n- **Remote branches** - branches from origin (e.g., `origin/main`)\n\n<Info>\nChanges you make won't affect the target branch until you create a pull request and merge.\n</Info>\n\n### Changing the Target Branch\n\nTo change the target branch after workspace creation:\n\n**From the Git panel:**\n1. Click the target branch dropdown (e.g., **main**) in the Git section of the right sidebar\n2. Select a new target branch\n\n**From the command bar:**\n1. Press `Cmd/Ctrl + K`\n2. Select **Change Target Branch**\n3. Choose the new target branch\n\n<Warning>\nChanging the target branch may require rebasing your changes. See [Git Operations](/workspaces/git-operations) if conflicts occur.\n</Warning>\n\n## Working Branch\n\nWhen you create a workspace, a **working branch** is automatically created for each repository. This is where your changes are made.\n\nThe working branch name is based on your task and branch prefix settings (configured in [General Settings](/settings/general)).\n\n### Viewing the Working Branch\n\nThe current working branch is displayed in the **Git** section of the details sidebar, under \"Working Branch\".\n\n## Multi-Repository Workspaces\n\n<Frame>\n<img style={{maxHeight: \"350px\"}} src=\"/images/workspaces-multi-repo.png\" alt=\"Workspace with multiple repositories showing independent git state for each\" />\n</Frame>\n\nWorkspaces can include multiple repositories for tasks that span several codebases.\n\n### When to Use Multiple Repositories\n\n- **Monorepo alternatives** - Work across frontend and backend repos simultaneously\n- **Shared libraries** - Update a library and its consumers together\n- **Microservices** - Coordinate changes across multiple services\n\n### Independent Git State\n\nEach repository in a multi-repo workspace maintains independent git state:\n\n- Separate working branches\n- Separate target branches\n- Individual commit histories\n- Independent pull requests\n\n### Cross-Repository Context\n\nWhen working with multiple repositories, the agent can:\n\n- Read code from all repositories\n- Make changes across repositories in a single session\n- Reference patterns from one repo while implementing in another\n\n<Tip>\nUse clear task descriptions that specify which repositories should be modified to help the agent understand your intent.\n</Tip>\n\n## Repository Actions\n\nAccess repository-specific actions through the command bar:\n\n| Action | Description |\n|--------|-------------|\n| **Copy Repo Path** | Copy the repository's local path to clipboard |\n| **Open Repo in IDE** | Open the repository in your configured editor |\n| **Repository Settings** | Configure repository-specific settings |\n| **Create PR** | Create a pull request for this repository |\n| **Merge** | Merge the target branch into your working branch |\n| **Rebase** | Rebase your working branch onto the target |\n| **Push** | Push commits to the remote repository |\n\n<Note>\nFor multi-repo workspaces, repository actions show which repo they apply to. Select the specific repository when prompted.\n</Note>\n\n## Removing Repositories\n\n<Frame>\n<img style={{maxHeight: \"250px\"}} src=\"/images/workspaces-remove-repo.png\" alt=\"Repository with X button to remove it from workspace\" />\n</Frame>\n\nTo remove a repository from a workspace, click the **X** button next to the repository name.\n\n<Warning>\nRemoving a repository doesn't delete any code or branches. It only removes the repository from the current workspace.\n</Warning>\n\n## Related Documentation\n\n- [Creating Workspaces](/workspaces/creating-workspaces) - Setting up new workspaces with repositories\n- [Git Operations](/workspaces/git-operations) - Detailed guide to git commands\n- [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working across multiple repositories\n"
  },
  {
    "path": "docs/workspaces/sessions.mdx",
    "content": "---\ntitle: \"Sessions\"\ndescription: \"Create and manage multiple conversation sessions within a workspace\"\n---\n\n<Frame>\n<img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-sessions.png\" alt=\"Session dropdown showing multiple sessions with New Session option\" />\n</Frame>\n\nSessions are conversation threads with coding agents within a workspace. Each session maintains its own conversation history, allowing you to work on different aspects of a task or try alternative approaches.\n\n## What is a Session?\n\nA **session** is a single conversation with a coding agent. Think of it like a chat thread:\n\n- **Each session has its own conversation history** - messages, code changes, and context\n- **Sessions share the same files** - changes from one session are visible to all sessions\n- **Sessions are independent processes** - one can be running while another is idle\n\n<Info>\n**Key point:** Sessions share files but not conversation context. If Session 1 makes changes, Session 2 can see those file changes but doesn't know what instructions Session 1 received.\n</Info>\n\n## Understanding Token Limits\n\nAI models have a **context window** - a limit on how much text they can \"remember\" in a conversation. When you hit this limit:\n\n- The agent may forget earlier parts of the conversation\n- Responses may become less accurate\n- You'll see the context gauge turn orange or red\n\n**Solution:** Start a new session. The new session gets a fresh context window while still having access to all your files.\n\n<Tip>\nWatch the **context gauge** in the chat interface. When it shows high usage (orange/red), consider starting a new session.\n</Tip>\n\n## When to Create a New Session\n\n<CardGroup cols={2}>\n<Card title=\"Token limits reached\" icon=\"gauge-high\">\nContext gauge shows high usage - start fresh to give the agent full context capacity.\n</Card>\n\n<Card title=\"Parallel work\" icon=\"code-branch\">\nHave agents work on independent parts simultaneously (backend API + frontend UI).\n</Card>\n\n<Card title=\"Different approaches\" icon=\"route\">\nTry an alternative solution without losing your original conversation.\n</Card>\n\n<Card title=\"Different agents\" icon=\"robot\">\nUse Claude Code for one task, Gemini CLI for another.\n</Card>\n</CardGroup>\n\n## When NOT to Create a New Session\n\n- **Continuing related work** - Keep using the same session if the agent needs context from earlier messages\n- **Providing feedback** - Use the same session to tell the agent what to fix\n- **Small follow-ups** - \"Also add a loading spinner\" belongs in the current session\n\n<Warning>\nNew sessions don't inherit conversation history. The new agent won't know what happened in previous sessions unless you explain it again or it reads the file changes.\n</Warning>\n\n<Info>\nAll sessions within a workspace share the same repositories and git state. Changes made by one session are visible to others.\n</Info>\n\n## Creating a New Session\n\n<Steps>\n<Step title=\"Open the Session Dropdown\">\n  Click the session dropdown (showing \"Latest\" or the session name) in the chat toolbar.\n</Step>\n\n<Step title=\"Click New Session\">\n  Select **+ New Session** from the dropdown menu.\n\n  <Frame>\n  <img style={{maxHeight: \"300px\"}} src=\"/images/workspaces-create-new-session.png\" alt=\"Session dropdown showing New Session option and list of existing sessions\" />\n  </Frame>\n</Step>\n\n<Step title=\"Start Your Conversation\">\n  The new session opens with a fresh conversation. Describe what you want the agent to work on.\n\n  <Tip>\n  Give context about what's already been done in other sessions if relevant. The agent doesn't have access to conversations from other sessions.\n  </Tip>\n</Step>\n</Steps>\n\n## Switching Between Sessions\n\nTo switch to a different session:\n\n1. Click the session dropdown in the chat toolbar\n2. Select the session you want to switch to\n\nThe dropdown shows:\n- **Session name** - Based on the initial task or auto-generated\n- **Latest** indicator - Shows which session was most recently active\n\n<Note>\nSwitching sessions doesn't interrupt any running agent processes. Each session's agent continues working independently.\n</Note>\n\n## Session States\n\nSessions can be in different states:\n\n| State | Description |\n|-------|-------------|\n| **Running** | Agent is actively processing and making changes |\n| **Idle** | Waiting for your input |\n| **Needs Attention** | Agent is waiting for approval or has a question |\n\nThe workspace sidebar shows the overall workspace state based on its sessions.\n\n## Managing Sessions\n\n### Renaming Sessions\n\nSessions are automatically named based on the initial task description. Currently, session names cannot be manually changed.\n\n### Viewing Session History\n\nEach session maintains its complete conversation history. Scroll up in the conversation panel to view earlier messages and agent actions.\n\n### Stopping an Agent\n\nIf an agent is running in the current session:\n\n1. Click the **Stop** button in the navbar, or\n2. Use the keyboard shortcut to stop execution\n\n<Warning>\nStopping an agent may leave changes in an incomplete state. Review the changes panel to see what was modified.\n</Warning>\n\n## Multiple Agents in Sessions\n\nDifferent sessions can use different agents:\n\n1. Create a new session\n2. Select a different agent from the **Agent** dropdown before sending your message\n3. Each session remembers which agent it's using\n\n<Tip>\nUse specialised agents for different tasks - for example, one agent for backend work and another for frontend changes.\n</Tip>\n\n## Session Best Practices\n\n### When to Create New Sessions\n\n- **Task complexity** - Break complex tasks into smaller sessions\n- **Token limits** - Start fresh when conversations get long\n- **Different approaches** - Try alternative solutions without losing progress\n- **Parallel work** - Have agents work on independent parts simultaneously\n\n### Keeping Sessions Organised\n\n- **One focus per session** - Keep each session focused on a specific goal\n- **Use workspace notes** - Document which session is for what purpose\n- **Review before switching** - Check the changes panel before switching sessions\n\n## Resolving Conflicts Between Sessions\n\nWhen multiple sessions make changes to the same files:\n\n1. The changes panel shows all modifications across sessions\n2. Review changes carefully before committing\n3. Use the inline comment feature to mark areas needing attention\n\n<Note>\nGit handles most conflicts automatically. For complex conflicts, see [Git Operations](/workspaces/git-operations).\n</Note>\n\n## Related Documentation\n\n- [Creating Workspaces](/workspaces/creating-workspaces) - Setting up new workspaces\n- [Interface Guide](/workspaces/interface) - Understanding the workspace layout\n- [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working with multiple repositories\n- [Command Bar](/workspaces/command-bar) - Quick actions and keyboard shortcuts\n"
  },
  {
    "path": "docs/workspaces/slash-commands.mdx",
    "content": "---\ntitle: \"Slash Commands\"\ndescription: \"Use slash commands to run agent commands directly from the chat input\"\n---\n\n<Frame>\n<img src=\"/images/workspaces-slash-commands.png\" alt=\"Slash command typeahead showing available commands when typing a forward slash\" />\n</Frame>\n\nSlash commands are shortcuts for common actions within your coding agent. Type `/` followed by a command name to run operations like compacting conversation history, initializing project documentation, or reviewing code changes.\n\n## Using Slash Commands\n\nTo use a slash command:\n\n1. Type `/` at the beginning of a new line in the chat input\n2. A typeahead menu appears showing available commands\n3. Continue typing to filter the list, or use arrow keys to navigate\n4. Press **Enter** or click to select a command\n5. Add any arguments after the command name (some commands accept optional parameters)\n6. Send the message as normal\n\n<Tip>\nSome commands accept optional arguments. For example, with Claude Code or Codex, `/compact focus on the authentication changes` will compact the conversation while emphasising the authentication work in the summary.\n</Tip>\n\n## Available Commands by Agent\n\nThe available slash commands depend on which coding agent you're using. Vibe Kanban discovers and exposes the same commands you'd have in the agent's native CLI, so the experience matches what you're used to.\n\n<Note>\nSome built-in commands that require TUI interaction (such as `/model` or `/theme`) are not currently supported in Vibe Kanban. Only non-interactive commands are available.\n</Note>\n\n### Claude Code\n\nClaude Code provides built-in commands, user-defined commands, and skills. For full details, see the [built-in commands](https://code.claude.com/docs/en/interactive-mode#built-in-commands) and [skills](https://code.claude.com/docs/en/skills) documentation.\n\n| Command | Description |\n|---------|-------------|\n| `/compact` | Clear conversation history but keep a summary in context |\n| `/review` | Review a pull request |\n| `/security-review` | Complete a security review of pending changes |\n| `/init` | Initialize a new CLAUDE.md file with codebase documentation |\n| `/pr-comments` | Get comments from a GitHub pull request |\n| `/context` | Visualize current context usage |\n| `/cost` | Show the total cost and duration of the current session |\n| `/release-notes` | View release notes |\n\nAny custom commands or skills you've installed will also appear in the typeahead.\n\n### OpenAI Codex\n\nCodex provides built-in commands. For full details, see the [Codex slash commands documentation](https://developers.openai.com/codex/cli/slash-commands/).\n\n| Command | Description |\n|---------|-------------|\n| `/compact` | Summarize the conversation to free tokens |\n| `/init` | Generate an AGENTS.md scaffold in the current directory |\n| `/status` | Display session configuration and token usage |\n| `/mcp` | List configured MCP tools |\n\n### OpenCode\n\nOpenCode provides built-in commands and custom commands. For full details, see the [built-in commands](https://opencode.ai/docs/tui/#commands) and [custom commands](https://opencode.ai/docs/commands) documentation.\n\n| Command | Description |\n|---------|-------------|\n| `/compact` | Compact the session |\n| `/commands` | Show all available commands |\n| `/models` | List available models |\n| `/agents` | List available agents |\n| `/status` | Show status information |\n| `/mcp` | Show MCP server status |\n\n## Command Discovery\n\nWhen you select an agent, Vibe Kanban automatically discovers the available slash commands. You'll see \"Discovering commands...\" in the typeahead while this happens.\n\nCommands are cached per workspace and agent, so subsequent uses are instant.\n\n<Info>\nIf you add new custom commands to your agent's configuration, switch to a different agent and back, or create a new session to refresh the command list.\n</Info>\n\n## Related Documentation\n\n- [Chat Interface](/workspaces/chat-interface) - Complete guide to the conversation panel\n- [Sessions](/workspaces/sessions) - Managing conversation sessions\n- [Supported Coding Agents](/supported-coding-agents) - Agent setup and configuration\n"
  },
  {
    "path": "local-build.sh",
    "content": "#!/bin/bash\n\nset -e  # Exit on any error\n\n# Detect OS and architecture\nOS=$(uname -s | tr '[:upper:]' '[:lower:]')\nARCH=$(uname -m)\n\n# Map architecture names\ncase \"$ARCH\" in\n  x86_64)\n    ARCH=\"x64\"\n    ;;\n  arm64|aarch64)\n    ARCH=\"arm64\"\n    ;;\n  *)\n    echo \"⚠️  Warning: Unknown architecture $ARCH, using as-is\"\n    ;;\nesac\n\n# Map OS names\ncase \"$OS\" in\n  linux)\n    OS=\"linux\"\n    ;;\n  darwin)\n    OS=\"macos\"\n    ;;\n  *)\n    echo \"⚠️  Warning: Unknown OS $OS, using as-is\"\n    ;;\nesac\n\nPLATFORM=\"${OS}-${ARCH}\"\n\n# Set CARGO_TARGET_DIR if not defined\nif [ -z \"$CARGO_TARGET_DIR\" ]; then\n  CARGO_TARGET_DIR=\"target\"\nfi\n\necho \"🔍 Detected platform: $PLATFORM\"\necho \"🔧 Using target directory: $CARGO_TARGET_DIR\"\n\n# Set API base URL for remote features\nexport VK_SHARED_API_BASE=\"https://api.vibekanban.com\"\nexport VITE_VK_SHARED_API_BASE=\"https://api.vibekanban.com\"\n\necho \"🧹 Cleaning previous builds...\"\nrm -rf npx-cli/dist\nmkdir -p npx-cli/dist/$PLATFORM\n\necho \"🔨 Building web app...\"\n(cd packages/local-web && npm run build)\n\necho \"🔨 Building Rust binaries...\"\ncargo build --release --manifest-path Cargo.toml\ncargo build --release --bin vibe-kanban-mcp --manifest-path Cargo.toml\n\necho \"📦 Creating distribution package...\"\n\n# Copy the main binary\ncp ${CARGO_TARGET_DIR}/release/server vibe-kanban\nzip -q vibe-kanban.zip vibe-kanban\nrm -f vibe-kanban \nmv vibe-kanban.zip npx-cli/dist/$PLATFORM/vibe-kanban.zip\n\n# Copy the MCP binary\ncp ${CARGO_TARGET_DIR}/release/vibe-kanban-mcp vibe-kanban-mcp\nzip -q vibe-kanban-mcp.zip vibe-kanban-mcp\nrm -f vibe-kanban-mcp\nmv vibe-kanban-mcp.zip npx-cli/dist/$PLATFORM/vibe-kanban-mcp.zip\n\n# Copy the Review CLI binary\ncp ${CARGO_TARGET_DIR}/release/review vibe-kanban-review\nzip -q vibe-kanban-review.zip vibe-kanban-review\nrm -f vibe-kanban-review\nmv vibe-kanban-review.zip npx-cli/dist/$PLATFORM/vibe-kanban-review.zip\n\necho \"✅ CLI build complete!\"\necho \"📁 Files created:\"\necho \"   - npx-cli/dist/$PLATFORM/vibe-kanban.zip\"\necho \"   - npx-cli/dist/$PLATFORM/vibe-kanban-mcp.zip\"\necho \"   - npx-cli/dist/$PLATFORM/vibe-kanban-review.zip\"\n\n# Optionally build the Tauri desktop app\nif [[ \"$1\" == \"--desktop\" || \"$1\" == \"--all\" ]]; then\n  # Map to Tauri platform naming\n  case \"$OS\" in\n    macos) TAURI_OS=\"darwin\" ;;\n    linux) TAURI_OS=\"linux\" ;;\n    *) TAURI_OS=\"$OS\" ;;\n  esac\n  case \"$ARCH\" in\n    arm64) TAURI_ARCH=\"aarch64\" ;;\n    x64) TAURI_ARCH=\"x86_64\" ;;\n    *) TAURI_ARCH=\"$ARCH\" ;;\n  esac\n  TAURI_PLATFORM=\"${TAURI_OS}-${TAURI_ARCH}\"\n\n  echo \"\"\n  echo \"🖥️  Building Tauri desktop app for $TAURI_PLATFORM...\"\n\n  # Replace the updater endpoint placeholder with a dummy URL for local builds\n  # (CI injects the real R2 URL; locally the updater is non-functional)\n  TAURI_CONF=\"crates/tauri-app/tauri.conf.json\"\n  node -e \"\n    const fs = require('fs');\n    const conf = JSON.parse(fs.readFileSync('$TAURI_CONF', 'utf8'));\n    conf.plugins.updater.endpoints = conf.plugins.updater.endpoints.map(e =>\n      e === '__TAURI_UPDATE_ENDPOINT__' ? 'https://localhost/disabled' : e\n    );\n    fs.writeFileSync('$TAURI_CONF', JSON.stringify(conf, null, 2) + '\\n');\n  \"\n\n  cargo tauri build\n\n  # Restore tauri.conf.json\n  git checkout -- \"$TAURI_CONF\"\n\n  TAURI_DIST=\"npx-cli/dist/tauri/$TAURI_PLATFORM\"\n  mkdir -p \"$TAURI_DIST\"\n\n  BUNDLE_DIR=\"${CARGO_TARGET_DIR}/release/bundle\"\n  # Copy updater artifacts (tar.gz bundles or NSIS exe)\n  find \"$BUNDLE_DIR\" -name \"*.app.tar.gz\" ! -name \"*.sig\" -exec cp {} \"$TAURI_DIST/\" \\; 2>/dev/null || true\n  find \"$BUNDLE_DIR\" -name \"*.AppImage.tar.gz\" ! -name \"*.sig\" -exec cp {} \"$TAURI_DIST/\" \\; 2>/dev/null || true\n  find \"$BUNDLE_DIR\" -name \"*-setup.exe\" -exec cp {} \"$TAURI_DIST/\" \\; 2>/dev/null || true\n\n  echo \"✅ Desktop app built:\"\n  ls -la \"$TAURI_DIST/\"\nfi\n\necho \"\"\necho \"📦 Installing npx-cli dependencies...\"\n(cd npx-cli && npm ci)\n\necho \"\"\necho \"🔨 Building npx-cli TypeScript...\"\n(cd npx-cli && npm run build)\n\necho \"\"\necho \"🚀 To test locally, run:\"\necho \"   cd npx-cli && node bin/cli.js                # browser mode (default)\"\necho \"   cd npx-cli && node bin/cli.js --desktop       # desktop mode (requires --desktop or --all build flag)\"\n"
  },
  {
    "path": "mobile-testing.md",
    "content": "# Testing on Mobile Devices\n\nThis guide explains how to access the remote-web frontend from a phone (iPhone/Android) for UI testing. It uses [Tailscale](https://tailscale.com) for stable networking and HTTPS certificates, and [Caddy](https://caddyserver.com) as a reverse proxy — no custom IPs, no random URLs, works on any network.\n\n**Time to set up**: ~15 minutes (one-time). After that, it's two commands in two terminals.\n\n---\n\n## Prerequisites\n\n### 1. Install Tailscale on your Mac\n\nDownload the standalone app from https://tailscale.com/download/mac (recommended). Alternatively, install from the [Mac App Store](https://apps.apple.com/app/tailscale/id1470499037).\n\nAfter installing:\n\n1. Open the Tailscale app\n2. Click the Tailscale icon in your menu bar (top-right of screen)\n3. Click **Log in** — this opens a browser window to sign in\n4. Once signed in, the icon turns active — you're connected\n\n> If you already have Tailscale installed, skip this step.\n\n### 2. Install Tailscale on your phone\n\n- **iPhone**: [App Store — Tailscale](https://apps.apple.com/app/tailscale/id1470499037)\n- **Android**: [Play Store — Tailscale](https://play.google.com/store/apps/details?id=com.tailscale.ipn)\n\nSign in with the **same account** you used on your Mac.\n\n### 3. Install Caddy on your Mac\n\n```bash\nbrew install caddy\n```\n\n### 4. Verify both devices are connected\n\nClick the Tailscale icon in your Mac menu bar — you should see your Mac listed as connected. You can also verify from the terminal:\n\n```bash\ntailscale status\n```\n\nBoth your Mac and phone should appear:\n\n```\n100.x.x.x   johns-macbook     user@   macOS   -\n100.x.x.x   iphone-john      user@   iOS     -\n```\n\n> If your phone shows \"offline\", open the Tailscale app on your phone and make sure the toggle is ON.\n\n### 5. Enable MagicDNS and HTTPS Certificates\n\n1. Open https://login.tailscale.com/admin/dns\n2. Scroll to the **Nameservers** section — make sure **MagicDNS** is enabled. If you see a \"Disable MagicDNS...\" button, it's already enabled.\n3. Scroll to the bottom of the page to the **\"HTTPS Certificates\"** section\n4. Click **\"Enable HTTPS\"** if it's not already enabled. If you see a \"Disable HTTPS...\" button, it's already enabled.\n\n> Enabling HTTPS means your machine names and tailnet DNS name will appear on a public certificate ledger. This is how Let's Encrypt works and is normal.\n\n---\n\n## One-Time Setup\n\nAll commands below auto-detect your Tailscale hostname — no manual copy-pasting needed.\n\n### Step 1 — Save your hostname to your shell profile\n\nRun the command for your shell:\n\n**zsh** (default on macOS):\n```bash\necho \"export TS_HOSTNAME=$(tailscale status --json | python3 -c \"import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))\")\" >> ~/.zshrc\nsource ~/.zshrc\n```\n\n**bash**:\n```bash\necho \"export TS_HOSTNAME=$(tailscale status --json | python3 -c \"import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))\")\" >> ~/.bashrc\nsource ~/.bashrc\n```\n\n**fish**:\n```bash\nset -Ux TS_HOSTNAME (tailscale status --json | python3 -c \"import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))\") \n```\n\nVerify it worked:\n```bash\necho \"Your hostname: $TS_HOSTNAME\"\n```\n\nVerify it resolves:\n\n```bash\nping -c 1 $TS_HOSTNAME\n```\n\n### Step 2 — Generate HTTPS certificates\n\n```bash\ntailscale cert $TS_HOSTNAME\n```\n\nThis creates `$TS_HOSTNAME.crt` and `$TS_HOSTNAME.key` in the current directory. These are real Let's Encrypt certificates — trusted by all browsers and devices, no extra installation needed on your phone.\n\n> Certs expire after 90 days. Re-run `tailscale cert $TS_HOSTNAME` to renew.\n\n### Step 3 — Create the Caddyfile\n\n```bash\ncat > Caddyfile << EOF\n${TS_HOSTNAME}:3001 {\n    tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key\n    reverse_proxy 127.0.0.1:3000\n}\n\n${TS_HOSTNAME}:8443 {\n    tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key\n    reverse_proxy 127.0.0.1:8082\n}\nEOF\n```\n\n**What this does:**\n- `https://$TS_HOSTNAME:3001` → proxies to the remote server on localhost:3000\n- `https://$TS_HOSTNAME:8443` → proxies to the relay server on localhost:8082\n\n> We use separate ports (3001 for the app, 8443 for the relay) to avoid conflicts with other services on your Tailscale hostname.\n\n### Step 4 — Create a GitHub OAuth app\n\nEach developer needs their own GitHub OAuth app so they can sign in from their phone. The app only needs `read:user` and `user:email` scopes — no special permissions required.\n\n1. Go to https://github.com/settings/applications/new\n2. Fill in the form:\n   - **Application name**: anything (e.g. `vibe-kanban-mobile-yourname`)\n   - **Homepage URL**: run `echo \"https://$TS_HOSTNAME:3001\"` and paste the output\n   - **Authorization callback URL**: run `echo \"https://$TS_HOSTNAME:3001/v1/oauth/github/callback\"` and paste the output\n3. Click **Register application**\n4. Copy the **Client ID** shown on the next page\n5. Click **Generate a new client secret** and copy it immediately (it won't be shown again)\n6. Add both values to `crates/remote/.env.remote`:\n   ```bash\n   # Replace with your own values\n   GITHUB_OAUTH_CLIENT_ID=your_client_id\n   GITHUB_OAUTH_CLIENT_SECRET=your_client_secret\n   ```\n\n> `.env.remote` is already in `.gitignore` — your credentials stay local. If the file already has these variables from the shared dev setup, replace them with your own.\n\n## Running\n\nThere are two modes: **Docker mode** (simple, no hot reload) and **Dev mode** (Vite hot reload for frontend changes). Pick whichever fits your workflow.\n\n---\n\n### Option A — Docker Mode (Simple)\n\nThe frontend is built inside Docker. No hot reload — you need to restart Docker to see frontend changes. Good for testing backend changes or doing final QA on your phone.\n\n**Two terminals:**\n\n```bash\n# Terminal 1 — Docker stack\nVITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \\\nPUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \\\npnpm remote:dev\n\n# Terminal 2 — Caddy\ncaddy run --config Caddyfile\n```\n\n> The first time you run with these env vars, Docker rebuilds the frontend with the Tailscale URLs baked in. This takes a few minutes. Subsequent runs with the same URLs are cached.\n\n---\n\n### Option B — Dev Mode (Vite Hot Reload)\n\nThe frontend runs outside Docker via Vite, so you get instant hot reload when editing React components. Caddy routes API requests to Docker and everything else to Vite.\n\n**Step 1 — Generate `Caddyfile.dev`:**\n\nThis file can't use shell variables directly, so generate it once (re-run if your hostname changes):\n\n```bash\ncat > Caddyfile.dev << EOF\n${TS_HOSTNAME}:3001 {\n    tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key\n    handle /api/* {\n        reverse_proxy 127.0.0.1:3000\n    }\n    handle /v1/* {\n        reverse_proxy 127.0.0.1:3000\n    }\n    handle /shape/* {\n        reverse_proxy 127.0.0.1:3000\n    }\n    handle {\n        reverse_proxy localhost:3002 {\n            header_up Host localhost:3002\n        }\n    }\n}\n\n${TS_HOSTNAME}:8443 {\n    tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key\n    reverse_proxy 127.0.0.1:8082\n}\nEOF\n```\n\n**What this routes:**\n- `/api/*`, `/v1/*`, `/shape/*` → Docker remote server (`:3000`)\n- Everything else → Vite dev server (`:3002`) with hot reload\n- `:8443` → Relay server (`:8082`)\n\n**Step 2 — Run four terminals:**\n\n```bash\n# Terminal 1 — Docker backends (no frontend build needed)\nPUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \\\npnpm remote:dev\n\n# Terminal 2 — Vite dev server (hot reload)\nVITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \\\npnpm --filter @vibe/remote-web dev\n\n# Terminal 3 — Caddy (dev config)\ncaddy run --config Caddyfile.dev\n\n# Terminal 4 (optional) — Local desktop client\nVK_SHARED_API_BASE=https://$TS_HOSTNAME:3001 \\\nVK_SHARED_RELAY_API_BASE=https://$TS_HOSTNAME:8443 \\\npnpm run dev\n```\n\n> Vite binds to `localhost:3002`. The `Caddyfile.dev` uses `localhost` (not `127.0.0.1`) to match — this avoids IPv6/IPv4 mismatch issues on macOS.\n\n---\n\n### Accessing from your phone\n\n1. Open the Tailscale app and make sure it's connected (toggle ON)\n2. Open Safari (or Chrome) and go to: `https://<your-hostname>:3001` (run `echo \"https://$TS_HOSTNAME:3001\"` if you forgot it)\n3. Sign in with GitHub\n4. You're in\n\nTo go back to regular localhost development, just run `pnpm remote:dev` without env vars — no cleanup needed.\n\n---\n\n## Quick Reference\n\n**Docker mode (2 terminals):**\n```bash\n# Terminal 1\nVITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \\\nPUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \\\npnpm remote:dev\n\n# Terminal 2\ncaddy run --config Caddyfile\n\n# On phone\necho \"https://$TS_HOSTNAME:3001\"\n```\n\n**Dev mode (4 terminals):**\n```bash\n# Terminal 1 — Docker backends\nPUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \\\npnpm remote:dev\n\n# Terminal 2 — Vite\nVITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \\\npnpm --filter @vibe/remote-web dev\n\n# Terminal 3 — Caddy\ncaddy run --config Caddyfile.dev\n\n# Terminal 4 (optional) — Desktop client\nVK_SHARED_API_BASE=https://$TS_HOSTNAME:3001 \\\nVK_SHARED_RELAY_API_BASE=https://$TS_HOSTNAME:8443 \\\npnpm run dev\n\n# On phone\necho \"https://$TS_HOSTNAME:3001\"\n```\n\n---\n\n## Troubleshooting\n\n| Problem | Solution |\n|---|---|\n| `$TS_HOSTNAME` is empty | Re-run: `source ~/.zshrc` or restart your terminal |\n| Phone can't reach the URL | Open Tailscale app on phone → make sure toggle is ON. Run `tailscale status` on Mac to verify both devices are connected |\n| Phone shows certificate warning | Re-run `tailscale cert $TS_HOSTNAME` — certs may have expired (90-day lifetime) |\n| `tailscale cert` fails with \"does not support getting TLS certs\" | Enable HTTPS certificates in Tailscale admin: https://login.tailscale.com/admin/dns → scroll to \"HTTPS Certificates\" at the bottom → click \"Enable HTTPS\" |\n| `tailscale cert` fails with \"invalid domain\" | Make sure `$TS_HOSTNAME` includes the tailnet name (e.g. `johns-macbook.tail99xyz.ts.net`). Re-run Step 1 |\n| OAuth redirect fails on phone | Run `echo \"https://$TS_HOSTNAME:3001/v1/oauth/github/callback\"` and verify it matches what's in GitHub settings |\n| First build is very slow | Normal — Docker rebuilds the frontend with the new `VITE_RELAY_API_BASE_URL`. Subsequent builds are cached |\n| Relay features (terminal, logs) don't work on phone | Check that `VITE_RELAY_API_BASE_URL` in the command matches your Caddy relay block (`https://$TS_HOSTNAME:8443`) |\n| Caddy asks for password | Normal on first run — it installs a local CA certificate. Enter your macOS password |\n| `caddy run` fails with \"address already in use\" | Another Caddy instance is running. Kill it: `pkill caddy`, then retry |\n| `ping $TS_HOSTNAME` doesn't resolve | Enable MagicDNS in Tailscale admin: https://login.tailscale.com/admin/dns |\n| Dev mode: Vite page loads but API calls fail | Make sure Docker is running (`pnpm remote:dev`) and you're using `Caddyfile.dev` (not `Caddyfile`) |\n| Dev mode: hot reload doesn't work on phone | Vite HMR uses WebSocket — verify Caddy is proxying to `localhost:3002` (not `127.0.0.1:3002`). Regenerate `Caddyfile.dev` if needed |\n| Dev mode: blank page or 502 on phone | Vite dev server may not be running. Check Terminal 2 is up with `pnpm --filter @vibe/remote-web dev` |\n"
  },
  {
    "path": "npx-cli/README.md",
    "content": "# Vibe Kanban\n\n> A visual project management tool for developers that integrates with git repositories and coding agents like Claude Code and Amp.\n\n## Quick Start\n\nRun vibe kanban instantly without installation:\n\n```bash\nnpx vibe-kanban\n```\n\nThis will launch the application locally and open it in your browser automatically.\n\nHelpful entrypoints:\n\n```bash\nnpx vibe-kanban --help\nnpx vibe-kanban --version\nnpx vibe-kanban review --help\nnpx vibe-kanban mcp --help\n```\n\n## What is Vibe Kanban?\n\nVibe Kanban is a modern project management tool designed specifically for developers. It helps you organize your coding projects with kanban-style task management while providing powerful integrations with git repositories and AI coding agents.\n\n### ✨ Key Features\n\n**🗂️ Project Management**\n\n- Add git repositories as projects (existing or create new ones)\n- Automatic git integration and repository validation\n- Project search functionality across all files\n- Custom setup and development scripts per project\n\n**📋 Task Management**\n\n- Create and manage tasks with kanban-style boards\n- Task status tracking (Todo, In Progress, Done)\n- Rich task descriptions and notes\n- Task execution with multiple AI agents\n\n**🤖 AI Agent Integration**\n\n- **Claude**: Advanced AI coding assistant\n- **Amp**: Powerful development agent\n- **Echo**: Simple testing/debugging agent\n- Create tasks and immediately start agent execution\n- Follow-up task execution for iterative development\n\n**⚡ Development Workflow**\n\n- Create isolated git worktrees for each task attempt\n- View diffs of changes made by agents\n- Merge successful changes back to main branch\n- Rebase task branches to stay up-to-date\n- Manual file editing and deletion\n- Integrated development server support\n\n**🎛️ Developer Tools**\n\n- Browse and validate git repositories from filesystem\n- Open task worktrees in your preferred editor (VS Code, Cursor, Windsurf, IntelliJ, Zed)\n- Real-time execution monitoring and process control\n- Stop running processes individually or all at once\n- Sound notifications for task completion\n\n## How It Works\n\n1. **Add Projects**: Import existing git repositories or create new ones\n2. **Create Tasks**: Define what needs to be built or fixed\n3. **Execute with AI**: Let coding agents work on your tasks in isolated environments\n4. **Review Changes**: See exactly what was modified using git diffs\n5. **Merge Results**: Incorporate successful changes into your main codebase\n\n## Core Functionality\n\nVibe Kanban provides a complete project management experience with these key capabilities:\n\n**Project Repository Management**\n\n- Full CRUD operations for managing coding projects\n- Automatic git repository detection and validation\n- Initialize new repositories or import existing ones\n- Project-wide file search functionality\n\n**Task Lifecycle Management**\n\n- Create, update, and delete tasks with rich descriptions\n- Track task progress through customizable status workflows\n- One-click task creation with immediate AI agent execution\n- Task attempt tracking with detailed execution history\n\n**AI Agent Execution Environment**\n\n- Isolated git worktrees for safe code experimentation\n- Real-time execution monitoring and activity logging\n- Process management with ability to stop individual or all processes\n- Support for follow-up executions to iterate on solutions\n\n**Code Change Management**\n\n- View detailed diffs of all changes made during task execution\n- Branch status monitoring to track divergence from main\n- One-click merging of successful changes back to main branch\n- Automatic rebasing to keep task branches up-to-date\n- Manual file deletion and cleanup capabilities\n\n**Development Integration**\n\n- Open task worktrees directly in your preferred code editor\n- Start and manage development servers for testing changes\n- Browse local filesystem to add new projects\n- Health monitoring for service availability\n\n## Configuration\n\nVibe Kanban supports customization through its configuration system:\n\n- **Editor Integration**: Choose your preferred code editor\n- **Sound Notifications**: Customize completion sounds\n- **Project Defaults**: Set default setup and development scripts\n\n## Technical Architecture\n\n- **Backend**: Rust with Axum web framework\n- **Frontend**: React with TypeScript\n- **Database**: SQLite for local data storage\n- **Git Integration**: Native git operations for repository management\n- **Process Management**: Tokio-based async execution monitoring\n\n## Requirements\n\n- Node.js (for npx execution)\n- Git (for repository operations)\n- Your preferred code editor (optional, for opening task worktrees)\n\n## Supported Platforms\n\n- Linux x64\n- Windows x64\n- macOS x64 (Intel)\n- macOS ARM64 (Apple Silicon)\n\n## Use Cases\n\n**🔧 Bug Fixes**\n\n- Create a task describing the bug\n- Let an AI agent analyze and fix the issue\n- Review the proposed changes\n- Merge if satisfied, or provide follow-up instructions\n\n**✨ Feature Development**\n\n- Break down features into manageable tasks\n- Use agents for initial implementation\n- Iterate with follow-up executions\n- Test using integrated development servers\n\n**🚀 Project Setup**\n\n- Bootstrap new projects with AI assistance\n- Set up development environments\n- Configure build and deployment scripts\n\n**📚 Code Documentation**\n\n- Generate documentation for existing code\n- Create README files and API documentation\n- Maintain up-to-date project information\n\n---\n\n**Ready to supercharge your development workflow?**\n\n```bash\nnpx vibe-kanban\n```\n\n_Start managing your projects with the power of AI coding agents today!_\n"
  },
  {
    "path": "npx-cli/package.json",
    "content": "{\n  \"name\": \"vibe-kanban\",\n  \"private\": false,\n  \"version\": \"0.1.33\",\n  \"main\": \"index.js\",\n  \"bin\": {\n    \"vibe-kanban\": \"bin/cli.js\"\n  },\n  \"scripts\": {\n    \"build\": \"esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=bin/cli.js --external:adm-zip --banner:js=\\\"#!/usr/bin/env node\\\"\",\n    \"check\": \"tsc --noEmit -p tsconfig.json\"\n  },\n  \"keywords\": [],\n  \"author\": \"bloop\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/BloopAI/vibe-kanban\"\n  },\n  \"engines\": {\n    \"node\": \">=20.19.0\"\n  },\n  \"license\": \"\",\n  \"description\": \"NPX wrapper around vibe-kanban and vibe-kanban-mcp\",\n  \"devDependencies\": {\n    \"esbuild\": \"^0.27.2\"\n  },\n  \"dependencies\": {\n    \"adm-zip\": \"^0.5.16\",\n    \"cac\": \"^7.0.0\"\n  },\n  \"files\": [\n    \"bin\",\n    \"dist\"\n  ]\n}\n"
  },
  {
    "path": "npx-cli/src/cli.ts",
    "content": "import { execSync, spawn } from \"child_process\";\nimport path from \"path\";\nimport fs from \"fs\";\nimport { cac } from \"cac\";\nimport {\n  ensureBinary,\n  ensureDesktopBundle,\n  BINARY_TAG,\n  CACHE_DIR,\n  DESKTOP_CACHE_DIR,\n  LOCAL_DEV_MODE,\n  LOCAL_DIST_DIR,\n  R2_BASE_URL,\n  getLatestVersion,\n} from \"./download\";\nimport {\n  getTauriPlatform,\n  installAndLaunch,\n  cleanOldDesktopVersions,\n} from \"./desktop\";\n\nconst CLI_VERSION: string = require(\"../package.json\").version;\n\ntype RootOptions = {\n  desktop?: boolean;\n};\n\n// Resolve effective arch for our published 64-bit binaries only.\n// Any ARM → arm64; anything else → x64. On macOS, handle Rosetta.\nfunction getEffectiveArch(): \"arm64\" | \"x64\" {\n  const platform = process.platform;\n  const nodeArch = process.arch;\n\n  if (platform === \"darwin\") {\n    // If Node itself is arm64, we're natively on Apple silicon\n    if (nodeArch === \"arm64\") return \"arm64\";\n\n    // Otherwise check for Rosetta translation\n    try {\n      const translated = execSync(\"sysctl -in sysctl.proc_translated\", {\n        encoding: \"utf8\",\n      }).trim();\n      if (translated === \"1\") return \"arm64\";\n    } catch {\n      // sysctl key not present → assume true Intel\n    }\n    return \"x64\";\n  }\n\n  // Non-macOS: coerce to broad families we support\n  if (/arm/i.test(nodeArch)) return \"arm64\";\n\n  // On Windows with 32-bit Node (ia32), detect OS arch via env\n  if (platform === \"win32\") {\n    const pa = process.env.PROCESSOR_ARCHITECTURE || \"\";\n    const paw = process.env.PROCESSOR_ARCHITEW6432 || \"\";\n    if (/arm/i.test(pa) || /arm/i.test(paw)) return \"arm64\";\n  }\n\n  return \"x64\";\n}\n\nconst platform = process.platform;\nconst arch = getEffectiveArch();\n\n// Map to our build target names\nfunction getPlatformDir(): string {\n  if (platform === \"linux\" && arch === \"x64\") return \"linux-x64\";\n  if (platform === \"linux\" && arch === \"arm64\") return \"linux-arm64\";\n  if (platform === \"win32\" && arch === \"x64\") return \"windows-x64\";\n  if (platform === \"win32\" && arch === \"arm64\") return \"windows-arm64\";\n  if (platform === \"darwin\" && arch === \"x64\") return \"macos-x64\";\n  if (platform === \"darwin\" && arch === \"arm64\") return \"macos-arm64\";\n\n  console.error(`Unsupported platform: ${platform}-${arch}`);\n  console.error(\"Supported platforms:\");\n  console.error(\"  - Linux x64\");\n  console.error(\"  - Linux ARM64\");\n  console.error(\"  - Windows x64\");\n  console.error(\"  - Windows ARM64\");\n  console.error(\"  - macOS x64 (Intel)\");\n  console.error(\"  - macOS ARM64 (Apple Silicon)\");\n  process.exit(1);\n}\n\nfunction getBinaryName(base: string): string {\n  return platform === \"win32\" ? `${base}.exe` : base;\n}\n\nconst platformDir = getPlatformDir();\n// In local dev mode, extract directly to dist directory; otherwise use global cache\nconst versionCacheDir = LOCAL_DEV_MODE\n  ? path.join(LOCAL_DIST_DIR, platformDir)\n  : path.join(CACHE_DIR, BINARY_TAG, platformDir);\n\n// Remove old version directories from the binary cache\nfunction cleanOldVersions(): void {\n  try {\n    const entries = fs.readdirSync(CACHE_DIR, {\n      withFileTypes: true,\n    });\n    for (const entry of entries) {\n      if (entry.isDirectory() && entry.name !== BINARY_TAG) {\n        const oldDir = path.join(CACHE_DIR, entry.name);\n        fs.rmSync(oldDir, { recursive: true, force: true });\n      }\n    }\n  } catch {\n    // Ignore cleanup errors — not critical\n  }\n}\n\nfunction showProgress(downloaded: number, total: number): void {\n  const percent = total ? Math.round((downloaded / total) * 100) : 0;\n  const mb = (downloaded / (1024 * 1024)).toFixed(1);\n  const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : \"?\";\n  process.stderr.write(\n    `\\r   Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`,\n  );\n}\n\nfunction buildMcpArgs(args: string[]): string[] {\n  return args.length > 0 ? args : [\"--mode\", \"global\"];\n}\n\nasync function extractAndRun(\n  baseName: string,\n  launch: (binPath: string) => void,\n): Promise<void> {\n  const binName = getBinaryName(baseName);\n  const binPath = path.join(versionCacheDir, binName);\n  const zipPath = path.join(versionCacheDir, `${baseName}.zip`);\n\n  // Clean old binary if exists\n  try {\n    if (fs.existsSync(binPath)) {\n      fs.unlinkSync(binPath);\n    }\n  } catch (err: unknown) {\n    if (process.env.VIBE_KANBAN_DEBUG) {\n      const msg = err instanceof Error ? err.message : String(err);\n      console.warn(`Warning: Could not delete existing binary: ${msg}`);\n    }\n  }\n\n  // Download if not cached\n  if (!fs.existsSync(zipPath)) {\n    console.error(`Downloading ${baseName}...`);\n    try {\n      await ensureBinary(platformDir, baseName, showProgress);\n      console.error(\"\"); // newline after progress\n    } catch (err: unknown) {\n      const msg = err instanceof Error ? err.message : String(err);\n      console.error(`\\nDownload failed: ${msg}`);\n      process.exit(1);\n    }\n  }\n\n  // Extract\n  if (!fs.existsSync(binPath)) {\n    try {\n      const { default: AdmZip } = await import(\"adm-zip\");\n      const zip = new AdmZip(zipPath);\n      zip.extractAllTo(versionCacheDir, true);\n    } catch (err: unknown) {\n      const msg = err instanceof Error ? err.message : String(err);\n      console.error(\"Extraction failed:\", msg);\n      try {\n        fs.unlinkSync(zipPath);\n      } catch {}\n      process.exit(1);\n    }\n  }\n\n  if (!fs.existsSync(binPath)) {\n    console.error(`Extracted binary not found at: ${binPath}`);\n    console.error(\n      \"This usually indicates a corrupt download. Please try again.\",\n    );\n    process.exit(1);\n  }\n\n  // Clean up old cached versions only after current version is fully ready\n  if (!LOCAL_DEV_MODE) {\n    cleanOldVersions();\n  }\n\n  // Set permissions (non-Windows)\n  if (platform !== \"win32\") {\n    try {\n      fs.chmodSync(binPath, 0o755);\n    } catch {}\n  }\n\n  return launch(binPath);\n}\n\nfunction checkForUpdates(): void {\n  const hasValidR2Url = !R2_BASE_URL.startsWith(\"__\");\n  if (LOCAL_DEV_MODE || !hasValidR2Url) {\n    return;\n  }\n\n  getLatestVersion()\n    .then((latest) => {\n      if (latest && latest !== CLI_VERSION) {\n        setTimeout(() => {\n          console.log(`\\nUpdate available: ${CLI_VERSION} -> ${latest}`);\n          console.log(`Run: npx vibe-kanban@latest`);\n        }, 2000);\n      }\n    })\n    .catch(() => {});\n}\n\nasync function runMcp(args: string[]): Promise<void> {\n  await extractAndRun(\"vibe-kanban-mcp\", (bin) => {\n    const proc = spawn(bin, buildMcpArgs(args), {\n      stdio: \"inherit\",\n    });\n    proc.on(\"exit\", (c) => process.exit(c || 0));\n    proc.on(\"error\", (e) => {\n      console.error(\"MCP server error:\", e.message);\n      process.exit(1);\n    });\n    process.on(\"SIGINT\", () => {\n      proc.kill(\"SIGINT\");\n    });\n    process.on(\"SIGTERM\", () => proc.kill(\"SIGTERM\"));\n  });\n}\n\nasync function runReview(args: string[]): Promise<void> {\n  await extractAndRun(\"vibe-kanban-review\", (bin) => {\n    const proc = spawn(bin, args, { stdio: \"inherit\" });\n    proc.on(\"exit\", (c) => process.exit(c || 0));\n    proc.on(\"error\", (e) => {\n      console.error(\"Review CLI error:\", e.message);\n      process.exit(1);\n    });\n  });\n}\n\nasync function runMain(desktopMode: boolean): Promise<void> {\n  checkForUpdates();\n\n  const modeLabel = LOCAL_DEV_MODE ? \" (local dev)\" : \"\";\n  const tauriPlatform = getTauriPlatform(platformDir);\n\n  // Default: browser mode (headless server + opens browser).\n  // Use --desktop to launch the desktop app instead.\n  if (desktopMode && tauriPlatform) {\n    try {\n      console.log(\n        `Starting vibe-kanban desktop v${CLI_VERSION}${modeLabel}...`,\n      );\n      const bundleInfo = await ensureDesktopBundle(tauriPlatform, showProgress);\n      console.error(\"\"); // newline after progress\n\n      // Clean old desktop versions after successful download\n      if (!LOCAL_DEV_MODE) {\n        cleanOldDesktopVersions(DESKTOP_CACHE_DIR, BINARY_TAG);\n      }\n\n      const exitCode = await installAndLaunch(bundleInfo, platform);\n      process.exit(exitCode);\n    } catch (err: unknown) {\n      const msg = err instanceof Error ? err.message : String(err);\n      console.error(`Desktop app not available: ${msg}`);\n      console.error(\"Falling back to browser mode...\");\n    }\n  }\n\n  // Browser mode (default — headless server + opens browser)\n  console.log(`Starting vibe-kanban v${CLI_VERSION}${modeLabel}...`);\n  await extractAndRun(\"vibe-kanban\", (bin) => {\n    execSync(`\"${bin}\"`, { stdio: \"inherit\" });\n  });\n}\n\nfunction normalizeArgv(argv: string[]): string[] {\n  const args = argv.slice(2);\n  const mcpFlagIndex = args.indexOf(\"--mcp\");\n  if (mcpFlagIndex === -1) {\n    return argv;\n  }\n\n  const normalizedArgs = [\n    ...args.slice(0, mcpFlagIndex),\n    \"mcp\",\n    ...args.slice(mcpFlagIndex + 1),\n  ];\n\n  return [...argv.slice(0, 2), ...normalizedArgs];\n}\n\nfunction runOrExit(task: Promise<void>): void {\n  void task.catch((err: unknown) => {\n    const msg = err instanceof Error ? err.message : String(err);\n    console.error(\"Fatal error:\", msg);\n    if (process.env.VIBE_KANBAN_DEBUG && err instanceof Error) {\n      console.error(err.stack);\n    }\n    process.exit(1);\n  });\n}\n\nasync function main(): Promise<void> {\n  fs.mkdirSync(versionCacheDir, { recursive: true });\n  const cli = cac(\"vibe-kanban\");\n\n  cli\n    .command(\"[...args]\", \"Launch the local vibe-kanban app\")\n    .option(\"--desktop\", \"Launch the desktop app instead of browser mode\")\n    .allowUnknownOptions()\n    .action((_args: string[], options: RootOptions) => {\n      runOrExit(runMain(Boolean(options.desktop)));\n    });\n\n  cli\n    .command(\"review [...args]\", \"Run the review CLI\")\n    .allowUnknownOptions()\n    .action((args: string[]) => {\n      runOrExit(runReview(args));\n    });\n\n  cli\n    .command(\"mcp [...args]\", \"Run the MCP server\")\n    .allowUnknownOptions()\n    .action((args: string[]) => {\n      runOrExit(runMcp(args));\n    });\n\n  cli.help();\n  cli.version(CLI_VERSION);\n  cli.parse(normalizeArgv(process.argv));\n}\n\nmain().catch((err: unknown) => {\n  const msg = err instanceof Error ? err.message : String(err);\n  console.error(\"Fatal error:\", msg);\n  if (process.env.VIBE_KANBAN_DEBUG && err instanceof Error) {\n    console.error(err.stack);\n  }\n  process.exit(1);\n});\n"
  },
  {
    "path": "npx-cli/src/desktop.ts",
    "content": "import { execSync, spawn } from 'child_process';\nimport path from 'path';\nimport fs from 'fs';\nimport os from 'os';\nimport type { DesktopBundleInfo } from './download';\n\ntype TauriPlatform = string | null;\n\ninterface SentinelMeta {\n  type: string;\n  appPath: string;\n}\n\nconst PLATFORM_MAP: Record<string, string> = {\n  'macos-arm64': 'darwin-aarch64',\n  'macos-x64': 'darwin-x86_64',\n  'linux-x64': 'linux-x86_64',\n  'linux-arm64': 'linux-aarch64',\n  'windows-x64': 'windows-x86_64',\n  'windows-arm64': 'windows-aarch64',\n};\n\n// Map NPX-style platform names to Tauri-style platform names\nexport function getTauriPlatform(\n  npxPlatformDir: string\n): TauriPlatform {\n  return PLATFORM_MAP[npxPlatformDir] || null;\n}\n\n// Extract .tar.gz using system tar (available on macOS, Linux, and Windows 10+)\nfunction extractTarGz(archivePath: string, destDir: string): void {\n  execSync(`tar -xzf \"${archivePath}\" -C \"${destDir}\"`, {\n    stdio: 'pipe',\n  });\n}\n\nfunction writeSentinel(dir: string, meta: SentinelMeta): void {\n  fs.writeFileSync(\n    path.join(dir, '.installed'),\n    JSON.stringify(meta)\n  );\n}\n\nfunction readSentinel(dir: string): SentinelMeta | null {\n  const sentinelPath = path.join(dir, '.installed');\n  if (!fs.existsSync(sentinelPath)) return null;\n  try {\n    return JSON.parse(\n      fs.readFileSync(sentinelPath, 'utf-8')\n    ) as SentinelMeta;\n  } catch {\n    return null;\n  }\n}\n\n// Try to copy the .app to a destination directory, returning the final path on success\nfunction tryCopyApp(\n  srcAppPath: string,\n  destDir: string\n): string | null {\n  try {\n    const appName = path.basename(srcAppPath);\n    const destAppPath = path.join(destDir, appName);\n\n    // Ensure destination directory exists\n    fs.mkdirSync(destDir, { recursive: true });\n\n    // Remove existing app at destination if present\n    if (fs.existsSync(destAppPath)) {\n      fs.rmSync(destAppPath, { recursive: true, force: true });\n    }\n\n    // Use cp -R for macOS .app bundles (preserves symlinks and metadata)\n    execSync(`cp -R \"${srcAppPath}\" \"${destAppPath}\"`, {\n      stdio: 'pipe',\n    });\n\n    return destAppPath;\n  } catch {\n    return null;\n  }\n}\n\n// macOS: extract .app.tar.gz, copy to /Applications, remove quarantine, launch with `open`\nasync function installAndLaunchMacOS(\n  bundleInfo: DesktopBundleInfo\n): Promise<number> {\n  const { archivePath, dir } = bundleInfo;\n\n  const sentinel = readSentinel(dir);\n  if (sentinel?.appPath && fs.existsSync(sentinel.appPath)) {\n    return launchMacOSApp(sentinel.appPath);\n  }\n\n  if (!archivePath || !fs.existsSync(archivePath)) {\n    throw new Error('No archive to extract for macOS desktop app');\n  }\n\n  extractTarGz(archivePath, dir);\n\n  const appName = fs.readdirSync(dir).find((f) => f.endsWith('.app'));\n  if (!appName) {\n    throw new Error(\n      `No .app bundle found in ${dir} after extraction`\n    );\n  }\n\n  const extractedAppPath = path.join(dir, appName);\n\n  // Try to install to /Applications, then ~/Applications, then fall back to cache dir\n  const userApplications = path.join(os.homedir(), 'Applications');\n  const finalAppPath =\n    tryCopyApp(extractedAppPath, '/Applications') ??\n    tryCopyApp(extractedAppPath, userApplications) ??\n    extractedAppPath;\n\n  // Clean up extracted copy if we successfully copied elsewhere\n  if (finalAppPath !== extractedAppPath) {\n    try {\n      fs.rmSync(extractedAppPath, { recursive: true, force: true });\n    } catch {}\n  }\n\n  // Remove quarantine attribute (app is already signed and notarized in CI)\n  try {\n    execSync(`xattr -rd com.apple.quarantine \"${finalAppPath}\"`, {\n      stdio: 'pipe',\n    });\n  } catch {}\n\n  writeSentinel(dir, { type: 'app-tar-gz', appPath: finalAppPath });\n\n  return launchMacOSApp(finalAppPath);\n}\n\nfunction launchMacOSApp(appPath: string): Promise<number> {\n  const appName = path.basename(appPath);\n  console.error(`Launching ${appName}...`);\n  const proc = spawn('open', ['--wait-apps', appPath], {\n    stdio: 'inherit',\n  });\n  return new Promise((resolve) => {\n    proc.on('exit', (code) => resolve(code || 0));\n  });\n}\n\n// Linux: extract AppImage.tar.gz, chmod +x, run\nasync function installAndLaunchLinux(\n  bundleInfo: DesktopBundleInfo\n): Promise<number> {\n  const { archivePath, dir } = bundleInfo;\n\n  const sentinel = readSentinel(dir);\n  if (sentinel?.appPath && fs.existsSync(sentinel.appPath)) {\n    return launchLinuxAppImage(sentinel.appPath);\n  }\n\n  if (!archivePath || !fs.existsSync(archivePath)) {\n    throw new Error('No archive to extract for Linux desktop app');\n  }\n\n  extractTarGz(archivePath, dir);\n\n  const appImage = fs\n    .readdirSync(dir)\n    .find((f) => f.endsWith('.AppImage'));\n  if (!appImage) {\n    throw new Error(`No .AppImage found in ${dir} after extraction`);\n  }\n\n  const appImagePath = path.join(dir, appImage);\n  fs.chmodSync(appImagePath, 0o755);\n\n  writeSentinel(dir, {\n    type: 'appimage-tar-gz',\n    appPath: appImagePath,\n  });\n\n  return launchLinuxAppImage(appImagePath);\n}\n\nfunction launchLinuxAppImage(appImagePath: string): Promise<number> {\n  const appImage = path.basename(appImagePath);\n  console.error(`Launching ${appImage}...`);\n  const proc = spawn(appImagePath, [], {\n    stdio: 'inherit',\n    detached: false,\n  });\n  return new Promise((resolve) => {\n    proc.on('exit', (code) => resolve(code || 0));\n  });\n}\n\n// Windows: run NSIS setup.exe silently, then launch installed app\nasync function installAndLaunchWindows(\n  bundleInfo: DesktopBundleInfo\n): Promise<number> {\n  const { dir } = bundleInfo;\n\n  const sentinel = readSentinel(dir);\n  if (sentinel?.appPath) {\n    const appExe = path.join(sentinel.appPath, 'Vibe Kanban.exe');\n    if (fs.existsSync(appExe)) {\n      return launchWindowsApp(appExe);\n    }\n  }\n\n  // Find the NSIS installer\n  const files = fs.readdirSync(dir);\n  const installer = files.find(\n    (f) =>\n      f.endsWith('-setup.exe') ||\n      (f.endsWith('.exe') && f !== '.installed')\n  );\n  if (!installer) {\n    throw new Error(`No installer found in ${dir}`);\n  }\n\n  const installerPath = path.join(dir, installer);\n  const installDir = path.join(dir, 'app');\n\n  console.error('Installing Vibe Kanban...');\n  try {\n    // NSIS supports /S for silent install and /D= for install directory\n    execSync(`\"${installerPath}\" /S /D=\"${installDir}\"`, {\n      stdio: 'inherit',\n      timeout: 120000,\n    });\n  } catch {\n    // If silent install fails (e.g. UAC denied), try interactive\n    console.error(\n      'Silent install failed, launching interactive installer...'\n    );\n    execSync(`\"${installerPath}\"`, { stdio: 'inherit' });\n    // For interactive install, the default location is used\n    const defaultDir = path.join(\n      process.env.LOCALAPPDATA || '',\n      'vibe-kanban'\n    );\n    if (fs.existsSync(path.join(defaultDir, 'Vibe Kanban.exe'))) {\n      writeSentinel(dir, {\n        type: 'nsis-exe',\n        appPath: defaultDir,\n      });\n      return launchWindowsApp(\n        path.join(defaultDir, 'Vibe Kanban.exe')\n      );\n    }\n    console.error(\n      'Installation complete. Please launch Vibe Kanban from your Start menu.'\n    );\n    return 0;\n  }\n\n  writeSentinel(dir, { type: 'nsis-exe', appPath: installDir });\n\n  const appExe = path.join(installDir, 'Vibe Kanban.exe');\n  if (fs.existsSync(appExe)) {\n    return launchWindowsApp(appExe);\n  }\n\n  console.error(\n    'Installation complete. Please launch Vibe Kanban from your Start menu.'\n  );\n  return 0;\n}\n\nfunction launchWindowsApp(appExe: string): number {\n  console.error('Launching Vibe Kanban...');\n  spawn(appExe, [], { detached: true, stdio: 'ignore' }).unref();\n  return 0;\n}\n\nexport async function installAndLaunch(\n  bundleInfo: DesktopBundleInfo,\n  osPlatform: NodeJS.Platform\n): Promise<number> {\n  if (osPlatform === 'darwin') {\n    return installAndLaunchMacOS(bundleInfo);\n  } else if (osPlatform === 'linux') {\n    return installAndLaunchLinux(bundleInfo);\n  } else if (osPlatform === 'win32') {\n    return installAndLaunchWindows(bundleInfo);\n  }\n  throw new Error(\n    `Desktop app not supported on platform: ${osPlatform}`\n  );\n}\n\nexport function cleanOldDesktopVersions(\n  desktopBaseDir: string,\n  currentTag: string\n): void {\n  try {\n    const entries = fs.readdirSync(desktopBaseDir, {\n      withFileTypes: true,\n    });\n    for (const entry of entries) {\n      if (entry.isDirectory() && entry.name !== currentTag) {\n        const oldDir = path.join(desktopBaseDir, entry.name);\n        try {\n          fs.rmSync(oldDir, { recursive: true, force: true });\n        } catch {\n          // Ignore errors (e.g. EBUSY on Windows if app is running)\n        }\n      }\n    }\n  } catch {\n    // Ignore cleanup errors\n  }\n}\n"
  },
  {
    "path": "npx-cli/src/download.ts",
    "content": "import https from 'https';\nimport fs from 'fs';\nimport path from 'path';\nimport crypto from 'crypto';\nimport os from 'os';\n\n// Replaced during npm pack by workflow\nexport const R2_BASE_URL = '__R2_PUBLIC_URL__';\nexport const BINARY_TAG = '__BINARY_TAG__'; // e.g., v0.0.135-20251215122030\nexport const CACHE_DIR = path.join(os.homedir(), '.vibe-kanban', 'bin');\n\n// Local development mode: use binaries from npx-cli/dist/ instead of R2\n// Only activate if dist/ exists (i.e., running from source after local-build.sh)\nexport const LOCAL_DIST_DIR = path.join(__dirname, '..', 'dist');\nexport const LOCAL_DEV_MODE =\n  fs.existsSync(LOCAL_DIST_DIR) ||\n  process.env.VIBE_KANBAN_LOCAL === '1';\n\nexport interface BinaryInfo {\n  sha256: string;\n  size: number;\n}\n\nexport interface BinaryManifest {\n  latest?: string;\n  platforms: Record<string, Record<string, BinaryInfo>>;\n}\n\nexport interface DesktopPlatformInfo {\n  file: string;\n  sha256: string;\n  type: string | null;\n}\n\nexport interface DesktopManifest {\n  platforms: Record<string, DesktopPlatformInfo>;\n}\n\nexport interface DesktopBundleInfo {\n  archivePath: string | null;\n  dir: string;\n  type: string | null;\n}\n\ntype ProgressCallback = (downloaded: number, total: number) => void;\n\nfunction fetchJson<T>(url: string): Promise<T> {\n  return new Promise((resolve, reject) => {\n    https\n      .get(url, (res) => {\n        if (res.statusCode === 301 || res.statusCode === 302) {\n          return fetchJson<T>(res.headers.location!)\n            .then(resolve)\n            .catch(reject);\n        }\n        if (res.statusCode !== 200) {\n          return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));\n        }\n        let data = '';\n        res.on('data', (chunk: string) => (data += chunk));\n        res.on('end', () => {\n          try {\n            resolve(JSON.parse(data) as T);\n          } catch {\n            reject(new Error(`Failed to parse JSON from ${url}`));\n          }\n        });\n      })\n      .on('error', reject);\n  });\n}\n\nfunction downloadFile(\n  url: string,\n  destPath: string,\n  expectedSha256: string | undefined,\n  onProgress?: ProgressCallback\n): Promise<string> {\n  const tempPath = destPath + '.tmp';\n  return new Promise((resolve, reject) => {\n    const file = fs.createWriteStream(tempPath);\n    const hash = crypto.createHash('sha256');\n\n    const cleanup = () => {\n      try {\n        fs.unlinkSync(tempPath);\n      } catch {}\n    };\n\n    https\n      .get(url, (res) => {\n        if (res.statusCode === 301 || res.statusCode === 302) {\n          file.close();\n          cleanup();\n          return downloadFile(\n            res.headers.location!,\n            destPath,\n            expectedSha256,\n            onProgress\n          )\n            .then(resolve)\n            .catch(reject);\n        }\n\n        if (res.statusCode !== 200) {\n          file.close();\n          cleanup();\n          return reject(\n            new Error(`HTTP ${res.statusCode} downloading ${url}`)\n          );\n        }\n\n        const totalSize = parseInt(\n          res.headers['content-length'] || '0',\n          10\n        );\n        let downloadedSize = 0;\n\n        res.on('data', (chunk: Buffer) => {\n          downloadedSize += chunk.length;\n          hash.update(chunk);\n          if (onProgress) onProgress(downloadedSize, totalSize);\n        });\n        res.pipe(file);\n\n        file.on('finish', () => {\n          file.close();\n          const actualSha256 = hash.digest('hex');\n          if (expectedSha256 && actualSha256 !== expectedSha256) {\n            cleanup();\n            reject(\n              new Error(\n                `Checksum mismatch: expected ${expectedSha256}, got ${actualSha256}`\n              )\n            );\n          } else {\n            try {\n              fs.renameSync(tempPath, destPath);\n              resolve(destPath);\n            } catch (err) {\n              cleanup();\n              reject(err);\n            }\n          }\n        });\n      })\n      .on('error', (err) => {\n        file.close();\n        cleanup();\n        reject(err);\n      });\n  });\n}\n\nexport async function ensureBinary(\n  platform: string,\n  binaryName: string,\n  onProgress?: ProgressCallback\n): Promise<string> {\n  // In local dev mode, use binaries directly from npx-cli/dist/\n  if (LOCAL_DEV_MODE) {\n    const localZipPath = path.join(\n      LOCAL_DIST_DIR,\n      platform,\n      `${binaryName}.zip`\n    );\n    if (fs.existsSync(localZipPath)) {\n      return localZipPath;\n    }\n    throw new Error(\n      `Local binary not found: ${localZipPath}\\n` +\n        `Run ./local-build.sh first to build the binaries.`\n    );\n  }\n\n  const cacheDir = path.join(CACHE_DIR, BINARY_TAG, platform);\n  const zipPath = path.join(cacheDir, `${binaryName}.zip`);\n\n  if (fs.existsSync(zipPath)) return zipPath;\n\n  fs.mkdirSync(cacheDir, { recursive: true });\n\n  const manifest = await fetchJson<BinaryManifest>(\n    `${R2_BASE_URL}/binaries/${BINARY_TAG}/manifest.json`\n  );\n  const binaryInfo = manifest.platforms?.[platform]?.[binaryName];\n\n  if (!binaryInfo) {\n    throw new Error(\n      `Binary ${binaryName} not available for ${platform}`\n    );\n  }\n\n  const url = `${R2_BASE_URL}/binaries/${BINARY_TAG}/${platform}/${binaryName}.zip`;\n  await downloadFile(url, zipPath, binaryInfo.sha256, onProgress);\n\n  return zipPath;\n}\n\nexport const DESKTOP_CACHE_DIR = path.join(\n  os.homedir(),\n  '.vibe-kanban',\n  'desktop'\n);\n\nexport async function ensureDesktopBundle(\n  tauriPlatform: string,\n  onProgress?: ProgressCallback\n): Promise<DesktopBundleInfo> {\n  // In local dev mode, use Tauri bundle from npx-cli/dist/tauri/<platform>/\n  if (LOCAL_DEV_MODE) {\n    const localDir = path.join(LOCAL_DIST_DIR, 'tauri', tauriPlatform);\n    if (fs.existsSync(localDir)) {\n      const files = fs.readdirSync(localDir);\n      const archive = files.find(\n        (f) => f.endsWith('.tar.gz') || f.endsWith('-setup.exe')\n      );\n      return {\n        dir: localDir,\n        archivePath: archive ? path.join(localDir, archive) : null,\n        type: null,\n      };\n    }\n    throw new Error(\n      `Local desktop bundle not found: ${localDir}\\n` +\n        `Run './local-build.sh --desktop' first to build the Tauri app.`\n    );\n  }\n\n  const cacheDir = path.join(\n    DESKTOP_CACHE_DIR,\n    BINARY_TAG,\n    tauriPlatform\n  );\n\n  // Check if already installed (sentinel file from previous run)\n  const sentinelPath = path.join(cacheDir, '.installed');\n  if (fs.existsSync(sentinelPath)) {\n    return { dir: cacheDir, archivePath: null, type: null };\n  }\n\n  fs.mkdirSync(cacheDir, { recursive: true });\n\n  // Fetch the desktop manifest\n  const manifest = await fetchJson<DesktopManifest>(\n    `${R2_BASE_URL}/binaries/${BINARY_TAG}/tauri/desktop-manifest.json`\n  );\n  const platformInfo = manifest.platforms?.[tauriPlatform];\n  if (!platformInfo) {\n    throw new Error(\n      `Desktop app not available for platform: ${tauriPlatform}`\n    );\n  }\n\n  const destPath = path.join(cacheDir, platformInfo.file);\n\n  // Skip download if file already exists (e.g. previous failed install)\n  if (!fs.existsSync(destPath)) {\n    const url = `${R2_BASE_URL}/binaries/${BINARY_TAG}/tauri/${tauriPlatform}/${platformInfo.file}`;\n    await downloadFile(url, destPath, platformInfo.sha256, onProgress);\n  }\n\n  return {\n    archivePath: destPath,\n    dir: cacheDir,\n    type: platformInfo.type,\n  };\n}\n\nexport async function getLatestVersion(): Promise<string | undefined> {\n  const manifest = await fetchJson<BinaryManifest>(\n    `${R2_BASE_URL}/binaries/manifest.json`\n  );\n  return manifest.latest;\n}\n"
  },
  {
    "path": "npx-cli/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"vibe-kanban\",\n  \"version\": \"0.1.33\",\n  \"private\": true,\n  \"bin\": {\n    \"vibe-kanban\": \"npx-cli/bin/cli.js\"\n  },\n  \"files\": [\n    \"npx-cli/bin/cli.js\",\n    \"npx-cli/dist/**\"\n  ],\n  \"scripts\": {\n    \"lint\": \"pnpm run local-web:lint && pnpm run ui:lint && pnpm run backend:lint && node scripts/check-unused-i18n-keys.mjs\",\n    \"format\": \"pnpm run backend:format && pnpm run web-core:format && pnpm run local-web:format && pnpm run remote-web:format\",\n    \"check\": \"pnpm run local-web:legacy-path-guard && pnpm run local-web:check && pnpm run remote-web:check && pnpm run web-core:check && pnpm run ui:check && pnpm run backend:check\",\n    \"dev\": \"export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export PREVIEW_PROXY_PORT=$(node scripts/setup-dev-environment.js preview_proxy) && export VK_ALLOWED_ORIGINS=\\\"http://localhost:${FRONTEND_PORT}\\\" && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && concurrently \\\"pnpm run backend:dev:watch\\\" \\\"pnpm run local-web:dev\\\"\",\n    \"test:npm\": \"./test-npm-package.sh\",\n    \"local-web:lint\": \"pnpm --filter @vibe/local-web run lint\",\n    \"local-web:legacy-path-guard\": \"./scripts/check-legacy-frontend-paths.sh\",\n    \"web-core:format\": \"pnpm --filter @vibe/web-core run format\",\n    \"web-core:check\": \"pnpm --filter @vibe/web-core run check\",\n    \"local-web:dev\": \"cd packages/local-web && pnpm run dev -- --port ${FRONTEND_PORT:-3000}\",\n    \"local-web:check\": \"pnpm --filter @vibe/local-web run check\",\n    \"remote-web:check\": \"pnpm --filter @vibe/remote-web run check\",\n    \"local-web:format\": \"pnpm --filter @vibe/local-web run format\",\n    \"remote-web:format\": \"pnpm --filter @vibe/remote-web run format\",\n    \"ui:lint\": \"pnpm --filter @vibe/ui run lint\",\n    \"ui:check\": \"pnpm --filter @vibe/ui run check\",\n    \"backend:lint\": \"cargo clippy --workspace --all-targets --features qa-mode -- -D warnings && cargo clippy --manifest-path crates/remote/Cargo.toml --all-targets -- -D warnings\",\n    \"backend:format\": \"cargo fmt --all && cargo fmt --all --manifest-path crates/remote/Cargo.toml\",\n    \"backend:dev\": \"BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) pnpm run backend:dev:watch\",\n    \"backend:check\": \"cargo check --workspace && cargo check --manifest-path crates/remote/Cargo.toml\",\n    \"backend:dev:watch\": \"DISABLE_WORKTREE_CLEANUP=1 RUST_LOG=debug cargo watch -w crates -x 'run --bin server'\",\n    \"dev:qa\": \"export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export PREVIEW_PROXY_PORT=$(node scripts/setup-dev-environment.js preview_proxy) && export VK_ALLOWED_ORIGINS=\\\"http://localhost:${FRONTEND_PORT}\\\" && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && concurrently \\\"pnpm run backend:dev:watch:qa\\\" \\\"pnpm run local-web:dev\\\"\",\n    \"generate-types\": \"cargo run --bin generate_types\",\n    \"generate-types:check\": \"cargo run --bin generate_types -- --check\",\n    \"remote:generate-types\": \"cargo run --manifest-path crates/remote/Cargo.toml --bin remote-generate-types\",\n    \"remote:generate-types:check\": \"cargo run --manifest-path crates/remote/Cargo.toml --bin remote-generate-types -- --check\",\n    \"prepare-db\": \"node scripts/prepare-db.js\",\n    \"prepare-db:check\": \"node scripts/prepare-db.js --check\",\n    \"build:bippy-bundle\": \"node scripts/build-bippy-bundle.mjs\",\n    \"build:npx\": \"bash ./local-build.sh\",\n    \"build:npx-cli\": \"cd npx-cli && npm ci && npm run build\",\n    \"check:npx-cli\": \"tsc --noEmit -p npx-cli/tsconfig.json\",\n    \"prepack\": \"pnpm run build:npx && pnpm run build:npx-cli\",\n    \"remote:dev\": \"cd crates/remote && docker compose --env-file .env.remote up --build ; docker compose --env-file .env.remote down -v\",\n    \"remote:dev:clean\": \"cd crates/remote && docker compose --env-file .env.remote down -v\",\n    \"remote:prepare-db\": \"cd crates/remote && bash scripts/prepare-db.sh\",\n    \"remote:prepare-db:check\": \"cd crates/remote && bash scripts/prepare-db.sh --check\",\n    \"tauri:dev\": \"set -a && [ -f .env ] && . ./.env && set +a && export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export VK_ALLOWED_ORIGINS=\\\"http://localhost:${FRONTEND_PORT}\\\" && export DISABLE_WORKTREE_CLEANUP=1 && export RUST_LOG=debug && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && cd crates/tauri-app && cargo tauri dev\",\n    \"tauri:build\": \"cd crates/tauri-app && cargo tauri build\"\n  },\n  \"devDependencies\": {\n    \"@types/adm-zip\": \"^0.5.7\",\n    \"@types/node\": \"^20.0.0\",\n    \"bippy\": \"0.5.28\",\n    \"concurrently\": \"^8.2.2\",\n    \"esbuild\": \"^0.27.2\",\n    \"jwt-decode\": \"^4.0.0\",\n    \"typescript\": \"^5.7.0\",\n    \"vite\": \"^7.3.1\"\n  },\n  \"engines\": {\n    \"node\": \">=20\",\n    \"pnpm\": \">=8\"\n  },\n  \"packageManager\": \"pnpm@10.13.1\"\n}\n"
  },
  {
    "path": "packages/local-web/.eslintrc.cjs",
    "content": "const path = require('path');\n\nconst i18nCheck = process.env.LINT_I18N === 'true';\n\n// Presentational components - these must be stateless and receive all data via props\nconst presentationalComponentPatterns = [\n  'src/components/ui-new/views/**/*.tsx',\n  'src/components/ui-new/primitives/**/*.tsx',\n];\n\nconst baseRestrictedImportPaths = [\n  {\n    name: '@ebay/nice-modal-react',\n    importNames: ['default'],\n    message:\n      'Do not import NiceModal directly. Use typed dialog APIs from migrated shared modules.',\n  },\n  {\n    name: '@/lib/modals',\n    importNames: ['showModal', 'hideModal', 'removeModal'],\n    message:\n      'Do not import showModal/hideModal/removeModal. Use DialogName.show(props) and DialogName.hide() instead.',\n  },\n  {\n    name: '@vibe/ui',\n    message:\n      'Do not import from @vibe/ui root. Use @vibe/ui/components/* subpaths.',\n  },\n];\n\n// All legacy directory import patterns.\nconst allLegacyPatterns = [\n  '@/components',\n  '@/components/**',\n  '@/constants',\n  '@/constants/**',\n  '@/contexts',\n  '@/contexts/**',\n  '@/hooks',\n  '@/hooks/**',\n  '@/keyboard',\n  '@/keyboard/**',\n  '@/lib',\n  '@/lib/**',\n  '@/types',\n  '@/types/**',\n  '@/utils',\n  '@/utils/**',\n];\n\nconst legacyDirectoryFilePatterns = [\n  'src/components/**/*.{ts,tsx}',\n  'src/constants/**/*.{ts,tsx}',\n  'src/contexts/**/*.{ts,tsx}',\n  'src/hooks/**/*.{ts,tsx}',\n  'src/keyboard/**/*.{ts,tsx}',\n  'src/lib/**/*.{ts,tsx}',\n  'src/types/**/*.{ts,tsx}',\n  'src/utils/**/*.{ts,tsx}',\n];\n\n// Legacy directories that should not be imported from proper FSD layers.\n// These are pending migration into shared/, features/, widgets/, or pages/.\nconst legacyBanGroup = {\n  group: allLegacyPatterns,\n  message:\n    'Do not import from legacy directories. Use the equivalent shared/ module, or migrate the code first.',\n};\n\n// Build a ban group for a specific legacy directory that bans all OTHER legacy\n// directories but allows same-directory imports (intra-legacy is OK).\nfunction legacyCrossBanGroup(ownPatterns) {\n  const ownSet = new Set(ownPatterns);\n  return {\n    group: allLegacyPatterns.filter((p) => !ownSet.has(p)),\n    message:\n      'Legacy directories must not import from other legacy directories. Migrate the dependency to shared/ first.',\n  };\n}\n\nfunction withLayerBoundaries(patterns) {\n  return [\n    'error',\n    {\n      paths: baseRestrictedImportPaths,\n      patterns,\n    },\n  ];\n}\n\nfunction withLayerBoundariesAndLegacyBan(patterns) {\n  return [\n    'error',\n    {\n      paths: baseRestrictedImportPaths,\n      patterns: [...patterns, legacyBanGroup],\n    },\n  ];\n}\n\nmodule.exports = {\n  root: true,\n  env: {\n    browser: true,\n    es2020: true,\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n    'plugin:i18next/recommended',\n    'plugin:eslint-comments/recommended',\n    'prettier',\n  ],\n  ignorePatterns: ['dist', '.eslintrc.cjs', 'src/routeTree.gen.ts'],\n  parser: '@typescript-eslint/parser',\n  plugins: ['react-refresh', '@typescript-eslint', 'unused-imports', 'i18next', 'eslint-comments', 'check-file', 'deprecation'],\n  parserOptions: {\n    ecmaVersion: 'latest',\n    sourceType: 'module',\n    project: path.join(__dirname, 'tsconfig.json'),\n  },\n  rules: {\n    'eslint-comments/no-use': ['error', { allow: [] }],\n    'react-refresh/only-export-components': 'off',\n    'unused-imports/no-unused-imports': 'error',\n    'unused-imports/no-unused-vars': [\n      'error',\n      {\n        vars: 'all',\n        args: 'after-used',\n        ignoreRestSiblings: false,\n      },\n    ],\n    '@typescript-eslint/no-explicit-any': 'warn',\n    '@typescript-eslint/switch-exhaustiveness-check': 'error',\n    // Enforce typesafe modal pattern\n    'no-restricted-imports': [\n      'error',\n      {\n        paths: baseRestrictedImportPaths,\n      },\n    ],\n    'no-restricted-syntax': [\n      'error',\n      {\n        selector:\n          'CallExpression[callee.object.name=\"NiceModal\"][callee.property.name=\"show\"]',\n        message:\n          'Do not use NiceModal.show() directly. Use DialogName.show(props) instead.',\n      },\n      {\n        selector:\n          'CallExpression[callee.object.name=\"NiceModal\"][callee.property.name=\"register\"]',\n        message:\n          'Do not use NiceModal.register(). Dialogs are registered automatically.',\n      },\n      {\n        selector: 'CallExpression[callee.name=\"showModal\"]',\n        message:\n          'Do not use showModal(). Use DialogName.show(props) instead.',\n      },\n      {\n        selector: 'CallExpression[callee.name=\"hideModal\"]',\n        message: 'Do not use hideModal(). Use DialogName.hide() instead.',\n      },\n      {\n        selector: 'CallExpression[callee.name=\"removeModal\"]',\n        message: 'Do not use removeModal(). Use DialogName.remove() instead.',\n      },\n      {\n        selector: 'ExportNamedDeclaration[source]',\n        message:\n          'Re-exports are not allowed. Import directly from the owning module instead.',\n      },\n    ],\n    // i18n rule - only active when LINT_I18N=true\n    'i18next/no-literal-string': i18nCheck\n      ? [\n          'warn',\n          {\n            markupOnly: true,\n            ignoreAttribute: [\n              'data-testid',\n              'to',\n              'href',\n              'id',\n              'key',\n              'type',\n              'role',\n              'className',\n              'style',\n              'aria-describedby',\n            ],\n            'jsx-components': {\n              exclude: ['code'],\n            },\n          },\n        ]\n      : 'off',\n    // File naming conventions\n    'check-file/filename-naming-convention': [\n      'error',\n      {\n        // React components (tsx) should be PascalCase\n        'src/**/*.tsx': 'PASCAL_CASE',\n        // Hooks should be camelCase starting with 'use'\n        'src/**/use*.ts': 'CAMEL_CASE',\n        // Utils should be camelCase\n        'src/utils/**/*.ts': 'CAMEL_CASE',\n        // Lib/config/constants should be camelCase\n        'src/lib/**/*.ts': 'CAMEL_CASE',\n        'src/config/**/*.ts': 'CAMEL_CASE',\n        'src/constants/**/*.ts': 'CAMEL_CASE',\n      },\n      {\n        ignoreMiddleExtensions: true,\n      },\n    ],\n  },\n  overrides: [\n    {\n      // Legacy directories sit outside the enforced layer hierarchy.\n      // They cannot import from higher layers (app/pages/widgets/features/entities)\n      // and cannot import from other legacy directories — but CAN import from\n      // themselves (intra-directory imports are fine).\n      files: ['src/hooks/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundaries([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'],\n            message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.',\n          },\n          legacyCrossBanGroup(['@/hooks', '@/hooks/**']),\n        ]),\n      },\n    },\n    {\n      files: ['src/contexts/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundaries([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'],\n            message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.',\n          },\n          legacyCrossBanGroup(['@/contexts', '@/contexts/**']),\n        ]),\n      },\n    },\n    {\n      files: ['src/lib/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundaries([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'],\n            message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.',\n          },\n          legacyCrossBanGroup(['@/lib', '@/lib/**']),\n        ]),\n      },\n    },\n    {\n      files: ['src/components/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundaries([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'],\n            message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.',\n          },\n          legacyCrossBanGroup(['@/components', '@/components/**']),\n        ]),\n      },\n    },\n    {\n      files: [\n        'src/utils/**/*.{ts,tsx}',\n        'src/constants/**/*.{ts,tsx}',\n        'src/types/**/*.{ts,tsx}',\n        'src/keyboard/**/*.{ts,tsx}',\n      ],\n      rules: {\n        'no-restricted-imports': withLayerBoundaries([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'],\n            message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.',\n          },\n          legacyCrossBanGroup([\n            '@/utils',\n            '@/utils/**',\n            '@/constants',\n            '@/constants/**',\n            '@/types',\n            '@/types/**',\n            '@/keyboard',\n            '@/keyboard/**',\n          ]),\n        ]),\n      },\n    },\n    {\n      // Pages may import from widgets, features, entities, shared, integrations\n      // but not from legacy directories.\n      files: ['src/pages/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([\n          {\n            group: ['@/app/**'],\n            message:\n              'Pages may not import from app. Only app imports pages.',\n          },\n        ]),\n      },\n    },\n    {\n      // App layer may import from any proper layer but not legacy directories.\n      files: ['src/app/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([]),\n      },\n    },\n    {\n      // Route definitions may import from pages and shared but not legacy.\n      files: ['src/routes/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([]),\n      },\n    },\n    {\n      files: ['src/routes/**/*.{ts,tsx}', 'src/routeTree.gen.ts'],\n      rules: {\n        'check-file/filename-naming-convention': 'off',\n      },\n    },\n    {\n      files: ['src/widgets/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**'],\n            message:\n              'Widgets may only import from features, entities, shared, and integrations.',\n          },\n        ]),\n      },\n    },\n    {\n      files: ['src/features/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**'],\n            message:\n              'Features may only import from entities, shared, and integrations.',\n          },\n        ]),\n        'no-restricted-syntax': [\n          'error',\n          {\n            selector: 'ExportNamedDeclaration[source.value=/^@\\\\u002F/]',\n            message:\n              'Re-exports from other layers in features are not allowed. They bypass layer boundaries. Import directly from the owning module instead.',\n          },\n          {\n            selector: 'ExportAllDeclaration[source.value=/^@\\\\u002F/]',\n            message:\n              'Wildcard re-exports from other layers in features are not allowed. They bypass layer boundaries.',\n          },\n        ],\n      },\n    },\n    {\n      files: ['src/entities/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([\n          {\n            group: [\n              '@/app/**',\n              '@/pages/**',\n              '@/widgets/**',\n              '@/features/**',\n              '@/entities/**',\n            ],\n            message: 'Entities may only import from shared and integrations.',\n          },\n        ]),\n      },\n    },\n    {\n      files: ['src/shared/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([\n          {\n            group: [\n              '@/app/**',\n              '@/pages/**',\n              '@/widgets/**',\n              '@/features/**',\n              '@/entities/**',\n              '@/integrations/**',\n            ],\n            message: 'Shared layer may only import from shared.',\n          },\n        ]),\n      },\n    },\n    {\n      files: ['src/integrations/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-imports': withLayerBoundariesAndLegacyBan([\n          {\n            group: ['@/app/**', '@/pages/**', '@/widgets/**'],\n            message:\n              'Integrations must not depend on app/pages/widgets. Use shared-level contracts instead.',\n          },\n        ]),\n      },\n    },\n    {\n      // Entry point exception - main.tsx can stay lowercase\n      files: ['src/main.tsx', 'src/vite-env.d.ts'],\n      rules: {\n        'check-file/filename-naming-convention': 'off',\n      },\n    },\n    {\n      // Shadcn UI components are an exception - keep kebab-case\n      files: ['src/components/ui/**/*.{ts,tsx}'],\n      rules: {\n        'check-file/filename-naming-convention': [\n          'error',\n          {\n            'src/components/ui/**/*.{ts,tsx}': 'KEBAB_CASE',\n          },\n          {\n            ignoreMiddleExtensions: true,\n          },\n        ],\n      },\n    },\n    {\n      files: [\n        '**/*.test.{ts,tsx}',\n        '**/*.stories.{ts,tsx}',\n        'src/pages/ui-new/ElectricTestPage.tsx',\n        'src/pages/Migration.tsx',\n        'src/components/ui-new/views/Migrate*.tsx',\n        'src/components/ui-new/containers/Migrate*.tsx',\n      ],\n      rules: {\n        'i18next/no-literal-string': 'off',\n      },\n    },\n    {\n      // Disable type-aware linting for config files\n      files: ['*.config.{ts,js,cjs,mjs}', '.eslintrc.cjs'],\n      parserOptions: {\n        project: null,\n      },\n      rules: {\n        '@typescript-eslint/switch-exhaustiveness-check': 'off',\n      },\n    },\n    {\n      // i18n index re-exports the default export from config for `import i18n from '@/i18n'`\n      files: ['src/i18n/index.ts'],\n      rules: {\n        'no-restricted-syntax': 'off',\n      },\n    },\n    {\n      // ui-new components must use Phosphor icons (not Lucide) and avoid deprecated APIs\n      files: ['src/components/ui-new/**/*.{ts,tsx}'],\n      rules: {\n        'deprecation/deprecation': 'error',\n        'no-restricted-imports': [\n          'error',\n          {\n            paths: [\n              {\n                name: '@vibe/ui',\n                message:\n                  'Do not import from @vibe/ui root. Use @vibe/ui/components/* subpaths.',\n              },\n              {\n                name: 'lucide-react',\n                message: 'Use @phosphor-icons/react instead of lucide-react in ui-new components.',\n              },\n            ],\n          },\n        ],\n        // Icon size restrictions - use Tailwind design system sizes\n        'no-restricted-syntax': [\n          'error',\n          {\n            selector: 'JSXAttribute[name.name=\"size\"][value.type=\"JSXExpressionContainer\"]',\n            message:\n              'Icons should use Tailwind size classes (size-icon-xs, size-icon-sm, size-icon-base, size-icon-lg, size-icon-xl) instead of the size prop. Example: <Icon className=\"size-icon-base\" />',\n          },\n          {\n            // Catch arbitrary pixel sizes like size-[10px], size-[7px], etc. in className\n            selector: 'Literal[value=/size-\\\\[\\\\d+px\\\\]/]',\n            message:\n              'Use standard icon sizes (size-icon-xs, size-icon-sm, size-icon-base, size-icon-lg, size-icon-xl) instead of arbitrary pixel values like size-[Npx].',\n          },\n          {\n            // Catch generic tailwind sizes like size-1, size-3, size-1.5, etc. (not size-icon-* or size-dot)\n            selector: 'Literal[value=/(?<!icon-)(?<!-)size-[0-9]/]',\n            message:\n              'Use design system sizes (size-icon-xs, size-icon-sm, size-icon-base, size-icon-lg, size-icon-xl, size-dot) instead of generic Tailwind sizes.',\n          },\n        ],\n      },\n    },\n    {\n      // Ban re-exports (barrel exports) in ui-new index files\n      files: ['src/components/ui-new/**/index.ts'],\n      rules: {\n        'no-restricted-syntax': [\n          'error',\n          {\n            selector: 'ExportNamedDeclaration[source]',\n            message: 'Re-exports are not allowed in ui-new. Export directly from source files.',\n          },\n          {\n            selector: 'ExportAllDeclaration',\n            message: 'Wildcard re-exports (export *) are not allowed in ui-new.',\n          },\n        ],\n      },\n    },\n    {\n      // Container components should not have optional props\n      files: ['src/components/ui-new/containers/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-syntax': [\n          'error',\n          {\n            selector: 'TSPropertySignature[optional=true]',\n            message:\n              'Optional props are not allowed in container components. Make the prop required or provide a default value.',\n          },\n        ],\n      },\n    },\n    {\n      // Logic hooks in ui-new/hooks/ - no JSX allowed\n      files: ['src/components/ui-new/hooks/**/*.{ts,tsx}'],\n      rules: {\n        'no-restricted-syntax': [\n          'error',\n          {\n            selector: 'JSXElement',\n            message: 'Logic hooks must not contain JSX. Return data and callbacks only.',\n          },\n          {\n            selector: 'JSXFragment',\n            message: 'Logic hooks must not contain JSX fragments.',\n          },\n        ],\n      },\n    },\n    {\n      // Presentational components (views & primitives) - strict presentation rules (no logic)\n      files: presentationalComponentPatterns,\n      rules: {\n        'no-restricted-imports': [\n          'error',\n          {\n            paths: [\n              {\n                name: '@vibe/ui',\n                message:\n                  'Do not import from @vibe/ui root. Use @vibe/ui/components/* subpaths.',\n              },\n              {\n                name: '@/lib/api',\n                message: 'Presentational components cannot import API. Pass data via props.',\n              },\n              {\n                name: '@tanstack/react-query',\n                importNames: ['useQuery', 'useMutation', 'useQueryClient', 'useInfiniteQuery'],\n                message: 'Presentational components cannot use data fetching hooks. Pass data via props.',\n              },\n            ],\n          },\n        ],\n        'no-restricted-syntax': [\n          'error',\n          {\n            selector: 'CallExpression[callee.name=\"useState\"]',\n            message: 'Presentational components should not manage state. Use controlled props.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useReducer\"]',\n            message: 'Presentational components should not use useReducer. Use container component.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useContext\"]',\n            message: 'Presentational components should not consume context. Pass data via props.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useQuery\"]',\n            message: 'Presentational components should not fetch data. Pass data via props.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useMutation\"]',\n            message: 'Presentational components should not mutate data. Pass callbacks via props.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useInfiniteQuery\"]',\n            message: 'Presentational components should not fetch data. Pass data via props.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useEffect\"]',\n            message: 'Presentational components should avoid side effects. Move to container.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useLayoutEffect\"]',\n            message: 'Presentational components should avoid layout effects. Move to container.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useCallback\"]',\n            message: 'Presentational components should receive callbacks via props.',\n          },\n          {\n            selector: 'CallExpression[callee.name=\"useNavigate\"]',\n            message: 'Presentational components should not handle navigation. Pass callbacks via props.',\n          },\n        ],\n      },\n    },\n    {\n      // Hard-enforce the new folder structure: legacy directories are disallowed.\n      files: legacyDirectoryFilePatterns,\n      rules: {\n        'no-restricted-syntax': [\n          'error',\n          {\n            selector: 'Program',\n            message:\n              'Legacy directories are not allowed. Move this file to app/pages/widgets/features/entities/shared/integrations.',\n          },\n        ],\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/local-web/.prettierignore",
    "content": "src/routeTree.gen.ts\n"
  },
  {
    "path": "packages/local-web/.prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"trailingComma\": \"es5\",\n  \"singleQuote\": true,\n  \"printWidth\": 80,\n  \"tabWidth\": 2,\n  \"useTabs\": false\n}"
  },
  {
    "path": "packages/local-web/AGENTS.md",
    "content": "## New Design System Styling Guidelines\n\n### CSS Variables & Tailwind Config\n\nThe new design uses custom CSS variables defined in `../web-core/src/app/styles/new/index.css` and configured in `tailwind.new.config.js`. All styles are scoped to the `.new-design` class.\n\n### Colors\n\n**Text colors** (use these instead of `text-gray-*`):\n- `text-high` - Primary text, highest contrast\n- `text-normal` - Standard text\n- `text-low` - Muted/secondary text, placeholders\n\n**Background colors**:\n- `bg-primary` - Main background\n- `bg-secondary` - Slightly darker, used for inputs, cards, sidebars\n- `bg-panel` - Panel/elevated surfaces\n\n**Accent colors**:\n- `brand` - Orange accent (`hsl(25 82% 54%)`)\n- `error` - Error states\n- `success` - Success states\n\n### Typography\n\n**Font families**:\n- `font-ibm-plex-sans` - Default sans-serif\n- `font-ibm-plex-mono` - Monospace/code\n\n**Font sizes** (smaller than typical Tailwind defaults):\n- `text-xs` - 8px\n- `text-sm` - 10px\n- `text-base` - 12px (default)\n- `text-lg` - 14px\n- `text-xl` - 16px\n\n### Spacing\n\nCustom spacing tokens:\n- `p-half` / `m-half` - 6px\n- `p-base` / `m-base` - 12px\n- `p-double` / `m-double` - 24px\n\n### Border Radius\n\nUses a small radius by default (`--radius: 0.125rem`):\n- `rounded` - Default small radius\n- `rounded-sm`, `rounded-md`, `rounded-lg` - Progressively larger\n\n### Focus States\n\nFocus rings use `ring-brand` (orange) and are inset by default.\n\n### Example Component Styling\n\n```tsx\n// Input field\nclassName=\"px-base bg-secondary rounded border text-base text-normal placeholder:text-low focus:outline-none focus:ring-1 focus:ring-brand\"\n\n// Button (icon)\nclassName=\"flex items-center justify-center bg-secondary rounded border text-low hover:text-normal\"\n\n// Sidebar container\nclassName=\"w-64 bg-secondary shrink-0 p-base\"\n```\n\n### Architecture Rules\n\n- **View components** (in `views/`) should be stateless - receive all data via props\n- **Container components** (in `containers/`) manage state and pass to views\n- **UI components** (in `ui-new/`) are reusable primitives\n- File names in `ui-new/` must be **PascalCase** (e.g., `Field.tsx`, `Label.tsx`)\n"
  },
  {
    "path": "packages/local-web/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.new.config.js\",\n    \"css\": \"src/styles/new/index.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"ui\": \"@/components/ui-new\",\n    \"utils\": \"@/lib/utils\"\n  }\n}"
  },
  {
    "path": "packages/local-web/components.legacy.json",
    "content": "{\n    \"$schema\": \"https://ui.shadcn.com/schema.json\",\n    \"style\": \"default\",\n    \"rsc\": false,\n    \"tsx\": true,\n    \"outDir\": \"components/ui\",\n    \"tailwind\": {\n        \"config\": \"tailwind.legacy.config.js\",\n        \"css\": \"src/styles/legacy/index.css\",\n        \"baseColor\": \"slate\",\n        \"cssVariables\": true,\n        \"prefix\": \"\"\n    },\n    \"aliases\": {\n        \"components\": \"@/components\",\n        \"utils\": \"@/lib/utils\"\n    }\n}"
  },
  {
    "path": "packages/local-web/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon-vk-light.svg\" media=\"(prefers-color-scheme: light)\">\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon-vk-dark.svg\" media=\"(prefers-color-scheme: dark)\">\n    <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/site.webmanifest\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\" />\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n    <meta name=\"theme-color\" content=\"#f2f2f2\" media=\"(prefers-color-scheme: light)\">\n    <meta name=\"theme-color\" content=\"#212121\" media=\"(prefers-color-scheme: dark)\">\n    <title>Vibe Kanban</title>\n</head>\n\n<body>\n    <div id=\"root\"></div>\n    <script>\n      // Polyfill crypto.randomUUID for non-secure contexts (e.g. HTTP access\n      // via LAN IP). Required because @tanstack/db calls crypto.randomUUID()\n      // directly without a fallback.\n      // See: https://github.com/TanStack/db/issues/784\n      if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {\n        crypto.randomUUID = function () {\n          var bytes = new Uint8Array(16);\n          if (typeof crypto.getRandomValues === 'function') {\n            crypto.getRandomValues(bytes);\n          } else {\n            for (var i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);\n          }\n          // Set RFC 4122 v4 version and variant bits\n          bytes[6] = (bytes[6] & 0x0f) | 0x40;\n          bytes[8] = (bytes[8] & 0x3f) | 0x80;\n          var h = [];\n          for (var j = 0; j < 16; j++) h.push(bytes[j].toString(16).padStart(2, '0'));\n          return (\n            h[0] + h[1] + h[2] + h[3] + '-' +\n            h[4] + h[5] + '-' +\n            h[6] + h[7] + '-' +\n            h[8] + h[9] + '-' +\n            h[10] + h[11] + h[12] + h[13] + h[14] + h[15]\n          );\n        };\n      }\n    </script>\n    <script type=\"module\" src=\"/src/app/entry/Bootstrap.tsx\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "packages/local-web/package.json",
    "content": "{\n  \"name\": \"@vibe/local-web\",\n  \"private\": true,\n  \"version\": \"0.1.33\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"VITE_OPEN=${VITE_OPEN:-false} vite\",\n    \"build\": \"tsc && vite build\",\n    \"check\": \"tsc --noEmit\",\n    \"preview\": \"vite preview\",\n    \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\n    \"lint:fix\": \"eslint . --ext ts,tsx --fix\",\n    \"lint:i18n\": \"LINT_I18N=true eslint . --ext ts,tsx --max-warnings 0\",\n    \"format\": \"prettier --write \\\"src/**/*.{ts,tsx,js,jsx,json,css,md}\\\"\",\n    \"format:check\": \"prettier --check \\\"src/**/*.{ts,tsx,js,jsx,json,css,md}\\\"\"\n  },\n  \"dependencies\": {\n    \"@codemirror/lang-json\": \"^6.0.2\",\n    \"@codemirror/language\": \"^6.11.2\",\n    \"@codemirror/lint\": \"^6.8.5\",\n    \"@codemirror/view\": \"^6.38.1\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@ebay/nice-modal-react\": \"^1.2.13\",\n    \"@git-diff-view/file\": \"^0.0.30\",\n    \"@git-diff-view/react\": \"^0.0.30\",\n    \"@hello-pangea/dnd\": \"^18.0.1\",\n    \"@lexical/code\": \"^0.36.2\",\n    \"@lexical/link\": \"^0.36.2\",\n    \"@lexical/list\": \"^0.36.2\",\n    \"@lexical/markdown\": \"^0.36.2\",\n    \"@lexical/react\": \"^0.36.2\",\n    \"@lexical/rich-text\": \"^0.36.2\",\n    \"@lexical/table\": \"^0.36.2\",\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"@pierre/diffs\": \"^1.0.8\",\n    \"@radix-ui/react-accordion\": \"^1.2.1\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-select\": \"^2.2.5\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.0.3\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.7\",\n    \"@rjsf/shadcn\": \"6.1.1\",\n    \"@sentry/react\": \"^9.34.0\",\n    \"@sentry/vite-plugin\": \"^3.5.0\",\n    \"@tanstack/electric-db-collection\": \"^0.2.6\",\n    \"@tanstack/react-db\": \"^0.1.50\",\n    \"@tanstack/react-form\": \"^1.23.8\",\n    \"@tanstack/react-query\": \"^5.85.5\",\n    \"@tanstack/react-router\": \"^1.161.1\",\n    \"@tanstack/zod-adapter\": \"^1.161.1\",\n    \"@tauri-apps/api\": \"^2.10.1\",\n    \"@uiw/react-codemirror\": \"^4.25.1\",\n    \"@vibe/web-core\": \"workspace:*\",\n    \"@vibe/ui\": \"workspace:*\",\n    \"@virtuoso.dev/message-list\": \"^1.13.3\",\n    \"@xterm/addon-fit\": \"^0.10.0\",\n    \"@xterm/addon-web-links\": \"^0.11.0\",\n    \"@xterm/xterm\": \"^5.5.0\",\n    \"class-variance-authority\": \"^0.7.0\",\n    \"click-to-react-component\": \"^1.1.2\",\n    \"clsx\": \"^2.0.0\",\n    \"cmdk\": \"^1.1.1\",\n    \"developer-icons\": \"^6.0.4\",\n    \"fancy-ansi\": \"^0.1.3\",\n    \"framer-motion\": \"^12.23.24\",\n    \"i18next\": \"^25.5.2\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"immer\": \"^11.1.3\",\n    \"jwt-decode\": \"^4.0.0\",\n    \"lexical\": \"^0.36.2\",\n    \"lodash\": \"^4.17.21\",\n    \"lucide-react\": \"^0.539.0\",\n    \"posthog-js\": \"^1.276.0\",\n    \"react\": \"^18.2.0\",\n    \"react-compiler-runtime\": \"^1.0.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-dropzone\": \"^14.3.8\",\n    \"react-hotkeys-hook\": \"^5.1.0\",\n    \"react-i18next\": \"^15.7.3\",\n    \"react-resizable-panels\": \"^4.0.13\",\n    \"react-use-websocket\": \"^4.13.0\",\n    \"react-virtuoso\": \"^4.14.0\",\n    \"rfc6902\": \"^5.1.2\",\n    \"simple-icons\": \"^15.16.0\",\n    \"tailwind-merge\": \"^2.2.0\",\n    \"tailwind-scrollbar\": \"^3.1.0\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"wa-sqlite\": \"^1.0.0\",\n    \"zod\": \"^3.25.76\",\n    \"zustand\": \"^4.5.4\"\n  },\n  \"devDependencies\": {\n    \"@rjsf/core\": \"6.1.1\",\n    \"@rjsf/utils\": \"6.1.1\",\n    \"@rjsf/validator-ajv8\": \"6.1.1\",\n    \"@tailwindcss/container-queries\": \"^0.1.1\",\n    \"@tanstack/router-plugin\": \"^1.161.1\",\n    \"@types/lodash\": \"^4.17.20\",\n    \"@types/react\": \"^18.2.43\",\n    \"@types/react-dom\": \"^18.2.17\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.21.0\",\n    \"@typescript-eslint/parser\": \"^6.21.0\",\n    \"@vitejs/plugin-react\": \"^4.2.1\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"babel-plugin-react-compiler\": \"^1.0.0\",\n    \"eslint\": \"^8.55.0\",\n    \"eslint-config-prettier\": \"^10.1.5\",\n    \"eslint-plugin-check-file\": \"^2.8.0\",\n    \"eslint-plugin-deprecation\": \"^3.0.0\",\n    \"eslint-plugin-eslint-comments\": \"^3.2.0\",\n    \"eslint-plugin-i18next\": \"^6.1.3\",\n    \"eslint-plugin-prettier\": \"^5.5.0\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.5\",\n    \"eslint-plugin-unused-imports\": \"^4.1.4\",\n    \"postcss\": \"^8.4.32\",\n    \"prettier\": \"^3.6.1\",\n    \"tailwindcss\": \"^3.4.0\",\n    \"typescript\": \"^5.9.2\",\n    \"vite\": \"^7.3.1\"\n  }\n}\n"
  },
  {
    "path": "packages/local-web/postcss.config.cjs",
    "content": "module.exports = {\n  plugins: {\n    // No config specified - @config directives in CSS files take precedence\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "packages/local-web/src/app/entry/App.tsx",
    "content": "import { RouterProvider } from '@tanstack/react-router';\nimport { HotkeysProvider } from 'react-hotkeys-hook';\nimport { UserSystemProvider } from '@web/app/providers/ConfigProvider';\nimport { ClickedElementsProvider } from '@web/app/providers/ClickedElementsProvider';\nimport { localAppNavigation } from '@web/app/navigation/AppNavigation';\nimport { LocalAuthProvider } from '@/shared/providers/auth/LocalAuthProvider';\nimport { AppRuntimeProvider } from '@/shared/hooks/useAppRuntime';\nimport { AppNavigationProvider } from '@/shared/hooks/useAppNavigation';\nimport { useTauriNotificationNavigation } from '@web/app/hooks/useTauriNotificationNavigation';\nimport { useTauriUpdateReady } from '@web/app/hooks/useTauriUpdateReady';\nimport { AppSystemNotifications } from '@web/app/notifications/AppSystemNotifications';\nimport { router } from '@web/app/router';\n\nfunction TauriListeners() {\n  useTauriNotificationNavigation();\n  useTauriUpdateReady();\n  return null;\n}\n\nfunction App() {\n  return (\n    <AppRuntimeProvider runtime=\"local\">\n      <AppNavigationProvider value={localAppNavigation}>\n        <TauriListeners />\n        <UserSystemProvider>\n          <LocalAuthProvider>\n            <AppSystemNotifications />\n            <ClickedElementsProvider>\n              <HotkeysProvider\n                initiallyActiveScopes={[\n                  'global',\n                  'workspace',\n                  'kanban',\n                  'projects',\n                ]}\n              >\n                <RouterProvider router={router} />\n              </HotkeysProvider>\n            </ClickedElementsProvider>\n          </LocalAuthProvider>\n        </UserSystemProvider>\n      </AppNavigationProvider>\n    </AppRuntimeProvider>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "packages/local-web/src/app/entry/Bootstrap.tsx",
    "content": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport * as Sentry from '@sentry/react';\nimport { ClickToComponent } from 'click-to-react-component';\nimport { QueryClientProvider } from '@tanstack/react-query';\nimport posthog from 'posthog-js';\nimport { PostHogProvider } from 'posthog-js/react';\nimport App from '@web/app/entry/App';\nimport i18n from '@/i18n';\nimport { router } from '@web/app/router';\nimport { oauthApi } from '@/shared/lib/api';\nimport { tokenManager } from '@/shared/lib/auth/tokenManager';\nimport { configureAuthRuntime } from '@/shared/lib/auth/runtime';\nimport '@/shared/types/modals';\nimport { queryClient } from '@/shared/lib/queryClient';\nimport { isTauriApp } from '@/shared/lib/platform';\nimport { initZoom, zoomIn, zoomOut, zoomReset } from '@/shared/lib/zoom';\n\nif (import.meta.env.VITE_SENTRY_DSN) {\n  Sentry.init({\n    dsn: import.meta.env.VITE_SENTRY_DSN,\n    tracesSampleRate: 1.0,\n    environment: import.meta.env.MODE === 'development' ? 'dev' : 'production',\n    integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)],\n  });\n  Sentry.setTag('source', 'frontend');\n}\n\nif (\n  import.meta.env.VITE_POSTHOG_API_KEY &&\n  import.meta.env.VITE_POSTHOG_API_ENDPOINT\n) {\n  posthog.init(import.meta.env.VITE_POSTHOG_API_KEY, {\n    api_host: import.meta.env.VITE_POSTHOG_API_ENDPOINT,\n    capture_pageview: false,\n    capture_pageleave: true,\n    capture_performance: true,\n    autocapture: false,\n    opt_out_capturing_by_default: true,\n  });\n} else {\n  console.warn(\n    'PostHog API key or endpoint not set. Analytics will be disabled.'\n  );\n}\n\n// In the Tauri desktop app, implement custom zoom (Cmd/Ctrl + =/–/0) via root\n// font-size scaling and block trackpad/touchpad pinch-to-zoom.\nif (isTauriApp()) {\n  initZoom();\n\n  document.addEventListener('keydown', (e) => {\n    const mod = e.metaKey || e.ctrlKey;\n    if (!mod) return;\n\n    if (e.key === '=' || e.key === '+') {\n      e.preventDefault();\n      zoomIn();\n    } else if (e.key === '-') {\n      e.preventDefault();\n      zoomOut();\n    } else if (e.key === '0') {\n      e.preventDefault();\n      zoomReset();\n    }\n  });\n\n  document.addEventListener(\n    'wheel',\n    (e) => {\n      if (e.ctrlKey) e.preventDefault();\n    },\n    { passive: false }\n  );\n  document.addEventListener('gesturestart', (e) => e.preventDefault());\n  document.addEventListener('gesturechange', (e) => e.preventDefault());\n}\n\nconfigureAuthRuntime({\n  getToken: () => tokenManager.getToken(),\n  triggerRefresh: () => tokenManager.triggerRefresh(),\n  registerShape: (shape) => tokenManager.registerShape(shape),\n  getCurrentUser: () => oauthApi.getCurrentUser(),\n});\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <React.StrictMode>\n    <QueryClientProvider client={queryClient}>\n      <PostHogProvider client={posthog}>\n        <Sentry.ErrorBoundary\n          fallback={<p>{i18n.t('common:states.error')}</p>}\n          showDialog\n        >\n          <ClickToComponent />\n          <App />\n        </Sentry.ErrorBoundary>\n      </PostHogProvider>\n    </QueryClientProvider>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "packages/local-web/src/app/hooks/useTauriNotificationNavigation.ts",
    "content": "/**\n * Listens for `navigate-to-workspace` events emitted by the Tauri backend\n * when a notification fires.\n *\n * Auto-navigation is temporarily disabled — the user handles navigation\n * manually for now.\n */\nexport function useTauriNotificationNavigation() {\n  // noop — auto-navigation disabled for now\n}\n"
  },
  {
    "path": "packages/local-web/src/app/hooks/useTauriUpdateReady.ts",
    "content": "import { useEffect } from 'react';\nimport { isTauriApp } from '@/shared/lib/platform';\nimport { useAppUpdateStore } from '@/shared/stores/useAppUpdateStore';\n\n/**\n * Listens for the `update-installed` event emitted by the Tauri backend\n * after an update has been silently downloaded and applied. Sets the\n * shared update store so the AppBar can show a restart button.\n */\nexport function useTauriUpdateReady() {\n  const setUpdate = useAppUpdateStore((s) => s.setUpdate);\n\n  useEffect(() => {\n    if (!isTauriApp()) return;\n\n    let unlisten: (() => void) | undefined;\n\n    async function setup() {\n      const { listen, emit } = await import('@tauri-apps/api/event');\n\n      unlisten = await listen<{ newVersion: string }>(\n        'update-installed',\n        (event) => {\n          setUpdate(event.payload.newVersion, () => {\n            emit('restart-app');\n          });\n        }\n      );\n    }\n\n    setup();\n    return () => {\n      unlisten?.();\n    };\n  }, [setUpdate]);\n}\n"
  },
  {
    "path": "packages/local-web/src/app/navigation/AppNavigation.ts",
    "content": "import { router } from '@web/app/router';\nimport type { FileRouteTypes } from '@web/routeTree.gen';\nimport {\n  type AppDestination,\n  type AppNavigation,\n  type NavigationTransition,\n} from '@/shared/lib/routes/appNavigation';\n\ntype LocalRouteId = FileRouteTypes['id'];\n\nfunction getPathParam(\n  routeParams: Record<string, string>,\n  key: string\n): string | null {\n  const value = routeParams[key];\n  return value ? value : null;\n}\n\nfunction resolveLocalDestinationFromPath(path: string): AppDestination | null {\n  const { pathname } = new URL(path, 'http://localhost');\n  const { foundRoute, routeParams } = router.getMatchedRoutes(pathname);\n\n  if (!foundRoute) {\n    return null;\n  }\n\n  switch (foundRoute.id as LocalRouteId) {\n    case '/':\n      return { kind: 'root' };\n    case '/onboarding':\n      return { kind: 'onboarding' };\n    case '/onboarding_/sign-in':\n      return { kind: 'onboarding-sign-in' };\n    case '/_app/migrate':\n      return { kind: 'migrate' };\n    case '/_app/workspaces':\n      return { kind: 'workspaces' };\n    case '/_app/workspaces_/create':\n      return { kind: 'workspaces-create' };\n    case '/_app/workspaces_/$workspaceId': {\n      const workspaceId = getPathParam(routeParams, 'workspaceId');\n      return workspaceId ? { kind: 'workspace', workspaceId } : null;\n    }\n    case '/workspaces/$workspaceId/vscode': {\n      const workspaceId = getPathParam(routeParams, 'workspaceId');\n      return workspaceId ? { kind: 'workspace-vscode', workspaceId } : null;\n    }\n    case '/_app/projects/$projectId': {\n      const projectId = getPathParam(routeParams, 'projectId');\n      return projectId ? { kind: 'project', projectId } : null;\n    }\n    case '/_app/projects/$projectId_/issues/$issueId': {\n      const projectId = getPathParam(routeParams, 'projectId');\n      const issueId = getPathParam(routeParams, 'issueId');\n      return projectId && issueId\n        ? { kind: 'project-issue', projectId, issueId }\n        : null;\n    }\n    case '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId': {\n      const projectId = getPathParam(routeParams, 'projectId');\n      const issueId = getPathParam(routeParams, 'issueId');\n      const workspaceId = getPathParam(routeParams, 'workspaceId');\n      return projectId && issueId && workspaceId\n        ? {\n            kind: 'project-issue-workspace',\n            projectId,\n            issueId,\n            workspaceId,\n          }\n        : null;\n    }\n    case '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId': {\n      const projectId = getPathParam(routeParams, 'projectId');\n      const issueId = getPathParam(routeParams, 'issueId');\n      const draftId = getPathParam(routeParams, 'draftId');\n      return projectId && issueId && draftId\n        ? {\n            kind: 'project-issue-workspace-create',\n            projectId,\n            issueId,\n            draftId,\n          }\n        : null;\n    }\n    case '/_app/projects/$projectId_/workspaces/create/$draftId': {\n      const projectId = getPathParam(routeParams, 'projectId');\n      const draftId = getPathParam(routeParams, 'draftId');\n      return projectId && draftId\n        ? {\n            kind: 'project-workspace-create',\n            projectId,\n            draftId,\n          }\n        : null;\n    }\n    default:\n      return null;\n  }\n}\n\nfunction destinationToLocalTarget(destination: AppDestination) {\n  switch (destination.kind) {\n    case 'root':\n      return { to: '/' } as const;\n    case 'onboarding':\n      return { to: '/onboarding' } as const;\n    case 'onboarding-sign-in':\n      return { to: '/onboarding/sign-in' } as const;\n    case 'migrate':\n      return { to: '/migrate' } as const;\n    case 'workspaces':\n      return { to: '/workspaces' } as const;\n    case 'workspaces-create':\n      return { to: '/workspaces/create' } as const;\n    case 'workspace':\n      return {\n        to: '/workspaces/$workspaceId',\n        params: { workspaceId: destination.workspaceId },\n      } as const;\n    case 'workspace-vscode':\n      return {\n        to: '/workspaces/$workspaceId/vscode',\n        params: { workspaceId: destination.workspaceId },\n      } as const;\n    case 'project':\n      return {\n        to: '/projects/$projectId',\n        params: { projectId: destination.projectId },\n      } as const;\n    case 'project-issue':\n      return {\n        to: '/projects/$projectId/issues/$issueId',\n        params: {\n          projectId: destination.projectId,\n          issueId: destination.issueId,\n        },\n      } as const;\n    case 'project-issue-workspace':\n      return {\n        to: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId',\n        params: {\n          projectId: destination.projectId,\n          issueId: destination.issueId,\n          workspaceId: destination.workspaceId,\n        },\n      } as const;\n    case 'project-issue-workspace-create':\n      return {\n        to: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId',\n        params: {\n          projectId: destination.projectId,\n          issueId: destination.issueId,\n          draftId: destination.draftId,\n        },\n      } as const;\n    case 'project-workspace-create':\n      return {\n        to: '/projects/$projectId/workspaces/create/$draftId',\n        params: {\n          projectId: destination.projectId,\n          draftId: destination.draftId,\n        },\n      } as const;\n  }\n}\n\nexport function createLocalAppNavigation(): AppNavigation {\n  const navigateTo = (\n    destination: AppDestination,\n    transition?: NavigationTransition\n  ) => {\n    void router.navigate({\n      ...destinationToLocalTarget(destination),\n      ...(transition?.replace !== undefined\n        ? { replace: transition.replace }\n        : {}),\n    });\n  };\n\n  const navigation: AppNavigation = {\n    resolveFromPath: (path) => resolveLocalDestinationFromPath(path),\n    goToRoot: (transition) => navigateTo({ kind: 'root' }, transition),\n    goToOnboarding: (transition) =>\n      navigateTo({ kind: 'onboarding' }, transition),\n    goToOnboardingSignIn: (transition) =>\n      navigateTo({ kind: 'onboarding-sign-in' }, transition),\n    goToMigrate: (transition) => navigateTo({ kind: 'migrate' }, transition),\n    goToWorkspaces: (transition) =>\n      navigateTo({ kind: 'workspaces' }, transition),\n    goToWorkspacesCreate: (transition) =>\n      navigateTo({ kind: 'workspaces-create' }, transition),\n    goToWorkspace: (workspaceId, transition) =>\n      navigateTo({ kind: 'workspace', workspaceId }, transition),\n    goToWorkspaceVsCode: (workspaceId, transition) =>\n      navigateTo({ kind: 'workspace-vscode', workspaceId }, transition),\n    goToProject: (projectId, transition) =>\n      navigateTo({ kind: 'project', projectId }, transition),\n    goToProjectIssue: (projectId, issueId, transition) =>\n      navigateTo({ kind: 'project-issue', projectId, issueId }, transition),\n    goToProjectIssueWorkspace: (projectId, issueId, workspaceId, transition) =>\n      navigateTo(\n        { kind: 'project-issue-workspace', projectId, issueId, workspaceId },\n        transition\n      ),\n    goToProjectIssueWorkspaceCreate: (\n      projectId,\n      issueId,\n      draftId,\n      transition\n    ) =>\n      navigateTo(\n        { kind: 'project-issue-workspace-create', projectId, issueId, draftId },\n        transition\n      ),\n    goToProjectWorkspaceCreate: (projectId, draftId, transition) =>\n      navigateTo(\n        { kind: 'project-workspace-create', projectId, draftId },\n        transition\n      ),\n  };\n\n  return navigation;\n}\n\nexport const localAppNavigation = createLocalAppNavigation();\n"
  },
  {
    "path": "packages/local-web/src/app/notifications/AppSystemNotifications.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useNotificationMembers } from '@/shared/hooks/useNotificationMembers';\nimport { useNotifications } from '@/shared/hooks/useNotifications';\nimport { getGroupedNotificationText } from '@/shared/lib/notificationMessage';\nimport { showSystemNotification } from '@web/app/notifications/showSystemNotification';\n\nexport function AppSystemNotifications() {\n  const { userId } = useAuth();\n  const { data, enabled, groupedNotifications } = useNotifications();\n  const { membersByUserId, isLoading, isFetching } =\n    useNotificationMembers(data);\n  const displayedNotificationIdsRef = useRef(new Set<string>());\n  const initializedRef = useRef(false);\n\n  useEffect(() => {\n    displayedNotificationIdsRef.current.clear();\n    initializedRef.current = false;\n  }, [userId]);\n\n  useEffect(() => {\n    if (!enabled || isLoading || isFetching) {\n      return;\n    }\n\n    if (!initializedRef.current) {\n      for (const group of groupedNotifications) {\n        if (!group.seen) {\n          displayedNotificationIdsRef.current.add(group.id);\n        }\n      }\n      initializedRef.current = true;\n      return;\n    }\n\n    const activeGroupIds = new Set(\n      groupedNotifications.map((group) => group.id)\n    );\n    for (const id of displayedNotificationIdsRef.current) {\n      if (!activeGroupIds.has(id)) {\n        displayedNotificationIdsRef.current.delete(id);\n      }\n    }\n\n    for (const group of groupedNotifications) {\n      if (group.seen || displayedNotificationIdsRef.current.has(group.id)) {\n        continue;\n      }\n\n      displayedNotificationIdsRef.current.add(group.id);\n      void showSystemNotification({\n        id: group.id,\n        title: 'Vibe Kanban',\n        body: getGroupedNotificationText(group, membersByUserId),\n      });\n    }\n  }, [enabled, groupedNotifications, isFetching, isLoading, membersByUserId]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/local-web/src/app/notifications/showSystemNotification.ts",
    "content": "import { invoke } from '@tauri-apps/api/core';\nimport { isTauriApp } from '@/shared/lib/platform';\n\ninterface NotificationPayload {\n  id: string;\n  title: string;\n  body: string;\n}\n\nexport async function showSystemNotification(\n  notification: NotificationPayload\n): Promise<void> {\n  if (!isTauriApp()) {\n    return;\n  }\n\n  try {\n    await invoke('show_system_notification', {\n      title: notification.title,\n      body: notification.body,\n    });\n  } catch (error) {\n    console.error(\n      `Failed to show system notification for group ${notification.id}:`,\n      error\n    );\n  }\n}\n"
  },
  {
    "path": "packages/local-web/src/app/providers/ClickedElementsProvider.tsx",
    "content": "import { useContext, useState, ReactNode, useEffect, useCallback } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type {\n  OpenInEditorPayload,\n  ComponentInfo,\n  SelectedComponent,\n} from '@/shared/lib/previewBridge';\nimport type { Workspace } from 'shared/types';\nimport { genId } from '@/shared/lib/id';\n\nexport interface ClickedEntry {\n  id: string;\n  payload: OpenInEditorPayload;\n  timestamp: number;\n  dedupeKey: string;\n  selectedDepth?: number; // 0 = innermost (selected), 1 = parent, etc.\n}\n\ninterface ClickedElementsContextType {\n  elements: ClickedEntry[];\n  addElement: (payload: OpenInEditorPayload) => void;\n  removeElement: (id: string) => void;\n  clearElements: () => void;\n  selectComponent: (id: string, depthFromInner: number) => void;\n  generateMarkdown: () => string;\n}\n\nconst ClickedElementsContext =\n  createHmrContext<ClickedElementsContextType | null>(\n    'ClickedElementsContext',\n    null\n  );\n\nexport function useClickedElements() {\n  const context = useContext(ClickedElementsContext);\n  if (!context) {\n    throw new Error(\n      'useClickedElements must be used within a ClickedElementsProvider'\n    );\n  }\n  return context;\n}\n\ninterface ClickedElementsProviderProps {\n  children: ReactNode;\n  attempt?: Workspace | null;\n}\n\nconst MAX_ELEMENTS = 20;\n\n// Helpers\n\nfunction stripPrefixes(p?: string): string {\n  if (!p) return '';\n  return p\n    .replace(/^file:\\/\\//, '')\n    .replace(/^webpack:\\/\\/\\//, '')\n    .replace(/^webpack:\\/\\//, '')\n    .trim();\n}\n\n// macOS alias handling; no-ops on other OSes\nfunction normalizeMacPrivateAliases(p: string): string {\n  if (!p) return p;\n  // Very light normalization mimicking path.rs logic\n  if (p === '/private/var') return '/var';\n  if (p.startsWith('/private/var/'))\n    return '/var/' + p.slice('/private/var/'.length);\n  if (p === '/private/tmp') return '/tmp';\n  if (p.startsWith('/private/tmp/'))\n    return '/tmp/' + p.slice('/private/tmp/'.length);\n  return p;\n}\n\n// Return { path, line?, col? } where `path` has no trailing :line(:col).\n// Works even when Windows drive letters contain a colon.\nfunction parsePathWithLineCol(raw?: string): {\n  path: string;\n  line?: number;\n  col?: number;\n} {\n  const s = stripPrefixes(raw);\n  if (!s) return { path: '' };\n  const normalized = normalizeMacPrivateAliases(s);\n\n  // Try to split trailing :line(:col). Last and second-to-last tokens must be numbers.\n  const parts = normalized.split(':');\n  if (parts.length <= 2) return { path: normalized };\n\n  const last = parts[parts.length - 1];\n  const maybeCol = Number(last);\n  if (!Number.isFinite(maybeCol)) return { path: normalized };\n\n  const prev = parts[parts.length - 2];\n  const maybeLine = Number(prev);\n  if (!Number.isFinite(maybeLine)) return { path: normalized };\n\n  // Windows drive (e.g., \"C\") is at index 0; this still works because we only strip the end\n  const basePath = parts.slice(0, parts.length - 2).join(':');\n  return { path: basePath, line: maybeLine, col: maybeCol };\n}\n\nfunction relativizePath(p: string, workspaceRoot?: string): string {\n  if (!p) return '';\n  const normalized = normalizeMacPrivateAliases(stripPrefixes(p));\n\n  if (!workspaceRoot) return normalized;\n\n  // Simple prefix strip; robust handling is on backend (path.rs).\n  // This keeps the UI stable even when run inside macOS /private/var containers.\n  const wr = normalizeMacPrivateAliases(workspaceRoot.replace(/\\/+$/, ''));\n  if (\n    normalized.startsWith(wr.endsWith('/') ? wr : wr + '/') ||\n    normalized === wr\n  ) {\n    const rel = normalized.slice(wr.length);\n    return rel.startsWith('/') ? rel.slice(1) : rel || '.';\n  }\n  return normalized;\n}\n\nfunction formatLoc(path: string, line?: number, col?: number) {\n  if (!path) return '';\n  if (line == null) return path;\n  return `${path}:${line}${col != null ? `:${col}` : ''}`;\n}\n\nfunction formatDomBits(ce?: OpenInEditorPayload['clickedElement']) {\n  const bits: string[] = [];\n  if (ce?.tag) bits.push(ce.tag.toLowerCase());\n  if (ce?.id) bits.push(`#${ce.id}`);\n  const classes = normalizeClassName(ce?.className);\n  if (classes) bits.push(`.${classes}`);\n  if (ce?.role) bits.push(`@${ce.role}`);\n  return bits.join('') || '(unknown)';\n}\n\nfunction normalizeClassName(className?: string): string {\n  if (!className) return '';\n  return className.split(/\\s+/).filter(Boolean).sort().join('.');\n}\n\nfunction makeDedupeKey(\n  payload: OpenInEditorPayload,\n  workspaceRoot?: string\n): string {\n  const s = payload.selected;\n  const ce = payload.clickedElement;\n\n  const { path } = parsePathWithLineCol(s.pathToSource);\n  const rel = relativizePath(path, workspaceRoot);\n\n  const domBits: string[] = [];\n  if (ce?.tag) domBits.push(ce.tag.toLowerCase());\n  if (ce?.id) domBits.push(`#${ce.id}`);\n  const normalizedClasses = normalizeClassName(ce?.className);\n  if (normalizedClasses) domBits.push(`.${normalizedClasses}`);\n  if (ce?.role) domBits.push(`@${ce.role}`);\n\n  const locKey = [\n    rel,\n    s.source?.lineNumber ?? '',\n    s.source?.columnNumber ?? '',\n  ].join(':');\n  return `${s.name}|${locKey}|${domBits.join('')}`;\n}\n\n// Remove heavy or unsafe props while retaining debuggability\nfunction pruneValue(\n  value: unknown,\n  depth: number,\n  maxString = 200,\n  maxArray = 20\n): unknown {\n  if (depth <= 0) return '[MaxDepth]';\n\n  if (value == null) return value;\n  const t = typeof value;\n  if (t === 'string')\n    return (value as string).length > maxString\n      ? (value as string).slice(0, maxString) + '…'\n      : value;\n  if (t === 'number' || t === 'boolean') return value;\n  if (t === 'function') return '[Function]';\n  if (t === 'bigint') return value.toString() + 'n';\n  if (t === 'symbol') return value.toString();\n\n  if (Array.isArray(value)) {\n    const lim = (value as unknown[])\n      .slice(0, maxArray)\n      .map((v) => pruneValue(v, depth - 1, maxString, maxArray));\n    if ((value as unknown[]).length > maxArray)\n      lim.push(`[+${(value as unknown[]).length - maxArray} more]`);\n    return lim;\n  }\n\n  if (t === 'object') {\n    const obj = value as Record<string, unknown>;\n    const out: Record<string, unknown> = {};\n    let count = 0;\n    for (const k of Object.keys(obj)) {\n      // Cap keys to keep small\n      if (count++ > 50) {\n        out['[TruncatedKeys]'] = true;\n        break;\n      }\n      out[k] = pruneValue(obj[k], depth - 1, maxString, maxArray);\n    }\n    return out;\n  }\n\n  return '[Unknown]';\n}\n\nfunction stripHeavyProps(payload: OpenInEditorPayload): OpenInEditorPayload {\n  // Avoid mutating caller objects\n  const shallowSelected = {\n    ...payload.selected,\n    props: pruneValue(payload.selected.props, 2) as Record<string, unknown>,\n  };\n\n  const shallowComponents = payload.components.map((c) => ({\n    ...c,\n    props: pruneValue(c.props, 2) as Record<string, unknown>,\n  }));\n\n  // dataset and coords are typically small; keep as-is.\n  return {\n    ...payload,\n    selected: shallowSelected,\n    components: shallowComponents,\n  };\n}\n\n// Build component chain from inner-most to outer-most\nfunction buildChainInnerToOuter(\n  payload: OpenInEditorPayload,\n  workspaceRoot?: string\n) {\n  const comps = payload.components ?? [];\n  const s = payload.selected;\n\n  // Start with the selected component as innermost\n  const innerToOuter: (ComponentInfo | SelectedComponent)[] = [s];\n\n  // Add components that aren't duplicates of selected\n  const selectedKey = `${s.name}|${s.pathToSource}|${s.source?.lineNumber}|${s.source?.columnNumber}`;\n  comps.forEach((c) => {\n    const compKey = `${c.name}|${c.pathToSource}|${c.source?.lineNumber}|${c.source?.columnNumber}`;\n    if (compKey !== selectedKey) {\n      innerToOuter.push(c);\n    }\n  });\n\n  // Remove duplicates by creating unique keys\n  const seen = new Set<string>();\n  return innerToOuter.filter((c) => {\n    const parsed = parsePathWithLineCol(c.pathToSource);\n    const rel = relativizePath(parsed.path, workspaceRoot);\n    const loc = formatLoc(\n      rel,\n      c.source?.lineNumber ?? parsed.line,\n      c.source?.columnNumber ?? parsed.col\n    );\n    const key = `${c.name}|${loc}`;\n\n    if (seen.has(key)) {\n      return false;\n    }\n    seen.add(key);\n    return true;\n  });\n}\n\nfunction formatClickedMarkdown(\n  entry: ClickedEntry,\n  workspaceRoot?: string\n): string {\n  const { payload, selectedDepth = 0 } = entry;\n  const chain = buildChainInnerToOuter(payload, workspaceRoot);\n  const effectiveChain = chain.slice(selectedDepth); // Start from selected anchor outward\n\n  // DOM\n  const dom = formatDomBits(payload.clickedElement);\n\n  // Use first component in effective chain as the \"selected start\"\n  const first = effectiveChain[0];\n  const parsed = parsePathWithLineCol(first.pathToSource);\n  const rel = relativizePath(parsed.path, workspaceRoot);\n  const loc = formatLoc(\n    rel,\n    first.source?.lineNumber ?? parsed.line,\n    first.source?.columnNumber ?? parsed.col\n  );\n\n  // Build hierarchy from effective chain\n  const items = effectiveChain.map((c, i) => {\n    const p = parsePathWithLineCol(c.pathToSource);\n    const r = relativizePath(p.path, workspaceRoot);\n    const l = formatLoc(\n      r,\n      c.source?.lineNumber ?? p.line,\n      c.source?.columnNumber ?? p.col\n    );\n    const indent = '  '.repeat(i);\n    const arrow = i > 0 ? '└─ ' : '';\n    const tag = i === 0 ? ' ← start' : '';\n    return `${indent}${arrow}${c.name} (\\`${l || 'no source'}\\`)${tag}`;\n  });\n\n  return [\n    `From preview click:`,\n    `- DOM: ${dom}`,\n    `- Selected start: ${first.name} (${loc ? `\\`${loc}\\`` : 'no source'})`,\n    effectiveChain.length > 1\n      ? ['- Component hierarchy:', ...items].join('\\n')\n      : '',\n  ]\n    .filter(Boolean)\n    .join('\\n');\n}\n\nexport function ClickedElementsProvider({\n  children,\n  attempt,\n}: ClickedElementsProviderProps) {\n  const [elements, setElements] = useState<ClickedEntry[]>([]);\n  const workspaceRoot = attempt?.container_ref;\n\n  // Clear elements when attempt changes\n  useEffect(() => {\n    setElements([]);\n  }, [attempt?.id]);\n\n  const addElement = (payload: OpenInEditorPayload) => {\n    const sanitized = stripHeavyProps(payload);\n    const dedupeKey = makeDedupeKey(sanitized, workspaceRoot || undefined);\n\n    setElements((prev) => {\n      const last = prev[prev.length - 1];\n      if (last && last.dedupeKey === dedupeKey) {\n        return prev; // Skip consecutive duplicate\n      }\n      const newEntry: ClickedEntry = {\n        id: genId(),\n        payload: sanitized,\n        timestamp: Date.now(),\n        dedupeKey,\n      };\n      const updated = [...prev, newEntry];\n      return updated.length > MAX_ELEMENTS\n        ? updated.slice(-MAX_ELEMENTS)\n        : updated;\n    });\n  };\n\n  const removeElement = (id: string) => {\n    setElements((prev) => prev.filter((e) => e.id !== id));\n  };\n\n  const clearElements = () => {\n    setElements([]);\n  };\n\n  const selectComponent = (id: string, depthFromInner: number) => {\n    setElements((prev) =>\n      prev.map((e) =>\n        e.id === id ? { ...e, selectedDepth: depthFromInner } : e\n      )\n    );\n  };\n\n  const generateMarkdown = useCallback(() => {\n    if (elements.length === 0) return '';\n    const header = `## Clicked Elements (${elements.length})\\n\\n`;\n    const body = elements\n      .map((e) => formatClickedMarkdown(e, workspaceRoot || undefined))\n      .join('\\n\\n');\n    return header + body;\n  }, [elements, workspaceRoot]);\n\n  return (\n    <ClickedElementsContext.Provider\n      value={{\n        elements,\n        addElement,\n        removeElement,\n        clearElements,\n        selectComponent,\n        generateMarkdown,\n      }}\n    >\n      {children}\n    </ClickedElementsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/local-web/src/app/providers/ConfigProvider.tsx",
    "content": "import { ReactNode, useCallback, useEffect, useMemo } from 'react';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport {\n  type Config,\n  type Environment,\n  type UserSystemInfo,\n  type BaseAgentCapability,\n} from 'shared/types';\nimport type { ExecutorProfile } from 'shared/types';\nimport { configApi } from '@/shared/lib/api';\nimport { updateLanguageFromConfig } from '@/i18n/config';\nimport { setRemoteApiBase } from '@/shared/lib/remoteApi';\nimport {\n  UserSystemContext,\n  type UserSystemContextType,\n} from '@/shared/hooks/useUserSystem';\n\ninterface UserSystemProviderProps {\n  children: ReactNode;\n}\n\nexport function UserSystemProvider({ children }: UserSystemProviderProps) {\n  const queryClient = useQueryClient();\n\n  const { data: userSystemInfo, isLoading } = useQuery({\n    queryKey: ['user-system'],\n    queryFn: configApi.getConfig,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  const config = userSystemInfo?.config || null;\n  const appVersion = userSystemInfo?.version || null;\n  const environment = userSystemInfo?.environment || null;\n  const analyticsUserId = userSystemInfo?.analytics_user_id || null;\n  const loginStatus = userSystemInfo?.login_status || null;\n  const profiles =\n    (userSystemInfo?.executors as Record<string, ExecutorProfile> | null) ||\n    null;\n  const capabilities =\n    (userSystemInfo?.capabilities as Record<\n      string,\n      BaseAgentCapability[]\n    > | null) || null;\n\n  // Set runtime remote API base URL for self-hosting support.\n  // Must run during render (not in useEffect) so it's set before children mount.\n  setRemoteApiBase(userSystemInfo?.shared_api_base);\n\n  // Sync language with i18n when config changes\n  useEffect(() => {\n    if (config?.language) {\n      updateLanguageFromConfig(config.language);\n    }\n  }, [config?.language]);\n\n  const updateConfig = useCallback(\n    (updates: Partial<Config>) => {\n      queryClient.setQueryData<UserSystemInfo>(['user-system'], (old) => {\n        if (!old) return old;\n        return {\n          ...old,\n          config: { ...old.config, ...updates },\n        };\n      });\n    },\n    [queryClient]\n  );\n\n  const saveConfig = useCallback(async (): Promise<boolean> => {\n    if (!config) return false;\n    try {\n      await configApi.saveConfig(config);\n      return true;\n    } catch (err) {\n      console.error('Error saving config:', err);\n      return false;\n    }\n  }, [config]);\n\n  const updateAndSaveConfig = useCallback(\n    async (updates: Partial<Config>): Promise<boolean> => {\n      if (!config) return false;\n\n      const newConfig = { ...config, ...updates };\n      updateConfig(updates);\n\n      try {\n        const saved = await configApi.saveConfig(newConfig);\n        queryClient.setQueryData<UserSystemInfo>(['user-system'], (old) => {\n          if (!old) return old;\n          return {\n            ...old,\n            config: saved,\n          };\n        });\n        return true;\n      } catch (err) {\n        console.error('Error saving config:', err);\n        queryClient.invalidateQueries({ queryKey: ['user-system'] });\n        return false;\n      }\n    },\n    [config, queryClient, updateConfig]\n  );\n\n  const reloadSystem = useCallback(async () => {\n    await queryClient.invalidateQueries({ queryKey: ['user-system'] });\n  }, [queryClient]);\n\n  const setEnvironment = useCallback(\n    (env: Environment | null) => {\n      queryClient.setQueryData<UserSystemInfo>(['user-system'], (old) => {\n        if (!old || !env) return old;\n        return { ...old, environment: env };\n      });\n    },\n    [queryClient]\n  );\n\n  const setProfiles = useCallback(\n    (newProfiles: Record<string, ExecutorProfile> | null) => {\n      queryClient.setQueryData<UserSystemInfo>(['user-system'], (old) => {\n        if (!old || !newProfiles) return old;\n        return {\n          ...old,\n          executors: newProfiles as unknown as UserSystemInfo['executors'],\n        };\n      });\n    },\n    [queryClient]\n  );\n\n  const setCapabilities = useCallback(\n    (newCapabilities: Record<string, BaseAgentCapability[]> | null) => {\n      queryClient.setQueryData<UserSystemInfo>(['user-system'], (old) => {\n        if (!old || !newCapabilities) return old;\n        return { ...old, capabilities: newCapabilities };\n      });\n    },\n    [queryClient]\n  );\n\n  // Memoize context value to prevent unnecessary re-renders\n  const value = useMemo<UserSystemContextType>(\n    () => ({\n      system: {\n        appVersion,\n        config,\n        environment,\n        profiles,\n        capabilities,\n        analyticsUserId,\n        loginStatus,\n      },\n      appVersion,\n      config,\n      environment,\n      profiles,\n      capabilities,\n      analyticsUserId,\n      loginStatus,\n      updateConfig,\n      saveConfig,\n      updateAndSaveConfig,\n      setEnvironment,\n      setProfiles,\n      setCapabilities,\n      reloadSystem,\n      loading: isLoading,\n    }),\n    [\n      appVersion,\n      config,\n      environment,\n      profiles,\n      capabilities,\n      analyticsUserId,\n      loginStatus,\n      updateConfig,\n      saveConfig,\n      updateAndSaveConfig,\n      reloadSystem,\n      isLoading,\n      setEnvironment,\n      setProfiles,\n      setCapabilities,\n    ]\n  );\n\n  return (\n    <UserSystemContext.Provider value={value}>\n      {children}\n    </UserSystemContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/local-web/src/app/providers/ThemeProvider.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { ThemeMode } from 'shared/types';\nimport { ThemeProviderContext } from '@/shared/hooks/useTheme';\n\ntype ThemeProviderProps = {\n  children: React.ReactNode;\n  initialTheme?: ThemeMode;\n};\n\nexport function ThemeProvider({\n  children,\n  initialTheme = ThemeMode.SYSTEM,\n  ...props\n}: ThemeProviderProps) {\n  const [theme, setThemeState] = useState<ThemeMode>(initialTheme);\n\n  // Update theme when initialTheme changes\n  useEffect(() => {\n    setThemeState(initialTheme);\n  }, [initialTheme]);\n\n  useEffect(() => {\n    const root = window.document.documentElement;\n\n    root.classList.remove('light', 'dark');\n\n    if (theme === ThemeMode.SYSTEM) {\n      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')\n        .matches\n        ? 'dark'\n        : 'light';\n\n      root.classList.add(systemTheme);\n      return;\n    }\n\n    root.classList.add(theme.toLowerCase());\n  }, [theme]);\n\n  const setTheme = (newTheme: ThemeMode) => {\n    setThemeState(newTheme);\n  };\n\n  const value = {\n    theme,\n    setTheme,\n  };\n\n  return (\n    <ThemeProviderContext.Provider {...props} value={value}>\n      {children}\n    </ThemeProviderContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/local-web/src/app/router/index.ts",
    "content": "import { createRouter } from '@tanstack/react-router';\nimport { routeTree } from '@web/routeTree.gen';\n\nexport const router = createRouter({ routeTree });\n\ndeclare module '@tanstack/react-router' {\n  interface Register {\n    router: typeof router;\n  }\n}\n"
  },
  {
    "path": "packages/local-web/src/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\nimport { Route as rootRouteImport } from './routes/__root'\nimport { Route as OnboardingRouteImport } from './routes/onboarding'\nimport { Route as AppRouteImport } from './routes/_app'\nimport { Route as IndexRouteImport } from './routes/index'\nimport { Route as OnboardingSignInRouteImport } from './routes/onboarding_.sign-in'\nimport { Route as AppWorkspacesRouteImport } from './routes/_app.workspaces'\nimport { Route as AppNotificationsRouteImport } from './routes/_app.notifications'\nimport { Route as AppMigrateRouteImport } from './routes/_app.migrate'\nimport { Route as WorkspacesWorkspaceIdVscodeRouteImport } from './routes/workspaces.$workspaceId.vscode'\nimport { Route as AppWorkspacesElectricTestRouteImport } from './routes/_app.workspaces_.electric-test'\nimport { Route as AppWorkspacesCreateRouteImport } from './routes/_app.workspaces_.create'\nimport { Route as AppWorkspacesWorkspaceIdRouteImport } from './routes/_app.workspaces_.$workspaceId'\nimport { Route as AppProjectsProjectIdRouteImport } from './routes/_app.projects.$projectId'\nimport { Route as AppProjectsProjectIdIssuesIssueIdRouteImport } from './routes/_app.projects.$projectId_.issues.$issueId'\nimport { Route as AppProjectsProjectIdWorkspacesCreateDraftIdRouteImport } from './routes/_app.projects.$projectId_.workspaces.create.$draftId'\nimport { Route as AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRouteImport } from './routes/_app.projects.$projectId_.issues.$issueId_.workspaces.$workspaceId'\nimport { Route as AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRouteImport } from './routes/_app.projects.$projectId_.issues.$issueId_.workspaces.create.$draftId'\n\nconst OnboardingRoute = OnboardingRouteImport.update({\n  id: '/onboarding',\n  path: '/onboarding',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst AppRoute = AppRouteImport.update({\n  id: '/_app',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst IndexRoute = IndexRouteImport.update({\n  id: '/',\n  path: '/',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst OnboardingSignInRoute = OnboardingSignInRouteImport.update({\n  id: '/onboarding_/sign-in',\n  path: '/onboarding/sign-in',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst AppWorkspacesRoute = AppWorkspacesRouteImport.update({\n  id: '/workspaces',\n  path: '/workspaces',\n  getParentRoute: () => AppRoute,\n} as any)\nconst AppNotificationsRoute = AppNotificationsRouteImport.update({\n  id: '/notifications',\n  path: '/notifications',\n  getParentRoute: () => AppRoute,\n} as any)\nconst AppMigrateRoute = AppMigrateRouteImport.update({\n  id: '/migrate',\n  path: '/migrate',\n  getParentRoute: () => AppRoute,\n} as any)\nconst WorkspacesWorkspaceIdVscodeRoute =\n  WorkspacesWorkspaceIdVscodeRouteImport.update({\n    id: '/workspaces/$workspaceId/vscode',\n    path: '/workspaces/$workspaceId/vscode',\n    getParentRoute: () => rootRouteImport,\n  } as any)\nconst AppWorkspacesElectricTestRoute =\n  AppWorkspacesElectricTestRouteImport.update({\n    id: '/workspaces_/electric-test',\n    path: '/workspaces/electric-test',\n    getParentRoute: () => AppRoute,\n  } as any)\nconst AppWorkspacesCreateRoute = AppWorkspacesCreateRouteImport.update({\n  id: '/workspaces_/create',\n  path: '/workspaces/create',\n  getParentRoute: () => AppRoute,\n} as any)\nconst AppWorkspacesWorkspaceIdRoute =\n  AppWorkspacesWorkspaceIdRouteImport.update({\n    id: '/workspaces_/$workspaceId',\n    path: '/workspaces/$workspaceId',\n    getParentRoute: () => AppRoute,\n  } as any)\nconst AppProjectsProjectIdRoute = AppProjectsProjectIdRouteImport.update({\n  id: '/projects/$projectId',\n  path: '/projects/$projectId',\n  getParentRoute: () => AppRoute,\n} as any)\nconst AppProjectsProjectIdIssuesIssueIdRoute =\n  AppProjectsProjectIdIssuesIssueIdRouteImport.update({\n    id: '/projects/$projectId_/issues/$issueId',\n    path: '/projects/$projectId/issues/$issueId',\n    getParentRoute: () => AppRoute,\n  } as any)\nconst AppProjectsProjectIdWorkspacesCreateDraftIdRoute =\n  AppProjectsProjectIdWorkspacesCreateDraftIdRouteImport.update({\n    id: '/projects/$projectId_/workspaces/create/$draftId',\n    path: '/projects/$projectId/workspaces/create/$draftId',\n    getParentRoute: () => AppRoute,\n  } as any)\nconst AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute =\n  AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRouteImport.update({\n    id: '/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId',\n    path: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId',\n    getParentRoute: () => AppRoute,\n  } as any)\nconst AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute =\n  AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRouteImport.update({\n    id: '/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId',\n    path: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId',\n    getParentRoute: () => AppRoute,\n  } as any)\n\nexport interface FileRoutesByFullPath {\n  '/': typeof IndexRoute\n  '/onboarding': typeof OnboardingRoute\n  '/migrate': typeof AppMigrateRoute\n  '/notifications': typeof AppNotificationsRoute\n  '/workspaces': typeof AppWorkspacesRoute\n  '/onboarding/sign-in': typeof OnboardingSignInRoute\n  '/projects/$projectId': typeof AppProjectsProjectIdRoute\n  '/workspaces/$workspaceId': typeof AppWorkspacesWorkspaceIdRoute\n  '/workspaces/create': typeof AppWorkspacesCreateRoute\n  '/workspaces/electric-test': typeof AppWorkspacesElectricTestRoute\n  '/workspaces/$workspaceId/vscode': typeof WorkspacesWorkspaceIdVscodeRoute\n  '/projects/$projectId/issues/$issueId': typeof AppProjectsProjectIdIssuesIssueIdRoute\n  '/projects/$projectId/workspaces/create/$draftId': typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute\n  '/projects/$projectId/issues/$issueId/workspaces/$workspaceId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute\n  '/projects/$projectId/issues/$issueId/workspaces/create/$draftId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute\n}\nexport interface FileRoutesByTo {\n  '/': typeof IndexRoute\n  '/onboarding': typeof OnboardingRoute\n  '/migrate': typeof AppMigrateRoute\n  '/notifications': typeof AppNotificationsRoute\n  '/workspaces': typeof AppWorkspacesRoute\n  '/onboarding/sign-in': typeof OnboardingSignInRoute\n  '/projects/$projectId': typeof AppProjectsProjectIdRoute\n  '/workspaces/$workspaceId': typeof AppWorkspacesWorkspaceIdRoute\n  '/workspaces/create': typeof AppWorkspacesCreateRoute\n  '/workspaces/electric-test': typeof AppWorkspacesElectricTestRoute\n  '/workspaces/$workspaceId/vscode': typeof WorkspacesWorkspaceIdVscodeRoute\n  '/projects/$projectId/issues/$issueId': typeof AppProjectsProjectIdIssuesIssueIdRoute\n  '/projects/$projectId/workspaces/create/$draftId': typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute\n  '/projects/$projectId/issues/$issueId/workspaces/$workspaceId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute\n  '/projects/$projectId/issues/$issueId/workspaces/create/$draftId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute\n}\nexport interface FileRoutesById {\n  __root__: typeof rootRouteImport\n  '/': typeof IndexRoute\n  '/_app': typeof AppRouteWithChildren\n  '/onboarding': typeof OnboardingRoute\n  '/_app/migrate': typeof AppMigrateRoute\n  '/_app/notifications': typeof AppNotificationsRoute\n  '/_app/workspaces': typeof AppWorkspacesRoute\n  '/onboarding_/sign-in': typeof OnboardingSignInRoute\n  '/_app/projects/$projectId': typeof AppProjectsProjectIdRoute\n  '/_app/workspaces_/$workspaceId': typeof AppWorkspacesWorkspaceIdRoute\n  '/_app/workspaces_/create': typeof AppWorkspacesCreateRoute\n  '/_app/workspaces_/electric-test': typeof AppWorkspacesElectricTestRoute\n  '/workspaces/$workspaceId/vscode': typeof WorkspacesWorkspaceIdVscodeRoute\n  '/_app/projects/$projectId_/issues/$issueId': typeof AppProjectsProjectIdIssuesIssueIdRoute\n  '/_app/projects/$projectId_/workspaces/create/$draftId': typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute\n  '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute\n  '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute\n}\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath\n  fullPaths:\n    | '/'\n    | '/onboarding'\n    | '/migrate'\n    | '/notifications'\n    | '/workspaces'\n    | '/onboarding/sign-in'\n    | '/projects/$projectId'\n    | '/workspaces/$workspaceId'\n    | '/workspaces/create'\n    | '/workspaces/electric-test'\n    | '/workspaces/$workspaceId/vscode'\n    | '/projects/$projectId/issues/$issueId'\n    | '/projects/$projectId/workspaces/create/$draftId'\n    | '/projects/$projectId/issues/$issueId/workspaces/$workspaceId'\n    | '/projects/$projectId/issues/$issueId/workspaces/create/$draftId'\n  fileRoutesByTo: FileRoutesByTo\n  to:\n    | '/'\n    | '/onboarding'\n    | '/migrate'\n    | '/notifications'\n    | '/workspaces'\n    | '/onboarding/sign-in'\n    | '/projects/$projectId'\n    | '/workspaces/$workspaceId'\n    | '/workspaces/create'\n    | '/workspaces/electric-test'\n    | '/workspaces/$workspaceId/vscode'\n    | '/projects/$projectId/issues/$issueId'\n    | '/projects/$projectId/workspaces/create/$draftId'\n    | '/projects/$projectId/issues/$issueId/workspaces/$workspaceId'\n    | '/projects/$projectId/issues/$issueId/workspaces/create/$draftId'\n  id:\n    | '__root__'\n    | '/'\n    | '/_app'\n    | '/onboarding'\n    | '/_app/migrate'\n    | '/_app/notifications'\n    | '/_app/workspaces'\n    | '/onboarding_/sign-in'\n    | '/_app/projects/$projectId'\n    | '/_app/workspaces_/$workspaceId'\n    | '/_app/workspaces_/create'\n    | '/_app/workspaces_/electric-test'\n    | '/workspaces/$workspaceId/vscode'\n    | '/_app/projects/$projectId_/issues/$issueId'\n    | '/_app/projects/$projectId_/workspaces/create/$draftId'\n    | '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId'\n    | '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId'\n  fileRoutesById: FileRoutesById\n}\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute\n  AppRoute: typeof AppRouteWithChildren\n  OnboardingRoute: typeof OnboardingRoute\n  OnboardingSignInRoute: typeof OnboardingSignInRoute\n  WorkspacesWorkspaceIdVscodeRoute: typeof WorkspacesWorkspaceIdVscodeRoute\n}\n\ndeclare module '@tanstack/react-router' {\n  interface FileRoutesByPath {\n    '/onboarding': {\n      id: '/onboarding'\n      path: '/onboarding'\n      fullPath: '/onboarding'\n      preLoaderRoute: typeof OnboardingRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/_app': {\n      id: '/_app'\n      path: ''\n      fullPath: '/'\n      preLoaderRoute: typeof AppRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/': {\n      id: '/'\n      path: '/'\n      fullPath: '/'\n      preLoaderRoute: typeof IndexRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/onboarding_/sign-in': {\n      id: '/onboarding_/sign-in'\n      path: '/onboarding/sign-in'\n      fullPath: '/onboarding/sign-in'\n      preLoaderRoute: typeof OnboardingSignInRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/_app/workspaces': {\n      id: '/_app/workspaces'\n      path: '/workspaces'\n      fullPath: '/workspaces'\n      preLoaderRoute: typeof AppWorkspacesRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/notifications': {\n      id: '/_app/notifications'\n      path: '/notifications'\n      fullPath: '/notifications'\n      preLoaderRoute: typeof AppNotificationsRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/migrate': {\n      id: '/_app/migrate'\n      path: '/migrate'\n      fullPath: '/migrate'\n      preLoaderRoute: typeof AppMigrateRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/workspaces/$workspaceId/vscode': {\n      id: '/workspaces/$workspaceId/vscode'\n      path: '/workspaces/$workspaceId/vscode'\n      fullPath: '/workspaces/$workspaceId/vscode'\n      preLoaderRoute: typeof WorkspacesWorkspaceIdVscodeRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/_app/workspaces_/electric-test': {\n      id: '/_app/workspaces_/electric-test'\n      path: '/workspaces/electric-test'\n      fullPath: '/workspaces/electric-test'\n      preLoaderRoute: typeof AppWorkspacesElectricTestRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/workspaces_/create': {\n      id: '/_app/workspaces_/create'\n      path: '/workspaces/create'\n      fullPath: '/workspaces/create'\n      preLoaderRoute: typeof AppWorkspacesCreateRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/workspaces_/$workspaceId': {\n      id: '/_app/workspaces_/$workspaceId'\n      path: '/workspaces/$workspaceId'\n      fullPath: '/workspaces/$workspaceId'\n      preLoaderRoute: typeof AppWorkspacesWorkspaceIdRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/projects/$projectId': {\n      id: '/_app/projects/$projectId'\n      path: '/projects/$projectId'\n      fullPath: '/projects/$projectId'\n      preLoaderRoute: typeof AppProjectsProjectIdRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/projects/$projectId_/issues/$issueId': {\n      id: '/_app/projects/$projectId_/issues/$issueId'\n      path: '/projects/$projectId/issues/$issueId'\n      fullPath: '/projects/$projectId/issues/$issueId'\n      preLoaderRoute: typeof AppProjectsProjectIdIssuesIssueIdRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/projects/$projectId_/workspaces/create/$draftId': {\n      id: '/_app/projects/$projectId_/workspaces/create/$draftId'\n      path: '/projects/$projectId/workspaces/create/$draftId'\n      fullPath: '/projects/$projectId/workspaces/create/$draftId'\n      preLoaderRoute: typeof AppProjectsProjectIdWorkspacesCreateDraftIdRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId': {\n      id: '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId'\n      path: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId'\n      fullPath: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId'\n      preLoaderRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRouteImport\n      parentRoute: typeof AppRoute\n    }\n    '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId': {\n      id: '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId'\n      path: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId'\n      fullPath: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId'\n      preLoaderRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRouteImport\n      parentRoute: typeof AppRoute\n    }\n  }\n}\n\ninterface AppRouteChildren {\n  AppMigrateRoute: typeof AppMigrateRoute\n  AppNotificationsRoute: typeof AppNotificationsRoute\n  AppWorkspacesRoute: typeof AppWorkspacesRoute\n  AppProjectsProjectIdRoute: typeof AppProjectsProjectIdRoute\n  AppWorkspacesWorkspaceIdRoute: typeof AppWorkspacesWorkspaceIdRoute\n  AppWorkspacesCreateRoute: typeof AppWorkspacesCreateRoute\n  AppWorkspacesElectricTestRoute: typeof AppWorkspacesElectricTestRoute\n  AppProjectsProjectIdIssuesIssueIdRoute: typeof AppProjectsProjectIdIssuesIssueIdRoute\n  AppProjectsProjectIdWorkspacesCreateDraftIdRoute: typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute\n  AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute\n  AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute\n}\n\nconst AppRouteChildren: AppRouteChildren = {\n  AppMigrateRoute: AppMigrateRoute,\n  AppNotificationsRoute: AppNotificationsRoute,\n  AppWorkspacesRoute: AppWorkspacesRoute,\n  AppProjectsProjectIdRoute: AppProjectsProjectIdRoute,\n  AppWorkspacesWorkspaceIdRoute: AppWorkspacesWorkspaceIdRoute,\n  AppWorkspacesCreateRoute: AppWorkspacesCreateRoute,\n  AppWorkspacesElectricTestRoute: AppWorkspacesElectricTestRoute,\n  AppProjectsProjectIdIssuesIssueIdRoute:\n    AppProjectsProjectIdIssuesIssueIdRoute,\n  AppProjectsProjectIdWorkspacesCreateDraftIdRoute:\n    AppProjectsProjectIdWorkspacesCreateDraftIdRoute,\n  AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute:\n    AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute,\n  AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute:\n    AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute,\n}\n\nconst AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  AppRoute: AppRouteWithChildren,\n  OnboardingRoute: OnboardingRoute,\n  OnboardingSignInRoute: OnboardingSignInRoute,\n  WorkspacesWorkspaceIdVscodeRoute: WorkspacesWorkspaceIdVscodeRoute,\n}\nexport const routeTree = rootRouteImport\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>()\n"
  },
  {
    "path": "packages/local-web/src/routes/__root.tsx",
    "content": "import { useEffect, type ReactNode } from 'react';\nimport { Outlet, createRootRoute, useLocation } from '@tanstack/react-router';\nimport { I18nextProvider } from 'react-i18next';\nimport { usePostHog } from 'posthog-js/react';\nimport { Provider as NiceModalProvider } from '@ebay/nice-modal-react';\nimport { ThemeMode } from 'shared/types';\nimport i18n from '@/i18n';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { ThemeProvider } from '@web/app/providers/ThemeProvider';\nimport { useUiPreferencesScratch } from '@/shared/hooks/useUiPreferencesScratch';\nimport { ReleaseNotesDialog } from '@/shared/dialogs/global/ReleaseNotesDialog';\nimport { WorkspaceProvider } from '@/shared/providers/WorkspaceProvider';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { ExecutionProcessesProvider } from '@/shared/providers/ExecutionProcessesProvider';\nimport { LogsPanelProvider } from '@/shared/providers/LogsPanelProvider';\nimport { ActionsProvider } from '@/shared/providers/ActionsProvider';\nimport { UserProvider } from '@/shared/providers/remote/UserProvider';\nimport '@/app/styles/new/index.css';\n\nfunction ExecutionProcessesProviderWrapper({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const { selectedSessionId } = useWorkspaceContext();\n  return (\n    <ExecutionProcessesProvider sessionId={selectedSessionId}>\n      {children}\n    </ExecutionProcessesProvider>\n  );\n}\n\nfunction RootRouteComponent() {\n  const { config, analyticsUserId, updateAndSaveConfig } = useUserSystem();\n  const posthog = usePostHog();\n  const location = useLocation();\n\n  useUiPreferencesScratch();\n\n  useEffect(() => {\n    if (!posthog || !analyticsUserId) return;\n\n    if (config?.analytics_enabled) {\n      posthog.opt_in_capturing();\n      posthog.identify(analyticsUserId);\n      console.log('[Analytics] Analytics enabled and user identified');\n    } else {\n      posthog.opt_out_capturing();\n      console.log('[Analytics] Analytics disabled by user preference');\n    }\n  }, [config?.analytics_enabled, analyticsUserId, posthog]);\n\n  useEffect(() => {\n    if (!config || !config.remote_onboarding_acknowledged) return;\n\n    const pathname = location.pathname;\n    if (pathname.startsWith('/onboarding') || pathname.startsWith('/migrate')) {\n      return;\n    }\n\n    let cancelled = false;\n\n    const showReleaseNotes = async () => {\n      if (config.show_release_notes) {\n        await ReleaseNotesDialog.show();\n        if (!cancelled) {\n          await updateAndSaveConfig({ show_release_notes: false });\n        }\n        ReleaseNotesDialog.hide();\n      }\n    };\n\n    void showReleaseNotes();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [config, updateAndSaveConfig, location.pathname]);\n\n  return (\n    <I18nextProvider i18n={i18n}>\n      <ThemeProvider initialTheme={config?.theme || ThemeMode.SYSTEM}>\n        <WorkspaceProvider>\n          <ExecutionProcessesProviderWrapper>\n            <LogsPanelProvider>\n              <UserProvider>\n                <ActionsProvider>\n                  <NiceModalProvider>\n                    <Outlet />\n                  </NiceModalProvider>\n                </ActionsProvider>\n              </UserProvider>\n            </LogsPanelProvider>\n          </ExecutionProcessesProviderWrapper>\n        </WorkspaceProvider>\n      </ThemeProvider>\n    </I18nextProvider>\n  );\n}\n\nexport const Route = createRootRoute({\n  component: RootRouteComponent,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.migrate.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { MigratePage } from '@/pages/migrate/MigratePage';\n\nexport const Route = createFileRoute('/_app/migrate')({\n  component: MigratePage,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.notifications.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { NotificationsPage } from '@/pages/workspaces/NotificationsPage';\n\nexport const Route = createFileRoute('/_app/notifications')({\n  component: NotificationsPage,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.projects.$projectId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban';\nimport { projectSearchValidator } from '@vibe/web-core/project-search';\n\nexport const Route = createFileRoute('/_app/projects/$projectId')({\n  validateSearch: projectSearchValidator,\n  component: LocalProjectKanban,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.projects.$projectId_.issues.$issueId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban';\nimport { projectSearchValidator } from '@vibe/web-core/project-search';\n\nexport const Route = createFileRoute(\n  '/_app/projects/$projectId_/issues/$issueId'\n)({\n  validateSearch: projectSearchValidator,\n  component: LocalProjectKanban,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.projects.$projectId_.issues.$issueId_.workspaces.$workspaceId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban';\nimport { projectSearchValidator } from '@vibe/web-core/project-search';\n\nexport const Route = createFileRoute(\n  '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId'\n)({\n  validateSearch: projectSearchValidator,\n  component: LocalProjectKanban,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.projects.$projectId_.issues.$issueId_.workspaces.create.$draftId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban';\nimport { projectSearchValidator } from '@vibe/web-core/project-search';\n\nexport const Route = createFileRoute(\n  '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId'\n)({\n  validateSearch: projectSearchValidator,\n  component: LocalProjectKanban,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.projects.$projectId_.workspaces.create.$draftId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban';\nimport { projectSearchValidator } from '@vibe/web-core/project-search';\n\nexport const Route = createFileRoute(\n  '/_app/projects/$projectId_/workspaces/create/$draftId'\n)({\n  validateSearch: projectSearchValidator,\n  component: LocalProjectKanban,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { SequenceTrackerProvider } from '@/shared/keyboard/SequenceTracker';\nimport { SequenceIndicator } from '@/shared/keyboard/SequenceIndicator';\nimport { useWorkspaceShortcuts } from '@/shared/keyboard/useWorkspaceShortcuts';\nimport { useIssueShortcuts } from '@/shared/keyboard/useIssueShortcuts';\nimport { useKeyShowHelp, Scope } from '@/shared/keyboard';\nimport { KeyboardShortcutsDialog } from '@/shared/dialogs/shared/KeyboardShortcutsDialog';\nimport { TerminalProvider } from '@/shared/providers/TerminalProvider';\nimport { SharedAppLayout } from '@/shared/components/ui-new/containers/SharedAppLayout';\n\nfunction KeyboardShortcutsHandler() {\n  useKeyShowHelp(\n    () => {\n      KeyboardShortcutsDialog.show();\n    },\n    { scope: Scope.GLOBAL }\n  );\n  useWorkspaceShortcuts();\n  useIssueShortcuts();\n  return null;\n}\n\nfunction AppLayoutRouteComponent() {\n  return (\n    <SequenceTrackerProvider>\n      <SequenceIndicator />\n      <KeyboardShortcutsHandler />\n      <TerminalProvider>\n        <SharedAppLayout />\n      </TerminalProvider>\n    </SequenceTrackerProvider>\n  );\n}\n\nexport const Route = createFileRoute('/_app')({\n  component: AppLayoutRouteComponent,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.workspaces.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { WorkspacesLanding } from '@/pages/workspaces/WorkspacesLanding';\n\nexport const Route = createFileRoute('/_app/workspaces')({\n  component: WorkspacesLanding,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.workspaces_.$workspaceId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { Workspaces } from '@/pages/workspaces/Workspaces';\n\nexport const Route = createFileRoute('/_app/workspaces_/$workspaceId')({\n  component: Workspaces,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.workspaces_.create.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { Workspaces } from '@/pages/workspaces/Workspaces';\n\nexport const Route = createFileRoute('/_app/workspaces_/create')({\n  component: Workspaces,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/_app.workspaces_.electric-test.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { ElectricTestPage } from '@/pages/workspaces/ElectricTestPage';\n\nexport const Route = createFileRoute('/_app/workspaces_/electric-test')({\n  component: ElectricTestPage,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/index.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { RootRedirectPage } from '@/pages/root/RootRedirectPage';\n\nfunction RootRedirectRouteComponent() {\n  return <RootRedirectPage />;\n}\n\nexport const Route = createFileRoute('/')({\n  component: RootRedirectRouteComponent,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/onboarding.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { LandingPage } from '@/features/onboarding/ui/LandingPage';\n\nfunction OnboardingLandingRouteComponent() {\n  return <LandingPage />;\n}\n\nexport const Route = createFileRoute('/onboarding')({\n  component: OnboardingLandingRouteComponent,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/onboarding_.sign-in.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { OnboardingSignInPage } from '@/features/onboarding/ui/OnboardingSignInPage';\n\nfunction OnboardingSignInRouteComponent() {\n  return <OnboardingSignInPage />;\n}\n\nexport const Route = createFileRoute('/onboarding_/sign-in')({\n  component: OnboardingSignInRouteComponent,\n});\n"
  },
  {
    "path": "packages/local-web/src/routes/workspaces.$workspaceId.vscode.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { TerminalProvider } from '@/shared/providers/TerminalProvider';\nimport { VSCodeWorkspacePage } from '@/pages/workspaces/VSCodeWorkspacePage';\n\nfunction VSCodeWorkspaceRouteComponent() {\n  return (\n    <TerminalProvider>\n      <VSCodeWorkspacePage />\n    </TerminalProvider>\n  );\n}\n\nexport const Route = createFileRoute('/workspaces/$workspaceId/vscode')({\n  component: VSCodeWorkspaceRouteComponent,\n});\n"
  },
  {
    "path": "packages/local-web/src/shared/types/virtual-executor-schemas.d.ts",
    "content": "declare module 'virtual:executor-schemas' {\n  import type { RJSFSchema } from '@rjsf/utils';\n  import type { BaseCodingAgent } from 'shared/types';\n\n  const schemas: Record<BaseCodingAgent, RJSFSchema>;\n  export { schemas };\n  export default schemas;\n}\n"
  },
  {
    "path": "packages/local-web/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ndeclare const __APP_VERSION__: string;\n"
  },
  {
    "path": "packages/local-web/tailwind.new.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\n\nconst sizes = {\n  '2xs': 0.5,\n  xs: 0.75,\n  sm: 0.875,\n  base: 1,\n  lg: 1.125,\n  xl: 1.25,\n}\n\nconst lineHeightMultiplier = 1.5;\nconst radiusMultiplier = 0.25;\nconst iconMultiplier = 1.25;\nconst chatMaxWidth = '48rem';\n\nfunction getSize(sizeLabel, multiplier = 1) {\n\n  return sizes[sizeLabel] * multiplier + \"rem\";\n}\n\nmodule.exports = {\n  darkMode: [\"class\"],\n  important: false,\n  content: [\n    './pages/**/*.{ts,tsx}',\n    './components/**/*.{ts,tsx}',\n    './app/**/*.{ts,tsx}',\n    './src/**/*.{ts,tsx}',\n    '../web-core/src/**/*.{ts,tsx}',\n    '../remote-web/src/**/*.{ts,tsx}',\n    '../ui/src/**/*.{ts,tsx}',\n    \"node_modules/@rjsf/shadcn/src/**/*.{js,ts,jsx,tsx,mdx}\"\n  ],\n  safelist: [\n    'xl:hidden',\n    'xl:relative',\n    'xl:inset-auto',\n    'xl:z-auto',\n    'xl:h-full',\n    'xl:w-[800px]',\n    'xl:flex',\n    'xl:flex-1',\n    'xl:min-w-0',\n    'xl:overflow-y-auto',\n    'xl:opacity-100',\n    'xl:pointer-events-auto',\n  ],\n  prefix: \"\",\n  theme: {\n    container: {\n      center: true,\n      padding: \"2rem\",\n      screens: {\n        \"2xl\": \"1400px\",\n      },\n    },\n    extend: {\n      height: {\n        'cta': '29px',\n      },\n      minHeight: {\n        'cta': '29px',\n      },\n      width: {\n        chat: chatMaxWidth,\n      },\n      containers: {\n        chat: chatMaxWidth,\n      },\n      size: {\n        'icon-2xs': getSize('2xs', iconMultiplier),\n        'icon-xs': getSize('xs', iconMultiplier),\n        'icon-sm': getSize('sm', iconMultiplier),\n        'icon-base': getSize('base', iconMultiplier),\n        'icon-lg': getSize('lg', iconMultiplier),\n        'icon-xl': getSize('xl', iconMultiplier),\n        'dot': '0.3rem', // 6px - for animated indicator dots\n      },\n      backgroundImage: {\n        'diagonal-lines': `\n          repeating-linear-gradient(-45deg, hsl(var(--text-low) / 0.4) 0 2px, transparent 1px 12px),\n          linear-gradient(hsl(var(--bg-primary)), hsl(var(--bg-primary)))\n        `,\n      },\n      ringColor: {\n        DEFAULT: 'hsl(var(--brand))',\n      },\n      fontSize: {\n        xs: [getSize('xs'), { lineHeight: getSize('xs', lineHeightMultiplier) }],      // 8px\n        sm: [getSize('sm'), { lineHeight: getSize('sm', lineHeightMultiplier) }],   // 10px\n        base: [getSize('base'), { lineHeight: getSize('base', lineHeightMultiplier) }],  // 12px (base)\n        lg: [getSize('lg'), { lineHeight: getSize('lg', lineHeightMultiplier) }],    // 14px\n        xl: [getSize('xl'), { lineHeight: getSize('xl', lineHeightMultiplier) }],         // 16px\n        cta: [getSize('base'), { lineHeight: getSize('base') }],         // 16px\n      },\n      spacing: {\n        'half': getSize('base', 0.25),\n        'base': getSize('base', 0.5),\n        'plusfifty': getSize('base', 0.75),\n        'double': getSize('base', 1),\n      },\n      colors: {\n        // Text colors: text-high, text-normal, text-low\n        high: \"hsl(var(--text-high))\",\n        normal: \"hsl(var(--text-normal))\",\n        low: \"hsl(var(--text-low))\",\n        // Background colors: bg-primary, bg-secondary, bg-panel\n        primary: \"hsl(var(--bg-primary))\",\n        secondary: \"hsl(var(--bg-secondary))\",\n        panel: \"hsl(var(--bg-panel))\",\n        // Accent colors\n        brand: \"hsl(var(--brand))\",\n        'brand-hover': \"hsl(var(--brand-hover))\",\n        'brand-secondary': \"hsl(var(--brand-secondary))\",\n        error: \"hsl(var(--error))\",\n        success: \"hsl(var(--success))\",\n        merged: \"hsl(var(--merged))\",\n        // Text on accent\n        'on-brand': \"hsl(var(--text-on-brand))\",\n        // shadcn-style colors (used by @apply in CSS base layer)\n        background: \"hsl(var(--bg-primary))\",\n        foreground: \"hsl(var(--text-normal))\",\n        border: \"hsl(var(--border))\",\n      },\n      borderColor: {\n        DEFAULT: \"hsl(var(--border))\",\n        border: \"hsl(var(--border))\",\n      },\n      borderRadius: {\n        lg: getSize('lg', radiusMultiplier),\n        md: getSize('sm', radiusMultiplier),\n        sm: getSize('xs', radiusMultiplier),\n      },\n      borderWidth: {\n        base: getSize('base'),\n        half: getSize('base', 0.5),\n      },\n      fontFamily: {\n        'ibm-plex-sans': ['\"IBM Plex Sans\"', '\"Noto Emoji\"', 'sans-serif'],\n        'ibm-plex-mono': ['\"IBM Plex Mono\"', 'monospace'],\n      },\n      keyframes: {\n        \"accordion-down\": {\n          from: { height: \"0\" },\n          to: { height: \"var(--radix-accordion-content-height)\" },\n        },\n        \"accordion-up\": {\n          from: { height: \"var(--radix-accordion-content-height)\" },\n          to: { height: \"0\" },\n        },\n        pill: {\n          '0%': { opacity: '0' },\n          '10%': { opacity: '1' },\n          '80%': { opacity: '1' },\n          '100%': { opacity: '0' },\n        },\n        'running-dot': {\n          '0%, 100%': { opacity: '0.3' },\n          '50%': { opacity: '1' },\n        },\n        'border-flash': {\n          '0%': { backgroundPosition: '-200% 0' },\n          '100%': { backgroundPosition: '200% 0' },\n        },\n        shake: {\n          '0%, 100%': { transform: 'translateX(0)' },\n          '10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-2px)' },\n          '20%, 40%, 60%, 80%': { transform: 'translateX(2px)' },\n        },\n      },\n      animation: {\n        \"accordion-down\": \"accordion-down 0.2s ease-out\",\n        \"accordion-up\": \"accordion-up 0.2s ease-out\",\n        pill: 'pill 2s ease-in-out forwards',\n        'running-dot-1': 'running-dot 1.4s ease-in-out infinite',\n        'running-dot-2': 'running-dot 1.4s ease-in-out 0.2s infinite',\n        'running-dot-3': 'running-dot 1.4s ease-in-out 0.4s infinite',\n        'border-flash': 'border-flash 2s linear infinite',\n        shake: 'shake 0.3s ease-in-out',\n      },\n    },\n  },\n  plugins: [require(\"tailwindcss-animate\"), require(\"@tailwindcss/container-queries\"), require(\"tailwind-scrollbar\")({ nocompatible: true })],\n}\n"
  },
  {
    "path": "packages/local-web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@web/*\": [\"./src/*\"],\n      \"@/*\": [\"../web-core/src/*\"],\n      \"shared/*\": [\"../../shared/*\"]\n    }\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "packages/local-web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "packages/local-web/vite.config.ts",
    "content": "// vite.config.ts\nimport { sentryVitePlugin } from \"@sentry/vite-plugin\";\nimport { createLogger, defineConfig, Plugin } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { tanstackRouter } from \"@tanstack/router-plugin/vite\";\nimport path from \"path\";\nimport fs from \"fs\";\nimport pkg from \"./package.json\";\n\nfunction createFilteredLogger() {\n  const logger = createLogger();\n  const originalError = logger.error.bind(logger);\n\n  let lastRestartLog = 0;\n  const DEBOUNCE_MS = 2000;\n\n  logger.error = (msg, options) => {\n    const isProxyError =\n      msg.includes(\"ws proxy socket error\") ||\n      msg.includes(\"ws proxy error:\") ||\n      msg.includes(\"http proxy error:\");\n\n    if (isProxyError) {\n      const now = Date.now();\n      if (now - lastRestartLog > DEBOUNCE_MS) {\n        logger.warn(\"Proxy connection closed, auto-reconnecting...\");\n        lastRestartLog = now;\n      }\n      return;\n    }\n    originalError(msg, options);\n  };\n\n  return logger;\n}\n\nfunction executorSchemasPlugin(): Plugin {\n  const VIRTUAL_ID = 'virtual:executor-schemas';\n  const RESOLVED_VIRTUAL_ID = '\\0' + VIRTUAL_ID;\n\n  return {\n    name: 'executor-schemas-plugin',\n    resolveId(id) {\n      if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID; // keep it virtual\n      return null;\n    },\n    load(id) {\n      if (id !== RESOLVED_VIRTUAL_ID) return null;\n\n      const schemasDir = path.resolve(__dirname, '../../shared/schemas');\n      const files = fs.existsSync(schemasDir)\n        ? fs.readdirSync(schemasDir).filter((f) => f.endsWith('.json'))\n        : [];\n\n      const imports: string[] = [];\n      const entries: string[] = [];\n\n      files.forEach((file, i) => {\n        const varName = `__schema_${i}`;\n        const importPath = `shared/schemas/${file}`; // uses your alias\n        const key = file.replace(/\\.json$/, '').toUpperCase(); // claude_code -> CLAUDE_CODE\n        imports.push(`import ${varName} from \"${importPath}\";`);\n        entries.push(`  \"${key}\": ${varName}`);\n      });\n\n      // IMPORTANT: pure JS (no TS types), and quote keys.\n      const code = `\n${imports.join('\\n')}\n\nexport const schemas = {\n${entries.join(',\\n')}\n};\n\nexport default schemas;\n`;\n      return code;\n    },\n  };\n}\n\nexport default defineConfig({\n  customLogger: createFilteredLogger(),\n  publicDir: path.resolve(__dirname, '../public'),\n  define: {\n    __APP_VERSION__: JSON.stringify(pkg.version),\n  },\n  plugins: [\n    tanstackRouter({\n      target: \"react\",\n      autoCodeSplitting: false,\n    }),\n    react({\n      babel: {\n        plugins: [\n          [\n            'babel-plugin-react-compiler',\n            {\n              target: '18',\n              sources: [\n                path.resolve(__dirname, 'src'),\n                path.resolve(__dirname, '../web-core/src'),\n              ],\n              environment: {\n                enableResetCacheOnSourceFileChanges: true,\n              },\n            },\n          ],\n        ],\n      },\n    }),\n    sentryVitePlugin({ org: 'bloop-ai', project: 'vibe-kanban' }),\n    executorSchemasPlugin(),\n  ],\n  resolve: {\n    alias: [\n      {\n        find: '@web',\n        replacement: path.resolve(__dirname, 'src'),\n      },\n      {\n        find: /^@\\//,\n        replacement: `${path.resolve(__dirname, '../web-core/src')}/`,\n      },\n      {\n        find: 'shared',\n        replacement: path.resolve(__dirname, '../../shared'),\n      },\n    ],\n  },\n  server: {\n    port: parseInt(process.env.FRONTEND_PORT || '3000'),\n    proxy: {\n      '/api': {\n        target: `http://localhost:${process.env.BACKEND_PORT || '3001'}`,\n        changeOrigin: true,\n        ws: true,\n      },\n    },\n    fs: {\n      allow: [path.resolve(__dirname, '.'), path.resolve(__dirname, '../..')],\n    },\n    open: process.env.VITE_OPEN === 'true',\n    allowedHosts: [\n      '.trycloudflare.com', // allow all cloudflared tunnels\n    ],\n  },\n  optimizeDeps: {\n    exclude: ['wa-sqlite'],\n  },\n  build: { sourcemap: true },\n});\n"
  },
  {
    "path": "packages/public/robots.txt",
    "content": "User-agent: *\nDisallow: /\n"
  },
  {
    "path": "packages/public/site.webmanifest",
    "content": "{\n  \"name\": \"Vibe Kanban\",\n  \"short_name\": \"VK\",\n  \"icons\": [\n    {\n      \"src\": \"/favicon-vk-light.svg\",\n      \"sizes\": \"any\",\n      \"purpose\": \"any\",\n      \"type\": \"image/svg+xml\"\n    },\n    {\n      \"src\": \"/favicon-vk-light-maskable.svg\",\n      \"sizes\": \"any\",\n      \"purpose\": \"maskable\",\n      \"type\": \"image/svg+xml\"\n    },\n    {\n      \"src\": \"/apple-touch-icon.png\",\n      \"sizes\": \"180x180\",\n      \"type\": \"image/png\"\n    }\n  ],\n  \"theme_color\": \"#f2f2f2\",\n  \"background_color\": \"#f2f2f2\",\n  \"display\": \"standalone\"\n}"
  },
  {
    "path": "packages/remote-web/.prettierignore",
    "content": "src/routeTree.gen.ts\n"
  },
  {
    "path": "packages/remote-web/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"robots\" content=\"noindex, nofollow\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/favicon.png\" />\n    <title>Vibe Kanban Remote</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/app/entry/Bootstrap.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "packages/remote-web/package.json",
    "content": "{\n  \"name\": \"@vibe/remote-web\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"check\": \"tsc --noEmit\",\n    \"preview\": \"vite preview\",\n    \"format\": \"prettier --write \\\"src/**/*.{ts,tsx,js,jsx,json,css,md}\\\"\",\n    \"format:check\": \"prettier --check \\\"src/**/*.{ts,tsx,js,jsx,json,css,md}\\\"\",\n    \"migrate:structure\": \"node ../../scripts/migrate-remote-web-structure.mjs --apply\",\n    \"migrate:structure:dry-run\": \"node ../../scripts/migrate-remote-web-structure.mjs\"\n  },\n  \"dependencies\": {\n    \"@ebay/nice-modal-react\": \"^1.2.13\",\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"@tanstack/react-query\": \"^5.85.5\",\n    \"@tanstack/react-router\": \"^1.161.1\",\n    \"@tanstack/zod-adapter\": \"^1.161.1\",\n    \"@vibe/ui\": \"workspace:*\",\n    \"@vibe/web-core\": \"workspace:*\",\n    \"clsx\": \"^2.1.1\",\n    \"posthog-js\": \"^1.283.0\",\n    \"prettier\": \"^3.6.1\",\n    \"react\": \"^18.2.0\",\n    \"react-hotkeys-hook\": \"^5.1.0\",\n    \"react-compiler-runtime\": \"^1.0.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"simple-icons\": \"^15.16.0\",\n    \"tailwind-merge\": \"^2.6.0\",\n    \"zod\": \"^3.25.76\",\n    \"zustand\": \"^4.5.7\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/container-queries\": \"^0.1.1\",\n    \"@tanstack/router-plugin\": \"^1.161.1\",\n    \"@types/react\": \"^18.2.43\",\n    \"@types/react-dom\": \"^18.2.17\",\n    \"@vitejs/plugin-react\": \"^4.2.1\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"babel-plugin-react-compiler\": \"^1.0.0\",\n    \"postcss\": \"^8.4.32\",\n    \"tailwind-scrollbar\": \"^3.1.0\",\n    \"tailwindcss\": \"^3.4.0\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"typescript\": \"^5.9.2\",\n    \"vite\": \"^7.3.1\"\n  }\n}\n"
  },
  {
    "path": "packages/remote-web/postcss.config.cjs",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "packages/remote-web/src/app/entry/App.tsx",
    "content": "import { RouterProvider } from \"@tanstack/react-router\";\nimport { HotkeysProvider } from \"react-hotkeys-hook\";\nimport { router } from \"@remote/app/router\";\nimport { AppRuntimeProvider } from \"@/shared/hooks/useAppRuntime\";\n\nexport function AppRouter() {\n  return (\n    <AppRuntimeProvider runtime=\"remote\">\n      <HotkeysProvider\n        initiallyActiveScopes={[\"global\", \"workspace\", \"kanban\", \"projects\"]}\n      >\n        <RouterProvider router={router} />\n      </HotkeysProvider>\n    </AppRuntimeProvider>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/entry/Bootstrap.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { QueryClientProvider } from \"@tanstack/react-query\";\nimport posthog from \"posthog-js\";\nimport { PostHogProvider } from \"posthog-js/react\";\nimport { AppRouter } from \"@remote/app/entry/App\";\nimport { RemoteAuthProvider } from \"@remote/app/providers/RemoteAuthProvider\";\nimport { getIdentity } from \"@remote/shared/lib/api\";\nimport { getToken, triggerRefresh } from \"@remote/shared/lib/auth/tokenManager\";\nimport \"@remote/app/styles/index.css\";\nimport \"@/i18n\";\nimport { configureAuthRuntime } from \"@/shared/lib/auth/runtime\";\nimport { setRemoteApiBase } from \"@/shared/lib/remoteApi\";\nimport { setRelayApiBase } from \"@/shared/lib/relayBackendApi\";\nimport { setLocalApiTransport } from \"@/shared/lib/localApiTransport\";\nimport \"@/shared/types/modals\";\nimport { queryClient } from \"@/shared/lib/queryClient\";\nimport {\n  openLocalApiWebSocketViaRelay,\n  requestLocalApiViaRelay,\n} from \"@remote/shared/lib/relayHostApi\";\n\nif (import.meta.env.VITE_PUBLIC_POSTHOG_KEY) {\n  posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {\n    api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,\n  });\n}\n\nsetRemoteApiBase(import.meta.env.VITE_API_BASE_URL || window.location.origin);\nsetRelayApiBase(\n  import.meta.env.VITE_RELAY_API_BASE_URL ||\n    import.meta.env.VITE_API_BASE_URL ||\n    window.location.origin,\n);\nsetLocalApiTransport({\n  request: requestLocalApiViaRelay,\n  openWebSocket: openLocalApiWebSocketViaRelay,\n});\n\nconfigureAuthRuntime({\n  getToken,\n  triggerRefresh,\n  registerShape: () => () => {},\n  getCurrentUser: async () => {\n    const identity = await getIdentity();\n    return { user_id: identity.user_id };\n  },\n});\n\nReactDOM.createRoot(document.getElementById(\"root\")!).render(\n  <React.StrictMode>\n    <QueryClientProvider client={queryClient}>\n      <PostHogProvider client={posthog}>\n        <RemoteAuthProvider>\n          <AppRouter />\n        </RemoteAuthProvider>\n      </PostHogProvider>\n    </QueryClientProvider>\n  </React.StrictMode>,\n);\n"
  },
  {
    "path": "packages/remote-web/src/app/layout/RemoteAppBarUserPopoverContainer.tsx",
    "content": "import { useCallback, useState } from \"react\";\nimport { useLocation, useNavigate } from \"@tanstack/react-router\";\nimport type { OrganizationWithRole } from \"shared/types\";\nimport { AppBarUserPopover } from \"@vibe/ui/components/AppBarUserPopover\";\nimport { logout } from \"@remote/shared/lib/api\";\nimport { SettingsDialog } from \"@/shared/dialogs/settings/SettingsDialog\";\nimport { useAuth } from \"@/shared/hooks/auth/useAuth\";\nimport { useUserSystem } from \"@/shared/hooks/useUserSystem\";\nimport { REMOTE_SETTINGS_SECTIONS } from \"@remote/shared/constants/settings\";\n\ninterface RemoteAppBarUserPopoverContainerProps {\n  organizations: OrganizationWithRole[];\n  selectedOrgId: string;\n  onOrgSelect: (orgId: string) => void;\n  onCreateOrg: () => void;\n}\n\nfunction toNextPath({\n  pathname,\n  searchStr,\n  hash,\n}: Pick<ReturnType<typeof useLocation>, \"pathname\" | \"searchStr\" | \"hash\">) {\n  return `${pathname}${searchStr}${hash}`;\n}\n\nexport function RemoteAppBarUserPopoverContainer({\n  organizations,\n  selectedOrgId,\n  onOrgSelect,\n  onCreateOrg,\n}: RemoteAppBarUserPopoverContainerProps) {\n  const { isSignedIn } = useAuth();\n  const { loginStatus } = useUserSystem();\n\n  // Extract avatar URL from first provider (matches local-web behavior)\n  const avatarUrl =\n    loginStatus?.status === \"loggedin\"\n      ? (loginStatus.profile.providers[0]?.avatar_url ?? null)\n      : null;\n  const navigate = useNavigate();\n  const location = useLocation();\n  const [open, setOpen] = useState(false);\n  const [avatarError, setAvatarError] = useState(false);\n\n  const handleSignIn = useCallback(() => {\n    const next = toNextPath(location);\n\n    navigate({\n      to: \"/account\",\n      search: next !== \"/\" ? { next } : undefined,\n    });\n  }, [location, navigate]);\n\n  const handleLogout = useCallback(async () => {\n    try {\n      await logout();\n    } catch (error) {\n      console.error(\"Failed to log out in remote web:\", error);\n    }\n\n    navigate({\n      to: \"/account\",\n      replace: true,\n    });\n  }, [navigate]);\n\n  const handleOrgSettings = useCallback(\n    async (orgId: string) => {\n      onOrgSelect(orgId);\n      await SettingsDialog.show({\n        initialSection: \"organizations\",\n        initialState: { organizationId: orgId },\n        sections: REMOTE_SETTINGS_SECTIONS,\n      });\n    },\n    [onOrgSelect],\n  );\n\n  const handleSettings = useCallback(async () => {\n    setOpen(false);\n    await SettingsDialog.show({\n      sections: REMOTE_SETTINGS_SECTIONS,\n    });\n  }, []);\n\n  return (\n    <AppBarUserPopover\n      isSignedIn={isSignedIn}\n      avatarUrl={avatarUrl}\n      avatarError={avatarError}\n      organizations={organizations}\n      selectedOrgId={selectedOrgId}\n      open={open}\n      onOpenChange={setOpen}\n      onOrgSelect={onOrgSelect}\n      onCreateOrg={onCreateOrg}\n      onOrgSettings={(orgId) => {\n        void handleOrgSettings(orgId);\n      }}\n      onSignIn={handleSignIn}\n      onLogout={() => {\n        void handleLogout();\n      }}\n      onAvatarError={() => setAvatarError(true)}\n      onSettings={() => {\n        void handleSettings();\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/layout/RemoteAppShell.tsx",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { useLocation, useNavigate, useParams } from \"@tanstack/react-router\";\nimport { siDiscord, siGithub } from \"simple-icons\";\nimport { AppBar, type AppBarHostStatus } from \"@vibe/ui/components/AppBar\";\nimport { XIcon, PlusIcon, HouseIcon, KanbanIcon } from \"@phosphor-icons/react\";\nimport { MobileDrawer } from \"@vibe/ui/components/MobileDrawer\";\nimport type { Project } from \"shared/remote-types\";\nimport { useIsMobile } from \"@/shared/hooks/useIsMobile\";\nimport { cn } from \"@/shared/lib/utils\";\nimport { useUserOrganizations } from \"@/shared/hooks/useUserOrganizations\";\nimport { useAuth } from \"@/shared/hooks/auth/useAuth\";\nimport { useOrganizationStore } from \"@/shared/stores/useOrganizationStore\";\nimport { useDiscordOnlineCount } from \"@/shared/hooks/useDiscordOnlineCount\";\nimport { useGitHubStars } from \"@/shared/hooks/useGitHubStars\";\nimport { AppBarNotificationBellContainer } from \"@/pages/workspaces/AppBarNotificationBellContainer\";\nimport { SettingsDialog } from \"@/shared/dialogs/settings/SettingsDialog\";\nimport { CommandBarDialog } from \"@/shared/dialogs/command-bar/CommandBarDialog\";\nimport { useCommandBarShortcut } from \"@/shared/hooks/useCommandBarShortcut\";\nimport { listOrganizationProjects } from \"@remote/shared/lib/api\";\nimport { RemoteAppBarUserPopoverContainer } from \"@remote/app/layout/RemoteAppBarUserPopoverContainer\";\nimport { RemoteNavbarContainer } from \"@remote/app/layout/RemoteNavbarContainer\";\nimport { RemoteDesktopNavbar } from \"@remote/app/layout/RemoteDesktopNavbar\";\nimport {\n  resolveRelayNavigationHostId,\n  useRelayAppBarHosts,\n} from \"@remote/shared/hooks/useRelayAppBarHosts\";\nimport { REMOTE_SETTINGS_SECTIONS } from \"@remote/shared/constants/settings\";\nimport {\n  CreateOrganizationDialog,\n  type CreateOrganizationResult,\n} from \"@/shared/dialogs/org/CreateOrganizationDialog\";\nimport {\n  CreateRemoteProjectDialog,\n  type CreateRemoteProjectResult,\n} from \"@/shared/dialogs/org/CreateRemoteProjectDialog\";\n\ninterface RemoteAppShellProps {\n  children: ReactNode;\n}\n\nfunction getHostInitials(name: string): string {\n  const trimmed = name.trim();\n  if (!trimmed) return \"??\";\n  const words = trimmed.split(/\\s+/);\n  if (words.length >= 2) {\n    return (words[0][0] + words[1][0]).toUpperCase();\n  }\n  return trimmed.slice(0, 2).toUpperCase();\n}\n\nexport function RemoteAppShell({ children }: RemoteAppShellProps) {\n  const navigate = useNavigate();\n  const location = useLocation();\n  const { hostId: routeHostId } = useParams({ strict: false });\n  const { isSignedIn } = useAuth();\n  const isWorkspaceContextRoute = location.pathname.includes(\"/workspaces\");\n  const isProjectRoute = /^\\/projects\\/[^/]+/.test(location.pathname);\n\n  useCommandBarShortcut(\n    () => CommandBarDialog.show(),\n    isWorkspaceContextRoute || isProjectRoute,\n  );\n  const isMobile = useIsMobile();\n  const [isDrawerOpen, setIsDrawerOpen] = useState(false);\n\n  const { data: organizationsData } = useUserOrganizations();\n  const organizations = organizationsData?.organizations ?? [];\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n\n  useEffect(() => {\n    if (organizations.length === 0) {\n      return;\n    }\n\n    const hasValidSelection = selectedOrgId\n      ? organizations.some((organization) => organization.id === selectedOrgId)\n      : false;\n\n    if (!hasValidSelection) {\n      const firstOrg = organizations.find(\n        (organization) => !organization.is_personal,\n      );\n      setSelectedOrgId((firstOrg ?? organizations[0]).id);\n    }\n  }, [organizations, selectedOrgId, setSelectedOrgId]);\n\n  const activeOrganizationId = useMemo(() => {\n    if (!selectedOrgId) {\n      return organizations[0]?.id ?? null;\n    }\n\n    const isSelectedOrgAvailable = organizations.some(\n      (organization) => organization.id === selectedOrgId,\n    );\n\n    if (!isSelectedOrgAvailable) {\n      return organizations[0]?.id ?? null;\n    }\n\n    return selectedOrgId;\n  }, [organizations, selectedOrgId]);\n\n  const projectsQuery = useQuery({\n    queryKey: [\"remote-app-shell\", \"projects\", activeOrganizationId],\n    queryFn: async (): Promise<Project[]> => {\n      if (!activeOrganizationId) {\n        return [];\n      }\n\n      const projects = await listOrganizationProjects(activeOrganizationId);\n      return [...projects].sort((a, b) => a.sort_order - b.sort_order);\n    },\n    enabled: isSignedIn && !!activeOrganizationId,\n    staleTime: 30_000,\n  });\n\n  const projects = projectsQuery.data ?? [];\n  const isLoadingProjects =\n    isSignedIn && !!activeOrganizationId && projectsQuery.isLoading;\n\n  const { data: onlineCount } = useDiscordOnlineCount();\n  const { data: starCount } = useGitHubStars();\n  const { hosts: relayHosts } = useRelayAppBarHosts(isSignedIn);\n\n  const selectedOrgName =\n    organizations.find((organization) => organization.id === selectedOrgId)\n      ?.name ?? null;\n\n  const isWorkspacesActive = location.pathname.includes(\"/workspaces\");\n  const activeHostId = routeHostId ?? null;\n  const preferredHostId = useMemo(\n    () => resolveRelayNavigationHostId(relayHosts, { routeHostId }),\n    [relayHosts, routeHostId],\n  );\n\n  const activeProjectId = useMemo(() => {\n    const segments = location.pathname.split(\"/\").filter(Boolean);\n    const projectSegmentIndex = segments.indexOf(\"projects\");\n    if (projectSegmentIndex === -1) {\n      return null;\n    }\n\n    return segments[projectSegmentIndex + 1] ?? null;\n  }, [location.pathname]);\n\n  const openRelaySettings = useCallback((hostId?: string) => {\n    void SettingsDialog.show({\n      initialSection: \"relay\",\n      ...(hostId ? { initialState: { hostId } } : {}),\n      sections: REMOTE_SETTINGS_SECTIONS,\n    });\n  }, []);\n\n  const handleWorkspacesClick = useCallback(() => {\n    if (preferredHostId) {\n      navigate({\n        to: \"/hosts/$hostId/workspaces\",\n        params: { hostId: preferredHostId },\n      });\n      return;\n    }\n\n    openRelaySettings();\n  }, [navigate, openRelaySettings, preferredHostId]);\n\n  const handleProjectClick = useCallback(\n    (projectId: string) => {\n      navigate({\n        to: \"/projects/$projectId\",\n        params: { projectId },\n      });\n    },\n    [navigate],\n  );\n\n  const handleCreateProject = useCallback(async () => {\n    if (!activeOrganizationId) {\n      return;\n    }\n\n    try {\n      const result: CreateRemoteProjectResult =\n        await CreateRemoteProjectDialog.show({\n          organizationId: activeOrganizationId,\n        });\n\n      if (result.action === \"created\" && result.project) {\n        void projectsQuery.refetch();\n        navigate({\n          to: \"/projects/$projectId\",\n          params: {\n            projectId: result.project.id,\n          },\n        });\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  }, [activeOrganizationId, navigate, projectsQuery]);\n\n  const handleCreateOrg = useCallback(async () => {\n    try {\n      const result: CreateOrganizationResult =\n        await CreateOrganizationDialog.show();\n\n      if (result.action === \"created\" && result.organizationId) {\n        setSelectedOrgId(result.organizationId);\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  }, [setSelectedOrgId]);\n\n  const handleHostClick = useCallback(\n    (hostId: string, status: AppBarHostStatus) => {\n      if (status === \"online\") {\n        navigate({\n          to: \"/hosts/$hostId/workspaces\",\n          params: { hostId },\n        });\n        return;\n      }\n\n      if (status !== \"unpaired\") {\n        return;\n      }\n\n      openRelaySettings(hostId);\n    },\n    [navigate, openRelaySettings],\n  );\n\n  const handlePairHostClick = useCallback(() => {\n    openRelaySettings();\n  }, [openRelaySettings]);\n\n  const mobileUserSlot = useMemo(() => {\n    if (!isMobile) return undefined;\n    return (\n      <RemoteAppBarUserPopoverContainer\n        organizations={organizations}\n        selectedOrgId={selectedOrgId ?? \"\"}\n        onOrgSelect={setSelectedOrgId}\n        onCreateOrg={handleCreateOrg}\n      />\n    );\n  }, [\n    isMobile,\n    organizations,\n    selectedOrgId,\n    setSelectedOrgId,\n    handleCreateOrg,\n  ]);\n\n  return (\n    <div\n      className={cn(\n        \"flex bg-primary\",\n        isMobile\n          ? \"fixed inset-0 pb-[env(safe-area-inset-bottom)]\"\n          : \"h-screen\",\n      )}\n    >\n      {!isMobile && (\n        <AppBar\n          projects={projects}\n          hosts={relayHosts}\n          hostsLabel=\"HOSTS\"\n          projectsLabel=\"BOARDS\"\n          onPairHostClick={isSignedIn ? handlePairHostClick : undefined}\n          activeHostId={activeHostId}\n          onCreateProject={handleCreateProject}\n          onWorkspacesClick={handleWorkspacesClick}\n          onHostClick={handleHostClick}\n          showWorkspacesButton={false}\n          onProjectClick={handleProjectClick}\n          onProjectsDragEnd={() => {}}\n          isSavingProjectOrder={true}\n          isWorkspacesActive={isWorkspacesActive}\n          activeProjectId={activeProjectId}\n          isSignedIn={isSignedIn}\n          isLoadingProjects={isLoadingProjects}\n          onSignIn={() => {\n            navigate({ to: \"/account\" });\n          }}\n          notificationBell={\n            isSignedIn ? <AppBarNotificationBellContainer /> : undefined\n          }\n          userPopover={\n            <RemoteAppBarUserPopoverContainer\n              organizations={organizations}\n              selectedOrgId={selectedOrgId ?? \"\"}\n              onOrgSelect={setSelectedOrgId}\n              onCreateOrg={handleCreateOrg}\n            />\n          }\n          starCount={starCount}\n          onlineCount={onlineCount}\n          githubIconPath={siGithub.path}\n          discordIconPath={siDiscord.path}\n        />\n      )}\n\n      <MobileDrawer\n        open={isDrawerOpen && isMobile}\n        onClose={() => setIsDrawerOpen(false)}\n      >\n        <div className=\"flex flex-col h-full\">\n          {/* Header: org name + close button */}\n          <div className=\"flex items-center justify-between p-4 border-b border-border\">\n            <span className=\"text-sm font-medium text-high truncate\">\n              {selectedOrgName ?? \"Organization\"}\n            </span>\n            <button\n              type=\"button\"\n              onClick={() => setIsDrawerOpen(false)}\n              className=\"p-1 rounded-sm text-low hover:text-normal cursor-pointer\"\n            >\n              <XIcon className=\"h-4 w-4\" weight=\"bold\" />\n            </button>\n          </div>\n\n          {/* Home link */}\n          <button\n            type=\"button\"\n            onClick={() => {\n              navigate({ to: \"/\" });\n              setIsDrawerOpen(false);\n            }}\n            className=\"flex items-center gap-2 px-4 py-3 text-sm text-normal hover:bg-secondary cursor-pointer\"\n          >\n            <HouseIcon className=\"h-4 w-4\" />\n            Home\n          </button>\n\n          {/* Divider */}\n          <div className=\"mx-3 border-t border-border\" />\n\n          {/* Hosts section */}\n          {isSignedIn && relayHosts.length > 0 && (\n            <>\n              <p className=\"px-4 pt-3 pb-1 text-xs font-medium uppercase tracking-wide text-low\">\n                Hosts\n              </p>\n              <div className=\"px-2\">\n                {relayHosts.map((host) => {\n                  const isOnline = host.status === \"online\";\n                  const isUnpaired = host.status === \"unpaired\";\n                  const isClickable = isOnline || isUnpaired;\n\n                  return (\n                    <button\n                      key={host.id}\n                      type=\"button\"\n                      disabled={!isClickable}\n                      onClick={() => {\n                        handleHostClick(host.id, host.status);\n                        setIsDrawerOpen(false);\n                      }}\n                      className={cn(\n                        \"flex items-center gap-3 w-full px-3 py-2 rounded-md text-sm text-left\",\n                        \"transition-colors\",\n                        isClickable\n                          ? \"cursor-pointer hover:bg-secondary\"\n                          : \"opacity-50\",\n                      )}\n                    >\n                      <div className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand/15 text-xs font-semibold text-brand\">\n                        {getHostInitials(host.name)}\n                      </div>\n                      <span className=\"min-w-0 flex-1 truncate text-normal\">\n                        {host.name}\n                      </span>\n                      <span\n                        className={cn(\n                          \"h-2 w-2 shrink-0 rounded-full\",\n                          isOnline\n                            ? \"bg-success\"\n                            : isUnpaired\n                              ? \"border border-warning bg-white\"\n                              : \"bg-low\",\n                        )}\n                      />\n                    </button>\n                  );\n                })}\n              </div>\n            </>\n          )}\n\n          {/* Link a host button */}\n          {isSignedIn && (\n            <div className=\"px-2\">\n              <button\n                type=\"button\"\n                onClick={() => {\n                  handlePairHostClick();\n                  setIsDrawerOpen(false);\n                }}\n                className=\"flex items-center gap-3 w-full px-3 py-2 rounded-md text-sm text-low hover:text-normal hover:bg-secondary cursor-pointer\"\n              >\n                <PlusIcon className=\"h-4 w-4\" />\n                Link a host\n              </button>\n            </div>\n          )}\n\n          {/* Divider */}\n          <div className=\"mx-3 border-t border-border\" />\n\n          {/* Project list */}\n          <div className=\"flex-1 overflow-y-auto p-2\">\n            {isSignedIn ? (\n              isLoadingProjects ? (\n                <p className=\"px-3 py-4 text-sm text-low\">Loading projects…</p>\n              ) : (\n                projects.map((project) => (\n                  <button\n                    type=\"button\"\n                    key={project.id}\n                    onClick={() => {\n                      handleProjectClick(project.id);\n                      setIsDrawerOpen(false);\n                    }}\n                    className={cn(\n                      \"flex items-center gap-3 w-full px-3 py-2.5 rounded-md text-sm text-left cursor-pointer\",\n                      \"transition-colors\",\n                      project.id === activeProjectId\n                        ? \"bg-brand/10 text-high\"\n                        : \"text-normal hover:bg-secondary\",\n                    )}\n                  >\n                    <span\n                      className=\"h-2.5 w-2.5 rounded-full shrink-0\"\n                      style={{ backgroundColor: `hsl(${project.color})` }}\n                    />\n                    <span className=\"truncate\">{project.name}</span>\n                  </button>\n                ))\n              )\n            ) : (\n              <div className=\"px-4 py-6 text-center\">\n                <KanbanIcon\n                  className=\"h-8 w-8 mx-auto text-low\"\n                  weight=\"bold\"\n                />\n                <p className=\"mt-3 text-sm font-medium text-high\">\n                  Kanban Boards\n                </p>\n                <p className=\"mt-1 text-xs text-low\">\n                  Sign in to organise your coding agents with kanban boards.\n                </p>\n                <div className=\"mt-4\">\n                  <button\n                    type=\"button\"\n                    onClick={() => {\n                      navigate({ to: \"/account\" });\n                      setIsDrawerOpen(false);\n                    }}\n                    className=\"w-full px-3 py-2 rounded-md text-sm font-medium bg-brand text-on-brand hover:bg-brand-hover cursor-pointer\"\n                  >\n                    Sign in\n                  </button>\n                </div>\n              </div>\n            )}\n          </div>\n\n          {/* Create Project button */}\n          {isSignedIn && (\n            <div className=\"p-3 border-t border-border\">\n              <button\n                type=\"button\"\n                onClick={() => {\n                  handleCreateProject();\n                  setIsDrawerOpen(false);\n                }}\n                className=\"flex items-center gap-2 w-full px-3 py-2.5 rounded-md text-sm text-low hover:text-normal hover:bg-secondary cursor-pointer\"\n              >\n                <PlusIcon className=\"h-4 w-4\" />\n                Create Project\n              </button>\n            </div>\n          )}\n        </div>\n      </MobileDrawer>\n\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        {isMobile && (isWorkspaceContextRoute || isProjectRoute) && (\n          <RemoteNavbarContainer\n            organizationName={selectedOrgName}\n            mobileMode={isMobile}\n            onOpenDrawer={() => setIsDrawerOpen(true)}\n            mobileUserSlot={mobileUserSlot}\n          />\n        )}\n        {!isMobile && (isWorkspaceContextRoute || isProjectRoute) && (\n          <RemoteDesktopNavbar />\n        )}\n        <div className=\"min-h-0 flex-1\">{children}</div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/layout/RemoteDesktopNavbar.tsx",
    "content": "import { useMemo, useCallback } from \"react\";\nimport { useLocation } from \"@tanstack/react-router\";\nimport { useWorkspaceContext } from \"@/shared/hooks/useWorkspaceContext\";\nimport { useActions } from \"@/shared/hooks/useActions\";\nimport { useSyncErrorContext } from \"@/shared/hooks/useSyncErrorContext\";\nimport { useUserOrganizations } from \"@/shared/hooks/useUserOrganizations\";\nimport { useOrganizationStore } from \"@/shared/stores/useOrganizationStore\";\nimport { Navbar, type NavbarSectionItem } from \"@vibe/ui/components/Navbar\";\nimport { NavbarActionGroups } from \"@/shared/actions\";\nimport {\n  NavbarDivider,\n  type ActionDefinition,\n  type NavbarItem as ActionNavbarItem,\n  type ActionVisibilityContext,\n  isSpecialIcon,\n  getActionIcon,\n  getActionTooltip,\n  isActionActive,\n  isActionEnabled,\n  isActionVisible,\n} from \"@/shared/types/actions\";\nimport { useActionVisibilityContext } from \"@/shared/hooks/useActionVisibilityContext\";\nimport { SettingsDialog } from \"@/shared/dialogs/settings/SettingsDialog\";\nimport { CommandBarDialog } from \"@/shared/dialogs/command-bar/CommandBarDialog\";\nimport { REMOTE_SETTINGS_SECTIONS } from \"@remote/shared/constants/settings\";\n\n/**\n * Check if a NavbarItem is a divider\n */\nfunction isDivider(item: ActionNavbarItem): item is typeof NavbarDivider {\n  return \"type\" in item && item.type === \"divider\";\n}\n\n/**\n * Filter navbar items by visibility, keeping dividers but removing them\n * if they would appear at the start, end, or consecutively.\n */\nfunction filterNavbarItems(\n  items: readonly ActionNavbarItem[],\n  ctx: ActionVisibilityContext,\n): ActionNavbarItem[] {\n  const filtered = items.filter((item) => {\n    if (isDivider(item)) return true;\n    if (!isActionVisible(item, ctx)) return false;\n    return !isSpecialIcon(getActionIcon(item, ctx));\n  });\n\n  const result: ActionNavbarItem[] = [];\n  for (const item of filtered) {\n    if (isDivider(item)) {\n      if (result.length > 0 && !isDivider(result[result.length - 1])) {\n        result.push(item);\n      }\n    } else {\n      result.push(item);\n    }\n  }\n\n  if (result.length > 0 && isDivider(result[result.length - 1])) {\n    result.pop();\n  }\n\n  return result;\n}\n\nfunction toNavbarSectionItems(\n  items: readonly ActionNavbarItem[],\n  ctx: ActionVisibilityContext,\n  onExecuteAction: (action: ActionDefinition) => void,\n): NavbarSectionItem[] {\n  return items.reduce<NavbarSectionItem[]>((result, item) => {\n    if (isDivider(item)) {\n      result.push({ type: \"divider\" });\n      return result;\n    }\n\n    const icon = getActionIcon(item, ctx);\n    if (isSpecialIcon(icon)) {\n      return result;\n    }\n\n    result.push({\n      type: \"action\",\n      id: item.id,\n      icon,\n      isActive: isActionActive(item, ctx),\n      tooltip: getActionTooltip(item, ctx),\n      shortcut: item.shortcut,\n      disabled: !isActionEnabled(item, ctx),\n      onClick: () => onExecuteAction(item),\n    });\n    return result;\n  }, []);\n}\n\n/**\n * Desktop navbar for remote workspace and project pages.\n *\n * Mounted on workspace detail routes (/workspaces/:id) and project routes (/projects/:id)\n * where all required providers (ActionsProvider, WorkspaceProvider, etc.) are available.\n *\n * Mobile navbar is handled separately by RemoteNavbarContainer.\n */\nexport function RemoteDesktopNavbar() {\n  const { executeAction } = useActions();\n  const { workspace: selectedWorkspace } = useWorkspaceContext();\n  const syncErrorContext = useSyncErrorContext();\n  const location = useLocation();\n\n  const isOnProjectPage =\n    /^\\/projects\\/[^/]+/.test(location.pathname) ||\n    /^\\/hosts\\/[^/]+\\/projects\\/[^/]+/.test(location.pathname);\n\n  const { data: orgsData } = useUserOrganizations();\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  const orgName =\n    orgsData?.organizations.find((o) => o.id === selectedOrgId)?.name ?? \"\";\n\n  const actionCtx = useActionVisibilityContext();\n\n  const handleExecuteAction = useCallback(\n    (action: ActionDefinition) => {\n      if (action.requiresTarget && selectedWorkspace?.id) {\n        executeAction(action, selectedWorkspace.id);\n      } else {\n        executeAction(action);\n      }\n    },\n    [executeAction, selectedWorkspace?.id],\n  );\n\n  const isMigratePage = actionCtx.layoutMode === \"migrate\";\n\n  const leftItems = useMemo(\n    () =>\n      isMigratePage\n        ? []\n        : toNavbarSectionItems(\n            filterNavbarItems(NavbarActionGroups.left, actionCtx),\n            actionCtx,\n            handleExecuteAction,\n          ),\n    [actionCtx, handleExecuteAction, isMigratePage],\n  );\n\n  const rightItems = useMemo(\n    () =>\n      isMigratePage\n        ? []\n        : toNavbarSectionItems(\n            filterNavbarItems(NavbarActionGroups.right, actionCtx),\n            actionCtx,\n            handleExecuteAction,\n          ),\n    [actionCtx, handleExecuteAction, isMigratePage],\n  );\n\n  const handleOpenSettings = useCallback(() => {\n    SettingsDialog.show({ sections: REMOTE_SETTINGS_SECTIONS });\n  }, []);\n\n  const handleOpenCommandBar = useCallback(() => {\n    CommandBarDialog.show();\n  }, []);\n\n  const navbarTitle = isOnProjectPage ? orgName : selectedWorkspace?.branch;\n\n  return (\n    <Navbar\n      workspaceTitle={navbarTitle}\n      leftItems={leftItems}\n      rightItems={rightItems}\n      syncErrors={syncErrorContext?.errors}\n      isOnProjectPage={isOnProjectPage}\n      onOpenSettings={handleOpenSettings}\n      onOpenCommandBar={handleOpenCommandBar}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/layout/RemoteNavbarContainer.tsx",
    "content": "import { useCallback, useEffect, useMemo, type ReactNode } from \"react\";\nimport { useLocation, useNavigate, useParams } from \"@tanstack/react-router\";\nimport {\n  MOBILE_TABS,\n  Navbar,\n  type MobileTabId,\n  type NavbarSectionItem,\n} from \"@vibe/ui/components/Navbar\";\nimport { SettingsDialog } from \"@/shared/dialogs/settings/SettingsDialog\";\nimport { CommandBarDialog } from \"@/shared/dialogs/command-bar/CommandBarDialog\";\nimport { REMOTE_SETTINGS_SECTIONS } from \"@remote/shared/constants/settings\";\nimport { useMobileActiveTab } from \"@/shared/stores/useUiPreferencesStore\";\nimport { useMobileWorkspaceTitle } from \"@remote/shared/stores/useMobileWorkspaceTitle\";\nimport { useActions } from \"@/shared/hooks/useActions\";\nimport { useWorkspaceContext } from \"@/shared/hooks/useWorkspaceContext\";\nimport { useActionVisibilityContext } from \"@/shared/hooks/useActionVisibilityContext\";\nimport { NavbarActionGroups } from \"@/shared/actions\";\nimport {\n  NavbarDivider,\n  type ActionDefinition,\n  type NavbarItem as ActionNavbarItem,\n  type ActionVisibilityContext,\n  isSpecialIcon,\n  getActionIcon,\n  getActionTooltip,\n  isActionActive,\n  isActionEnabled,\n  isActionVisible,\n} from \"@/shared/types/actions\";\n\n/**\n * Check if a NavbarItem is a divider\n */\nfunction isDivider(item: ActionNavbarItem): item is typeof NavbarDivider {\n  return \"type\" in item && item.type === \"divider\";\n}\n\n/**\n * Filter navbar items by visibility, keeping dividers but removing them\n * if they would appear at the start, end, or consecutively.\n */\nfunction filterNavbarItems(\n  items: readonly ActionNavbarItem[],\n  ctx: ActionVisibilityContext,\n): ActionNavbarItem[] {\n  const filtered = items.filter((item) => {\n    if (isDivider(item)) return true;\n    if (!isActionVisible(item, ctx)) return false;\n    return !isSpecialIcon(getActionIcon(item, ctx));\n  });\n\n  const result: ActionNavbarItem[] = [];\n  for (const item of filtered) {\n    if (isDivider(item)) {\n      if (result.length > 0 && !isDivider(result[result.length - 1])) {\n        result.push(item);\n      }\n    } else {\n      result.push(item);\n    }\n  }\n\n  if (result.length > 0 && isDivider(result[result.length - 1])) {\n    result.pop();\n  }\n\n  return result;\n}\n\nfunction toNavbarSectionItems(\n  items: readonly ActionNavbarItem[],\n  ctx: ActionVisibilityContext,\n  onExecuteAction: (action: ActionDefinition) => void,\n): NavbarSectionItem[] {\n  return items.reduce<NavbarSectionItem[]>((result, item) => {\n    if (isDivider(item)) {\n      result.push({ type: \"divider\" });\n      return result;\n    }\n\n    const icon = getActionIcon(item, ctx);\n    if (isSpecialIcon(icon)) {\n      return result;\n    }\n\n    result.push({\n      type: \"action\",\n      id: item.id,\n      icon,\n      isActive: isActionActive(item, ctx),\n      tooltip: getActionTooltip(item, ctx),\n      shortcut: item.shortcut,\n      disabled: !isActionEnabled(item, ctx),\n      onClick: () => onExecuteAction(item),\n    });\n    return result;\n  }, []);\n}\n\ninterface RemoteNavbarContainerProps {\n  organizationName: string | null;\n  mobileMode?: boolean;\n  onOpenDrawer?: () => void;\n  mobileUserSlot?: ReactNode;\n}\n\nexport function RemoteNavbarContainer({\n  organizationName,\n  mobileMode,\n  onOpenDrawer,\n  mobileUserSlot,\n}: RemoteNavbarContainerProps) {\n  const location = useLocation();\n  const { hostId } = useParams({ strict: false });\n  const mobileWorkspaceTitle = useMobileWorkspaceTitle((s) => s.title);\n  const { executeAction } = useActions();\n  const { workspace: selectedWorkspace } = useWorkspaceContext();\n  const actionCtx = useActionVisibilityContext();\n\n  const [mobileActiveTab, setMobileActiveTab] = useMobileActiveTab();\n\n  const remoteMobileTabs = useMemo(\n    () =>\n      MOBILE_TABS.filter((t) => t.id !== \"preview\" && t.id !== \"workspaces\"),\n    [],\n  );\n\n  const isOnWorkspaceView = /^\\/hosts\\/[^/]+\\/workspaces\\/[^/]+/.test(\n    location.pathname,\n  );\n  const isOnWorkspaceList = /^\\/hosts\\/[^/]+\\/workspaces\\/?$/.test(\n    location.pathname,\n  );\n\n  useEffect(() => {\n    if (isOnWorkspaceView) {\n      setMobileActiveTab(\"chat\");\n    }\n  }, [isOnWorkspaceView, setMobileActiveTab]);\n  const navigate = useNavigate();\n\n  const isOnProjectPage = /^\\/projects\\/[^/]+/.test(location.pathname);\n  const pathSegments = location.pathname.split(\"/\").filter(Boolean);\n  const projectSegmentIndex = pathSegments.indexOf(\"projects\");\n  const projectId =\n    projectSegmentIndex === -1\n      ? null\n      : (pathSegments[projectSegmentIndex + 1] ?? null);\n  const isOnProjectSubRoute =\n    isOnProjectPage &&\n    (location.pathname.includes(\"/issues/\") ||\n      location.pathname.includes(\"/workspaces/\"));\n\n  const handleExecuteAction = useCallback(\n    (action: ActionDefinition) => {\n      if (action.requiresTarget && selectedWorkspace?.id) {\n        executeAction(action, selectedWorkspace.id);\n      } else {\n        executeAction(action);\n      }\n    },\n    [executeAction, selectedWorkspace?.id],\n  );\n\n  const rightItems = useMemo(\n    () =>\n      toNavbarSectionItems(\n        filterNavbarItems(NavbarActionGroups.right, actionCtx),\n        actionCtx,\n        handleExecuteAction,\n      ),\n    [actionCtx, handleExecuteAction],\n  );\n\n  const workspaceTitle = useMemo(() => {\n    if (isOnProjectPage) {\n      return organizationName ?? \"Project\";\n    }\n    if (isOnWorkspaceView) {\n      return mobileWorkspaceTitle ?? undefined;\n    }\n    return undefined;\n  }, [\n    location.pathname,\n    organizationName,\n    isOnProjectPage,\n    isOnWorkspaceView,\n    mobileWorkspaceTitle,\n  ]);\n\n  const mobileShowBack = isOnWorkspaceView || isOnWorkspaceList;\n\n  const handleNavigateBack = useCallback(() => {\n    if (isOnProjectPage && projectId) {\n      navigate({\n        to: \"/projects/$projectId\",\n        params: { projectId },\n      });\n    } else if (isOnWorkspaceView) {\n      if (!hostId) {\n        navigate({ to: \"/\" });\n        return;\n      }\n      navigate({ to: \"/hosts/$hostId/workspaces\", params: { hostId } });\n    } else {\n      navigate({ to: \"/\" });\n    }\n  }, [navigate, hostId, isOnProjectPage, projectId, isOnWorkspaceView]);\n\n  const handleOpenSettings = useCallback(() => {\n    SettingsDialog.show({ sections: REMOTE_SETTINGS_SECTIONS });\n  }, []);\n\n  const handleOpenCommandBar = useCallback(() => {\n    CommandBarDialog.show();\n  }, []);\n\n  return (\n    <Navbar\n      workspaceTitle={workspaceTitle}\n      rightItems={isOnProjectPage ? rightItems : undefined}\n      mobileMode={mobileMode}\n      mobileUserSlot={mobileUserSlot}\n      isOnProjectPage={isOnProjectPage}\n      isOnProjectSubRoute={isOnProjectSubRoute}\n      onNavigateBack={handleNavigateBack}\n      mobileShowBack={mobileShowBack}\n      onOpenSettings={handleOpenSettings}\n      onOpenCommandBar={handleOpenCommandBar}\n      onOpenDrawer={isOnProjectPage ? onOpenDrawer : undefined}\n      mobileActiveTab={mobileActiveTab as MobileTabId}\n      onMobileTabChange={(tab) => setMobileActiveTab(tab)}\n      mobileTabs={remoteMobileTabs}\n      showMobileTabs={isOnWorkspaceView}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/navigation/AppNavigation.ts",
    "content": "import { router } from \"@remote/app/router\";\nimport type { FileRouteTypes } from \"@remote/routeTree.gen\";\nimport {\n  type AppDestination,\n  type AppNavigation,\n  type NavigationTransition,\n} from \"@/shared/lib/routes/appNavigation\";\n\ntype RemoteRouteId = FileRouteTypes[\"id\"];\n\nfunction getPathParam(\n  routeParams: Record<string, string>,\n  key: string,\n): string | null {\n  const value = routeParams[key];\n  return value ? value : null;\n}\n\nexport function resolveRemoteDestinationFromPath(\n  path: string,\n): AppDestination | null {\n  const { pathname } = new URL(path, \"http://localhost\");\n  const { foundRoute, routeParams } = router.getMatchedRoutes(pathname);\n\n  if (!foundRoute) {\n    return null;\n  }\n\n  switch (foundRoute.id as RemoteRouteId) {\n    case \"/\":\n      return { kind: \"root\" };\n    case \"/hosts/$hostId/workspaces\": {\n      const hostId = getPathParam(routeParams, \"hostId\");\n      return hostId ? { kind: \"workspaces\", hostId } : null;\n    }\n    case \"/hosts/$hostId/workspaces_/create\": {\n      const hostId = getPathParam(routeParams, \"hostId\");\n      return hostId ? { kind: \"workspaces-create\", hostId } : null;\n    }\n    case \"/hosts/$hostId/workspaces_/$workspaceId\": {\n      const hostId = getPathParam(routeParams, \"hostId\");\n      const workspaceId = getPathParam(routeParams, \"workspaceId\");\n      return hostId && workspaceId\n        ? { kind: \"workspace\", hostId, workspaceId }\n        : null;\n    }\n    case \"/hosts/$hostId/workspaces/$workspaceId/vscode\": {\n      const hostId = getPathParam(routeParams, \"hostId\");\n      const workspaceId = getPathParam(routeParams, \"workspaceId\");\n      return hostId && workspaceId\n        ? { kind: \"workspace-vscode\", hostId, workspaceId }\n        : null;\n    }\n    case \"/projects/$projectId\": {\n      const projectId = getPathParam(routeParams, \"projectId\");\n      return projectId ? { kind: \"project\", projectId } : null;\n    }\n    case \"/projects/$projectId_/issues/$issueId\": {\n      const projectId = getPathParam(routeParams, \"projectId\");\n      const issueId = getPathParam(routeParams, \"issueId\");\n      return projectId && issueId\n        ? { kind: \"project-issue\", projectId, issueId }\n        : null;\n    }\n    case \"/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId\": {\n      const projectId = getPathParam(routeParams, \"projectId\");\n      const issueId = getPathParam(routeParams, \"issueId\");\n      const hostId = getPathParam(routeParams, \"hostId\");\n      const workspaceId = getPathParam(routeParams, \"workspaceId\");\n      return projectId && issueId && hostId && workspaceId\n        ? {\n            kind: \"project-issue-workspace\",\n            projectId,\n            issueId,\n            hostId,\n            workspaceId,\n          }\n        : null;\n    }\n    case \"/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId\": {\n      const projectId = getPathParam(routeParams, \"projectId\");\n      const issueId = getPathParam(routeParams, \"issueId\");\n      const hostId = getPathParam(routeParams, \"hostId\");\n      const draftId = getPathParam(routeParams, \"draftId\");\n      return projectId && issueId && hostId && draftId\n        ? {\n            kind: \"project-issue-workspace-create\",\n            projectId,\n            issueId,\n            hostId,\n            draftId,\n          }\n        : null;\n    }\n    case \"/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId\": {\n      const projectId = getPathParam(routeParams, \"projectId\");\n      const hostId = getPathParam(routeParams, \"hostId\");\n      const draftId = getPathParam(routeParams, \"draftId\");\n      return projectId && hostId && draftId\n        ? {\n            kind: \"project-workspace-create\",\n            projectId,\n            hostId,\n            draftId,\n          }\n        : null;\n    }\n    default:\n      return null;\n  }\n}\n\nfunction destinationToRemoteTarget(\n  destination: AppDestination,\n  options: { currentHostId: string | null },\n) {\n  const destinationHostId =\n    \"hostId\" in destination ? (destination.hostId ?? null) : null;\n  const effectiveHostId = destinationHostId ?? options.currentHostId;\n\n  switch (destination.kind) {\n    case \"root\":\n      return { to: \"/\" } as const;\n    case \"onboarding\":\n      return { to: \"/\" } as const;\n    case \"onboarding-sign-in\":\n      return { to: \"/\" } as const;\n    case \"migrate\":\n      return { to: \"/\" } as const;\n    case \"workspaces\":\n      if (effectiveHostId) {\n        return {\n          to: \"/hosts/$hostId/workspaces\",\n          params: { hostId: effectiveHostId },\n        } as const;\n      }\n      return { to: \"/\" } as const;\n    case \"workspaces-create\":\n      if (effectiveHostId) {\n        return {\n          to: \"/hosts/$hostId/workspaces/create\",\n          params: { hostId: effectiveHostId },\n        } as const;\n      }\n      return { to: \"/\" } as const;\n    case \"workspace\":\n      if (effectiveHostId) {\n        return {\n          to: \"/hosts/$hostId/workspaces/$workspaceId\",\n          params: {\n            hostId: effectiveHostId,\n            workspaceId: destination.workspaceId,\n          },\n        } as const;\n      }\n      return { to: \"/\" } as const;\n    case \"workspace-vscode\":\n      if (effectiveHostId) {\n        return {\n          to: \"/hosts/$hostId/workspaces/$workspaceId/vscode\",\n          params: {\n            hostId: effectiveHostId,\n            workspaceId: destination.workspaceId,\n          },\n        } as const;\n      }\n      return { to: \"/\" } as const;\n    case \"project\":\n      return {\n        to: \"/projects/$projectId\",\n        params: { projectId: destination.projectId },\n      } as const;\n    case \"project-issue\":\n      return {\n        to: \"/projects/$projectId/issues/$issueId\",\n        params: {\n          projectId: destination.projectId,\n          issueId: destination.issueId,\n        },\n      } as const;\n    case \"project-issue-workspace\":\n      return {\n        to: \"/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId\",\n        params: {\n          projectId: destination.projectId,\n          issueId: destination.issueId,\n          hostId: destination.hostId,\n          workspaceId: destination.workspaceId,\n        },\n      } as const;\n    case \"project-issue-workspace-create\":\n      return {\n        to: \"/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId\",\n        params: {\n          projectId: destination.projectId,\n          issueId: destination.issueId,\n          hostId: destination.hostId,\n          draftId: destination.draftId,\n        },\n      } as const;\n    case \"project-workspace-create\":\n      return {\n        to: \"/projects/$projectId/hosts/$hostId/workspaces/create/$draftId\",\n        params: {\n          projectId: destination.projectId,\n          hostId: destination.hostId,\n          draftId: destination.draftId,\n        },\n      } as const;\n  }\n}\n\nexport function createRemoteHostAppNavigation(hostId: string): AppNavigation {\n  const navigateTo = (\n    destination: AppDestination,\n    transition?: NavigationTransition,\n  ) => {\n    void router.navigate({\n      ...destinationToRemoteTarget(destination, {\n        currentHostId: hostId,\n      }),\n      ...(transition?.replace !== undefined\n        ? { replace: transition.replace }\n        : {}),\n    });\n  };\n\n  const navigation: AppNavigation = {\n    resolveFromPath: (path) => resolveRemoteDestinationFromPath(path),\n    goToRoot: (transition) => navigateTo({ kind: \"root\" }, transition),\n    goToOnboarding: (transition) =>\n      navigateTo({ kind: \"onboarding\" }, transition),\n    goToOnboardingSignIn: (transition) =>\n      navigateTo({ kind: \"onboarding-sign-in\" }, transition),\n    goToMigrate: (transition) => navigateTo({ kind: \"migrate\" }, transition),\n    goToWorkspaces: (transition) =>\n      navigateTo({ kind: \"workspaces\", hostId }, transition),\n    goToWorkspacesCreate: (transition) =>\n      navigateTo({ kind: \"workspaces-create\", hostId }, transition),\n    goToWorkspace: (workspaceId, transition) =>\n      navigateTo({ kind: \"workspace\", hostId, workspaceId }, transition),\n    goToWorkspaceVsCode: (workspaceId, transition) =>\n      navigateTo({ kind: \"workspace-vscode\", hostId, workspaceId }, transition),\n    goToProject: (projectId, transition) =>\n      navigateTo({ kind: \"project\", projectId }, transition),\n    goToProjectIssue: (projectId, issueId, transition) =>\n      navigateTo({ kind: \"project-issue\", projectId, issueId }, transition),\n    goToProjectIssueWorkspace: (projectId, issueId, workspaceId, transition) =>\n      navigateTo(\n        {\n          kind: \"project-issue-workspace\",\n          hostId,\n          projectId,\n          issueId,\n          workspaceId,\n        },\n        transition,\n      ),\n    goToProjectIssueWorkspaceCreate: (\n      projectId,\n      issueId,\n      draftId,\n      transition,\n    ) =>\n      navigateTo(\n        {\n          kind: \"project-issue-workspace-create\",\n          hostId,\n          projectId,\n          issueId,\n          draftId,\n        },\n        transition,\n      ),\n    goToProjectWorkspaceCreate: (projectId, draftId, transition) =>\n      navigateTo(\n        { kind: \"project-workspace-create\", hostId, projectId, draftId },\n        transition,\n      ),\n  };\n\n  return navigation;\n}\n\nfunction createRemoteFallbackAppNavigation(): AppNavigation {\n  const navigateTo = (\n    destination: AppDestination,\n    transition?: NavigationTransition,\n  ) => {\n    void router.navigate({\n      ...destinationToRemoteTarget(destination, {\n        currentHostId: null,\n      }),\n      ...(transition?.replace !== undefined\n        ? { replace: transition.replace }\n        : {}),\n    });\n  };\n\n  const navigation: AppNavigation = {\n    resolveFromPath: (path) => resolveRemoteDestinationFromPath(path),\n    goToRoot: (transition) => navigateTo({ kind: \"root\" }, transition),\n    goToOnboarding: (transition) =>\n      navigateTo({ kind: \"onboarding\" }, transition),\n    goToOnboardingSignIn: (transition) =>\n      navigateTo({ kind: \"onboarding-sign-in\" }, transition),\n    goToMigrate: (transition) => navigateTo({ kind: \"migrate\" }, transition),\n    goToWorkspaces: (transition) =>\n      navigateTo({ kind: \"workspaces\" }, transition),\n    goToWorkspacesCreate: (transition) =>\n      navigateTo({ kind: \"workspaces-create\" }, transition),\n    goToWorkspace: (workspaceId, transition) =>\n      navigateTo({ kind: \"workspace\", workspaceId }, transition),\n    goToWorkspaceVsCode: (workspaceId, transition) =>\n      navigateTo({ kind: \"workspace-vscode\", workspaceId }, transition),\n    goToProject: (projectId, transition) =>\n      navigateTo({ kind: \"project\", projectId }, transition),\n    goToProjectIssue: (projectId, issueId, transition) =>\n      navigateTo({ kind: \"project-issue\", projectId, issueId }, transition),\n    goToProjectIssueWorkspace: (projectId, issueId, workspaceId, transition) =>\n      navigateTo(\n        { kind: \"project-issue-workspace\", projectId, issueId, workspaceId },\n        transition,\n      ),\n    goToProjectIssueWorkspaceCreate: (\n      projectId,\n      issueId,\n      draftId,\n      transition,\n    ) =>\n      navigateTo(\n        { kind: \"project-issue-workspace-create\", projectId, issueId, draftId },\n        transition,\n      ),\n    goToProjectWorkspaceCreate: (projectId, draftId, transition) =>\n      navigateTo(\n        { kind: \"project-workspace-create\", projectId, draftId },\n        transition,\n      ),\n  };\n\n  return navigation;\n}\n\nexport const remoteFallbackAppNavigation = createRemoteFallbackAppNavigation();\n"
  },
  {
    "path": "packages/remote-web/src/app/providers/RemoteActionsProvider.tsx",
    "content": "import {\n  useCallback,\n  useContext,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { useParams } from \"@tanstack/react-router\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport type { Workspace } from \"shared/types\";\nimport {\n  ActionsContext,\n  type ActionsContextValue,\n} from \"@/shared/hooks/useActions\";\nimport { UserContext } from \"@/shared/hooks/useUserContext\";\nimport {\n  type ActionDefinition,\n  type ActionExecutorContext,\n  type ActionVisibilityContext,\n  getActionLabel,\n  resolveLabel,\n  type ProjectMutations,\n} from \"@/shared/types/actions\";\nimport { SettingsDialog } from \"@/shared/dialogs/settings/SettingsDialog\";\nimport { useAppNavigation } from \"@/shared/hooks/useAppNavigation\";\nimport { useAppRuntime } from \"@/shared/hooks/useAppRuntime\";\nimport { useOrganizationStore } from \"@/shared/stores/useOrganizationStore\";\nimport {\n  buildKanbanIssueComposerKey,\n  openKanbanIssueComposer,\n  type ProjectIssueCreateOptions,\n} from \"@/shared/stores/useKanbanIssueComposerStore\";\nimport { REMOTE_SETTINGS_SECTIONS } from \"@remote/shared/constants/settings\";\n\ninterface RemoteActionsProviderProps {\n  children: ReactNode;\n}\n\nfunction noOpSelection(name: string) {\n  console.warn(`[RemoteActionsProvider] ${name} is unavailable in remote web.`);\n}\n\nexport function RemoteActionsProvider({\n  children,\n}: RemoteActionsProviderProps) {\n  const runtime = useAppRuntime();\n  const appNavigation = useAppNavigation();\n  const queryClient = useQueryClient();\n  const { projectId, hostId } = useParams({ strict: false });\n  const userCtx = useContext(UserContext);\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  const [defaultCreateStatusId, setDefaultCreateStatusId] = useState<\n    string | undefined\n  >();\n  const [projectMutations, setProjectMutations] =\n    useState<ProjectMutations | null>(null);\n\n  const registerProjectMutations = useCallback(\n    (mutations: ProjectMutations | null) => {\n      setProjectMutations(mutations);\n    },\n    [],\n  );\n\n  const navigateToCreateIssue = useCallback(\n    (options?: ProjectIssueCreateOptions) => {\n      if (!projectId) return;\n      openKanbanIssueComposer(\n        buildKanbanIssueComposerKey(hostId ?? null, projectId),\n        options,\n      );\n    },\n    [hostId, projectId],\n  );\n\n  const openStatusSelection = useCallback(async () => {\n    noOpSelection(\"Status selection\");\n  }, []);\n\n  const openPrioritySelection = useCallback(async () => {\n    noOpSelection(\"Priority selection\");\n  }, []);\n\n  const openAssigneeSelection = useCallback(async () => {\n    noOpSelection(\"Assignee selection\");\n  }, []);\n\n  const openSubIssueSelection = useCallback(async () => {\n    noOpSelection(\"Sub-issue selection\");\n    return undefined;\n  }, []);\n\n  const openWorkspaceSelection = useCallback(async () => {\n    noOpSelection(\"Workspace selection\");\n  }, []);\n\n  const openRelationshipSelection = useCallback(async () => {\n    noOpSelection(\"Relationship selection\");\n  }, []);\n\n  const executorContext = useMemo<ActionExecutorContext>(\n    () => ({\n      appNavigation,\n      queryClient,\n      selectWorkspace: () => {\n        noOpSelection(\"Workspace actions\");\n      },\n      activeWorkspaces: [],\n      currentWorkspaceId: null,\n      containerRef: null,\n      runningDevServers: [],\n      startDevServer: () => {\n        noOpSelection(\"Dev server actions\");\n      },\n      stopDevServer: () => {\n        noOpSelection(\"Dev server actions\");\n      },\n      currentLogs: null,\n      logsPanelContent: null,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      openSubIssueSelection,\n      openWorkspaceSelection,\n      openRelationshipSelection,\n      navigateToCreateIssue,\n      defaultCreateStatusId,\n      kanbanOrgId: selectedOrgId ?? undefined,\n      kanbanProjectId: projectId,\n      projectMutations: projectMutations ?? undefined,\n      remoteWorkspaces: userCtx?.workspaces ?? [],\n      runtime,\n    }),\n    [\n      runtime,\n      queryClient,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      openSubIssueSelection,\n      openWorkspaceSelection,\n      openRelationshipSelection,\n      navigateToCreateIssue,\n      defaultCreateStatusId,\n      selectedOrgId,\n      projectId,\n      projectMutations,\n      userCtx?.workspaces,\n    ],\n  );\n\n  const executeAction = useCallback(\n    async (action: ActionDefinition): Promise<void> => {\n      if (action.id === \"settings\") {\n        await SettingsDialog.show({\n          initialSection: \"organizations\",\n          sections: REMOTE_SETTINGS_SECTIONS,\n        });\n        return;\n      }\n\n      if (action.id === \"project-settings\") {\n        await SettingsDialog.show({\n          initialSection: \"remote-projects\",\n          initialState: {\n            organizationId: selectedOrgId ?? undefined,\n            projectId: projectId ?? undefined,\n          },\n          sections: REMOTE_SETTINGS_SECTIONS,\n        });\n        return;\n      }\n\n      console.warn(\n        `[RemoteActionsProvider] Action \"${action.id}\" is unavailable in remote web.`,\n      );\n    },\n    [projectId, selectedOrgId],\n  );\n\n  const getLabel = useCallback(\n    (\n      action: ActionDefinition,\n      workspace?: Workspace,\n      ctx?: ActionVisibilityContext,\n    ) => {\n      if (ctx) {\n        return getActionLabel(action, ctx, workspace);\n      }\n      return resolveLabel(action, workspace);\n    },\n    [],\n  );\n\n  const value = useMemo<ActionsContextValue>(\n    () => ({\n      executeAction,\n      getLabel,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      openSubIssueSelection,\n      openWorkspaceSelection,\n      openRelationshipSelection,\n      setDefaultCreateStatusId,\n      registerProjectMutations,\n      executorContext,\n    }),\n    [\n      executeAction,\n      getLabel,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      openSubIssueSelection,\n      openWorkspaceSelection,\n      openRelationshipSelection,\n      registerProjectMutations,\n      executorContext,\n    ],\n  );\n\n  return (\n    <ActionsContext.Provider value={value}>{children}</ActionsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/providers/RemoteAuthProvider.tsx",
    "content": "import { useEffect, useMemo, type ReactNode } from \"react\";\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { AUTH_CHANGED_EVENT, isLoggedIn } from \"@remote/shared/lib/auth\";\nimport { getIdentity } from \"@remote/shared/lib/api\";\nimport {\n  AuthContext,\n  type AuthContextValue,\n} from \"@/shared/hooks/auth/useAuth\";\n\nconst TOKENS_QUERY_KEY = [\"remote-auth\", \"tokens\"] as const;\nconst IDENTITY_QUERY_KEY = [\"remote-auth\", \"identity\"] as const;\n\ninterface RemoteAuthProviderProps {\n  children: ReactNode;\n}\n\nexport function RemoteAuthProvider({ children }: RemoteAuthProviderProps) {\n  const queryClient = useQueryClient();\n\n  const tokensQuery = useQuery({\n    queryKey: TOKENS_QUERY_KEY,\n    queryFn: () => isLoggedIn(),\n    staleTime: 0,\n    refetchOnWindowFocus: true,\n  });\n\n  const hasTokens = tokensQuery.data === true;\n\n  const identityQuery = useQuery({\n    queryKey: IDENTITY_QUERY_KEY,\n    queryFn: () => getIdentity(),\n    enabled: hasTokens,\n    retry: false,\n    staleTime: 30_000,\n    refetchOnWindowFocus: true,\n  });\n  const identityUserId = identityQuery.data?.user_id ?? null;\n\n  useEffect(() => {\n    const handleAuthChanged = () => {\n      void queryClient.invalidateQueries({ queryKey: TOKENS_QUERY_KEY });\n      void queryClient.invalidateQueries({ queryKey: IDENTITY_QUERY_KEY });\n    };\n\n    window.addEventListener(AUTH_CHANGED_EVENT, handleAuthChanged);\n    return () => {\n      window.removeEventListener(AUTH_CHANGED_EVENT, handleAuthChanged);\n    };\n  }, [queryClient]);\n\n  const value = useMemo<AuthContextValue>(() => {\n    if (tokensQuery.status === \"pending\") {\n      return { isSignedIn: false, isLoaded: false, userId: null };\n    }\n\n    if (!hasTokens) {\n      return { isSignedIn: false, isLoaded: true, userId: null };\n    }\n\n    if (identityQuery.status === \"pending\") {\n      return { isSignedIn: false, isLoaded: false, userId: null };\n    }\n\n    if (identityUserId) {\n      return {\n        isSignedIn: true,\n        isLoaded: true,\n        userId: identityUserId,\n      };\n    }\n\n    return { isSignedIn: false, isLoaded: true, userId: null };\n  }, [tokensQuery.status, hasTokens, identityQuery.status, identityUserId]);\n\n  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/providers/RemoteUserSystemProvider.tsx",
    "content": "import { ReactNode, useCallback, useMemo } from \"react\";\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { useParams } from \"@tanstack/react-router\";\nimport type {\n  BaseAgentCapability,\n  Config,\n  Environment,\n  ExecutorProfile,\n  UserSystemInfo,\n} from \"shared/types\";\nimport { configApi } from \"@/shared/lib/api\";\nimport { useAuth } from \"@/shared/hooks/auth/useAuth\";\nimport {\n  UserSystemContext,\n  type UserSystemContextType,\n} from \"@/shared/hooks/useUserSystem\";\n\ninterface RemoteUserSystemProviderProps {\n  children: ReactNode;\n}\n\nexport function RemoteUserSystemProvider({\n  children,\n}: RemoteUserSystemProviderProps) {\n  const queryClient = useQueryClient();\n  const { isSignedIn, isLoaded } = useAuth();\n  const { hostId } = useParams({ strict: false });\n  const userSystemQueryKey = useMemo(\n    () => [\"remote-workspace-user-system\", hostId] as const,\n    [hostId],\n  );\n\n  const { data: userSystemInfo, isLoading } = useQuery({\n    queryKey: userSystemQueryKey,\n    queryFn: configApi.getConfig,\n    enabled: isLoaded && isSignedIn && !!hostId,\n    staleTime: 5 * 60 * 1000,\n  });\n\n  const config = userSystemInfo?.config || null;\n  const appVersion = userSystemInfo?.version || null;\n  const environment = userSystemInfo?.environment || null;\n  const analyticsUserId = userSystemInfo?.analytics_user_id || null;\n  const loginStatus = userSystemInfo?.login_status || null;\n  const profiles =\n    (userSystemInfo?.executors as Record<string, ExecutorProfile> | null) ||\n    null;\n  const capabilities =\n    (userSystemInfo?.capabilities as Record<\n      string,\n      BaseAgentCapability[]\n    > | null) || null;\n  const loading = !isLoaded || (isSignedIn && isLoading);\n\n  const updateConfig = useCallback(\n    (updates: Partial<Config>) => {\n      queryClient.setQueryData<UserSystemInfo>(userSystemQueryKey, (old) => {\n        if (!old) return old;\n        return {\n          ...old,\n          config: { ...old.config, ...updates },\n        };\n      });\n    },\n    [queryClient, userSystemQueryKey],\n  );\n\n  const saveConfig = useCallback(async (): Promise<boolean> => {\n    if (!config) return false;\n\n    try {\n      await configApi.saveConfig(config);\n      return true;\n    } catch (err) {\n      console.error(\"Error saving config:\", err);\n      return false;\n    }\n  }, [config]);\n\n  const updateAndSaveConfig = useCallback(\n    async (updates: Partial<Config>): Promise<boolean> => {\n      if (!config) return false;\n\n      const newConfig = { ...config, ...updates };\n      updateConfig(updates);\n\n      try {\n        const saved = await configApi.saveConfig(newConfig);\n        queryClient.setQueryData<UserSystemInfo>(userSystemQueryKey, (old) => {\n          if (!old) return old;\n          return {\n            ...old,\n            config: saved,\n          };\n        });\n        return true;\n      } catch (err) {\n        console.error(\"Error saving config:\", err);\n        queryClient.invalidateQueries({\n          queryKey: userSystemQueryKey,\n        });\n        return false;\n      }\n    },\n    [config, queryClient, updateConfig, userSystemQueryKey],\n  );\n\n  const reloadSystem = useCallback(async () => {\n    await queryClient.invalidateQueries({\n      queryKey: userSystemQueryKey,\n    });\n  }, [queryClient, userSystemQueryKey]);\n\n  const setEnvironment = useCallback(\n    (env: Environment | null) => {\n      queryClient.setQueryData<UserSystemInfo>(userSystemQueryKey, (old) => {\n        if (!old || !env) return old;\n        return { ...old, environment: env };\n      });\n    },\n    [queryClient, userSystemQueryKey],\n  );\n\n  const setProfiles = useCallback(\n    (newProfiles: Record<string, ExecutorProfile> | null) => {\n      queryClient.setQueryData<UserSystemInfo>(userSystemQueryKey, (old) => {\n        if (!old || !newProfiles) return old;\n        return {\n          ...old,\n          executors: newProfiles as unknown as UserSystemInfo[\"executors\"],\n        };\n      });\n    },\n    [queryClient, userSystemQueryKey],\n  );\n\n  const setCapabilities = useCallback(\n    (newCapabilities: Record<string, BaseAgentCapability[]> | null) => {\n      queryClient.setQueryData<UserSystemInfo>(userSystemQueryKey, (old) => {\n        if (!old || !newCapabilities) return old;\n        return { ...old, capabilities: newCapabilities };\n      });\n    },\n    [queryClient, userSystemQueryKey],\n  );\n\n  const value = useMemo<UserSystemContextType>(\n    () => ({\n      system: {\n        appVersion,\n        config,\n        environment,\n        profiles,\n        capabilities,\n        analyticsUserId,\n        loginStatus,\n      },\n      appVersion,\n      config,\n      environment,\n      profiles,\n      capabilities,\n      analyticsUserId,\n      loginStatus,\n      updateConfig,\n      saveConfig,\n      updateAndSaveConfig,\n      setEnvironment,\n      setProfiles,\n      setCapabilities,\n      reloadSystem,\n      loading,\n    }),\n    [\n      appVersion,\n      config,\n      environment,\n      profiles,\n      capabilities,\n      analyticsUserId,\n      loginStatus,\n      updateConfig,\n      saveConfig,\n      updateAndSaveConfig,\n      setEnvironment,\n      setProfiles,\n      setCapabilities,\n      reloadSystem,\n      loading,\n    ],\n  );\n\n  return (\n    <UserSystemContext.Provider value={value}>\n      {children}\n    </UserSystemContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/router/index.ts",
    "content": "import { createRouter } from \"@tanstack/react-router\";\nimport { routeTree } from \"@remote/routeTree.gen\";\n\nexport const router = createRouter({ routeTree });\n\ndeclare module \"@tanstack/react-router\" {\n  interface Register {\n    router: typeof router;\n  }\n}\n"
  },
  {
    "path": "packages/remote-web/src/app/styles/index.css",
    "content": "@import \"../../../../web-core/src/app/styles/new/index.css\";\n"
  },
  {
    "path": "packages/remote-web/src/pages/HomePage.tsx",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { Link, useNavigate, useSearch } from \"@tanstack/react-router\";\nimport type { Project } from \"shared/remote-types\";\nimport type { OrganizationWithRole } from \"shared/types\";\nimport { listOrganizationProjects } from \"@remote/shared/lib/api\";\nimport { clearTokens } from \"@remote/shared/lib/auth\";\nimport { SettingsDialog } from \"@/shared/dialogs/settings/SettingsDialog\";\nimport { useOrganizationStore } from \"@/shared/stores/useOrganizationStore\";\nimport { useUserOrganizations } from \"@/shared/hooks/useUserOrganizations\";\nimport { REMOTE_SETTINGS_SECTIONS } from \"@remote/shared/constants/settings\";\nimport { useAuth } from \"@/shared/hooks/auth/useAuth\";\nimport { useIsMobile } from \"@/shared/hooks/useIsMobile\";\nimport {\n  resolveRelayNavigationHostId,\n  useRelayAppBarHosts,\n} from \"@remote/shared/hooks/useRelayAppBarHosts\";\n\ntype OrganizationWithProjects = {\n  organization: OrganizationWithRole;\n  projects: Project[];\n};\n\nfunction getHostInitials(name: string): string {\n  const trimmed = name.trim();\n  if (!trimmed) return \"??\";\n  const words = trimmed.split(/\\s+/);\n  if (words.length >= 2) {\n    return (words[0][0] + words[1][0]).toUpperCase();\n  }\n  return trimmed.slice(0, 2).toUpperCase();\n}\n\nexport default function HomePage() {\n  const navigate = useNavigate();\n  const search = useSearch({ from: \"/\" });\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n  const {\n    data: orgsResponse,\n    isLoading: orgsLoading,\n    error: orgsError,\n  } = useUserOrganizations();\n  const organizations = orgsResponse?.organizations;\n  const [items, setItems] = useState<OrganizationWithProjects[]>([]);\n  const [isLoadingProjects, setIsLoadingProjects] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const { isSignedIn } = useAuth();\n  const { hosts } = useRelayAppBarHosts(isSignedIn);\n  const isMobile = useIsMobile();\n  const preferredHostId = useMemo(\n    () => resolveRelayNavigationHostId(hosts),\n    [hosts],\n  );\n\n  const openRelaySettings = useCallback((hostId?: string) => {\n    void SettingsDialog.show({\n      initialSection: \"relay\",\n      ...(hostId ? { initialState: { hostId } } : {}),\n      sections: REMOTE_SETTINGS_SECTIONS,\n    });\n  }, []);\n\n  useEffect(() => {\n    const legacyOrgId = search.legacyOrgSettingsOrgId;\n    if (!legacyOrgId) {\n      return;\n    }\n\n    setSelectedOrgId(legacyOrgId);\n    navigate({\n      to: \"/\",\n      search: {},\n      replace: true,\n    });\n\n    void SettingsDialog.show({\n      initialSection: \"organizations\",\n      initialState: { organizationId: legacyOrgId },\n      sections: REMOTE_SETTINGS_SECTIONS,\n    });\n  }, [navigate, search.legacyOrgSettingsOrgId, setSelectedOrgId]);\n\n  const handleSignInAgain = async () => {\n    await clearTokens();\n    navigate({\n      to: \"/account\",\n      replace: true,\n    });\n  };\n\n  useEffect(() => {\n    if (!organizations) {\n      return;\n    }\n\n    let cancelled = false;\n\n    const load = async () => {\n      setIsLoadingProjects(true);\n      setError(null);\n\n      try {\n        const organizationsWithProjects = await Promise.all(\n          organizations.map(async (organization) => {\n            const projects = await listOrganizationProjects(organization.id);\n            return {\n              organization,\n              projects: projects.sort((a, b) => a.sort_order - b.sort_order),\n            };\n          }),\n        );\n\n        if (!cancelled) {\n          setItems(organizationsWithProjects);\n        }\n      } catch (e) {\n        if (!cancelled) {\n          setError(\n            e instanceof Error ? e.message : \"Failed to load organizations\",\n          );\n        }\n      } finally {\n        if (!cancelled) {\n          setIsLoadingProjects(false);\n        }\n      }\n    };\n\n    void load();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [organizations]);\n\n  const loading = orgsLoading || isLoadingProjects;\n  const displayError =\n    error ??\n    (orgsError\n      ? orgsError instanceof Error\n        ? orgsError.message\n        : \"Failed to load organizations\"\n      : null);\n\n  if (loading) {\n    return (\n      <CenteredCard>\n        <h1 className=\"text-lg font-semibold text-high\">Organizations</h1>\n        <p className=\"mt-base text-sm text-normal\">\n          Loading organizations and projects...\n        </p>\n      </CenteredCard>\n    );\n  }\n\n  if (displayError) {\n    return (\n      <CenteredCard>\n        <h1 className=\"text-lg font-semibold text-high\">Failed to load</h1>\n        <p className=\"mt-base text-sm text-normal\">{displayError}</p>\n        <button\n          type=\"button\"\n          className=\"mt-double rounded-sm bg-brand px-base py-half text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover\"\n          onClick={() => {\n            void handleSignInAgain();\n          }}\n        >\n          Sign in again\n        </button>\n      </CenteredCard>\n    );\n  }\n\n  const organizationCount = items.length;\n  const totalProjectCount = items.reduce(\n    (count, item) => count + item.projects.length,\n    0,\n  );\n\n  return (\n    <div className=\"h-full overflow-auto\">\n      <div className=\"mx-auto w-full max-w-6xl px-base py-base sm:px-double sm:py-double\">\n        {isMobile && isSignedIn && (\n          <section className=\"mb-double\">\n            <h2 className=\"text-lg font-semibold text-high\">Your Hosts</h2>\n            {hosts.length === 0 ? (\n              <div className=\"mt-base rounded-sm border border-border bg-secondary p-base text-center\">\n                <p className=\"text-sm text-low\">No hosts linked yet</p>\n                <button\n                  type=\"button\"\n                  className=\"mt-base rounded-sm border border-border bg-primary px-base py-half text-sm font-medium text-normal hover:border-brand/60 hover:text-high\"\n                  onClick={() => {\n                    openRelaySettings();\n                  }}\n                >\n                  Link a host\n                </button>\n              </div>\n            ) : (\n              <div className=\"mt-base space-y-half\">\n                {hosts.map((host) => {\n                  const isOnline = host.status === \"online\";\n                  const isUnpaired = host.status === \"unpaired\";\n                  const isClickable = isOnline || isUnpaired;\n\n                  return (\n                    <button\n                      key={host.id}\n                      type=\"button\"\n                      disabled={!isClickable}\n                      className={`flex w-full items-center gap-base rounded-sm border border-border bg-primary px-base py-base text-left transition-colors ${\n                        isClickable\n                          ? \"hover:border-high/20 hover:bg-panel\"\n                          : \"opacity-50\"\n                      }`}\n                      onClick={() => {\n                        if (isOnline) {\n                          navigate({\n                            to: \"/hosts/$hostId/workspaces\",\n                            params: { hostId: host.id },\n                          });\n                        } else if (isUnpaired) {\n                          openRelaySettings(host.id);\n                        }\n                      }}\n                    >\n                      <div className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand/15 text-xs font-semibold text-brand\">\n                        {getHostInitials(host.name)}\n                      </div>\n                      <span className=\"min-w-0 flex-1 truncate text-sm font-medium text-high\">\n                        {host.name}\n                      </span>\n                      <span\n                        className={`h-2.5 w-2.5 shrink-0 rounded-full ${\n                          isOnline\n                            ? \"bg-success\"\n                            : isUnpaired\n                              ? \"border border-warning bg-white\"\n                              : \"bg-low\"\n                        }`}\n                      />\n                    </button>\n                  );\n                })}\n                <button\n                  type=\"button\"\n                  className=\"flex w-full items-center justify-center rounded-sm border border-dashed border-border px-base py-half text-sm text-low hover:border-brand/60 hover:text-normal\"\n                  onClick={() => {\n                    openRelaySettings();\n                  }}\n                >\n                  Link a host\n                </button>\n              </div>\n            )}\n          </section>\n        )}\n\n        <header className=\"space-y-half\">\n          <h1 className=\"text-2xl font-semibold text-high\">Organizations</h1>\n          <p className=\"text-sm text-low\">\n            {organizationCount}{\" \"}\n            {organizationCount === 1 ? \"organization\" : \"organizations\"} •{\" \"}\n            {totalProjectCount}{\" \"}\n            {totalProjectCount === 1 ? \"project\" : \"projects\"}\n          </p>\n        </header>\n\n        {organizationCount === 0 ? (\n          <section className=\"mt-double rounded-sm border border-border bg-secondary p-base sm:p-double\">\n            <h2 className=\"text-base font-medium text-high\">\n              No organizations found\n            </h2>\n            <p className=\"mt-half text-sm text-low\">\n              Create or join an organization to start working on projects.\n            </p>\n          </section>\n        ) : (\n          <div className=\"mt-double space-y-double\">\n            {items.map(({ organization, projects }) => (\n              <OrganizationSection\n                key={organization.id}\n                organization={organization}\n                projects={projects}\n                hostId={preferredHostId}\n                onRequireHost={openRelaySettings}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction CenteredCard({ children }: { children: ReactNode }) {\n  return (\n    <div className=\"flex h-full items-center justify-center px-base\">\n      <section className=\"w-full max-w-md rounded-sm border border-border bg-secondary p-double text-center\">\n        {children}\n      </section>\n    </div>\n  );\n}\n\nfunction OrganizationSection({\n  organization,\n  projects,\n  hostId,\n  onRequireHost,\n}: OrganizationWithProjects & {\n  hostId: string | null;\n  onRequireHost: () => void;\n}) {\n  return (\n    <section className=\"space-y-base\">\n      <header className=\"flex items-center justify-between gap-base\">\n        <h2 className=\"truncate text-lg font-medium text-high\">\n          {organization.name}\n        </h2>\n        <p className=\"shrink-0 text-xs text-low\">\n          {projects.length} {projects.length === 1 ? \"project\" : \"projects\"}\n        </p>\n      </header>\n\n      {projects.length === 0 ? (\n        <div className=\"rounded-sm border border-border bg-primary px-base py-base text-sm text-low\">\n          No projects yet\n        </div>\n      ) : (\n        <ul className=\"grid gap-base sm:grid-cols-2\">\n          {projects.map((project) => (\n            <li key={project.id}>\n              <ProjectCard\n                project={project}\n                hostId={hostId}\n                onRequireHost={onRequireHost}\n              />\n            </li>\n          ))}\n          {projects.length % 2 === 1 ? (\n            <li className=\"hidden sm:block\" aria-hidden=\"true\">\n              <ProjectCardSkeleton />\n            </li>\n          ) : null}\n        </ul>\n      )}\n    </section>\n  );\n}\n\nfunction ProjectCard({\n  project,\n  hostId,\n  onRequireHost,\n}: {\n  project: Project;\n  hostId: string | null;\n  onRequireHost: () => void;\n}) {\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n\n  if (!hostId) {\n    return (\n      <button\n        type=\"button\"\n        className=\"group flex h-[61px] w-full flex-col justify-center rounded-sm border border-border bg-primary px-base py-base text-left hover:border-brand/60 hover:bg-panel\"\n        onClick={onRequireHost}\n      >\n        <p className=\"text-sm font-medium text-high\">{project.name}</p>\n        <p className=\"mt-half text-xs text-low\">Link a host to open project</p>\n      </button>\n    );\n  }\n\n  return (\n    <Link\n      to=\"/projects/$projectId\"\n      params={{ projectId: project.id }}\n      onClick={() => {\n        setSelectedOrgId(project.organization_id);\n      }}\n      className=\"group flex h-[61px] flex-col justify-center rounded-sm border border-border bg-primary px-base py-base hover:border-high/20 hover:bg-panel focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand\"\n    >\n      <p className=\"text-sm font-medium text-high\">{project.name}</p>\n      <p className=\"mt-half text-xs text-low group-hover:text-normal\">\n        Open project\n      </p>\n    </Link>\n  );\n}\n\nfunction ProjectCardSkeleton() {\n  return (\n    <div className=\"h-[61px] rounded-sm border border-border bg-primary animate-pulse\" />\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/InvitationCompletePage.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useParams, useSearch } from \"@tanstack/react-router\";\nimport { acceptInvitation, redeemOAuth } from \"@remote/shared/lib/api\";\nimport { storeTokens } from \"@remote/shared/lib/auth\";\nimport {\n  clearInvitationToken,\n  clearVerifier,\n  retrieveInvitationToken,\n  retrieveVerifier,\n} from \"@remote/shared/lib/pkce\";\n\nexport default function InvitationCompletePage() {\n  const { token: urlToken } = useParams({\n    from: \"/invitations/$token/complete\",\n  });\n  const search = useSearch({ from: \"/invitations/$token/complete\" });\n  const [error, setError] = useState<string | null>(null);\n  const [isAccepted, setIsAccepted] = useState(false);\n\n  const handoffId = search.handoff_id;\n  const appCode = search.app_code;\n  const oauthError = search.error;\n\n  useEffect(() => {\n    const completeInvitation = async () => {\n      if (oauthError) {\n        setError(`OAuth error: ${oauthError}`);\n        return;\n      }\n\n      if (!handoffId || !appCode) {\n        return;\n      }\n\n      try {\n        const verifier = retrieveVerifier();\n        if (!verifier) {\n          setError(\"OAuth session lost. Please try again.\");\n          return;\n        }\n\n        const token = retrieveInvitationToken() || urlToken;\n        if (!token) {\n          setError(\"Invitation token lost. Please try again.\");\n          return;\n        }\n\n        const { access_token, refresh_token } = await redeemOAuth(\n          handoffId,\n          appCode,\n          verifier,\n        );\n\n        await storeTokens(access_token, refresh_token);\n        await acceptInvitation(token, access_token);\n\n        clearVerifier();\n        clearInvitationToken();\n\n        setIsAccepted(true);\n      } catch (e) {\n        setError(\n          e instanceof Error ? e.message : \"Failed to complete invitation\",\n        );\n        clearVerifier();\n        clearInvitationToken();\n      }\n    };\n\n    void completeInvitation();\n  }, [handoffId, appCode, oauthError, urlToken]);\n\n  if (error) {\n    const retryPath = urlToken ? `/invitations/${urlToken}/accept` : \"/account\";\n\n    return (\n      <StatusCard title=\"Could not accept invitation\" variant=\"error\">\n        <p className=\"mt-base text-sm text-normal\">{error}</p>\n        <button\n          type=\"button\"\n          className=\"mt-double w-full rounded-sm bg-brand px-base py-half text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover\"\n          onClick={() => {\n            window.location.assign(retryPath);\n          }}\n        >\n          Try again\n        </button>\n      </StatusCard>\n    );\n  }\n\n  if (isAccepted) {\n    return (\n      <StatusCard title=\"Invitation accepted!\">\n        <p className=\"mt-base text-sm text-normal\">\n          Your invitation is confirmed. You can now close this page.\n        </p>\n        <a\n          href=\"https://www.vibekanban.com/docs/getting-started\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"mt-double block w-full rounded-sm bg-brand px-base py-half text-center text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover\"\n        >\n          Get started\n        </a>\n      </StatusCard>\n    );\n  }\n\n  return (\n    <StatusCard title=\"Completing invitation...\">\n      <p className=\"mt-base text-sm text-low\">Processing OAuth callback...</p>\n    </StatusCard>\n  );\n}\n\nfunction StatusCard({\n  title,\n  variant,\n  children,\n}: {\n  title: string;\n  variant?: \"error\";\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-base py-double\">\n        <div className=\"rounded-sm border border-border bg-secondary p-double\">\n          <h2\n            className={`text-lg font-semibold ${variant === \"error\" ? \"text-error\" : \"text-high\"}`}\n          >\n            {title}\n          </h2>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/InvitationPage.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useParams } from \"@tanstack/react-router\";\nimport {\n  getInvitation,\n  initOAuth,\n  type InvitationLookupResponse,\n  type OAuthProvider,\n} from \"@remote/shared/lib/api\";\nimport {\n  generateChallenge,\n  generateVerifier,\n  storeInvitationToken,\n  storeVerifier,\n} from \"@remote/shared/lib/pkce\";\n\nexport default function InvitationPage() {\n  const { token } = useParams({ from: \"/invitations/$token/accept\" });\n  const [invitation, setInvitation] = useState<InvitationLookupResponse | null>(\n    null,\n  );\n  const [error, setError] = useState<string | null>(null);\n  const [pendingProvider, setPendingProvider] = useState<OAuthProvider | null>(\n    null,\n  );\n\n  useEffect(() => {\n    let cancelled = false;\n\n    const loadInvitation = async () => {\n      setError(null);\n      setInvitation(null);\n\n      try {\n        const response = await getInvitation(token);\n        if (!cancelled) {\n          setInvitation(response);\n        }\n      } catch (e) {\n        if (!cancelled) {\n          setError(\n            e instanceof Error ? e.message : \"Failed to load invitation\",\n          );\n        }\n      }\n    };\n\n    void loadInvitation();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [token]);\n\n  const handleOAuthLogin = async (provider: OAuthProvider) => {\n    setPendingProvider(provider);\n    setError(null);\n\n    try {\n      const verifier = generateVerifier();\n      const challenge = await generateChallenge(verifier);\n\n      storeVerifier(verifier);\n      storeInvitationToken(token);\n\n      const appBase =\n        import.meta.env.VITE_APP_BASE_URL || window.location.origin;\n      const callbackUrl = new URL(`/invitations/${token}/complete`, appBase);\n\n      const { authorize_url } = await initOAuth(\n        provider,\n        callbackUrl.toString(),\n        challenge,\n      );\n      window.location.assign(authorize_url);\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"OAuth init failed\");\n      setPendingProvider(null);\n    }\n  };\n\n  if (error && !invitation) {\n    return (\n      <StatusCard title=\"Invalid or expired invitation\" variant=\"error\">\n        <p className=\"mt-base text-sm text-normal\">{error}</p>\n      </StatusCard>\n    );\n  }\n\n  if (!invitation) {\n    return (\n      <StatusCard title=\"Loading invitation...\">\n        <p className=\"mt-base text-sm text-low\">Please wait.</p>\n      </StatusCard>\n    );\n  }\n\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-base py-double\">\n        <div className=\"space-y-double rounded-sm border border-border bg-secondary p-double\">\n          <header className=\"space-y-half text-center\">\n            <h1 className=\"text-2xl font-semibold text-high\">\n              You&apos;re invited\n            </h1>\n            <p className=\"text-sm text-low\">\n              You&apos;ve been invited to join{\" \"}\n              <span className=\"font-medium text-high\">\n                {invitation.organization_name ?? invitation.organization_slug}\n              </span>{\" \"}\n              on Vibe Kanban.\n            </p>\n          </header>\n\n          <section className=\"mx-auto w-full max-w-xs space-y-half border-t border-border pt-base text-sm\">\n            <div className=\"flex items-center justify-between gap-base\">\n              <span className=\"text-low\">Role</span>\n              <span className=\"font-medium text-high\">{invitation.role}</span>\n            </div>\n            <div className=\"flex items-center justify-between gap-base\">\n              <span className=\"text-low\">Expires</span>\n              <span className=\"font-medium text-high\">\n                {new Date(invitation.expires_at).toLocaleDateString()}\n              </span>\n            </div>\n          </section>\n\n          {error && (\n            <div className=\"rounded-sm border border-error/30 bg-error/10 p-base\">\n              <p className=\"text-sm text-high\">{error}</p>\n            </div>\n          )}\n\n          <section className=\"space-y-base border-t border-border pt-base text-center\">\n            <p className=\"text-sm text-low\">Choose a provider to continue:</p>\n            <div className=\"flex flex-col items-center gap-2\">\n              <OAuthButton\n                provider=\"github\"\n                label=\"Continue with GitHub\"\n                onClick={() => void handleOAuthLogin(\"github\")}\n                disabled={pendingProvider !== null}\n                loading={pendingProvider === \"github\"}\n              />\n              <OAuthButton\n                provider=\"google\"\n                label=\"Continue with Google\"\n                onClick={() => void handleOAuthLogin(\"google\")}\n                disabled={pendingProvider !== null}\n                loading={pendingProvider === \"google\"}\n              />\n            </div>\n          </section>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction OAuthButton({\n  provider,\n  label,\n  onClick,\n  disabled,\n  loading,\n}: {\n  provider: OAuthProvider;\n  label: string;\n  onClick: () => void;\n  disabled?: boolean;\n  loading?: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      className=\"flex h-10 min-w-[280px] items-center justify-center rounded-[4px] border border-[#dadce0] bg-[#f2f2f2] px-3 text-[14px] font-medium text-[#1f1f1f] transition-colors hover:bg-[#e8eaed] active:bg-[#e2e3e5] disabled:cursor-not-allowed disabled:opacity-50\"\n      style={{ fontFamily: \"'Roboto', Arial, sans-serif\" }}\n      onClick={onClick}\n      disabled={disabled || loading}\n    >\n      {loading\n        ? `Opening ${provider === \"github\" ? \"GitHub\" : \"Google\"}...`\n        : label}\n    </button>\n  );\n}\n\nfunction StatusCard({\n  title,\n  variant,\n  children,\n}: {\n  title: string;\n  variant?: \"error\";\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-base py-double\">\n        <div className=\"rounded-sm border border-border bg-secondary p-double\">\n          <h2\n            className={`text-lg font-semibold ${variant === \"error\" ? \"text-error\" : \"text-high\"}`}\n          >\n            {title}\n          </h2>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/LoginCompletePage.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useNavigate, useSearch } from \"@tanstack/react-router\";\nimport { redeemOAuth } from \"@remote/shared/lib/api\";\nimport { storeTokens } from \"@remote/shared/lib/auth\";\nimport { retrieveVerifier, clearVerifier } from \"@remote/shared/lib/pkce\";\n\nfunction getSafeNextPath(nextPath: string | undefined): string {\n  if (!nextPath) {\n    return \"/\";\n  }\n\n  if (!nextPath.startsWith(\"/\") || nextPath.startsWith(\"//\")) {\n    return \"/\";\n  }\n\n  return nextPath;\n}\n\nexport default function LoginCompletePage() {\n  const navigate = useNavigate();\n  const search = useSearch({ from: \"/account_/complete\" });\n  const [error, setError] = useState<string | null>(null);\n\n  const handoffId = search.handoff_id;\n  const appCode = search.app_code;\n  const oauthError = search.error;\n  const nextPath = getSafeNextPath(search.next);\n\n  useEffect(() => {\n    const complete = async () => {\n      if (oauthError) {\n        setError(`OAuth error: ${oauthError}`);\n        return;\n      }\n\n      if (!handoffId || !appCode) {\n        return;\n      }\n\n      try {\n        const verifier = retrieveVerifier();\n        if (!verifier) {\n          setError(\"OAuth session lost. Please try again.\");\n          return;\n        }\n\n        const { access_token, refresh_token } = await redeemOAuth(\n          handoffId,\n          appCode,\n          verifier,\n        );\n\n        await storeTokens(access_token, refresh_token);\n        clearVerifier();\n\n        window.location.replace(nextPath);\n      } catch (e) {\n        setError(e instanceof Error ? e.message : \"Failed to complete login\");\n        clearVerifier();\n      }\n    };\n\n    void complete();\n  }, [handoffId, appCode, oauthError, nextPath]);\n\n  if (error) {\n    return (\n      <StatusCard title=\"Login failed\" variant=\"error\">\n        <p className=\"text-sm text-normal mt-base\">{error}</p>\n        <button\n          type=\"button\"\n          className=\"mt-double w-full rounded-sm bg-brand px-base py-half text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover\"\n          onClick={() =>\n            navigate({\n              to: \"/account\",\n              search: nextPath !== \"/\" ? { next: nextPath } : undefined,\n              replace: true,\n            })\n          }\n        >\n          Try again\n        </button>\n      </StatusCard>\n    );\n  }\n\n  return (\n    <StatusCard title=\"Completing login...\">\n      <p className=\"text-sm text-low mt-base\">Processing OAuth callback...</p>\n    </StatusCard>\n  );\n}\n\nfunction StatusCard({\n  title,\n  variant,\n  children,\n}: {\n  title: string;\n  variant?: \"error\";\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-base py-double\">\n        <div className=\"rounded-sm border border-border bg-secondary p-double\">\n          <h2\n            className={`text-lg font-semibold ${variant === \"error\" ? \"text-error\" : \"text-high\"}`}\n          >\n            {title}\n          </h2>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/LoginPage.tsx",
    "content": "import { useState } from \"react\";\nimport { useSearch } from \"@tanstack/react-router\";\nimport { initOAuth, type OAuthProvider } from \"@remote/shared/lib/api\";\nimport { BrandLogo } from \"@remote/shared/components/BrandLogo\";\nimport {\n  generateVerifier,\n  generateChallenge,\n  storeVerifier,\n} from \"@remote/shared/lib/pkce\";\n\nexport default function LoginPage() {\n  const { next } = useSearch({ from: \"/account\" });\n  const [error, setError] = useState<string | null>(null);\n  const [pending, setPending] = useState<OAuthProvider | null>(null);\n\n  const handleLogin = async (provider: OAuthProvider) => {\n    setPending(provider);\n    setError(null);\n\n    try {\n      const verifier = generateVerifier();\n      const challenge = await generateChallenge(verifier);\n      storeVerifier(verifier);\n\n      const appBase =\n        import.meta.env.VITE_APP_BASE_URL || window.location.origin;\n      const callbackUrl = new URL(\"/account/complete\", appBase);\n      if (next) {\n        callbackUrl.searchParams.set(\"next\", next);\n      }\n      const returnTo = callbackUrl.toString();\n\n      const { authorize_url } = await initOAuth(provider, returnTo, challenge);\n      window.location.assign(authorize_url);\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"OAuth init failed\");\n      setPending(null);\n    }\n  };\n\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-base py-double\">\n        <div className=\"space-y-double rounded-sm border border-border bg-secondary p-double\">\n          <header className=\"space-y-double text-center\">\n            <div className=\"flex justify-center\">\n              <BrandLogo className=\"h-8 w-auto\" />\n            </div>\n            <p className=\"text-sm text-low\">Sign in to continue</p>\n          </header>\n\n          {error && (\n            <div className=\"rounded-sm border border-error/30 bg-error/10 p-base\">\n              <p className=\"text-sm text-high\">{error}</p>\n            </div>\n          )}\n\n          <section className=\"flex flex-col items-center gap-2\">\n            <OAuthButton\n              provider=\"github\"\n              label=\"Continue with GitHub\"\n              onClick={() => void handleLogin(\"github\")}\n              disabled={pending !== null}\n              loading={pending === \"github\"}\n            />\n            <OAuthButton\n              provider=\"google\"\n              label=\"Continue with Google\"\n              onClick={() => void handleLogin(\"google\")}\n              disabled={pending !== null}\n              loading={pending === \"google\"}\n            />\n          </section>\n\n          <p className=\"text-center text-sm text-low\">\n            Need help getting started?{\" \"}\n            <a\n              href=\"https://www.vibekanban.com/docs\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-normal underline decoration-border underline-offset-4 transition-colors hover:text-high\"\n            >\n              Read the docs\n            </a>\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction OAuthButton({\n  provider,\n  label,\n  onClick,\n  disabled,\n  loading,\n}: {\n  provider: OAuthProvider;\n  label: string;\n  onClick: () => void;\n  disabled?: boolean;\n  loading?: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      className=\"flex h-10 min-w-[280px] items-center justify-center rounded-[4px] border border-[#dadce0] bg-[#f2f2f2] px-3 text-[14px] font-medium text-[#1f1f1f] transition-colors hover:bg-[#e8eaed] active:bg-[#e2e3e5] disabled:cursor-not-allowed disabled:opacity-50\"\n      style={{ fontFamily: \"'Roboto', Arial, sans-serif\" }}\n      onClick={onClick}\n      disabled={disabled || loading}\n    >\n      {loading\n        ? `Opening ${provider === \"github\" ? \"GitHub\" : \"Google\"}...`\n        : label}\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/NotFoundPage.tsx",
    "content": "export default function NotFoundPage() {\n  return (\n    <div className=\"flex h-full items-center justify-center\">\n      <div className=\"text-center\">\n        <h1 className=\"text-xl font-semibold text-high\">404</h1>\n        <p className=\"mt-base text-normal\">Page not found</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/RemoteProjectKanbanShell.tsx",
    "content": "import { ProjectKanban } from \"@/pages/kanban/ProjectKanban\";\n\nexport function RemoteProjectKanbanShell() {\n  return <ProjectKanban />;\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/RemoteWorkspacesPageShell.tsx",
    "content": "import { useEffect, type ReactNode } from \"react\";\nimport { useParams } from \"@tanstack/react-router\";\nimport WorkspacesUnavailablePage from \"@remote/pages/WorkspacesUnavailablePage\";\nimport { useRelayWorkspaceHostHealth } from \"@remote/shared/hooks/useRelayWorkspaceHostHealth\";\nimport { useWorkspaceContext } from \"@/shared/hooks/useWorkspaceContext\";\nimport { useMobileWorkspaceTitle } from \"@remote/shared/stores/useMobileWorkspaceTitle\";\n\ninterface RemoteWorkspacesPageShellProps {\n  children: ReactNode;\n}\n\nfunction WorkspaceTitleSync() {\n  const { workspace } = useWorkspaceContext();\n  const setTitle = useMobileWorkspaceTitle((s) => s.setTitle);\n\n  useEffect(() => {\n    setTitle(workspace?.name ?? workspace?.branch ?? null);\n    return () => setTitle(null);\n  }, [workspace?.name, workspace?.branch, setTitle]);\n\n  return null;\n}\n\nexport function RemoteWorkspacesPageShell({\n  children,\n}: RemoteWorkspacesPageShellProps) {\n  const { hostId } = useParams({ strict: false });\n  const hostHealth = useRelayWorkspaceHostHealth(hostId ?? null);\n\n  if (!hostId) {\n    return <WorkspacesUnavailablePage />;\n  }\n\n  if (hostHealth.isChecking) {\n    return (\n      <WorkspacesUnavailablePage\n        blockedHost={{\n          id: hostId,\n          name: null,\n        }}\n        isCheckingBlockedHost\n      />\n    );\n  }\n\n  if (hostHealth.isError) {\n    return (\n      <WorkspacesUnavailablePage\n        blockedHost={{\n          id: hostId,\n          name: null,\n          errorMessage: hostHealth.errorMessage,\n        }}\n      />\n    );\n  }\n\n  return (\n    <>\n      <WorkspaceTitleSync />\n      {children}\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/UpgradeCompletePage.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useNavigate, useSearch } from \"@tanstack/react-router\";\nimport { redeemOAuth } from \"@remote/shared/lib/api\";\nimport { storeTokens } from \"@remote/shared/lib/auth\";\nimport { clearVerifier, retrieveVerifier } from \"@remote/shared/lib/pkce\";\n\nexport default function UpgradeCompletePage() {\n  const navigate = useNavigate();\n  const search = useSearch({ from: \"/upgrade_/complete\" });\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState(false);\n\n  const handoffId = search.handoff_id;\n  const appCode = search.app_code;\n  const oauthError = search.error;\n\n  useEffect(() => {\n    const completeLogin = async () => {\n      if (oauthError) {\n        setError(`OAuth error: ${oauthError}`);\n        return;\n      }\n\n      if (!handoffId || !appCode) {\n        return;\n      }\n\n      try {\n        const verifier = retrieveVerifier();\n        if (!verifier) {\n          setError(\"OAuth session lost. Please try again.\");\n          return;\n        }\n\n        const { access_token, refresh_token } = await redeemOAuth(\n          handoffId,\n          appCode,\n          verifier,\n        );\n\n        await storeTokens(access_token, refresh_token);\n        clearVerifier();\n        setSuccess(true);\n\n        setTimeout(() => {\n          window.location.replace(\"/upgrade\");\n        }, 700);\n      } catch (e) {\n        setError(e instanceof Error ? e.message : \"Failed to complete login\");\n        clearVerifier();\n      }\n    };\n\n    void completeLogin();\n  }, [handoffId, appCode, oauthError]);\n\n  if (error) {\n    return (\n      <StatusCard title=\"Login failed\" variant=\"error\">\n        <p className=\"mt-base text-sm text-normal\">{error}</p>\n        <button\n          type=\"button\"\n          className=\"mt-double w-full rounded-sm bg-brand px-base py-half text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover\"\n          onClick={() => navigate({ to: \"/upgrade\", replace: true })}\n        >\n          Try again\n        </button>\n      </StatusCard>\n    );\n  }\n\n  if (success) {\n    return (\n      <StatusCard title=\"Login successful\">\n        <p className=\"mt-base text-sm text-normal\">\n          Redirecting to complete your subscription...\n        </p>\n      </StatusCard>\n    );\n  }\n\n  return (\n    <StatusCard title=\"Completing login...\">\n      <p className=\"mt-base text-sm text-low\">Processing OAuth callback...</p>\n    </StatusCard>\n  );\n}\n\nfunction StatusCard({\n  title,\n  variant,\n  children,\n}: {\n  title: string;\n  variant?: \"error\";\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-base py-double\">\n        <div className=\"rounded-sm border border-border bg-secondary p-double\">\n          <h2\n            className={`text-lg font-semibold ${\n              variant === \"error\" ? \"text-error\" : \"text-high\"\n            }`}\n          >\n            {title}\n          </h2>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/UpgradePage.tsx",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport { useNavigate, useSearch } from \"@tanstack/react-router\";\nimport type { OrganizationWithRole } from \"shared/types\";\nimport { useAuth } from \"@/shared/hooks/auth/useAuth\";\nimport { BrandLogo } from \"@remote/shared/components/BrandLogo\";\nimport {\n  createCheckoutSession,\n  initOAuth,\n  listOrganizations,\n  type OAuthProvider,\n} from \"@remote/shared/lib/api\";\nimport {\n  generateChallenge,\n  generateVerifier,\n  storeVerifier,\n} from \"@remote/shared/lib/pkce\";\n\nconst UPGRADE_ORG_KEY = \"upgrade_org_id\";\nconst UPGRADE_RETURN_KEY = \"upgrade_return\";\n\ntype Step = \"plan-selection\" | \"sign-in\" | \"org-selection\";\n\nexport default function UpgradePage() {\n  const search = useSearch({ from: \"/upgrade\" });\n  const navigate = useNavigate();\n  const { isSignedIn, isLoaded } = useAuth();\n\n  const [step, setStep] = useState<Step>(\"plan-selection\");\n  const [organizations, setOrganizations] = useState<OrganizationWithRole[]>(\n    [],\n  );\n  const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);\n  const [loadingOrganizations, setLoadingOrganizations] = useState(false);\n  const [oauthLoading, setOauthLoading] = useState<OAuthProvider | null>(null);\n  const [checkoutLoading, setCheckoutLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const loadOrganizations = useCallback(async () => {\n    setLoadingOrganizations(true);\n    setError(null);\n\n    try {\n      const { organizations: orgs } = await listOrganizations();\n      const eligibleOrgs = orgs.filter(\n        (org) => !org.is_personal && org.user_role === \"ADMIN\",\n      );\n      setOrganizations(eligibleOrgs);\n\n      const savedOrgId = localStorage.getItem(UPGRADE_ORG_KEY);\n      const preferredOrgId = search.org_id ?? savedOrgId;\n      const preferredOrg = preferredOrgId\n        ? eligibleOrgs.find((org) => org.id === preferredOrgId)\n        : null;\n\n      if (preferredOrg) {\n        setSelectedOrgId(preferredOrg.id);\n      } else {\n        setSelectedOrgId(eligibleOrgs[0]?.id ?? null);\n      }\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"Failed to load organizations\");\n    } finally {\n      setLoadingOrganizations(false);\n    }\n  }, [search.org_id]);\n\n  useEffect(() => {\n    if (search.org_id) {\n      localStorage.setItem(UPGRADE_ORG_KEY, search.org_id);\n      setSelectedOrgId(search.org_id);\n    }\n  }, [search.org_id]);\n\n  useEffect(() => {\n    if (!isLoaded || !isSignedIn) {\n      return;\n    }\n\n    const isReturningFromUpgradeLogin =\n      sessionStorage.getItem(UPGRADE_RETURN_KEY) === \"true\";\n    if (!isReturningFromUpgradeLogin) {\n      return;\n    }\n\n    sessionStorage.removeItem(UPGRADE_RETURN_KEY);\n    setStep(\"org-selection\");\n    void loadOrganizations();\n  }, [isLoaded, isSignedIn, loadOrganizations]);\n\n  const handleSubscribe = async () => {\n    if (!isLoaded) {\n      return;\n    }\n\n    if (isSignedIn) {\n      setStep(\"org-selection\");\n      await loadOrganizations();\n      return;\n    }\n\n    setStep(\"sign-in\");\n  };\n\n  const handleOAuthLogin = async (provider: OAuthProvider) => {\n    setOauthLoading(provider);\n    setError(null);\n\n    try {\n      const verifier = generateVerifier();\n      const challenge = await generateChallenge(verifier);\n      storeVerifier(verifier);\n\n      sessionStorage.setItem(UPGRADE_RETURN_KEY, \"true\");\n\n      const appBase =\n        import.meta.env.VITE_APP_BASE_URL || window.location.origin;\n      const returnTo = `${appBase}/upgrade/complete`;\n      const { authorize_url } = await initOAuth(provider, returnTo, challenge);\n\n      window.location.assign(authorize_url);\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"OAuth init failed\");\n      setOauthLoading(null);\n    }\n  };\n\n  const handleCheckout = async () => {\n    if (!selectedOrgId) {\n      return;\n    }\n\n    setCheckoutLoading(true);\n    setError(null);\n\n    try {\n      localStorage.setItem(UPGRADE_ORG_KEY, selectedOrgId);\n\n      const appBase =\n        import.meta.env.VITE_APP_BASE_URL || window.location.origin;\n      const { url } = await createCheckoutSession(\n        selectedOrgId,\n        `${appBase}/upgrade/success`,\n        `${appBase}/upgrade?org_id=${selectedOrgId}`,\n      );\n\n      window.location.assign(url);\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"Failed to start checkout\");\n      setCheckoutLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-6xl flex-col justify-center px-base py-double\">\n        <div className=\"mx-auto w-full max-w-5xl\">\n          <header className=\"mb-double space-y-base text-center\">\n            <div className=\"flex justify-center\">\n              <BrandLogo className=\"h-8 w-auto\" />\n            </div>\n            <h1 className=\"text-xl font-semibold text-high\">\n              Choose Your Plan\n            </h1>\n            <p className=\"text-sm text-low\">\n              Pick the plan that fits your team and continue to checkout.\n            </p>\n          </header>\n\n          {error && (\n            <div className=\"mx-auto mb-double w-full max-w-xl rounded-sm border border-error/40 bg-error/10 p-base\">\n              <p className=\"text-sm text-high\">{error}</p>\n              <button\n                type=\"button\"\n                className=\"mt-half text-xs text-normal hover:text-high\"\n                onClick={() => setError(null)}\n              >\n                Dismiss\n              </button>\n            </div>\n          )}\n\n          {step === \"plan-selection\" && (\n            <div className=\"grid gap-base md:grid-cols-3\">\n              <PlanCard\n                name=\"Basic\"\n                price=\"Free\"\n                description=\"For individual users\"\n                features={[\n                  \"1 user included\",\n                  \"Core features\",\n                  \"Community support\",\n                ]}\n              />\n              <PlanCard\n                name=\"Pro\"\n                price=\"$30\"\n                priceUnit=\"/user/month\"\n                description=\"For teams of 2-49\"\n                features={[\n                  \"2-49 users\",\n                  \"All Basic features\",\n                  \"99.5% SLA\",\n                  \"Discord support\",\n                ]}\n                popular\n                cta=\"Subscribe\"\n                onCta={() => {\n                  void handleSubscribe();\n                }}\n              />\n              <PlanCard\n                name=\"Enterprise\"\n                price=\"Custom\"\n                description=\"For large organizations\"\n                features={[\n                  \"50+ users\",\n                  \"All Pro features\",\n                  \"SSO / SAML\",\n                  \"99.9% SLA\",\n                  \"Dedicated Slack channel\",\n                ]}\n                cta=\"Contact Sales\"\n                onCta={() => {\n                  window.location.assign(\n                    \"mailto:louis@bloop.ai?subject=Enterprise%20Plan%20Inquiry\",\n                  );\n                }}\n              />\n            </div>\n          )}\n\n          {step === \"sign-in\" && (\n            <div className=\"mx-auto w-full max-w-lg\">\n              <div className=\"rounded-sm border border-border bg-secondary p-double\">\n                <h2 className=\"text-lg font-semibold text-high\">Sign In</h2>\n                <p className=\"mt-half text-sm text-low\">\n                  Sign in to continue with your subscription.\n                </p>\n\n                <div className=\"mt-double flex flex-col items-center gap-2\">\n                  <OAuthButton\n                    label=\"Continue with GitHub\"\n                    onClick={() => {\n                      void handleOAuthLogin(\"github\");\n                    }}\n                    disabled={oauthLoading !== null}\n                    loading={oauthLoading === \"github\"}\n                  />\n                  <OAuthButton\n                    label=\"Continue with Google\"\n                    onClick={() => {\n                      void handleOAuthLogin(\"google\");\n                    }}\n                    disabled={oauthLoading !== null}\n                    loading={oauthLoading === \"google\"}\n                  />\n                </div>\n\n                <button\n                  type=\"button\"\n                  className=\"mt-double w-full rounded-sm border border-border bg-primary px-base py-half text-sm text-normal hover:text-high\"\n                  onClick={() => setStep(\"plan-selection\")}\n                >\n                  Back to plans\n                </button>\n              </div>\n            </div>\n          )}\n\n          {step === \"org-selection\" && (\n            <div className=\"mx-auto w-full max-w-lg\">\n              <div className=\"rounded-sm border border-border bg-secondary p-double\">\n                <h2 className=\"text-lg font-semibold text-high\">\n                  Select Organization\n                </h2>\n                <p className=\"mt-half text-sm text-low\">\n                  Choose the organization you want to upgrade.\n                </p>\n\n                {loadingOrganizations ? (\n                  <p className=\"mt-double text-sm text-normal\">\n                    Loading organizations...\n                  </p>\n                ) : organizations.length === 0 ? (\n                  <>\n                    <p className=\"mt-double text-sm text-normal\">\n                      No eligible organizations found. You need admin access to\n                      a non-personal organization to upgrade.\n                    </p>\n                    <button\n                      type=\"button\"\n                      className=\"mt-double w-full rounded-sm bg-brand px-base py-half text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover\"\n                      onClick={() => navigate({ to: \"/\" })}\n                    >\n                      Go to Organizations\n                    </button>\n                  </>\n                ) : (\n                  <>\n                    <div className=\"mt-double space-y-half\">\n                      {organizations.map((organization) => (\n                        <label\n                          key={organization.id}\n                          className={`block cursor-pointer rounded-sm border p-base transition-colors ${\n                            selectedOrgId === organization.id\n                              ? \"border-brand bg-panel\"\n                              : \"border-border bg-primary hover:border-high\"\n                          }`}\n                        >\n                          <div className=\"flex items-center gap-base\">\n                            <input\n                              type=\"radio\"\n                              name=\"organization\"\n                              value={organization.id}\n                              checked={selectedOrgId === organization.id}\n                              onChange={() => setSelectedOrgId(organization.id)}\n                            />\n                            <div>\n                              <p className=\"text-sm font-medium text-high\">\n                                {organization.name}\n                              </p>\n                              <p className=\"text-xs text-low\">\n                                @{organization.slug}\n                              </p>\n                            </div>\n                          </div>\n                        </label>\n                      ))}\n                    </div>\n\n                    <button\n                      type=\"button\"\n                      className=\"mt-double w-full rounded-sm bg-brand px-base py-half text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover disabled:cursor-not-allowed disabled:opacity-50\"\n                      onClick={() => {\n                        void handleCheckout();\n                      }}\n                      disabled={!selectedOrgId || checkoutLoading}\n                    >\n                      {checkoutLoading\n                        ? \"Redirecting...\"\n                        : \"Continue to Checkout\"}\n                    </button>\n                  </>\n                )}\n\n                <button\n                  type=\"button\"\n                  className=\"mt-base w-full rounded-sm border border-border bg-primary px-base py-half text-sm text-normal hover:text-high\"\n                  onClick={() => setStep(\"plan-selection\")}\n                >\n                  Back to plans\n                </button>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction PlanCard({\n  name,\n  price,\n  priceUnit,\n  description,\n  features,\n  popular,\n  cta,\n  onCta,\n}: {\n  name: string;\n  price: string;\n  priceUnit?: string;\n  description: string;\n  features: string[];\n  popular?: boolean;\n  cta?: string;\n  onCta?: () => void;\n}) {\n  return (\n    <div\n      className={`rounded-sm border bg-secondary p-double ${\n        popular ? \"border-brand\" : \"border-border\"\n      }`}\n    >\n      <div className=\"mb-base text-center\">\n        <h2 className=\"text-lg font-semibold text-high\">{name}</h2>\n        <p className=\"mt-half text-xl font-semibold text-high\">\n          {price}\n          {priceUnit && (\n            <span className=\"ml-half text-sm text-low\">{priceUnit}</span>\n          )}\n        </p>\n        <p className=\"mt-half text-sm text-low\">{description}</p>\n      </div>\n\n      <ul className=\"space-y-half\">\n        {features.map((feature, index) => (\n          <li key={`${feature}-${index}`} className=\"text-sm text-normal\">\n            {feature}\n          </li>\n        ))}\n      </ul>\n\n      {cta && onCta ? (\n        <button\n          type=\"button\"\n          className={`mt-double w-full rounded-sm px-base py-half text-sm font-medium transition-colors ${\n            popular\n              ? \"bg-brand text-on-brand hover:bg-brand-hover\"\n              : \"bg-primary text-high hover:bg-panel\"\n          }`}\n          onClick={onCta}\n        >\n          {cta}\n        </button>\n      ) : (\n        <div className=\"mt-double w-full rounded-sm border border-border bg-primary px-base py-half text-center text-sm text-low\">\n          Current plan\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction OAuthButton({\n  label,\n  onClick,\n  disabled,\n  loading,\n}: {\n  label: string;\n  onClick: () => void;\n  disabled?: boolean;\n  loading?: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      className=\"flex h-10 min-w-[280px] items-center justify-center rounded-[4px] border border-[#dadce0] bg-[#f2f2f2] px-3 text-[14px] font-medium text-[#1f1f1f] transition-colors hover:bg-[#e8eaed] active:bg-[#e2e3e5] disabled:cursor-not-allowed disabled:opacity-50\"\n      style={{ fontFamily: \"'Roboto', Arial, sans-serif\" }}\n      onClick={onClick}\n      disabled={disabled || loading}\n    >\n      {loading ? \"Opening provider...\" : label}\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/UpgradeSuccessPage.tsx",
    "content": "import { Link } from \"@tanstack/react-router\";\n\nexport default function UpgradeSuccessPage() {\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-base py-double\">\n        <div className=\"rounded-sm border border-border bg-secondary p-double text-center\">\n          <h1 className=\"text-xl font-semibold text-high\">Upgrade Complete</h1>\n          <p className=\"mt-base text-sm text-normal\">\n            Your subscription is now active.\n          </p>\n          <p className=\"mt-base text-sm text-low\">\n            Continue in the web app, or return to your desktop app to keep\n            working.\n          </p>\n\n          <Link\n            to=\"/\"\n            className=\"mt-double block w-full rounded-sm bg-brand px-base py-half text-sm font-medium text-on-brand transition-colors hover:bg-brand-hover\"\n          >\n            Go to Organizations\n          </Link>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/pages/WorkspacesUnavailablePage.tsx",
    "content": "import { useMemo } from \"react\";\nimport { useParams } from \"@tanstack/react-router\";\nimport { SettingsDialog } from \"@/shared/dialogs/settings/SettingsDialog\";\nimport { REMOTE_SETTINGS_SECTIONS } from \"@remote/shared/constants/settings\";\n\ninterface BlockedHostState {\n  id: string;\n  name: string | null;\n  errorMessage?: string | null;\n}\n\ninterface WorkspacesUnavailablePageProps {\n  blockedHost?: BlockedHostState;\n  isCheckingBlockedHost?: boolean;\n}\n\nexport default function WorkspacesUnavailablePage({\n  blockedHost,\n  isCheckingBlockedHost = false,\n}: WorkspacesUnavailablePageProps) {\n  const { hostId } = useParams({ strict: false });\n\n  const selectedHostId = useMemo(\n    () => blockedHost?.id ?? hostId ?? null,\n    [blockedHost?.id, hostId],\n  );\n\n  const selectedHostName = useMemo(\n    () => blockedHost?.name ?? selectedHostId,\n    [blockedHost?.name, selectedHostId],\n  );\n\n  const isBlockedHostState = Boolean(blockedHost);\n\n  const openRelaySettings = () => {\n    void SettingsDialog.show({\n      initialSection: \"relay\",\n      sections: REMOTE_SETTINGS_SECTIONS,\n    });\n  };\n\n  return (\n    <div className=\"mx-auto flex h-full w-full max-w-3xl items-center justify-center px-double py-double\">\n      <div className=\"w-full space-y-base rounded-sm border border-border bg-secondary p-double\">\n        <h1 className=\"text-xl font-semibold text-high\">Workspaces</h1>\n\n        {isCheckingBlockedHost ? (\n          <p className=\"text-sm text-low\">\n            Connecting to{\" \"}\n            <span className=\"font-medium text-high\">\n              {selectedHostName ?? \"selected host\"}\n            </span>\n            ...\n          </p>\n        ) : isBlockedHostState ? (\n          <div className=\"space-y-base\">\n            <div className=\"rounded-sm border border-warning/40 bg-warning/10 p-base\">\n              <p className=\"text-sm font-medium text-high\">\n                Could not connect to {selectedHostName ?? \"the selected host\"}.\n              </p>\n              <p className=\"mt-half text-sm text-low\">\n                This host is offline or no longer reachable from this browser.\n              </p>\n            </div>\n\n            <ol className=\"list-inside list-decimal space-y-half text-sm text-low\">\n              <li>\n                On that machine, open Vibe Kanban and confirm the host is\n                online.\n              </li>\n              <li>\n                If it still fails, open Relay Settings and pair this host again.\n              </li>\n            </ol>\n\n            {blockedHost?.errorMessage && (\n              <p className=\"break-all text-xs text-low\">\n                Last connection error: {blockedHost.errorMessage}\n              </p>\n            )}\n          </div>\n        ) : (\n          <p className=\"text-sm text-low\">\n            Select an online host in the app bar to load local workspaces\n            through relay.\n          </p>\n        )}\n\n        <button\n          type=\"button\"\n          onClick={openRelaySettings}\n          className=\"rounded-sm border border-border bg-primary px-base py-half text-xs text-normal hover:border-brand/60\"\n        >\n          Open Relay Settings\n        </button>\n\n        {isBlockedHostState && (\n          <p className=\"text-sm text-low\">\n            After the host is online again, select it from the app bar and\n            retry.\n          </p>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\nimport { Route as rootRouteImport } from './routes/__root'\nimport { Route as UpgradeRouteImport } from './routes/upgrade'\nimport { Route as NotificationsRouteImport } from './routes/notifications'\nimport { Route as LoginRouteImport } from './routes/login'\nimport { Route as AccountRouteImport } from './routes/account'\nimport { Route as IndexRouteImport } from './routes/index'\nimport { Route as UpgradeSuccessRouteImport } from './routes/upgrade_.success'\nimport { Route as UpgradeCompleteRouteImport } from './routes/upgrade_.complete'\nimport { Route as ProjectsProjectIdRouteImport } from './routes/projects.$projectId'\nimport { Route as LoginCompleteRouteImport } from './routes/login_.complete'\nimport { Route as AccountCompleteRouteImport } from './routes/account_.complete'\nimport { Route as InvitationsTokenCompleteRouteImport } from './routes/invitations.$token.complete'\nimport { Route as InvitationsTokenAcceptRouteImport } from './routes/invitations.$token.accept'\nimport { Route as HostsHostIdWorkspacesRouteImport } from './routes/hosts.$hostId.workspaces'\nimport { Route as AccountOrganizationsOrgIdRouteImport } from './routes/account_.organizations.$orgId'\nimport { Route as ProjectsProjectIdIssuesIssueIdRouteImport } from './routes/projects.$projectId_.issues.$issueId'\nimport { Route as HostsHostIdWorkspacesCreateRouteImport } from './routes/hosts.$hostId.workspaces_.create'\nimport { Route as HostsHostIdWorkspacesWorkspaceIdRouteImport } from './routes/hosts.$hostId.workspaces_.$workspaceId'\nimport { Route as HostsHostIdWorkspacesWorkspaceIdVscodeRouteImport } from './routes/hosts.$hostId.workspaces.$workspaceId.vscode'\nimport { Route as ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRouteImport } from './routes/projects.$projectId_.hosts.$hostId.workspaces.create.$draftId'\nimport { Route as ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRouteImport } from './routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.$workspaceId'\nimport { Route as ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRouteImport } from './routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.create.$draftId'\n\nconst UpgradeRoute = UpgradeRouteImport.update({\n  id: '/upgrade',\n  path: '/upgrade',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst NotificationsRoute = NotificationsRouteImport.update({\n  id: '/notifications',\n  path: '/notifications',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst LoginRoute = LoginRouteImport.update({\n  id: '/login',\n  path: '/login',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst AccountRoute = AccountRouteImport.update({\n  id: '/account',\n  path: '/account',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst IndexRoute = IndexRouteImport.update({\n  id: '/',\n  path: '/',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst UpgradeSuccessRoute = UpgradeSuccessRouteImport.update({\n  id: '/upgrade_/success',\n  path: '/upgrade/success',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst UpgradeCompleteRoute = UpgradeCompleteRouteImport.update({\n  id: '/upgrade_/complete',\n  path: '/upgrade/complete',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst ProjectsProjectIdRoute = ProjectsProjectIdRouteImport.update({\n  id: '/projects/$projectId',\n  path: '/projects/$projectId',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst LoginCompleteRoute = LoginCompleteRouteImport.update({\n  id: '/login_/complete',\n  path: '/login/complete',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst AccountCompleteRoute = AccountCompleteRouteImport.update({\n  id: '/account_/complete',\n  path: '/account/complete',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst InvitationsTokenCompleteRoute =\n  InvitationsTokenCompleteRouteImport.update({\n    id: '/invitations/$token/complete',\n    path: '/invitations/$token/complete',\n    getParentRoute: () => rootRouteImport,\n  } as any)\nconst InvitationsTokenAcceptRoute = InvitationsTokenAcceptRouteImport.update({\n  id: '/invitations/$token/accept',\n  path: '/invitations/$token/accept',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst HostsHostIdWorkspacesRoute = HostsHostIdWorkspacesRouteImport.update({\n  id: '/hosts/$hostId/workspaces',\n  path: '/hosts/$hostId/workspaces',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst AccountOrganizationsOrgIdRoute =\n  AccountOrganizationsOrgIdRouteImport.update({\n    id: '/account_/organizations/$orgId',\n    path: '/account/organizations/$orgId',\n    getParentRoute: () => rootRouteImport,\n  } as any)\nconst ProjectsProjectIdIssuesIssueIdRoute =\n  ProjectsProjectIdIssuesIssueIdRouteImport.update({\n    id: '/projects/$projectId_/issues/$issueId',\n    path: '/projects/$projectId/issues/$issueId',\n    getParentRoute: () => rootRouteImport,\n  } as any)\nconst HostsHostIdWorkspacesCreateRoute =\n  HostsHostIdWorkspacesCreateRouteImport.update({\n    id: '/hosts/$hostId/workspaces_/create',\n    path: '/hosts/$hostId/workspaces/create',\n    getParentRoute: () => rootRouteImport,\n  } as any)\nconst HostsHostIdWorkspacesWorkspaceIdRoute =\n  HostsHostIdWorkspacesWorkspaceIdRouteImport.update({\n    id: '/hosts/$hostId/workspaces_/$workspaceId',\n    path: '/hosts/$hostId/workspaces/$workspaceId',\n    getParentRoute: () => rootRouteImport,\n  } as any)\nconst HostsHostIdWorkspacesWorkspaceIdVscodeRoute =\n  HostsHostIdWorkspacesWorkspaceIdVscodeRouteImport.update({\n    id: '/$workspaceId/vscode',\n    path: '/$workspaceId/vscode',\n    getParentRoute: () => HostsHostIdWorkspacesRoute,\n  } as any)\nconst ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute =\n  ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRouteImport.update({\n    id: '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId',\n    path: '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId',\n    getParentRoute: () => rootRouteImport,\n  } as any)\nconst ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute =\n  ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRouteImport.update(\n    {\n      id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId',\n      path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId',\n      getParentRoute: () => rootRouteImport,\n    } as any,\n  )\nconst ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute =\n  ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRouteImport.update(\n    {\n      id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId',\n      path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId',\n      getParentRoute: () => rootRouteImport,\n    } as any,\n  )\n\nexport interface FileRoutesByFullPath {\n  '/': typeof IndexRoute\n  '/account': typeof AccountRoute\n  '/login': typeof LoginRoute\n  '/notifications': typeof NotificationsRoute\n  '/upgrade': typeof UpgradeRoute\n  '/account/complete': typeof AccountCompleteRoute\n  '/login/complete': typeof LoginCompleteRoute\n  '/projects/$projectId': typeof ProjectsProjectIdRoute\n  '/upgrade/complete': typeof UpgradeCompleteRoute\n  '/upgrade/success': typeof UpgradeSuccessRoute\n  '/account/organizations/$orgId': typeof AccountOrganizationsOrgIdRoute\n  '/hosts/$hostId/workspaces': typeof HostsHostIdWorkspacesRouteWithChildren\n  '/invitations/$token/accept': typeof InvitationsTokenAcceptRoute\n  '/invitations/$token/complete': typeof InvitationsTokenCompleteRoute\n  '/hosts/$hostId/workspaces/$workspaceId': typeof HostsHostIdWorkspacesWorkspaceIdRoute\n  '/hosts/$hostId/workspaces/create': typeof HostsHostIdWorkspacesCreateRoute\n  '/projects/$projectId/issues/$issueId': typeof ProjectsProjectIdIssuesIssueIdRoute\n  '/hosts/$hostId/workspaces/$workspaceId/vscode': typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute\n  '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute\n  '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute\n  '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute\n}\nexport interface FileRoutesByTo {\n  '/': typeof IndexRoute\n  '/account': typeof AccountRoute\n  '/login': typeof LoginRoute\n  '/notifications': typeof NotificationsRoute\n  '/upgrade': typeof UpgradeRoute\n  '/account/complete': typeof AccountCompleteRoute\n  '/login/complete': typeof LoginCompleteRoute\n  '/projects/$projectId': typeof ProjectsProjectIdRoute\n  '/upgrade/complete': typeof UpgradeCompleteRoute\n  '/upgrade/success': typeof UpgradeSuccessRoute\n  '/account/organizations/$orgId': typeof AccountOrganizationsOrgIdRoute\n  '/hosts/$hostId/workspaces': typeof HostsHostIdWorkspacesRouteWithChildren\n  '/invitations/$token/accept': typeof InvitationsTokenAcceptRoute\n  '/invitations/$token/complete': typeof InvitationsTokenCompleteRoute\n  '/hosts/$hostId/workspaces/$workspaceId': typeof HostsHostIdWorkspacesWorkspaceIdRoute\n  '/hosts/$hostId/workspaces/create': typeof HostsHostIdWorkspacesCreateRoute\n  '/projects/$projectId/issues/$issueId': typeof ProjectsProjectIdIssuesIssueIdRoute\n  '/hosts/$hostId/workspaces/$workspaceId/vscode': typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute\n  '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute\n  '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute\n  '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute\n}\nexport interface FileRoutesById {\n  __root__: typeof rootRouteImport\n  '/': typeof IndexRoute\n  '/account': typeof AccountRoute\n  '/login': typeof LoginRoute\n  '/notifications': typeof NotificationsRoute\n  '/upgrade': typeof UpgradeRoute\n  '/account_/complete': typeof AccountCompleteRoute\n  '/login_/complete': typeof LoginCompleteRoute\n  '/projects/$projectId': typeof ProjectsProjectIdRoute\n  '/upgrade_/complete': typeof UpgradeCompleteRoute\n  '/upgrade_/success': typeof UpgradeSuccessRoute\n  '/account_/organizations/$orgId': typeof AccountOrganizationsOrgIdRoute\n  '/hosts/$hostId/workspaces': typeof HostsHostIdWorkspacesRouteWithChildren\n  '/invitations/$token/accept': typeof InvitationsTokenAcceptRoute\n  '/invitations/$token/complete': typeof InvitationsTokenCompleteRoute\n  '/hosts/$hostId/workspaces_/$workspaceId': typeof HostsHostIdWorkspacesWorkspaceIdRoute\n  '/hosts/$hostId/workspaces_/create': typeof HostsHostIdWorkspacesCreateRoute\n  '/projects/$projectId_/issues/$issueId': typeof ProjectsProjectIdIssuesIssueIdRoute\n  '/hosts/$hostId/workspaces/$workspaceId/vscode': typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute\n  '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute\n  '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute\n  '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute\n}\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath\n  fullPaths:\n    | '/'\n    | '/account'\n    | '/login'\n    | '/notifications'\n    | '/upgrade'\n    | '/account/complete'\n    | '/login/complete'\n    | '/projects/$projectId'\n    | '/upgrade/complete'\n    | '/upgrade/success'\n    | '/account/organizations/$orgId'\n    | '/hosts/$hostId/workspaces'\n    | '/invitations/$token/accept'\n    | '/invitations/$token/complete'\n    | '/hosts/$hostId/workspaces/$workspaceId'\n    | '/hosts/$hostId/workspaces/create'\n    | '/projects/$projectId/issues/$issueId'\n    | '/hosts/$hostId/workspaces/$workspaceId/vscode'\n    | '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId'\n    | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId'\n    | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId'\n  fileRoutesByTo: FileRoutesByTo\n  to:\n    | '/'\n    | '/account'\n    | '/login'\n    | '/notifications'\n    | '/upgrade'\n    | '/account/complete'\n    | '/login/complete'\n    | '/projects/$projectId'\n    | '/upgrade/complete'\n    | '/upgrade/success'\n    | '/account/organizations/$orgId'\n    | '/hosts/$hostId/workspaces'\n    | '/invitations/$token/accept'\n    | '/invitations/$token/complete'\n    | '/hosts/$hostId/workspaces/$workspaceId'\n    | '/hosts/$hostId/workspaces/create'\n    | '/projects/$projectId/issues/$issueId'\n    | '/hosts/$hostId/workspaces/$workspaceId/vscode'\n    | '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId'\n    | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId'\n    | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId'\n  id:\n    | '__root__'\n    | '/'\n    | '/account'\n    | '/login'\n    | '/notifications'\n    | '/upgrade'\n    | '/account_/complete'\n    | '/login_/complete'\n    | '/projects/$projectId'\n    | '/upgrade_/complete'\n    | '/upgrade_/success'\n    | '/account_/organizations/$orgId'\n    | '/hosts/$hostId/workspaces'\n    | '/invitations/$token/accept'\n    | '/invitations/$token/complete'\n    | '/hosts/$hostId/workspaces_/$workspaceId'\n    | '/hosts/$hostId/workspaces_/create'\n    | '/projects/$projectId_/issues/$issueId'\n    | '/hosts/$hostId/workspaces/$workspaceId/vscode'\n    | '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId'\n    | '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId'\n    | '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId'\n  fileRoutesById: FileRoutesById\n}\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute\n  AccountRoute: typeof AccountRoute\n  LoginRoute: typeof LoginRoute\n  NotificationsRoute: typeof NotificationsRoute\n  UpgradeRoute: typeof UpgradeRoute\n  AccountCompleteRoute: typeof AccountCompleteRoute\n  LoginCompleteRoute: typeof LoginCompleteRoute\n  ProjectsProjectIdRoute: typeof ProjectsProjectIdRoute\n  UpgradeCompleteRoute: typeof UpgradeCompleteRoute\n  UpgradeSuccessRoute: typeof UpgradeSuccessRoute\n  AccountOrganizationsOrgIdRoute: typeof AccountOrganizationsOrgIdRoute\n  HostsHostIdWorkspacesRoute: typeof HostsHostIdWorkspacesRouteWithChildren\n  InvitationsTokenAcceptRoute: typeof InvitationsTokenAcceptRoute\n  InvitationsTokenCompleteRoute: typeof InvitationsTokenCompleteRoute\n  HostsHostIdWorkspacesWorkspaceIdRoute: typeof HostsHostIdWorkspacesWorkspaceIdRoute\n  HostsHostIdWorkspacesCreateRoute: typeof HostsHostIdWorkspacesCreateRoute\n  ProjectsProjectIdIssuesIssueIdRoute: typeof ProjectsProjectIdIssuesIssueIdRoute\n  ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute: typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute\n  ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute\n  ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute\n}\n\ndeclare module '@tanstack/react-router' {\n  interface FileRoutesByPath {\n    '/upgrade': {\n      id: '/upgrade'\n      path: '/upgrade'\n      fullPath: '/upgrade'\n      preLoaderRoute: typeof UpgradeRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/notifications': {\n      id: '/notifications'\n      path: '/notifications'\n      fullPath: '/notifications'\n      preLoaderRoute: typeof NotificationsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/login': {\n      id: '/login'\n      path: '/login'\n      fullPath: '/login'\n      preLoaderRoute: typeof LoginRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/account': {\n      id: '/account'\n      path: '/account'\n      fullPath: '/account'\n      preLoaderRoute: typeof AccountRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/': {\n      id: '/'\n      path: '/'\n      fullPath: '/'\n      preLoaderRoute: typeof IndexRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/upgrade_/success': {\n      id: '/upgrade_/success'\n      path: '/upgrade/success'\n      fullPath: '/upgrade/success'\n      preLoaderRoute: typeof UpgradeSuccessRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/upgrade_/complete': {\n      id: '/upgrade_/complete'\n      path: '/upgrade/complete'\n      fullPath: '/upgrade/complete'\n      preLoaderRoute: typeof UpgradeCompleteRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/projects/$projectId': {\n      id: '/projects/$projectId'\n      path: '/projects/$projectId'\n      fullPath: '/projects/$projectId'\n      preLoaderRoute: typeof ProjectsProjectIdRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/login_/complete': {\n      id: '/login_/complete'\n      path: '/login/complete'\n      fullPath: '/login/complete'\n      preLoaderRoute: typeof LoginCompleteRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/account_/complete': {\n      id: '/account_/complete'\n      path: '/account/complete'\n      fullPath: '/account/complete'\n      preLoaderRoute: typeof AccountCompleteRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/invitations/$token/complete': {\n      id: '/invitations/$token/complete'\n      path: '/invitations/$token/complete'\n      fullPath: '/invitations/$token/complete'\n      preLoaderRoute: typeof InvitationsTokenCompleteRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/invitations/$token/accept': {\n      id: '/invitations/$token/accept'\n      path: '/invitations/$token/accept'\n      fullPath: '/invitations/$token/accept'\n      preLoaderRoute: typeof InvitationsTokenAcceptRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/hosts/$hostId/workspaces': {\n      id: '/hosts/$hostId/workspaces'\n      path: '/hosts/$hostId/workspaces'\n      fullPath: '/hosts/$hostId/workspaces'\n      preLoaderRoute: typeof HostsHostIdWorkspacesRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/account_/organizations/$orgId': {\n      id: '/account_/organizations/$orgId'\n      path: '/account/organizations/$orgId'\n      fullPath: '/account/organizations/$orgId'\n      preLoaderRoute: typeof AccountOrganizationsOrgIdRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/projects/$projectId_/issues/$issueId': {\n      id: '/projects/$projectId_/issues/$issueId'\n      path: '/projects/$projectId/issues/$issueId'\n      fullPath: '/projects/$projectId/issues/$issueId'\n      preLoaderRoute: typeof ProjectsProjectIdIssuesIssueIdRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/hosts/$hostId/workspaces_/create': {\n      id: '/hosts/$hostId/workspaces_/create'\n      path: '/hosts/$hostId/workspaces/create'\n      fullPath: '/hosts/$hostId/workspaces/create'\n      preLoaderRoute: typeof HostsHostIdWorkspacesCreateRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/hosts/$hostId/workspaces_/$workspaceId': {\n      id: '/hosts/$hostId/workspaces_/$workspaceId'\n      path: '/hosts/$hostId/workspaces/$workspaceId'\n      fullPath: '/hosts/$hostId/workspaces/$workspaceId'\n      preLoaderRoute: typeof HostsHostIdWorkspacesWorkspaceIdRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/hosts/$hostId/workspaces/$workspaceId/vscode': {\n      id: '/hosts/$hostId/workspaces/$workspaceId/vscode'\n      path: '/$workspaceId/vscode'\n      fullPath: '/hosts/$hostId/workspaces/$workspaceId/vscode'\n      preLoaderRoute: typeof HostsHostIdWorkspacesWorkspaceIdVscodeRouteImport\n      parentRoute: typeof HostsHostIdWorkspacesRoute\n    }\n    '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId': {\n      id: '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId'\n      path: '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId'\n      fullPath: '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId'\n      preLoaderRoute: typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId': {\n      id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId'\n      path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId'\n      fullPath: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId'\n      preLoaderRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId': {\n      id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId'\n      path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId'\n      fullPath: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId'\n      preLoaderRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n  }\n}\n\ninterface HostsHostIdWorkspacesRouteChildren {\n  HostsHostIdWorkspacesWorkspaceIdVscodeRoute: typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute\n}\n\nconst HostsHostIdWorkspacesRouteChildren: HostsHostIdWorkspacesRouteChildren = {\n  HostsHostIdWorkspacesWorkspaceIdVscodeRoute:\n    HostsHostIdWorkspacesWorkspaceIdVscodeRoute,\n}\n\nconst HostsHostIdWorkspacesRouteWithChildren =\n  HostsHostIdWorkspacesRoute._addFileChildren(\n    HostsHostIdWorkspacesRouteChildren,\n  )\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  AccountRoute: AccountRoute,\n  LoginRoute: LoginRoute,\n  NotificationsRoute: NotificationsRoute,\n  UpgradeRoute: UpgradeRoute,\n  AccountCompleteRoute: AccountCompleteRoute,\n  LoginCompleteRoute: LoginCompleteRoute,\n  ProjectsProjectIdRoute: ProjectsProjectIdRoute,\n  UpgradeCompleteRoute: UpgradeCompleteRoute,\n  UpgradeSuccessRoute: UpgradeSuccessRoute,\n  AccountOrganizationsOrgIdRoute: AccountOrganizationsOrgIdRoute,\n  HostsHostIdWorkspacesRoute: HostsHostIdWorkspacesRouteWithChildren,\n  InvitationsTokenAcceptRoute: InvitationsTokenAcceptRoute,\n  InvitationsTokenCompleteRoute: InvitationsTokenCompleteRoute,\n  HostsHostIdWorkspacesWorkspaceIdRoute: HostsHostIdWorkspacesWorkspaceIdRoute,\n  HostsHostIdWorkspacesCreateRoute: HostsHostIdWorkspacesCreateRoute,\n  ProjectsProjectIdIssuesIssueIdRoute: ProjectsProjectIdIssuesIssueIdRoute,\n  ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute:\n    ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute,\n  ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute:\n    ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute,\n  ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute:\n    ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute,\n}\nexport const routeTree = rootRouteImport\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>()\n"
  },
  {
    "path": "packages/remote-web/src/routes/__root.tsx",
    "content": "import { type ReactNode, useEffect, useMemo } from \"react\";\nimport {\n  createRootRoute,\n  Outlet,\n  useLocation,\n  useParams,\n} from \"@tanstack/react-router\";\nimport { Provider as NiceModalProvider } from \"@ebay/nice-modal-react\";\nimport { useSystemTheme } from \"@remote/shared/hooks/useSystemTheme\";\nimport { RemoteActionsProvider } from \"@remote/app/providers/RemoteActionsProvider\";\nimport { RemoteUserSystemProvider } from \"@remote/app/providers/RemoteUserSystemProvider\";\nimport { RemoteAppShell } from \"@remote/app/layout/RemoteAppShell\";\nimport { UserProvider } from \"@/shared/providers/remote/UserProvider\";\nimport { WorkspaceProvider } from \"@/shared/providers/WorkspaceProvider\";\nimport { ExecutionProcessesProvider } from \"@/shared/providers/ExecutionProcessesProvider\";\nimport { TerminalProvider } from \"@/shared/providers/TerminalProvider\";\nimport { LogsPanelProvider } from \"@/shared/providers/LogsPanelProvider\";\nimport { ActionsProvider } from \"@/shared/providers/ActionsProvider\";\nimport { useAuth } from \"@/shared/hooks/auth/useAuth\";\nimport { useKanbanIssueComposerScratch } from \"@/shared/hooks/useKanbanIssueComposerScratch\";\nimport { useUiPreferencesScratch } from \"@/shared/hooks/useUiPreferencesScratch\";\nimport { useWorkspaceContext } from \"@/shared/hooks/useWorkspaceContext\";\nimport { AppNavigationProvider } from \"@/shared/hooks/useAppNavigation\";\nimport {\n  SequenceTrackerProvider,\n  SequenceIndicator,\n  useWorkspaceShortcuts,\n  useIssueShortcuts,\n  useKeyShowHelp,\n  Scope,\n} from \"@/shared/keyboard\";\nimport { KeyboardShortcutsDialog } from \"@/shared/dialogs/shared/KeyboardShortcutsDialog\";\nimport {\n  createRemoteHostAppNavigation,\n  remoteFallbackAppNavigation,\n  resolveRemoteDestinationFromPath,\n} from \"@remote/app/navigation/AppNavigation\";\nimport {\n  resolveRelayNavigationHostId,\n  useRelayAppBarHosts,\n} from \"@remote/shared/hooks/useRelayAppBarHosts\";\nimport { setActiveRelayHostId } from \"@remote/shared/lib/relay/activeHostContext\";\nimport {\n  isProjectDestination,\n  isWorkspacesDestination,\n} from \"@/shared/lib/routes/appNavigation\";\nimport NotFoundPage from \"../pages/NotFoundPage\";\n\nexport const Route = createRootRoute({\n  component: RootLayout,\n  notFoundComponent: NotFoundPage,\n});\n\nfunction ExecutionProcessesProviderWrapper({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const { selectedSessionId } = useWorkspaceContext();\n\n  return (\n    <ExecutionProcessesProvider sessionId={selectedSessionId}>\n      {children}\n    </ExecutionProcessesProvider>\n  );\n}\n\n/**\n * Global keyboard shortcut that doesn't require workspace/actions context.\n * Renders inside HotkeysProvider (from App.tsx) but outside WorkspaceProvider.\n */\nfunction GlobalKeyboardShortcuts() {\n  useKeyShowHelp(\n    () => {\n      KeyboardShortcutsDialog.show();\n    },\n    { scope: Scope.GLOBAL },\n  );\n  return null;\n}\n\n/**\n * Workspace & issue keyboard shortcuts that require ActionsProvider + WorkspaceProvider.\n * Must be rendered inside WorkspaceRouteProviders.\n */\nfunction WorkspaceKeyboardShortcuts() {\n  useWorkspaceShortcuts();\n  useIssueShortcuts();\n  return null;\n}\n\nfunction WorkspaceRouteProviders({ children }: { children: ReactNode }) {\n  return (\n    <WorkspaceProvider>\n      <ExecutionProcessesProviderWrapper>\n        <TerminalProvider>\n          <LogsPanelProvider>\n            <ActionsProvider>\n              <WorkspaceKeyboardShortcuts />\n              {children}\n            </ActionsProvider>\n          </LogsPanelProvider>\n        </TerminalProvider>\n      </ExecutionProcessesProviderWrapper>\n    </WorkspaceProvider>\n  );\n}\n\nfunction RootLayout() {\n  useSystemTheme();\n  useUiPreferencesScratch();\n  useKanbanIssueComposerScratch();\n  const { isSignedIn } = useAuth();\n  const location = useLocation();\n  const { hostId } = useParams({ strict: false });\n  const routeHostId = hostId ?? null;\n  const { hosts: relayHosts } = useRelayAppBarHosts(isSignedIn);\n  const navigationHostId = useMemo(\n    () => resolveRelayNavigationHostId(relayHosts, { routeHostId }),\n    [relayHosts, routeHostId],\n  );\n\n  useEffect(() => {\n    setActiveRelayHostId(navigationHostId);\n  }, [navigationHostId]);\n\n  const appNavigation = useMemo(\n    () =>\n      navigationHostId\n        ? createRemoteHostAppNavigation(navigationHostId)\n        : remoteFallbackAppNavigation,\n    [navigationHostId],\n  );\n  const isStandaloneRoute =\n    location.pathname.startsWith(\"/account\") ||\n    location.pathname.startsWith(\"/login\") ||\n    location.pathname.startsWith(\"/upgrade\") ||\n    location.pathname.startsWith(\"/invitations\");\n  const destination = resolveRemoteDestinationFromPath(location.pathname);\n  const isWorkspaceProviderRoute =\n    isProjectDestination(destination) || isWorkspacesDestination(destination);\n\n  const pageContent = isStandaloneRoute ? (\n    <Outlet />\n  ) : (\n    <SequenceTrackerProvider>\n      <SequenceIndicator />\n      <GlobalKeyboardShortcuts />\n      <RemoteAppShell>\n        <Outlet />\n      </RemoteAppShell>\n    </SequenceTrackerProvider>\n  );\n\n  const content = isWorkspaceProviderRoute ? (\n    <WorkspaceRouteProviders>\n      <NiceModalProvider>{pageContent}</NiceModalProvider>\n    </WorkspaceRouteProviders>\n  ) : (\n    <NiceModalProvider>{pageContent}</NiceModalProvider>\n  );\n\n  return (\n    <AppNavigationProvider value={appNavigation}>\n      <UserProvider>\n        <RemoteActionsProvider>\n          <RemoteUserSystemProvider>{content}</RemoteUserSystemProvider>\n        </RemoteActionsProvider>\n      </UserProvider>\n    </AppNavigationProvider>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/routes/account.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\nimport { z } from \"zod\";\nimport { redirectAuthenticatedToHome } from \"@remote/shared/lib/route-auth\";\nimport LoginPage from \"../pages/LoginPage\";\n\nconst searchSchema = z.object({\n  next: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/account\")({\n  validateSearch: zodValidator(searchSchema),\n  beforeLoad: async () => {\n    await redirectAuthenticatedToHome();\n  },\n  component: LoginPage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/account_.complete.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { z } from \"zod\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\nimport LoginCompletePage from \"../pages/LoginCompletePage\";\n\nconst searchSchema = z.object({\n  handoff_id: z.string().optional(),\n  app_code: z.string().optional(),\n  error: z.string().optional(),\n  next: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/account_/complete\")({\n  component: LoginCompletePage,\n  validateSearch: zodValidator(searchSchema),\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/account_.organizations.$orgId.tsx",
    "content": "import { createFileRoute, redirect } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\n\nexport const Route = createFileRoute(\"/account_/organizations/$orgId\")({\n  beforeLoad: async ({ location, params }) => {\n    await requireAuthenticated(location);\n\n    throw redirect({\n      to: \"/\",\n      search: { legacyOrgSettingsOrgId: params.orgId },\n    });\n  },\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/hosts.$hostId.workspaces.$workspaceId.vscode.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { VSCodeWorkspacePage } from \"@/pages/workspaces/VSCodeWorkspacePage\";\nimport { RemoteWorkspacesPageShell } from \"@remote/pages/RemoteWorkspacesPageShell\";\n\nexport const Route = createFileRoute(\n  \"/hosts/$hostId/workspaces/$workspaceId/vscode\",\n)({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  component: WorkspaceVSCodeRouteComponent,\n});\n\nfunction WorkspaceVSCodeRouteComponent() {\n  return (\n    <RemoteWorkspacesPageShell>\n      <VSCodeWorkspacePage />\n    </RemoteWorkspacesPageShell>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/routes/hosts.$hostId.workspaces.tsx",
    "content": "import { useState } from \"react\";\nimport {\n  createFileRoute,\n  useNavigate,\n  useParams,\n} from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { WorkspacesLanding } from \"@/pages/workspaces/WorkspacesLanding\";\nimport { RemoteWorkspacesPageShell } from \"@remote/pages/RemoteWorkspacesPageShell\";\nimport { useIsMobile } from \"@/shared/hooks/useIsMobile\";\nimport { useWorkspaceContext } from \"@/shared/hooks/useWorkspaceContext\";\nimport { cn } from \"@/shared/lib/utils\";\nimport { CommandBarDialog } from \"@/shared/dialogs/command-bar/CommandBarDialog\";\nimport {\n  PlusIcon,\n  GitBranchIcon,\n  HandIcon,\n  TriangleIcon,\n  PlayIcon,\n  FileIcon,\n  CircleIcon,\n  GitPullRequestIcon,\n  PushPinIcon,\n  DotsThreeIcon,\n  ArchiveIcon,\n  ArrowLeftIcon,\n} from \"@phosphor-icons/react\";\nimport { RunningDots } from \"@vibe/ui/components/RunningDots\";\n\nexport const Route = createFileRoute(\"/hosts/$hostId/workspaces\")({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  component: WorkspacesRouteComponent,\n});\n\nfunction WorkspacesRouteComponent() {\n  const isMobile = useIsMobile();\n  return (\n    <RemoteWorkspacesPageShell>\n      {isMobile ? <MobileWorkspacesList /> : <WorkspacesLanding />}\n    </RemoteWorkspacesPageShell>\n  );\n}\n\nfunction MobileWorkspacesList() {\n  const navigate = useNavigate();\n  const { hostId } = useParams({ from: \"/hosts/$hostId/workspaces\" });\n  const { activeWorkspaces, archivedWorkspaces, selectWorkspace } =\n    useWorkspaceContext();\n  const [showArchive, setShowArchive] = useState(false);\n  const workspaces = showArchive ? archivedWorkspaces : activeWorkspaces;\n\n  const handleSelectWorkspace = (id: string) => {\n    selectWorkspace(id);\n    navigate({\n      to: \"/hosts/$hostId/workspaces/$workspaceId\",\n      params: { hostId, workspaceId: id },\n    });\n  };\n\n  const handleCreateWorkspace = () => {\n    navigate({ to: \"/hosts/$hostId/workspaces/create\", params: { hostId } });\n  };\n\n  return (\n    <div className=\"flex flex-col h-full bg-primary\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between px-base py-base border-b border-border\">\n        <h1 className=\"text-lg font-semibold text-high\">\n          {showArchive ? \"Archived\" : \"Workspaces\"}\n        </h1>\n        <button\n          onClick={handleCreateWorkspace}\n          className={cn(\n            \"flex items-center gap-half rounded-md px-plusfifty py-half\",\n            \"bg-brand text-on-brand text-sm font-medium\",\n            \"active:opacity-80 transition-opacity\",\n          )}\n        >\n          <PlusIcon className=\"size-icon-sm\" />\n          New\n        </button>\n      </div>\n\n      {/* Workspace list */}\n      <div className=\"flex-1 overflow-y-auto\">\n        {workspaces.length === 0 ? (\n          <div className=\"flex flex-col items-center justify-center h-full px-double text-center\">\n            <p className=\"text-low text-sm\">\n              {showArchive ? \"No archived workspaces\" : \"No workspaces yet\"}\n            </p>\n            {!showArchive && (\n              <button\n                onClick={handleCreateWorkspace}\n                className=\"mt-base text-brand text-sm font-medium active:opacity-80\"\n              >\n                Create your first workspace\n              </button>\n            )}\n          </div>\n        ) : (\n          <div className=\"flex flex-col\">\n            {workspaces.map((workspace) => {\n              const isFailed =\n                workspace.latestProcessStatus === \"failed\" ||\n                workspace.latestProcessStatus === \"killed\";\n              const hasChanges =\n                workspace.filesChanged !== undefined &&\n                workspace.filesChanged > 0;\n\n              return (\n                <div\n                  key={workspace.id}\n                  className={cn(\n                    \"group relative flex items-center gap-half px-base py-plusfifty\",\n                    \"border-b border-border\",\n                  )}\n                >\n                  <button\n                    onClick={() => handleSelectWorkspace(workspace.id)}\n                    className={cn(\n                      \"flex flex-1 flex-col gap-half min-w-0\",\n                      \"text-left active:bg-secondary transition-colors\",\n                    )}\n                  >\n                    <span className=\"text-sm font-medium text-high truncate\">\n                      {workspace.name}\n                    </span>\n                    <span className=\"flex items-center gap-base text-xs text-low\">\n                      {/* Branch */}\n                      {workspace.branch && (\n                        <span className=\"flex items-center gap-half min-w-0 shrink truncate\">\n                          <GitBranchIcon className=\"size-icon-xs shrink-0\" />\n                          <span className=\"truncate\">{workspace.branch}</span>\n                        </span>\n                      )}\n\n                      {/* Status indicators */}\n                      <span className=\"flex items-center gap-half shrink-0\">\n                        {/* Dev server running */}\n                        {workspace.hasRunningDevServer && (\n                          <PlayIcon\n                            className=\"size-icon-xs text-brand shrink-0\"\n                            weight=\"fill\"\n                          />\n                        )}\n\n                        {/* Failed/killed status (only when not running) */}\n                        {!workspace.isRunning && isFailed && (\n                          <TriangleIcon\n                            className=\"size-icon-xs text-error shrink-0\"\n                            weight=\"fill\"\n                          />\n                        )}\n\n                        {/* Running dots OR hand icon for pending approval */}\n                        {workspace.isRunning &&\n                          (workspace.hasPendingApproval ? (\n                            <HandIcon\n                              className=\"size-icon-xs text-brand shrink-0\"\n                              weight=\"fill\"\n                            />\n                          ) : (\n                            <RunningDots />\n                          ))}\n\n                        {/* Unseen activity indicator (only when not running and not failed) */}\n                        {workspace.hasUnseenActivity &&\n                          !workspace.isRunning &&\n                          !isFailed && (\n                            <CircleIcon\n                              className=\"size-icon-xs text-brand shrink-0\"\n                              weight=\"fill\"\n                            />\n                          )}\n\n                        {/* PR status icon */}\n                        {workspace.prStatus === \"open\" && (\n                          <GitPullRequestIcon\n                            className=\"size-icon-xs text-success shrink-0\"\n                            weight=\"fill\"\n                          />\n                        )}\n                        {workspace.prStatus === \"merged\" && (\n                          <GitPullRequestIcon\n                            className=\"size-icon-xs text-merged shrink-0\"\n                            weight=\"fill\"\n                          />\n                        )}\n\n                        {/* Pin icon */}\n                        {workspace.isPinned && (\n                          <PushPinIcon\n                            className=\"size-icon-xs text-brand shrink-0\"\n                            weight=\"fill\"\n                          />\n                        )}\n                      </span>\n\n                      {/* Elapsed time */}\n                      {!workspace.isRunning &&\n                        workspace.latestProcessCompletedAt && (\n                          <span className=\"shrink-0\">\n                            {formatRelativeElapsed(\n                              workspace.latestProcessCompletedAt,\n                            )}\n                          </span>\n                        )}\n\n                      {/* File changes */}\n                      {hasChanges && (\n                        <span className=\"shrink-0 flex items-center gap-half\">\n                          <FileIcon className=\"size-icon-xs\" weight=\"fill\" />\n                          <span>{workspace.filesChanged}</span>\n                          {workspace.linesAdded !== undefined && (\n                            <span className=\"text-success\">\n                              +{workspace.linesAdded}\n                            </span>\n                          )}\n                          {workspace.linesRemoved !== undefined && (\n                            <span className=\"text-error\">\n                              -{workspace.linesRemoved}\n                            </span>\n                          )}\n                        </span>\n                      )}\n                    </span>\n                  </button>\n                  {/* Workspace actions menu */}\n                  <button\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      CommandBarDialog.show({\n                        page: \"workspaceActions\",\n                        workspaceId: workspace.id,\n                      });\n                    }}\n                    className=\"shrink-0 p-1.5 rounded-sm text-low hover:text-normal hover:bg-tertiary active:bg-tertiary\"\n                    aria-label=\"Workspace actions\"\n                  >\n                    <DotsThreeIcon className=\"size-5\" weight=\"bold\" />\n                  </button>\n                </div>\n              );\n            })}\n          </div>\n        )}\n      </div>\n\n      {/* Fixed footer toggle */}\n      <div className=\"border-t border-border p-base\">\n        <button\n          onClick={() => setShowArchive(!showArchive)}\n          className=\"w-full flex items-center gap-base text-sm text-low hover:text-normal transition-colors duration-100\"\n        >\n          {showArchive ? (\n            <>\n              <ArrowLeftIcon className=\"size-icon-xs\" />\n              <span>Back to Active</span>\n            </>\n          ) : (\n            <>\n              <ArchiveIcon className=\"size-icon-xs\" />\n              <span>View Archive</span>\n              {archivedWorkspaces.length > 0 && (\n                <span className=\"ml-auto text-xs bg-tertiary px-1.5 py-0.5 rounded\">\n                  {archivedWorkspaces.length}\n                </span>\n              )}\n            </>\n          )}\n        </button>\n      </div>\n    </div>\n  );\n}\n\nconst formatRelativeElapsed = (dateString: string): string => {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffSecs = Math.floor(diffMs / 1000);\n  const diffMins = Math.floor(diffSecs / 60);\n  const diffHours = Math.floor(diffMins / 60);\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffSecs < 60) return \"just now\";\n  if (diffMins < 60) return `${diffMins}m ago`;\n  if (diffHours < 24) return `${diffHours}h ago`;\n  return `${diffDays}d ago`;\n};\n"
  },
  {
    "path": "packages/remote-web/src/routes/hosts.$hostId.workspaces_.$workspaceId.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { Workspaces } from \"@/pages/workspaces/Workspaces\";\nimport { RemoteWorkspacesPageShell } from \"@remote/pages/RemoteWorkspacesPageShell\";\n\nexport const Route = createFileRoute(\"/hosts/$hostId/workspaces_/$workspaceId\")(\n  {\n    beforeLoad: async ({ location }) => {\n      await requireAuthenticated(location);\n    },\n    component: WorkspaceRouteComponent,\n  },\n);\n\nfunction WorkspaceRouteComponent() {\n  return (\n    <RemoteWorkspacesPageShell>\n      <Workspaces />\n    </RemoteWorkspacesPageShell>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/routes/hosts.$hostId.workspaces_.create.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { Workspaces } from \"@/pages/workspaces/Workspaces\";\nimport { RemoteWorkspacesPageShell } from \"@remote/pages/RemoteWorkspacesPageShell\";\n\nexport const Route = createFileRoute(\"/hosts/$hostId/workspaces_/create\")({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  component: WorkspacesCreateRouteComponent,\n});\n\nfunction WorkspacesCreateRouteComponent() {\n  return (\n    <RemoteWorkspacesPageShell>\n      <Workspaces />\n    </RemoteWorkspacesPageShell>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/routes/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\nimport { z } from \"zod\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport HomePage from \"../pages/HomePage\";\n\nconst searchSchema = z.object({\n  legacyOrgSettingsOrgId: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/\")({\n  validateSearch: zodValidator(searchSchema),\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  component: HomePage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/invitations.$token.accept.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport InvitationPage from \"../pages/InvitationPage\";\n\nexport const Route = createFileRoute(\"/invitations/$token/accept\")({\n  component: InvitationPage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/invitations.$token.complete.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { z } from \"zod\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\nimport InvitationCompletePage from \"../pages/InvitationCompletePage\";\n\nconst searchSchema = z.object({\n  handoff_id: z.string().optional(),\n  app_code: z.string().optional(),\n  error: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/invitations/$token/complete\")({\n  validateSearch: zodValidator(searchSchema),\n  component: InvitationCompletePage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/login.tsx",
    "content": "import { createFileRoute, redirect } from \"@tanstack/react-router\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\nimport { z } from \"zod\";\n\nconst searchSchema = z.object({\n  next: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/login\")({\n  validateSearch: zodValidator(searchSchema),\n  beforeLoad: ({ search }) => {\n    throw redirect({\n      to: \"/account\",\n      search: search.next ? { next: search.next } : undefined,\n    });\n  },\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/login_.complete.tsx",
    "content": "import { createFileRoute, redirect } from \"@tanstack/react-router\";\nimport { z } from \"zod\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\n\nconst searchSchema = z.object({\n  handoff_id: z.string().optional(),\n  app_code: z.string().optional(),\n  error: z.string().optional(),\n  next: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/login_/complete\")({\n  validateSearch: zodValidator(searchSchema),\n  beforeLoad: ({ search }) => {\n    throw redirect({\n      to: \"/account/complete\",\n      search,\n    });\n  },\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/notifications.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { NotificationsPage } from \"@/pages/workspaces/NotificationsPage\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\n\nexport const Route = createFileRoute(\"/notifications\")({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  component: NotificationsPage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/projects.$projectId.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { ProjectKanban } from \"@/pages/kanban/ProjectKanban\";\nimport { projectSearchValidator } from \"@vibe/web-core/project-search\";\n\nexport const Route = createFileRoute(\"/projects/$projectId\")({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  validateSearch: projectSearchValidator,\n  component: ProjectKanban,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/projects.$projectId_.hosts.$hostId.workspaces.create.$draftId.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { projectSearchValidator } from \"@vibe/web-core/project-search\";\nimport { RemoteProjectKanbanShell } from \"@remote/pages/RemoteProjectKanbanShell\";\n\nexport const Route = createFileRoute(\n  \"/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId\",\n)({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  validateSearch: projectSearchValidator,\n  component: RemoteProjectKanbanShell,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/projects.$projectId_.issues.$issueId.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { ProjectKanban } from \"@/pages/kanban/ProjectKanban\";\nimport { projectSearchValidator } from \"@vibe/web-core/project-search\";\n\nexport const Route = createFileRoute(\"/projects/$projectId_/issues/$issueId\")({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  validateSearch: projectSearchValidator,\n  component: ProjectKanban,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.$workspaceId.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { projectSearchValidator } from \"@vibe/web-core/project-search\";\nimport { RemoteProjectKanbanShell } from \"@remote/pages/RemoteProjectKanbanShell\";\n\nexport const Route = createFileRoute(\n  \"/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId\",\n)({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  validateSearch: projectSearchValidator,\n  component: RemoteProjectKanbanShell,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.create.$draftId.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { requireAuthenticated } from \"@remote/shared/lib/route-auth\";\nimport { projectSearchValidator } from \"@vibe/web-core/project-search\";\nimport { RemoteProjectKanbanShell } from \"@remote/pages/RemoteProjectKanbanShell\";\n\nexport const Route = createFileRoute(\n  \"/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId\",\n)({\n  beforeLoad: async ({ location }) => {\n    await requireAuthenticated(location);\n  },\n  validateSearch: projectSearchValidator,\n  component: RemoteProjectKanbanShell,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/upgrade.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\nimport { z } from \"zod\";\nimport UpgradePage from \"@remote/pages/UpgradePage\";\n\nconst searchSchema = z.object({\n  org_id: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/upgrade\")({\n  validateSearch: zodValidator(searchSchema),\n  component: UpgradePage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/upgrade_.complete.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { zodValidator } from \"@tanstack/zod-adapter\";\nimport { z } from \"zod\";\nimport UpgradeCompletePage from \"@remote/pages/UpgradeCompletePage\";\n\nconst searchSchema = z.object({\n  handoff_id: z.string().optional(),\n  app_code: z.string().optional(),\n  error: z.string().optional(),\n});\n\nexport const Route = createFileRoute(\"/upgrade_/complete\")({\n  validateSearch: zodValidator(searchSchema),\n  component: UpgradeCompletePage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/routes/upgrade_.success.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport UpgradeSuccessPage from \"@remote/pages/UpgradeSuccessPage\";\n\nexport const Route = createFileRoute(\"/upgrade_/success\")({\n  component: UpgradeSuccessPage,\n});\n"
  },
  {
    "path": "packages/remote-web/src/shared/components/BrandLogo.tsx",
    "content": "interface BrandLogoProps {\n  className?: string;\n  alt?: string;\n}\n\nexport function BrandLogo({\n  className = \"h-8 w-auto\",\n  alt = \"Vibe Kanban\",\n}: BrandLogoProps) {\n  return (\n    <picture>\n      <source\n        srcSet=\"/vibe-kanban-logo-dark.svg\"\n        media=\"(prefers-color-scheme: dark)\"\n      />\n      <img src=\"/vibe-kanban-logo.svg\" alt={alt} className={className} />\n    </picture>\n  );\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/constants/settings.ts",
    "content": "import type { SettingsSectionType } from \"@/shared/dialogs/settings/settings/SettingsSection\";\n\nexport const REMOTE_SETTINGS_SECTIONS: SettingsSectionType[] = [\n  \"organizations\",\n  \"remote-projects\",\n  \"relay\",\n];\n"
  },
  {
    "path": "packages/remote-web/src/shared/hooks/useRelayAppBarHosts.ts",
    "content": "import { useMemo } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport type { AppBarHost } from \"@vibe/ui/components/AppBar\";\nimport type { RelayHost } from \"shared/remote-types\";\nimport { listPairedRelayHosts } from \"@/shared/lib/relayPairingStorage\";\nimport { listRelayHosts } from \"@/shared/lib/remoteApi\";\n\nconst RELAY_APP_BAR_HOSTS_QUERY_KEY = [\"relay-app-bar-hosts\", \"hosts\"] as const;\nconst RELAY_APP_BAR_PAIRED_HOSTS_QUERY_KEY = [\n  \"relay-app-bar-hosts\",\n  \"paired-hosts\",\n] as const;\n\ninterface UseRelayAppBarHostsResult {\n  hosts: AppBarHost[];\n  isLoading: boolean;\n}\n\nexport interface ResolveRelayNavigationHostOptions {\n  routeHostId?: string | null;\n}\n\nexport function resolveRelayNavigationHostId(\n  hosts: AppBarHost[],\n  options?: ResolveRelayNavigationHostOptions,\n): string | null {\n  const routeHostId = options?.routeHostId ?? null;\n  if (routeHostId) {\n    return routeHostId;\n  }\n\n  const onlineHost = hosts.find((host) => host.status === \"online\");\n  if (onlineHost) {\n    return onlineHost.id;\n  }\n\n  return null;\n}\n\nfunction mapRelayHostStatus(\n  host: RelayHost,\n  pairedHostIds: Set<string>,\n): AppBarHost[\"status\"] {\n  if (!pairedHostIds.has(host.id)) {\n    return \"unpaired\";\n  }\n\n  return host.status === \"online\" ? \"online\" : \"offline\";\n}\n\nexport function useRelayAppBarHosts(\n  enabled: boolean,\n): UseRelayAppBarHostsResult {\n  const hostsQuery = useQuery({\n    queryKey: RELAY_APP_BAR_HOSTS_QUERY_KEY,\n    queryFn: listRelayHosts,\n    enabled,\n    staleTime: 30_000,\n    refetchInterval: 30_000,\n  });\n\n  const pairedHostsQuery = useQuery({\n    queryKey: RELAY_APP_BAR_PAIRED_HOSTS_QUERY_KEY,\n    queryFn: async () => {\n      try {\n        return await listPairedRelayHosts();\n      } catch (error) {\n        console.error(\"Failed to load paired relay hosts for app bar\", error);\n        return [];\n      }\n    },\n    enabled,\n    staleTime: 5_000,\n    refetchInterval: 5_000,\n  });\n\n  const hosts = useMemo<AppBarHost[]>(() => {\n    if (!enabled) {\n      return [];\n    }\n\n    const relayHosts = hostsQuery.data ?? [];\n    const pairedHostIds = new Set(\n      (pairedHostsQuery.data ?? []).map((host) => host.host_id),\n    );\n\n    return relayHosts.map((host) => ({\n      id: host.id,\n      name: host.name,\n      status: mapRelayHostStatus(host, pairedHostIds),\n    }));\n  }, [enabled, hostsQuery.data, pairedHostsQuery.data]);\n\n  return {\n    hosts,\n    isLoading: enabled && (hostsQuery.isLoading || pairedHostsQuery.isLoading),\n  };\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/hooks/useRelayWorkspaceHostHealth.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { makeLocalApiRequest } from \"@/shared/lib/localApiTransport\";\n\ninterface UseRelayWorkspaceHostHealthResult {\n  isChecking: boolean;\n  isError: boolean;\n  errorMessage: string | null;\n}\n\nfunction getErrorMessage(error: unknown): string | null {\n  if (error instanceof Error && error.message.length > 0) {\n    return error.message;\n  }\n\n  return null;\n}\n\nexport function useRelayWorkspaceHostHealth(\n  hostId: string | null,\n): UseRelayWorkspaceHostHealthResult {\n  const hostHealthQuery = useQuery({\n    queryKey: [\"remote-workspaces-host-health\", hostId],\n    enabled: !!hostId,\n    retry: false,\n    staleTime: 5_000,\n    refetchInterval: 15_000,\n    queryFn: async (): Promise<true> => {\n      const response = await makeLocalApiRequest(\"/api/info\", {\n        cache: \"no-store\",\n      });\n\n      if (!response.ok) {\n        throw new Error(`Host returned HTTP ${response.status}`);\n      }\n\n      return true;\n    },\n  });\n\n  const isHostUnavailable =\n    hostHealthQuery.isError || hostHealthQuery.isRefetchError;\n\n  return {\n    isChecking: hostHealthQuery.isPending,\n    isError: isHostUnavailable,\n    errorMessage: isHostUnavailable\n      ? getErrorMessage(hostHealthQuery.error)\n      : null,\n  };\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/hooks/useSystemTheme.ts",
    "content": "import { useEffect } from \"react\";\n\n/**\n * Applies 'dark' or 'light' class to <html> based on the browser's\n * prefers-color-scheme, and updates live when the OS setting changes.\n */\nexport function useSystemTheme() {\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n    function apply(dark: boolean) {\n      const root = document.documentElement;\n      root.classList.toggle(\"dark\", dark);\n      root.classList.toggle(\"light\", !dark);\n    }\n\n    apply(query.matches);\n\n    function onChange(e: MediaQueryListEvent) {\n      apply(e.matches);\n    }\n\n    query.addEventListener(\"change\", onChange);\n    return () => query.removeEventListener(\"change\", onChange);\n  }, []);\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/api.ts",
    "content": "import { getToken, triggerRefresh } from \"@remote/shared/lib/auth/tokenManager\";\nimport { clearTokens } from \"@remote/shared/lib/auth\";\nimport type { Project } from \"shared/remote-types\";\nimport type { ListOrganizationsResponse } from \"shared/types\";\n\nconst API_BASE = import.meta.env.VITE_API_BASE_URL || \"\";\n\nexport type OAuthProvider = \"github\" | \"google\";\n\ntype HandoffInitResponse = {\n  handoff_id: string;\n  authorize_url: string;\n};\n\ntype HandoffRedeemResponse = {\n  access_token: string;\n  refresh_token: string;\n};\n\nexport type InvitationLookupResponse = {\n  id: string;\n  organization_slug: string;\n  organization_name?: string;\n  role: string;\n  expires_at: string;\n};\n\ntype AcceptInvitationResponse = {\n  organization_id: string;\n  organization_slug: string;\n  role: string;\n};\n\ntype IdentityResponse = {\n  user_id: string;\n  username: string | null;\n  email: string;\n};\n\nexport async function initOAuth(\n  provider: OAuthProvider,\n  returnTo: string,\n  appChallenge: string,\n): Promise<HandoffInitResponse> {\n  const res = await fetch(`${API_BASE}/v1/oauth/web/init`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      provider,\n      return_to: returnTo,\n      app_challenge: appChallenge,\n    }),\n  });\n  if (!res.ok) {\n    throw new Error(`OAuth init failed (${res.status})`);\n  }\n  return res.json();\n}\n\nexport async function redeemOAuth(\n  handoffId: string,\n  appCode: string,\n  appVerifier: string,\n): Promise<HandoffRedeemResponse> {\n  const res = await fetch(`${API_BASE}/v1/oauth/web/redeem`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      handoff_id: handoffId,\n      app_code: appCode,\n      app_verifier: appVerifier,\n    }),\n  });\n  if (!res.ok) {\n    throw new Error(`OAuth redeem failed (${res.status})`);\n  }\n  return res.json();\n}\n\nexport async function getInvitation(\n  token: string,\n): Promise<InvitationLookupResponse> {\n  const res = await fetch(`${API_BASE}/v1/invitations/${token}`);\n  if (!res.ok) {\n    throw new Error(`Invitation not found (${res.status})`);\n  }\n  return res.json();\n}\n\nexport async function acceptInvitation(\n  token: string,\n  accessToken: string,\n): Promise<AcceptInvitationResponse> {\n  const res = await fetch(`${API_BASE}/v1/invitations/${token}/accept`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Authorization: `Bearer ${accessToken}`,\n    },\n  });\n  if (!res.ok) {\n    throw new Error(`Failed to accept invitation (${res.status})`);\n  }\n  return res.json();\n}\n\nexport async function refreshTokens(\n  refreshToken: string,\n): Promise<{ access_token: string; refresh_token: string }> {\n  const res = await fetch(`${API_BASE}/v1/tokens/refresh`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ refresh_token: refreshToken }),\n  });\n  if (!res.ok) {\n    const err = new Error(`Token refresh failed (${res.status})`);\n    (err as Error & { status: number }).status = res.status;\n    throw err;\n  }\n  return res.json();\n}\n\nexport async function authenticatedFetch(\n  url: string,\n  options: RequestInit = {},\n): Promise<Response> {\n  const accessToken = await getToken();\n\n  const res = await fetch(url, {\n    ...options,\n    headers: {\n      ...options.headers,\n      Authorization: `Bearer ${accessToken}`,\n    },\n  });\n\n  if (res.status === 401) {\n    const newAccessToken = await triggerRefresh();\n    return fetch(url, {\n      ...options,\n      headers: {\n        ...options.headers,\n        Authorization: `Bearer ${newAccessToken}`,\n      },\n    });\n  }\n\n  return res;\n}\n\nexport async function logout(): Promise<void> {\n  try {\n    await authenticatedFetch(`${API_BASE}/v1/oauth/logout`, {\n      method: \"POST\",\n    });\n  } finally {\n    await clearTokens();\n  }\n}\n\nexport async function listOrganizations(): Promise<ListOrganizationsResponse> {\n  const res = await authenticatedFetch(`${API_BASE}/v1/organizations`);\n  if (!res.ok) {\n    throw new Error(`Failed to list organizations (${res.status})`);\n  }\n  return res.json();\n}\n\nexport async function getIdentity(): Promise<IdentityResponse> {\n  const res = await authenticatedFetch(`${API_BASE}/v1/identity`);\n  if (!res.ok) {\n    throw new Error(`Failed to fetch identity (${res.status})`);\n  }\n  return res.json();\n}\n\nexport async function listOrganizationProjects(\n  organizationId: string,\n): Promise<Project[]> {\n  const params = new URLSearchParams({\n    organization_id: organizationId,\n  });\n\n  const res = await authenticatedFetch(`${API_BASE}/v1/projects?${params}`);\n  if (!res.ok) {\n    throw new Error(`Failed to list projects (${res.status})`);\n  }\n\n  const body = (await res.json()) as { projects: Project[] };\n  return body.projects;\n}\n\nexport async function createCheckoutSession(\n  organizationId: string,\n  successUrl: string,\n  cancelUrl: string,\n): Promise<{ url: string }> {\n  const res = await authenticatedFetch(\n    `${API_BASE}/v1/organizations/${organizationId}/billing/checkout`,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        success_url: successUrl,\n        cancel_url: cancelUrl,\n      }),\n    },\n  );\n\n  if (!res.ok) {\n    throw new Error(`Failed to create checkout session (${res.status})`);\n  }\n\n  return res.json();\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/auth/tokenManager.ts",
    "content": "import {\n  getAccessToken,\n  getRefreshToken,\n  storeTokens,\n  clearAccessToken,\n  clearTokens,\n} from \"@remote/shared/lib/auth\";\nimport { shouldRefreshAccessToken } from \"shared/jwt\";\nimport { refreshTokens } from \"@remote/shared/lib/api\";\n\nconst TOKEN_REFRESH_TIMEOUT_MS = 80_000;\nconst TOKEN_REFRESH_MAX_ATTEMPTS = 3;\n\nasync function refreshWithRetry(refreshToken: string) {\n  for (let attempt = 1; attempt <= TOKEN_REFRESH_MAX_ATTEMPTS; attempt++) {\n    const backoffMs = Math.min(500 * 2 ** (attempt - 1), 2000);\n    let timeoutId: ReturnType<typeof setTimeout>;\n    try {\n      return await Promise.race([\n        refreshTokens(refreshToken),\n        new Promise<never>((_, reject) => {\n          timeoutId = setTimeout(\n            () => reject(new Error(\"Token refresh timed out\")),\n            TOKEN_REFRESH_TIMEOUT_MS,\n          );\n        }),\n      ]);\n    } catch (error) {\n      const isTimeout =\n        error instanceof Error && error.message === \"Token refresh timed out\";\n      if (isTimeout) throw error;\n\n      const status = (error as { status?: number }).status;\n      const isRetryable =\n        !status || status >= 500 || error instanceof TypeError;\n      if (isRetryable && attempt < TOKEN_REFRESH_MAX_ATTEMPTS) {\n        await new Promise((r) => setTimeout(r, backoffMs));\n        continue;\n      }\n      throw error;\n    } finally {\n      clearTimeout(timeoutId!);\n    }\n  }\n  throw new Error(\"Token refresh failed after retries\");\n}\n\nlet refreshPromise: Promise<string> | null = null;\n\nasync function doTokenRefresh(): Promise<string> {\n  const current = await getAccessToken();\n  if (current && !shouldRefreshAccessToken(current)) return current;\n\n  const refreshToken = await getRefreshToken();\n  if (!refreshToken) {\n    await clearTokens();\n    throw new Error(\"No refresh token available\");\n  }\n\n  const tokens = await refreshWithRetry(refreshToken);\n  await storeTokens(tokens.access_token, tokens.refresh_token);\n  return tokens.access_token;\n}\n\nfunction handleTokenRefresh(): Promise<string> {\n  if (refreshPromise) return refreshPromise;\n\n  const innerPromise =\n    typeof navigator.locks?.request === \"function\"\n      ? navigator.locks\n          .request(\"rf-token-refresh\", doTokenRefresh)\n          .then((t) => t)\n      : doTokenRefresh();\n\n  const promise = innerPromise\n    .catch(async (error: unknown) => {\n      await clearTokens();\n\n      const status = (error as { status?: number }).status;\n      if (status === 401) {\n        throw new Error(\"Session expired. Please sign in again.\");\n      }\n\n      throw new Error(\"Session refresh failed. Please sign in again.\");\n    })\n    .finally(() => {\n      refreshPromise = null;\n    });\n\n  refreshPromise = promise;\n  return promise;\n}\n\nexport async function getToken(): Promise<string> {\n  const accessToken = await getAccessToken();\n  if (!accessToken) {\n    if (!(await getRefreshToken())) throw new Error(\"Not authenticated\");\n    return handleTokenRefresh();\n  }\n  if (shouldRefreshAccessToken(accessToken)) return handleTokenRefresh();\n  return accessToken;\n}\n\nexport async function triggerRefresh(): Promise<string> {\n  await clearAccessToken();\n  return handleTokenRefresh();\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/auth.ts",
    "content": "const DB_NAME = \"rf-auth\";\nconst STORE_NAME = \"tokens\";\nconst ACCESS_TOKEN_KEY = \"access_token\";\nconst REFRESH_TOKEN_KEY = \"refresh_token\";\nexport const AUTH_CHANGED_EVENT = \"remote-auth-changed\";\n\nfunction emitAuthChanged(): void {\n  if (typeof window !== \"undefined\") {\n    window.dispatchEvent(new Event(AUTH_CHANGED_EVENT));\n  }\n}\n\nfunction openDB(): Promise<IDBDatabase> {\n  return new Promise((resolve, reject) => {\n    const request = indexedDB.open(DB_NAME, 1);\n    request.onupgradeneeded = () => {\n      request.result.createObjectStore(STORE_NAME);\n    };\n    request.onsuccess = () => resolve(request.result);\n    request.onerror = () => reject(request.error);\n  });\n}\n\nfunction get(key: string): Promise<string | null> {\n  return openDB().then(\n    (db) =>\n      new Promise((resolve, reject) => {\n        const tx = db.transaction(STORE_NAME, \"readonly\");\n        let value: string | null = null;\n        const req = tx.objectStore(STORE_NAME).get(key);\n\n        req.onsuccess = () => {\n          value = (req.result as string) ?? null;\n        };\n        req.onerror = () => reject(req.error);\n        tx.oncomplete = () => resolve(value);\n        tx.onerror = () => reject(tx.error);\n        tx.onabort = () => reject(tx.error);\n      }),\n  );\n}\n\nfunction put(key: string, value: string): Promise<void> {\n  return openDB().then(\n    (db) =>\n      new Promise((resolve, reject) => {\n        const tx = db.transaction(STORE_NAME, \"readwrite\");\n        const req = tx.objectStore(STORE_NAME).put(value, key);\n        req.onerror = () => reject(req.error);\n        tx.oncomplete = () => resolve();\n        tx.onerror = () => reject(tx.error);\n        tx.onabort = () => reject(tx.error);\n      }),\n  );\n}\n\nfunction del(key: string): Promise<void> {\n  return openDB().then(\n    (db) =>\n      new Promise((resolve, reject) => {\n        const tx = db.transaction(STORE_NAME, \"readwrite\");\n        const req = tx.objectStore(STORE_NAME).delete(key);\n        req.onerror = () => reject(req.error);\n        tx.oncomplete = () => resolve();\n        tx.onerror = () => reject(tx.error);\n        tx.onabort = () => reject(tx.error);\n      }),\n  );\n}\n\nexport async function storeTokens(\n  accessToken: string,\n  refreshToken: string,\n): Promise<void> {\n  await put(ACCESS_TOKEN_KEY, accessToken);\n  await put(REFRESH_TOKEN_KEY, refreshToken);\n  emitAuthChanged();\n}\n\nexport function getAccessToken(): Promise<string | null> {\n  return get(ACCESS_TOKEN_KEY);\n}\n\nexport function getRefreshToken(): Promise<string | null> {\n  return get(REFRESH_TOKEN_KEY);\n}\n\nexport async function clearAccessToken(): Promise<void> {\n  await del(ACCESS_TOKEN_KEY);\n}\n\nexport async function clearTokens(): Promise<void> {\n  await del(ACCESS_TOKEN_KEY);\n  await del(REFRESH_TOKEN_KEY);\n  emitAuthChanged();\n}\n\nexport async function isLoggedIn(): Promise<boolean> {\n  const [access, refresh] = await Promise.all([\n    getAccessToken(),\n    getRefreshToken(),\n  ]);\n  return access !== null && refresh !== null;\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/pkce.ts",
    "content": "function base64UrlEncode(array: Uint8Array): string {\n  const base64 = btoa(String.fromCharCode(...array));\n  return base64.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=/g, \"\");\n}\n\nfunction bytesToHex(bytes: Uint8Array): string {\n  let out = \"\";\n  for (let i = 0; i < bytes.length; i++) {\n    out += bytes[i].toString(16).padStart(2, \"0\");\n  }\n  return out;\n}\n\nexport function generateVerifier(): string {\n  const array = new Uint8Array(32);\n  crypto.getRandomValues(array);\n  return base64UrlEncode(array);\n}\n\nexport async function generateChallenge(verifier: string): Promise<string> {\n  const data = new TextEncoder().encode(verifier);\n  const hash = await crypto.subtle.digest(\"SHA-256\", data);\n  return bytesToHex(new Uint8Array(hash));\n}\n\nconst VERIFIER_KEY = \"oauth_verifier\";\nconst INVITATION_TOKEN_KEY = \"invitation_token\";\n\nexport function storeVerifier(verifier: string): void {\n  sessionStorage.setItem(VERIFIER_KEY, verifier);\n}\n\nexport function retrieveVerifier(): string | null {\n  return sessionStorage.getItem(VERIFIER_KEY);\n}\n\nexport function clearVerifier(): void {\n  sessionStorage.removeItem(VERIFIER_KEY);\n}\n\nexport function storeInvitationToken(token: string): void {\n  sessionStorage.setItem(INVITATION_TOKEN_KEY, token);\n}\n\nexport function retrieveInvitationToken(): string | null {\n  return sessionStorage.getItem(INVITATION_TOKEN_KEY);\n}\n\nexport function clearInvitationToken(): void {\n  sessionStorage.removeItem(INVITATION_TOKEN_KEY);\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/activeHostContext.ts",
    "content": "let activeRelayHostId: string | null = null;\n\nexport function setActiveRelayHostId(hostId: string | null): void {\n  activeRelayHostId = hostId;\n}\n\nexport function getActiveRelayHostId(): string | null {\n  return activeRelayHostId;\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/bytes.ts",
    "content": "export const TEXT_ENCODER = new TextEncoder();\nexport const TEXT_DECODER = new TextDecoder();\n\nexport function bytesToBase64(bytes: Uint8Array): string {\n  let binary = \"\";\n  for (const value of bytes) {\n    binary += String.fromCharCode(value);\n  }\n  return btoa(binary);\n}\n\nexport function base64ToBytes(value: string): Uint8Array {\n  const binary = atob(value);\n  const bytes = new Uint8Array(binary.length);\n  for (let index = 0; index < binary.length; index += 1) {\n    bytes[index] = binary.charCodeAt(index);\n  }\n  return bytes;\n}\n\nexport function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n  return bytes.buffer.slice(\n    bytes.byteOffset,\n    bytes.byteOffset + bytes.byteLength,\n  ) as ArrayBuffer;\n}\n\nexport async function sha256Base64(bytes: Uint8Array): Promise<string> {\n  const hashBuffer = await crypto.subtle.digest(\n    \"SHA-256\",\n    toArrayBuffer(bytes),\n  );\n  return bytesToBase64(new Uint8Array(hashBuffer));\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/context.ts",
    "content": "import {\n  type PairedRelayHost,\n  listPairedRelayHosts,\n  savePairedRelayHost,\n  subscribeRelayPairingChanges,\n} from \"@/shared/lib/relayPairingStorage\";\nimport { createRelaySession } from \"@/shared/lib/remoteApi\";\nimport {\n  createRelaySessionAuthCode,\n  establishRelaySessionBaseUrl,\n  getRelayApiUrl,\n  refreshRelaySigningSession,\n} from \"@/shared/lib/relayBackendApi\";\nimport { buildRelaySigningSessionRefreshPayload } from \"@/shared/lib/relaySigningSessionRefresh\";\n\nimport type { RelayHostContext } from \"@remote/shared/lib/relay/types\";\n\nconst relaySessionBaseUrlCache = new Map<string, Promise<string>>();\n\nsubscribeRelayPairingChanges(({ hostId }) => {\n  relaySessionBaseUrlCache.delete(hostId);\n});\n\nexport async function resolveRelayHostContext(\n  hostId: string,\n): Promise<RelayHostContext> {\n  const pairedHost = await findPairedHost(hostId);\n  if (!pairedHost) {\n    throw new Error(\n      \"This host is not paired with your browser. Pair it in Relay settings.\",\n    );\n  }\n\n  if (!pairedHost.signing_session_id) {\n    throw new Error(\n      \"This host pairing is outdated. Re-pair it in Relay settings.\",\n    );\n  }\n\n  const relaySessionBaseUrl = await getRelaySessionBaseUrl(hostId);\n  return {\n    pairedHost,\n    relaySessionBaseUrl,\n  };\n}\n\nexport function invalidateRelaySessionBaseUrl(hostId: string): void {\n  relaySessionBaseUrlCache.delete(hostId);\n}\n\nexport async function tryRefreshRelayHostSigningSession(\n  context: RelayHostContext,\n): Promise<RelayHostContext | null> {\n  const clientId = context.pairedHost.client_id;\n  if (!clientId) {\n    return null;\n  }\n\n  try {\n    const payload = await buildRelaySigningSessionRefreshPayload(\n      clientId,\n      context.pairedHost.private_key_jwk,\n    );\n    const refreshed = await refreshRelaySigningSession(\n      context.relaySessionBaseUrl,\n      payload,\n    );\n    const updatedPairedHost: PairedRelayHost = {\n      ...context.pairedHost,\n      signing_session_id: refreshed.signing_session_id,\n    };\n    await savePairedRelayHost(updatedPairedHost);\n\n    return {\n      ...context,\n      pairedHost: updatedPairedHost,\n    };\n  } catch (error) {\n    console.warn(\"Failed to refresh relay signing session\", error);\n    return null;\n  }\n}\n\nasync function getRelaySessionBaseUrl(hostId: string): Promise<string> {\n  const cached = relaySessionBaseUrlCache.get(hostId);\n  if (cached) {\n    return cached;\n  }\n\n  const created = createRelaySessionBaseUrl(hostId).catch((error) => {\n    relaySessionBaseUrlCache.delete(hostId);\n    throw error;\n  });\n\n  relaySessionBaseUrlCache.set(hostId, created);\n  return created;\n}\n\nasync function createRelaySessionBaseUrl(hostId: string): Promise<string> {\n  const relaySession = await createRelaySession(hostId);\n  const authCode = await createRelaySessionAuthCode(relaySession.id);\n  const relayApiUrl = getRelayApiUrl();\n  return establishRelaySessionBaseUrl(relayApiUrl, hostId, authCode.code);\n}\n\nasync function findPairedHost(hostId: string): Promise<PairedRelayHost | null> {\n  const pairedHosts = await listPairedRelayHosts();\n  return pairedHosts.find((host) => host.host_id === hostId) ?? null;\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/http.ts",
    "content": "import {\n  buildSignedHeaders,\n  CONTENT_TYPE_HEADER,\n} from \"@remote/shared/lib/relay/signing\";\nimport type { RelayHostContext } from \"@remote/shared/lib/relay/types\";\n\nexport function isAuthFailureStatus(status: number): boolean {\n  return status === 401 || status === 403;\n}\n\nexport async function sendRelayHostRequest(\n  context: RelayHostContext,\n  params: {\n    normalizedPath: string;\n    method: string;\n    body: BodyInit | undefined;\n    bodyBytes: Uint8Array;\n    contentType: string | null;\n    requestInit: RequestInit;\n  },\n): Promise<Response> {\n  const headers = await buildSignedHeaders(\n    context.pairedHost,\n    params.method,\n    params.normalizedPath,\n    params.bodyBytes,\n    params.requestInit.headers,\n  );\n\n  if (params.contentType && !headers.has(CONTENT_TYPE_HEADER)) {\n    headers.set(CONTENT_TYPE_HEADER, params.contentType);\n  }\n\n  return fetch(`${context.relaySessionBaseUrl}${params.normalizedPath}`, {\n    ...params.requestInit,\n    body: params.body,\n    headers,\n    credentials: \"include\",\n  });\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/keyCache.ts",
    "content": "import type { PairedRelayHost } from \"@/shared/lib/relayPairingStorage\";\nimport { subscribeRelayPairingChanges } from \"@/shared/lib/relayPairingStorage\";\n\nimport { base64ToBytes, toArrayBuffer } from \"@remote/shared/lib/relay/bytes\";\n\nconst signingKeyCache = new Map<string, CryptoKey>();\nconst serverVerifyKeyCache = new Map<string, CryptoKey>();\n\nsubscribeRelayPairingChanges(({ hostId }) => {\n  clearRelayHostCryptoCaches(hostId);\n});\n\nexport async function getSigningKey(\n  pairedHost: PairedRelayHost,\n): Promise<CryptoKey> {\n  const signingSessionId = pairedHost.signing_session_id;\n  if (!signingSessionId) {\n    throw new Error(\"Missing signing session for paired host.\");\n  }\n\n  const cacheKey = pairedHost.host_id;\n  const cachedKey = signingKeyCache.get(cacheKey);\n  if (cachedKey) {\n    return cachedKey;\n  }\n\n  const importedKey = await crypto.subtle.importKey(\n    \"jwk\",\n    pairedHost.private_key_jwk,\n    { name: \"Ed25519\" },\n    false,\n    [\"sign\"],\n  );\n\n  signingKeyCache.set(cacheKey, importedKey);\n  return importedKey;\n}\n\nexport async function getServerVerifyKey(\n  pairedHost: PairedRelayHost,\n): Promise<CryptoKey> {\n  const signingSessionId = pairedHost.signing_session_id;\n  if (!signingSessionId) {\n    throw new Error(\"Missing signing session for paired host.\");\n  }\n\n  const cacheKey = pairedHost.host_id;\n  const cachedKey = serverVerifyKeyCache.get(cacheKey);\n  if (cachedKey) {\n    return cachedKey;\n  }\n\n  const serverPublicKeyB64 = pairedHost.server_public_key_b64;\n  if (!serverPublicKeyB64) {\n    throw new Error(\"Missing server signing key for paired host.\");\n  }\n\n  const importedKey = await crypto.subtle.importKey(\n    \"raw\",\n    toArrayBuffer(base64ToBytes(serverPublicKeyB64)),\n    { name: \"Ed25519\" },\n    false,\n    [\"verify\"],\n  );\n\n  serverVerifyKeyCache.set(cacheKey, importedKey);\n  return importedKey;\n}\n\nexport function clearRelayHostCryptoCaches(hostId: string): void {\n  signingKeyCache.delete(hostId);\n  serverVerifyKeyCache.delete(hostId);\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/routing.ts",
    "content": "export function isWorkspaceRoutePath(pathname: string): boolean {\n  const segments = pathname.split(\"/\").filter(Boolean);\n  if (segments[0] !== \"hosts\" || !segments[1]) {\n    return false;\n  }\n\n  if (segments[2] === \"workspaces\") {\n    return true;\n  }\n\n  if (segments[2] !== \"projects\" || !segments[3]) {\n    return false;\n  }\n\n  const isIssueWorkspacePath =\n    segments[4] === \"issues\" &&\n    !!segments[5] &&\n    segments[6] === \"workspaces\" &&\n    !!segments[7];\n\n  const isProjectWorkspaceCreatePath =\n    segments[4] === \"workspaces\" && segments[5] === \"create\" && !!segments[6];\n\n  return isIssueWorkspacePath || isProjectWorkspaceCreatePath;\n}\n\nexport function parseRelayHostIdFromPathname(pathname: string): string | null {\n  const segments = pathname.split(\"/\").filter(Boolean);\n  const hostsSegmentIndex = segments.indexOf(\"hosts\");\n  if (hostsSegmentIndex === -1) {\n    return null;\n  }\n\n  return segments[hostsSegmentIndex + 1] ?? null;\n}\n\nexport function resolveRelayHostIdForCurrentPage(): string | null {\n  if (typeof window === \"undefined\") {\n    return null;\n  }\n\n  return parseRelayHostIdFromPathname(window.location.pathname);\n}\n\nexport function shouldRelayApiPath(pathAndQuery: string): boolean {\n  const [path] = pathAndQuery.split(\"?\");\n  if (!path.startsWith(\"/api/\")) {\n    return false;\n  }\n\n  return !path.startsWith(\"/api/remote/\");\n}\n\nexport function normalizePath(pathAndQuery: string): string {\n  return pathAndQuery.startsWith(\"/\") ? pathAndQuery : `/${pathAndQuery}`;\n}\n\nexport function toPathAndQuery(pathOrUrl: string): string {\n  if (/^https?:\\/\\//i.test(pathOrUrl) || /^wss?:\\/\\//i.test(pathOrUrl)) {\n    const url = new URL(pathOrUrl);\n    return `${url.pathname}${url.search}`;\n  }\n\n  return pathOrUrl.startsWith(\"/\") ? pathOrUrl : `/${pathOrUrl}`;\n}\n\nexport function openBrowserWebSocket(pathOrUrl: string): WebSocket {\n  if (/^wss?:\\/\\//i.test(pathOrUrl)) {\n    return new WebSocket(pathOrUrl);\n  }\n\n  if (/^https?:\\/\\//i.test(pathOrUrl)) {\n    return new WebSocket(pathOrUrl.replace(/^http/i, \"ws\"));\n  }\n\n  const protocol = window.location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n  const normalizedPath = pathOrUrl.startsWith(\"/\")\n    ? pathOrUrl\n    : `/${pathOrUrl}`;\n  return new WebSocket(`${protocol}//${window.location.host}${normalizedPath}`);\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/signing.ts",
    "content": "import type { PairedRelayHost } from \"@/shared/lib/relayPairingStorage\";\n\nimport {\n  bytesToBase64,\n  sha256Base64,\n  TEXT_ENCODER,\n  toArrayBuffer,\n} from \"@remote/shared/lib/relay/bytes\";\nimport { getSigningKey } from \"@remote/shared/lib/relay/keyCache\";\nimport type {\n  NormalizedRelayRequestBody,\n  RelaySignature,\n} from \"@remote/shared/lib/relay/types\";\n\nexport const CONTENT_TYPE_HEADER = \"Content-Type\";\n\nconst SIGNING_SESSION_HEADER = \"x-vk-sig-session\";\nconst TIMESTAMP_HEADER = \"x-vk-sig-ts\";\nconst NONCE_HEADER = \"x-vk-sig-nonce\";\nconst REQUEST_SIGNATURE_HEADER = \"x-vk-sig-signature\";\n\nconst EMPTY_BYTES = new Uint8Array();\n// Placeholder origin used only to construct/parse relative URLs. Never fetched.\nconst URL_PARSE_BASE = \"https://example.invalid\";\n\nexport async function buildSignedHeaders(\n  pairedHost: PairedRelayHost,\n  method: string,\n  pathAndQuery: string,\n  bodyBytes: Uint8Array,\n  incomingHeaders?: HeadersInit,\n): Promise<Headers> {\n  const signature = await buildRelaySignature(\n    pairedHost,\n    method,\n    pathAndQuery,\n    bodyBytes,\n  );\n\n  const headers = new Headers(incomingHeaders);\n  headers.set(SIGNING_SESSION_HEADER, signature.signingSessionId);\n  headers.set(TIMESTAMP_HEADER, String(signature.timestamp));\n  headers.set(NONCE_HEADER, signature.nonce);\n  headers.set(REQUEST_SIGNATURE_HEADER, signature.signature);\n  return headers;\n}\n\nexport async function buildRelaySignature(\n  pairedHost: PairedRelayHost,\n  method: string,\n  pathAndQuery: string,\n  bodyBytes: Uint8Array,\n): Promise<RelaySignature> {\n  const signingSessionId = pairedHost.signing_session_id;\n  if (!signingSessionId) {\n    throw new Error(\n      \"This host pairing is missing signing metadata. Re-pair it.\",\n    );\n  }\n\n  const timestamp = Math.floor(Date.now() / 1000);\n  const nonce = crypto.randomUUID().replace(/-/g, \"\");\n  const bodyHashB64 = await sha256Base64(bodyBytes);\n\n  const message = [\n    \"v1\",\n    String(timestamp),\n    method.toUpperCase(),\n    pathAndQuery,\n    signingSessionId,\n    nonce,\n    bodyHashB64,\n  ].join(\"|\");\n\n  const signingKey = await getSigningKey(pairedHost);\n  const signature = await crypto.subtle.sign(\n    \"Ed25519\",\n    signingKey,\n    toArrayBuffer(TEXT_ENCODER.encode(message)),\n  );\n\n  return {\n    signingSessionId,\n    timestamp,\n    nonce,\n    signature: bytesToBase64(new Uint8Array(signature)),\n  };\n}\n\nexport async function normalizeRequestBody(\n  body: BodyInit | null | undefined,\n): Promise<NormalizedRelayRequestBody> {\n  if (body == null) {\n    return { body: undefined, bodyBytes: EMPTY_BYTES, contentType: null };\n  }\n\n  if (typeof body === \"string\") {\n    return {\n      body,\n      bodyBytes: TEXT_ENCODER.encode(body),\n      contentType: \"text/plain;charset=UTF-8\",\n    };\n  }\n\n  const probeRequest = new Request(URL_PARSE_BASE, {\n    method: \"POST\",\n    body,\n  });\n\n  const serializedBody = new Uint8Array(await probeRequest.arrayBuffer());\n  return {\n    // Use the exact serialized bytes for both signing and transport.\n    body: serializedBody,\n    bodyBytes: serializedBody,\n    contentType: probeRequest.headers.get(CONTENT_TYPE_HEADER),\n  };\n}\n\nexport function appendSignatureToPath(\n  pathAndQuery: string,\n  signature: RelaySignature,\n): string {\n  const url = new URL(pathAndQuery, URL_PARSE_BASE);\n  url.searchParams.set(SIGNING_SESSION_HEADER, signature.signingSessionId);\n  url.searchParams.set(TIMESTAMP_HEADER, String(signature.timestamp));\n  url.searchParams.set(NONCE_HEADER, signature.nonce);\n  url.searchParams.set(REQUEST_SIGNATURE_HEADER, signature.signature);\n  return `${url.pathname}${url.search}`;\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/types.ts",
    "content": "import type { PairedRelayHost } from \"@/shared/lib/relayPairingStorage\";\n\nexport interface RelaySignature {\n  signingSessionId: string;\n  timestamp: number;\n  nonce: string;\n  signature: string;\n}\n\nexport interface RelayHostContext {\n  pairedHost: PairedRelayHost;\n  relaySessionBaseUrl: string;\n}\n\nexport type RelayWsMessageType = \"text\" | \"binary\" | \"ping\" | \"pong\" | \"close\";\n\nexport interface RelaySignedWsEnvelope {\n  version: number;\n  seq: number;\n  msg_type: RelayWsMessageType;\n  payload_b64: string;\n  signature_b64: string;\n}\n\nexport interface RelayWsSigningContext {\n  signingSessionId: string;\n  requestNonce: string;\n  inboundSeq: number;\n  outboundSeq: number;\n  signingKey: CryptoKey;\n  serverVerifyKey: CryptoKey;\n}\n\nexport interface NormalizedRelayRequestBody {\n  body: BodyInit | undefined;\n  bodyBytes: Uint8Array;\n  contentType: string | null;\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relay/ws.ts",
    "content": "import type { PairedRelayHost } from \"@/shared/lib/relayPairingStorage\";\n\nimport {\n  base64ToBytes,\n  bytesToBase64,\n  sha256Base64,\n  TEXT_DECODER,\n  TEXT_ENCODER,\n  toArrayBuffer,\n} from \"@remote/shared/lib/relay/bytes\";\nimport {\n  getServerVerifyKey,\n  getSigningKey,\n} from \"@remote/shared/lib/relay/keyCache\";\nimport type {\n  RelaySignature,\n  RelaySignedWsEnvelope,\n  RelayWsMessageType,\n  RelayWsSigningContext,\n} from \"@remote/shared/lib/relay/types\";\n\nconst WS_ENVELOPE_VERSION = 1;\n\nexport async function createRelayWsSigningContext(\n  pairedHost: PairedRelayHost,\n  requestSignature: RelaySignature,\n): Promise<RelayWsSigningContext> {\n  const [signingKey, serverVerifyKey] = await Promise.all([\n    getSigningKey(pairedHost),\n    getServerVerifyKey(pairedHost),\n  ]);\n\n  return {\n    signingSessionId: requestSignature.signingSessionId,\n    requestNonce: requestSignature.nonce,\n    inboundSeq: 0,\n    outboundSeq: 0,\n    signingKey,\n    serverVerifyKey,\n  };\n}\n\nexport function createRelaySignedWebSocket(\n  rawSocket: WebSocket,\n  signingContext: RelayWsSigningContext,\n): WebSocket {\n  return new RelaySignedWebSocket(\n    rawSocket,\n    signingContext,\n  ) as unknown as WebSocket;\n}\n\nclass RelaySignedWebSocket extends EventTarget {\n  onopen: WebSocket[\"onopen\"] = null;\n  onerror: WebSocket[\"onerror\"] = null;\n  onclose: WebSocket[\"onclose\"] = null;\n  onmessage: WebSocket[\"onmessage\"] = null;\n\n  private outboundQueue: Promise<void> = Promise.resolve();\n  private inboundQueue: Promise<void> = Promise.resolve();\n  private binaryTypeValue: BinaryType = \"blob\";\n\n  constructor(\n    private readonly rawSocket: WebSocket,\n    private readonly signingContext: RelayWsSigningContext,\n  ) {\n    super();\n    this.rawSocket.binaryType = \"arraybuffer\";\n    this.attachRawSocketListeners();\n  }\n\n  get url(): string {\n    return this.rawSocket.url;\n  }\n\n  get protocol(): string {\n    return this.rawSocket.protocol;\n  }\n\n  get extensions(): string {\n    return this.rawSocket.extensions;\n  }\n\n  get bufferedAmount(): number {\n    return this.rawSocket.bufferedAmount;\n  }\n\n  get readyState(): number {\n    return this.rawSocket.readyState;\n  }\n\n  get binaryType(): BinaryType {\n    return this.binaryTypeValue;\n  }\n\n  set binaryType(value: BinaryType) {\n    this.binaryTypeValue = value;\n  }\n\n  send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {\n    this.outboundQueue = this.outboundQueue\n      .then(async () => {\n        if (this.rawSocket.readyState !== WebSocket.OPEN) {\n          return;\n        }\n\n        const { msgType, payload } = await normalizeOutboundWsPayload(data);\n        const envelope = await buildRelayWsEnvelope(\n          this.signingContext,\n          msgType,\n          payload,\n        );\n        this.rawSocket.send(JSON.stringify(envelope));\n      })\n      .catch((error) => {\n        this.emitProtocolError(error);\n      });\n  }\n\n  close(code?: number, reason?: string): void {\n    this.rawSocket.close(code, reason);\n  }\n\n  private attachRawSocketListeners(): void {\n    this.rawSocket.addEventListener(\"open\", () => {\n      this.emitOpen();\n    });\n\n    this.rawSocket.addEventListener(\"message\", (event) => {\n      this.inboundQueue = this.inboundQueue\n        .then(async () => {\n          const envelope = await decodeRelayWsEnvelope(\n            this.signingContext,\n            event.data,\n          );\n          await this.forwardDecodedFrame(envelope.msg_type, envelope.payload);\n        })\n        .catch((error) => {\n          this.emitProtocolError(error);\n        });\n    });\n\n    this.rawSocket.addEventListener(\"error\", () => {\n      this.emitError();\n    });\n\n    this.rawSocket.addEventListener(\"close\", (event) => {\n      this.emitClose(event.code, event.reason, event.wasClean);\n    });\n  }\n\n  private async forwardDecodedFrame(\n    msgType: RelayWsMessageType,\n    payload: Uint8Array,\n  ): Promise<void> {\n    switch (msgType) {\n      case \"text\":\n        this.emitMessage(TEXT_DECODER.decode(payload));\n        return;\n      case \"binary\":\n        this.emitMessage(await this.toBinaryMessageData(payload));\n        return;\n      case \"close\": {\n        const closePayload = decodeClosePayload(payload);\n        if (closePayload.code == null) {\n          this.close();\n          return;\n        }\n\n        try {\n          this.close(closePayload.code, closePayload.reason);\n        } catch {\n          this.close();\n        }\n        return;\n      }\n      case \"ping\":\n      case \"pong\":\n        return;\n    }\n  }\n\n  private async toBinaryMessageData(\n    payload: Uint8Array,\n  ): Promise<ArrayBuffer | Blob> {\n    if (this.binaryTypeValue === \"arraybuffer\") {\n      return toArrayBuffer(payload);\n    }\n    return new Blob([toArrayBuffer(payload)]);\n  }\n\n  private emitOpen(): void {\n    const event = new Event(\"open\");\n    this.onopen?.call(this.asWebSocket(), event);\n    this.dispatchEvent(event);\n  }\n\n  private emitError(): void {\n    const event = new Event(\"error\");\n    this.onerror?.call(this.asWebSocket(), event);\n    this.dispatchEvent(event);\n  }\n\n  private emitClose(code: number, reason: string, wasClean: boolean): void {\n    const event = new CloseEvent(\"close\", { code, reason, wasClean });\n    this.onclose?.call(this.asWebSocket(), event);\n    this.dispatchEvent(event);\n  }\n\n  private emitMessage(data: string | ArrayBuffer | Blob): void {\n    const event = new MessageEvent(\"message\", { data });\n    this.onmessage?.call(this.asWebSocket(), event);\n    this.dispatchEvent(event);\n  }\n\n  private emitProtocolError(error: unknown): void {\n    console.error(\"Failed to process relay WebSocket frame:\", error);\n    this.emitError();\n    if (\n      this.rawSocket.readyState === WebSocket.OPEN ||\n      this.rawSocket.readyState === WebSocket.CONNECTING\n    ) {\n      this.rawSocket.close(1002, \"Invalid relay frame\");\n    }\n  }\n\n  private asWebSocket(): WebSocket {\n    return this as unknown as WebSocket;\n  }\n}\n\nasync function normalizeOutboundWsPayload(\n  data: string | ArrayBufferLike | Blob | ArrayBufferView,\n): Promise<{ msgType: RelayWsMessageType; payload: Uint8Array }> {\n  if (typeof data === \"string\") {\n    return { msgType: \"text\", payload: TEXT_ENCODER.encode(data) };\n  }\n\n  if (data instanceof Blob) {\n    return {\n      msgType: \"binary\",\n      payload: new Uint8Array(await data.arrayBuffer()),\n    };\n  }\n\n  if (ArrayBuffer.isView(data)) {\n    return {\n      msgType: \"binary\",\n      payload: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),\n    };\n  }\n\n  if (data instanceof ArrayBuffer) {\n    return { msgType: \"binary\", payload: new Uint8Array(data) };\n  }\n\n  throw new Error(\"Unsupported WebSocket payload type.\");\n}\n\nasync function decodeRelayWsEnvelope(\n  signingContext: RelayWsSigningContext,\n  rawData: unknown,\n): Promise<RelaySignedWsEnvelope & { payload: Uint8Array }> {\n  const rawFrame = await decodeWsFrameBytes(rawData);\n  const parsedEnvelope = parseRelayWsEnvelope(rawFrame);\n\n  if (parsedEnvelope.version !== WS_ENVELOPE_VERSION) {\n    throw new Error(\"Unsupported relay WS envelope version.\");\n  }\n\n  const expectedSeq = signingContext.inboundSeq + 1;\n  if (parsedEnvelope.seq !== expectedSeq) {\n    throw new Error(\n      `Invalid relay WS sequence: expected ${expectedSeq}, got ${parsedEnvelope.seq}.`,\n    );\n  }\n\n  const payload = base64ToBytes(parsedEnvelope.payload_b64);\n  const signatureBytes = base64ToBytes(parsedEnvelope.signature_b64);\n  const signingInput = await buildRelayWsSigningInput(\n    signingContext.signingSessionId,\n    signingContext.requestNonce,\n    parsedEnvelope.seq,\n    parsedEnvelope.msg_type,\n    payload,\n  );\n\n  const isValid = await crypto.subtle.verify(\n    \"Ed25519\",\n    signingContext.serverVerifyKey,\n    toArrayBuffer(signatureBytes),\n    toArrayBuffer(TEXT_ENCODER.encode(signingInput)),\n  );\n\n  if (!isValid) {\n    throw new Error(\"Invalid relay WS frame signature.\");\n  }\n\n  signingContext.inboundSeq = parsedEnvelope.seq;\n  return { ...parsedEnvelope, payload };\n}\n\nasync function buildRelayWsEnvelope(\n  signingContext: RelayWsSigningContext,\n  msgType: RelayWsMessageType,\n  payload: Uint8Array,\n): Promise<RelaySignedWsEnvelope> {\n  const nextSeq = signingContext.outboundSeq + 1;\n  const signingInput = await buildRelayWsSigningInput(\n    signingContext.signingSessionId,\n    signingContext.requestNonce,\n    nextSeq,\n    msgType,\n    payload,\n  );\n\n  const signature = await crypto.subtle.sign(\n    \"Ed25519\",\n    signingContext.signingKey,\n    toArrayBuffer(TEXT_ENCODER.encode(signingInput)),\n  );\n\n  signingContext.outboundSeq = nextSeq;\n\n  return {\n    version: WS_ENVELOPE_VERSION,\n    seq: nextSeq,\n    msg_type: msgType,\n    payload_b64: bytesToBase64(payload),\n    signature_b64: bytesToBase64(new Uint8Array(signature)),\n  };\n}\n\nasync function buildRelayWsSigningInput(\n  signingSessionId: string,\n  requestNonce: string,\n  seq: number,\n  msgType: RelayWsMessageType,\n  payload: Uint8Array,\n): Promise<string> {\n  const payloadHashB64 = await sha256Base64(payload);\n  return [\n    \"v1\",\n    signingSessionId,\n    requestNonce,\n    String(seq),\n    msgType,\n    payloadHashB64,\n  ].join(\"|\");\n}\n\nasync function decodeWsFrameBytes(rawData: unknown): Promise<Uint8Array> {\n  if (typeof rawData === \"string\") {\n    return TEXT_ENCODER.encode(rawData);\n  }\n\n  if (rawData instanceof Blob) {\n    return new Uint8Array(await rawData.arrayBuffer());\n  }\n\n  if (ArrayBuffer.isView(rawData)) {\n    return new Uint8Array(\n      rawData.buffer,\n      rawData.byteOffset,\n      rawData.byteLength,\n    );\n  }\n\n  if (rawData instanceof ArrayBuffer) {\n    return new Uint8Array(rawData);\n  }\n\n  throw new Error(\"Unsupported relay WebSocket frame.\");\n}\n\nfunction parseRelayWsEnvelope(rawFrame: Uint8Array): RelaySignedWsEnvelope {\n  let parsed: unknown;\n  try {\n    parsed = JSON.parse(TEXT_DECODER.decode(rawFrame));\n  } catch {\n    throw new Error(\"Invalid relay WS envelope JSON.\");\n  }\n\n  if (typeof parsed !== \"object\" || parsed == null) {\n    throw new Error(\"Invalid relay WS envelope.\");\n  }\n\n  const envelope = parsed as Partial<RelaySignedWsEnvelope>;\n  if (\n    typeof envelope.version !== \"number\" ||\n    typeof envelope.seq !== \"number\" ||\n    !isRelayWsMessageType(envelope.msg_type) ||\n    typeof envelope.payload_b64 !== \"string\" ||\n    typeof envelope.signature_b64 !== \"string\"\n  ) {\n    throw new Error(\"Invalid relay WS envelope shape.\");\n  }\n\n  return {\n    version: envelope.version,\n    seq: envelope.seq,\n    msg_type: envelope.msg_type,\n    payload_b64: envelope.payload_b64,\n    signature_b64: envelope.signature_b64,\n  };\n}\n\nfunction isRelayWsMessageType(value: unknown): value is RelayWsMessageType {\n  return (\n    value === \"text\" ||\n    value === \"binary\" ||\n    value === \"ping\" ||\n    value === \"pong\" ||\n    value === \"close\"\n  );\n}\n\nfunction decodeClosePayload(payload: Uint8Array): {\n  code?: number;\n  reason?: string;\n} {\n  if (payload.length === 0) {\n    return {};\n  }\n\n  if (payload.length < 2) {\n    throw new Error(\"Invalid relay WS close payload.\");\n  }\n\n  const code = (payload[0] << 8) | payload[1];\n  const reason =\n    payload.length > 2 ? TEXT_DECODER.decode(payload.slice(2)) : \"\";\n  return { code, reason };\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/relayHostApi.ts",
    "content": "import {\n  invalidateRelaySessionBaseUrl,\n  resolveRelayHostContext,\n  tryRefreshRelayHostSigningSession,\n} from \"@remote/shared/lib/relay/context\";\nimport { getActiveRelayHostId } from \"@remote/shared/lib/relay/activeHostContext\";\nimport {\n  isAuthFailureStatus,\n  sendRelayHostRequest,\n} from \"@remote/shared/lib/relay/http\";\nimport {\n  isWorkspaceRoutePath,\n  normalizePath,\n  openBrowserWebSocket,\n  resolveRelayHostIdForCurrentPage,\n  shouldRelayApiPath,\n  toPathAndQuery,\n} from \"@remote/shared/lib/relay/routing\";\nimport {\n  appendSignatureToPath,\n  buildRelaySignature,\n  normalizeRequestBody,\n} from \"@remote/shared/lib/relay/signing\";\nimport {\n  createRelaySignedWebSocket,\n  createRelayWsSigningContext,\n} from \"@remote/shared/lib/relay/ws\";\n\nconst EMPTY_BYTES = new Uint8Array();\n\nexport { isWorkspaceRoutePath };\n\nexport async function requestLocalApiViaRelay(\n  pathOrUrl: string,\n  requestInit: RequestInit = {},\n): Promise<Response> {\n  const pathAndQuery = toPathAndQuery(pathOrUrl);\n\n  if (!shouldRelayApiPath(pathAndQuery)) {\n    return fetch(pathOrUrl, requestInit);\n  }\n\n  const hostId = resolveRelayHostIdForCurrentPage() ?? getActiveRelayHostId();\n  if (!hostId) {\n    throw new Error(\n      \"Host context is required for local API requests. Navigate under /hosts/{hostId}/...\",\n    );\n  }\n\n  return requestRelayHostApi(hostId, pathAndQuery, requestInit);\n}\n\nexport async function openLocalApiWebSocketViaRelay(\n  pathOrUrl: string,\n): Promise<WebSocket> {\n  const pathAndQuery = toPathAndQuery(pathOrUrl);\n\n  if (!shouldRelayApiPath(pathAndQuery)) {\n    return openBrowserWebSocket(pathOrUrl);\n  }\n\n  const hostId = resolveRelayHostIdForCurrentPage() ?? getActiveRelayHostId();\n  if (!hostId) {\n    throw new Error(\n      \"Host context is required for local API WebSocket requests. Navigate under /hosts/{hostId}/...\",\n    );\n  }\n\n  return openRelayHostWebSocket(hostId, pathAndQuery);\n}\n\nexport async function requestRelayHostApi(\n  hostId: string,\n  pathOrUrl: string,\n  requestInit: RequestInit = {},\n): Promise<Response> {\n  const pathAndQuery = toPathAndQuery(pathOrUrl);\n  const normalizedPath = normalizePath(pathAndQuery);\n  const method = (requestInit.method ?? \"GET\").toUpperCase();\n\n  const { body, bodyBytes, contentType } = await normalizeRequestBody(\n    requestInit.body,\n  );\n\n  const context = await resolveRelayHostContext(hostId);\n  const initialResponse = await sendRelayHostRequest(context, {\n    normalizedPath,\n    method,\n    body,\n    bodyBytes,\n    contentType,\n    requestInit,\n  });\n  if (!isAuthFailureStatus(initialResponse.status)) {\n    return initialResponse;\n  }\n\n  invalidateRelaySessionBaseUrl(hostId);\n  const refreshedContext = await tryRefreshRelayHostSigningSession(context);\n  if (!refreshedContext) {\n    return initialResponse;\n  }\n\n  const retryResponse = await sendRelayHostRequest(refreshedContext, {\n    normalizedPath,\n    method,\n    body,\n    bodyBytes,\n    contentType,\n    requestInit,\n  });\n  if (isAuthFailureStatus(retryResponse.status)) {\n    invalidateRelaySessionBaseUrl(hostId);\n  }\n\n  return retryResponse;\n}\n\nexport async function openRelayHostWebSocket(\n  hostId: string,\n  pathOrUrl: string,\n): Promise<WebSocket> {\n  const baseContext = await resolveRelayHostContext(hostId);\n  const context =\n    (await tryRefreshRelayHostSigningSession(baseContext)) ?? baseContext;\n  const pathAndQuery = toPathAndQuery(pathOrUrl);\n  const normalizedPath = normalizePath(pathAndQuery);\n\n  const signature = await buildRelaySignature(\n    context.pairedHost,\n    \"GET\",\n    normalizedPath,\n    EMPTY_BYTES,\n  );\n\n  const signedPath = appendSignatureToPath(normalizedPath, signature);\n  const wsUrl = `${context.relaySessionBaseUrl}${signedPath}`.replace(\n    /^http/i,\n    \"ws\",\n  );\n\n  const signingContext = await createRelayWsSigningContext(\n    context.pairedHost,\n    signature,\n  );\n  return createRelaySignedWebSocket(new WebSocket(wsUrl), signingContext);\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/lib/route-auth.ts",
    "content": "import { redirect, type ParsedLocation } from \"@tanstack/react-router\";\nimport { isLoggedIn } from \"@remote/shared/lib/auth\";\n\ntype RouteLocation = Pick<ParsedLocation, \"pathname\" | \"searchStr\" | \"hash\">;\n\nfunction toNextPath({ pathname, searchStr, hash }: RouteLocation): string {\n  return `${pathname}${searchStr}${hash}`;\n}\n\nexport async function requireAuthenticated(location: RouteLocation) {\n  if (await isLoggedIn()) {\n    return;\n  }\n\n  throw redirect({\n    to: \"/account\",\n    search: {\n      next: toNextPath(location),\n    },\n  });\n}\n\nexport async function redirectAuthenticatedToHome() {\n  if (await isLoggedIn()) {\n    throw redirect({ to: \"/\" });\n  }\n}\n"
  },
  {
    "path": "packages/remote-web/src/shared/stores/useMobileWorkspaceTitle.ts",
    "content": "import { create } from \"zustand\";\n\ninterface MobileWorkspaceTitleStore {\n  title: string | null;\n  setTitle: (title: string | null) => void;\n}\n\nexport const useMobileWorkspaceTitle = create<MobileWorkspaceTitleStore>(\n  (set) => ({\n    title: null,\n    setTitle: (title) => set({ title }),\n  }),\n);\n"
  },
  {
    "path": "packages/remote-web/src/shared/types/virtual-executor-schemas.d.ts",
    "content": "declare module \"virtual:executor-schemas\" {\n  import type { BaseCodingAgent } from \"@/shared/types\";\n\n  type RJSFSchema = Record<string, unknown>;\n\n  const schemas: Record<BaseCodingAgent, RJSFSchema>;\n  export { schemas };\n  export default schemas;\n}\n"
  },
  {
    "path": "packages/remote-web/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_API_BASE_URL: string;\n  readonly VITE_RELAY_API_BASE_URL: string;\n  readonly VITE_APP_BASE_URL: string;\n  readonly VITE_PUBLIC_POSTHOG_KEY: string;\n  readonly VITE_PUBLIC_POSTHOG_HOST: string;\n}\n\ninterface ImportMeta {\n  readonly env: ImportMetaEnv;\n}\n\ndeclare const __APP_VERSION__: string;\n"
  },
  {
    "path": "packages/remote-web/tailwind.config.cjs",
    "content": "module.exports = {\n  content: [\n    \"./index.html\",\n    \"./src/**/*.{js,jsx,ts,tsx}\",\n    \"../web-core/src/**/*.{js,jsx,ts,tsx}\",\n  ],\n};\n"
  },
  {
    "path": "packages/remote-web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@remote/*\": [\"./src/*\"],\n      \"@/*\": [\"../web-core/src/*\"],\n      \"shared/*\": [\"../../shared/*\"]\n    }\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "packages/remote-web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "packages/remote-web/vite.config.ts",
    "content": "import fs from \"fs\";\nimport path from \"path\";\nimport { defineConfig, type Plugin } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { tanstackRouter } from \"@tanstack/router-plugin/vite\";\nimport pkg from \"./package.json\";\n\nfunction executorSchemasPlugin(): Plugin {\n  const VIRTUAL_ID = \"virtual:executor-schemas\";\n  const RESOLVED_VIRTUAL_ID = `\\0${VIRTUAL_ID}`;\n\n  return {\n    name: \"executor-schemas-plugin\",\n    resolveId(id) {\n      if (id === VIRTUAL_ID) {\n        return RESOLVED_VIRTUAL_ID;\n      }\n      return null;\n    },\n    load(id) {\n      if (id !== RESOLVED_VIRTUAL_ID) {\n        return null;\n      }\n\n      const schemasDir = path.resolve(__dirname, \"../../shared/schemas\");\n      const files = fs.existsSync(schemasDir)\n        ? fs.readdirSync(schemasDir).filter((file) => file.endsWith(\".json\"))\n        : [];\n\n      const imports: string[] = [];\n      const entries: string[] = [];\n\n      files.forEach((file, index) => {\n        const varName = `__schema_${index}`;\n        const importPath = `shared/schemas/${file}`;\n        const key = file.replace(/\\.json$/, \"\").toUpperCase();\n        imports.push(`import ${varName} from \"${importPath}\";`);\n        entries.push(`  \"${key}\": ${varName}`);\n      });\n\n      return `\n${imports.join(\"\\n\")}\n\nexport const schemas = {\n${entries.join(\",\\n\")}\n};\n\nexport default schemas;\n`;\n    },\n  };\n}\n\nexport default defineConfig({\n  publicDir: path.resolve(__dirname, \"../public\"),\n  define: {\n    __APP_VERSION__: JSON.stringify(pkg.version),\n  },\n  plugins: [\n    tanstackRouter({\n      target: \"react\",\n      autoCodeSplitting: false,\n    }),\n    react({\n      babel: {\n        plugins: [\n          [\n            \"babel-plugin-react-compiler\",\n            {\n              target: \"18\",\n              sources: [\n                path.resolve(__dirname, \"src\"),\n                path.resolve(__dirname, \"../web-core/src\"),\n              ],\n              environment: {\n                enableResetCacheOnSourceFileChanges: true,\n              },\n            },\n          ],\n        ],\n      },\n    }),\n    executorSchemasPlugin(),\n  ],\n  resolve: {\n    alias: [\n      {\n        find: \"@remote\",\n        replacement: path.resolve(__dirname, \"src\"),\n      },\n      {\n        find: /^@\\//,\n        replacement: `${path.resolve(__dirname, \"../web-core/src\")}/`,\n      },\n      {\n        find: \"shared\",\n        replacement: path.resolve(__dirname, \"../../shared\"),\n      },\n    ],\n  },\n  server: {\n    port: 3002,\n    allowedHosts: [\n      \".trycloudflare.com\", // allow all cloudflared tunnels\n    ],\n    fs: {\n      allow: [path.resolve(__dirname, \".\"), path.resolve(__dirname, \"../..\")],\n    },\n  },\n});\n"
  },
  {
    "path": "packages/ui/.eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  env: {\n    browser: true,\n    es2020: true,\n  },\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    ecmaVersion: 'latest',\n    sourceType: 'module',\n    project: './tsconfig.json',\n    tsconfigRootDir: __dirname,\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n    'prettier',\n  ],\n  plugins: ['@typescript-eslint', 'react-hooks', 'unused-imports'],\n  ignorePatterns: ['dist'],\n  rules: {\n    'unused-imports/no-unused-imports': 'error',\n    'unused-imports/no-unused-vars': [\n      'error',\n      {\n        vars: 'all',\n        args: 'after-used',\n        ignoreRestSiblings: false,\n      },\n    ],\n    '@typescript-eslint/no-explicit-any': 'warn',\n  },\n};\n"
  },
  {
    "path": "packages/ui/README.md",
    "content": "# @vibe/ui\n\nShared UI package for reusable web app primitives.\n\n## Scope (initial)\n\n- Package scaffold and exports.\n- Shared utility helpers (`cn`).\n- Tailwind class generation remains configured in `packages/local-web/tailwind.new.config.js`.\n\n## Notes\n\n- Tailwind scanning for this package is enabled from `packages/local-web/tailwind.new.config.js` via:\n  `../ui/src/**/*.{ts,tsx}`.\n- The app-level stylesheet remains `packages/web-core/src/styles/new/index.css`.\n"
  },
  {
    "path": "packages/ui/package.json",
    "content": "{\n  \"name\": \"@vibe/ui\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"check\": \"tsc --noEmit -p tsconfig.json\",\n    \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\n    \"format\": \"prettier --config ../../packages/local-web/.prettierrc.json --write \\\"src/**/*.{ts,tsx,js,jsx,json,md}\\\"\",\n    \"format:check\": \"prettier --config ../../packages/local-web/.prettierrc.json --check \\\"src/**/*.{ts,tsx,js,jsx,json,md}\\\"\"\n  },\n  \"sideEffects\": false,\n  \"exports\": {\n    \"./components/*\": \"./src/components/*.tsx\",\n    \"./lib/*\": \"./src/lib/*.ts\"\n  },\n  \"dependencies\": {\n    \"@ebay/nice-modal-react\": \"^1.2.13\",\n    \"@hello-pangea/dnd\": \"^18.0.1\",\n    \"@lexical/code\": \"^0.36.2\",\n    \"@lexical/link\": \"^0.36.2\",\n    \"@lexical/list\": \"^0.36.2\",\n    \"@lexical/markdown\": \"^0.36.2\",\n    \"@lexical/react\": \"^0.36.2\",\n    \"@lexical/table\": \"^0.36.2\",\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"@pierre/diffs\": \"^1.0.8\",\n    \"@radix-ui/react-accordion\": \"^1.2.1\",\n    \"@radix-ui/react-dialog\": \"^1.1.4\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-select\": \"^2.2.5\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.3\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@tanstack/react-query\": \"^5.85.5\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"developer-icons\": \"^6.0.4\",\n    \"lexical\": \"^0.36.2\",\n    \"lucide-react\": \"^0.541.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-hotkeys-hook\": \"^5.1.0\",\n    \"react-i18next\": \"^15.7.4\",\n    \"react-virtuoso\": \"^4.14.0\",\n    \"tailwind-merge\": \"^3.3.1\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^18.2.43\",\n    \"@types/react-dom\": \"^18.2.17\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.21.0\",\n    \"@typescript-eslint/parser\": \"^6.21.0\",\n    \"eslint\": \"^8.55.0\",\n    \"eslint-config-prettier\": \"^10.1.5\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"eslint-plugin-unused-imports\": \"^4.1.4\",\n    \"prettier\": \"^3.6.1\",\n    \"typescript\": \"^5.9.2\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/src/components/Accordion.tsx",
    "content": "import * as React from 'react';\nimport * as AccordionPrimitive from '@radix-ui/react-accordion';\n\nimport { cn } from '../lib/cn';\n\nconst Accordion = AccordionPrimitive.Root;\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item ref={ref} className={cn(className)} {...props} />\n));\nAccordionItem.displayName = AccordionPrimitive.Item.displayName;\n\ninterface AccordionTriggerProps\n  extends React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> {\n  sticky?: boolean;\n}\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  AccordionTriggerProps\n>(({ className, children, sticky = false, ...props }, ref) => (\n  <AccordionPrimitive.Header\n    className={cn('flex', sticky && 'sticky top-0 z-10 bg-panel')}\n  >\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn('flex w-full items-center', className)}\n      {...props}\n    >\n      {children}\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n));\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className={cn(\n      'overflow-hidden',\n      'data-[state=open]:animate-accordion-down',\n      'data-[state=closed]:animate-accordion-up',\n      className\n    )}\n    {...props}\n  />\n));\nAccordionContent.displayName = AccordionPrimitive.Content.displayName;\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\n"
  },
  {
    "path": "packages/ui/src/components/Alert.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '../lib/cn';\n\nconst alertVariants = cva(\n  'relative w-full border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground text-sm',\n  {\n    variants: {\n      variant: {\n        default: 'bg-background text-foreground',\n        destructive:\n          'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',\n        success:\n          'border-success/50 bg-success/10 text-success-foreground [&>svg]:text-success',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  }\n);\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role=\"alert\"\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n));\nAlert.displayName = 'Alert';\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn('mb-1 font-medium leading-none tracking-tight', className)}\n    {...props}\n  />\n));\nAlertTitle.displayName = 'AlertTitle';\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('text-sm [&_p]:leading-relaxed', className)}\n    {...props}\n  />\n));\nAlertDescription.displayName = 'AlertDescription';\n\nexport { Alert, AlertTitle, AlertDescription };\n"
  },
  {
    "path": "packages/ui/src/components/AppBar.tsx",
    "content": "import {\n  DragDropContext,\n  Draggable,\n  Droppable,\n  type DropResult,\n} from '@hello-pangea/dnd';\nimport type { ReactNode } from 'react';\nimport {\n  LayoutIcon,\n  LinkIcon,\n  PlusIcon,\n  KanbanIcon,\n  SpinnerIcon,\n  StarIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { AppBarButton } from './AppBarButton';\nimport { AppBarSocialLink } from './AppBarSocialLink';\nimport {\n  Popover,\n  PopoverTrigger,\n  PopoverContent,\n  PopoverClose,\n} from './Popover';\nimport { Tooltip } from './Tooltip';\nimport { useTranslation } from 'react-i18next';\n\nfunction formatStarCount(count: number): string {\n  if (count < 1000) return String(count);\n  const k = count / 1000;\n  return k >= 10 ? `${Math.floor(k)}k` : `${k.toFixed(1)}k`;\n}\n\nfunction getProjectInitials(name: string): string {\n  const trimmed = name.trim();\n  if (!trimmed) return '??';\n\n  const words = trimmed.split(/\\s+/);\n  if (words.length >= 2) {\n    return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase();\n  }\n  return trimmed.slice(0, 2).toUpperCase();\n}\n\ninterface AppBarProps {\n  projects: AppBarProject[];\n  hosts?: AppBarHost[];\n  hostsLabel?: string;\n  projectsLabel?: string;\n  onPairHostClick?: () => void;\n  activeHostId?: string | null;\n  onCreateProject: () => void;\n  onWorkspacesClick: () => void;\n  onHostClick?: (hostId: string, status: AppBarHostStatus) => void;\n  showWorkspacesButton?: boolean;\n  onProjectClick: (projectId: string) => void;\n  onProjectsDragEnd: (result: DropResult) => void;\n  isSavingProjectOrder?: boolean;\n  isWorkspacesActive: boolean;\n  activeProjectId: string | null;\n  isSignedIn?: boolean;\n  isLoadingProjects?: boolean;\n  onSignIn?: () => void;\n  onMigrate?: () => void;\n  onHoverStart?: () => void;\n  onHoverEnd?: () => void;\n  notificationBell?: ReactNode;\n  userPopover?: ReactNode;\n  starCount?: number | null;\n  onlineCount?: number | null;\n  appVersion?: string | null;\n  updateVersion?: string | null;\n  onUpdateClick?: () => void;\n  githubIconPath: string;\n  discordIconPath: string;\n}\n\nexport interface AppBarProject {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport type AppBarHostStatus = 'online' | 'offline' | 'unpaired';\n\nexport interface AppBarHost {\n  id: string;\n  name: string;\n  status: AppBarHostStatus;\n}\n\nfunction getHostStatusLabel(status: AppBarHostStatus): string {\n  if (status === 'online') return 'Online';\n  if (status === 'offline') return 'Offline';\n  return 'Unpaired';\n}\n\nfunction getHostStatusIndicatorClass(status: AppBarHostStatus): string {\n  if (status === 'online') return 'bg-success';\n  if (status === 'offline') return 'bg-low';\n  return 'bg-white border-warning';\n}\n\nexport function AppBar({\n  projects,\n  hosts = [],\n  hostsLabel,\n  projectsLabel,\n  onPairHostClick,\n  activeHostId = null,\n  onCreateProject,\n  onWorkspacesClick,\n  onHostClick,\n  showWorkspacesButton = true,\n  onProjectClick,\n  onProjectsDragEnd,\n  isSavingProjectOrder,\n  isWorkspacesActive,\n  activeProjectId,\n  isSignedIn,\n  isLoadingProjects,\n  onSignIn,\n  onMigrate,\n  onHoverStart,\n  onHoverEnd,\n  notificationBell,\n  userPopover,\n  starCount,\n  onlineCount,\n  appVersion,\n  updateVersion,\n  onUpdateClick,\n  githubIconPath,\n  discordIconPath,\n}: AppBarProps) {\n  const { t } = useTranslation('common');\n  const showHostsSection =\n    showWorkspacesButton || hosts.length > 0 || !!onPairHostClick;\n\n  return (\n    <div\n      onMouseEnter={onHoverStart}\n      onMouseLeave={onHoverEnd}\n      className={cn(\n        'flex flex-col items-center h-full min-h-0 overflow-y-auto p-base gap-base',\n        'bg-secondary border-r border-border'\n      )}\n    >\n      {showHostsSection && (\n        <div className=\"flex flex-col items-center gap-1\">\n          {hostsLabel && (\n            <p className=\"w-10 text-center text-[9px] font-medium uppercase leading-none tracking-wide text-low\">\n              {hostsLabel}\n            </p>\n          )}\n          {showWorkspacesButton && (\n            <AppBarButton\n              icon={LayoutIcon}\n              label=\"Workspaces\"\n              isActive={isWorkspacesActive}\n              onClick={onWorkspacesClick}\n            />\n          )}\n          {hosts.map((host) => {\n            const isOffline = host.status === 'offline';\n            const isActiveHost = host.id === activeHostId;\n            return (\n              <Tooltip\n                key={host.id}\n                content={`${host.name} · ${getHostStatusLabel(host.status)}`}\n                side=\"right\"\n              >\n                <div className=\"relative\">\n                  <span\n                    className={cn(\n                      'absolute -top-1 -right-1 z-10',\n                      'w-3.5 h-3.5 rounded-full border border-secondary',\n                      getHostStatusIndicatorClass(host.status)\n                    )}\n                    aria-hidden=\"true\"\n                  />\n                  <button\n                    type=\"button\"\n                    disabled={isOffline}\n                    onClick={() => {\n                      if (isOffline) {\n                        return;\n                      }\n                      onHostClick?.(host.id, host.status);\n                    }}\n                    className={cn(\n                      'relative flex items-center justify-center w-10 h-10 rounded-lg',\n                      'text-sm font-medium transition-colors',\n                      'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n                      isOffline\n                        ? 'bg-primary text-low opacity-50 cursor-not-allowed'\n                        : 'bg-primary text-normal cursor-pointer',\n                      isActiveHost && 'ring-2 ring-brand',\n                      host.status === 'online' && 'hover:bg-brand/10',\n                      host.status === 'unpaired' &&\n                        'text-warning hover:bg-warning/10'\n                    )}\n                    aria-label={`${host.name} (${getHostStatusLabel(host.status)})`}\n                  >\n                    {getProjectInitials(host.name)}\n                  </button>\n                </div>\n              </Tooltip>\n            );\n          })}\n          {onPairHostClick && (\n            <Tooltip content=\"Pair host\" side=\"right\">\n              <button\n                type=\"button\"\n                onClick={onPairHostClick}\n                className={cn(\n                  'flex items-center justify-center w-10 h-10 rounded-lg',\n                  'text-sm font-medium transition-colors cursor-pointer',\n                  'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n                  'bg-primary text-muted hover:text-normal hover:bg-tertiary'\n                )}\n                aria-label=\"Pair host\"\n              >\n                <LinkIcon size={20} />\n              </button>\n            </Tooltip>\n          )}\n        </div>\n      )}\n\n      {(hosts.length > 0 || onPairHostClick) && (\n        <div className=\"w-8 h-px bg-border\" aria-hidden=\"true\" />\n      )}\n\n      {/* Project management popover for unsigned users */}\n      {!isSignedIn && (\n        <Popover>\n          <Tooltip content={t('appBar.kanban.tooltip')} side=\"right\">\n            <PopoverTrigger asChild>\n              <button\n                type=\"button\"\n                className={cn(\n                  'flex items-center justify-center w-10 h-10 rounded-lg',\n                  'transition-colors cursor-pointer',\n                  'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n                  'bg-primary text-normal hover:bg-brand/10'\n                )}\n                aria-label={t('appBar.kanban.tooltip')}\n              >\n                <KanbanIcon className=\"size-icon-base\" weight=\"bold\" />\n              </button>\n            </PopoverTrigger>\n          </Tooltip>\n          <PopoverContent side=\"right\" sideOffset={8}>\n            <p className=\"text-sm font-medium text-high\">\n              {t('appBar.kanban.title')}\n            </p>\n            <p className=\"text-xs text-low mt-1\">\n              {t('appBar.kanban.description')}\n            </p>\n            <div className=\"mt-base flex items-center gap-half\">\n              <PopoverClose asChild>\n                <button\n                  type=\"button\"\n                  onClick={onSignIn}\n                  className={cn(\n                    'px-base py-1 rounded-sm text-xs',\n                    'bg-brand text-on-brand hover:bg-brand-hover cursor-pointer'\n                  )}\n                >\n                  {t('signIn')}\n                </button>\n              </PopoverClose>\n              <PopoverClose asChild>\n                <button\n                  type=\"button\"\n                  onClick={onMigrate}\n                  className={cn(\n                    'px-base py-1 rounded-sm text-xs',\n                    'bg-secondary text-normal hover:bg-panel border border-border cursor-pointer'\n                  )}\n                >\n                  {t('appBar.kanban.migrateOldProjects')}\n                </button>\n              </PopoverClose>\n            </div>\n          </PopoverContent>\n        </Popover>\n      )}\n\n      {/* Loading spinner for projects */}\n      {isLoadingProjects && (\n        <div className=\"flex items-center justify-center w-10 h-10\">\n          <SpinnerIcon className=\"size-5 animate-spin text-muted\" />\n        </div>\n      )}\n\n      {/* Middle section: Project buttons */}\n      {projectsLabel && (\n        <p className=\"w-10 text-center text-[9px] font-medium uppercase leading-none tracking-wide text-low\">\n          {projectsLabel}\n        </p>\n      )}\n      <DragDropContext onDragEnd={onProjectsDragEnd}>\n        <Droppable\n          droppableId=\"app-bar-projects\"\n          direction=\"vertical\"\n          isDropDisabled={isSavingProjectOrder}\n        >\n          {(dropProvided) => (\n            <div\n              ref={dropProvided.innerRef}\n              {...dropProvided.droppableProps}\n              className=\"flex flex-col items-center -mb-base\"\n            >\n              {projects.map((project, index) => (\n                <Draggable\n                  key={project.id}\n                  draggableId={project.id}\n                  index={index}\n                  disableInteractiveElementBlocking\n                  isDragDisabled={isSavingProjectOrder}\n                >\n                  {(dragProvided, snapshot) => (\n                    <div\n                      ref={dragProvided.innerRef}\n                      {...dragProvided.draggableProps}\n                      {...dragProvided.dragHandleProps}\n                      className=\"mb-base\"\n                      style={dragProvided.draggableProps.style}\n                    >\n                      <Tooltip content={project.name} side=\"right\">\n                        <button\n                          type=\"button\"\n                          onClick={() => onProjectClick(project.id)}\n                          className={cn(\n                            'flex items-center justify-center w-10 h-10 rounded-lg',\n                            'text-sm font-medium transition-colors cursor-grab',\n                            'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n                            snapshot.isDragging && 'shadow-lg',\n                            activeProjectId === project.id\n                              ? ''\n                              : 'bg-primary text-normal hover:opacity-80'\n                          )}\n                          style={\n                            activeProjectId === project.id\n                              ? {\n                                  color: `hsl(${project.color})`,\n                                  backgroundColor: `hsl(${project.color} / 0.2)`,\n                                }\n                              : undefined\n                          }\n                          aria-label={project.name}\n                        >\n                          {getProjectInitials(project.name)}\n                        </button>\n                      </Tooltip>\n                    </div>\n                  )}\n                </Draggable>\n              ))}\n              {dropProvided.placeholder}\n            </div>\n          )}\n        </Droppable>\n      </DragDropContext>\n\n      {/* Create project button */}\n      {isSignedIn && (\n        <Tooltip content=\"Create project\" side=\"right\">\n          <button\n            type=\"button\"\n            onClick={onCreateProject}\n            className={cn(\n              'flex items-center justify-center w-10 h-10 rounded-lg',\n              'text-sm font-medium transition-colors cursor-pointer',\n              'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n              'bg-primary text-muted hover:text-normal hover:bg-tertiary'\n            )}\n            aria-label=\"Create project\"\n          >\n            <PlusIcon size={20} />\n          </button>\n        </Tooltip>\n      )}\n\n      {/* Bottom section: Notifications + User popover + GitHub + Discord */}\n      <div className=\"mt-auto pt-base flex flex-col items-center gap-4\">\n        {notificationBell}\n        {userPopover}\n        <AppBarSocialLink\n          href=\"https://github.com/BloopAI/vibe-kanban\"\n          label=\"Star on GitHub\"\n          iconPath={githubIconPath}\n          badge={\n            starCount != null && (\n              <>\n                <StarIcon size={10} weight=\"fill\" />\n                {formatStarCount(starCount)}\n              </>\n            )\n          }\n        />\n        <AppBarSocialLink\n          href=\"https://discord.gg/AC4nwVtJM3\"\n          label=\"Join our Discord\"\n          iconPath={discordIconPath}\n          badge={\n            onlineCount != null && (onlineCount > 999 ? '999+' : onlineCount)\n          }\n        />\n        {updateVersion ? (\n          <Tooltip content={`Update to v${updateVersion}`} side=\"right\">\n            <button\n              type=\"button\"\n              onClick={onUpdateClick}\n              className={cn(\n                'flex items-center justify-center py-1 rounded-md w-10',\n                'text-[9px] font-ibm-plex-mono font-medium leading-none',\n                'bg-brand text-on-brand hover:bg-brand-hover',\n                'transition-colors cursor-pointer'\n              )}\n            >\n              Update\n            </button>\n          </Tooltip>\n        ) : (\n          appVersion && (\n            <p\n              className=\"text-[9px] font-ibm-plex-mono text-low leading-none truncate max-w-10 text-center\"\n              title={`v${appVersion}`}\n            >\n              v{appVersion}\n            </p>\n          )\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/AppBarButton.tsx",
    "content": "import * as React from 'react';\nimport type { Icon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\n\ninterface AppBarButtonProps {\n  icon?: Icon;\n  label: string;\n  isActive?: boolean;\n  onClick?: () => void;\n  className?: string;\n  children?: React.ReactNode;\n}\n\nexport function AppBarButton({\n  icon: IconComponent,\n  label,\n  isActive = false,\n  onClick,\n  className,\n  children,\n}: AppBarButtonProps) {\n  const button = (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        'flex items-center justify-center w-10 h-10 rounded-lg',\n        'transition-colors cursor-pointer',\n        'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n        isActive\n          ? 'bg-brand/20 text-brand'\n          : 'bg-primary text-normal hover:bg-brand/10',\n        className\n      )}\n      aria-label={label}\n    >\n      {IconComponent && (\n        <IconComponent className=\"size-icon-base\" weight=\"bold\" />\n      )}\n      {children}\n    </button>\n  );\n\n  return (\n    <Tooltip content={label} side=\"right\">\n      {button}\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/AppBarSocialLink.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\n\ninterface AppBarSocialLinkProps {\n  href: string;\n  label: string;\n  iconPath: string;\n  badge?: ReactNode;\n}\n\nexport function AppBarSocialLink({\n  href,\n  label,\n  iconPath,\n  badge,\n}: AppBarSocialLinkProps) {\n  return (\n    <Tooltip content={label} side=\"right\">\n      <a\n        href={href}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className={cn(\n          'relative flex items-center justify-center w-10 h-10 rounded-lg',\n          'text-sm font-medium transition-colors cursor-pointer',\n          'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n          'bg-panel text-normal hover:opacity-80'\n        )}\n        aria-label={label}\n      >\n        <svg\n          className=\"w-5 h-5\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n          aria-hidden=\"true\"\n        >\n          <path d={iconPath} />\n        </svg>\n        {badge != null && badge !== false && (\n          <span className=\"absolute -top-2 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center gap-0.5 rounded-full bg-brand-secondary text-[10px] font-medium text-white\">\n            {badge}\n          </span>\n        )}\n      </a>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/AppBarUserPopover.tsx",
    "content": "import {\n  BuildingsIcon,\n  CheckIcon,\n  GearIcon,\n  PlusIcon,\n  SignInIcon,\n  SignOutIcon,\n  UserIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n} from './Dropdown';\n\nexport interface AppBarUserOrganization {\n  id: string;\n  name: string;\n}\n\ninterface AppBarUserPopoverProps {\n  isSignedIn: boolean;\n  avatarUrl: string | null;\n  avatarError: boolean;\n  organizations: AppBarUserOrganization[];\n  selectedOrgId: string;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onOrgSelect: (orgId: string) => void;\n  onCreateOrg?: () => void;\n  onOrgSettings?: (orgId: string) => void;\n  onSettings?: () => void;\n  onSignIn: () => void;\n  onLogout: () => void;\n  onAvatarError: () => void;\n}\n\nexport function AppBarUserPopover({\n  isSignedIn,\n  avatarUrl,\n  avatarError,\n  organizations,\n  selectedOrgId,\n  open,\n  onOpenChange,\n  onOrgSelect,\n  onCreateOrg,\n  onOrgSettings,\n  onSettings,\n  onSignIn,\n  onLogout,\n  onAvatarError,\n}: AppBarUserPopoverProps) {\n  const { t } = useTranslation();\n  const settingsLabel = t('settings:settings.layout.nav.title', {\n    defaultValue: 'Settings',\n  });\n\n  if (!isSignedIn) {\n    return (\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <button\n            type=\"button\"\n            className={cn(\n              'flex items-center justify-center w-7 h-7 sm:w-10 sm:h-10 rounded-md sm:rounded-lg',\n              'bg-panel text-normal font-medium text-sm',\n              'transition-colors cursor-pointer',\n              'hover:bg-panel/70',\n              'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand'\n            )}\n            aria-label=\"Sign in\"\n          >\n            <UserIcon className=\"size-icon-sm\" weight=\"bold\" />\n          </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent side=\"right\" align=\"end\" className=\"min-w-[200px]\">\n          <DropdownMenuItem icon={SignInIcon} onClick={onSignIn}>\n            {t('signIn')}\n          </DropdownMenuItem>\n          {onSettings && (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem icon={GearIcon} onClick={onSettings}>\n                {settingsLabel}\n              </DropdownMenuItem>\n            </>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    );\n  }\n\n  return (\n    <DropdownMenu open={open} onOpenChange={onOpenChange}>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          className={cn(\n            'flex items-center justify-center w-7 h-7 sm:w-10 sm:h-10 rounded-md sm:rounded-lg',\n            'transition-colors cursor-pointer overflow-hidden',\n            'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n            (!avatarUrl || avatarError) &&\n              'bg-panel text-normal font-medium text-sm',\n            (!avatarUrl || avatarError) && 'hover:bg-panel/70'\n          )}\n          aria-label=\"Account\"\n        >\n          {avatarUrl && !avatarError ? (\n            <img\n              src={avatarUrl}\n              alt=\"User avatar\"\n              className=\"w-full h-full object-cover\"\n              onError={onAvatarError}\n            />\n          ) : (\n            <UserIcon className=\"size-icon-sm\" weight=\"bold\" />\n          )}\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent side=\"right\" align=\"end\" className=\"min-w-[200px]\">\n        <DropdownMenuLabel>{t('orgSwitcher.organizations')}</DropdownMenuLabel>\n        <DropdownMenuSeparator />\n        {organizations.map((org) => (\n          <DropdownMenuItem\n            key={org.id}\n            icon={org.id === selectedOrgId ? CheckIcon : BuildingsIcon}\n            onClick={() => onOrgSelect(org.id)}\n            className={cn(org.id === selectedOrgId && 'bg-brand/10', 'group')}\n          >\n            <span className=\"flex items-center gap-2 w-full\">\n              <span className=\"flex-1 truncate\">{org.name}</span>\n              {onOrgSettings && (\n                <button\n                  type=\"button\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    onOpenChange(false);\n                    onOrgSettings(org.id);\n                  }}\n                  className=\"sm:opacity-0 sm:group-hover:opacity-100 p-1 rounded hover:bg-secondary transition-opacity shrink-0\"\n                  aria-label={t('orgSwitcher.orgSettings')}\n                >\n                  <GearIcon className=\"size-icon-xs\" weight=\"bold\" />\n                </button>\n              )}\n            </span>\n          </DropdownMenuItem>\n        ))}\n        <DropdownMenuSeparator />\n        <DropdownMenuItem icon={PlusIcon} onClick={onCreateOrg}>\n          {t('orgSwitcher.createOrganization')}\n        </DropdownMenuItem>\n        {onSettings && (\n          <>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem icon={GearIcon} onClick={onSettings}>\n              {settingsLabel}\n            </DropdownMenuItem>\n          </>\n        )}\n        <DropdownMenuSeparator />\n        <DropdownMenuItem icon={SignOutIcon} onClick={onLogout}>\n          {t('signOut')}\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/AskUserQuestionBanner.tsx",
    "content": "import {\n  forwardRef,\n  useCallback,\n  useImperativeHandle,\n  useMemo,\n  useState,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { AskUserQuestionItem, QuestionAnswer } from 'shared/types';\nimport { QuestionIcon } from '@phosphor-icons/react';\n\nexport interface AskUserQuestionBannerHandle {\n  /** Submit a custom free-text answer for the current question (triggered by Enter in the editor) */\n  submitCustomAnswer: (text: string) => void;\n}\n\ninterface AskUserQuestionBannerProps {\n  questions: AskUserQuestionItem[];\n  onSubmitAnswers: (answers: QuestionAnswer[]) => void;\n  isSubmitting: boolean;\n  isTimedOut: boolean;\n  error: string | null;\n}\n\nexport const AskUserQuestionBanner = forwardRef<\n  AskUserQuestionBannerHandle,\n  AskUserQuestionBannerProps\n>(function AskUserQuestionBanner(\n  { questions, onSubmitAnswers, isSubmitting, isTimedOut, error },\n  ref\n) {\n  const { t } = useTranslation('common');\n  // Track completed answers: question text -> selected labels array\n  const [answers, setAnswers] = useState<Record<string, string[]>>({});\n\n  // Convert internal state to ordered QuestionAnswer[] for submission\n  const toQuestionAnswers = useCallback(\n    (rec: Record<string, string[]>): QuestionAnswer[] =>\n      questions\n        .filter((q) => rec[q.question] !== undefined)\n        .map((q) => ({ question: q.question, answer: rec[q.question] })),\n    [questions]\n  );\n  // Track which question index we're currently showing\n  const currentIndex = useMemo(() => {\n    for (let i = 0; i < questions.length; i++) {\n      if (answers[questions[i].question] === undefined) return i;\n    }\n    return questions.length; // all answered\n  }, [questions, answers]);\n\n  // For multi-select: track toggled labels for the current question\n  const [multiSelectLabels, setMultiSelectLabels] = useState<Set<string>>(\n    new Set()\n  );\n\n  const currentQuestion =\n    currentIndex < questions.length ? questions[currentIndex] : null;\n  const isAllAnswered = currentIndex >= questions.length;\n  const disabled = isSubmitting || isTimedOut;\n\n  // Select an option for single-select questions → immediately advance\n  const handleSelectOption = useCallback(\n    (label: string) => {\n      if (disabled || !currentQuestion) return;\n\n      if (currentQuestion.multiSelect) {\n        // Toggle label in multi-select set\n        setMultiSelectLabels((prev) => {\n          const next = new Set(prev);\n          if (next.has(label)) {\n            next.delete(label);\n          } else {\n            next.add(label);\n          }\n          return next;\n        });\n      } else {\n        // Single select → record answer and advance\n        const newAnswers = {\n          ...answers,\n          [currentQuestion.question]: [label],\n        };\n        setAnswers(newAnswers);\n\n        // If this was the last question, submit\n        if (currentIndex === questions.length - 1) {\n          onSubmitAnswers(toQuestionAnswers(newAnswers));\n        }\n      }\n    },\n    [\n      disabled,\n      currentQuestion,\n      answers,\n      currentIndex,\n      questions.length,\n      onSubmitAnswers,\n      toQuestionAnswers,\n    ]\n  );\n\n  // Confirm multi-select or \"Other\" text answer\n  const handleConfirmMultiSelect = useCallback(() => {\n    if (disabled || !currentQuestion) return;\n\n    const labels = Array.from(multiSelectLabels);\n    if (labels.length === 0) return;\n\n    const newAnswers = {\n      ...answers,\n      [currentQuestion.question]: labels,\n    };\n    setAnswers(newAnswers);\n    setMultiSelectLabels(new Set());\n\n    if (currentIndex === questions.length - 1) {\n      onSubmitAnswers(toQuestionAnswers(newAnswers));\n    }\n  }, [\n    disabled,\n    currentQuestion,\n    multiSelectLabels,\n    answers,\n    currentIndex,\n    questions.length,\n    onSubmitAnswers,\n    toQuestionAnswers,\n  ]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      submitCustomAnswer: (text: string) => {\n        if (disabled || !currentQuestion || !text.trim()) return;\n        const newAnswers = {\n          ...answers,\n          [currentQuestion.question]: [text.trim()],\n        };\n        setAnswers(newAnswers);\n        if (currentIndex === questions.length - 1) {\n          onSubmitAnswers(toQuestionAnswers(newAnswers));\n        }\n      },\n    }),\n    [\n      disabled,\n      currentQuestion,\n      answers,\n      currentIndex,\n      questions.length,\n      onSubmitAnswers,\n      toQuestionAnswers,\n    ]\n  );\n\n  if (isAllAnswered && !isSubmitting) return null;\n\n  return (\n    <div className=\"border-b\">\n      {/* Header */}\n      <div className=\"flex items-center gap-base px-double py-base\">\n        <QuestionIcon className=\"h-4 w-4 text-brand flex-shrink-0\" />\n        <span className=\"text-sm text-normal flex-1\">\n          {t('askQuestion.title')}\n          {questions.length > 1 && (\n            <span className=\"text-low ml-1\">\n              ({Math.min(currentIndex + 1, questions.length)}/{questions.length}\n              )\n            </span>\n          )}\n        </span>\n      </div>\n\n      {/* Current question */}\n      {currentQuestion && (\n        <div className=\"px-double pb-base\">\n          <div className=\"flex items-center gap-base mb-base\">\n            <span className=\"text-xs font-medium text-low bg-secondary px-1 py-0.5 rounded\">\n              {currentQuestion.header}\n            </span>\n            {currentQuestion.multiSelect && (\n              <span className=\"text-xs text-low\">\n                {t('askQuestion.selectMultiple')}\n              </span>\n            )}\n          </div>\n          <p className=\"text-sm font-medium text-normal mb-base\">\n            {currentQuestion.question}\n          </p>\n          <div className=\"flex flex-wrap gap-base\">\n            {currentQuestion.options.map((opt) => {\n              const isSelected =\n                currentQuestion.multiSelect && multiSelectLabels.has(opt.label);\n              return (\n                <button\n                  key={opt.label}\n                  type=\"button\"\n                  disabled={disabled}\n                  onClick={() => handleSelectOption(opt.label)}\n                  className={`\n                    group relative rounded-md border px-2.5 py-1.5 text-xs transition-all\n                    ${\n                      isSelected\n                        ? 'border-brand bg-brand/10 text-normal'\n                        : 'border-border text-low hover:border-brand/40 hover:text-normal hover:bg-accent'\n                    }\n                    ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}\n                  `}\n                  title={opt.description}\n                >\n                  <span className=\"font-medium\">{opt.label}</span>\n                </button>\n              );\n            })}\n          </div>\n          {/* Multi-select confirm button */}\n          {currentQuestion.multiSelect && multiSelectLabels.size > 0 && (\n            <button\n              type=\"button\"\n              disabled={disabled}\n              onClick={handleConfirmMultiSelect}\n              className=\"mt-2 rounded-md bg-brand px-3 py-1 text-xs font-medium text-white hover:bg-brand/90 transition-colors disabled:opacity-50\"\n            >\n              {t('askQuestion.confirmSelection')}\n            </button>\n          )}\n        </div>\n      )}\n\n      {error && (\n        <div className=\"px-double pb-base text-sm text-error\">{error}</div>\n      )}\n\n      {isSubmitting && (\n        <div className=\"px-double pb-base text-sm text-low\">\n          {t('askQuestion.submitting')}\n        </div>\n      )}\n    </div>\n  );\n});\n"
  },
  {
    "path": "packages/ui/src/components/AutoExpandingTextarea.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '../lib/cn';\n\ninterface AutoExpandingTextareaProps extends React.ComponentProps<'textarea'> {\n  maxRows?: number;\n  disableInternalScroll?: boolean;\n}\n\nconst AutoExpandingTextarea = React.forwardRef<\n  HTMLTextAreaElement,\n  AutoExpandingTextareaProps\n>(\n  (\n    { className, maxRows = 10, disableInternalScroll = false, ...props },\n    ref\n  ) => {\n    const internalRef = React.useRef<HTMLTextAreaElement>(null);\n\n    // Get the actual ref to use\n    const textareaRef = ref || internalRef;\n\n    const adjustHeight = React.useCallback(() => {\n      const textarea = (textareaRef as React.RefObject<HTMLTextAreaElement>)\n        .current;\n      if (!textarea) return;\n\n      // Reset height to auto to get the natural height\n      textarea.style.height = 'auto';\n\n      if (disableInternalScroll) {\n        // When parent handles scroll, expand to full content height\n        textarea.style.height = `${textarea.scrollHeight}px`;\n      } else {\n        // Calculate line height\n        const style = window.getComputedStyle(textarea);\n        const lineHeight = parseInt(style.lineHeight) || 20;\n        const paddingTop = parseInt(style.paddingTop) || 0;\n        const paddingBottom = parseInt(style.paddingBottom) || 0;\n\n        // Calculate max height based on maxRows\n        const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;\n\n        // Set the height to scrollHeight, but cap at maxHeight\n        const newHeight = Math.min(textarea.scrollHeight, maxHeight);\n        textarea.style.height = `${newHeight}px`;\n      }\n    }, [maxRows, disableInternalScroll, textareaRef]);\n\n    // Adjust height on mount and when content changes\n    React.useEffect(() => {\n      adjustHeight();\n    }, [adjustHeight, props.value]);\n\n    // Adjust height on input\n    const { onInput } = props;\n    const handleInput = React.useCallback(\n      (e: React.FormEvent<HTMLTextAreaElement>) => {\n        adjustHeight();\n        if (onInput) {\n          onInput(e);\n        }\n      },\n      [adjustHeight, onInput]\n    );\n\n    return (\n      <textarea\n        className={cn(\n          'bg-muted p-0 min-h-[80px] w-full text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-x-hidden whitespace-pre-wrap break-words',\n          disableInternalScroll ? 'overflow-hidden' : 'overflow-y-auto',\n          className\n        )}\n        ref={textareaRef}\n        onInput={handleInput}\n        {...props}\n      />\n    );\n  }\n);\n\nAutoExpandingTextarea.displayName = 'AutoExpandingTextarea';\n\nexport { AutoExpandingTextarea };\n"
  },
  {
    "path": "packages/ui/src/components/AutoResizeTextarea.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '../lib/cn';\n\nfunction normalizeSingleLineValue(value: string): string {\n  return value.replace(/\\r\\n|\\r|\\n/g, ' ');\n}\n\nexport interface AutoResizeTextareaProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<'textarea'>,\n    'onChange' | 'value'\n  > {\n  value: string;\n  onChange: (value: string) => void;\n  preventNewlines?: boolean;\n}\n\nexport const AutoResizeTextarea = React.forwardRef<\n  HTMLTextAreaElement,\n  AutoResizeTextareaProps\n>(function AutoResizeTextarea(\n  {\n    value,\n    onChange,\n    preventNewlines = true,\n    rows = 1,\n    className,\n    onInput,\n    onKeyDown,\n    onPaste,\n    ...props\n  },\n  ref\n) {\n  const internalRef = React.useRef<HTMLTextAreaElement | null>(null);\n\n  const setTextareaRef = React.useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      internalRef.current = node;\n\n      if (!ref) return;\n      if (typeof ref === 'function') {\n        ref(node);\n        return;\n      }\n\n      ref.current = node;\n    },\n    [ref]\n  );\n\n  const resizeToContent = React.useCallback(() => {\n    const textarea = internalRef.current;\n    if (!textarea) return;\n    if (textarea.clientWidth <= 1) return;\n\n    textarea.style.height = 'auto';\n    textarea.style.height = `${textarea.scrollHeight}px`;\n  }, []);\n\n  const normalizedValue = preventNewlines\n    ? normalizeSingleLineValue(value)\n    : value;\n\n  React.useLayoutEffect(() => {\n    resizeToContent();\n  }, [normalizedValue, resizeToContent]);\n\n  React.useLayoutEffect(() => {\n    const textarea = internalRef.current;\n    if (!textarea) return;\n\n    if (typeof ResizeObserver === 'undefined') return;\n\n    const observer = new ResizeObserver(() => {\n      resizeToContent();\n    });\n\n    observer.observe(textarea);\n\n    return () => observer.disconnect();\n  }, [resizeToContent]);\n\n  const handleInput = React.useCallback(\n    (event: React.FormEvent<HTMLTextAreaElement>) => {\n      resizeToContent();\n      onInput?.(event);\n    },\n    [resizeToContent, onInput]\n  );\n\n  const handleChange = React.useCallback(\n    (event: React.ChangeEvent<HTMLTextAreaElement>) => {\n      const nextValue = preventNewlines\n        ? normalizeSingleLineValue(event.target.value)\n        : event.target.value;\n      onChange(nextValue);\n    },\n    [onChange, preventNewlines]\n  );\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n      if (preventNewlines && event.key === 'Enter') {\n        event.preventDefault();\n      }\n      onKeyDown?.(event);\n    },\n    [preventNewlines, onKeyDown]\n  );\n\n  const handlePaste = React.useCallback(\n    (event: React.ClipboardEvent<HTMLTextAreaElement>) => {\n      if (!preventNewlines) {\n        onPaste?.(event);\n        return;\n      }\n\n      const pastedText = event.clipboardData.getData('text');\n      if (!/[\\r\\n]/.test(pastedText)) {\n        onPaste?.(event);\n        return;\n      }\n\n      event.preventDefault();\n\n      const textarea = event.currentTarget;\n      const start = textarea.selectionStart ?? textarea.value.length;\n      const end = textarea.selectionEnd ?? textarea.value.length;\n      const sanitizedText = normalizeSingleLineValue(pastedText);\n      const nextValue =\n        textarea.value.slice(0, start) +\n        sanitizedText +\n        textarea.value.slice(end);\n\n      onChange(nextValue);\n\n      requestAnimationFrame(() => {\n        const node = internalRef.current;\n        if (!node) return;\n\n        const nextCaret = start + sanitizedText.length;\n        node.setSelectionRange(nextCaret, nextCaret);\n      });\n\n      onPaste?.(event);\n    },\n    [onChange, onPaste, preventNewlines]\n  );\n\n  return (\n    <textarea\n      {...props}\n      ref={setTextareaRef}\n      rows={rows}\n      value={normalizedValue}\n      onInput={handleInput}\n      onChange={handleChange}\n      onKeyDown={handleKeyDown}\n      onPaste={handlePaste}\n      className={cn(\n        'w-full resize-none overflow-hidden bg-transparent focus:outline-none',\n        className\n      )}\n    />\n  );\n});\n"
  },
  {
    "path": "packages/ui/src/components/Badge.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '../lib/cn';\n\nconst badgeVariants = cva(\n  'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',\n  {\n    variants: {\n      variant: {\n        default:\n          'border-foreground/50 bg-primary text-primary-foreground hover:bg-primary/80',\n        secondary:\n          'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        destructive:\n          'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',\n        outline: 'text-foreground',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  }\n);\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "packages/ui/src/components/BulkActionBar.tsx",
    "content": "'use client';\n\nimport { useTranslation } from 'react-i18next';\nimport {\n  ArrowsLeftRightIcon,\n  ArrowFatLineUpIcon,\n  UsersIcon,\n  TrashIcon,\n  XIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\n\ninterface BulkActionButtonProps {\n  icon: React.ElementType;\n  label: string;\n  onClick: () => void;\n  variant?: 'default' | 'destructive';\n}\n\nfunction BulkActionButton({\n  icon: IconComponent,\n  label,\n  onClick,\n  variant = 'default',\n}: BulkActionButtonProps) {\n  return (\n    <Tooltip content={label}>\n      <button\n        type=\"button\"\n        onClick={onClick}\n        className={cn(\n          'flex items-center gap-half px-base py-half rounded-sm text-sm',\n          'transition-colors',\n          variant === 'destructive'\n            ? 'text-destructive hover:bg-destructive/10'\n            : 'text-high hover:bg-secondary'\n        )}\n      >\n        <IconComponent className=\"size-icon-xs\" weight=\"bold\" />\n        <span>{label}</span>\n      </button>\n    </Tooltip>\n  );\n}\n\nexport interface BulkActionBarProps {\n  selectedCount: number;\n  onChangeStatus: () => void;\n  onChangePriority: () => void;\n  onChangeAssignees: () => void;\n  onDelete: () => void;\n  onClearSelection: () => void;\n}\n\nexport function BulkActionBar({\n  selectedCount,\n  onChangeStatus,\n  onChangePriority,\n  onChangeAssignees,\n  onDelete,\n  onClearSelection,\n}: BulkActionBarProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <div className=\"fixed bottom-6 left-1/2 -translate-x-1/2 z-50\">\n      <div className=\"flex items-center gap-half bg-primary border border-secondary rounded-lg shadow-[0_4px_24px_rgba(0,0,0,0.3)] px-base py-half\">\n        <span className=\"text-sm font-medium text-high whitespace-nowrap px-half\">\n          {t('kanban.bulkSelectedCount', {\n            count: selectedCount,\n            defaultValue: '{{count}} selected',\n          })}\n        </span>\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        <BulkActionButton\n          icon={ArrowsLeftRightIcon}\n          label={t('kanban.bulkChangeStatus', { defaultValue: 'Status' })}\n          onClick={onChangeStatus}\n        />\n        <BulkActionButton\n          icon={ArrowFatLineUpIcon}\n          label={t('kanban.bulkChangePriority', { defaultValue: 'Priority' })}\n          onClick={onChangePriority}\n        />\n        <BulkActionButton\n          icon={UsersIcon}\n          label={t('kanban.bulkChangeAssignees', { defaultValue: 'Assignee' })}\n          onClick={onChangeAssignees}\n        />\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        <BulkActionButton\n          icon={TrashIcon}\n          label={t('kanban.bulkDelete', { defaultValue: 'Delete' })}\n          onClick={onDelete}\n          variant=\"destructive\"\n        />\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        <Tooltip\n          content={t('kanban.bulkClearSelection', {\n            defaultValue: 'Clear selection',\n          })}\n        >\n          <button\n            type=\"button\"\n            onClick={onClearSelection}\n            className=\"flex items-center justify-center p-half rounded-sm text-low hover:text-normal hover:bg-secondary transition-colors\"\n            aria-label={t('kanban.bulkClearSelection', {\n              defaultValue: 'Clear selection',\n            })}\n          >\n            <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n          </button>\n        </Tooltip>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Button.tsx",
    "content": "import * as React from 'react';\nimport { twMerge } from 'tailwind-merge';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '../lib/cn';\n\nconst buttonVariants = cva(\n  'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed',\n  {\n    variants: {\n      variant: {\n        default:\n          'text-primary-foreground hover:bg-primary/90 border border-foreground',\n        destructive:\n          'border border-destructive text-destructive hover:bg-destructive/10',\n        outline:\n          'border border-input hover:bg-accent hover:text-accent-foreground',\n        secondary: 'text-secondary-foreground hover:bg-secondary/80 border',\n        ghost: 'hover:text-primary-foreground/50',\n        link: 'hover:underline',\n        icon: 'bg-transparent rounded text-muted-foreground hover:text-foreground',\n      },\n      size: {\n        default: 'h-10 px-4 py-2',\n        xs: 'h-8 px-2 text-xs',\n        sm: 'h-9 px-3',\n        lg: 'h-11 px-8',\n        icon: 'h-10 w-10',\n      },\n    },\n    compoundVariants: [{ variant: 'icon', class: 'p-0 h-4' }],\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n);\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : 'button';\n    return (\n      <Comp\n        className={twMerge(cn(buttonVariants({ variant, size, className })))}\n        ref={ref}\n        {...props}\n      />\n    );\n  }\n);\nButton.displayName = 'Button';\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "packages/ui/src/components/Card.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '../lib/cn';\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('bg-card text-card-foreground', className)}\n    {...props}\n  />\n));\nCard.displayName = 'Card';\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('flex flex-col space-y-1.5 p-6', className)}\n    {...props}\n  />\n));\nCardHeader.displayName = 'CardHeader';\n\nconst CardTitle = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      'text-2xl font-semibold leading-none tracking-tight',\n      className\n    )}\n    {...props}\n  />\n));\nCardTitle.displayName = 'CardTitle';\n\nconst CardDescription = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nCardDescription.displayName = 'CardDescription';\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />\n));\nCardContent.displayName = 'CardContent';\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('flex items-center p-6 pt-0', className)}\n    {...props}\n  />\n));\nCardFooter.displayName = 'CardFooter';\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "packages/ui/src/components/ChangeTargetDialog.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from './KeyboardDialog';\nimport { Button } from './Button';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from './Select';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '../lib/modals';\n\nexport interface ChangeTargetBranchOption {\n  name: string;\n  isCurrent: boolean;\n}\n\nexport interface ChangeTargetDialogProps {\n  branches: ChangeTargetBranchOption[];\n  onChangeTargetBranch: (newTargetBranch: string) => Promise<void>;\n}\n\nconst ChangeTargetDialogImpl = NiceModal.create<ChangeTargetDialogProps>(\n  ({ branches, onChangeTargetBranch }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['tasks', 'common']);\n    const [selectedBranch, setSelectedBranch] = useState<string>('');\n    const [isSubmitting, setIsSubmitting] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n\n    useEffect(() => {\n      if (!modal.visible) return;\n      setSelectedBranch(branches[0]?.name ?? '');\n      setError(null);\n      setIsSubmitting(false);\n    }, [branches, modal.visible]);\n\n    const handleConfirm = async () => {\n      if (!selectedBranch) return;\n\n      setIsSubmitting(true);\n      setError(null);\n      try {\n        await onChangeTargetBranch(selectedBranch);\n        modal.hide();\n      } catch (err) {\n        const message =\n          err && typeof err === 'object' && 'message' in err\n            ? String(err.message)\n            : 'Failed to change target branch';\n        setError(message);\n      } finally {\n        setIsSubmitting(false);\n      }\n    };\n\n    const handleCancel = () => {\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('branches.changeTarget.dialog.title')}</DialogTitle>\n            <DialogDescription>\n              {t('branches.changeTarget.dialog.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <label htmlFor=\"base-branch\" className=\"text-sm font-medium\">\n                {t('rebase.dialog.targetLabel')}\n              </label>\n              <Select value={selectedBranch} onValueChange={setSelectedBranch}>\n                <SelectTrigger id=\"base-branch\">\n                  <SelectValue\n                    placeholder={t('branches.changeTarget.dialog.placeholder')}\n                  />\n                </SelectTrigger>\n                <SelectContent>\n                  {branches.map((branch) => (\n                    <SelectItem key={branch.name} value={branch.name}>\n                      {branch.name}\n                      {branch.isCurrent\n                        ? ` (${t('branchSelector.badges.current')})`\n                        : ''}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n            {error && <p className=\"text-sm text-destructive\">{error}</p>}\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isSubmitting}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              onClick={handleConfirm}\n              disabled={isSubmitting || !selectedBranch}\n            >\n              {isSubmitting\n                ? t('branches.changeTarget.dialog.inProgress')\n                : t('branches.changeTarget.dialog.action')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const ChangeTargetDialog = defineModal<ChangeTargetDialogProps, void>(\n  ChangeTargetDialogImpl\n);\n"
  },
  {
    "path": "packages/ui/src/components/ChangesPanel.tsx",
    "content": "import type { ForwardedRef, ReactNode, RefAttributes } from 'react';\nimport { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Virtuoso, VirtuosoHandle, ListRange } from 'react-virtuoso';\nimport { cn } from '../lib/cn';\n\nexport interface ChangesPanelHandle {\n  scrollToIndex: (\n    index: number,\n    options?: { align?: 'start' | 'center' | 'end' }\n  ) => void;\n}\n\nexport interface ChangesPanelDiff {\n  newPath?: string | null;\n  oldPath?: string | null;\n  additions?: number | null;\n  deletions?: number | null;\n}\n\nexport interface DiffItemData<\n  TDiff extends ChangesPanelDiff = ChangesPanelDiff,\n> {\n  diff: TDiff;\n  initialExpanded?: boolean;\n}\n\nexport interface RenderDiffItemProps<\n  TDiff extends ChangesPanelDiff = ChangesPanelDiff,\n> {\n  diff: TDiff;\n  initialExpanded?: boolean;\n  workspaceId: string;\n}\n\nexport interface ChangesPanelProps<\n  TDiff extends ChangesPanelDiff = ChangesPanelDiff,\n> {\n  className?: string;\n  diffItems: DiffItemData<TDiff>[];\n  renderDiffItem: (props: RenderDiffItemProps<TDiff>) => ReactNode;\n  onDiffRef?: (path: string, el: HTMLDivElement | null) => void;\n  /** Callback for Virtuoso's scroll container ref */\n  onScrollerRef?: (ref: HTMLElement | Window | null) => void;\n  /** Callback when visible range changes (for scroll sync) */\n  onRangeChanged?: (range: { startIndex: number; endIndex: number }) => void;\n  /** Attempt ID for opening files in IDE */\n  workspaceId: string;\n}\n\nconst HEADER_HEIGHT = 48;\nconst LINE_HEIGHT = 20;\nconst PADDING = 16;\nconst SPACING = 8;\n\nfunction getDiffPath(diff: ChangesPanelDiff): string {\n  return diff.newPath || diff.oldPath || '';\n}\n\nfunction estimateDiffHeight(\n  diff: ChangesPanelDiff,\n  isExpanded: boolean\n): number {\n  if (!isExpanded) {\n    return HEADER_HEIGHT + SPACING;\n  }\n\n  const lineCount = (diff.additions ?? 0) + (diff.deletions ?? 0);\n  const estimatedLines = Math.max(lineCount * 1.2, 10);\n\n  return HEADER_HEIGHT + estimatedLines * LINE_HEIGHT + PADDING + SPACING;\n}\n\nfunction calculateDefaultHeight(diffs: ChangesPanelDiff[]): number {\n  if (diffs.length === 0) return 200;\n\n  const heights = diffs.map((diff) => estimateDiffHeight(diff, true));\n  heights.sort((a, b) => a - b);\n\n  const mid = Math.floor(heights.length / 2);\n  return heights.length % 2 === 0\n    ? (heights[mid - 1] + heights[mid]) / 2\n    : heights[mid];\n}\n\nconst ChangesPanelInner = <TDiff extends ChangesPanelDiff>(\n  {\n    className,\n    diffItems,\n    renderDiffItem,\n    onDiffRef,\n    onScrollerRef,\n    onRangeChanged,\n    workspaceId,\n  }: ChangesPanelProps<TDiff>,\n  ref: ForwardedRef<ChangesPanelHandle>\n) => {\n  const { t } = useTranslation(['tasks', 'common']);\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n\n  useImperativeHandle(ref, () => ({\n    scrollToIndex: (\n      index: number,\n      options?: { align?: 'start' | 'center' | 'end' }\n    ) => {\n      virtuosoRef.current?.scrollToIndex({\n        index,\n        align: options?.align ?? 'start',\n        behavior: 'auto',\n      });\n    },\n  }));\n\n  const handleRangeChanged = (range: ListRange) => {\n    onRangeChanged?.({\n      startIndex: range.startIndex,\n      endIndex: range.endIndex,\n    });\n  };\n\n  const defaultItemHeight = useMemo(\n    () => calculateDefaultHeight(diffItems.map((item) => item.diff)),\n    [diffItems]\n  );\n\n  if (diffItems.length === 0) {\n    return (\n      <div\n        className={cn(\n          'w-full h-full bg-secondary flex flex-col px-base',\n          className\n        )}\n      >\n        <div className=\"flex-1 flex items-center justify-center text-low\">\n          <p className=\"text-sm\">{t('common:empty.noChanges')}</p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        'w-full h-full bg-secondary flex flex-col px-base',\n        className\n      )}\n    >\n      <Virtuoso\n        ref={virtuosoRef}\n        scrollerRef={onScrollerRef}\n        data={diffItems}\n        defaultItemHeight={defaultItemHeight}\n        components={{\n          Header: () => <div className=\"h-base\" />,\n        }}\n        itemContent={(_index, { diff, initialExpanded }) => {\n          const path = getDiffPath(diff);\n          return (\n            <div ref={(el) => onDiffRef?.(path, el)}>\n              {renderDiffItem({ diff, initialExpanded, workspaceId })}\n            </div>\n          );\n        }}\n        computeItemKey={(index, { diff }) => getDiffPath(diff) || String(index)}\n        rangeChanged={handleRangeChanged}\n        increaseViewportBy={{ top: 500, bottom: 300 }}\n      />\n    </div>\n  );\n};\n\ntype ChangesPanelComponent = <\n  TDiff extends ChangesPanelDiff = ChangesPanelDiff,\n>(\n  props: ChangesPanelProps<TDiff> & RefAttributes<ChangesPanelHandle>\n) => JSX.Element;\n\nexport const ChangesPanel = forwardRef(\n  ChangesPanelInner\n) as ChangesPanelComponent;\n"
  },
  {
    "path": "packages/ui/src/components/ChatAggregatedDiffEntries.tsx",
    "content": "import { useMemo } from 'react';\nimport {\n  CaretDownIcon,\n  ArrowSquareUpRightIcon,\n  FileIcon as DefaultFileIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { ToolStatusDot, type ToolStatusLike } from './ToolStatusDot';\nimport type { ChatFileEntryDiffInput } from './ChatFileEntry';\n\nexport type ChatAggregatedDiffChange = {\n  action: 'edit' | 'write' | 'delete' | 'rename';\n  unified_diff?: string;\n  has_line_numbers?: boolean;\n  content?: string;\n  new_path?: string;\n};\n\nexport interface AggregatedDiffEntry {\n  /** The file change data */\n  change: ChatAggregatedDiffChange;\n  /** Tool status for this change */\n  status: ToolStatusLike | null;\n  /** Unique key for expansion state */\n  expansionKey: string;\n}\n\ninterface ChatAggregatedDiffEntriesProps {\n  /** The file path being edited */\n  filePath: string;\n  /** The individual diff entries for this file */\n  entries: AggregatedDiffEntry[];\n  /** Whether the accordion is expanded */\n  expanded: boolean;\n  /** Currently hovered state */\n  isHovered: boolean;\n  /** Callback when toggling expansion */\n  onToggle: () => void;\n  /** Callback when hover state changes */\n  onHoverChange: (hovered: boolean) => void;\n  /** Callback to open file in changes panel */\n  onOpenInChanges: (() => void) | null;\n  className?: string;\n  fileIcon?: React.ElementType;\n  isVSCode?: boolean;\n  onOpenInVSCode?: (filePath: string) => void;\n  renderDiffBody?: (args: {\n    filePath: string;\n    change: ChatAggregatedDiffChange;\n    diffContent?: ChatFileEntryDiffInput;\n  }) => React.ReactNode;\n}\n\nfunction parseUnifiedDiffStats(unifiedDiff: string) {\n  let additions = 0;\n  let deletions = 0;\n\n  for (const line of unifiedDiff.split('\\n')) {\n    if (line.startsWith('+++') || line.startsWith('---')) {\n      continue;\n    }\n    if (line.startsWith('+')) {\n      additions += 1;\n    } else if (line.startsWith('-')) {\n      deletions += 1;\n    }\n  }\n\n  return { additions, deletions };\n}\n\nfunction buildDiffContent(\n  change: ChatAggregatedDiffChange,\n  filePath: string\n): ChatFileEntryDiffInput | undefined {\n  if (change.action === 'edit' && change.unified_diff) {\n    return {\n      type: 'unified',\n      path: filePath,\n      unifiedDiff: change.unified_diff,\n      hasLineNumbers: change.has_line_numbers ?? true,\n    };\n  }\n  if (change.action === 'write' && change.content) {\n    return {\n      type: 'content',\n      oldContent: '',\n      newContent: change.content,\n      newPath: filePath,\n    };\n  }\n  return undefined;\n}\n\nfunction getActionLabel(change: ChatAggregatedDiffChange) {\n  switch (change.action) {\n    case 'edit':\n      return 'Edit';\n    case 'write':\n      return 'Write';\n    case 'delete':\n      return 'Delete';\n    case 'rename':\n      return change.new_path ? `Rename → ${change.new_path}` : 'Rename';\n    default:\n      return 'Change';\n  }\n}\n\nfunction DiffEntry({\n  filePath,\n  change,\n  status,\n  renderDiffBody,\n}: {\n  filePath: string;\n  change: ChatAggregatedDiffChange;\n  status: ToolStatusLike | null;\n  renderDiffBody?: (args: {\n    filePath: string;\n    change: ChatAggregatedDiffChange;\n    diffContent?: ChatFileEntryDiffInput;\n  }) => React.ReactNode;\n}) {\n  const { additions, deletions } = useMemo(() => {\n    if (change.action === 'edit' && change.unified_diff) {\n      return parseUnifiedDiffStats(change.unified_diff);\n    }\n    return { additions: undefined, deletions: undefined };\n  }, [change]);\n\n  const writeAdditions =\n    change.action === 'write' && change.content\n      ? change.content.split('\\n').length\n      : undefined;\n  const diffContent = useMemo(\n    () => buildDiffContent(change, filePath),\n    [change, filePath]\n  );\n  const hasStats =\n    (additions !== undefined && additions > 0) ||\n    (deletions !== undefined && deletions > 0) ||\n    (writeAdditions !== undefined && writeAdditions > 0);\n\n  return (\n    <div className=\"border-t border-muted/50 first:border-t-0\">\n      <div className=\"flex items-center p-base bg-muted/10\">\n        <div className=\"flex-1 flex items-center gap-base min-w-0\">\n          <span className=\"relative shrink-0\">\n            {status && <ToolStatusDot status={status} className=\"size-2\" />}\n          </span>\n          <span className=\"text-sm text-low\">{getActionLabel(change)}</span>\n          {hasStats && (\n            <span className=\"text-sm shrink-0\">\n              {(additions ?? writeAdditions) !== undefined &&\n                (additions ?? writeAdditions)! > 0 && (\n                  <span className=\"text-success\">\n                    +{additions ?? writeAdditions}\n                  </span>\n                )}\n              {(additions ?? writeAdditions) !== undefined &&\n                deletions !== undefined &&\n                deletions > 0 &&\n                ' '}\n              {deletions !== undefined && deletions > 0 && (\n                <span className=\"text-error\">-{deletions}</span>\n              )}\n            </span>\n          )}\n        </div>\n      </div>\n\n      {diffContent &&\n        renderDiffBody?.({\n          filePath,\n          change,\n          diffContent,\n        })}\n    </div>\n  );\n}\n\nexport function ChatAggregatedDiffEntries({\n  filePath,\n  entries,\n  expanded,\n  isHovered,\n  onToggle,\n  onHoverChange,\n  onOpenInChanges,\n  className,\n  fileIcon,\n  isVSCode = false,\n  onOpenInVSCode,\n  renderDiffBody,\n}: ChatAggregatedDiffEntriesProps) {\n  const { t } = useTranslation('tasks');\n  const FileIcon = fileIcon ?? DefaultFileIcon;\n\n  const handleClick = () => {\n    if (isVSCode) {\n      onOpenInVSCode?.(filePath);\n      return;\n    }\n    onToggle();\n  };\n\n  const totalStats = useMemo(() => {\n    let additions = 0;\n    let deletions = 0;\n\n    for (const entry of entries) {\n      const { change } = entry;\n      if (change.action === 'edit' && change.unified_diff) {\n        const stats = parseUnifiedDiffStats(change.unified_diff);\n        additions += stats.additions ?? 0;\n        deletions += stats.deletions ?? 0;\n      } else if (change.action === 'write' && change.content) {\n        additions += change.content.split('\\n').length;\n      }\n    }\n\n    return { additions, deletions };\n  }, [entries]);\n\n  const aggregateStatus = useMemo(() => {\n    return entries.reduce<ToolStatusLike | null>((worst, entry) => {\n      if (!entry.status) return worst;\n      if (!worst) return entry.status;\n\n      const statusPriority: Record<string, number> = {\n        failed: 6,\n        denied: 5,\n        timed_out: 4,\n        pending_approval: 3,\n        created: 2,\n        success: 1,\n      };\n\n      const worstPriority = statusPriority[worst.status] || 0;\n      const currentPriority = statusPriority[entry.status.status] || 0;\n\n      return currentPriority > worstPriority ? entry.status : worst;\n    }, null);\n  }, [entries]);\n\n  const isDenied = aggregateStatus?.status === 'denied';\n  const hasStats = totalStats.additions > 0 || totalStats.deletions > 0;\n\n  return (\n    <div\n      className={cn(\n        'rounded-sm border overflow-hidden',\n        isDenied && 'border-error bg-error/10',\n        className\n      )}\n    >\n      <div\n        className={cn(\n          'flex items-center p-base w-full',\n          isDenied ? 'bg-error/20' : 'bg-panel',\n          'cursor-pointer'\n        )}\n        onClick={handleClick}\n        onMouseEnter={() => onHoverChange(true)}\n        onMouseLeave={() => onHoverChange(false)}\n        role=\"button\"\n        aria-expanded={expanded}\n        data-scroll-anchor-target=\"\"\n      >\n        <div className=\"flex-1 flex items-center gap-base min-w-0\">\n          <span className=\"relative shrink-0\">\n            {!isVSCode && isHovered ? (\n              <CaretDownIcon\n                className={cn(\n                  'size-icon-base transition-transform duration-150',\n                  !expanded && '-rotate-90'\n                )}\n              />\n            ) : (\n              <FileIcon className=\"size-icon-base\" />\n            )}\n            {aggregateStatus && (\n              <ToolStatusDot\n                status={aggregateStatus}\n                className=\"absolute -bottom-0.5 -right-0.5\"\n              />\n            )}\n          </span>\n          <span className=\"text-sm text-normal truncate\">{filePath}</span>\n          <span className=\"text-xs text-low shrink-0\">\n            · {entries.length} {entries.length === 1 ? 'edit' : 'edits'}\n          </span>\n          {!isVSCode && onOpenInChanges && (\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                e.stopPropagation();\n                onOpenInChanges();\n              }}\n              className=\"shrink-0 p-0.5 rounded hover:bg-muted text-low hover:text-normal transition-colors\"\n              title={t('conversation.viewInChangesPanel')}\n            >\n              <ArrowSquareUpRightIcon className=\"size-icon-xs\" />\n            </button>\n          )}\n          {hasStats && (\n            <span className=\"text-sm shrink-0\">\n              {totalStats.additions > 0 && (\n                <span className=\"text-success\">+{totalStats.additions}</span>\n              )}\n              {totalStats.additions > 0 && totalStats.deletions > 0 && ' '}\n              {totalStats.deletions > 0 && (\n                <span className=\"text-error\">-{totalStats.deletions}</span>\n              )}\n            </span>\n          )}\n        </div>\n        {!isVSCode && (\n          <CaretDownIcon\n            className={cn(\n              'size-icon-xs shrink-0 text-low transition-transform',\n              !expanded && '-rotate-90'\n            )}\n          />\n        )}\n      </div>\n\n      {!isVSCode && expanded && (\n        <div className=\"border-t\">\n          {entries.map((entry) => (\n            <DiffEntry\n              key={entry.expansionKey}\n              filePath={filePath}\n              change={entry.change}\n              status={entry.status}\n              renderDiffBody={renderDiffBody}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatAggregatedToolEntries.tsx",
    "content": "import { ListMagnifyingGlassIcon, CaretRightIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { ToolStatusDot, type ToolStatusLike } from './ToolStatusDot';\n\nexport interface AggregatedEntry {\n  summary: string;\n  status?: ToolStatusLike;\n  expansionKey: string;\n  content?: string;\n  command?: string;\n}\n\ninterface ChatAggregatedToolEntriesProps {\n  entries: AggregatedEntry[];\n  expanded: boolean;\n  isHovered: boolean;\n  onToggle: () => void;\n  onHoverChange: (hovered: boolean) => void;\n  /** Label to show before the count (e.g., \"Read\", \"Search\") */\n  label: string;\n  /** Unit label for counting (e.g., \"file\", \"URL\") - will be pluralized automatically */\n  unit: string;\n  icon?: React.ElementType;\n  className?: string;\n  onViewContent?: (index: number) => void;\n}\n\nexport function ChatAggregatedToolEntries({\n  entries,\n  expanded,\n  isHovered,\n  onToggle,\n  onHoverChange,\n  label,\n  unit,\n  icon: Icon = ListMagnifyingGlassIcon,\n  className,\n  onViewContent,\n}: ChatAggregatedToolEntriesProps) {\n  if (entries.length === 0) return null;\n\n  // If only one entry, don't aggregate\n  if (entries.length === 1) {\n    const entry = entries[0];\n    return (\n      <div\n        className={cn(\n          'flex items-center gap-base text-sm text-low',\n          onViewContent && 'cursor-pointer',\n          className\n        )}\n        onClick={onViewContent ? () => onViewContent(0) : undefined}\n        role={onViewContent ? 'button' : undefined}\n      >\n        <span className=\"relative shrink-0 pt-0.5\">\n          <Icon className=\"size-icon-base\" />\n          {entry.status && (\n            <ToolStatusDot\n              status={entry.status}\n              className=\"absolute -bottom-0.5 -left-0.5\"\n            />\n          )}\n        </span>\n        <span className=\"truncate\">{entry.summary}</span>\n      </div>\n    );\n  }\n\n  // Get the worst status among all entries for the aggregate indicator\n  const aggregateStatus = entries.reduce<ToolStatusLike | undefined>(\n    (worst, entry) => {\n      if (!entry.status) return worst;\n      if (!worst) return entry.status;\n\n      // Priority: failed > denied > timed_out > pending_approval > created > success\n      const statusPriority: Record<string, number> = {\n        failed: 6,\n        denied: 5,\n        timed_out: 4,\n        pending_approval: 3,\n        created: 2,\n        success: 1,\n      };\n\n      const worstPriority = statusPriority[worst.status] || 0;\n      const currentPriority = statusPriority[entry.status.status] || 0;\n\n      return currentPriority > worstPriority ? entry.status : worst;\n    },\n    undefined\n  );\n\n  return (\n    <div className={cn('flex flex-col', className)}>\n      {/* Header row - clickable to expand/collapse */}\n      <div\n        className=\"flex items-center gap-base text-sm text-low cursor-pointer group\"\n        onClick={onToggle}\n        onMouseEnter={() => onHoverChange(true)}\n        onMouseLeave={() => onHoverChange(false)}\n        role=\"button\"\n        aria-expanded={expanded}\n        data-scroll-anchor-target=\"\"\n      >\n        <span className=\"relative shrink-0 pt-0.5\">\n          {isHovered ? (\n            <CaretRightIcon\n              className={cn(\n                'size-icon-base transition-transform duration-150',\n                expanded && 'rotate-90'\n              )}\n            />\n          ) : (\n            <Icon className=\"size-icon-base\" />\n          )}\n          {aggregateStatus && (\n            <ToolStatusDot\n              status={aggregateStatus}\n              className=\"absolute -bottom-0.5 -left-0.5\"\n            />\n          )}\n        </span>\n        <span className=\"truncate\">\n          {label} · {entries.length} {entries.length === 1 ? unit : `${unit}s`}\n        </span>\n      </div>\n\n      {/* Expanded content */}\n      {expanded && (\n        <div className=\"ml-6 pt-1 flex flex-col gap-0.5\">\n          {entries.map((entry, index) => (\n            <div\n              key={entry.expansionKey}\n              className={cn(\n                'flex items-center gap-base text-sm text-low pl-base',\n                onViewContent && 'cursor-pointer hover:text-normal'\n              )}\n              onClick={onViewContent ? () => onViewContent(index) : undefined}\n              role={onViewContent ? 'button' : undefined}\n            >\n              <span className=\"relative shrink-0 pt-0.5\">\n                <Icon className=\"size-icon-base\" />\n                {entry.status && (\n                  <ToolStatusDot\n                    status={entry.status}\n                    className=\"absolute -bottom-0.5 -left-0.5\"\n                  />\n                )}\n              </span>\n              <span className=\"truncate\">{entry.summary}</span>\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatApprovalCard.tsx",
    "content": "import type { ReactNode } from 'react';\nimport {\n  ChatEntryContainer,\n  type ChatEntryStatusLike,\n} from './ChatEntryContainer';\n\nexport interface ChatApprovalCardRenderProps {\n  content: string;\n  workspaceId?: string;\n}\n\ninterface ChatApprovalCardProps {\n  title: string;\n  content: string;\n  expanded?: boolean;\n  onToggle?: () => void;\n  className?: string;\n  workspaceId?: string;\n  status: ChatEntryStatusLike;\n  renderMarkdown: (props: ChatApprovalCardRenderProps) => ReactNode;\n}\n\nexport function ChatApprovalCard({\n  title,\n  content,\n  expanded = false,\n  onToggle,\n  className,\n  workspaceId,\n  status,\n  renderMarkdown,\n}: ChatApprovalCardProps) {\n  return (\n    <ChatEntryContainer\n      variant=\"plan\"\n      title={title}\n      expanded={expanded}\n      onToggle={onToggle}\n      className={className}\n      status={status}\n    >\n      {renderMarkdown({ content, workspaceId })}\n    </ChatEntryContainer>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatAssistantMessage.tsx",
    "content": "import type { ReactNode } from 'react';\n\nexport interface ChatAssistantMessageRenderProps {\n  content: string;\n  workspaceId?: string;\n}\n\ninterface ChatAssistantMessageProps {\n  content: string;\n  workspaceId?: string;\n  renderMarkdown: (props: ChatAssistantMessageRenderProps) => ReactNode;\n}\n\nexport function ChatAssistantMessage({\n  content,\n  workspaceId,\n  renderMarkdown,\n}: ChatAssistantMessageProps) {\n  return renderMarkdown({ content, workspaceId });\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatBoxBase.tsx",
    "content": "import { type ReactNode } from 'react';\nimport { ImageIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { Toolbar } from './Toolbar';\n\nexport enum VisualVariant {\n  NORMAL = 'NORMAL',\n  FEEDBACK = 'FEEDBACK',\n  EDIT = 'EDIT',\n  PLAN = 'PLAN',\n}\n\nexport interface DropzoneProps {\n  getRootProps: () => Record<string, unknown>;\n  getInputProps: () => Record<string, unknown>;\n  isDragActive: boolean;\n}\n\ninterface ChatBoxBaseProps {\n  // Editor node (provided by frontend)\n  editor: ReactNode;\n\n  // Error display\n  error?: string | null;\n\n  // Header content (right side - session/executor dropdown)\n  headerRight?: ReactNode;\n\n  // Header content (left side - stats)\n  headerLeft?: ReactNode;\n\n  // Footer left content (additional toolbar items like attach button)\n  footerLeft?: ReactNode;\n\n  // Footer right content (action buttons)\n  footerRight: ReactNode;\n\n  // Model selector node (rendered with footer controls)\n  modelSelector?: ReactNode;\n\n  // Banner content (queued message indicator, feedback mode indicator)\n  banner?: ReactNode;\n\n  // visualVariant\n  visualVariant: VisualVariant;\n\n  // Whether the workspace is running (shows animated border)\n  isRunning?: boolean;\n\n  // Dropzone props for drag-and-drop image uploads\n  dropzone?: DropzoneProps;\n}\n\n/**\n * Base chat box layout component.\n * Provides shared structure for CreateChatBox and SessionChatBox.\n */\nexport function ChatBoxBase({\n  editor,\n  error,\n  headerRight,\n  headerLeft,\n  footerLeft,\n  footerRight,\n  modelSelector,\n  banner,\n  visualVariant,\n  isRunning,\n  dropzone,\n}: ChatBoxBaseProps) {\n  const { t } = useTranslation(['common', 'tasks']);\n\n  const isDragActive = dropzone?.isDragActive ?? false;\n\n  return (\n    <div\n      {...(dropzone?.getRootProps() ?? {})}\n      className={cn(\n        'relative flex w-chat max-w-full flex-col rounded-sm border border-border bg-secondary',\n        (visualVariant === VisualVariant.FEEDBACK ||\n          visualVariant === VisualVariant.EDIT ||\n          visualVariant === VisualVariant.PLAN) &&\n          'border-brand bg-brand/10',\n        isRunning && 'chat-box-running'\n      )}\n    >\n      {dropzone && <input {...dropzone.getInputProps()} />}\n\n      {isDragActive && (\n        <div className=\"absolute inset-0 z-50 flex items-center justify-center rounded-sm border-2 border-dashed border-brand bg-primary/80 backdrop-blur-sm pointer-events-none animate-in fade-in-0 duration-150\">\n          <div className=\"text-center\">\n            <div className=\"mx-auto mb-2 w-10 h-10 rounded-full bg-brand/10 flex items-center justify-center\">\n              <ImageIcon className=\"h-5 w-5 text-brand\" />\n            </div>\n            <p className=\"text-sm font-medium text-high\">\n              {t('tasks:dropzone.dropImagesHere')}\n            </p>\n            <p className=\"text-xs text-low mt-0.5\">\n              {t('tasks:dropzone.supportedFormats')}\n            </p>\n          </div>\n        </div>\n      )}\n      {/* Error alert */}\n      {error && (\n        <div className=\"bg-error/10 border-b px-double py-base\">\n          <p className=\"text-error text-sm\">{error}</p>\n        </div>\n      )}\n\n      {/* Banner content (queued indicator, feedback mode, etc.) */}\n      {banner}\n\n      {/* Header - Stats and selector */}\n      {visualVariant === VisualVariant.NORMAL && (\n        <div className=\"flex items-center gap-base border-b px-base py-base\">\n          <div className=\"flex flex-1 items-center gap-base text-sm min-w-0 overflow-hidden\">\n            {headerLeft}\n          </div>\n          <Toolbar className=\"gap-[9px]\">{headerRight}</Toolbar>\n        </div>\n      )}\n\n      {/* Editor area */}\n      <div className=\"flex flex-col gap-plusfifty px-base py-base rounded-md\">\n        {editor}\n\n        {/* Footer - Controls */}\n        <div className=\"flex items-end justify-between gap-base\">\n          <Toolbar className=\"flex-1 min-w-0 flex-wrap !gap-half\">\n            {modelSelector}\n            {footerLeft}\n          </Toolbar>\n          <div className=\"flex shrink-0 gap-base\">{footerRight}</div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatCollapsedThinking.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { ChatDotsIcon, CaretRightIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\n\nexport interface ThinkingEntry {\n  content: string;\n  expansionKey: string;\n}\n\nexport interface ChatCollapsedThinkingRenderProps {\n  content: string;\n  workspaceId?: string;\n  className?: string;\n}\n\ninterface ChatCollapsedThinkingProps {\n  entries: ThinkingEntry[];\n  expanded: boolean;\n  isHovered: boolean;\n  onToggle: () => void;\n  onHoverChange: (hovered: boolean) => void;\n  className?: string;\n  workspaceId?: string;\n  renderMarkdown: (props: ChatCollapsedThinkingRenderProps) => ReactNode;\n}\n\n/**\n * A collapsible group for thinking entries in previous conversation turns.\n * When collapsed, shows \"Thinking\" with the thinking icon.\n * When expanded, shows all thinking entries with their full content.\n */\nexport function ChatCollapsedThinking({\n  entries,\n  expanded,\n  isHovered,\n  onToggle,\n  onHoverChange,\n  className,\n  workspaceId,\n  renderMarkdown,\n}: ChatCollapsedThinkingProps) {\n  const { t } = useTranslation('common');\n\n  if (entries.length === 0) return null;\n\n  return (\n    <div className={cn('flex flex-col', className)}>\n      {/* Header row - clickable to expand/collapse */}\n      <div\n        className=\"flex items-center gap-base text-sm text-low cursor-pointer group\"\n        onClick={onToggle}\n        onMouseEnter={() => onHoverChange(true)}\n        onMouseLeave={() => onHoverChange(false)}\n        role=\"button\"\n        aria-expanded={expanded}\n        data-scroll-anchor-target=\"\"\n      >\n        <span className=\"shrink-0 pt-0.5\">\n          {isHovered ? (\n            <CaretRightIcon\n              className={cn(\n                'size-icon-base transition-transform duration-150',\n                expanded && 'rotate-90'\n              )}\n            />\n          ) : (\n            <ChatDotsIcon className=\"size-icon-base\" />\n          )}\n        </span>\n        <span className=\"truncate\">{t('conversation.thinking')}</span>\n      </div>\n\n      {/* Expanded content */}\n      {expanded && (\n        <div className=\"ml-6 pt-1 flex flex-col gap-base\">\n          {entries.map((entry) => (\n            <div key={entry.expansionKey} className=\"text-sm text-low pl-base\">\n              {renderMarkdown({\n                content: entry.content,\n                workspaceId: workspaceId,\n                className: 'text-sm',\n              })}\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatEmptyState.tsx",
    "content": "import { ChatCircleDotsIcon } from '@phosphor-icons/react';\n\nimport { cn } from '../lib/cn';\n\ninterface ChatEmptyStateProps {\n  title: string;\n  description?: string;\n  className?: string;\n}\n\nexport function ChatEmptyState({\n  title,\n  description,\n  className,\n}: ChatEmptyStateProps) {\n  return (\n    <div\n      className={cn(\n        'mx-auto flex max-w-md flex-col items-center gap-2 text-center',\n        className\n      )}\n    >\n      <div className=\"flex size-12 items-center justify-center rounded-full border border-border/70 bg-panel text-low\">\n        <ChatCircleDotsIcon className=\"size-6\" />\n      </div>\n      <p className=\"text-sm font-medium text-normal\">{title}</p>\n      {description ? <p className=\"text-sm text-low\">{description}</p> : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatEntryContainer.tsx",
    "content": "import { ComponentType, type KeyboardEvent } from 'react';\nimport {\n  CaretDownIcon,\n  UserIcon,\n  ListChecksIcon,\n  GearIcon,\n  IconProps,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\ntype Variant = 'user' | 'plan' | 'plan_denied' | 'system';\n\nexport interface ChatEntryStatusLike {\n  status: string;\n}\n\ninterface VariantConfig {\n  icon: ComponentType<IconProps>;\n  border: string;\n  headerBg: string;\n  bg: string;\n}\n\nconst variantConfig: Record<Variant, VariantConfig> = {\n  user: {\n    icon: UserIcon,\n    border: 'border-border',\n    headerBg: '',\n    bg: '',\n  },\n  plan: {\n    icon: ListChecksIcon,\n    border: 'border-brand',\n    headerBg: 'bg-brand/20',\n    bg: 'bg-brand/10',\n  },\n  plan_denied: {\n    icon: ListChecksIcon,\n    border: 'border-error',\n    headerBg: 'bg-error/20',\n    bg: 'bg-error/10',\n  },\n  system: {\n    icon: GearIcon,\n    border: 'border-border',\n    headerBg: 'bg-gray-50 dark:bg-gray-900/30',\n    bg: '',\n  },\n};\n\ninterface ChatEntryContainerProps {\n  variant: Variant;\n  title?: React.ReactNode;\n  headerRight?: React.ReactNode;\n  expanded?: boolean;\n  onToggle?: () => void;\n  children?: React.ReactNode;\n  actions?: React.ReactNode;\n  className?: string;\n  status?: ChatEntryStatusLike;\n  isGreyed?: boolean;\n}\n\nexport function ChatEntryContainer({\n  variant,\n  title,\n  headerRight,\n  expanded = false,\n  onToggle,\n  children,\n  actions,\n  className,\n  status,\n  isGreyed,\n}: ChatEntryContainerProps) {\n  // Special case for plan denied\n  const config =\n    variant === 'plan' && status?.status === 'denied'\n      ? variantConfig.plan_denied\n      : variantConfig[variant];\n  const Icon = config.icon;\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!onToggle || event.target !== event.currentTarget) return;\n    if (event.key !== 'Enter' && event.key !== ' ') return;\n    event.preventDefault();\n    onToggle();\n  };\n\n  return (\n    <div\n      className={cn(\n        'rounded-sm w-full',\n        config.border && 'border',\n        config.border,\n        config.bg,\n        isGreyed && 'opacity-50 pointer-events-none',\n        className\n      )}\n    >\n      {/* Header */}\n      <div\n        className={cn(\n          'flex items-center px-double py-base gap-base rounded-sm overflow-hidden',\n          config.headerBg,\n          onToggle && 'cursor-pointer'\n        )}\n        onClick={onToggle}\n        onKeyDown={handleKeyDown}\n        role={onToggle ? 'button' : undefined}\n        aria-expanded={onToggle ? expanded : undefined}\n        tabIndex={onToggle ? 0 : undefined}\n      >\n        <Icon className=\"size-icon-xs shrink-0 text-low\" />\n        {title && (\n          <span className=\"flex-1 text-sm text-normal truncate\">{title}</span>\n        )}\n        {headerRight}\n        {onToggle && (\n          <CaretDownIcon\n            className={cn(\n              'size-icon-xs shrink-0 text-low transition-transform',\n              !expanded && '-rotate-90'\n            )}\n          />\n        )}\n      </div>\n\n      {/* Content - shown when expanded */}\n      {expanded && children && <div className=\"p-double\">{children}</div>}\n\n      {/* Actions footer - optional */}\n      {actions && (\n        <div className=\"bg-brand/20 backdrop-blur-sm flex items-center gap-base px-double py-base border-t sticky bottom-0 rounded-md\">\n          {actions}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatErrorMessage.tsx",
    "content": "import { WarningCircleIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\ninterface ChatErrorMessageProps {\n  content: string;\n  className?: string;\n  expanded?: boolean;\n  onToggle?: () => void;\n}\n\nexport function ChatErrorMessage({\n  content,\n  className,\n  expanded,\n  onToggle,\n}: ChatErrorMessageProps) {\n  return (\n    <div\n      className={cn(\n        'flex items-start gap-base text-sm text-error cursor-pointer',\n        className\n      )}\n      onClick={onToggle}\n      role=\"button\"\n    >\n      <WarningCircleIcon className=\"shrink-0 size-icon-base pt-0.5\" />\n      <span\n        className={cn(\n          !expanded && 'truncate',\n          expanded && 'whitespace-pre-wrap break-all'\n        )}\n      >\n        {content}\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatFileEntry.tsx",
    "content": "import type { KeyboardEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  CaretDownIcon,\n  ArrowSquareUpRightIcon,\n  FileIcon as DefaultFileIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { ToolStatusDot, type ToolStatusLike } from './ToolStatusDot';\n\nexport type ChatFileEntryDiffInput =\n  | {\n      type: 'content';\n      oldContent: string;\n      newContent: string;\n      oldPath?: string;\n      newPath: string;\n    }\n  | {\n      type: 'unified';\n      path: string;\n      unifiedDiff: string;\n      hasLineNumbers?: boolean;\n    };\n\ninterface ChatFileEntryProps {\n  filename: string;\n  additions?: number;\n  deletions?: number;\n  expanded?: boolean;\n  onToggle?: () => void;\n  className?: string;\n  status?: ToolStatusLike;\n  /** Optional diff content for expanded view */\n  diffContent?: ChatFileEntryDiffInput;\n  /** Optional callback to open file in changes panel */\n  onOpenInChanges?: () => void;\n  /** Optional file icon override from the app layer */\n  fileIcon?: React.ElementType;\n  /** Whether host app is running inside VSCode iframe */\n  isVSCode?: boolean;\n  /** Optional VSCode file opener from the app layer */\n  onOpenInVSCode?: (filename: string) => void;\n  /** Optional diff renderer from the app layer */\n  renderDiffBody?: (diffContent: ChatFileEntryDiffInput) => React.ReactNode;\n}\n\nexport function ChatFileEntry({\n  filename,\n  additions,\n  deletions,\n  expanded = false,\n  onToggle,\n  className,\n  status,\n  diffContent,\n  onOpenInChanges,\n  fileIcon,\n  isVSCode = false,\n  onOpenInVSCode,\n  renderDiffBody,\n}: ChatFileEntryProps) {\n  const { t } = useTranslation('tasks');\n  const hasStats = additions !== undefined || deletions !== undefined;\n  const FileIcon = fileIcon ?? DefaultFileIcon;\n  const isDenied = status?.status === 'denied';\n  const hasDiffContent = Boolean(diffContent && renderDiffBody);\n\n  const handleClick = () => {\n    if (isVSCode) {\n      onOpenInVSCode?.(filename);\n      return;\n    }\n    onToggle?.();\n  };\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.target !== event.currentTarget) return;\n    if (event.key !== 'Enter' && event.key !== ' ') return;\n    event.preventDefault();\n    handleClick();\n  };\n  const isInteractive = Boolean(onToggle || isVSCode);\n\n  // If we have diff content, wrap in a container with the diff body\n  if (hasDiffContent) {\n    return (\n      <div\n        className={cn(\n          'rounded-sm border overflow-hidden',\n          isDenied && 'border-error bg-error/10',\n          className\n        )}\n      >\n        {/* Header */}\n        <div\n          className={cn(\n            'flex items-center p-base w-full',\n            isDenied ? 'bg-error/20' : 'bg-panel',\n            isInteractive && 'cursor-pointer'\n          )}\n          onClick={handleClick}\n          onKeyDown={handleKeyDown}\n          role={isInteractive ? 'button' : undefined}\n          aria-expanded={onToggle ? expanded : undefined}\n          tabIndex={isInteractive ? 0 : undefined}\n          data-scroll-anchor-target={isInteractive ? '' : undefined}\n        >\n          <div className=\"flex-1 flex items-center gap-base min-w-0\">\n            <span className=\"relative shrink-0\">\n              <FileIcon className=\"size-icon-base\" />\n              {status && (\n                <ToolStatusDot\n                  status={status}\n                  className=\"absolute -bottom-0.5 -right-0.5\"\n                />\n              )}\n            </span>\n            <span className=\"text-sm text-normal truncate\">{filename}</span>\n            {onOpenInChanges && (\n              <button\n                type=\"button\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onOpenInChanges();\n                }}\n                className=\"shrink-0 p-0.5 rounded hover:bg-muted text-low hover:text-normal transition-colors\"\n                title={t('conversation.viewInChangesPanel')}\n              >\n                <ArrowSquareUpRightIcon className=\"size-icon-xs\" />\n              </button>\n            )}\n            {hasStats && (\n              <span className=\"text-sm shrink-0\">\n                {additions !== undefined && additions > 0 && (\n                  <span className=\"text-success\">+{additions}</span>\n                )}\n                {additions !== undefined && deletions !== undefined && ' '}\n                {deletions !== undefined && deletions > 0 && (\n                  <span className=\"text-error\">-{deletions}</span>\n                )}\n              </span>\n            )}\n          </div>\n          {!isVSCode && onToggle && (\n            <CaretDownIcon\n              className={cn(\n                'size-icon-xs shrink-0 text-low transition-transform',\n                !expanded && '-rotate-90'\n              )}\n            />\n          )}\n        </div>\n\n        {/* Diff body - shown when expanded */}\n        {!isVSCode && expanded && diffContent && renderDiffBody?.(diffContent)}\n      </div>\n    );\n  }\n\n  // Original header-only rendering (no diff content)\n  return (\n    <div\n      className={cn(\n        'flex items-center border rounded-sm p-base w-full',\n        isDenied ? 'bg-error/20 border-error' : 'bg-panel',\n        isInteractive && 'cursor-pointer',\n        className\n      )}\n      onClick={handleClick}\n      onKeyDown={handleKeyDown}\n      role={isInteractive ? 'button' : undefined}\n      aria-expanded={onToggle ? expanded : undefined}\n      tabIndex={isInteractive ? 0 : undefined}\n      data-scroll-anchor-target={isInteractive ? '' : undefined}\n    >\n      <div className=\"flex-1 flex items-center gap-base min-w-0\">\n        <span className=\"relative shrink-0\">\n          <FileIcon className=\"size-icon-base\" />\n          {status && (\n            <ToolStatusDot\n              status={status}\n              className=\"absolute -bottom-0.5 -right-0.5\"\n            />\n          )}\n        </span>\n        <span className=\"text-sm text-normal truncate\">{filename}</span>\n        {onOpenInChanges && (\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation();\n              onOpenInChanges();\n            }}\n            className=\"shrink-0 p-0.5 rounded hover:bg-muted text-low hover:text-normal transition-colors\"\n            title={t('conversation.viewInChangesPanel')}\n          >\n            <ArrowSquareUpRightIcon className=\"size-icon-xs\" />\n          </button>\n        )}\n        {hasStats && (\n          <span className=\"text-sm shrink-0\">\n            {additions !== undefined && additions > 0 && (\n              <span className=\"text-success\">+{additions}</span>\n            )}\n            {additions !== undefined && deletions !== undefined && ' '}\n            {deletions !== undefined && deletions > 0 && (\n              <span className=\"text-error\">-{deletions}</span>\n            )}\n          </span>\n        )}\n      </div>\n      {!isVSCode && onToggle && (\n        <CaretDownIcon\n          className={cn(\n            'size-icon-xs shrink-0 text-low transition-transform',\n            !expanded && '-rotate-90'\n          )}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatMarkdown.tsx",
    "content": "import { cn } from '../lib/cn';\n\nexport interface ChatMarkdownRenderProps {\n  content: string;\n  className?: string;\n  workspaceId?: string;\n}\n\ninterface ChatMarkdownProps {\n  content: string;\n  maxWidth?: string;\n  className?: string;\n  workspaceId?: string;\n  renderContent: (props: ChatMarkdownRenderProps) => React.ReactNode;\n}\n\nexport function ChatMarkdown({\n  content,\n  maxWidth = '800px',\n  className,\n  workspaceId,\n  renderContent,\n}: ChatMarkdownProps) {\n  const contentClassName = cn('whitespace-pre-wrap break-words', className);\n\n  return (\n    <div className=\"text-sm\" style={{ maxWidth }}>\n      {renderContent({\n        content,\n        className: contentClassName,\n        workspaceId,\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatScriptEntry.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { TerminalIcon, WrenchIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { ToolStatusDot, type ToolStatusLike } from './ToolStatusDot';\n\ninterface ChatScriptEntryProps {\n  title: string;\n  command?: string;\n  processId: string;\n  exitCode?: number | null;\n  className?: string;\n  status: ToolStatusLike;\n  onViewProcess: (processId: string) => void;\n  onFix?: () => void;\n}\n\nexport function ChatScriptEntry({\n  title,\n  command,\n  processId,\n  exitCode,\n  className,\n  status,\n  onViewProcess,\n  onFix,\n}: ChatScriptEntryProps) {\n  const { t } = useTranslation('tasks');\n  const isRunning = status.status === 'created';\n  const isSuccess = status.status === 'success';\n  const isFailed = status.status === 'failed';\n\n  const handleFixClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    onFix?.();\n  };\n\n  const handleClick = () => {\n    onViewProcess(processId);\n  };\n\n  const getSubtitle = () => {\n    if (isRunning) {\n      return t('conversation.script.running');\n    }\n    if (isFailed && exitCode !== null && exitCode !== undefined) {\n      return t('conversation.script.exitCode', { code: exitCode });\n    }\n    if (isSuccess) {\n      return t('conversation.script.completedSuccessfully');\n    }\n    return t('conversation.script.clickToViewLogs');\n  };\n\n  return (\n    <div\n      className={cn(\n        'flex items-start gap-base text-sm cursor-pointer hover:bg-secondary/50 rounded-md -mx-half px-half py-half transition-colors',\n        className\n      )}\n      onClick={handleClick}\n      role=\"button\"\n      tabIndex={0}\n      onKeyDown={(e) => {\n        if (e.key === 'Enter' || e.key === ' ') {\n          e.preventDefault();\n          handleClick();\n        }\n      }}\n    >\n      <span className=\"relative shrink-0 pt-0.5\">\n        <TerminalIcon className=\"size-icon-base text-low\" />\n        <ToolStatusDot\n          status={status}\n          className=\"absolute -bottom-0.5 -left-0.5\"\n        />\n      </span>\n      <div className=\"flex flex-col min-w-0 flex-1\">\n        <span className=\"text-normal font-medium\">{title}</span>\n        {command && (\n          <code className=\"text-low text-xs font-mono truncate block\">\n            {command}\n          </code>\n        )}\n        <span className=\"text-low text-xs\">{getSubtitle()}</span>\n      </div>\n      {isFailed && onFix && (\n        <button\n          type=\"button\"\n          onClick={handleFixClick}\n          className=\"shrink-0 flex items-center gap-1 px-2 py-1 text-xs text-brand hover:text-brand-hover hover:bg-secondary rounded transition-colors\"\n          title={t('scriptFixer.fixScript')}\n        >\n          <WrenchIcon className=\"size-icon-xs\" />\n          <span>{t('scriptFixer.fixScript')}</span>\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatScriptPlaceholder.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { TerminalIcon, GearSixIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nexport type ScriptPlaceholderType = 'setup' | 'cleanup';\n\ninterface ChatScriptPlaceholderProps {\n  type: ScriptPlaceholderType;\n  className?: string;\n  onConfigure?: () => void;\n}\n\nexport function ChatScriptPlaceholder({\n  type,\n  className,\n  onConfigure,\n}: ChatScriptPlaceholderProps) {\n  const { t } = useTranslation('tasks');\n\n  const title =\n    type === 'setup'\n      ? t('conversation.scriptPlaceholder.setupTitle')\n      : t('conversation.scriptPlaceholder.cleanupTitle');\n\n  const description =\n    type === 'setup'\n      ? t('conversation.scriptPlaceholder.setupDescription')\n      : t('conversation.scriptPlaceholder.cleanupDescription');\n\n  return (\n    <div\n      className={cn(\n        'flex items-start gap-base text-sm rounded-md -mx-half px-half py-half',\n        className\n      )}\n    >\n      <span className=\"relative shrink-0 pt-0.5\">\n        <TerminalIcon className=\"size-icon-base text-lowest\" />\n      </span>\n      <div className=\"flex flex-col min-w-0 flex-1 gap-0.5\">\n        <span className=\"text-low font-medium\">{title}</span>\n        <span className=\"text-lowest text-xs\">{description}</span>\n      </div>\n      {onConfigure && (\n        <button\n          type=\"button\"\n          onClick={onConfigure}\n          className=\"shrink-0 flex items-center gap-1 px-2 py-1 text-xs text-brand hover:text-brand-hover hover:bg-secondary rounded transition-colors\"\n        >\n          <GearSixIcon className=\"size-icon-xs\" />\n          <span>{t('conversation.scriptPlaceholder.configure')}</span>\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatSubagentEntry.tsx",
    "content": "import type { KeyboardEvent, ReactNode } from 'react';\nimport { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  CaretDownIcon,\n  CpuIcon,\n  CheckCircleIcon,\n  XCircleIcon,\n  CircleNotchIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport type { ToolStatusLike } from './ToolStatusDot';\n\nexport interface ChatSubagentResultLike {\n  value?: unknown | null;\n}\n\nexport interface ChatSubagentEntryRenderProps {\n  content: string;\n  workspaceId?: string;\n}\n\ninterface ChatSubagentEntryProps {\n  description: string;\n  subagentType?: string | null;\n  result?: ChatSubagentResultLike | null;\n  expanded?: boolean;\n  onToggle?: () => void;\n  className?: string;\n  status?: ToolStatusLike;\n  workspaceId?: string;\n  renderMarkdown: (props: ChatSubagentEntryRenderProps) => ReactNode;\n}\n\n/**\n * Renders a collapsible subagent (Task tool) entry showing:\n * - Header with subagent type and description\n * - Expandable content showing the subagent's output/conversation\n */\nexport function ChatSubagentEntry({\n  description,\n  subagentType,\n  result,\n  expanded = false,\n  onToggle,\n  className,\n  status,\n  workspaceId,\n  renderMarkdown,\n}: ChatSubagentEntryProps) {\n  const { t } = useTranslation('common');\n\n  // Determine status icon - consistent with ToolStatusDot\n  const StatusIcon = useMemo(() => {\n    if (!status) return null;\n    const statusType = status.status;\n\n    // Map status to visual state (consistent with ToolStatusDot)\n    const isSuccess = statusType === 'success';\n    const isError =\n      statusType === 'failed' ||\n      statusType === 'denied' ||\n      statusType === 'timed_out';\n    const isPending =\n      statusType === 'created' || statusType === 'pending_approval';\n\n    if (isSuccess) {\n      return (\n        <CheckCircleIcon className=\"size-icon-xs text-success\" weight=\"fill\" />\n      );\n    }\n    if (isError) {\n      return <XCircleIcon className=\"size-icon-xs text-error\" weight=\"fill\" />;\n    }\n    if (isPending) {\n      return <CircleNotchIcon className=\"size-icon-xs text-low animate-spin\" />;\n    }\n    return null;\n  }, [status]);\n\n  // Determine if status is an error state (for styling)\n  const isErrorStatus = useMemo(() => {\n    if (!status) return false;\n    return (\n      status.status === 'failed' ||\n      status.status === 'denied' ||\n      status.status === 'timed_out'\n    );\n  }, [status]);\n\n  // Format the subagent type for display\n  const formattedType = useMemo(() => {\n    if (!subagentType) return t('conversation.subagent.defaultType');\n    // Capitalize first letter and format\n    return subagentType.charAt(0).toUpperCase() + subagentType.slice(1);\n  }, [subagentType, t]);\n\n  // Extract the result content for display\n  const resultContent = useMemo(() => {\n    if (!result?.value) return null;\n\n    // Handle both string and object values\n    if (typeof result.value === 'string') {\n      return result.value;\n    }\n\n    // For JSON results, stringify with formatting\n    return JSON.stringify(result.value, null, 2);\n  }, [result]);\n\n  // Determine if we have content to show\n  const hasContent = Boolean(resultContent);\n  const isInteractive = Boolean(onToggle && hasContent);\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!isInteractive || event.target !== event.currentTarget) return;\n    if (event.key !== 'Enter' && event.key !== ' ') return;\n    event.preventDefault();\n    onToggle?.();\n  };\n\n  return (\n    <div\n      className={cn(\n        'rounded-sm border overflow-hidden',\n        isErrorStatus && 'border-error bg-error/5',\n        status?.status === 'success' && 'border-success/50',\n        !isErrorStatus && status?.status !== 'success' && 'border-border',\n        className\n      )}\n    >\n      {/* Header */}\n      <div\n        className={cn(\n          'flex items-center px-double py-base gap-base',\n          isErrorStatus && 'bg-error/10',\n          status?.status === 'success' && 'bg-success/5',\n          isInteractive && 'cursor-pointer'\n        )}\n        onClick={isInteractive ? onToggle : undefined}\n        onKeyDown={handleKeyDown}\n        role={isInteractive ? 'button' : undefined}\n        aria-expanded={isInteractive ? expanded : undefined}\n        tabIndex={isInteractive ? 0 : undefined}\n        data-scroll-anchor-target={isInteractive ? '' : undefined}\n      >\n        <span className=\"relative shrink-0\">\n          <CpuIcon className=\"size-icon-base text-low\" />\n        </span>\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center gap-base\">\n            <span className=\"text-xs font-medium text-low uppercase tracking-wide\">\n              {formattedType}\n            </span>\n            {StatusIcon}\n          </div>\n          <span className=\"text-sm text-normal truncate block\">\n            {description}\n          </span>\n        </div>\n        {isInteractive && (\n          <CaretDownIcon\n            className={cn(\n              'size-icon-xs shrink-0 text-low transition-transform',\n              !expanded && '-rotate-90'\n            )}\n          />\n        )}\n      </div>\n\n      {/* Expanded content - shows subagent output */}\n      {expanded && hasContent && (\n        <div className=\"border-t p-double bg-panel/50\">\n          <div className=\"text-xs font-medium text-low pb-base uppercase tracking-wide\">\n            {t('conversation.output')}\n          </div>\n          <div className=\"prose prose-sm dark:prose-invert max-w-none\">\n            {renderMarkdown({ content: resultContent!, workspaceId })}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatSystemMessage.tsx",
    "content": "import { InfoIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\ninterface ChatSystemMessageProps {\n  content: string;\n  className?: string;\n  expanded?: boolean;\n  onToggle?: () => void;\n}\n\nexport function ChatSystemMessage({\n  content,\n  className,\n  expanded,\n  onToggle,\n}: ChatSystemMessageProps) {\n  return (\n    <div\n      className={cn(\n        'flex items-start gap-base text-sm text-low cursor-pointer',\n        className\n      )}\n      onClick={onToggle}\n      role=\"button\"\n    >\n      <InfoIcon className=\"shrink-0 size-icon-base pt-0.5\" />\n      <span\n        className={cn(\n          !expanded && 'truncate',\n          expanded && 'whitespace-pre-wrap break-all'\n        )}\n      >\n        {content}\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatThinkingMessage.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { ChatDotsIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nexport interface ChatThinkingMessageRenderProps {\n  content: string;\n  workspaceId?: string;\n  className?: string;\n}\n\ninterface ChatThinkingMessageProps {\n  content: string;\n  className?: string;\n  workspaceId?: string;\n  renderMarkdown: (props: ChatThinkingMessageRenderProps) => ReactNode;\n}\n\nexport function ChatThinkingMessage({\n  content,\n  className,\n  workspaceId,\n  renderMarkdown,\n}: ChatThinkingMessageProps) {\n  return (\n    <div\n      className={cn('flex items-start gap-base text-sm text-low', className)}\n    >\n      <ChatDotsIcon className=\"shrink-0 size-icon-base pt-0.5\" />\n      {renderMarkdown({\n        content,\n        workspaceId: workspaceId,\n        className: 'text-sm',\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatTodoList.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { ListChecksIcon, CaretDownIcon } from '@phosphor-icons/react';\nimport { Circle, Check, CircleDot } from 'lucide-react';\nimport { cn } from '../lib/cn';\n\nexport interface TodoItemLike {\n  content: string;\n  status?: string | null;\n}\n\ninterface ChatTodoListProps {\n  todos: TodoItemLike[];\n  expanded?: boolean;\n  onToggle?: () => void;\n}\n\nfunction getStatusIcon(status?: string | null) {\n  const s = (status || '').toLowerCase();\n  if (s === 'completed')\n    return <Check aria-hidden className=\"h-4 w-4 text-success\" />;\n  if (s === 'in_progress' || s === 'in-progress')\n    return <CircleDot aria-hidden className=\"h-4 w-4 text-blue-500\" />;\n  if (s === 'cancelled')\n    return <Circle aria-hidden className=\"h-4 w-4 text-gray-400\" />;\n  return <Circle aria-hidden className=\"h-4 w-4 text-muted-foreground\" />;\n}\n\nexport function ChatTodoList({ todos, expanded, onToggle }: ChatTodoListProps) {\n  const { t } = useTranslation('tasks');\n\n  return (\n    <div className=\"text-sm\">\n      <div\n        className=\"flex items-center gap-base text-low cursor-pointer\"\n        onClick={onToggle}\n        role=\"button\"\n      >\n        <ListChecksIcon className=\"shrink-0 size-icon-base\" />\n        <span className=\"flex-1\">{t('conversation.updatedTodos')}</span>\n        <CaretDownIcon\n          className={cn(\n            'shrink-0 size-icon-base transition-transform',\n            expanded && 'rotate-180'\n          )}\n        />\n      </div>\n      {expanded && todos.length > 0 && (\n        <ul className=\"pt-base ml-6 [&>li+li]:pt-1\">\n          {todos.map((todo, index) => (\n            <li\n              key={`${todo.content}-${index}`}\n              className=\"flex items-start gap-2\"\n            >\n              <span className=\"pt-0.5 h-4 w-4 flex items-center justify-center shrink-0\">\n                {getStatusIcon(todo.status)}\n              </span>\n              <span className=\"leading-5 break-words\">\n                {todo.status?.toLowerCase() === 'cancelled' ? (\n                  <s className=\"text-gray-400\">{todo.content}</s>\n                ) : (\n                  todo.content\n                )}\n              </span>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ChatToolSummary.tsx",
    "content": "import { forwardRef } from 'react';\nimport {\n  ListMagnifyingGlassIcon,\n  TerminalWindowIcon,\n  FileTextIcon,\n  GlobeIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { ToolStatusDot, type ToolStatusLike } from './ToolStatusDot';\n\ninterface ChatToolSummaryProps {\n  summary: string;\n  className?: string;\n  expanded?: boolean;\n  onToggle?: () => void;\n  status?: ToolStatusLike;\n  onViewContent?: () => void;\n  toolName?: string;\n  isTruncated?: boolean;\n  /** The action type for determining the icon */\n  actionType?: string;\n}\n\nexport const ChatToolSummary = forwardRef<\n  HTMLSpanElement,\n  ChatToolSummaryProps\n>(function ChatToolSummary(\n  {\n    summary,\n    className,\n    expanded,\n    onToggle,\n    status,\n    onViewContent,\n    toolName,\n    isTruncated,\n    actionType,\n  },\n  ref\n) {\n  // Can expand if text is truncated and onToggle is provided\n  const canExpand = isTruncated && onToggle;\n  const isClickable = Boolean(onViewContent || canExpand);\n\n  const handleClick = () => {\n    if (onViewContent) {\n      onViewContent();\n    } else if (canExpand) {\n      onToggle();\n    }\n  };\n\n  // Determine icon based on action type or tool name\n  const getIcon = () => {\n    if (toolName === 'Bash') return TerminalWindowIcon;\n    switch (actionType) {\n      case 'file_read':\n        return FileTextIcon;\n      case 'search':\n        return ListMagnifyingGlassIcon;\n      case 'web_fetch':\n        return GlobeIcon;\n      default:\n        return ListMagnifyingGlassIcon;\n    }\n  };\n  const Icon = getIcon();\n\n  return (\n    <div\n      className={cn(\n        'flex items-center gap-base text-sm text-low',\n        isClickable && 'cursor-pointer',\n        className\n      )}\n      onClick={isClickable ? handleClick : undefined}\n      role={isClickable ? 'button' : undefined}\n    >\n      <span className=\"relative shrink-0 pt-0.5\">\n        <Icon className=\"size-icon-base\" />\n        {status && (\n          <ToolStatusDot\n            status={status}\n            className=\"absolute -bottom-0.5 -left-0.5\"\n          />\n        )}\n      </span>\n      <span\n        ref={ref}\n        className={cn(\n          !expanded && 'truncate',\n          expanded && 'whitespace-pre-wrap break-all'\n        )}\n      >\n        {summary}\n      </span>\n    </div>\n  );\n});\n"
  },
  {
    "path": "packages/ui/src/components/ChatUserMessage.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { PencilSimpleIcon, ArrowUUpLeftIcon } from '@phosphor-icons/react';\nimport { ChatEntryContainer } from './ChatEntryContainer';\nimport { Tooltip } from './Tooltip';\n\nexport interface ChatUserMessageRenderProps {\n  content: string;\n  workspaceId?: string;\n}\n\ninterface ChatUserMessageProps {\n  content: string;\n  expanded?: boolean;\n  onToggle?: () => void;\n  className?: string;\n  workspaceId?: string;\n  onEdit?: () => void;\n  onReset?: () => void;\n  isGreyed?: boolean;\n  renderMarkdown: (props: ChatUserMessageRenderProps) => ReactNode;\n}\n\nexport function ChatUserMessage({\n  content,\n  expanded = true,\n  onToggle,\n  className,\n  workspaceId,\n  onEdit,\n  onReset,\n  isGreyed,\n  renderMarkdown,\n}: ChatUserMessageProps) {\n  const { t } = useTranslation('tasks');\n\n  const headerActions =\n    !isGreyed && (onEdit || onReset) ? (\n      <div className=\"flex items-center gap-1\">\n        {onReset && (\n          <Tooltip content={t('conversation.actions.resetTooltip')}>\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                e.stopPropagation();\n                onReset();\n              }}\n              className=\"p-1 rounded hover:bg-muted text-low hover:text-normal transition-colors\"\n              aria-label={t('conversation.actions.reset')}\n            >\n              <ArrowUUpLeftIcon className=\"size-icon-xs\" />\n            </button>\n          </Tooltip>\n        )}\n        {onEdit && (\n          <Tooltip content={t('conversation.actions.edit')}>\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                e.stopPropagation();\n                onEdit();\n              }}\n              className=\"p-1 rounded hover:bg-muted text-low hover:text-normal transition-colors\"\n              aria-label={t('conversation.actions.edit')}\n            >\n              <PencilSimpleIcon className=\"size-icon-xs\" />\n            </button>\n          </Tooltip>\n        )}\n      </div>\n    ) : undefined;\n\n  return (\n    <ChatEntryContainer\n      variant=\"user\"\n      title={t('conversation.you')}\n      expanded={expanded}\n      onToggle={onToggle}\n      className={className}\n      isGreyed={isGreyed}\n      headerRight={headerActions}\n    >\n      {renderMarkdown({ content, workspaceId })}\n    </ChatEntryContainer>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Checkbox.tsx",
    "content": "import * as React from 'react';\nimport { Check } from 'lucide-react';\nimport { cn } from '../lib/cn';\n\ninterface CheckboxProps {\n  id?: string;\n  checked?: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n  className?: string;\n  disabled?: boolean;\n}\n\nconst Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(\n  (\n    { className, checked = false, onCheckedChange, disabled, ...props },\n    ref\n  ) => {\n    return (\n      <button\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={checked}\n        ref={ref}\n        className={cn(\n          'peer h-4 w-4 shrink-0 rounded-sm border border-primary-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',\n          checked && 'bg-primary text-primary-foreground',\n          className\n        )}\n        disabled={disabled}\n        onClick={() => onCheckedChange?.(!checked)}\n        {...props}\n      >\n        {checked && (\n          <div className=\"flex items-center justify-center text-current\">\n            <Check className=\"h-4 w-4\" />\n          </div>\n        )}\n      </button>\n    );\n  }\n);\nCheckbox.displayName = 'Checkbox';\n\nexport { Checkbox };\n"
  },
  {
    "path": "packages/ui/src/components/ClickableCodePlugin.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\n\ninterface ClickableCodePluginProps {\n  /** Function to find a matching diff path (supports partial/right-hand match) */\n  findMatchingDiffPath: (text: string) => string | null;\n  /** Callback when a clickable code element is clicked (receives the full path) */\n  onCodeClick: (fullPath: string) => void;\n}\n\n/**\n * Plugin that makes inline code elements clickable when their content\n * matches a file path in the current diffs.\n *\n * Supports fuzzy right-hand matching: \"ChatMarkdown.tsx\" will match\n * \"src/components/ui-new/primitives/conversation/ChatMarkdown.tsx\"\n *\n * Only active in read-only mode. Adds hover styling and click handlers\n * to matching code elements.\n */\nexport function ClickableCodePlugin({\n  findMatchingDiffPath,\n  onCodeClick,\n}: ClickableCodePluginProps) {\n  const [editor] = useLexicalComposerContext();\n  const processedElementsRef = useRef<WeakSet<Element>>(new WeakSet());\n\n  useEffect(() => {\n    const root = editor.getRootElement();\n    if (!root) return;\n\n    // Process a single code element\n    const processCodeElement = (element: Element) => {\n      // Skip if already processed\n      if (processedElementsRef.current.has(element)) return;\n\n      const text = element.textContent?.trim() ?? '';\n\n      // Check if this matches a diff path (supports fuzzy right-hand match)\n      const matchedPath = findMatchingDiffPath(text);\n      if (!matchedPath) return;\n\n      // Mark as processed\n      processedElementsRef.current.add(element);\n\n      // Add clickable styling\n      (element as HTMLElement).style.cursor = 'pointer';\n      element.classList.add('clickable-code');\n\n      // Add click handler - use the full matched path for navigation\n      const handleClick = (e: Event) => {\n        e.preventDefault();\n        e.stopPropagation();\n        onCodeClick(matchedPath);\n      };\n\n      element.addEventListener('click', handleClick);\n\n      // Store cleanup function on the element\n      (element as HTMLElement).dataset.clickableCode = 'true';\n    };\n\n    // Process all existing code elements\n    const processAllCodeElements = () => {\n      // Inline code uses the theme class which includes 'font-mono' and 'bg-muted'\n      // The actual class applied is from theme.text.code\n      const codeElements = root.querySelectorAll(\n        'code, .font-mono.bg-muted, [class*=\"text-code\"]'\n      );\n      codeElements.forEach(processCodeElement);\n    };\n\n    // Initial processing\n    processAllCodeElements();\n\n    // Watch for new code elements being added\n    const observer = new MutationObserver((mutations) => {\n      for (const mutation of mutations) {\n        // Check added nodes\n        for (const node of mutation.addedNodes) {\n          if (node instanceof Element) {\n            // Check if the node itself is a code element\n            if (\n              node.matches('code, .font-mono.bg-muted, [class*=\"text-code\"]')\n            ) {\n              processCodeElement(node);\n            }\n            // Check child code elements\n            const childCodeElements = node.querySelectorAll(\n              'code, .font-mono.bg-muted, [class*=\"text-code\"]'\n            );\n            childCodeElements.forEach(processCodeElement);\n          }\n        }\n      }\n    });\n\n    observer.observe(root, {\n      childList: true,\n      subtree: true,\n    });\n\n    return () => {\n      observer.disconnect();\n      // Clean up click handlers\n      const clickableElements = root.querySelectorAll('[data-clickable-code]');\n      clickableElements.forEach((el) => {\n        (el as HTMLElement).style.cursor = '';\n        el.classList.remove('clickable-code');\n        delete (el as HTMLElement).dataset.clickableCode;\n      });\n      processedElementsRef.current = new WeakSet();\n    };\n  }, [editor, findMatchingDiffPath, onCodeClick]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/CodeBlockShortcutPlugin.tsx",
    "content": "import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { useEffect } from 'react';\nimport { $createCodeNode } from '@lexical/code';\nimport {\n  $getSelection,\n  $isRangeSelection,\n  $isTextNode,\n  $isParagraphNode,\n  $createTextNode,\n  ElementNode,\n} from 'lexical';\n\nconst CODE_START_REGEX = /^```([\\w-]*)$/;\nconst CODE_END_REGEX = /^```$/;\n\n/**\n * Plugin that detects when user types closing ``` and converts the\n * paragraphs between opening and closing backticks into a code block.\n *\n * This handles the typing case - paste/import is handled by CODE_BLOCK_TRANSFORMER.\n */\nexport function CodeBlockShortcutPlugin() {\n  const [editor] = useLexicalComposerContext();\n\n  useEffect(() => {\n    return editor.registerUpdateListener(({ dirtyLeaves }) => {\n      // Only process if there are dirty leaves (actual changes)\n      if (dirtyLeaves.size === 0) return;\n\n      editor.update(() => {\n        const selection = $getSelection();\n        if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;\n\n        const anchorNode = selection.anchor.getNode();\n        if (!$isTextNode(anchorNode)) return;\n\n        const currentParagraph = anchorNode.getParent();\n        if (!$isParagraphNode(currentParagraph)) return;\n\n        const currentText = currentParagraph.getTextContent();\n\n        // Check if current line is closing ```\n        if (!CODE_END_REGEX.test(currentText)) return;\n\n        // Scan backward to find opening ```\n        let openingParagraph: ElementNode | null = null;\n        let language: string | undefined;\n        const contentParagraphs: ElementNode[] = [];\n\n        let sibling = currentParagraph.getPreviousSibling();\n        while (sibling) {\n          if ($isParagraphNode(sibling)) {\n            const text = sibling.getTextContent();\n            const startMatch = text.match(CODE_START_REGEX);\n            if (startMatch) {\n              openingParagraph = sibling;\n              language = startMatch[1] || undefined;\n              break;\n            }\n            contentParagraphs.unshift(sibling);\n          }\n          sibling = sibling.getPreviousSibling();\n        }\n\n        if (!openingParagraph) return;\n\n        // Collect content from paragraphs between opening and closing\n        const codeLines = contentParagraphs.map((p) => p.getTextContent());\n        const code = codeLines.join('\\n');\n\n        // Create code node\n        const codeNode = $createCodeNode(language);\n        if (code) {\n          codeNode.append($createTextNode(code));\n        }\n\n        // Replace opening paragraph with code node\n        openingParagraph.replace(codeNode);\n\n        // Remove content paragraphs and closing paragraph\n        contentParagraphs.forEach((p) => p.remove());\n        currentParagraph.remove();\n\n        // Position cursor at end of code block\n        codeNode.selectEnd();\n      });\n    });\n  }, [editor]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/CodeHighlightPlugin.tsx",
    "content": "import { useEffect } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { registerCodeHighlighting } from '@lexical/code';\n\nexport function CodeHighlightPlugin() {\n  const [editor] = useLexicalComposerContext();\n\n  useEffect(() => {\n    return registerCodeHighlighting(editor);\n  }, [editor]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/CollapsibleSectionHeader.tsx",
    "content": "import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';\nimport { useEffect, useState } from 'react';\nimport type { Icon } from '@phosphor-icons/react';\nimport { CaretDownIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nconst STORAGE_KEY_PREFIX = 'vibe.ui.collapsible.';\n\nfunction getInitialExpanded(\n  persistKey: string | undefined,\n  defaultExpanded: boolean\n) {\n  if (!persistKey || typeof window === 'undefined') return defaultExpanded;\n  try {\n    const stored = window.localStorage.getItem(\n      `${STORAGE_KEY_PREFIX}${persistKey}`\n    );\n    if (stored == null) return defaultExpanded;\n    return stored === 'true';\n  } catch {\n    return defaultExpanded;\n  }\n}\n\nexport type SectionAction = {\n  icon: Icon;\n  onClick: () => void;\n  isActive?: boolean;\n};\n\ninterface CollapsibleSectionHeaderProps {\n  persistKey?: string;\n  title: string;\n  defaultExpanded?: boolean;\n  collapsible?: boolean;\n  actions?: SectionAction[];\n  headerExtra?: ReactNode;\n  children?: ReactNode;\n  className?: string;\n}\n\nexport function CollapsibleSectionHeader({\n  persistKey,\n  title,\n  defaultExpanded = true,\n  collapsible = true,\n  actions = [],\n  headerExtra,\n  children,\n  className,\n}: CollapsibleSectionHeaderProps) {\n  const [expanded, setExpanded] = useState(() =>\n    getInitialExpanded(persistKey, defaultExpanded)\n  );\n\n  useEffect(() => {\n    setExpanded(getInitialExpanded(persistKey, defaultExpanded));\n  }, [persistKey, defaultExpanded]);\n\n  useEffect(() => {\n    if (!persistKey) return;\n    try {\n      window.localStorage.setItem(\n        `${STORAGE_KEY_PREFIX}${persistKey}`,\n        String(expanded)\n      );\n    } catch {\n      // Ignore localStorage failures (private mode/quota/security errors).\n    }\n  }, [persistKey, expanded]);\n\n  const handleActionClick = (\n    e: MouseEvent<HTMLSpanElement>,\n    onClick: () => void\n  ) => {\n    e.stopPropagation();\n    onClick();\n  };\n\n  const handleActionKeyDown = (\n    e: KeyboardEvent<HTMLSpanElement>,\n    onClick: () => void\n  ) => {\n    if (e.key !== 'Enter' && e.key !== ' ') return;\n    e.preventDefault();\n    e.stopPropagation();\n    onClick();\n  };\n\n  const isExpanded = collapsible ? expanded : true;\n\n  const headerContent = (\n    <>\n      <span className=\"font-medium truncate text-normal\">{title}</span>\n      <div className=\"flex items-center gap-half\">\n        {headerExtra}\n        {actions.map((action, index) => {\n          const ActionIcon = action.icon;\n          return (\n            <span\n              key={index}\n              role=\"button\"\n              tabIndex={0}\n              onClick={(e) => handleActionClick(e, action.onClick)}\n              onKeyDown={(e) => handleActionKeyDown(e, action.onClick)}\n              className={cn(\n                'hover:text-normal',\n                action.isActive ? 'text-brand' : 'text-low'\n              )}\n            >\n              <ActionIcon className=\"size-icon-xs\" weight=\"bold\" />\n            </span>\n          );\n        })}\n        {collapsible && (\n          <CaretDownIcon\n            weight=\"fill\"\n            className={cn(\n              'size-icon-xs text-low transition-transform',\n              !expanded && '-rotate-90'\n            )}\n          />\n        )}\n      </div>\n    </>\n  );\n\n  return (\n    <div className={cn('flex flex-col h-full min-h-0', className)}>\n      <div className=\"\">\n        {collapsible ? (\n          <button\n            type=\"button\"\n            onClick={() => setExpanded((prev) => !prev)}\n            className={cn(\n              'flex items-center justify-between w-full px-base py-half cursor-pointer'\n            )}\n          >\n            {headerContent}\n          </button>\n        ) : (\n          <div\n            className={cn(\n              'flex items-center justify-between w-full px-base py-half'\n            )}\n          >\n            {headerContent}\n          </div>\n        )}\n      </div>\n      {isExpanded && children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ColorPicker.tsx",
    "content": "import { forwardRef } from 'react';\nimport { cn } from '../lib/cn';\n\nexport const PRESET_COLORS = [\n  '0 84% 60%',\n  '24 95% 53%',\n  '45 93% 58%',\n  '158 64% 52%',\n  '200 98% 39%',\n  '271 81% 56%',\n  '330 81% 60%',\n  '183 74% 44%',\n  '262 52% 47%',\n  '142 71% 45%',\n  '17 88% 40%',\n  '231 48% 48%',\n] as const;\n\nexport interface InlineColorPickerProps {\n  value: string;\n  onChange: (color: string) => void;\n  colors?: readonly string[];\n  onKeyDown?: (e: React.KeyboardEvent) => void;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport const InlineColorPicker = forwardRef<\n  HTMLDivElement,\n  InlineColorPickerProps\n>(\n  (\n    { value, onChange, colors = PRESET_COLORS, onKeyDown, disabled, className },\n    ref\n  ) => {\n    const currentIndex = colors.indexOf(value);\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n      if (disabled) return;\n\n      if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n        e.preventDefault();\n        e.stopPropagation();\n        const newIndex =\n          currentIndex <= 0 ? colors.length - 1 : currentIndex - 1;\n        onChange(colors[newIndex]);\n      } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n        e.preventDefault();\n        e.stopPropagation();\n        const newIndex =\n          currentIndex >= colors.length - 1 ? 0 : currentIndex + 1;\n        onChange(colors[newIndex]);\n      }\n\n      onKeyDown?.(e);\n    };\n\n    return (\n      <div\n        ref={ref}\n        role=\"radiogroup\"\n        aria-label=\"Select a color\"\n        tabIndex={disabled ? -1 : 0}\n        onKeyDown={handleKeyDown}\n        className={cn('flex flex-wrap gap-half outline-none', className)}\n      >\n        {colors.map((color) => (\n          <button\n            key={color}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={color === value}\n            disabled={disabled}\n            onClick={() => onChange(color)}\n            className={cn(\n              'w-6 h-6 rounded-full transition-all',\n              color === value\n                ? 'ring-2 ring-brand ring-offset-1'\n                : 'hover:scale-110',\n              disabled && 'opacity-50 cursor-not-allowed hover:scale-100'\n            )}\n            style={{ backgroundColor: `hsl(${color})` }}\n          />\n        ))}\n      </div>\n    );\n  }\n);\n\nInlineColorPicker.displayName = 'InlineColorPicker';\n"
  },
  {
    "path": "packages/ui/src/components/Command.tsx",
    "content": "import * as React from 'react';\nimport { Command as CommandPrimitive } from 'cmdk';\nimport { MagnifyingGlassIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { Dialog, DialogContent, DialogTitle } from './Dialog';\n\nconst Command = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      'flex h-full w-full flex-col overflow-hidden bg-panel text-high',\n      className\n    )}\n    {...props}\n  />\n));\nCommand.displayName = CommandPrimitive.displayName;\n\ninterface CommandDialogProps extends React.ComponentProps<typeof Dialog> {\n  onCloseAutoFocus?: (event: Event) => void;\n  onOpenAutoFocus?: (event: Event) => void;\n}\n\nfunction CommandDialog({\n  children,\n  onCloseAutoFocus,\n  onOpenAutoFocus,\n  ...props\n}: CommandDialogProps) {\n  return (\n    <Dialog {...props}>\n      <DialogContent\n        className=\"overflow-hidden p-0\"\n        hideCloseButton\n        onCloseAutoFocus={onCloseAutoFocus}\n        onOpenAutoFocus={onOpenAutoFocus}\n        aria-describedby={undefined}\n      >\n        <DialogTitle className=\"sr-only\">Command Bar</DialogTitle>\n        {children}\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nconst CommandInput = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  <div\n    className=\"flex flex-1 items-center border-b border-border px-base\"\n    cmdk-input-wrapper=\"\"\n  >\n    <MagnifyingGlassIcon\n      className=\"mr-base h-4 w-4 shrink-0 text-low\"\n      weight=\"bold\"\n    />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        'flex h-10 w-full rounded-sm bg-transparent py-half text-base text-high outline-none',\n        'placeholder:text-low disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      {...props}\n    />\n  </div>\n));\nCommandInput.displayName = CommandPrimitive.Input.displayName;\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}\n    {...props}\n  />\n));\nCommandList.displayName = CommandPrimitive.List.displayName;\n\nconst CommandEmpty = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className=\"py-base text-center text-sm text-low\"\n    {...props}\n  />\n));\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName;\n\nconst CommandGroup = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      'overflow-hidden p-half text-high',\n      '[&_[cmdk-group-heading]]:px-base [&_[cmdk-group-heading]]:py-half',\n      '[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-low',\n      className\n    )}\n    {...props}\n  />\n));\nCommandGroup.displayName = CommandPrimitive.Group.displayName;\n\nconst CommandSeparator = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 h-px bg-border', className)}\n    {...props}\n  />\n));\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName;\n\nconst CommandItem = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex cursor-pointer select-none items-center gap-base rounded-sm px-base py-half text-sm outline-none',\n      'data-[selected=true]:bg-secondary data-[selected=true]:text-high',\n      'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',\n      className\n    )}\n    {...props}\n  />\n));\nCommandItem.displayName = CommandPrimitive.Item.displayName;\n\nfunction CommandShortcut({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) {\n  return (\n    <span\n      className={cn('ml-auto text-xs tracking-widest text-low', className)}\n      {...props}\n    />\n  );\n}\nCommandShortcut.displayName = 'CommandShortcut';\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n};\n"
  },
  {
    "path": "packages/ui/src/components/CommandBar.tsx",
    "content": "import {\n  ArrowDownIcon,\n  ArrowFatLineUpIcon,\n  ArrowUpIcon,\n  CaretLeftIcon,\n  CopyIcon,\n  FolderIcon,\n  GitBranchIcon,\n  MinusIcon,\n  PlusIcon,\n} from '@phosphor-icons/react';\nimport type { Icon } from '@phosphor-icons/react';\nimport type { ReactNode } from 'react';\nimport { useDeferredValue, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandShortcut,\n} from './Command';\n\ntype PriorityId = 'urgent' | 'high' | 'medium' | 'low';\n\nexport interface CommandBarAction {\n  id: string;\n  icon: Icon | string;\n  shortcut?: string;\n  variant?: 'default' | 'destructive' | string;\n  keywords?: string[];\n}\n\nexport interface CommandBarGroup<\n  TAction extends CommandBarAction,\n  TPageId extends string = string,\n> {\n  label: string;\n  items: CommandBarGroupItem<TAction, TPageId>[];\n}\n\nexport interface CommandBarPage<\n  TAction extends CommandBarAction,\n  TPageId extends string = string,\n> {\n  id: string;\n  title?: string;\n  groups: CommandBarGroup<TAction, TPageId>[];\n}\n\nexport interface CommandBarStatusItem {\n  id: string;\n  name: string;\n  color: string;\n}\n\ninterface PageItem<TPageId extends string = string> {\n  type: 'page';\n  pageId: TPageId;\n  label: string;\n  icon: Icon;\n}\n\ninterface RepoItem {\n  type: 'repo';\n  repo: {\n    id: string;\n    display_name: string;\n  };\n}\n\ninterface BranchItem {\n  type: 'branch';\n  branch: {\n    name: string;\n    isCurrent: boolean;\n  };\n}\n\ninterface StatusItem {\n  type: 'status';\n  status: CommandBarStatusItem;\n}\n\ninterface PriorityItem {\n  type: 'priority';\n  priority: {\n    id: string | null;\n    name: string;\n  };\n}\n\ninterface CreateSubIssueItem {\n  type: 'createSubIssue';\n}\n\ninterface IssueItem {\n  type: 'issue';\n  issue: {\n    id: string;\n    simple_id: string;\n    title: string;\n    status_id: string;\n    priority?: string | null;\n  };\n}\n\ninterface ActionItem<TAction extends CommandBarAction> {\n  type: 'action';\n  action: TAction;\n}\n\nexport type CommandBarGroupItem<\n  TAction extends CommandBarAction,\n  TPageId extends string = string,\n> =\n  | PageItem<TPageId>\n  | RepoItem\n  | BranchItem\n  | StatusItem\n  | PriorityItem\n  | CreateSubIssueItem\n  | IssueItem\n  | ActionItem<TAction>;\n\ninterface CommandBarProps<\n  TAction extends CommandBarAction,\n  TPageId extends string = string,\n> {\n  page: CommandBarPage<TAction, TPageId>;\n  canGoBack: boolean;\n  onGoBack: () => void;\n  onSelect: (item: CommandBarGroupItem<TAction, TPageId>) => void;\n  getLabel: (action: TAction) => string;\n  search: string;\n  onSearchChange: (search: string) => void;\n  statuses?: CommandBarStatusItem[];\n  renderSpecialActionIcon?: (iconName: string) => ReactNode;\n}\n\nconst BRANCH_SEARCH_RESULT_LIMIT = 300;\n\nconst PRIORITY_CONFIG: Record<PriorityId, { icon: Icon; colorClass: string }> =\n  {\n    urgent: { icon: ArrowFatLineUpIcon, colorClass: 'text-error' },\n    high: { icon: ArrowUpIcon, colorClass: 'text-brand' },\n    medium: { icon: MinusIcon, colorClass: 'text-low' },\n    low: { icon: ArrowDownIcon, colorClass: 'text-success' },\n  };\n\nfunction getPriorityConfig(priorityId: string | null | undefined) {\n  if (!priorityId) return null;\n  if (priorityId in PRIORITY_CONFIG) {\n    return PRIORITY_CONFIG[priorityId as PriorityId];\n  }\n  return null;\n}\n\nfunction ActionItemIcon({\n  icon,\n  renderSpecialActionIcon,\n}: {\n  icon: Icon | string;\n  renderSpecialActionIcon?: (iconName: string) => ReactNode;\n}) {\n  if (typeof icon === 'string') {\n    if (icon === 'copy-icon') {\n      return <CopyIcon className=\"h-4 w-4\" weight=\"regular\" />;\n    }\n    const customIcon = renderSpecialActionIcon?.(icon);\n    return customIcon ? <>{customIcon}</> : null;\n  }\n\n  const IconComponent = icon;\n  return <IconComponent className=\"h-4 w-4\" weight=\"regular\" />;\n}\n\nexport function CommandBar<\n  TAction extends CommandBarAction,\n  TPageId extends string = string,\n>({\n  page,\n  canGoBack,\n  onGoBack,\n  onSelect,\n  getLabel,\n  search,\n  onSearchChange,\n  statuses = [],\n  renderSpecialActionIcon,\n}: CommandBarProps<TAction, TPageId>) {\n  const { t } = useTranslation('common');\n  const deferredSearch = useDeferredValue(search);\n  const normalizedSearch = deferredSearch.trim().toLowerCase();\n  const isSearching = normalizedSearch.length > 0;\n\n  const filteredGroups = useMemo(() => {\n    if (!isSearching) {\n      return page.groups;\n    }\n\n    const isBranchSelectionPage = page.id === 'selectBranch';\n    const groups: CommandBarGroup<TAction, TPageId>[] = [];\n    let remainingBranchResults = BRANCH_SEARCH_RESULT_LIMIT;\n\n    for (const group of page.groups) {\n      const matchedItems: CommandBarGroupItem<TAction, TPageId>[] = [];\n\n      for (const item of group.items) {\n        const label = getItemSearchLabel(item, getLabel);\n        if (!label) continue;\n        if (!label.toLowerCase().includes(normalizedSearch)) continue;\n\n        if (isBranchSelectionPage && item.type === 'branch') {\n          if (remainingBranchResults <= 0) {\n            continue;\n          }\n          remainingBranchResults -= 1;\n        }\n\n        matchedItems.push(item);\n      }\n\n      if (matchedItems.length > 0) {\n        groups.push({\n          label: group.label,\n          items: matchedItems,\n        });\n      }\n\n      if (isBranchSelectionPage && remainingBranchResults <= 0) {\n        break;\n      }\n    }\n\n    return groups;\n  }, [getLabel, isSearching, normalizedSearch, page.groups, page.id]);\n\n  return (\n    <Command\n      className=\"rounded-sm border border-border [&_[cmdk-group-heading]]:px-base [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-low [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-half [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-base [&_[cmdk-item]]:py-half\"\n      shouldFilter={false}\n      loop\n    >\n      <div className=\"flex items-center border-b border-border\">\n        <CommandInput\n          placeholder={page.title || t('commandBar.defaultPlaceholder')}\n          value={search}\n          onValueChange={onSearchChange}\n        />\n      </div>\n      <CommandList>\n        <CommandEmpty>{t('commandBar.noResults')}</CommandEmpty>\n        {canGoBack && !search && (\n          <CommandGroup>\n            <CommandItem value=\"__back__\" onSelect={onGoBack}>\n              <CaretLeftIcon className=\"h-4 w-4\" weight=\"bold\" />\n              <span>{t('commandBar.back')}</span>\n            </CommandItem>\n          </CommandGroup>\n        )}\n        {filteredGroups.map((group) => (\n          <CommandGroup key={group.label} heading={group.label}>\n            {group.items.map((item) => {\n              if (item.type === 'page') {\n                const IconComponent = item.icon;\n                return (\n                  <CommandItem\n                    key={item.pageId}\n                    value={item.pageId}\n                    onSelect={() => onSelect(item)}\n                  >\n                    <IconComponent className=\"h-4 w-4\" weight=\"regular\" />\n                    <span>{item.label}</span>\n                  </CommandItem>\n                );\n              }\n\n              if (item.type === 'repo') {\n                return (\n                  <CommandItem\n                    key={item.repo.id}\n                    value={`${item.repo.id} ${item.repo.display_name}`}\n                    onSelect={() => onSelect(item)}\n                  >\n                    <FolderIcon className=\"h-4 w-4\" weight=\"regular\" />\n                    <span>{item.repo.display_name}</span>\n                  </CommandItem>\n                );\n              }\n\n              if (item.type === 'branch') {\n                return (\n                  <CommandItem\n                    key={item.branch.name}\n                    value={item.branch.name}\n                    onSelect={() => onSelect(item)}\n                  >\n                    <GitBranchIcon className=\"h-4 w-4\" weight=\"regular\" />\n                    <span>{item.branch.name}</span>\n                    {item.branch.isCurrent && (\n                      <span className=\"ml-auto text-xs capitalize text-low\">\n                        {t('branchSelector.badges.current')}\n                      </span>\n                    )}\n                  </CommandItem>\n                );\n              }\n\n              if (item.type === 'status') {\n                return (\n                  <CommandItem\n                    key={item.status.id}\n                    value={`${item.status.id} ${item.status.name}`}\n                    onSelect={() => onSelect(item)}\n                  >\n                    <div\n                      className=\"h-4 w-4 rounded-full shrink-0\"\n                      style={{ backgroundColor: `hsl(${item.status.color})` }}\n                    />\n                    <span>{item.status.name}</span>\n                  </CommandItem>\n                );\n              }\n\n              if (item.type === 'priority') {\n                const config = getPriorityConfig(item.priority.id);\n                const IconComponent = config?.icon;\n                return (\n                  <CommandItem\n                    key={item.priority.id ?? 'no-priority'}\n                    value={`${item.priority.id ?? 'none'} ${item.priority.name}`}\n                    onSelect={() => onSelect(item)}\n                  >\n                    {IconComponent && (\n                      <IconComponent\n                        className={`h-4 w-4 ${config?.colorClass}`}\n                        weight=\"bold\"\n                      />\n                    )}\n                    <span>{item.priority.name}</span>\n                  </CommandItem>\n                );\n              }\n\n              if (item.type === 'createSubIssue') {\n                return (\n                  <CommandItem\n                    key=\"create-sub-issue\"\n                    value=\"create new issue\"\n                    onSelect={() => onSelect(item)}\n                  >\n                    <PlusIcon\n                      className=\"h-4 w-4 shrink-0 text-brand\"\n                      weight=\"bold\"\n                    />\n                    <span>{t('kanban.createNewIssue')}</span>\n                  </CommandItem>\n                );\n              }\n\n              if (item.type === 'issue') {\n                const config = getPriorityConfig(item.issue.priority ?? null);\n                const PriorityIconComponent = config?.icon;\n                const statusColor =\n                  statuses.find((status) => status.id === item.issue.status_id)\n                    ?.color ?? '0 0% 50%';\n                return (\n                  <CommandItem\n                    key={item.issue.id}\n                    value={`${item.issue.id} ${item.issue.simple_id} ${item.issue.title}`}\n                    onSelect={() => onSelect(item)}\n                  >\n                    {PriorityIconComponent && (\n                      <PriorityIconComponent\n                        className={`h-4 w-4 shrink-0 ${config?.colorClass}`}\n                        weight=\"bold\"\n                      />\n                    )}\n                    <span className=\"font-mono text-low shrink-0\">\n                      {item.issue.simple_id}\n                    </span>\n                    <div\n                      className=\"h-2 w-2 rounded-full shrink-0\"\n                      style={{ backgroundColor: `hsl(${statusColor})` }}\n                    />\n                    <span className=\"truncate\">{item.issue.title}</span>\n                  </CommandItem>\n                );\n              }\n\n              const label = getLabel(item.action);\n              return (\n                <CommandItem\n                  key={item.action.id}\n                  value={`${item.action.id} ${label}`}\n                  onSelect={() => onSelect(item)}\n                  className={\n                    item.action.variant === 'destructive'\n                      ? 'text-error'\n                      : undefined\n                  }\n                >\n                  <ActionItemIcon\n                    icon={item.action.icon}\n                    renderSpecialActionIcon={renderSpecialActionIcon}\n                  />\n                  <span>{label}</span>\n                  {item.action.shortcut && (\n                    <CommandShortcut>{item.action.shortcut}</CommandShortcut>\n                  )}\n                </CommandItem>\n              );\n            })}\n          </CommandGroup>\n        ))}\n      </CommandList>\n    </Command>\n  );\n}\n\nfunction getItemSearchLabel<\n  TAction extends CommandBarAction,\n  TPageId extends string,\n>(\n  item: CommandBarGroupItem<TAction, TPageId>,\n  getLabel: (action: TAction) => string\n) {\n  if (item.type === 'page') {\n    return `${item.pageId} ${item.label}`;\n  }\n  if (item.type === 'repo') {\n    return `${item.repo.id} ${item.repo.display_name}`;\n  }\n  if (item.type === 'branch') {\n    return item.branch.name;\n  }\n  if (item.type === 'status') {\n    return `${item.status.id} ${item.status.name}`;\n  }\n  if (item.type === 'priority') {\n    return `${item.priority.id ?? 'none'} ${item.priority.name}`;\n  }\n  if (item.type === 'issue') {\n    return `${item.issue.id} ${item.issue.simple_id} ${item.issue.title}`;\n  }\n  if (item.type === 'createSubIssue') {\n    return 'create new issue';\n  }\n  const keywords = item.action.keywords?.join(' ') ?? '';\n  return `${item.action.id} ${getLabel(item.action)} ${keywords}`.trim();\n}\n"
  },
  {
    "path": "packages/ui/src/components/CommentCard.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../lib/cn';\n\nexport type CommentCardVariant = 'user' | 'github' | 'input';\n\ninterface CommentCardProps {\n  /** Determines the visual styling */\n  variant: CommentCardVariant;\n  /** Main content (editor, text, etc.) */\n  children: ReactNode;\n  /** Optional header (author, timestamp) */\n  header?: ReactNode;\n  /** Optional action buttons */\n  actions?: ReactNode;\n  /** Additional className for the outer wrapper */\n  className?: string;\n}\n\nconst variantStyles: Record<CommentCardVariant, string> = {\n  user: 'bg-brand/20 border-brand',\n  github: 'bg-secondary border-border',\n  input: 'bg-brand/20 border-brand',\n};\n\n/**\n * Shared primitive for displaying comments in diff views.\n * Used by ReviewCommentRenderer, GitHubCommentRenderer, and CommentWidgetLine.\n */\nexport function CommentCard({\n  variant,\n  children,\n  header,\n  actions,\n  className,\n}: CommentCardProps) {\n  return (\n    <div className=\"p-base bg-panel font-sans text-base\">\n      <div\n        className={cn(\n          'p-base rounded-sm border',\n          variantStyles[variant],\n          className\n        )}\n      >\n        {header && <div className=\"mb-half\">{header}</div>}\n        {children}\n        {actions && <div className=\"mt-half flex gap-half\">{actions}</div>}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ComponentInfoKeyboardPlugin.tsx",
    "content": "import { useEffect } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  KEY_BACKSPACE_COMMAND,\n  KEY_DELETE_COMMAND,\n  COMMAND_PRIORITY_LOW,\n  $getSelection,\n  $isNodeSelection,\n  type LexicalNode,\n} from 'lexical';\n\ntype ComponentInfoKeyboardPluginProps = {\n  isTargetNode: (node: LexicalNode) => boolean;\n};\n\nexport function ComponentInfoKeyboardPlugin({\n  isTargetNode,\n}: ComponentInfoKeyboardPluginProps) {\n  const [editor] = useLexicalComposerContext();\n\n  useEffect(() => {\n    const deleteSelectedNodes = (): boolean => {\n      const selection = $getSelection();\n      if (!$isNodeSelection(selection)) return false;\n\n      const nodes = selection.getNodes();\n      const targetNodes = nodes.filter(isTargetNode);\n\n      if (targetNodes.length === 0) return false;\n\n      for (const node of targetNodes) {\n        node.remove();\n      }\n\n      return true;\n    };\n\n    const unregisterBackspace = editor.registerCommand(\n      KEY_BACKSPACE_COMMAND,\n      () => deleteSelectedNodes(),\n      COMMAND_PRIORITY_LOW\n    );\n\n    const unregisterDelete = editor.registerCommand(\n      KEY_DELETE_COMMAND,\n      () => deleteSelectedNodes(),\n      COMMAND_PRIORITY_LOW\n    );\n\n    return () => {\n      unregisterBackspace();\n      unregisterDelete();\n    };\n  }, [editor, isTargetNode]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/ConfirmDialog.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from './KeyboardDialog';\nimport { Button } from './Button';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport {\n  WarningIcon,\n  InfoIcon,\n  CheckCircleIcon,\n  XCircleIcon,\n} from '@phosphor-icons/react';\nimport { defineModal, type ConfirmResult } from '../lib/modals';\n\nexport interface ConfirmDialogProps {\n  title: string;\n  message: string;\n  confirmText?: string;\n  cancelText?: string;\n  variant?: 'default' | 'destructive' | 'info' | 'success';\n  icon?: boolean;\n  showCancelButton?: boolean;\n}\n\nconst ConfirmDialogImpl = NiceModal.create<ConfirmDialogProps>((props) => {\n  const { t } = useTranslation(['tasks', 'common']);\n  const modal = useModal();\n  const {\n    title,\n    message,\n    confirmText = t('common:confirm.defaultConfirm'),\n    cancelText = t('common:confirm.defaultCancel'),\n    variant = 'default',\n    icon = true,\n    showCancelButton = true,\n  } = props;\n\n  const handleConfirm = () => {\n    modal.resolve('confirmed' as ConfirmResult);\n    modal.hide();\n  };\n\n  const handleCancel = () => {\n    modal.resolve('canceled' as ConfirmResult);\n    modal.hide();\n  };\n\n  const getIcon = () => {\n    if (!icon) return null;\n\n    const iconClass = 'h-6 w-6';\n\n    switch (variant) {\n      case 'destructive':\n        return <WarningIcon className={`${iconClass} text-destructive`} />;\n      case 'info':\n        return <InfoIcon className={`${iconClass} text-blue-500`} />;\n      case 'success':\n        return <CheckCircleIcon className={`${iconClass} text-green-500`} />;\n      default:\n        return <XCircleIcon className={`${iconClass} text-muted-foreground`} />;\n    }\n  };\n\n  const getConfirmButtonVariant = () => {\n    return variant === 'destructive' ? 'destructive' : 'default';\n  };\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleCancel}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader>\n          <div className=\"flex items-center gap-3\">\n            {getIcon()}\n            <DialogTitle>{title}</DialogTitle>\n          </div>\n          <DialogDescription className=\"text-left pt-2\">\n            {message}\n          </DialogDescription>\n        </DialogHeader>\n        {showCancelButton ? (\n          <DialogFooter className=\"gap-2\">\n            <Button variant=\"outline\" onClick={handleCancel}>\n              {cancelText}\n            </Button>\n            <Button variant={getConfirmButtonVariant()} onClick={handleConfirm}>\n              {confirmText}\n            </Button>\n          </DialogFooter>\n        ) : (\n          <div className=\"flex w-full\">\n            <Button\n              className=\"ml-auto\"\n              variant={getConfirmButtonVariant()}\n              onClick={handleConfirm}\n            >\n              {confirmText}\n            </Button>\n          </div>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const ConfirmDialog = defineModal<ConfirmDialogProps, ConfirmResult>(\n  ConfirmDialogImpl\n);\n"
  },
  {
    "path": "packages/ui/src/components/ContextBar.tsx",
    "content": "import {\n  forwardRef,\n  type CSSProperties,\n  type MouseEvent,\n  type ReactNode,\n} from 'react';\nimport type { Icon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\n\ninterface ContextBarButtonProps {\n  icon: Icon;\n  label: string;\n  iconClassName?: string;\n  onClick?: () => void;\n  disabled?: boolean;\n}\n\nconst ContextBarButton = forwardRef<HTMLButtonElement, ContextBarButtonProps>(\n  function ContextBarButton(\n    { icon: IconComponent, label, iconClassName, onClick, disabled },\n    ref\n  ) {\n    return (\n      <button\n        ref={ref}\n        type=\"button\"\n        className={cn(\n          'flex items-center justify-center transition-colors',\n          'drop-shadow-[2px_2px_4px_rgba(121,121,121,0.25)]',\n          'text-low group-hover:text-normal',\n          disabled && 'opacity-40'\n        )}\n        aria-label={label}\n        onClick={onClick}\n        disabled={disabled}\n      >\n        <IconComponent\n          className={cn('size-icon-base', iconClassName)}\n          weight=\"bold\"\n        />\n      </button>\n    );\n  }\n);\n\nfunction DragHandle({\n  onMouseDown,\n  isDragging,\n}: {\n  onMouseDown: (e: MouseEvent) => void;\n  isDragging: boolean;\n}) {\n  return (\n    <div\n      className={cn(\n        'flex justify-center py-half border-b',\n        isDragging ? 'cursor-grabbing' : 'cursor-grab'\n      )}\n      onMouseDown={onMouseDown}\n    >\n      <div className=\"flex gap-[2px] py-half\">\n        <span className=\"size-dot rounded-full bg-panel group-hover:bg-low transition\" />\n        <span className=\"size-dot rounded-full bg-panel group-hover:bg-low transition\" />\n        <span className=\"size-dot rounded-full bg-panel group-hover:bg-low transition\" />\n      </div>\n    </div>\n  );\n}\n\nexport interface ContextBarActionItem {\n  type: 'action';\n  key: string;\n  label: string;\n  tooltip?: string;\n  shortcut?: string;\n  icon?: Icon;\n  iconClassName?: string;\n  disabled?: boolean;\n  onClick?: () => void;\n  customContent?: ReactNode;\n}\n\nexport interface ContextBarDividerItem {\n  type: 'divider';\n  key: string;\n}\n\nexport type ContextBarRenderItem = ContextBarActionItem | ContextBarDividerItem;\n\nexport interface ContextBarProps {\n  style: CSSProperties;\n  isDragging: boolean;\n  onDragHandleMouseDown: (e: MouseEvent) => void;\n  primaryItems?: ContextBarRenderItem[];\n  secondaryItems?: ContextBarRenderItem[];\n}\n\nfunction renderContextBarItem(item: ContextBarRenderItem) {\n  if (item.type === 'divider') {\n    return <div key={item.key} className=\"h-px bg-border\" />;\n  }\n\n  if (item.customContent) {\n    return <div key={item.key}>{item.customContent}</div>;\n  }\n\n  if (!item.icon) {\n    return null;\n  }\n\n  const button = (\n    <ContextBarButton\n      icon={item.icon}\n      label={item.label}\n      iconClassName={item.iconClassName}\n      onClick={item.onClick}\n      disabled={item.disabled}\n    />\n  );\n\n  return (\n    <div key={item.key}>\n      {item.tooltip ? (\n        <Tooltip content={item.tooltip} shortcut={item.shortcut} side=\"left\">\n          {button}\n        </Tooltip>\n      ) : (\n        button\n      )}\n    </div>\n  );\n}\n\nexport function ContextBar({\n  style,\n  isDragging,\n  onDragHandleMouseDown,\n  primaryItems = [],\n  secondaryItems = [],\n}: ContextBarProps) {\n  return (\n    <div\n      className={cn(\n        'absolute z-50',\n        !isDragging && 'transition-all duration-300 ease-out'\n      )}\n      style={style}\n    >\n      <div className=\"group bg-secondary/50 backdrop-blur-sm border border-secondary rounded shadow-[inset_2px_2px_5px_rgba(255,255,255,0.03),_0_0_10px_rgba(0,0,0,0.2)] hover:shadow-[inset_2px_2px_5px_rgba(255,255,255,0.06),_0_0_10px_rgba(0,0,0,0.4)] transition-shadow px-base\">\n        <DragHandle\n          onMouseDown={onDragHandleMouseDown}\n          isDragging={isDragging}\n        />\n\n        <div className=\"flex flex-col py-base\">\n          {primaryItems.length > 0 && (\n            <div className=\"flex flex-col items-center gap-base\">\n              {primaryItems.map(renderContextBarItem)}\n            </div>\n          )}\n\n          {primaryItems.length > 0 && secondaryItems.length > 0 && (\n            <div className=\"h-px bg-border my-base\" />\n          )}\n\n          {secondaryItems.length > 0 && (\n            <div className=\"flex flex-col items-center gap-base\">\n              {secondaryItems.map(renderContextBarItem)}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ContextUsageGauge.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\n\nexport interface ContextUsageInfo {\n  total_tokens: number;\n  model_context_window: number;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nexport interface ContextUsageGaugeProps {\n  tokenUsageInfo?: ContextUsageInfo | null;\n  className?: string;\n}\n\nexport function ContextUsageGauge({\n  tokenUsageInfo,\n  className,\n}: ContextUsageGaugeProps) {\n  const { t } = useTranslation('common');\n  const { percentage, formattedUsed, formattedTotal, status } = useMemo(() => {\n    if (!tokenUsageInfo || tokenUsageInfo.model_context_window === 0) {\n      return {\n        percentage: 0,\n        formattedUsed: '0',\n        formattedTotal: '0',\n        status: 'empty' as const,\n      };\n    }\n\n    const pct = Math.min(\n      100,\n      (tokenUsageInfo.total_tokens / tokenUsageInfo.model_context_window) * 100\n    );\n\n    const formatTokens = (n: number) => {\n      if (n >= 1_000_000) {\n        const m = n / 1_000_000;\n        return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`;\n      }\n      if (n >= 1_000) return `${Math.round(n / 1_000)}K`;\n      return n.toString();\n    };\n\n    let statusValue: 'low' | 'medium' | 'high' | 'critical' | 'empty';\n    if (pct < 50) statusValue = 'low';\n    else if (pct < 75) statusValue = 'medium';\n    else if (pct < 90) statusValue = 'high';\n    else statusValue = 'critical';\n\n    return {\n      percentage: pct,\n      formattedUsed: formatTokens(tokenUsageInfo.total_tokens),\n      formattedTotal: formatTokens(tokenUsageInfo.model_context_window),\n      status: statusValue,\n    };\n  }, [tokenUsageInfo]);\n\n  const progress = clamp(percentage / 100, 0, 1);\n\n  const tooltip =\n    status === 'empty'\n      ? t('contextUsage.emptyTooltip')\n      : t('contextUsage.tooltip', {\n          percentage: Math.round(percentage),\n          used: formattedUsed,\n          total: formattedTotal,\n        });\n\n  const progressColor =\n    status === 'empty'\n      ? 'text-low/40'\n      : status === 'critical'\n        ? 'text-error'\n        : status === 'high'\n          ? 'text-brand-secondary'\n          : status === 'medium'\n            ? 'text-normal'\n            : 'text-low';\n\n  const radius = 8;\n  const strokeWidth = 2;\n  const circumference = 2 * Math.PI * radius;\n  const dashOffset = circumference * (1 - progress);\n\n  return (\n    <Tooltip content={tooltip} side=\"bottom\">\n      <div\n        className={cn(\n          'flex items-center justify-center rounded-sm p-half',\n          'hover:bg-panel transition-colors cursor-help',\n          className\n        )}\n        aria-label={\n          status === 'empty'\n            ? t('contextUsage.label')\n            : t('contextUsage.ariaLabel', {\n                percentage: Math.round(percentage),\n              })\n        }\n        role=\"img\"\n      >\n        <svg\n          viewBox=\"0 0 20 20\"\n          className=\"size-icon-base -rotate-90\"\n          aria-hidden=\"true\"\n        >\n          <circle\n            cx=\"10\"\n            cy=\"10\"\n            r={radius}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={strokeWidth}\n            className=\"text-border/60\"\n          />\n          <circle\n            cx=\"10\"\n            cy=\"10\"\n            r={radius}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={strokeWidth}\n            strokeLinecap=\"round\"\n            strokeDasharray={`${circumference} ${circumference}`}\n            strokeDashoffset={dashOffset}\n            className={cn(\n              progressColor,\n              'transition-all duration-500 ease-out'\n            )}\n          />\n        </svg>\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/CreateChatBox.tsx",
    "content": "import { type ReactNode, useRef } from 'react';\nimport { CheckIcon, PaperclipIcon, XIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { Checkbox } from './Checkbox';\nimport { ChatBoxBase, VisualVariant, type DropzoneProps } from './ChatBoxBase';\nimport { DropdownMenuItem, DropdownMenuLabel } from './Dropdown';\nimport { PrimaryButton } from './PrimaryButton';\nimport type { LocalAttachmentMetadata } from './WorkspaceContext';\nimport { ToolbarDropdown, ToolbarIconButton } from './Toolbar';\n\nexport interface EditorProps {\n  value: string;\n  onChange: (value: string) => void;\n}\n\nexport interface ModelSelectorProps<TExecutorConfig = unknown> {\n  onAdvancedSettings: () => void;\n  presets: string[];\n  selectedPreset: string | null;\n  onPresetSelect: (presetId: string | null) => void;\n  onOverrideChange: (partial: Partial<TExecutorConfig>) => void;\n  executorConfig: TExecutorConfig | null;\n  presetOptions: TExecutorConfig | null | undefined;\n}\n\nexport interface ExecutorProps<TExecutor extends string = string> {\n  selected: TExecutor | null;\n  options: TExecutor[];\n  onChange: (executor: TExecutor) => void;\n}\n\nexport interface SaveAsDefaultProps {\n  checked: boolean;\n  onChange: (checked: boolean) => void;\n  visible: boolean;\n}\n\nexport interface LinkedIssueBadgeProps {\n  simpleId: string;\n  title: string;\n  onRemove: () => void;\n}\n\nexport interface CreateChatBoxEditorRenderProps<\n  TExecutor extends string = string,\n> {\n  value: string;\n  onChange: (value: string) => void;\n  onCmdEnter: () => void;\n  disabled: boolean;\n  repoIds?: string[];\n  repoId?: string;\n  executor: TExecutor | null;\n  onPasteFiles?: (files: File[]) => void;\n  localAttachments?: LocalAttachmentMetadata[];\n}\n\ninterface CreateChatBoxProps<TExecutor extends string = string> {\n  editor: EditorProps;\n  renderEditor: (props: CreateChatBoxEditorRenderProps<TExecutor>) => ReactNode;\n  agentIcon?: ReactNode;\n  onSend: () => void;\n  isSending: boolean;\n  disabled?: boolean;\n  executor: ExecutorProps<TExecutor>;\n  formatExecutorLabel?: (executor: TExecutor) => string;\n  emptyExecutorLabel?: string;\n  saveAsDefault?: SaveAsDefaultProps;\n  error?: string | null;\n  repoIds?: string[];\n  repoId?: string;\n  modelSelector?: ReactNode;\n  onPasteFiles?: (files: File[]) => void;\n  localAttachments?: LocalAttachmentMetadata[];\n  dropzone?: DropzoneProps;\n  onEditRepos: () => void;\n  repoSummaryLabel: string;\n  repoSummaryTitle: string;\n  linkedIssue?: LinkedIssueBadgeProps | null;\n}\n\n/**\n * Lightweight chat box for create mode.\n * Supports sending and attachments - no queue, stop, or feedback functionality.\n */\nfunction defaultExecutorLabel(executor: string) {\n  return executor\n    .replace(/[_-]+/g, ' ')\n    .toLowerCase()\n    .replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\nexport function CreateChatBox<TExecutor extends string = string>({\n  editor,\n  renderEditor,\n  agentIcon,\n  onSend,\n  isSending,\n  disabled = false,\n  executor,\n  formatExecutorLabel = defaultExecutorLabel,\n  emptyExecutorLabel = 'Select Executor',\n  saveAsDefault,\n  error,\n  repoIds,\n  repoId,\n  modelSelector,\n  onPasteFiles,\n  localAttachments,\n  dropzone,\n  onEditRepos,\n  repoSummaryLabel,\n  repoSummaryTitle,\n  linkedIssue,\n}: CreateChatBoxProps<TExecutor>) {\n  const { t } = useTranslation(['common', 'tasks']);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const isDisabled = disabled || isSending;\n  const canSend = editor.value.trim().length > 0 && !isDisabled;\n\n  const handleCmdEnter = () => {\n    if (canSend) {\n      onSend();\n    }\n  };\n\n  const handleAttachClick = () => {\n    fileInputRef.current?.click();\n  };\n\n  const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = Array.from(e.target.files || []);\n    if (files.length > 0 && onPasteFiles) {\n      onPasteFiles(files);\n    }\n    e.target.value = '';\n  };\n\n  const executorLabel = executor.selected\n    ? formatExecutorLabel(executor.selected)\n    : emptyExecutorLabel;\n\n  return (\n    <ChatBoxBase\n      editor={renderEditor({\n        value: editor.value,\n        onChange: editor.onChange,\n        onCmdEnter: handleCmdEnter,\n        disabled: isDisabled,\n        repoIds,\n        repoId,\n        executor: executor.selected ?? null,\n        onPasteFiles,\n        localAttachments,\n      })}\n      error={error}\n      visualVariant={VisualVariant.NORMAL}\n      dropzone={dropzone}\n      modelSelector={modelSelector}\n      headerLeft={\n        <>\n          {agentIcon}\n          <ToolbarDropdown label={executorLabel} disabled={isDisabled}>\n            <DropdownMenuLabel>\n              {t('tasks:conversation.executors')}\n            </DropdownMenuLabel>\n            {executor.options.map((exec) => (\n              <DropdownMenuItem\n                key={exec}\n                icon={executor.selected === exec ? CheckIcon : undefined}\n                onClick={() => executor.onChange(exec)}\n              >\n                {formatExecutorLabel(exec)}\n              </DropdownMenuItem>\n            ))}\n          </ToolbarDropdown>\n          {saveAsDefault?.visible && (\n            <label className=\"flex items-center gap-1.5 text-sm text-low cursor-pointer ml-2\">\n              <Checkbox\n                checked={saveAsDefault.checked}\n                onCheckedChange={saveAsDefault.onChange}\n                className=\"h-3.5 w-3.5\"\n                disabled={isDisabled}\n              />\n              <span>{t('tasks:conversation.saveAsDefault')}</span>\n            </label>\n          )}\n        </>\n      }\n      footerLeft={\n        <>\n          <ToolbarIconButton\n            icon={PaperclipIcon}\n            aria-label={t('tasks:taskFormDialog.attachFile')}\n            title={t('tasks:taskFormDialog.attachFile')}\n            onClick={handleAttachClick}\n            disabled={isDisabled}\n          />\n          <input\n            ref={fileInputRef}\n            type=\"file\"\n            multiple\n            className=\"hidden\"\n            onChange={handleFileInputChange}\n          />\n          <button\n            type=\"button\"\n            onClick={onEditRepos}\n            title={repoSummaryTitle}\n            disabled={isDisabled}\n            className=\"max-w-[320px] truncate text-sm text-normal hover:text-high disabled:cursor-not-allowed disabled:opacity-50\"\n          >\n            {repoSummaryLabel}\n          </button>\n          {linkedIssue && (\n            <>\n              <div\n                className=\"inline-flex items-center gap-half whitespace-nowrap text-sm text-low\"\n                title={linkedIssue.title}\n              >\n                <span className=\"font-mono text-xs text-normal\">\n                  {linkedIssue.simpleId}\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={linkedIssue.onRemove}\n                  disabled={isDisabled}\n                  className=\"inline-flex items-center text-low hover:text-error transition-colors disabled:cursor-not-allowed disabled:opacity-50\"\n                  aria-label={`Remove link to ${linkedIssue.simpleId}`}\n                >\n                  <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n                </button>\n              </div>\n            </>\n          )}\n        </>\n      }\n      footerRight={\n        <PrimaryButton\n          onClick={onSend}\n          disabled={!canSend}\n          actionIcon={isSending ? 'spinner' : undefined}\n          value={\n            isSending\n              ? t('tasks:conversation.workspace.creating')\n              : t('tasks:conversation.workspace.create')\n          }\n        />\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/CreateRepoDialog.tsx",
    "content": "import { useState, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from './KeyboardDialog';\nimport { Button } from './Button';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport { FolderSimpleIcon, SpinnerIcon } from '@phosphor-icons/react';\nimport { defineModal } from '../lib/modals';\n\nexport interface CreateRepoDialogProps {\n  onBrowseForPath?: (currentPath: string) => Promise<string | null | undefined>;\n  onCreateRepo: (params: {\n    parentPath: string;\n    folderName: string;\n  }) => Promise<void>;\n}\n\nexport type CreateRepoDialogResult = {\n  action: 'created' | 'canceled';\n};\n\nconst CreateRepoDialogImpl = NiceModal.create<CreateRepoDialogProps>(\n  ({ onBrowseForPath, onCreateRepo }) => {\n    const { t } = useTranslation(['tasks', 'common']);\n    const modal = useModal();\n\n    const [name, setName] = useState('');\n    const [parentPath, setParentPath] = useState('');\n    const [isSubmitting, setIsSubmitting] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n\n    const handleBrowseForPath = useCallback(async () => {\n      if (!onBrowseForPath) return;\n      const selectedPath = await onBrowseForPath(parentPath);\n\n      if (selectedPath) {\n        setParentPath(selectedPath);\n      }\n    }, [onBrowseForPath, parentPath]);\n\n    const handleCreate = useCallback(async () => {\n      const trimmedName = name.trim();\n      if (!trimmedName) {\n        setError(t('git.createRepo.errors.nameRequired'));\n        return;\n      }\n\n      setIsSubmitting(true);\n      setError(null);\n      try {\n        await onCreateRepo({\n          parentPath: parentPath.trim() || '.',\n          folderName: trimmedName,\n        });\n        modal.resolve({ action: 'created' } as CreateRepoDialogResult);\n        modal.hide();\n      } catch (err) {\n        setError(\n          err instanceof Error\n            ? err.message\n            : t('git.createRepo.errors.createFailed')\n        );\n      } finally {\n        setIsSubmitting(false);\n      }\n    }, [name, onCreateRepo, parentPath, modal, t]);\n\n    const handleCancel = useCallback(() => {\n      modal.resolve({ action: 'canceled' } as CreateRepoDialogResult);\n      modal.hide();\n    }, [modal]);\n\n    const canSubmit = name.trim().length > 0;\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleCancel}>\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>{t('git.createRepo.dialog.title')}</DialogTitle>\n            <DialogDescription>\n              {t('git.createRepo.dialog.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"flex flex-col gap-4 py-4\">\n            {/* Name input */}\n            <div className=\"flex flex-col gap-2\">\n              <label className=\"text-sm font-medium\">\n                {t('git.createRepo.form.nameLabel')}\n              </label>\n              <input\n                type=\"text\"\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n                placeholder={t('git.createRepo.form.namePlaceholder')}\n                disabled={isSubmitting}\n                className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n              />\n            </div>\n\n            {/* Location input */}\n            <div className=\"flex flex-col gap-2\">\n              <label className=\"text-sm font-medium\">\n                {t('git.createRepo.form.locationLabel')}\n              </label>\n              <div className=\"flex gap-2\">\n                <input\n                  type=\"text\"\n                  value={parentPath}\n                  onChange={(e) => setParentPath(e.target.value)}\n                  placeholder={t('git.createRepo.form.locationPlaceholder')}\n                  disabled={isSubmitting}\n                  className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n                />\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  size=\"icon\"\n                  onClick={handleBrowseForPath}\n                  disabled={isSubmitting || !onBrowseForPath}\n                >\n                  <FolderSimpleIcon className=\"h-4 w-4\" weight=\"fill\" />\n                </Button>\n              </div>\n            </div>\n\n            {/* Error */}\n            {error && <p className=\"text-sm text-destructive\">{error}</p>}\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isSubmitting}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              onClick={handleCreate}\n              disabled={isSubmitting || !canSubmit}\n            >\n              {isSubmitting ? (\n                <>\n                  <SpinnerIcon className=\"h-4 w-4 animate-spin mr-2\" />\n                  {t('git.createRepo.states.creating')}\n                </>\n              ) : (\n                t('git.createRepo.buttons.createRepository')\n              )}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const CreateRepoDialog = defineModal<\n  CreateRepoDialogProps,\n  CreateRepoDialogResult\n>(CreateRepoDialogImpl);\n"
  },
  {
    "path": "packages/ui/src/components/DataTable.tsx",
    "content": "import * as React from 'react';\nimport {\n  Table,\n  TableHead,\n  TableBody,\n  TableRow,\n  TableHeaderCell,\n  TableCell,\n  TableEmpty,\n  TableLoading,\n} from './Table';\n\nexport type ColumnDef<T> = {\n  id: string;\n  header: React.ReactNode;\n  accessor: (row: T) => React.ReactNode;\n  className?: string;\n  headerClassName?: string;\n};\n\nexport interface DataTableProps<T> {\n  data: T[];\n  columns: ColumnDef<T>[];\n  keyExtractor: (row: T) => string;\n  onRowClick?: (row: T) => void;\n  isLoading?: boolean;\n  emptyState?: React.ReactNode;\n  headerContent?: React.ReactNode;\n}\n\nexport function DataTable<T>({\n  data,\n  columns,\n  keyExtractor,\n  onRowClick,\n  isLoading,\n  emptyState,\n  headerContent,\n}: DataTableProps<T>) {\n  const colSpan = columns.length;\n\n  return (\n    <Table>\n      <TableHead>\n        <tr>\n          {headerContent ? (\n            <TableHeaderCell colSpan={colSpan}>{headerContent}</TableHeaderCell>\n          ) : (\n            columns.map((column) => (\n              <TableHeaderCell\n                key={column.id}\n                className={column.headerClassName}\n              >\n                {column.header}\n              </TableHeaderCell>\n            ))\n          )}\n        </tr>\n      </TableHead>\n      <TableBody>\n        {isLoading ? (\n          <TableLoading colSpan={colSpan} />\n        ) : data.length === 0 ? (\n          <TableEmpty colSpan={colSpan}>{emptyState || 'No data'}</TableEmpty>\n        ) : (\n          data.map((row) => {\n            const key = keyExtractor(row);\n            const handleClick = onRowClick ? () => onRowClick(row) : undefined;\n            const handleKeyDown = onRowClick\n              ? (e: React.KeyboardEvent) => {\n                  if (e.key === 'Enter' || e.key === ' ') {\n                    e.preventDefault();\n                    onRowClick(row);\n                  }\n                }\n              : undefined;\n\n            return (\n              <TableRow\n                key={key}\n                clickable={!!onRowClick}\n                role={onRowClick ? 'button' : undefined}\n                tabIndex={onRowClick ? 0 : undefined}\n                onClick={handleClick}\n                onKeyDown={handleKeyDown}\n              >\n                {columns.map((column) => (\n                  <TableCell key={column.id} className={column.className}>\n                    {column.accessor(row)}\n                  </TableCell>\n                ))}\n              </TableRow>\n            );\n          })\n        )}\n      </TableBody>\n    </Table>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/DeleteWorkspaceDialog.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from './KeyboardDialog';\nimport { Button } from './Button';\nimport { Checkbox } from './Checkbox';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport {\n  WarningIcon,\n  GitBranchIcon,\n  LinkBreakIcon,\n} from '@phosphor-icons/react';\nimport { defineModal } from '../lib/modals';\n\nexport interface DeleteWorkspaceDialogProps {\n  branchName: string;\n  hasOpenPR?: boolean;\n  isLinkedToIssue?: boolean;\n  linkedIssueSimpleId?: string;\n}\n\nexport type DeleteWorkspaceDialogResult = {\n  action: 'confirmed' | 'canceled';\n  deleteBranches?: boolean;\n  unlinkFromIssue?: boolean;\n};\n\nconst DeleteWorkspaceDialogImpl = NiceModal.create<DeleteWorkspaceDialogProps>(\n  ({\n    branchName,\n    hasOpenPR = false,\n    isLinkedToIssue = false,\n    linkedIssueSimpleId,\n  }) => {\n    const modal = useModal();\n    const { t } = useTranslation();\n    const [deleteBranches, setDeleteBranches] = useState(false);\n    const [unlinkFromIssue, setUnlinkFromIssue] = useState(true);\n\n    const canDeleteBranches = !hasOpenPR;\n\n    const handleConfirm = () => {\n      modal.resolve({\n        action: 'confirmed',\n        deleteBranches: canDeleteBranches && deleteBranches,\n        unlinkFromIssue: isLinkedToIssue && unlinkFromIssue,\n      } as DeleteWorkspaceDialogResult);\n      modal.hide();\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as DeleteWorkspaceDialogResult);\n      modal.hide();\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleCancel}>\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <div className=\"flex items-center gap-3\">\n              <WarningIcon className=\"h-6 w-6 text-destructive\" />\n              <DialogTitle>\n                {t('workspaces.deleteDialog.title', 'Delete Workspace')}\n              </DialogTitle>\n            </div>\n            <DialogDescription className=\"text-left pt-2\">\n              {t(\n                'workspaces.deleteDialog.description',\n                'Are you sure you want to delete this workspace? This action cannot be undone.'\n              )}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"py-4 space-y-4\">\n            <div className=\"flex flex-col gap-1\">\n              <div\n                className={`flex items-center gap-3 text-sm font-medium select-none ${\n                  canDeleteBranches\n                    ? 'cursor-pointer'\n                    : 'text-muted-foreground cursor-not-allowed'\n                }`}\n                onClick={() => {\n                  if (canDeleteBranches) setDeleteBranches((v) => !v);\n                }}\n              >\n                <Checkbox\n                  checked={deleteBranches}\n                  disabled={!canDeleteBranches}\n                />\n                <span className=\"flex items-center gap-2\">\n                  <GitBranchIcon className=\"h-4 w-4\" />\n                  {t(\n                    'workspaces.deleteDialog.deleteBranchLabel',\n                    'Delete branch'\n                  )}{' '}\n                  <code className=\"rounded bg-muted px-1 py-0.5 text-xs font-mono\">\n                    {branchName}\n                  </code>\n                </span>\n              </div>\n              {hasOpenPR && (\n                <p className=\"text-xs text-muted-foreground pl-7\">\n                  {t(\n                    'workspaces.deleteDialog.cannotDeleteOpenPr',\n                    'Cannot delete branch while PR is open'\n                  )}\n                </p>\n              )}\n            </div>\n            {isLinkedToIssue && (\n              <div\n                className=\"flex items-center gap-3 text-sm font-medium cursor-pointer select-none\"\n                onClick={() => setUnlinkFromIssue((v) => !v)}\n              >\n                <Checkbox checked={unlinkFromIssue} />\n                <span className=\"flex items-center gap-2\">\n                  <LinkBreakIcon className=\"h-4 w-4\" />\n                  {t(\n                    'workspaces.deleteDialog.unlinkFromIssueLabel',\n                    'Also unlink from issue'\n                  )}\n                  {linkedIssueSimpleId && (\n                    <>\n                      {' '}\n                      <code className=\"rounded bg-muted px-1 py-0.5 text-xs font-mono\">\n                        {linkedIssueSimpleId}\n                      </code>\n                    </>\n                  )}\n                </span>\n              </div>\n            )}\n          </div>\n\n          <DialogFooter className=\"gap-2\">\n            <Button variant=\"outline\" onClick={handleCancel}>\n              {t('buttons.cancel')}\n            </Button>\n            <Button variant=\"destructive\" onClick={handleConfirm}>\n              {t('buttons.delete')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const DeleteWorkspaceDialog = defineModal<\n  DeleteWorkspaceDialogProps,\n  DeleteWorkspaceDialogResult\n>(DeleteWorkspaceDialogImpl);\n"
  },
  {
    "path": "packages/ui/src/components/Dialog.tsx",
    "content": "import * as React from 'react';\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { X } from 'lucide-react';\nimport { cn } from '../lib/cn';\n\nconst Dialog = DialogPrimitive.Root;\n\nconst DialogTrigger = DialogPrimitive.Trigger;\n\nconst DialogClose = DialogPrimitive.Close;\n\nfunction DialogPortal({\n  children,\n  ...props\n}: DialogPrimitive.DialogPortalProps) {\n  return <DialogPrimitive.Portal {...props}>{children}</DialogPrimitive.Portal>;\n}\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    data-tauri-drag-region\n    className={cn(\n      'fixed inset-0 z-[9998] bg-black/50',\n      'data-[state=open]:animate-in data-[state=closed]:animate-out',\n      'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n      className\n    )}\n    {...props}\n  />\n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {\n    hideCloseButton?: boolean;\n  }\n>(({ className, children, hideCloseButton = false, ...props }, ref) => {\n  return (\n    <DialogPortal>\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        ref={ref}\n        className={cn(\n          'fixed left-[50%] top-[50%] z-[9999] translate-x-[-50%] translate-y-[-50%]',\n          'w-full max-w-lg bg-panel border border-border rounded-sm shadow-lg',\n          'data-[state=open]:animate-in data-[state=closed]:animate-out',\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n          'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',\n          'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',\n          'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',\n          'duration-200',\n          className\n        )}\n        {...props}\n      >\n        {children}\n        {!hideCloseButton && (\n          <DialogPrimitive.Close className=\"absolute right-base top-base rounded-sm opacity-70 ring-offset-panel transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 disabled:pointer-events-none\">\n            <X className=\"h-4 w-4 text-normal\" />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  );\n});\nDialogContent.displayName = DialogPrimitive.Content.displayName;\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col space-y-1.5 text-center sm:text-left',\n      className\n    )}\n    {...props}\n  />\n);\nDialogHeader.displayName = 'DialogHeader';\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2',\n      className\n    )}\n    {...props}\n  />\n);\nDialogFooter.displayName = 'DialogFooter';\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      'text-lg font-semibold leading-none tracking-tight text-high',\n      className\n    )}\n    {...props}\n  />\n));\nDialogTitle.displayName = DialogPrimitive.Title.displayName;\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn('text-sm text-normal', className)}\n    {...props}\n  />\n));\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n  Dialog,\n  DialogPortal,\n  DialogOverlay,\n  DialogTrigger,\n  DialogClose,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription,\n};\n"
  },
  {
    "path": "packages/ui/src/components/Dropdown.tsx",
    "content": "import * as React from 'react';\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport {\n  CaretDownIcon,\n  CaretRightIcon,\n  CheckIcon,\n  MagnifyingGlassIcon,\n  type Icon,\n} from '@phosphor-icons/react';\n\nimport { cn } from '../lib/cn';\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\n// Direct passthrough - inherits asChild support from Radix automatically\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\n// Styled trigger button with icon/label/caret - use for default styled triggers\ninterface DropdownMenuTriggerButtonProps\n  extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> {\n  icon?: Icon;\n  label?: string;\n  showCaret?: boolean;\n  size?: 'default' | 'sm';\n}\n\nexport const dropdownMenuTriggerButtonClassName =\n  'flex items-center gap-half bg-secondary border border-border rounded-sm px-base py-half ' +\n  'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand ' +\n  'disabled:opacity-50 disabled:cursor-not-allowed min-w-0';\n\nconst sizeClasses = {\n  default: '',\n  sm: 'text-sm h-cta',\n} as const;\n\nconst DropdownMenuTriggerButton = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,\n  DropdownMenuTriggerButtonProps\n>(\n  (\n    {\n      className,\n      icon: IconComponent,\n      label,\n      showCaret = true,\n      size = 'default',\n      children,\n      ...props\n    },\n    ref\n  ) => (\n    <DropdownMenuPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        dropdownMenuTriggerButtonClassName,\n        sizeClasses[size],\n        className\n      )}\n      {...props}\n    >\n      {IconComponent && (\n        <IconComponent className=\"size-icon-xs text-normal\" weight=\"bold\" />\n      )}\n      {label && (\n        <span\n          className={cn(\n            'text-normal truncate flex-1 text-left',\n            size === 'default' && 'text-sm'\n          )}\n        >\n          {label}\n        </span>\n      )}\n      {children}\n      {showCaret && (\n        <CaretDownIcon\n          className=\"size-icon-2xs text-normal flex-shrink-0\"\n          weight=\"bold\"\n        />\n      )}\n    </DropdownMenuPrimitive.Trigger>\n  )\n);\nDropdownMenuTriggerButton.displayName = 'DropdownMenuTriggerButton';\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      'flex cursor-pointer select-none items-center gap-base px-base py-half mx-half rounded-sm',\n      'text-sm text-high outline-none',\n      'focus:bg-secondary data-[state=open]:bg-secondary',\n      '[&_svg]:pointer-events-none [&_svg]:size-icon-xs [&_svg]:shrink-0',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <CaretRightIcon className=\"ml-auto\" weight=\"bold\" />\n  </DropdownMenuPrimitive.SubTrigger>\n));\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.SubContent\n        ref={ref}\n        className={cn(\n          'z-[10000] min-w-[8rem] overflow-hidden',\n          'bg-panel border border-border rounded-sm py-half shadow-md',\n          'data-[state=open]:animate-in',\n          'data-[state=open]:fade-in-0',\n          'data-[state=open]:zoom-in-95',\n          'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',\n          'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n          'origin-[--radix-dropdown-menu-content-transform-origin]',\n          className\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  );\n});\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName;\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        ref={ref}\n        sideOffset={sideOffset}\n        className={cn(\n          'z-[10000] min-w-[8rem] overflow-y-auto overflow-x-hidden',\n          'max-h-[var(--radix-dropdown-menu-content-available-height)]',\n          'bg-panel border border-border rounded-sm py-half shadow-md',\n          'data-[state=open]:animate-in',\n          'data-[state=open]:fade-in-0',\n          'data-[state=open]:zoom-in-95',\n          'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',\n          'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n          'origin-[--radix-dropdown-menu-content-transform-origin]',\n          className\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  );\n});\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\ninterface DropdownMenuItemProps\n  extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> {\n  icon?: Icon;\n  badge?: React.ReactNode;\n  variant?: 'default' | 'destructive';\n  /** When true, prevents hover from stealing focus (useful for searchable dropdowns) */\n  preventFocusOnHover?: boolean;\n}\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  DropdownMenuItemProps\n>(\n  (\n    {\n      className,\n      icon: IconComponent,\n      badge,\n      variant = 'default',\n      preventFocusOnHover = false,\n      onPointerMove,\n      onPointerLeave,\n      children,\n      ...props\n    },\n    ref\n  ) => (\n    <DropdownMenuPrimitive.Item\n      ref={ref}\n      className={cn(\n        'relative flex cursor-pointer select-none items-center gap-base',\n        'px-base py-half mx-half rounded-sm outline-none transition-colors',\n        'focus:bg-secondary',\n        'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n        '[&_svg]:pointer-events-none [&_svg]:size-icon-xs [&_svg]:shrink-0',\n        variant === 'default' && 'text-high',\n        variant === 'destructive' && 'text-error',\n        className\n      )}\n      onPointerMove={\n        preventFocusOnHover\n          ? (e) => {\n              e.preventDefault();\n              onPointerMove?.(e);\n            }\n          : onPointerMove\n      }\n      onPointerLeave={\n        preventFocusOnHover\n          ? (e) => {\n              e.preventDefault();\n              onPointerLeave?.(e);\n            }\n          : onPointerLeave\n      }\n      {...props}\n    >\n      {IconComponent && <IconComponent weight=\"bold\" />}\n      <span className=\"flex-1 text-sm truncate\">{children}</span>\n      {badge && <span className=\"text-sm text-high text-right\">{badge}</span>}\n    </DropdownMenuPrimitive.Item>\n  )\n);\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-pointer select-none items-center gap-base',\n      'py-half px-base mx-half rounded-sm text-sm text-high',\n      'outline-none transition-colors focus:bg-secondary',\n      'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"flex-1\">{children}</span>\n    <DropdownMenuPrimitive.ItemIndicator>\n      <CheckIcon className=\"size-icon-xs text-brand shrink-0\" weight=\"bold\" />\n    </DropdownMenuPrimitive.ItemIndicator>\n  </DropdownMenuPrimitive.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName;\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-pointer select-none items-center',\n      'py-half pl-double pr-base mx-half rounded-sm text-sm text-high',\n      'outline-none transition-colors focus:bg-secondary',\n      'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-base flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <svg className=\"size-icon-xs fill-current\" viewBox=\"0 0 24 24\">\n          <circle cx=\"12\" cy=\"12\" r=\"6\" />\n        </svg>\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n));\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn('px-base py-half text-sm font-semibold text-low', className)}\n    {...props}\n  />\n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn('h-px bg-border my-half', className)}\n    {...props}\n  />\n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn('ml-auto text-xs tracking-widest text-low', className)}\n      {...props}\n    />\n  );\n};\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\n\ninterface DropdownMenuSearchInputProps\n  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {\n  onValueChange?: (value: string) => void;\n}\n\nconst DropdownMenuSearchInput = React.forwardRef<\n  HTMLInputElement,\n  DropdownMenuSearchInputProps\n>(({ className, onValueChange, onChange, ...props }, ref) => {\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    onChange?.(e);\n    onValueChange?.(e.target.value);\n  };\n\n  return (\n    <div className=\"flex items-center gap-base px-plusfifty py-base\">\n      <MagnifyingGlassIcon className=\"size-icon-xs text-low\" weight=\"bold\" />\n      <input\n        ref={ref}\n        type=\"text\"\n        className={cn(\n          'flex-1 bg-transparent text-sm text-low placeholder:text-low',\n          'outline-none border-none',\n          className\n        )}\n        onChange={handleChange}\n        autoFocus\n        {...props}\n      />\n    </div>\n  );\n});\nDropdownMenuSearchInput.displayName = 'DropdownMenuSearchInput';\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuTriggerButton,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n  DropdownMenuSearchInput,\n};\n"
  },
  {
    "path": "packages/ui/src/components/DropdownMenu.tsx",
    "content": "import * as React from 'react';\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\n\nimport { cn } from '../lib/cn';\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean;\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      'flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto\" />\n  </DropdownMenuPrimitive.SubTrigger>\n));\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Portal>\n    <DropdownMenuPrimitive.SubContent\n      ref={ref}\n      className={cn(\n        'bg-primary z-[10000] min-w-[8rem] overflow-hidden border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',\n        className\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n));\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName;\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <DropdownMenuPrimitive.Portal>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        'bg-primary z-[10000] max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden border p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',\n        className\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n));\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  />\n));\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-pointer select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName;\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-pointer select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-2 w-2 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n));\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      'px-2 py-1.5 text-sm font-semibold',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  />\n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 my-1 h-px bg-border', className)}\n    {...props}\n  />\n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn('ml-auto text-xs tracking-widest opacity-60', className)}\n      {...props}\n    />\n  );\n};\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n};\n"
  },
  {
    "path": "packages/ui/src/components/EmojiPicker.tsx",
    "content": "import { type ReactNode, useState } from 'react';\nimport { cn } from '../lib/cn';\nimport { Popover, PopoverContent, PopoverTrigger } from './Popover';\n\n// Common reaction emojis\nconst REACTION_EMOJIS = [\n  '👍',\n  '👎',\n  '❤️',\n  '😄',\n  '😢',\n  '🎉',\n  '🚀',\n  '👀',\n  '🔥',\n  '💯',\n  '✅',\n  '❌',\n  '🤔',\n  '👏',\n  '💪',\n  '🙌',\n];\n\ninterface EmojiPickerProps {\n  onSelect: (emoji: string) => void;\n  children: ReactNode;\n}\n\nexport function EmojiPicker({ onSelect, children }: EmojiPickerProps) {\n  const [open, setOpen] = useState(false);\n\n  const handleSelect = (emoji: string) => {\n    onSelect(emoji);\n    setOpen(false);\n  };\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>{children}</PopoverTrigger>\n      <PopoverContent\n        sideOffset={4}\n        className={cn(\n          'w-auto bg-panel border border-border rounded-sm p-base shadow-md',\n          'data-[state=open]:animate-in',\n          'data-[state=open]:fade-in-0',\n          'data-[state=open]:zoom-in-95',\n          'data-[side=bottom]:slide-in-from-top-2',\n          'data-[side=top]:slide-in-from-bottom-2',\n          'origin-[--radix-popover-content-transform-origin]'\n        )}\n      >\n        <div className=\"grid grid-cols-8 gap-half\">\n          {REACTION_EMOJIS.map((emoji) => (\n            <button\n              key={emoji}\n              type=\"button\"\n              onClick={() => handleSelect(emoji)}\n              className={cn(\n                'size-7 flex items-center justify-center rounded-sm',\n                'hover:bg-secondary transition-colors',\n                'text-base color-emoji'\n              )}\n            >\n              {emoji}\n            </button>\n          ))}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ErrorAlert.tsx",
    "content": "import { XIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nconst splitLines = (value: string): string[] => value.split(/\\r\\n|\\r|\\n/);\n\ninterface ErrorAlertProps {\n  message: string;\n  className?: string;\n  onDismiss?: () => void;\n  dismissLabel?: string;\n}\n\nexport function ErrorAlert({\n  message,\n  className,\n  onDismiss,\n  dismissLabel,\n}: ErrorAlertProps) {\n  return (\n    <div\n      role=\"alert\"\n      className={cn(\n        'relative w-full rounded-sm border border-error bg-error/10 px-base py-half text-sm text-error',\n        className\n      )}\n    >\n      <div className={cn('leading-relaxed', onDismiss && 'pr-double')}>\n        {splitLines(message).map((line, i, lines) => (\n          <span key={i}>\n            {line}\n            {i < lines.length - 1 && <br />}\n          </span>\n        ))}\n      </div>\n      {onDismiss && (\n        <button\n          type=\"button\"\n          onClick={onDismiss}\n          aria-label={dismissLabel ?? 'Dismiss error'}\n          className=\"absolute right-half top-half rounded-sm p-[2px] text-error/90 hover:bg-error/15 hover:text-error transition-colors\"\n        >\n          <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ErrorDialog.tsx",
    "content": "import NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport { WarningIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from './Dialog';\nimport { Button } from './Button';\nimport { defineModal } from '../lib/modals';\n\nexport interface ErrorDialogProps {\n  title: string;\n  message: string;\n  buttonText?: string;\n}\n\nconst ErrorDialogImpl = NiceModal.create<ErrorDialogProps>((props) => {\n  const { t } = useTranslation('common');\n  const modal = useModal();\n  const { title, message, buttonText = t('ok') } = props;\n\n  const handleDismiss = () => {\n    modal.resolve();\n    modal.hide();\n  };\n\n  return (\n    <Dialog\n      open={modal.visible}\n      onOpenChange={(open) => !open && handleDismiss()}\n    >\n      <DialogContent\n        className=\"sm:max-w-[425px] p-double\"\n        style={{ zIndex: 10001 }}\n      >\n        <DialogHeader>\n          <div className=\"flex items-center gap-3\">\n            <WarningIcon className=\"h-6 w-6 text-destructive\" />\n            <DialogTitle>{title}</DialogTitle>\n          </div>\n          <DialogDescription className=\"text-left pt-2\">\n            {message}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"flex w-full justify-end\">\n          <Button onClick={handleDismiss}>{buttonText}</Button>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const ErrorDialog = defineModal<ErrorDialogProps, void>(ErrorDialogImpl);\n"
  },
  {
    "path": "packages/ui/src/components/FileTagTypeaheadPlugin.tsx",
    "content": "import { useState, useCallback, useMemo, useEffect, useRef } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  LexicalTypeaheadMenuPlugin,\n  MenuOption,\n} from '@lexical/react/LexicalTypeaheadMenuPlugin';\nimport {\n  $createTextNode,\n  $getRoot,\n  $createParagraphNode,\n  $isParagraphNode,\n  KEY_ESCAPE_COMMAND,\n  COMMAND_PRIORITY_NORMAL,\n} from 'lexical';\nimport {\n  TagIcon,\n  FileTextIcon,\n  GearIcon,\n  PlusIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { useTypeaheadOpen } from './TypeaheadOpenContext';\nimport { TypeaheadMenu } from './TypeaheadMenu';\n\nconst MAX_FILE_RESULTS = 10;\n\ntype DiffFileResult = {\n  path: string;\n  name: string;\n  is_file: boolean;\n  match_type: 'FileName' | 'DirectoryName' | 'FullPath';\n  score: bigint;\n};\n\nexport type FileTagLike = {\n  id: string | number;\n  tag_name: string;\n  content: string;\n};\n\nexport type FileResultLike = {\n  path: string;\n  name: string;\n  is_file: boolean;\n  match_type: 'FileName' | 'DirectoryName' | 'FullPath';\n  score: bigint | number;\n};\n\nexport type SearchResultItemLike =\n  | {\n      type: 'tag';\n      tag: FileTagLike;\n    }\n  | {\n      type: 'file';\n      file: FileResultLike;\n    };\n\nexport type RepoLike = {\n  id: string;\n  name: string;\n  display_name?: string | null;\n};\n\ntype ChooseRepoResult = {\n  repoId: string;\n};\n\ntype SearchArgs = {\n  repoIds?: string[];\n};\n\ntype FileTagTypeaheadPluginProps = {\n  repoIds?: string[];\n  diffPaths?: Set<string>;\n  preferredRepoId?: string | null;\n  setPreferredRepoId?: (repoId: string | null) => void;\n  listRecentRepos?: () => Promise<RepoLike[]>;\n  getRepoById?: (repoId: string) => Promise<RepoLike | null>;\n  chooseRepo?: (repos: RepoLike[]) => Promise<ChooseRepoResult | undefined>;\n  onCreateTag?: () => Promise<boolean>;\n  searchTagsAndFiles?: (\n    query: string,\n    args: SearchArgs\n  ) => Promise<SearchResultItemLike[]>;\n};\n\nclass FileTagOption extends MenuOption {\n  item: SearchResultItemLike;\n\n  constructor(item: SearchResultItemLike) {\n    const key =\n      item.type === 'tag' ? `tag-${item.tag.id}` : `file-${item.file.path}`;\n    super(key);\n    this.item = item;\n  }\n}\n\nfunction getMatchingDiffFiles(\n  query: string,\n  diffPaths: Set<string>\n): DiffFileResult[] {\n  if (!query) return [];\n  const lowerQuery = query.toLowerCase();\n  return Array.from(diffPaths)\n    .filter((path) => {\n      const name = path.split('/').pop() || path;\n      return (\n        name.toLowerCase().includes(lowerQuery) ||\n        path.toLowerCase().includes(lowerQuery)\n      );\n    })\n    .map((path) => {\n      const name = path.split('/').pop() || path;\n      const nameMatches = name.toLowerCase().includes(lowerQuery);\n      return {\n        path,\n        name,\n        is_file: true,\n        match_type: nameMatches ? ('FileName' as const) : ('FullPath' as const),\n        // High score to rank diff files above server results.\n        score: BigInt(Number.MAX_SAFE_INTEGER),\n      };\n    });\n}\n\nfunction getRepoDisplayName(repo: RepoLike): string {\n  return repo.display_name || repo.name;\n}\n\nexport function FileTagTypeaheadPlugin({\n  repoIds,\n  diffPaths,\n  preferredRepoId,\n  setPreferredRepoId,\n  listRecentRepos,\n  getRepoById,\n  chooseRepo,\n  onCreateTag,\n  searchTagsAndFiles,\n}: FileTagTypeaheadPluginProps) {\n  const [editor] = useLexicalComposerContext();\n  const [options, setOptions] = useState<FileTagOption[]>([]);\n  const [recentRepoCatalog, setRecentRepoCatalog] = useState<RepoLike[] | null>(\n    null\n  );\n  const [preferredRepoName, setPreferredRepoName] = useState<string | null>(\n    null\n  );\n  const [showMissingRepoState, setShowMissingRepoState] = useState(false);\n  const [isChoosingRepo, setIsChoosingRepo] = useState(false);\n  const { t } = useTranslation('common');\n  const { setIsOpen } = useTypeaheadOpen();\n  const searchRequestRef = useRef(0);\n  const lastQueryRef = useRef<string | null>(null);\n\n  const effectiveDiffPaths = useMemo(\n    () => diffPaths ?? new Set<string>(),\n    [diffPaths]\n  );\n  const usePreferenceRepoSelection = repoIds === undefined;\n  const canManageRepoPreference =\n    usePreferenceRepoSelection &&\n    !!setPreferredRepoId &&\n    !!listRecentRepos &&\n    !!chooseRepo;\n\n  const effectiveRepoIds = useMemo(() => {\n    if (!usePreferenceRepoSelection) {\n      return repoIds;\n    }\n    return preferredRepoId ? [preferredRepoId] : undefined;\n  }, [preferredRepoId, repoIds, usePreferenceRepoSelection]);\n\n  const canSearchFiles = Boolean(effectiveRepoIds && effectiveRepoIds.length);\n\n  const loadRecentRepos = useCallback(\n    async (force = false): Promise<RepoLike[]> => {\n      if (!force && recentRepoCatalog !== null) {\n        return recentRepoCatalog;\n      }\n      if (!listRecentRepos) {\n        setRecentRepoCatalog([]);\n        return [];\n      }\n      const repos = await listRecentRepos();\n      setRecentRepoCatalog(repos);\n      return repos;\n    },\n    [listRecentRepos, recentRepoCatalog]\n  );\n\n  const runSearch = useCallback(\n    async (query: string, overrideRepoIds?: string[]) => {\n      const requestId = ++searchRequestRef.current;\n      const scopedRepoIds = overrideRepoIds ?? effectiveRepoIds;\n      const fileSearchEnabled = Boolean(\n        scopedRepoIds && scopedRepoIds.length > 0\n      );\n\n      const localFiles = fileSearchEnabled\n        ? getMatchingDiffFiles(query, effectiveDiffPaths)\n        : [];\n      const localFilePaths = new Set(localFiles.map((f) => f.path));\n\n      try {\n        const serverResults = searchTagsAndFiles\n          ? await searchTagsAndFiles(query, { repoIds: scopedRepoIds })\n          : [];\n\n        if (requestId !== searchRequestRef.current) {\n          return;\n        }\n\n        const tagResults = serverResults.filter((r) => r.type === 'tag');\n        const serverFileResults = serverResults\n          .filter((r) => r.type === 'file')\n          .filter((r) => !localFilePaths.has(r.file.path));\n\n        const limitedLocalFiles = localFiles.slice(0, MAX_FILE_RESULTS);\n        const remainingSlots = MAX_FILE_RESULTS - limitedLocalFiles.length;\n        const limitedServerFiles = serverFileResults.slice(0, remainingSlots);\n\n        const mergedResults: SearchResultItemLike[] = [\n          ...tagResults,\n          ...limitedLocalFiles.map((file) => ({\n            type: 'file' as const,\n            file,\n          })),\n          ...limitedServerFiles,\n        ];\n\n        setOptions(mergedResults.map((result) => new FileTagOption(result)));\n      } catch (err) {\n        if (requestId === searchRequestRef.current) {\n          setOptions([]);\n        }\n        console.error('Failed to search tags/files', {\n          requestId,\n          query,\n          err,\n        });\n      }\n    },\n    [effectiveDiffPaths, effectiveRepoIds, searchTagsAndFiles]\n  );\n\n  useEffect(() => {\n    if (!usePreferenceRepoSelection || !preferredRepoId || !listRecentRepos) {\n      if (!preferredRepoId) {\n        setPreferredRepoName(null);\n      }\n      return;\n    }\n\n    let canceled = false;\n    void loadRecentRepos()\n      .then(async (recentRepos) => {\n        if (canceled) return;\n\n        const matchingRecentRepo = recentRepos.find(\n          (repo) => repo.id === preferredRepoId\n        );\n        if (matchingRecentRepo) {\n          setPreferredRepoName(getRepoDisplayName(matchingRecentRepo));\n          setShowMissingRepoState(false);\n          return;\n        }\n\n        const existingRepo = getRepoById\n          ? await getRepoById(preferredRepoId)\n          : null;\n\n        if (canceled) return;\n        if (existingRepo) {\n          setPreferredRepoName(getRepoDisplayName(existingRepo));\n          setShowMissingRepoState(false);\n          return;\n        }\n\n        setPreferredRepoName(null);\n        setShowMissingRepoState(true);\n        setPreferredRepoId?.(null);\n\n        const queryToRefresh = lastQueryRef.current;\n        if (queryToRefresh !== null) {\n          void runSearch(queryToRefresh, []);\n        }\n      })\n      .catch((err) => {\n        console.error('Failed to load repos for file-search preference', err);\n      });\n\n    return () => {\n      canceled = true;\n    };\n  }, [\n    getRepoById,\n    listRecentRepos,\n    loadRecentRepos,\n    preferredRepoId,\n    runSearch,\n    setPreferredRepoId,\n    usePreferenceRepoSelection,\n  ]);\n\n  const handleChooseRepo = useCallback(async () => {\n    if (!chooseRepo || !setPreferredRepoId) {\n      return;\n    }\n\n    setIsChoosingRepo(true);\n    try {\n      const repos = await loadRecentRepos(true);\n      const repoResult = await chooseRepo(repos);\n\n      if (!repoResult?.repoId) {\n        return;\n      }\n\n      const selectedRepo = repos.find((repo) => repo.id === repoResult.repoId);\n      if (!selectedRepo) {\n        return;\n      }\n\n      setPreferredRepoId(selectedRepo.id);\n      setPreferredRepoName(getRepoDisplayName(selectedRepo));\n      setShowMissingRepoState(false);\n\n      const queryToRefresh = lastQueryRef.current;\n      if (queryToRefresh !== null) {\n        void runSearch(queryToRefresh, [selectedRepo.id]);\n      }\n    } catch (err) {\n      console.error('Failed to choose repo for file search', err);\n    } finally {\n      setIsChoosingRepo(false);\n    }\n  }, [chooseRepo, loadRecentRepos, runSearch, setPreferredRepoId]);\n\n  const closeTypeahead = useCallback(() => {\n    editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown'));\n  }, [editor]);\n\n  const handleCreateTag = useCallback(async () => {\n    closeTypeahead();\n    if (!onCreateTag) {\n      return;\n    }\n\n    try {\n      const saved = await onCreateTag();\n      if (saved) {\n        const queryToRefresh = lastQueryRef.current;\n        if (queryToRefresh !== null) {\n          void runSearch(queryToRefresh);\n        }\n      }\n    } catch {\n      // User cancelled.\n    }\n  }, [closeTypeahead, onCreateTag, runSearch]);\n\n  const onQueryChange = useCallback(\n    (query: string | null) => {\n      if (query === null) {\n        setOptions([]);\n        return;\n      }\n\n      lastQueryRef.current = query;\n      void runSearch(query);\n    },\n    [runSearch]\n  );\n\n  return (\n    <LexicalTypeaheadMenuPlugin<FileTagOption>\n      commandPriority={COMMAND_PRIORITY_NORMAL}\n      triggerFn={(text) => {\n        const match = /(?:^|\\s)@([^\\s@]*)$/.exec(text);\n        if (!match) return null;\n        const offset = match.index + match[0].indexOf('@');\n        return {\n          leadOffset: offset,\n          matchingString: match[1],\n          replaceableString: match[0].slice(match[0].indexOf('@')),\n        };\n      }}\n      options={options}\n      onQueryChange={onQueryChange}\n      onOpen={() => setIsOpen(true)}\n      onClose={() => setIsOpen(false)}\n      onSelectOption={(option, nodeToReplace, closeMenu) => {\n        editor.update(() => {\n          if (!nodeToReplace) return;\n\n          if (option.item.type === 'tag') {\n            const textToInsert = option.item.tag.content ?? '';\n            const textNode = $createTextNode(textToInsert);\n            nodeToReplace.replace(textNode);\n            textNode.select(textToInsert.length, textToInsert.length);\n          } else {\n            const fileName = option.item.file.name ?? '';\n            const fullPath = option.item.file.path ?? '';\n\n            const fileNameNode = $createTextNode(fileName);\n            fileNameNode.toggleFormat('code');\n            nodeToReplace.replace(fileNameNode);\n\n            const spaceNode = $createTextNode(' ');\n            fileNameNode.insertAfter(spaceNode);\n            spaceNode.setFormat(0);\n            spaceNode.select(1, 1);\n\n            const root = $getRoot();\n            const children = root.getChildren();\n            let pathAlreadyExists = false;\n\n            for (const child of children) {\n              if (!$isParagraphNode(child)) continue;\n\n              const textNodes = child.getAllTextNodes();\n              for (const textNode of textNodes) {\n                if (\n                  textNode.hasFormat('code') &&\n                  textNode.getTextContent() === fullPath\n                ) {\n                  pathAlreadyExists = true;\n                  break;\n                }\n              }\n              if (pathAlreadyExists) break;\n            }\n\n            if (!pathAlreadyExists && fullPath) {\n              const pathParagraph = $createParagraphNode();\n              const pathNode = $createTextNode(fullPath);\n              pathNode.toggleFormat('code');\n              pathParagraph.append(pathNode);\n\n              const trailingSpace = $createTextNode(' ');\n              pathParagraph.append(trailingSpace);\n              trailingSpace.setFormat(0);\n\n              root.append(pathParagraph);\n            }\n          }\n        });\n\n        closeMenu();\n      }}\n      menuRenderFn={(\n        anchorRef,\n        { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }\n      ) => {\n        if (!anchorRef.current) return null;\n\n        const tagResults = options.filter((r) => r.item.type === 'tag');\n        const fileResults = options.filter((r) => r.item.type === 'file');\n        const showChooseRepoControl =\n          canManageRepoPreference && !canSearchFiles;\n        const showSelectedRepoState = canManageRepoPreference && canSearchFiles;\n        const showFilesSection =\n          fileResults.length > 0 ||\n          showChooseRepoControl ||\n          showSelectedRepoState ||\n          showMissingRepoState;\n        const hasSearchResults =\n          tagResults.length > 0 || fileResults.length > 0;\n        const showGlobalEmptyState = !hasSearchResults && !showFilesSection;\n        const selectedRepoLabel = preferredRepoName ?? preferredRepoId;\n        const repoCtaLabel = showSelectedRepoState\n          ? t('typeahead.selectedRepo', {\n              repoName: selectedRepoLabel,\n            })\n          : t('typeahead.chooseRepo');\n\n        return createPortal(\n          <TypeaheadMenu\n            anchorEl={anchorRef.current}\n            editorEl={editor.getRootElement()}\n            onClickOutside={closeTypeahead}\n          >\n            <TypeaheadMenu.Header>\n              <TagIcon className=\"size-icon-xs\" weight=\"bold\" />\n              {t('typeahead.tags')}\n            </TypeaheadMenu.Header>\n\n            {showGlobalEmptyState ? (\n              <TypeaheadMenu.Empty>\n                {t('typeahead.noTagsOrFiles')}\n              </TypeaheadMenu.Empty>\n            ) : (\n              <TypeaheadMenu.ScrollArea>\n                <TypeaheadMenu.Action onClick={() => void handleCreateTag()}>\n                  <span className=\"flex items-center gap-half\">\n                    <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n                    <span>{t('typeahead.createTag')}</span>\n                  </span>\n                </TypeaheadMenu.Action>\n\n                {tagResults.map((option, index) => {\n                  if (option.item.type !== 'tag') return null;\n                  const tag = option.item.tag;\n                  return (\n                    <TypeaheadMenu.Item\n                      key={option.key}\n                      isSelected={index === selectedIndex}\n                      index={index}\n                      setHighlightedIndex={setHighlightedIndex}\n                      onClick={() => selectOptionAndCleanUp(option)}\n                    >\n                      <div className=\"flex items-center gap-half font-medium\">\n                        <TagIcon\n                          className=\"size-icon-xs shrink-0\"\n                          weight=\"bold\"\n                        />\n                        <span>@{tag.tag_name}</span>\n                      </div>\n                      {tag.content && (\n                        <div className=\"text-xs text-low truncate\">\n                          {tag.content.slice(0, 60)}\n                          {tag.content.length > 60 ? '...' : ''}\n                        </div>\n                      )}\n                    </TypeaheadMenu.Item>\n                  );\n                })}\n\n                {showFilesSection && (\n                  <>\n                    {tagResults.length > 0 && <TypeaheadMenu.Divider />}\n                    <TypeaheadMenu.SectionHeader>\n                      {t('typeahead.files')}\n                    </TypeaheadMenu.SectionHeader>\n                    {showMissingRepoState && (\n                      <TypeaheadMenu.Empty>\n                        {t('typeahead.missingRepo')}\n                      </TypeaheadMenu.Empty>\n                    )}\n                    {(showChooseRepoControl || showSelectedRepoState) && (\n                      <TypeaheadMenu.Action\n                        onClick={() => {\n                          void handleChooseRepo();\n                        }}\n                        disabled={isChoosingRepo}\n                      >\n                        <span className=\"flex items-center gap-half\">\n                          <GearIcon className=\"size-icon-xs\" weight=\"bold\" />\n                          <span>{repoCtaLabel}</span>\n                        </span>\n                      </TypeaheadMenu.Action>\n                    )}\n                    {fileResults.map((option) => {\n                      if (option.item.type !== 'file') return null;\n                      const index = options.indexOf(option);\n                      const file = option.item.file;\n                      return (\n                        <TypeaheadMenu.Item\n                          key={option.key}\n                          isSelected={index === selectedIndex}\n                          index={index}\n                          setHighlightedIndex={setHighlightedIndex}\n                          onClick={() => selectOptionAndCleanUp(option)}\n                        >\n                          <div className=\"flex items-center gap-half font-medium truncate\">\n                            <FileTextIcon\n                              className=\"size-icon-xs shrink-0\"\n                              weight=\"bold\"\n                            />\n                            <span>{file.name}</span>\n                          </div>\n                          <div className=\"text-xs text-low truncate\">\n                            {file.path}\n                          </div>\n                        </TypeaheadMenu.Item>\n                      );\n                    })}\n                  </>\n                )}\n              </TypeaheadMenu.ScrollArea>\n            )}\n          </TypeaheadMenu>,\n          document.body\n        );\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/FileTree.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  GithubLogoIcon,\n  CaretUpIcon,\n  CaretDownIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\nimport { FileTreeSearchBar } from './FileTreeSearchBar';\nimport { FileTreeNode, type FileTreeNodeItem } from './FileTreeNode';\n\nexport interface FileTreeViewNode extends FileTreeNodeItem {\n  children?: FileTreeViewNode[];\n}\n\ninterface FileTreeProps {\n  nodes: FileTreeViewNode[];\n  collapsedPaths: Set<string>;\n  onToggleExpand: (path: string) => void;\n  selectedPath?: string | null;\n  onSelectFile?: (path: string) => void;\n  onNodeRef?: (path: string, el: HTMLDivElement | null) => void;\n  renderFileIcon?: (fileName: string) => ReactNode;\n  searchQuery: string;\n  onSearchChange: (value: string) => void;\n  isAllExpanded: boolean;\n  onToggleExpandAll: () => void;\n  className?: string;\n  /** Whether to show GitHub comments */\n  showGitHubComments?: boolean;\n  /** Callback to toggle GitHub comments visibility */\n  onToggleGitHubComments?: (show: boolean) => void;\n  /** Function to get comment count for a file path (handles prefixed paths) */\n  getGitHubCommentCountForFile?: (filePath: string) => number;\n  /** Whether GitHub comments are currently loading */\n  isGitHubCommentsLoading?: boolean;\n  /** Callback to navigate between files with GitHub comments */\n  onNavigateComments?: (direction: 'prev' | 'next') => void;\n  /** Whether there are files with GitHub comments to navigate */\n  hasFilesWithComments?: boolean;\n}\n\nexport function FileTree({\n  nodes,\n  collapsedPaths,\n  onToggleExpand,\n  selectedPath,\n  onSelectFile,\n  onNodeRef,\n  searchQuery,\n  onSearchChange,\n  isAllExpanded,\n  onToggleExpandAll,\n  className,\n  showGitHubComments,\n  onToggleGitHubComments,\n  getGitHubCommentCountForFile,\n  isGitHubCommentsLoading,\n  onNavigateComments,\n  hasFilesWithComments,\n  renderFileIcon,\n}: FileTreeProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n  const renderNodes = (nodeList: FileTreeViewNode[], depth = 0) => {\n    return nodeList.map((node) => (\n      <div key={node.id}>\n        <FileTreeNode\n          ref={\n            node.type === 'file' && onNodeRef\n              ? (el) => onNodeRef(node.path, el)\n              : undefined\n          }\n          node={node}\n          depth={depth}\n          isExpanded={!collapsedPaths.has(node.path)}\n          isSelected={selectedPath === node.path}\n          onToggle={\n            node.type === 'folder' ? () => onToggleExpand(node.path) : undefined\n          }\n          onSelect={\n            node.type === 'file' && onSelectFile\n              ? () => onSelectFile(node.path)\n              : undefined\n          }\n          renderFileIcon={renderFileIcon}\n          commentCount={getGitHubCommentCountForFile?.(node.path)}\n          showCommentBadge={showGitHubComments}\n        />\n        {node.type === 'folder' &&\n          node.children &&\n          !collapsedPaths.has(node.path) &&\n          renderNodes(node.children, depth + 1)}\n      </div>\n    ));\n  };\n\n  return (\n    <div className={cn('flex-1 w-full bg-secondary flex flex-col', className)}>\n      <div className=\"px-base pt-base overflow-hidden\">\n        <div className=\"flex items-center gap-half\">\n          <div className=\"flex-1 min-w-0\">\n            <FileTreeSearchBar\n              searchQuery={searchQuery}\n              onSearchChange={onSearchChange}\n              isAllExpanded={isAllExpanded}\n              onToggleExpandAll={onToggleExpandAll}\n            />\n          </div>\n          {showGitHubComments && onNavigateComments && hasFilesWithComments && (\n            <>\n              <Tooltip content={t('common:fileTree.prevGitHubComment')}>\n                <button\n                  type=\"button\"\n                  onClick={() => onNavigateComments('prev')}\n                  className=\"p-1 rounded hover:bg-panel transition-colors shrink-0 text-low hover:text-normal\"\n                  aria-label={t('common:fileTree.prevGitHubComment')}\n                >\n                  <CaretUpIcon className=\"size-icon-sm\" />\n                </button>\n              </Tooltip>\n              <Tooltip content={t('common:fileTree.nextGitHubComment')}>\n                <button\n                  type=\"button\"\n                  onClick={() => onNavigateComments('next')}\n                  className=\"p-1 rounded hover:bg-panel transition-colors shrink-0 text-low hover:text-normal\"\n                  aria-label={t('common:fileTree.nextGitHubComment')}\n                >\n                  <CaretDownIcon className=\"size-icon-sm\" />\n                </button>\n              </Tooltip>\n            </>\n          )}\n          {onToggleGitHubComments && (\n            <Tooltip\n              content={\n                showGitHubComments\n                  ? t('common:fileTree.hideGitHubComments')\n                  : t('common:fileTree.showGitHubComments')\n              }\n            >\n              <button\n                type=\"button\"\n                onClick={() => onToggleGitHubComments(!showGitHubComments)}\n                className={cn(\n                  'p-1 rounded hover:bg-panel transition-colors shrink-0',\n                  showGitHubComments ? 'text-normal' : 'text-low',\n                  isGitHubCommentsLoading && 'opacity-50 animate-pulse'\n                )}\n                aria-label={\n                  showGitHubComments\n                    ? t('common:fileTree.hideGitHubComments')\n                    : t('common:fileTree.showGitHubComments')\n                }\n              >\n                <GithubLogoIcon className=\"size-icon-sm\" weight=\"fill\" />\n              </button>\n            </Tooltip>\n          )}\n        </div>\n      </div>\n      <div className=\"p-base flex-1 min-h-0 overflow-auto scrollbar-thin scrollbar-thumb-panel scrollbar-track-transparent\">\n        {nodes.length > 0 ? (\n          renderNodes(nodes)\n        ) : (\n          <div className=\"p-base text-low text-sm\">\n            {searchQuery ? t('common:fileTree.noResults') : 'No changed files'}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/FileTreeNode.tsx",
    "content": "import { forwardRef, type ReactNode } from 'react';\nimport {\n  CaretDownIcon,\n  CaretRightIcon,\n  FolderSimpleIcon,\n  GithubLogoIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nexport type FileTreeNodeType = 'file' | 'folder';\nexport type FileTreeNodeChangeKind =\n  | 'added'\n  | 'deleted'\n  | 'modified'\n  | 'renamed'\n  | 'copied'\n  | 'permissionChange';\n\nexport interface FileTreeNodeItem {\n  id: string;\n  name: string;\n  path: string;\n  type: FileTreeNodeType;\n  diff?: {\n    oldPath?: string | null;\n  } | null;\n  changeKind?: FileTreeNodeChangeKind;\n  additions?: number | null;\n  deletions?: number | null;\n}\n\ninterface FileTreeNodeProps {\n  node: FileTreeNodeItem;\n  depth: number;\n  isExpanded?: boolean;\n  isSelected?: boolean;\n  onToggle?: () => void;\n  onSelect?: () => void;\n  renderFileIcon?: (fileName: string) => ReactNode;\n  /** GitHub comment count for this file */\n  commentCount?: number;\n  /** Whether to show the comment badge */\n  showCommentBadge?: boolean;\n}\n\nexport const FileTreeNode = forwardRef<HTMLDivElement, FileTreeNodeProps>(\n  function FileTreeNode(\n    {\n      node,\n      depth,\n      isExpanded = false,\n      isSelected = false,\n      onToggle,\n      onSelect,\n      renderFileIcon,\n      commentCount,\n      showCommentBadge,\n    },\n    ref\n  ) {\n    const isFolder = node.type === 'folder';\n    const isDeleted = node.changeKind === 'deleted';\n    const isAdded = node.changeKind === 'added';\n    const isRenamed = node.changeKind === 'renamed';\n    const isCopied = node.changeKind === 'copied';\n    const fileIcon = isFolder ? null : renderFileIcon?.(node.name);\n\n    // Extract filename from path for renamed/copied display\n    const getFileName = (path: string) => path.split('/').pop() || path;\n\n    const handleClick = () => {\n      if (isFolder && onToggle) {\n        onToggle();\n      } else if (!isFolder && onSelect) {\n        onSelect();\n      }\n    };\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'flex items-center h-[26px] cursor-pointer text-low hover:bg-panel rounded',\n          'relative select-none',\n          isSelected && 'bg-panel text-normal'\n        )}\n        onClick={handleClick}\n      >\n        {/* Indentation guides */}\n        {depth > 0 && (\n          <div className=\"absolute left-0 top-0 bottom-0 flex\">\n            {Array.from({ length: depth }).map((_, i) => (\n              <div\n                key={i}\n                className=\"h-full w-3 flex justify-center\"\n                style={{ marginLeft: i === 0 ? '6px' : '0' }}\n              >\n                <div className=\"h-full border-l border-border\" />\n              </div>\n            ))}\n          </div>\n        )}\n\n        {/* Content with padding based on depth */}\n        <div\n          className=\"flex items-center gap-half flex-1 pr-base whitespace-nowrap\"\n          style={{ paddingLeft: `${depth * 12 + 6}px` }}\n        >\n          {/* Expand/collapse caret for folders */}\n          <span className=\"w-3 flex items-center justify-center shrink-0\">\n            {isFolder &&\n              (isExpanded ? (\n                <CaretDownIcon className=\"size-icon-xs\" weight=\"fill\" />\n              ) : (\n                <CaretRightIcon className=\"size-icon-xs\" weight=\"fill\" />\n              ))}\n          </span>\n\n          {/* Icon */}\n          <span className=\"shrink-0\">\n            {isFolder ? (\n              <FolderSimpleIcon className=\"size-icon-sm\" weight=\"fill\" />\n            ) : null}\n            {fileIcon}\n          </span>\n\n          {/* File/folder name - color based on change kind */}\n          <span\n            className={cn(\n              'text-sm',\n              isDeleted && 'text-error line-through',\n              isAdded && 'text-success'\n            )}\n          >\n            {node.name}\n          </span>\n\n          {/* Show old filename for renamed/copied files */}\n          {(isRenamed || isCopied) && node.diff?.oldPath && (\n            <span className=\"text-low text-sm shrink-0\">\n              ← {getFileName(node.diff.oldPath)}\n            </span>\n          )}\n\n          {/* Stats for files */}\n          {node.type === 'file' && (node.additions || node.deletions) && (\n            <span className=\"text-sm shrink-0 ml-base\">\n              {node.additions != null && node.additions > 0 && (\n                <span className=\"text-success\">+{node.additions}</span>\n              )}\n              {node.additions != null &&\n                node.additions > 0 &&\n                node.deletions != null &&\n                node.deletions > 0 &&\n                ' '}\n              {node.deletions != null && node.deletions > 0 && (\n                <span className=\"text-error\">-{node.deletions}</span>\n              )}\n            </span>\n          )}\n\n          {/* GitHub comment badge */}\n          {showCommentBadge &&\n            node.type === 'file' &&\n            commentCount != null &&\n            commentCount > 0 && (\n              <span className=\"inline-flex items-center gap-0.5 text-xs text-low shrink-0 ml-half\">\n                <GithubLogoIcon className=\"size-icon-xs\" weight=\"fill\" />\n                {commentCount}\n              </span>\n            )}\n        </div>\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "packages/ui/src/components/FileTreeSearchBar.tsx",
    "content": "import { ArrowsInSimpleIcon, ArrowsOutSimpleIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { InputField } from './InputField';\n\ninterface FileTreeSearchBarProps {\n  searchQuery: string;\n  onSearchChange: (value: string) => void;\n  isAllExpanded: boolean;\n  onToggleExpandAll: () => void;\n  className?: string;\n}\n\nexport function FileTreeSearchBar({\n  searchQuery,\n  onSearchChange,\n  isAllExpanded,\n  onToggleExpandAll,\n  className,\n}: FileTreeSearchBarProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n  const ExpandIcon = isAllExpanded ? ArrowsInSimpleIcon : ArrowsOutSimpleIcon;\n\n  return (\n    <InputField\n      value={searchQuery}\n      onChange={onSearchChange}\n      placeholder={t('common:fileTree.searchPlaceholder')}\n      variant=\"search\"\n      actionIcon={ExpandIcon}\n      onAction={onToggleExpandAll}\n      className={className}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/GitPanel.tsx",
    "content": "import { GitBranchIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { RepoCard, type RepoAction } from './RepoCard';\nimport { InputField } from './InputField';\nimport { ErrorAlert } from './ErrorAlert';\n\nexport interface RepoInfo {\n  id: string;\n  name: string;\n  targetBranch: string;\n  commitsAhead: number;\n  commitsBehind: number;\n  remoteCommitsAhead?: number;\n  prNumber?: number;\n  prUrl?: string;\n  prStatus?: 'open' | 'merged' | 'closed' | 'unknown';\n  showPushButton?: boolean;\n  isPushPending?: boolean;\n  isPushSuccess?: boolean;\n  isPushError?: boolean;\n  isTargetRemote?: boolean;\n}\n\ninterface GitPanelProps {\n  repos: RepoInfo[];\n  repoSelectedActions?: Record<string, RepoAction>;\n  workingBranchName: string;\n  onWorkingBranchNameChange: (name: string) => void;\n  onActionsClick?: (repoId: string, action: RepoAction) => void;\n  onRepoActionChange?: (repoId: string, action: RepoAction) => void;\n  onPushClick?: (repoId: string) => void;\n  onMoreClick?: (repoId: string) => void;\n  onAddRepo?: () => void;\n  className?: string;\n  error?: string | null;\n}\n\nexport function GitPanel({\n  repos,\n  repoSelectedActions,\n  workingBranchName,\n  onWorkingBranchNameChange,\n  onActionsClick,\n  onRepoActionChange,\n  onPushClick,\n  onMoreClick,\n  className,\n  error,\n}: GitPanelProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n\n  return (\n    <div\n      className={cn(\n        'flex flex-col flex-1 w-full bg-secondary text-low overflow-y-auto',\n        className\n      )}\n    >\n      {error && <ErrorAlert message={error} />}\n      <div className=\"gap-base px-base\">\n        {repos.map((repo) => (\n          <RepoCard\n            key={repo.id}\n            repoId={repo.id}\n            name={repo.name}\n            targetBranch={repo.targetBranch}\n            commitsAhead={repo.commitsAhead}\n            commitsBehind={repo.commitsBehind}\n            prNumber={repo.prNumber}\n            prUrl={repo.prUrl}\n            prStatus={repo.prStatus}\n            showPushButton={repo.showPushButton}\n            isPushPending={repo.isPushPending}\n            isPushSuccess={repo.isPushSuccess}\n            isPushError={repo.isPushError}\n            isTargetRemote={repo.isTargetRemote}\n            selectedAction={repoSelectedActions?.[repo.id] ?? 'pull-request'}\n            onSelectedActionChange={(action) =>\n              onRepoActionChange?.(repo.id, action)\n            }\n            onChangeTarget={() => onActionsClick?.(repo.id, 'change-target')}\n            onRebase={() => onActionsClick?.(repo.id, 'rebase')}\n            onActionsClick={(action) => onActionsClick?.(repo.id, action)}\n            onPushClick={() => onPushClick?.(repo.id)}\n            onMoreClick={() => onMoreClick?.(repo.id)}\n          />\n        ))}\n        <div className=\"bg-primary flex flex-col gap-base w-full p-base rounded-sm my-base\">\n          <div className=\"flex gap-base items-center\">\n            <GitBranchIcon className=\"size-icon-md text-base\" weight=\"fill\" />\n            <p className=\"font-medium truncate\">\n              {t('common:sections.workingBranch')}\n            </p>\n          </div>\n          <InputField\n            variant=\"editable\"\n            value={workingBranchName}\n            onChange={onWorkingBranchNameChange}\n            placeholder={t('gitPanel.advanced.placeholder')}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/GoogleLogo.tsx",
    "content": "import { cn } from '../lib/cn';\n\ninterface GoogleLogoProps {\n  className?: string;\n}\n\n/**\n * Google's official standard-color \"G\" logo.\n * Colors: Blue (#4285F4), Red (#EA4335), Yellow (#FBBC05), Green (#34A853)\n */\nexport function GoogleLogo({ className }: GoogleLogoProps) {\n  return (\n    <svg\n      className={cn('size-5', className)}\n      viewBox=\"0 0 48 48\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      aria-hidden=\"true\"\n    >\n      <path\n        fill=\"#EA4335\"\n        d=\"M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z\"\n      />\n      <path\n        fill=\"#4285F4\"\n        d=\"M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z\"\n      />\n      <path\n        fill=\"#FBBC05\"\n        d=\"M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z\"\n      />\n      <path\n        fill=\"#34A853\"\n        d=\"M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z\"\n      />\n      <path fill=\"none\" d=\"M0 0h48v48H0z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/GuideDialogShell.tsx",
    "content": "import { useState, type ReactNode } from 'react';\nimport { CaretLeftIcon, XIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nexport interface GuideDialogTopic {\n  id: string;\n  title: string;\n  content: ReactNode;\n  imageSrc?: string;\n  imageAlt?: string;\n}\n\ninterface GuideDialogShellProps {\n  topics: GuideDialogTopic[];\n  closeLabel: string;\n  onClose: () => void;\n  className?: string;\n}\n\nexport function GuideDialogShell({\n  topics,\n  closeLabel,\n  onClose,\n  className,\n}: GuideDialogShellProps) {\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const [mobileShowContent, setMobileShowContent] = useState(false);\n\n  if (topics.length === 0) {\n    return null;\n  }\n\n  const selectedTopic = topics[selectedIndex] ?? topics[0];\n\n  return (\n    <>\n      <div\n        data-tauri-drag-region\n        className=\"fixed inset-0 z-[9998] bg-black/50 animate-in fade-in-0 duration-200\"\n        onClick={onClose}\n      />\n      {/* Dialog wrapper - handles positioning */}\n      <div\n        className={cn(\n          'fixed z-[9999]',\n          // Mobile: full screen\n          'inset-0',\n          // Desktop: centered with fixed size\n          'md:inset-auto md:left-1/2 md:top-1/2 md:-translate-x-1/2 md:-translate-y-1/2'\n        )}\n      >\n        <div\n          className={cn(\n            'h-full w-full flex overflow-hidden',\n            'bg-panel/95 backdrop-blur-sm shadow-lg',\n            'animate-in fade-in-0 slide-in-from-bottom-4 duration-200',\n            // Mobile: full screen, no rounded corners\n            'rounded-none border-0',\n            // Desktop: fixed size with rounded corners\n            'md:w-[800px] md:h-[600px] md:rounded-sm md:border md:border-border/50',\n            className\n          )}\n        >\n          {/* Sidebar - hidden on mobile when showing content */}\n          <div\n            className={cn(\n              'bg-secondary/80 border-r border-border/50 flex flex-col',\n              // Mobile: full width, hidden when showing content\n              'w-full',\n              mobileShowContent && 'hidden',\n              // Desktop: fixed width sidebar, always visible\n              'md:w-52 md:block'\n            )}\n          >\n            {/* Header with mobile close button */}\n            <div className=\"p-3 flex items-center justify-between md:hidden\">\n              <span className=\"text-sm font-medium text-high\">Topics</span>\n              <button\n                onClick={onClose}\n                className=\"p-1 rounded-sm hover:bg-secondary text-low hover:text-normal\"\n              >\n                <XIcon className=\"h-4 w-4\" weight=\"bold\" />\n              </button>\n            </div>\n            <nav className=\"flex-1 p-3 flex flex-col gap-1 overflow-y-auto md:pt-3\">\n              {topics.map((topic, idx) => (\n                <button\n                  key={topic.id}\n                  onClick={() => {\n                    setSelectedIndex(idx);\n                    setMobileShowContent(true);\n                  }}\n                  className={cn(\n                    'text-left px-3 py-2 rounded-sm text-sm transition-colors',\n                    idx === selectedIndex\n                      ? 'bg-brand/10 text-brand font-medium'\n                      : 'text-normal hover:bg-primary/10'\n                  )}\n                >\n                  {topic.title}\n                </button>\n              ))}\n            </nav>\n          </div>\n          {/* Content - hidden on mobile when showing nav */}\n          <div\n            className={cn(\n              'flex-1 flex flex-col relative overflow-y-auto',\n              // Mobile: full width, hidden when showing nav\n              !mobileShowContent && 'hidden',\n              // Desktop: always visible\n              'md:flex'\n            )}\n          >\n            {/* Mobile header with back button */}\n            <div className=\"flex items-center gap-2 p-3 border-b border-border/50 md:hidden\">\n              <button\n                onClick={() => setMobileShowContent(false)}\n                className=\"p-1 rounded-sm hover:bg-secondary text-low hover:text-normal\"\n              >\n                <CaretLeftIcon className=\"h-4 w-4\" weight=\"bold\" />\n              </button>\n              <span className=\"text-sm font-medium text-high\">Back</span>\n              <button\n                onClick={onClose}\n                className=\"ml-auto p-1 rounded-sm hover:bg-secondary text-low hover:text-normal\"\n              >\n                <XIcon className=\"h-4 w-4\" weight=\"bold\" />\n              </button>\n            </div>\n            {/* Desktop close button */}\n            <button\n              onClick={onClose}\n              className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-panel transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 hidden md:block\"\n            >\n              <XIcon className=\"h-4 w-4 text-normal\" />\n              <span className=\"sr-only\">{closeLabel}</span>\n            </button>\n            <div className=\"p-6 pt-4 md:pt-6 flex-1\">\n              <h2 className=\"text-xl font-semibold text-high mb-4 pr-8\">\n                {selectedTopic.title}\n              </h2>\n              {selectedTopic.imageSrc && (\n                <img\n                  src={selectedTopic.imageSrc}\n                  alt={selectedTopic.imageAlt ?? selectedTopic.title}\n                  className=\"w-full rounded-sm border border-border/30 mb-4\"\n                />\n              )}\n              <div className=\"text-normal text-sm leading-relaxed space-y-3\">\n                {typeof selectedTopic.content === 'string' ? (\n                  <p>{selectedTopic.content}</p>\n                ) : (\n                  selectedTopic.content\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IconButton.tsx",
    "content": "import { cn } from '../lib/cn';\nimport type { Icon } from '@phosphor-icons/react';\n\ninterface IconButtonProps {\n  icon: Icon;\n  iconClassName?: string;\n  onClick?: () => void;\n  disabled?: boolean;\n  variant?: 'default' | 'tertiary';\n  'aria-label': string;\n  title?: string;\n  className?: string;\n}\n\nexport function IconButton({\n  icon: IconComponent,\n  iconClassName,\n  onClick,\n  disabled,\n  variant = 'default',\n  'aria-label': ariaLabel,\n  title,\n  className,\n}: IconButtonProps) {\n  const variantStyles = disabled\n    ? 'opacity-40 cursor-not-allowed'\n    : variant === 'default'\n      ? 'text-low hover:text-normal hover:bg-secondary/50'\n      : 'bg-panel hover:bg-secondary text-normal';\n\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        'flex items-center justify-center p-half rounded-sm transition-colors',\n        variantStyles,\n        className\n      )}\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={ariaLabel}\n      title={title}\n    >\n      <IconComponent\n        className={cn('size-icon-sm', iconClassName)}\n        weight=\"bold\"\n      />\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IconButtonGroup.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../lib/cn';\nimport type { Icon } from '@phosphor-icons/react';\nimport { Tooltip } from './Tooltip';\n\ninterface ButtonGroupProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * ButtonGroup - A container for grouped buttons with a shared border\n * Can contain IconButtonGroupItem (icon-only) or ButtonGroupItem (text/mixed)\n */\nexport function ButtonGroup({ children, className }: ButtonGroupProps) {\n  return (\n    <div\n      className={cn(\n        'flex items-center rounded-sm border border-border overflow-hidden',\n        className\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\n// Alias for backwards compatibility\nexport const IconButtonGroup = ButtonGroup;\n\ninterface IconButtonGroupItemProps {\n  icon: Icon;\n  iconClassName?: string;\n  onClick?: () => void;\n  disabled?: boolean;\n  active?: boolean;\n  'aria-label': string;\n  title?: string;\n  className?: string;\n}\n\nexport function IconButtonGroupItem({\n  icon: IconComponent,\n  iconClassName,\n  onClick,\n  disabled,\n  active,\n  'aria-label': ariaLabel,\n  title,\n  className,\n}: IconButtonGroupItemProps) {\n  const stateStyles = disabled\n    ? 'opacity-40 cursor-not-allowed'\n    : active\n      ? 'bg-secondary text-normal'\n      : 'text-low hover:text-normal hover:bg-secondary/50';\n\n  const button = (\n    <button\n      type=\"button\"\n      className={cn('p-half transition-colors', stateStyles, className)}\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={ariaLabel}\n    >\n      <IconComponent\n        className={cn('size-icon-sm', iconClassName)}\n        weight=\"bold\"\n      />\n    </button>\n  );\n\n  return title ? <Tooltip content={title}>{button}</Tooltip> : button;\n}\n\ninterface ButtonGroupItemProps {\n  icon?: Icon;\n  iconClassName?: string;\n  onClick?: () => void;\n  disabled?: boolean;\n  active?: boolean;\n  'aria-label'?: string;\n  title?: string;\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * ButtonGroupItem - A button within a ButtonGroup that supports text, icons, or both\n */\nexport function ButtonGroupItem({\n  icon: IconComponent,\n  iconClassName,\n  onClick,\n  disabled,\n  active,\n  'aria-label': ariaLabel,\n  title,\n  className,\n  children,\n}: ButtonGroupItemProps) {\n  const stateStyles = disabled\n    ? 'opacity-40 cursor-not-allowed'\n    : active\n      ? 'bg-secondary text-normal'\n      : 'text-low hover:text-normal hover:bg-secondary/50';\n\n  // Use smaller padding for icon-only, larger for text content\n  const paddingStyles = children ? 'px-base py-half' : 'p-half';\n\n  const button = (\n    <button\n      type=\"button\"\n      className={cn(\n        'text-sm transition-colors',\n        paddingStyles,\n        stateStyles,\n        className\n      )}\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={ariaLabel}\n    >\n      {IconComponent && (\n        <IconComponent\n          className={cn('size-icon-sm', children && 'mr-half', iconClassName)}\n          weight=\"bold\"\n        />\n      )}\n      {children}\n    </button>\n  );\n\n  return title ? <Tooltip content={title}>{button}</Tooltip> : button;\n}\n"
  },
  {
    "path": "packages/ui/src/components/ImageKeyboardPlugin.tsx",
    "content": "import { useEffect } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  KEY_BACKSPACE_COMMAND,\n  KEY_DELETE_COMMAND,\n  COMMAND_PRIORITY_LOW,\n  $getSelection,\n  $isNodeSelection,\n  type LexicalNode,\n} from 'lexical';\n\ntype ImageKeyboardPluginProps = {\n  isTargetNode: (node: LexicalNode) => boolean;\n};\n\nexport function ImageKeyboardPlugin({\n  isTargetNode,\n}: ImageKeyboardPluginProps) {\n  const [editor] = useLexicalComposerContext();\n\n  useEffect(() => {\n    const deleteSelectedNodes = (): boolean => {\n      const selection = $getSelection();\n      if (!$isNodeSelection(selection)) return false;\n\n      const nodes = selection.getNodes();\n      const targetNodes = nodes.filter(isTargetNode);\n\n      if (targetNodes.length === 0) return false;\n\n      for (const node of targetNodes) {\n        node.remove();\n      }\n\n      return true;\n    };\n\n    const unregisterBackspace = editor.registerCommand(\n      KEY_BACKSPACE_COMMAND,\n      () => deleteSelectedNodes(),\n      COMMAND_PRIORITY_LOW\n    );\n\n    const unregisterDelete = editor.registerCommand(\n      KEY_DELETE_COMMAND,\n      () => deleteSelectedNodes(),\n      COMMAND_PRIORITY_LOW\n    );\n\n    return () => {\n      unregisterBackspace();\n      unregisterDelete();\n    };\n  }, [editor, isTargetNode]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/Input.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '../lib/cn';\nimport { twMerge } from 'tailwind-merge';\n\nexport interface InputProps\n  extends React.InputHTMLAttributes<HTMLInputElement> {\n  onCommandEnter?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n  onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n}\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n  (\n    {\n      className,\n      type,\n      onKeyDown,\n      onCommandEnter,\n      onCommandShiftEnter,\n      ...props\n    },\n    ref\n  ) => {\n    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (e.key === 'Escape') {\n        e.currentTarget.blur();\n      }\n      if (e.key === 'Enter' && !e.nativeEvent.isComposing) {\n        if (e.metaKey && e.shiftKey) {\n          onCommandShiftEnter?.(e);\n        } else {\n          onCommandEnter?.(e);\n        }\n      }\n      onKeyDown?.(e);\n    };\n\n    return (\n      <input\n        ref={ref}\n        type={type}\n        onKeyDown={handleKeyDown}\n        className={twMerge(\n          cn(\n            'flex h-10 w-full border px-3 py-2 text-sm ring-offset-background file:border-0 bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',\n            className\n          )\n        )}\n        {...props}\n      />\n    );\n  }\n);\n\nInput.displayName = 'Input';\nexport { Input };\n"
  },
  {
    "path": "packages/ui/src/components/InputField.tsx",
    "content": "import * as React from 'react';\nimport {\n  ArrowCounterClockwiseIcon,\n  CheckIcon,\n  type Icon,\n  PencilSimpleLineIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\ninterface InputFieldProps {\n  value: string;\n  onChange: (value: string) => void;\n  placeholder?: string;\n  className?: string;\n  variant?: 'editable' | 'search';\n  actionIcon?: Icon;\n  onAction?: () => void;\n  disabled?: boolean;\n  onFocusChange?: (focused: boolean) => void;\n}\n\nexport function InputField({\n  value,\n  onChange,\n  placeholder,\n  className,\n  variant = 'editable',\n  actionIcon: ActionIcon,\n  onAction,\n  disabled,\n  onFocusChange,\n}: InputFieldProps) {\n  const [isEditing, setIsEditing] = React.useState(false);\n  const [editValue, setEditValue] = React.useState(value);\n  const [justSaved, setJustSaved] = React.useState(false);\n  const [isFocused, setIsFocused] = React.useState(false);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  // Sync editValue when value prop changes (and not editing)\n  React.useEffect(() => {\n    if (!isEditing) {\n      setEditValue(value);\n    }\n  }, [value, isEditing]);\n\n  // Focus input when entering edit mode (editable variant only)\n  React.useEffect(() => {\n    if (variant === 'editable' && isEditing && inputRef.current) {\n      inputRef.current.focus();\n      inputRef.current.select();\n    }\n  }, [variant, isEditing]);\n\n  // Clear justSaved after 2 seconds\n  React.useEffect(() => {\n    if (justSaved) {\n      const timer = setTimeout(() => {\n        setJustSaved(false);\n      }, 2000);\n      return () => clearTimeout(timer);\n    }\n  }, [justSaved]);\n\n  const handleSave = () => {\n    setIsEditing(false);\n    if (editValue !== value) {\n      onChange(editValue);\n      setJustSaved(true);\n    }\n  };\n\n  const handleCancel = () => {\n    setIsEditing(false);\n    setEditValue(value);\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (variant === 'editable') {\n      if (e.key === 'Enter') {\n        handleSave();\n      } else if (e.key === 'Escape') {\n        handleCancel();\n      }\n    }\n  };\n\n  // Determine border color based on state\n  const getBorderClass = () => {\n    if (variant === 'editable') {\n      if (justSaved) return 'border-success';\n      if (isEditing) return 'border-brand';\n    }\n    if (variant === 'search' && isFocused) return 'border-brand';\n    return 'border-border';\n  };\n\n  // For search variant: always show input\n  // For editable variant: show input only when editing\n  const showInput = variant === 'search' || isEditing;\n\n  return (\n    <div\n      className={cn(\n        'bg-secondary border rounded-sm px-base py-half flex items-center gap-base transition-colors',\n        getBorderClass(),\n        className\n      )}\n    >\n      {showInput ? (\n        <input\n          ref={inputRef}\n          type=\"text\"\n          value={variant === 'editable' ? editValue : value}\n          onChange={(e) =>\n            variant === 'editable'\n              ? setEditValue(e.target.value)\n              : onChange(e.target.value)\n          }\n          onKeyDown={handleKeyDown}\n          onFocus={() => {\n            setIsFocused(true);\n            onFocusChange?.(true);\n          }}\n          onBlur={() => {\n            setIsFocused(false);\n            onFocusChange?.(false);\n          }}\n          placeholder={placeholder}\n          disabled={disabled}\n          className=\"flex-1 text-sm text-high bg-transparent placeholder:text-low placeholder:opacity-80 focus:outline-none min-w-0\"\n        />\n      ) : (\n        <span className=\"flex-1 text-sm text-normal truncate min-w-0\">\n          {value || <span className=\"text-low opacity-80\">{placeholder}</span>}\n        </span>\n      )}\n\n      {/* Editable variant icons */}\n      {variant === 'editable' && justSaved && (\n        <CheckIcon\n          className=\"size-icon-sm text-success shrink-0\"\n          weight=\"bold\"\n        />\n      )}\n      {variant === 'editable' && isEditing && !justSaved && (\n        <>\n          <ArrowCounterClockwiseIcon\n            className=\"size-icon-sm text-low shrink-0 cursor-pointer hover:text-normal\"\n            weight=\"bold\"\n            onMouseDown={(e) => {\n              e.preventDefault();\n              handleCancel();\n            }}\n          />\n          <CheckIcon\n            className=\"size-icon-sm text-low shrink-0 cursor-pointer hover:text-normal\"\n            weight=\"bold\"\n            onMouseDown={(e) => {\n              e.preventDefault();\n              handleSave();\n            }}\n          />\n        </>\n      )}\n      {variant === 'editable' && !isEditing && !justSaved && (\n        <PencilSimpleLineIcon\n          className=\"size-icon-sm text-low shrink-0 cursor-pointer hover:text-normal\"\n          weight=\"regular\"\n          onClick={() => setIsEditing(true)}\n        />\n      )}\n\n      {/* Search variant action button */}\n      {variant === 'search' && ActionIcon && (\n        <button\n          type=\"button\"\n          onClick={onAction}\n          disabled={disabled}\n          className=\"size-icon-sm text-low shrink-0 cursor-pointer hover:text-normal flex items-center justify-center\"\n        >\n          <ActionIcon className=\"size-icon-sm\" weight=\"bold\" />\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueCommentsSection.tsx",
    "content": "import type { Ref, ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { LocalAttachmentMetadata } from './WorkspaceContext';\nimport { cn } from '../lib/cn';\nimport {\n  DotsThreeIcon,\n  SmileyIcon,\n  ArrowUpIcon,\n  PencilSimpleIcon,\n  TrashIcon,\n  ArrowBendUpLeftIcon,\n  PaperclipIcon,\n} from '@phosphor-icons/react';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from './RadixTooltip';\nimport { ErrorAlert } from './ErrorAlert';\nimport { UserAvatar, type UserAvatarUser } from './UserAvatar';\nimport { CollapsibleSectionHeader } from './CollapsibleSectionHeader';\nimport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n} from './Dropdown';\nimport { EmojiPicker } from './EmojiPicker';\n\nexport interface IssueCommentData {\n  id: string;\n  authorId: string | null;\n  authorName: string;\n  message: string;\n  createdAt: string;\n  author?: UserAvatarUser | null;\n  canModify: boolean;\n}\n\nexport interface ReactionGroup {\n  emoji: string;\n  count: number;\n  hasReacted: boolean;\n  reactionId: string | undefined;\n  userNames: string[];\n}\n\nfunction formatRelativeTime(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMinutes = Math.floor(diffMs / (1000 * 60));\n  const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n\n  if (diffDays > 0) return `${diffDays}d`;\n  if (diffHours > 0) return `${diffHours}h`;\n  if (diffMinutes > 0) return `${diffMinutes}m`;\n  return 'now';\n}\n\ninterface DropzoneProps {\n  getRootProps: () => Record<string, unknown>;\n  getInputProps: () => Record<string, unknown>;\n  isDragActive: boolean;\n}\n\nexport interface IssueCommentsEditorProps {\n  value: string;\n  onChange?: (value: string) => void;\n  placeholder?: string;\n  className?: string;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  localAttachments?: LocalAttachmentMetadata[];\n  onCmdEnter?: () => void;\n  onPasteFiles?: (files: File[]) => void;\n  editorRef?: Ref<unknown>;\n}\n\ninterface IssueCommentsSectionProps {\n  comments: IssueCommentData[];\n  commentInput: string;\n  onCommentInputChange: (value: string) => void;\n  onSubmitComment: () => void;\n  editingCommentId: string | null;\n  editingValue: string;\n  onEditingValueChange: (value: string) => void;\n  onStartEdit: (commentId: string) => void;\n  onSaveEdit: () => void;\n  onCancelEdit: () => void;\n  onDeleteComment: (id: string) => void;\n  reactionsByCommentId: Map<string, ReactionGroup[]>;\n  onToggleReaction: (commentId: string, emoji: string) => void;\n  onReply: (authorName: string, message: string) => void;\n  isLoading?: boolean;\n  commentEditorRef?: Ref<unknown>;\n  onPasteFiles?: (files: File[]) => void;\n  localAttachments?: LocalAttachmentMetadata[];\n  dropzoneProps?: DropzoneProps;\n  onBrowseAttachment?: () => void;\n  isUploading?: boolean;\n  attachmentError?: string | null;\n  onDismissAttachmentError?: () => void;\n  renderEditor: (props: IssueCommentsEditorProps) => ReactNode;\n}\n\nexport function IssueCommentsSection({\n  comments,\n  commentInput,\n  onCommentInputChange,\n  onSubmitComment,\n  editingCommentId,\n  editingValue,\n  onEditingValueChange,\n  onStartEdit,\n  onSaveEdit,\n  onCancelEdit,\n  onDeleteComment,\n  reactionsByCommentId,\n  onToggleReaction,\n  onReply,\n  isLoading,\n  commentEditorRef,\n  onPasteFiles,\n  localAttachments,\n  dropzoneProps,\n  onBrowseAttachment,\n  isUploading,\n  attachmentError,\n  onDismissAttachmentError,\n  renderEditor,\n}: IssueCommentsSectionProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <CollapsibleSectionHeader\n      title={t('kanban.comments')}\n      persistKey=\"kanban-issue-comments\"\n      defaultExpanded={true}\n      actions={[]}\n    >\n      <div className=\"p-base flex flex-col gap-base border-t\">\n        {/* Comments list */}\n        {isLoading ? (\n          <div className=\"flex flex-col gap-double animate-pulse\">\n            <div className=\"h-4 bg-secondary rounded w-3/4\" />\n            <div className=\"h-4 bg-secondary rounded w-1/2\" />\n          </div>\n        ) : comments.length === 0 ? (\n          <p className=\"text-low\">{t('kanban.noCommentsYet')}</p>\n        ) : (\n          comments.map((comment) => (\n            <CommentItem\n              key={comment.id}\n              comment={comment}\n              isEditing={editingCommentId === comment.id}\n              editValue={editingCommentId === comment.id ? editingValue : ''}\n              onEditValueChange={onEditingValueChange}\n              onStartEdit={() => onStartEdit(comment.id)}\n              onSaveEdit={onSaveEdit}\n              onCancelEdit={onCancelEdit}\n              onDelete={() => onDeleteComment(comment.id)}\n              reactions={reactionsByCommentId.get(comment.id) ?? []}\n              onToggleReaction={(emoji) => onToggleReaction(comment.id, emoji)}\n              onReply={() => onReply(comment.authorName, comment.message)}\n              renderEditor={renderEditor}\n            />\n          ))\n        )}\n\n        {/* Comment Input with WYSIWYG + dropzone */}\n        <div\n          {...dropzoneProps?.getRootProps()}\n          className=\"relative flex flex-col gap-double bg-secondary border border-border rounded-sm p-double\"\n        >\n          <input {...dropzoneProps?.getInputProps()} />\n          {renderEditor({\n            value: commentInput,\n            onChange: onCommentInputChange,\n            placeholder: t('kanban.enterCommentPlaceholder'),\n            className: 'min-h-[20px]',\n            localAttachments,\n            onCmdEnter: onSubmitComment,\n            onPasteFiles,\n            autoFocus: false,\n            editorRef: commentEditorRef,\n          })}\n          {attachmentError && (\n            <div className=\"mb-half\">\n              <ErrorAlert\n                message={attachmentError}\n                onDismiss={onDismissAttachmentError}\n                dismissLabel={t('buttons.close')}\n              />\n            </div>\n          )}\n          <div className=\"flex items-center justify-end gap-half\">\n            {onBrowseAttachment && (\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <button\n                      type=\"button\"\n                      onClick={onBrowseAttachment}\n                      title={t('kanban.attachFile')}\n                      className={cn(\n                        'size-[22px] rounded-full bg-panel border border-border',\n                        'flex items-center justify-center',\n                        'text-low hover:text-normal transition-colors'\n                      )}\n                      aria-label={t('kanban.attachFile')}\n                    >\n                      <PaperclipIcon size={12} />\n                    </button>\n                  </TooltipTrigger>\n                  <TooltipContent>{t('kanban.attachFileHint')}</TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            )}\n            <button\n              type=\"button\"\n              onClick={onSubmitComment}\n              disabled={!commentInput.trim() || isUploading}\n              className={cn(\n                'size-[22px] rounded-full bg-panel border border-border',\n                'flex items-center justify-center',\n                'text-high hover:bg-secondary transition-colors',\n                'disabled:opacity-50 disabled:cursor-not-allowed'\n              )}\n            >\n              <ArrowUpIcon size={12} weight=\"bold\" />\n            </button>\n          </div>\n          {dropzoneProps?.isDragActive && (\n            <div className=\"absolute inset-0 z-50 bg-primary/80 backdrop-blur-sm border-2 border-dashed border-brand rounded flex items-center justify-center\">\n              <p className=\"text-sm font-medium text-high\">\n                {t('kanban.dropFilesHere')}\n              </p>\n            </div>\n          )}\n        </div>\n      </div>\n    </CollapsibleSectionHeader>\n  );\n}\n\ninterface CommentItemProps {\n  comment: IssueCommentData;\n  isEditing: boolean;\n  editValue: string;\n  onEditValueChange: (value: string) => void;\n  onStartEdit: () => void;\n  onSaveEdit: () => void;\n  onCancelEdit: () => void;\n  onDelete: () => void;\n  reactions: ReactionGroup[];\n  onToggleReaction: (emoji: string) => void;\n  onReply: () => void;\n  renderEditor: (props: IssueCommentsEditorProps) => ReactNode;\n}\n\nfunction CommentItem({\n  comment,\n  isEditing,\n  editValue,\n  onEditValueChange,\n  onStartEdit,\n  onSaveEdit,\n  onCancelEdit,\n  onDelete,\n  reactions,\n  onToggleReaction,\n  onReply,\n  renderEditor,\n}: CommentItemProps) {\n  const { t } = useTranslation('common');\n  const timeAgo = formatRelativeTime(comment.createdAt);\n\n  return (\n    <div className=\"flex flex-col gap-base\">\n      {/* Header row */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-base\">\n          {comment.author ? (\n            <UserAvatar user={comment.author} className=\"size-4\" />\n          ) : (\n            <div className=\"size-4 rounded-full bg-secondary border border-border flex items-center justify-center text-[10px] text-low\">\n              {comment.authorName.charAt(0).toUpperCase()}\n            </div>\n          )}\n          <span className=\"font-medium text-low\">{comment.authorName}</span>\n          <span className=\"font-medium text-low\">·</span>\n          <span className=\"font-light text-low\">{timeAgo}</span>\n        </div>\n        {/* Menu dropdown - only shown if user can modify */}\n        {comment.canModify && (\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <button className=\"size-5 flex items-center justify-center text-low hover:text-normal\">\n                <DotsThreeIcon size={16} weight=\"bold\" />\n              </button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuItem icon={PencilSimpleIcon} onSelect={onStartEdit}>\n                {t('buttons.edit')}\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                icon={TrashIcon}\n                variant=\"destructive\"\n                onSelect={onDelete}\n              >\n                {t('buttons.delete')}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n      </div>\n\n      {/* Message - editable or read-only */}\n      {isEditing ? (\n        <div className=\"flex flex-col gap-half bg-primary border border-border rounded-sm p-double\">\n          {renderEditor({\n            value: editValue,\n            onChange: onEditValueChange,\n            autoFocus: true,\n            onCmdEnter: onSaveEdit,\n            className: 'min-h-[40px]',\n          })}\n          <div className=\"flex gap-half justify-end\">\n            <button\n              type=\"button\"\n              onClick={onCancelEdit}\n              className=\"px-base py-half text-low hover:text-normal\"\n            >\n              {t('buttons.cancel')}\n            </button>\n            <button\n              type=\"button\"\n              onClick={onSaveEdit}\n              disabled={!editValue.trim()}\n              className={cn(\n                'px-base py-half bg-brand text-on-brand rounded-sm',\n                'hover:bg-brand-hover disabled:opacity-50'\n              )}\n            >\n              {t('buttons.save')}\n            </button>\n          </div>\n        </div>\n      ) : (\n        renderEditor({\n          value: comment.message,\n          disabled: true,\n          className: 'text-normal',\n        })\n      )}\n\n      {/* Reactions row */}\n      <div className=\"flex items-center gap-base flex-wrap\">\n        {/* Existing reactions */}\n        <TooltipProvider>\n          {reactions.map((reaction) => (\n            <Tooltip key={reaction.emoji}>\n              <TooltipTrigger asChild>\n                <button\n                  type=\"button\"\n                  onClick={() => onToggleReaction(reaction.emoji)}\n                  className={cn(\n                    'flex items-center gap-half px-base py-half rounded-sm',\n                    'border transition-colors',\n                    reaction.hasReacted\n                      ? 'bg-brand/10 border-brand text-brand'\n                      : 'bg-secondary border-border text-low hover:text-normal'\n                  )}\n                >\n                  <span className=\"color-emoji\">{reaction.emoji}</span>\n                  <span className=\"text-xs\">{reaction.count}</span>\n                </button>\n              </TooltipTrigger>\n              <TooltipContent className=\"bg-panel border border-border\">\n                {reaction.userNames.join(', ')}\n              </TooltipContent>\n            </Tooltip>\n          ))}\n        </TooltipProvider>\n\n        {/* Add reaction button */}\n        <EmojiPicker onSelect={onToggleReaction}>\n          <button\n            type=\"button\"\n            className=\"size-6 flex items-center justify-center text-low hover:text-normal rounded-sm hover:bg-secondary transition-colors\"\n          >\n            <SmileyIcon size={16} />\n          </button>\n        </EmojiPicker>\n\n        {/* Reply button */}\n        <button\n          type=\"button\"\n          onClick={onReply}\n          className=\"flex items-center gap-half text-low hover:text-normal transition-colors\"\n        >\n          <ArrowBendUpLeftIcon size={16} />\n          <span className=\"font-light\">{t('buttons.reply')}</span>\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueListRow.tsx",
    "content": "'use client';\n\nimport type { MouseEvent } from 'react';\nimport { cn } from '../lib/cn';\nimport { Draggable } from '@hello-pangea/dnd';\nimport { DotsSixVerticalIcon } from '@phosphor-icons/react';\nimport { PriorityIcon, type PriorityLevel } from './PriorityIcon';\nimport { StatusDot } from './StatusDot';\nimport { KanbanBadge } from './KanbanBadge';\nimport { KanbanAssignee, type KanbanAssigneeUser } from './KanbanAssignee';\nimport {\n  RelationshipBadge,\n  type RelationshipDisplayType,\n} from './RelationshipBadge';\nimport { Checkbox } from './Checkbox';\n\n/**\n * Formats a date as a relative time string (e.g., \"1d\", \"2h\", \"3m\")\n */\nfunction formatRelativeTime(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMinutes = Math.floor(diffMs / (1000 * 60));\n  const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n\n  if (diffDays > 0) {\n    return `${diffDays}d`;\n  }\n  if (diffHours > 0) {\n    return `${diffHours}h`;\n  }\n  if (diffMinutes > 0) {\n    return `${diffMinutes}m`;\n  }\n  return 'now';\n}\n\nconst MAX_VISIBLE_TAGS = 2;\n\nexport interface IssueListRowIssue {\n  id: string;\n  simple_id: string;\n  title: string;\n  priority: PriorityLevel | null;\n  created_at: string;\n}\n\nexport interface IssueListRowTag {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface IssueListRowRelationship {\n  relationshipId: string;\n  displayType: RelationshipDisplayType;\n  relatedIssueDisplayId: string;\n}\n\nexport interface IssueListRowProps {\n  issue: IssueListRowIssue;\n  index: number;\n  statusColor: string;\n  tags: IssueListRowTag[];\n  relationships?: IssueListRowRelationship[];\n  assignees: KanbanAssigneeUser[];\n  onClick: (e: MouseEvent) => void;\n  isSelected: boolean;\n  isMultiSelectActive?: boolean;\n  isChecked?: boolean;\n  onCheckboxChange?: (checked: boolean) => void;\n  className?: string;\n}\n\nexport function IssueListRow({\n  issue,\n  index,\n  statusColor,\n  tags,\n  relationships = [],\n  assignees,\n  onClick,\n  isSelected,\n  isMultiSelectActive = false,\n  isChecked = false,\n  onCheckboxChange,\n  className,\n}: IssueListRowProps) {\n  const showCheckbox = isMultiSelectActive || isChecked;\n  const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);\n\n  return (\n    <Draggable draggableId={issue.id} index={index}>\n      {(provided, snapshot) => (\n        <div\n          ref={provided.innerRef}\n          {...provided.draggableProps}\n          role=\"button\"\n          tabIndex={0}\n          onClick={onClick}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              e.preventDefault();\n              onClick(e as unknown as MouseEvent);\n            }\n          }}\n          className={cn(\n            'group/row flex items-center justify-between gap-double px-double py-half',\n            'transition-colors',\n            'hover:bg-secondary',\n            (isSelected || isChecked) && 'bg-secondary',\n            snapshot.isDragging && 'bg-secondary shadow-lg cursor-grabbing',\n            className\n          )}\n        >\n          {/* Left side: Checkbox/Drag handle, Priority, ID, Status, Title */}\n          <div className=\"flex items-center gap-double flex-1 min-w-0\">\n            <div className=\"relative shrink-0 w-4 flex items-center justify-center\">\n              {/* Drag handle — hidden when checkbox is shown */}\n              <div\n                {...provided.dragHandleProps}\n                className={cn(\n                  'cursor-grab',\n                  showCheckbox ? 'hidden' : 'flex group-hover/row:hidden'\n                )}\n                onClick={(e) => e.stopPropagation()}\n              >\n                <DotsSixVerticalIcon\n                  className=\"size-icon-xs text-low\"\n                  weight=\"bold\"\n                />\n              </div>\n              {/* Checkbox — shown on hover or when multi-select active */}\n              <div\n                className={cn(\n                  'items-center justify-center',\n                  showCheckbox ? 'flex' : 'hidden group-hover/row:flex'\n                )}\n                onClick={(e) => e.stopPropagation()}\n              >\n                <Checkbox\n                  checked={isChecked}\n                  onCheckedChange={(checked) => {\n                    onCheckboxChange?.(checked);\n                  }}\n                />\n              </div>\n            </div>\n            <PriorityIcon priority={issue.priority} />\n            <span className=\"font-ibm-plex-mono text-sm text-normal shrink-0\">\n              {issue.simple_id}\n            </span>\n            <StatusDot color={statusColor} />\n            <span className=\"text-base text-high truncate\">{issue.title}</span>\n          </div>\n\n          {/* Right side: Tags, Assignee, Age */}\n          <div className=\"flex items-center gap-base shrink-0\">\n            {visibleTags.length > 0 && (\n              <div className=\"flex items-center gap-half\">\n                {visibleTags.map((tag) => (\n                  <KanbanBadge key={tag.id} name={tag.name} color={tag.color} />\n                ))}\n              </div>\n            )}\n            {relationships.length > 0 && (\n              <div className=\"flex items-center gap-half\">\n                {relationships.slice(0, 2).map((rel) => (\n                  <RelationshipBadge\n                    key={rel.relationshipId}\n                    displayType={rel.displayType}\n                    relatedIssueDisplayId={rel.relatedIssueDisplayId}\n                    compact\n                  />\n                ))}\n                {relationships.length > 2 && (\n                  <span className=\"text-sm text-low\">\n                    +{relationships.length - 2}\n                  </span>\n                )}\n              </div>\n            )}\n            <KanbanAssignee assignees={assignees} />\n            <span className=\"text-sm text-low w-5 text-right\">\n              {formatRelativeTime(issue.created_at)}\n            </span>\n          </div>\n        </div>\n      )}\n    </Draggable>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueListSection.tsx",
    "content": "'use client';\n\nimport { useCallback, useState, type MouseEvent } from 'react';\nimport { cn } from '../lib/cn';\nimport { Droppable } from '@hello-pangea/dnd';\nimport { CaretDownIcon } from '@phosphor-icons/react';\nimport { StatusDot } from './StatusDot';\nimport { KanbanBadge } from './KanbanBadge';\nimport {\n  IssueListRow,\n  type IssueListRowIssue,\n  type IssueListRowTag,\n  type IssueListRowRelationship,\n} from './IssueListRow';\nimport type { KanbanAssigneeUser } from './KanbanAssignee';\n\nexport interface IssueListSectionStatus {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface IssueListSectionProps {\n  status: IssueListSectionStatus;\n  issueIds: string[];\n  issueMap: Record<string, IssueListRowIssue>;\n  issueAssigneesMap: Record<string, KanbanAssigneeUser[]>;\n  getTagObjectsForIssue: (issueId: string) => IssueListRowTag[];\n  getResolvedRelationshipsForIssue?: (\n    issueId: string\n  ) => IssueListRowRelationship[];\n  onIssueClick: (issueId: string, e: MouseEvent) => void;\n  selectedIssueId: string | null;\n  selectedIssueIds?: Set<string>;\n  isMultiSelectActive?: boolean;\n  onIssueCheckboxChange?: (issueId: string, checked: boolean) => void;\n  className?: string;\n}\n\nexport function IssueListSection({\n  status,\n  issueIds,\n  issueMap,\n  issueAssigneesMap,\n  getTagObjectsForIssue,\n  getResolvedRelationshipsForIssue,\n  onIssueClick,\n  selectedIssueId,\n  selectedIssueIds,\n  isMultiSelectActive,\n  onIssueCheckboxChange,\n  className,\n}: IssueListSectionProps) {\n  const storageKey = `ui.issue-list-section.${status.id}`;\n  const [isExpanded, setExpanded] = useState(() => {\n    if (typeof window === 'undefined') return true;\n    const stored = window.localStorage.getItem(storageKey);\n    return stored == null ? true : stored === 'true';\n  });\n  const handleToggleExpanded = useCallback(() => {\n    setExpanded((prevExpanded) => {\n      const nextExpanded = !prevExpanded;\n      if (typeof window !== 'undefined') {\n        window.localStorage.setItem(storageKey, String(nextExpanded));\n      }\n      return nextExpanded;\n    });\n  }, [storageKey]);\n\n  return (\n    <div className={cn('flex flex-col', className)}>\n      {/* Section Header */}\n      <button\n        type=\"button\"\n        onClick={handleToggleExpanded}\n        className={cn(\n          'flex items-center justify-between',\n          'h-8 px-double py-base',\n          'bg-panel border-y border-border',\n          'cursor-pointer transition-colors',\n          'hover:bg-secondary'\n        )}\n      >\n        <div className=\"flex items-center gap-base\">\n          <CaretDownIcon\n            className={cn(\n              'size-icon-xs text-low transition-transform',\n              !isExpanded && '-rotate-90'\n            )}\n            weight=\"bold\"\n          />\n          <StatusDot color={status.color} />\n          <span className=\"text-base font-medium text-high\">{status.name}</span>\n        </div>\n        <KanbanBadge name={String(issueIds.length)} />\n      </button>\n\n      {/* Section Content - Droppable area */}\n      <Droppable droppableId={status.id}>\n        {(provided) => (\n          <div\n            ref={provided.innerRef}\n            {...provided.droppableProps}\n            className=\"flex flex-col min-h-8\"\n          >\n            {isExpanded &&\n              issueIds.map((issueId, index) => {\n                const issue = issueMap[issueId];\n                if (!issue) return null;\n\n                return (\n                  <IssueListRow\n                    key={issue.id}\n                    issue={issue}\n                    index={index}\n                    statusColor={status.color}\n                    tags={getTagObjectsForIssue(issue.id)}\n                    relationships={getResolvedRelationshipsForIssue?.(issue.id)}\n                    assignees={issueAssigneesMap[issue.id] ?? []}\n                    onClick={(e) => onIssueClick(issue.id, e)}\n                    isSelected={selectedIssueId === issue.id}\n                    isMultiSelectActive={isMultiSelectActive}\n                    isChecked={selectedIssueIds?.has(issue.id)}\n                    onCheckboxChange={(checked) =>\n                      onIssueCheckboxChange?.(issue.id, checked)\n                    }\n                  />\n                );\n              })}\n            {provided.placeholder}\n          </div>\n        )}\n      </Droppable>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueListView.tsx",
    "content": "'use client';\n\nimport type { MouseEvent } from 'react';\nimport { cn } from '../lib/cn';\nimport type { KanbanAssigneeUser } from './KanbanAssignee';\nimport {\n  IssueListSection,\n  type IssueListSectionStatus,\n} from './IssueListSection';\nimport type {\n  IssueListRowIssue,\n  IssueListRowRelationship,\n  IssueListRowTag,\n} from './IssueListRow';\n\nexport interface IssueListViewProps {\n  statuses: IssueListSectionStatus[];\n  items: Record<string, string[]>;\n  issueMap: Record<string, IssueListRowIssue>;\n  issueAssigneesMap: Record<string, KanbanAssigneeUser[]>;\n  getTagObjectsForIssue: (issueId: string) => IssueListRowTag[];\n  getResolvedRelationshipsForIssue?: (\n    issueId: string\n  ) => IssueListRowRelationship[];\n  onIssueClick: (issueId: string, e: MouseEvent) => void;\n  selectedIssueId: string | null;\n  selectedIssueIds?: Set<string>;\n  isMultiSelectActive?: boolean;\n  onIssueCheckboxChange?: (issueId: string, checked: boolean) => void;\n  className?: string;\n}\n\nexport function IssueListView({\n  statuses,\n  items,\n  issueMap,\n  issueAssigneesMap,\n  getTagObjectsForIssue,\n  getResolvedRelationshipsForIssue,\n  onIssueClick,\n  selectedIssueId,\n  selectedIssueIds,\n  isMultiSelectActive,\n  onIssueCheckboxChange,\n  className,\n}: IssueListViewProps) {\n  return (\n    <div className={cn('flex flex-col h-full overflow-y-auto', className)}>\n      {statuses.map((status) => (\n        <IssueListSection\n          key={status.id}\n          status={status}\n          issueIds={items[status.id] ?? []}\n          issueMap={issueMap}\n          issueAssigneesMap={issueAssigneesMap}\n          getTagObjectsForIssue={getTagObjectsForIssue}\n          getResolvedRelationshipsForIssue={getResolvedRelationshipsForIssue}\n          onIssueClick={onIssueClick}\n          selectedIssueId={selectedIssueId}\n          selectedIssueIds={selectedIssueIds}\n          isMultiSelectActive={isMultiSelectActive}\n          onIssueCheckboxChange={onIssueCheckboxChange}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssuePropertyRow.tsx",
    "content": "import { cn } from '../lib/cn';\nimport { PlusIcon, UsersIcon, XIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { PrimaryButton } from './PrimaryButton';\nimport { IconButton } from './IconButton';\nimport { StatusDot } from './StatusDot';\nimport { PriorityIcon, type PriorityLevel } from './PriorityIcon';\nimport { UserAvatar, type UserAvatarUser } from './UserAvatar';\nimport { KanbanAssignee, type KanbanAssigneeUser } from './KanbanAssignee';\n\nexport interface IssuePropertyStatus {\n  id: string;\n  name: string;\n  color: string;\n}\n\nconst priorityLabels: Record<PriorityLevel, string> = {\n  urgent: 'Urgent',\n  high: 'High',\n  medium: 'Medium',\n  low: 'Low',\n};\n\nexport interface IssuePropertyRowProps {\n  statusId: string;\n  priority: PriorityLevel | null;\n  assigneeIds: string[];\n  assigneeUsers?: KanbanAssigneeUser[];\n  statuses: IssuePropertyStatus[];\n  creatorUser?: UserAvatarUser | null;\n  parentIssue?: { id: string; simpleId: string } | null;\n  onParentIssueClick?: () => void;\n  onRemoveParentIssue?: () => void;\n  onStatusClick: () => void;\n  onPriorityClick: () => void;\n  onAssigneeClick: () => void;\n  onAddClick?: () => void;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function IssuePropertyRow({\n  statusId,\n  priority,\n  assigneeUsers,\n  statuses,\n  creatorUser,\n  parentIssue,\n  onParentIssueClick,\n  onRemoveParentIssue,\n  onStatusClick,\n  onPriorityClick,\n  onAssigneeClick,\n  onAddClick,\n  disabled,\n  className,\n}: IssuePropertyRowProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <div className={cn('flex items-center gap-half flex-wrap', className)}>\n      <PrimaryButton\n        variant=\"tertiary\"\n        onClick={onStatusClick}\n        disabled={disabled}\n      >\n        <StatusDot\n          color={statuses.find((s) => s.id === statusId)?.color ?? '0 0% 50%'}\n        />\n        {statuses.find((s) => s.id === statusId)?.name ?? 'Select status'}\n      </PrimaryButton>\n\n      <PrimaryButton\n        variant=\"tertiary\"\n        onClick={onPriorityClick}\n        disabled={disabled}\n      >\n        <PriorityIcon priority={priority} />\n        {priority ? priorityLabels[priority] : 'No priority'}\n      </PrimaryButton>\n\n      <PrimaryButton\n        variant=\"tertiary\"\n        onClick={onAssigneeClick}\n        disabled={disabled}\n      >\n        {assigneeUsers && assigneeUsers.length > 0 ? (\n          <KanbanAssignee assignees={assigneeUsers} />\n        ) : (\n          <>\n            <UsersIcon className=\"size-icon-xs\" weight=\"bold\" />\n            {t('kanban.assignee', 'Assignee')}\n          </>\n        )}\n      </PrimaryButton>\n\n      {creatorUser &&\n        (creatorUser.first_name?.trim() || creatorUser.username?.trim()) && (\n          <div className=\"flex items-center gap-half px-base py-half bg-panel rounded-sm text-sm whitespace-nowrap\">\n            <span className=\"text-low\">\n              {t('kanban.createdBy', 'Created by')}\n            </span>\n            <UserAvatar\n              user={creatorUser}\n              className=\"h-5 w-5 text-[9px] border border-border\"\n            />\n            <span className=\"text-normal truncate max-w-[120px]\">\n              {creatorUser.first_name?.trim() || creatorUser.username?.trim()}\n            </span>\n          </div>\n        )}\n\n      {parentIssue && (\n        <div className=\"flex items-center gap-half\">\n          <PrimaryButton\n            variant=\"tertiary\"\n            onClick={onParentIssueClick}\n            disabled={disabled}\n            className=\"whitespace-nowrap text-sm\"\n          >\n            <span className=\"text-low\">\n              {t('kanban.parentIssue', 'Parent')}:\n            </span>\n            <span className=\"font-ibm-plex-mono text-normal\">\n              {parentIssue.simpleId}\n            </span>\n          </PrimaryButton>\n          {onRemoveParentIssue && (\n            <IconButton\n              icon={XIcon}\n              onClick={onRemoveParentIssue}\n              disabled={disabled}\n              aria-label=\"Remove parent issue\"\n              title=\"Remove parent issue\"\n            />\n          )}\n        </div>\n      )}\n\n      {onAddClick && (\n        <IconButton\n          icon={PlusIcon}\n          onClick={onAddClick}\n          disabled={disabled}\n          aria-label=\"Add\"\n          title=\"Add\"\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueRelationshipsSection.tsx",
    "content": "'use client';\n\nimport { useTranslation } from 'react-i18next';\nimport { XIcon } from '@phosphor-icons/react';\nimport { CollapsibleSectionHeader } from './CollapsibleSectionHeader';\nimport {\n  RelationshipBadge,\n  type RelationshipDisplayType,\n} from './RelationshipBadge';\n\nexport interface IssueRelationshipsSectionRelationship {\n  relationshipId: string;\n  relatedIssueId: string;\n  relatedIssueDisplayId: string;\n  displayType: RelationshipDisplayType;\n}\n\nexport interface IssueRelationshipsSectionProps {\n  relationships: IssueRelationshipsSectionRelationship[];\n  onRelationshipClick: (relatedIssueId: string) => void;\n  onRemoveRelationship?: (relationshipId: string) => void;\n  isLoading?: boolean;\n  headerExtra?: React.ReactNode;\n}\n\nexport function IssueRelationshipsSection({\n  relationships,\n  onRelationshipClick,\n  onRemoveRelationship,\n  isLoading,\n  headerExtra,\n}: IssueRelationshipsSectionProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <CollapsibleSectionHeader\n      title={t('kanban.relationships', 'Relationships')}\n      persistKey=\"kanban-issue-relationships\"\n      defaultExpanded={true}\n      headerExtra={headerExtra}\n    >\n      <div className=\"p-base flex flex-col gap-half border-t\">\n        {isLoading ? (\n          <p className=\"text-low py-half\">{t('states.loading')}</p>\n        ) : relationships.length === 0 ? (\n          <p className=\"text-low py-half\">\n            {t('kanban.noRelationships', 'No relationships')}\n          </p>\n        ) : (\n          relationships.map((rel) => (\n            <div\n              key={rel.relationshipId}\n              className=\"flex items-center justify-between group\"\n            >\n              <RelationshipBadge\n                displayType={rel.displayType}\n                relatedIssueDisplayId={rel.relatedIssueDisplayId}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onRelationshipClick(rel.relatedIssueId);\n                }}\n              />\n              {onRemoveRelationship && (\n                <button\n                  type=\"button\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    onRemoveRelationship(rel.relationshipId);\n                  }}\n                  className=\"p-half rounded-sm text-low hover:text-error hover:bg-error/10 transition-colors opacity-0 group-hover:opacity-100\"\n                  aria-label=\"Remove relationship\"\n                >\n                  <XIcon className=\"size-icon-2xs\" weight=\"bold\" />\n                </button>\n              )}\n            </div>\n          ))\n        )}\n      </div>\n    </CollapsibleSectionHeader>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueSubIssuesSection.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { Droppable } from '@hello-pangea/dnd';\nimport {\n  CollapsibleSectionHeader,\n  type SectionAction,\n} from './CollapsibleSectionHeader';\nimport { SubIssueRow } from './SubIssueRow';\nimport type { PriorityLevel } from './PriorityIcon';\nimport type { KanbanAssigneeUser } from './KanbanAssignee';\n\nexport interface SubIssueData {\n  id: string;\n  simpleId: string;\n  title: string;\n  priority: PriorityLevel | null;\n  statusColor: string;\n  assignees: KanbanAssigneeUser[];\n  createdAt: string;\n  parentIssueSortOrder: number | null;\n}\n\nexport interface IssueSubIssuesSectionProps {\n  parentIssueId: string;\n  subIssues: SubIssueData[];\n  onSubIssueClick: (issueId: string) => void;\n  onSubIssueMarkIndependent?: (subIssueId: string) => void;\n  onSubIssueDelete?: (subIssueId: string) => void;\n  onSubIssuePriorityClick?: (subIssueId: string) => void;\n  onSubIssueAssigneeClick?: (subIssueId: string) => void;\n  isLoading?: boolean;\n  isReordering?: boolean;\n  actions?: SectionAction[];\n}\n\nexport function IssueSubIssuesSection({\n  parentIssueId,\n  subIssues,\n  onSubIssueClick,\n  onSubIssueMarkIndependent,\n  onSubIssueDelete,\n  onSubIssuePriorityClick,\n  onSubIssueAssigneeClick,\n  isLoading,\n  isReordering,\n  actions,\n}: IssueSubIssuesSectionProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <CollapsibleSectionHeader\n      title={t('kanban.subIssues', 'Sub-issues')}\n      persistKey=\"kanban-issue-sub-issues\"\n      defaultExpanded={true}\n      actions={actions}\n    >\n      <Droppable droppableId={parentIssueId}>\n        {(provided) => (\n          <div\n            ref={provided.innerRef}\n            {...provided.droppableProps}\n            className=\"p-base flex flex-col relative border-t\"\n          >\n            {isReordering && (\n              <div className=\"absolute inset-0 bg-background/50 flex items-center justify-center z-10\">\n                <p className=\"text-low\">{t('common.loading', 'Loading...')}</p>\n              </div>\n            )}\n            {isLoading ? (\n              <p className=\"text-low py-half\">\n                {t('common.loading', 'Loading...')}\n              </p>\n            ) : subIssues.length === 0 ? (\n              <p className=\"text-low py-half\">\n                {t('kanban.noSubIssues', 'No sub-issues')}\n              </p>\n            ) : (\n              subIssues.map((subIssue, index) => (\n                <SubIssueRow\n                  key={subIssue.id}\n                  id={subIssue.id}\n                  index={index}\n                  simpleId={subIssue.simpleId}\n                  title={subIssue.title}\n                  priority={subIssue.priority}\n                  statusColor={subIssue.statusColor}\n                  assignees={subIssue.assignees}\n                  createdAt={subIssue.createdAt}\n                  onClick={() => onSubIssueClick(subIssue.id)}\n                  onMarkIndependentClick={\n                    onSubIssueMarkIndependent\n                      ? (e) => {\n                          e.stopPropagation();\n                          onSubIssueMarkIndependent(subIssue.id);\n                        }\n                      : undefined\n                  }\n                  onDeleteClick={\n                    onSubIssueDelete\n                      ? (e) => {\n                          e.stopPropagation();\n                          onSubIssueDelete(subIssue.id);\n                        }\n                      : undefined\n                  }\n                  onPriorityClick={\n                    onSubIssuePriorityClick\n                      ? (e) => {\n                          e.stopPropagation();\n                          onSubIssuePriorityClick(subIssue.id);\n                        }\n                      : undefined\n                  }\n                  onAssigneeClick={\n                    onSubIssueAssigneeClick\n                      ? (e) => {\n                          e.stopPropagation();\n                          onSubIssueAssigneeClick(subIssue.id);\n                        }\n                      : undefined\n                  }\n                />\n              ))\n            )}\n            {provided.placeholder}\n\n            {/* Loading overlay - preserves height while showing loading state */}\n            {isReordering && (\n              <div className=\"absolute inset-0 bg-background/80 flex items-center justify-center\">\n                <span className=\"text-low text-sm\">\n                  {t('common.saving', 'Saving...')}\n                </span>\n              </div>\n            )}\n          </div>\n        )}\n      </Droppable>\n    </CollapsibleSectionHeader>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueTagsRow.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { PlusIcon, HashIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { PRESET_COLORS } from './ColorPicker';\nimport { PrBadge, type PrBadgeStatus } from './PrBadge';\nimport { TAG_COLORS } from './SearchableTagDropdown';\n\n// Re-export for backwards compatibility.\nexport { PRESET_COLORS, TAG_COLORS };\n\nexport interface IssueTagBase {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface LinkedPullRequest {\n  id: string;\n  number: number;\n  url: string;\n  status: PrBadgeStatus;\n}\n\nexport interface LinkedIssue {\n  id: string;\n  displayId: string;\n  title: string;\n}\n\nexport interface IssueTagsRowProps<TTag extends IssueTagBase = IssueTagBase> {\n  selectedTagIds: string[];\n  availableTags: TTag[];\n  linkedPrs?: LinkedPullRequest[];\n  linkedIssues?: LinkedIssue[];\n  onTagsChange: (tagIds: string[]) => void;\n  onCreateTag?: (data: { name: string; color: string }) => string;\n  renderAddTagControl?: (\n    props: IssueTagsRowAddTagControlProps<TTag>\n  ) => ReactNode;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport interface IssueTagsRowAddTagControlProps<\n  TTag extends IssueTagBase = IssueTagBase,\n> {\n  tags: TTag[];\n  selectedTagIds: string[];\n  onTagToggle: (tagId: string) => void;\n  onCreateTag: (data: { name: string; color: string }) => string;\n  disabled: boolean;\n  trigger: ReactNode;\n}\n\nexport function IssueTagsRow<TTag extends IssueTagBase>({\n  selectedTagIds,\n  availableTags,\n  linkedPrs = [],\n  linkedIssues = [],\n  onTagsChange,\n  onCreateTag,\n  renderAddTagControl,\n  disabled,\n  className,\n}: IssueTagsRowProps<TTag>) {\n  const selectedTags = availableTags.filter((tag) =>\n    selectedTagIds.includes(tag.id)\n  );\n\n  const handleTagToggle = (tagId: string) => {\n    if (selectedTagIds.includes(tagId)) {\n      onTagsChange(selectedTagIds.filter((id) => id !== tagId));\n    } else {\n      onTagsChange([...selectedTagIds, tagId]);\n    }\n  };\n\n  const handleCreateTag = (data: { name: string; color: string }): string => {\n    return onCreateTag?.(data) ?? '';\n  };\n\n  const addTagTrigger = (\n    <button\n      type=\"button\"\n      className=\"flex items-center justify-center h-5 w-5 rounded-sm text-low hover:text-normal hover:bg-panel transition-colors disabled:opacity-50\"\n      disabled={disabled}\n      aria-label=\"Add tag\"\n    >\n      <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n    </button>\n  );\n\n  return (\n    <div className={cn('flex items-center gap-half flex-wrap', className)}>\n      {/* Selected Tags - clickable to remove on hover */}\n      {selectedTags.map((tag) => (\n        <button\n          key={tag.id}\n          type=\"button\"\n          onClick={() => handleTagToggle(tag.id)}\n          disabled={disabled}\n          className={cn(\n            'inline-flex items-center justify-center',\n            'h-5 px-base gap-half',\n            'bg-panel rounded-sm',\n            'text-sm text-low font-medium',\n            'whitespace-nowrap',\n            'transition-colors',\n            !disabled &&\n              'hover:bg-error/20 hover:text-error hover:line-through cursor-pointer',\n            disabled && 'cursor-default'\n          )}\n        >\n          <span\n            className=\"w-2 h-2 rounded-full shrink-0\"\n            style={{ backgroundColor: `hsl(${tag.color})` }}\n          />\n          {tag.name}\n        </button>\n      ))}\n\n      {/* Linked PRs */}\n      {linkedPrs.map((pr) => (\n        <PrBadge\n          key={pr.id}\n          number={pr.number}\n          url={pr.url}\n          status={pr.status}\n        />\n      ))}\n\n      {/* Linked Issues */}\n      {linkedIssues.map((issue) => (\n        <button\n          key={issue.id}\n          type=\"button\"\n          className=\"inline-flex items-center gap-half h-5 px-base bg-panel rounded-sm text-sm text-low hover:text-normal transition-colors\"\n          title={issue.title}\n        >\n          <HashIcon className=\"size-icon-xs\" weight=\"bold\" />\n          <span>{issue.displayId}</span>\n        </button>\n      ))}\n\n      {/* Add Tag Dropdown */}\n      {onCreateTag &&\n        (renderAddTagControl?.({\n          tags: availableTags,\n          selectedTagIds,\n          onTagToggle: handleTagToggle,\n          onCreateTag: handleCreateTag,\n          disabled: disabled ?? false,\n          trigger: addTagTrigger,\n        }) ??\n          addTagTrigger)}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueWorkspaceCard.tsx",
    "content": "import { cn } from '../lib/cn';\nimport { useTranslation } from 'react-i18next';\nimport {\n  GitPullRequestIcon,\n  DotsThreeIcon,\n  LinkBreakIcon,\n  TrashIcon,\n  PlayIcon,\n  HandIcon,\n  TriangleIcon,\n  CircleIcon,\n} from '@phosphor-icons/react';\nimport { UserAvatar, type UserAvatarUser } from './UserAvatar';\nimport { RunningDots } from './RunningDots';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from './DropdownMenu';\n\nexport interface WorkspacePr {\n  number: number;\n  url: string;\n  status: 'open' | 'merged' | 'closed';\n}\n\nexport interface WorkspaceWithStats {\n  id: string;\n  localWorkspaceId: string | null;\n  name: string | null;\n  archived: boolean;\n  filesChanged: number;\n  linesAdded: number;\n  linesRemoved: number;\n  prs: WorkspacePr[];\n  owner: UserAvatarUser | null;\n  updatedAt: string;\n  isOwnedByCurrentUser: boolean;\n  isRunning?: boolean;\n  hasPendingApproval?: boolean;\n  hasRunningDevServer?: boolean;\n  hasUnseenActivity?: boolean;\n  latestProcessCompletedAt?: string;\n  latestProcessStatus?: 'running' | 'completed' | 'failed' | 'killed';\n}\n\nexport interface IssueWorkspaceCardProps {\n  workspace: WorkspaceWithStats;\n  onClick?: () => void;\n  onUnlink?: () => void;\n  onDelete?: () => void;\n  showOwner?: boolean;\n  showStatusBadge?: boolean;\n  showNoPrText?: boolean;\n  className?: string;\n}\n\nexport interface IssueWorkspaceCreateCardProps {\n  onClick?: () => void;\n  className?: string;\n  shouldAnimateCreateButton?: boolean;\n}\n\ninterface IssueWorkspaceCardContainerProps {\n  onClick?: () => void;\n  className?: string;\n  children: React.ReactNode;\n}\n\nfunction IssueWorkspaceCardContainer({\n  onClick,\n  className,\n  children,\n}: IssueWorkspaceCardContainerProps) {\n  return (\n    <div\n      className={cn(\n        'flex flex-col gap-half p-base bg-panel rounded-sm transition-all duration-150',\n        onClick && 'cursor-pointer hover:bg-secondary/70',\n        className\n      )}\n      onClick={\n        onClick\n          ? (e) => {\n              e.stopPropagation();\n              onClick();\n            }\n          : undefined\n      }\n      role={onClick ? 'button' : undefined}\n      tabIndex={onClick ? 0 : undefined}\n      onKeyDown={\n        onClick\n          ? (e) => {\n              if (e.key === 'Enter' || e.key === ' ') {\n                e.preventDefault();\n                e.stopPropagation();\n                onClick();\n              }\n            }\n          : undefined\n      }\n    >\n      {children}\n    </div>\n  );\n}\n\nexport function IssueWorkspaceCard({\n  workspace,\n  onClick,\n  onUnlink,\n  onDelete,\n  showOwner = true,\n  showStatusBadge = true,\n  showNoPrText = true,\n  className,\n}: IssueWorkspaceCardProps) {\n  const { t } = useTranslation('common');\n  const timeAgo = getTimeAgo(\n    workspace.latestProcessCompletedAt ?? workspace.updatedAt\n  );\n  const isRunning = workspace.isRunning ?? false;\n  const hasPendingApproval = workspace.hasPendingApproval ?? false;\n  const hasRunningDevServer = workspace.hasRunningDevServer ?? false;\n  const hasUnseenActivity = workspace.hasUnseenActivity ?? false;\n  const isFailed =\n    workspace.latestProcessStatus === 'failed' ||\n    workspace.latestProcessStatus === 'killed';\n  const hasLiveStatusIndicator =\n    hasRunningDevServer ||\n    isFailed ||\n    isRunning ||\n    (hasUnseenActivity && !isRunning);\n\n  return (\n    <IssueWorkspaceCardContainer onClick={onClick} className={className}>\n      {/* Row 1: Status badge + Name (left), Owner avatar + menu (right) */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-half min-w-0\">\n          {showStatusBadge && (\n            <span\n              className={cn(\n                'px-1.5 py-0.5 rounded text-xs font-medium shrink-0',\n                workspace.archived\n                  ? 'bg-secondary text-low'\n                  : 'bg-success/10 text-success'\n              )}\n            >\n              {workspace.archived\n                ? t('workspaces.archived')\n                : t('workspaces.active')}\n            </span>\n          )}\n          {workspace.name && (\n            <span className=\"text-sm text-high truncate\">{workspace.name}</span>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-half\">\n          {showOwner && workspace.owner && (\n            <UserAvatar\n              user={workspace.owner}\n              className=\"h-5 w-5 text-[10px] border-2 border-panel\"\n            />\n          )}\n          {(onUnlink || onDelete) && (\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <button\n                  onClick={(e) => e.stopPropagation()}\n                  className=\"p-0.5 rounded hover:bg-secondary transition-colors\"\n                  aria-label={t('workspaces.more')}\n                >\n                  <DotsThreeIcon\n                    className=\"size-icon-xs text-low\"\n                    weight=\"bold\"\n                  />\n                </button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                {onUnlink && (\n                  <DropdownMenuItem\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      onUnlink();\n                    }}\n                  >\n                    <LinkBreakIcon className=\"size-icon-xs\" />\n                    {t('workspaces.unlinkFromIssue')}\n                  </DropdownMenuItem>\n                )}\n                {onDelete && (\n                  <DropdownMenuItem\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      onDelete();\n                    }}\n                    className=\"text-destructive focus:text-destructive\"\n                  >\n                    <TrashIcon className=\"size-icon-xs\" />\n                    {t('workspaces.deleteWorkspace')}\n                  </DropdownMenuItem>\n                )}\n              </DropdownMenuContent>\n            </DropdownMenu>\n          )}\n        </div>\n      </div>\n\n      {/* Row 2: Live status + stats (left), PR buttons (right) */}\n      <div className=\"flex items-center justify-between gap-half min-w-0\">\n        <div className=\"flex items-center flex-wrap sm:flex-nowrap gap-half text-sm text-low min-w-0 flex-1 overflow-hidden\">\n          <div className=\"flex items-center gap-half shrink-0\">\n            {hasRunningDevServer && (\n              <PlayIcon\n                className=\"size-icon-xs text-brand shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n\n            {!isRunning && isFailed && (\n              <TriangleIcon\n                className=\"size-icon-xs text-error shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n\n            {isRunning &&\n              (hasPendingApproval ? (\n                <HandIcon\n                  className=\"size-icon-xs text-brand shrink-0\"\n                  weight=\"fill\"\n                />\n              ) : (\n                <RunningDots />\n              ))}\n\n            {hasUnseenActivity && !isRunning && !isFailed && (\n              <CircleIcon\n                className=\"size-icon-xs text-brand shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n          </div>\n\n          {hasLiveStatusIndicator && (\n            <span className=\"text-low/50 shrink-0\">·</span>\n          )}\n\n          <span className=\"whitespace-nowrap shrink-0\">{timeAgo}</span>\n          {workspace.filesChanged > 0 && (\n            <>\n              <span className=\"text-low/50 shrink-0\">·</span>\n              <span className=\"whitespace-nowrap shrink-0\">\n                {t('workspaces.filesChanged', {\n                  count: workspace.filesChanged,\n                })}\n              </span>\n            </>\n          )}\n          {workspace.linesAdded > 0 && (\n            <>\n              <span className=\"text-low/50 shrink-0\">·</span>\n              <span className=\"text-success whitespace-nowrap shrink-0\">\n                +{workspace.linesAdded}\n              </span>\n            </>\n          )}\n          {workspace.linesRemoved > 0 && (\n            <>\n              <span className=\"text-low/50 shrink-0\">·</span>\n              <span className=\"text-error whitespace-nowrap shrink-0\">\n                -{workspace.linesRemoved}\n              </span>\n            </>\n          )}\n        </div>\n\n        <div className=\"hidden sm:flex items-center gap-half shrink-0\">\n          {workspace.prs.length > 0 ? (\n            workspace.prs.map((pr) => (\n              <a\n                key={pr.number}\n                href={pr.url}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                onClick={(e) => e.stopPropagation()}\n                className={cn(\n                  'flex items-center gap-half px-1.5 py-0.5 rounded text-xs font-medium transition-colors',\n                  pr.status === 'merged'\n                    ? 'bg-merged/10 text-merged hover:bg-merged/20'\n                    : pr.status === 'closed'\n                      ? 'bg-error/10 text-error hover:bg-error/20'\n                      : 'bg-success/10 text-success hover:bg-success/20'\n                )}\n              >\n                <GitPullRequestIcon className=\"size-icon-2xs\" weight=\"bold\" />\n                <span>#{pr.number}</span>\n              </a>\n            ))\n          ) : showNoPrText ? (\n            <span className=\"text-xs text-low whitespace-nowrap\">\n              {t('kanban.noPrCreated')}\n            </span>\n          ) : null}\n        </div>\n      </div>\n    </IssueWorkspaceCardContainer>\n  );\n}\n\nexport function IssueWorkspaceCreateCard({\n  onClick,\n  className,\n  shouldAnimateCreateButton = false,\n}: IssueWorkspaceCreateCardProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <IssueWorkspaceCardContainer\n      className={cn('border border-dashed border-border', className)}\n    >\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-half min-w-0\">\n          <span className=\"px-1.5 py-0.5 rounded text-xs font-medium shrink-0 bg-secondary text-low\">\n            {t('workspaces.draft')}\n          </span>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-base\">\n        <span className=\"text-sm text-low truncate\">\n          {t('workspaces.newWorkspace')}\n        </span>\n        <button\n          type=\"button\"\n          onClick={onClick}\n          disabled={!onClick}\n          className={cn(\n            'shrink-0 rounded-sm px-base py-half text-cta h-cta flex items-center bg-brand-secondary text-on-brand hover:bg-brand-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed',\n            shouldAnimateCreateButton && 'create-issue-attention'\n          )}\n        >\n          {t('buttons.create')}\n        </button>\n      </div>\n    </IssueWorkspaceCardContainer>\n  );\n}\n\nfunction getTimeAgo(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMins = Math.floor(diffMs / (1000 * 60));\n  const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n  const diffWeeks = Math.floor(diffDays / 7);\n\n  if (diffMins < 1) return 'just now';\n  if (diffMins < 60) return `${diffMins}m ago`;\n  if (diffHours < 24) return `${diffHours}h ago`;\n  if (diffDays < 7) return `${diffDays}d ago`;\n  return `${diffWeeks}w ago`;\n}\n"
  },
  {
    "path": "packages/ui/src/components/IssueWorkspacesSection.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport {\n  IssueWorkspaceCard,\n  IssueWorkspaceCreateCard,\n  type WorkspaceWithStats,\n} from './IssueWorkspaceCard';\nimport {\n  CollapsibleSectionHeader,\n  type SectionAction,\n} from './CollapsibleSectionHeader';\n\nexport interface IssueWorkspacesSectionProps {\n  workspaces: WorkspaceWithStats[];\n  isLoading?: boolean;\n  actions?: SectionAction[];\n  onWorkspaceClick?: (localWorkspaceId: string | null) => void;\n  onCreateWorkspace?: () => void;\n  onUnlinkWorkspace?: (localWorkspaceId: string) => void;\n  onDeleteWorkspace?: (localWorkspaceId: string) => void;\n  shouldAnimateCreateButton?: boolean;\n}\n\n/**\n * View component for the workspaces section in the issue panel.\n * Displays a collapsible list of workspace cards.\n */\nexport function IssueWorkspacesSection({\n  workspaces,\n  isLoading,\n  actions = [],\n  onWorkspaceClick,\n  onCreateWorkspace,\n  onUnlinkWorkspace,\n  onDeleteWorkspace,\n  shouldAnimateCreateButton = false,\n}: IssueWorkspacesSectionProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <CollapsibleSectionHeader\n      title={t('workspaces.title')}\n      persistKey=\"kanban-issue-workspaces\"\n      defaultExpanded={true}\n      actions={actions}\n    >\n      <div className=\"px-base p-base flex flex-col gap-base border-t\">\n        {isLoading ? (\n          <p className=\"text-low py-half\">{t('workspaces.loading')}</p>\n        ) : workspaces.length === 0 ? (\n          <IssueWorkspaceCreateCard\n            onClick={onCreateWorkspace}\n            shouldAnimateCreateButton={shouldAnimateCreateButton}\n          />\n        ) : (\n          workspaces.map((workspace) => {\n            const { localWorkspaceId } = workspace;\n            return (\n              <IssueWorkspaceCard\n                key={workspace.id}\n                workspace={workspace}\n                onClick={\n                  onWorkspaceClick &&\n                  localWorkspaceId &&\n                  workspace.isOwnedByCurrentUser\n                    ? () => onWorkspaceClick(localWorkspaceId)\n                    : undefined\n                }\n                onUnlink={\n                  onUnlinkWorkspace && localWorkspaceId\n                    ? () => onUnlinkWorkspace(localWorkspaceId)\n                    : undefined\n                }\n                onDelete={\n                  onDeleteWorkspace &&\n                  localWorkspaceId &&\n                  workspace.isOwnedByCurrentUser\n                    ? () => onDeleteWorkspace(localWorkspaceId)\n                    : undefined\n                }\n              />\n            );\n          })\n        )}\n      </div>\n    </CollapsibleSectionHeader>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/KanbanAssignee.tsx",
    "content": "'use client';\n\nimport { UsersIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\n\nconst MAX_VISIBLE_AVATARS = 2;\n\nexport type KanbanAssigneeUser = {\n  user_id: string;\n  first_name?: string | null;\n  last_name?: string | null;\n  username?: string | null;\n  avatar_url?: string | null;\n};\n\nexport type KanbanAssigneeProps = {\n  assignees: KanbanAssigneeUser[];\n  className?: string;\n};\n\nconst buildOptimizedImageUrl = (rawUrl: string): string => {\n  try {\n    const url = new URL(rawUrl);\n    url.searchParams.set('width', '64');\n    url.searchParams.set('height', '64');\n    url.searchParams.set('fit', 'crop');\n    url.searchParams.set('quality', '80');\n    return url.toString();\n  } catch {\n    const separator = rawUrl.includes('?') ? '&' : '?';\n    return `${rawUrl}${separator}width=64&height=64&fit=crop&quality=80`;\n  }\n};\n\nconst buildInitials = (user: KanbanAssigneeUser): string => {\n  const first = user.first_name?.trim().charAt(0)?.toUpperCase() ?? '';\n  const last = user.last_name?.trim().charAt(0)?.toUpperCase() ?? '';\n\n  if (first || last) {\n    return `${first}${last}`.trim() || first || last || '?';\n  }\n\n  const handle = user.username?.trim().charAt(0)?.toUpperCase();\n  return handle ?? '?';\n};\n\nconst buildLabel = (user: KanbanAssigneeUser): string => {\n  const name = [user.first_name, user.last_name]\n    .filter((value): value is string => Boolean(value && value.trim()))\n    .join(' ');\n\n  if (name) return name;\n  if (user.username?.trim()) return user.username;\n  return 'User';\n};\n\nconst handleImageError = (event: React.SyntheticEvent<HTMLImageElement>) => {\n  const img = event.currentTarget;\n  img.style.display = 'none';\n  const fallback = img.nextElementSibling;\n  if (fallback instanceof HTMLElement) {\n    fallback.style.display = 'flex';\n  }\n};\n\nconst AssigneeAvatar = ({ user }: { user: KanbanAssigneeUser }) => {\n  const initials = buildInitials(user);\n  const label = buildLabel(user);\n  const imageUrl = user.avatar_url\n    ? buildOptimizedImageUrl(user.avatar_url)\n    : null;\n\n  return (\n    <Tooltip content={label}>\n      <div\n        className={cn(\n          'flex size-icon-base shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-secondary text-xs font-medium text-low',\n          'h-5 w-5 text-[10px] ring-1 ring-background'\n        )}\n        aria-label={label}\n      >\n        {imageUrl && (\n          <img\n            src={imageUrl}\n            alt={label}\n            className=\"h-full w-full object-cover\"\n            loading=\"lazy\"\n            onError={handleImageError}\n          />\n        )}\n        <span style={imageUrl ? { display: 'none' } : undefined}>\n          {initials}\n        </span>\n      </div>\n    </Tooltip>\n  );\n};\n\nexport const KanbanAssignee = ({\n  assignees,\n  className,\n}: KanbanAssigneeProps) => {\n  if (assignees.length === 0) {\n    return (\n      <div\n        className={cn('flex items-center justify-center', 'h-6 w-6', className)}\n        aria-label=\"Unassigned\"\n      >\n        <UsersIcon className=\"size-icon-xs text-low\" weight=\"bold\" />\n      </div>\n    );\n  }\n\n  const visibleAssignees = assignees.slice(0, MAX_VISIBLE_AVATARS);\n  const remainingCount = assignees.length - MAX_VISIBLE_AVATARS;\n\n  return (\n    <div className={cn('flex items-center h-6', className)}>\n      <div className=\"flex -space-x-1\">\n        {visibleAssignees.map((assignee) => (\n          <AssigneeAvatar key={assignee.user_id} user={assignee} />\n        ))}\n      </div>\n      {remainingCount > 0 && (\n        <span className=\"ml-half text-xs text-low\">+{remainingCount}</span>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/components/KanbanBadge.tsx",
    "content": "'use client';\n\nimport { cn } from '../lib/cn';\n\nexport type KanbanBadgeProps = {\n  name: string;\n  color?: string;\n  className?: string;\n};\n\nexport const KanbanBadge = ({ name, color, className }: KanbanBadgeProps) => {\n  return (\n    <span\n      className={cn(\n        'inline-flex items-center justify-center',\n        'h-5 px-base gap-half',\n        'bg-panel rounded-sm',\n        'text-sm text-low font-medium',\n        'whitespace-nowrap',\n        className\n      )}\n    >\n      {color && (\n        <span\n          className=\"w-2 h-2 rounded-full shrink-0\"\n          style={{ backgroundColor: `hsl(${color})` }}\n        />\n      )}\n      {name}\n    </span>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/components/KanbanBoard.tsx",
    "content": "'use client';\n\nimport { Card } from './Card';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from './RadixTooltip';\nimport { cn } from '../lib/cn';\nimport {\n  DragDropContext,\n  Droppable,\n  Draggable,\n  type DropResult,\n  type DraggableProvided,\n  type DraggableStateSnapshot,\n  type DroppableProvided,\n} from '@hello-pangea/dnd';\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type MutableRefObject,\n  type ReactNode,\n  type Ref,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { DotsSixVerticalIcon, PlusIcon } from '@phosphor-icons/react';\nimport { Button } from './Button';\n\nexport type { DropResult } from '@hello-pangea/dnd';\n\nexport type Status = {\n  id: string;\n  name: string;\n  color: string;\n};\n\nexport type Feature = {\n  id: string;\n  name: string;\n  startAt: Date;\n  endAt: Date;\n  status: Status;\n};\n\n// =============================================================================\n// Kanban Board (Droppable Column)\n// =============================================================================\n\nexport type KanbanBoardProps = {\n  children: ReactNode;\n  className?: string;\n};\n\nexport const KanbanBoard = ({ children, className }: KanbanBoardProps) => {\n  return (\n    <div className={cn('flex flex-col min-h-40', className)}>{children}</div>\n  );\n};\n\n// =============================================================================\n// Kanban Card (Draggable)\n// =============================================================================\n\nexport type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {\n  index: number;\n  children?: ReactNode;\n  className?: string;\n  onClick?: (e: MouseEvent<HTMLDivElement>) => void;\n  tabIndex?: number;\n  forwardedRef?: Ref<HTMLDivElement>;\n  onKeyDown?: (e: KeyboardEvent) => void;\n  isOpen?: boolean;\n  isSelected?: boolean;\n  dragDisabled?: boolean;\n  isMobile?: boolean;\n};\n\nexport const KanbanCard = ({\n  id,\n  name,\n  index,\n  children,\n  className,\n  onClick,\n  tabIndex,\n  forwardedRef,\n  onKeyDown,\n  isOpen,\n  isSelected,\n  dragDisabled = false,\n  isMobile,\n}: KanbanCardProps) => {\n  return (\n    <Draggable draggableId={id} index={index} isDragDisabled={dragDisabled}>\n      {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {\n        // Combine DnD ref and forwarded ref\n        const setRefs = (node: HTMLDivElement | null) => {\n          provided.innerRef(node);\n          if (typeof forwardedRef === 'function') {\n            forwardedRef(node);\n          } else if (forwardedRef && typeof forwardedRef === 'object') {\n            (forwardedRef as MutableRefObject<HTMLDivElement | null>).current =\n              node;\n          }\n        };\n\n        return (\n          <Card\n            className={cn(\n              'p-base outline-none flex-col border -mt-[1px] -mx-[1px] bg-primary',\n              snapshot.isDragging && 'cursor-grabbing shadow-lg',\n              isSelected\n                ? 'ring-2 ring-accent ring-inset bg-accent/5'\n                : isOpen && 'ring-2 ring-secondary-foreground ring-inset',\n              className\n            )}\n            ref={setRefs}\n            {...provided.draggableProps}\n            {...(isMobile ? {} : provided.dragHandleProps)}\n            tabIndex={tabIndex}\n            onClick={\n              isMobile\n                ? (e) => {\n                    if (!snapshot.isDragging) onClick?.(e);\n                  }\n                : undefined\n            }\n            onMouseUp={\n              !isMobile\n                ? (e) => {\n                    if (e.button === 0 && !snapshot.isDragging) {\n                      onClick?.(e);\n                    }\n                  }\n                : undefined\n            }\n            onKeyDown={onKeyDown}\n          >\n            {isMobile ? (\n              <div className=\"flex gap-half\">\n                <div\n                  {...provided.dragHandleProps}\n                  className=\"flex items-start pt-half cursor-grab shrink-0\"\n                  onClick={(e) => e.stopPropagation()}\n                >\n                  <DotsSixVerticalIcon\n                    className=\"size-icon-xs text-low\"\n                    weight=\"bold\"\n                  />\n                </div>\n                <div className=\"flex-1 min-w-0\">\n                  {children ?? (\n                    <p className=\"m-0 font-medium text-sm\">{name}</p>\n                  )}\n                </div>\n              </div>\n            ) : (\n              (children ?? <p className=\"m-0 font-medium text-sm\">{name}</p>)\n            )}\n          </Card>\n        );\n      }}\n    </Draggable>\n  );\n};\n\n// =============================================================================\n// Kanban Cards Container\n// =============================================================================\n\nexport type KanbanCardsProps = {\n  id: string;\n  children: ReactNode;\n  className?: string;\n};\n\nexport const KanbanCards = ({ id, children, className }: KanbanCardsProps) => (\n  <Droppable droppableId={id}>\n    {(provided: DroppableProvided) => (\n      <div\n        className={cn('flex flex-1 flex-col', className)}\n        ref={provided.innerRef}\n        {...provided.droppableProps}\n      >\n        {children}\n        {provided.placeholder}\n      </div>\n    )}\n  </Droppable>\n);\n\n// =============================================================================\n// Kanban Header\n// =============================================================================\n\nexport type KanbanHeaderProps =\n  | {\n      children: ReactNode;\n    }\n  | {\n      name: Status['name'];\n      color: Status['color'];\n      className?: string;\n      onAddTask?: () => void;\n    };\n\nexport const KanbanHeader = (props: KanbanHeaderProps) => {\n  const { t } = useTranslation('tasks');\n\n  if ('children' in props) {\n    return props.children;\n  }\n\n  return (\n    <Card\n      className={cn(\n        'sticky top-0 z-20 flex shrink-0 items-center gap-base p-base flex gap-base',\n        'bg-background',\n        props.className\n      )}\n      style={{\n        backgroundImage: `linear-gradient(hsl(var(${props.color}) / 0.03), hsl(var(${props.color}) / 0.03))`,\n      }}\n    >\n      <span className=\"flex-1 flex items-center gap-base\">\n        <div\n          className=\"h-2 w-2 rounded-full\"\n          style={{ backgroundColor: `hsl(var(${props.color}))` }}\n        />\n\n        <p className=\"m-0 text-sm\">{props.name}</p>\n      </span>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              className=\"m-0 p-0 h-0 text-foreground/50 hover:text-foreground\"\n              onClick={props.onAddTask}\n              aria-label={t('actions.addTask')}\n            >\n              <PlusIcon className=\"h-4 w-4\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent side=\"top\">{t('actions.addTask')}</TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    </Card>\n  );\n};\n\n// =============================================================================\n// Kanban Provider (DragDropContext)\n// =============================================================================\n\nexport type KanbanProviderProps = {\n  children: ReactNode;\n  onDragEnd: (result: DropResult) => void;\n  className?: string;\n};\n\nexport const KanbanProvider = ({\n  children,\n  onDragEnd,\n  className,\n}: KanbanProviderProps) => {\n  return (\n    <DragDropContext onDragEnd={onDragEnd}>\n      <div\n        className={cn(\n          'inline-grid grid-flow-col auto-cols-[minmax(200px,400px)] divide-x border-x items-stretch min-h-full',\n          className\n        )}\n      >\n        {children}\n      </div>\n    </DragDropContext>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/components/KanbanCardContent.tsx",
    "content": "'use client';\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  CircleDashedIcon,\n  DotsThreeIcon,\n  PlusIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { PriorityIcon, type PriorityLevel } from './PriorityIcon';\nimport { KanbanBadge } from './KanbanBadge';\nimport { KanbanAssignee, type KanbanAssigneeUser } from './KanbanAssignee';\nimport { RunningDots } from './RunningDots';\nimport { PrBadge, type PrBadgeStatus } from './PrBadge';\nimport {\n  RelationshipBadge,\n  type RelationshipDisplayType,\n} from './RelationshipBadge';\n\nexport interface KanbanTag {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface KanbanRelationship {\n  relationshipId: string;\n  displayType: RelationshipDisplayType;\n  relatedIssueDisplayId: string;\n}\n\nexport interface KanbanPullRequest {\n  id: string;\n  number: number;\n  url: string;\n  status: PrBadgeStatus;\n}\n\nexport interface TagEditRenderProps<TTag extends KanbanTag = KanbanTag> {\n  allTags: TTag[];\n  selectedTagIds: string[];\n  onTagToggle: (tagId: string) => void;\n  onCreateTag: (data: { name: string; color: string }) => string;\n  trigger: ReactNode;\n}\n\nexport interface TagEditProps<TTag extends KanbanTag = KanbanTag> {\n  allTags: TTag[];\n  selectedTagIds: string[];\n  onTagToggle: (tagId: string) => void;\n  onCreateTag: (data: { name: string; color: string }) => string;\n  renderTagEditor?: (props: TagEditRenderProps<TTag>) => ReactNode;\n}\n\nconst IMAGE_FILE_EXTENSION_REGEX =\n  /\\.(png|jpe?g|gif|webp|bmp|svg|avif|heic|heif)$/i;\n\nfunction isImageLikeAttachmentName(name: string): boolean {\n  const normalized = name.trim();\n  if (!normalized) {\n    return false;\n  }\n\n  return IMAGE_FILE_EXTENSION_REGEX.test(normalized);\n}\n\nfunction formatKanbanDescriptionPreview(\n  markdown: string,\n  options: {\n    codeBlockLabel: string;\n    imageLabel: string;\n    imageWithNameLabel: (name: string) => string;\n    fileLabel: string;\n    fileWithNameLabel: (name: string) => string;\n  }\n): string {\n  return markdown\n    .replace(/```[\\s\\S]*?```/g, options.codeBlockLabel)\n    .replace(\n      /!\\[([^\\]]*)\\]\\(([^)]+)\\)/g,\n      (_match, altText: string, url: string) => {\n        const normalizedAlt = altText.trim();\n        const normalizedUrl = url.trim();\n        const isImageAttachment =\n          normalizedUrl.startsWith('attachment://') &&\n          isImageLikeAttachmentName(normalizedAlt);\n\n        if (isImageAttachment) {\n          return normalizedAlt\n            ? options.imageWithNameLabel(normalizedAlt)\n            : options.imageLabel;\n        }\n\n        return normalizedAlt\n          ? options.fileWithNameLabel(normalizedAlt)\n          : options.fileLabel;\n      }\n    )\n    .replace(\n      /(?<!!)\\[([^\\]]*)\\]\\((attachment:\\/\\/[^)]+|\\.vibe-attachments\\/[^)]+)\\)/g,\n      (_match, label: string) => {\n        const normalizedLabel = label.trim();\n        return normalizedLabel\n          ? options.fileWithNameLabel(normalizedLabel)\n          : options.fileLabel;\n      }\n    )\n    .replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '$1')\n    .replace(/^#{1,6}\\s+/gm, '')\n    .replace(/^\\s*>\\s?/gm, '')\n    .replace(/^\\s*([-*+]|\\d+\\.)\\s+/gm, '')\n    .replace(/`([^`]+)`/g, '$1')\n    .replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n    .replace(/__([^_]+)__/g, '$1')\n    .replace(/\\*([^*]+)\\*/g, '$1')\n    .replace(/_([^_]+)_/g, '$1')\n    .replace(/~~([^~]+)~~/g, '$1')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nexport type KanbanCardContentProps<TTag extends KanbanTag = KanbanTag> = {\n  displayId: string;\n  title: string;\n  description?: string | null;\n  priority: PriorityLevel | null;\n  tags: KanbanTag[];\n  assignees: KanbanAssigneeUser[];\n  pullRequests?: KanbanPullRequest[];\n  relationships?: KanbanRelationship[];\n  isSubIssue?: boolean;\n  isLoading?: boolean;\n  className?: string;\n  onPriorityClick?: (e: MouseEvent) => void;\n  onAssigneeClick?: (e: MouseEvent) => void;\n  onMoreActionsClick?: () => void;\n  tagEditProps?: TagEditProps<TTag>;\n  isMobile?: boolean;\n};\n\nexport function KanbanCardContent<TTag extends KanbanTag = KanbanTag>({\n  displayId,\n  title,\n  description,\n  priority,\n  tags,\n  assignees,\n  pullRequests = [],\n  relationships = [],\n  isSubIssue,\n  isLoading = false,\n  className,\n  onPriorityClick,\n  onAssigneeClick,\n  onMoreActionsClick,\n  tagEditProps,\n  isMobile,\n}: KanbanCardContentProps<TTag>) {\n  const { t } = useTranslation('common');\n  const previewDescription = useMemo(() => {\n    if (!description) {\n      return null;\n    }\n\n    const formatted = formatKanbanDescriptionPreview(description, {\n      codeBlockLabel: t('kanban.previewCodeBlock'),\n      imageLabel: t('kanban.previewImage'),\n      imageWithNameLabel: (name: string) =>\n        t('kanban.previewImageWithName', { name }),\n      fileLabel: t('kanban.previewFile'),\n      fileWithNameLabel: (name: string) =>\n        t('kanban.previewFileWithName', { name }),\n    });\n    return formatted.length > 0 ? formatted : null;\n  }, [description, t]);\n\n  const tagsDisplay = (\n    <>\n      {tags.slice(0, 2).map((tag) => (\n        <KanbanBadge key={tag.id} name={tag.name} color={tag.color} />\n      ))}\n      {tags.length > 2 && (\n        <span className=\"text-sm text-low\">+{tags.length - 2}</span>\n      )}\n      {tagEditProps && tags.length === 0 && (\n        <PlusIcon className=\"size-icon-xs text-low\" weight=\"bold\" />\n      )}\n    </>\n  );\n  const tagEditorTrigger = (\n    <button\n      type=\"button\"\n      onClick={(e) => e.stopPropagation()}\n      className=\"flex items-center gap-half cursor-pointer hover:bg-secondary rounded-sm transition-colors\"\n    >\n      {tagsDisplay}\n    </button>\n  );\n\n  return (\n    <div className={cn('flex flex-col gap-half min-w-0', className)}>\n      {/* Row 1: Task ID + sub-issue indicator + loading dots + more actions */}\n      <div className=\"flex items-center justify-between gap-half\">\n        <div className=\"flex items-center gap-half min-w-0\">\n          {isSubIssue && (\n            <span className=\"text-sm text-low\">\n              {t('kanban.subIssueIndicator')}\n            </span>\n          )}\n          <span className=\"font-ibm-plex-mono text-sm text-low truncate\">\n            {displayId}\n          </span>\n          {isLoading && <RunningDots />}\n        </div>\n        {onMoreActionsClick && (\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation();\n              onMoreActionsClick();\n            }}\n            onMouseDown={(e) => e.stopPropagation()}\n            className={cn(\n              'p-half -m-half rounded-sm text-low hover:text-normal hover:bg-secondary shrink-0',\n              isMobile\n                ? ''\n                : 'invisible opacity-0 group-hover:visible group-hover:opacity-100',\n              'transition-[opacity,color,background-color]'\n            )}\n            aria-label=\"More actions\"\n            title=\"More actions\"\n          >\n            <DotsThreeIcon className=\"size-icon-xs\" weight=\"bold\" />\n          </button>\n        )}\n      </div>\n\n      {/* Row 2: Title */}\n      <span className=\"text-base text-normal truncate\">{title}</span>\n\n      {/* Row 3: Description (optional, truncated) */}\n      {previewDescription && (\n        <p\n          className={cn(\n            'text-sm text-low m-0',\n            isMobile\n              ? 'leading-tight line-clamp-2'\n              : 'leading-relaxed line-clamp-4'\n          )}\n        >\n          {previewDescription}\n        </p>\n      )}\n\n      {/* Row 4: Priority + Assignee */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-half min-w-0\">\n          {onPriorityClick ? (\n            <button\n              type=\"button\"\n              onClick={onPriorityClick}\n              onMouseDown={(e) => e.stopPropagation()}\n              className=\"flex items-center cursor-pointer hover:bg-secondary rounded-sm transition-colors\"\n            >\n              <PriorityIcon priority={priority} />\n              {!priority && (\n                <CircleDashedIcon\n                  className=\"size-icon-xs text-low\"\n                  weight=\"bold\"\n                />\n              )}\n            </button>\n          ) : (\n            <PriorityIcon priority={priority} />\n          )}\n        </div>\n        {onAssigneeClick ? (\n          <button\n            type=\"button\"\n            onClick={onAssigneeClick}\n            onMouseDown={(e) => e.stopPropagation()}\n            className=\"cursor-pointer hover:bg-secondary rounded-sm transition-colors\"\n          >\n            <KanbanAssignee assignees={assignees} />\n          </button>\n        ) : (\n          <KanbanAssignee assignees={assignees} />\n        )}\n      </div>\n\n      {/* Row 5: Tags, PRs, Relationships (own row to prevent overflow) */}\n      {(tags.length > 0 ||\n        tagEditProps ||\n        pullRequests.length > 0 ||\n        relationships.length > 0) && (\n        <div className=\"flex items-center gap-half flex-wrap min-w-0\">\n          {tagEditProps ? (\n            (tagEditProps.renderTagEditor?.({\n              allTags: tagEditProps.allTags,\n              selectedTagIds: tagEditProps.selectedTagIds,\n              onTagToggle: tagEditProps.onTagToggle,\n              onCreateTag: tagEditProps.onCreateTag,\n              trigger: tagEditorTrigger,\n            }) ?? tagEditorTrigger)\n          ) : (\n            <>\n              {tags.slice(0, 2).map((tag) => (\n                <KanbanBadge key={tag.id} name={tag.name} color={tag.color} />\n              ))}\n              {tags.length > 2 && (\n                <span className=\"text-sm text-low\">+{tags.length - 2}</span>\n              )}\n            </>\n          )}\n          {pullRequests.slice(0, 2).map((pr) => (\n            <PrBadge\n              key={pr.id}\n              number={pr.number}\n              url={pr.url}\n              status={pr.status}\n            />\n          ))}\n          {pullRequests.length > 2 && (\n            <span className=\"text-sm text-low\">+{pullRequests.length - 2}</span>\n          )}\n          {relationships.slice(0, 2).map((rel) => (\n            <RelationshipBadge\n              key={rel.relationshipId}\n              displayType={rel.displayType}\n              relatedIssueDisplayId={rel.relatedIssueDisplayId}\n              compact\n            />\n          ))}\n          {relationships.length > 2 && (\n            <span className=\"text-sm text-low\">\n              +{relationships.length - 2}\n            </span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/KanbanFilterBar.tsx",
    "content": "import { useState, type ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  ArrowLeftIcon,\n  FunnelIcon,\n  MagnifyingGlassIcon,\n  PlusIcon,\n  XIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport type { PriorityLevel } from './PriorityIcon';\nimport { InputField } from './InputField';\nimport { PrimaryButton } from './PrimaryButton';\nimport { ButtonGroup, ButtonGroupItem } from './IconButtonGroup';\n\nexport interface KanbanFilterTag {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface KanbanFilterUser {\n  user_id: string;\n  first_name?: string | null;\n  last_name?: string | null;\n  username?: string | null;\n  avatar_url?: string | null;\n}\n\nexport interface KanbanFilterState<TSortField extends string = string> {\n  searchQuery: string;\n  priorities: PriorityLevel[];\n  assigneeIds: string[];\n  tagIds: string[];\n  sortField: TSortField;\n  sortDirection: 'asc' | 'desc';\n}\n\nexport interface KanbanProjectViewIds {\n  TEAM: string;\n  PERSONAL: string;\n}\n\nconst DEFAULT_KANBAN_PROJECT_VIEW_IDS: KanbanProjectViewIds = {\n  TEAM: 'team',\n  PERSONAL: 'personal',\n};\n\nexport interface RenderKanbanFiltersDialogProps<\n  TTag extends KanbanFilterTag = KanbanFilterTag,\n  TUser extends KanbanFilterUser = KanbanFilterUser,\n  TSortField extends string = string,\n> {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  tags: TTag[];\n  users: TUser[];\n  projectId: string;\n  currentUserId: string | null;\n  filters: KanbanFilterState<TSortField>;\n  showSubIssues: boolean;\n  showWorkspaces: boolean;\n  onPrioritiesChange: (priorities: PriorityLevel[]) => void;\n  onAssigneesChange: (assigneeIds: string[]) => void;\n  onTagsChange: (tagIds: string[]) => void;\n  onSortChange: (sortField: TSortField, sortDirection: 'asc' | 'desc') => void;\n  onShowSubIssuesChange: (show: boolean) => void;\n  onShowWorkspacesChange: (show: boolean) => void;\n}\n\ninterface KanbanFilterBarProps<\n  TTag extends KanbanFilterTag = KanbanFilterTag,\n  TUser extends KanbanFilterUser = KanbanFilterUser,\n  TSortField extends string = string,\n> {\n  isFiltersDialogOpen: boolean;\n  onFiltersDialogOpenChange: (open: boolean) => void;\n  tags: TTag[];\n  users: TUser[];\n  activeViewId: string;\n  onViewChange: (viewId: string) => void;\n  viewIds?: KanbanProjectViewIds;\n  projectId: string;\n  currentUserId: string | null;\n  filters: KanbanFilterState<TSortField>;\n  showSubIssues: boolean;\n  showWorkspaces: boolean;\n  hasActiveFilters: boolean;\n  onSearchQueryChange: (searchQuery: string) => void;\n  onPrioritiesChange: (priorities: PriorityLevel[]) => void;\n  onAssigneesChange: (assigneeIds: string[]) => void;\n  onTagsChange: (tagIds: string[]) => void;\n  onSortChange: (sortField: TSortField, sortDirection: 'asc' | 'desc') => void;\n  onShowSubIssuesChange: (show: boolean) => void;\n  onShowWorkspacesChange: (show: boolean) => void;\n  onClearFilters: () => void;\n  onCreateIssue: () => void;\n  shouldAnimateCreateButton: boolean;\n  isMobile?: boolean;\n  renderFiltersDialog?: (\n    props: RenderKanbanFiltersDialogProps<TTag, TUser, TSortField>\n  ) => ReactNode;\n}\n\nexport function KanbanFilterBar<\n  TTag extends KanbanFilterTag = KanbanFilterTag,\n  TUser extends KanbanFilterUser = KanbanFilterUser,\n  TSortField extends string = string,\n>({\n  isFiltersDialogOpen,\n  onFiltersDialogOpenChange,\n  tags,\n  users,\n  activeViewId,\n  onViewChange,\n  viewIds = DEFAULT_KANBAN_PROJECT_VIEW_IDS,\n  projectId,\n  currentUserId,\n  filters,\n  showSubIssues,\n  showWorkspaces,\n  hasActiveFilters,\n  onSearchQueryChange,\n  onPrioritiesChange,\n  onAssigneesChange,\n  onTagsChange,\n  onSortChange,\n  onShowSubIssuesChange,\n  onShowWorkspacesChange,\n  onClearFilters,\n  onCreateIssue,\n  shouldAnimateCreateButton,\n  isMobile,\n  renderFiltersDialog,\n}: KanbanFilterBarProps<TTag, TUser, TSortField>) {\n  const { t } = useTranslation('common');\n  const [mobileSearchExpanded, setMobileSearchExpanded] = useState(false);\n\n  const handleClearSearch = () => {\n    onSearchQueryChange('');\n  };\n\n  return (\n    <>\n      {isMobile && mobileSearchExpanded ? (\n        <div className=\"flex items-center gap-half\">\n          <button\n            type=\"button\"\n            onClick={() => {\n              onSearchQueryChange('');\n              setMobileSearchExpanded(false);\n            }}\n            className=\"p-half rounded-sm text-low hover:text-normal hover:bg-secondary transition-colors shrink-0\"\n            aria-label={t('kanban.closeSearch', 'Close search')}\n          >\n            <ArrowLeftIcon className=\"size-icon-sm\" weight=\"bold\" />\n          </button>\n          <InputField\n            value={filters.searchQuery}\n            onChange={onSearchQueryChange}\n            placeholder={t('kanban.searchPlaceholder', 'Search issues...')}\n            variant=\"search\"\n            className=\"min-w-0 flex-1\"\n          />\n        </div>\n      ) : (\n        <div\n          className={cn(\n            'flex min-w-0 flex-wrap items-center',\n            isMobile ? 'gap-half' : 'gap-base'\n          )}\n        >\n          <ButtonGroup className=\"flex-wrap\">\n            <ButtonGroupItem\n              active={activeViewId === viewIds.TEAM}\n              onClick={() => onViewChange(viewIds.TEAM)}\n            >\n              {t('kanban.team', 'Team')}\n            </ButtonGroupItem>\n            <ButtonGroupItem\n              active={activeViewId === viewIds.PERSONAL}\n              onClick={() => onViewChange(viewIds.PERSONAL)}\n            >\n              {t('kanban.personal', 'Personal')}\n            </ButtonGroupItem>\n          </ButtonGroup>\n\n          {isMobile ? (\n            <button\n              type=\"button\"\n              onClick={() => setMobileSearchExpanded(true)}\n              className={cn(\n                'p-half rounded-sm transition-colors',\n                filters.searchQuery\n                  ? 'text-brand hover:text-brand'\n                  : 'text-low hover:text-normal hover:bg-secondary'\n              )}\n              aria-label={t('kanban.searchPlaceholder', 'Search issues...')}\n            >\n              <MagnifyingGlassIcon className=\"size-icon-sm\" weight=\"bold\" />\n            </button>\n          ) : (\n            <InputField\n              value={filters.searchQuery}\n              onChange={onSearchQueryChange}\n              placeholder={t('kanban.searchPlaceholder', 'Search issues...')}\n              variant=\"search\"\n              actionIcon={filters.searchQuery ? XIcon : undefined}\n              onAction={handleClearSearch}\n              className=\"min-w-[160px] w-[220px] max-w-full\"\n            />\n          )}\n\n          <button\n            type=\"button\"\n            onClick={() => onFiltersDialogOpenChange(true)}\n            className={cn(\n              'flex items-center justify-center p-half rounded-sm transition-colors',\n              hasActiveFilters\n                ? 'text-brand hover:text-brand'\n                : 'text-low hover:text-normal hover:bg-secondary'\n            )}\n            aria-label={t('kanban.filters', 'Open filters')}\n            title={t('kanban.filters', 'Open filters')}\n          >\n            <FunnelIcon className=\"size-icon-sm\" weight=\"bold\" />\n          </button>\n\n          {hasActiveFilters && (\n            <PrimaryButton\n              variant=\"tertiary\"\n              value={t('kanban.clearFilters', 'Clear filters')}\n              actionIcon={XIcon}\n              onClick={onClearFilters}\n            />\n          )}\n\n          {isMobile ? (\n            <button\n              type=\"button\"\n              onClick={() => onCreateIssue()}\n              className={cn(\n                'rounded-sm p-half bg-brand hover:bg-brand-hover text-on-brand transition-colors',\n                shouldAnimateCreateButton && 'create-issue-attention'\n              )}\n              aria-label={t('kanban.newIssue', 'New issue')}\n            >\n              <PlusIcon className=\"size-icon-sm\" weight=\"bold\" />\n            </button>\n          ) : (\n            <PrimaryButton\n              variant=\"secondary\"\n              value={t('kanban.newIssue', 'New issue')}\n              actionIcon={PlusIcon}\n              onClick={() => onCreateIssue()}\n              className={cn(\n                shouldAnimateCreateButton && 'create-issue-attention'\n              )}\n            />\n          )}\n        </div>\n      )}\n\n      {renderFiltersDialog?.({\n        open: isFiltersDialogOpen,\n        onOpenChange: onFiltersDialogOpenChange,\n        projectId,\n        currentUserId,\n        tags,\n        users,\n        filters,\n        showSubIssues,\n        showWorkspaces,\n        onPrioritiesChange,\n        onAssigneesChange,\n        onTagsChange,\n        onSortChange,\n        onShowSubIssuesChange,\n        onShowWorkspacesChange,\n      })}\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/KanbanIssuePanel.tsx",
    "content": "import {\n  useState,\n  useEffect,\n  useRef,\n  useCallback,\n  type ReactNode,\n  type RefObject,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { LocalAttachmentMetadata } from './WorkspaceContext';\nimport { cn } from '../lib/cn';\nimport {\n  XIcon,\n  LinkIcon,\n  DotsThreeIcon,\n  TrashIcon,\n  PaperclipIcon,\n  ImageIcon,\n  EyeIcon,\n  PencilSimpleIcon,\n} from '@phosphor-icons/react';\nimport {\n  IssueTagsRow,\n  type IssueTagBase,\n  type IssueTagsRowAddTagControlProps,\n  type LinkedPullRequest as IssueTagsLinkedPullRequest,\n} from './IssueTagsRow';\nimport { PrimaryButton } from './PrimaryButton';\nimport { Toggle } from './Toggle';\nimport {\n  IssuePropertyRow,\n  type IssuePropertyRowProps,\n} from './IssuePropertyRow';\nimport { IconButton } from './IconButton';\nimport { AutoResizeTextarea } from './AutoResizeTextarea';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from './RadixTooltip';\nimport { ErrorAlert } from './ErrorAlert';\n\nexport type IssuePanelMode = 'create' | 'edit';\ntype IssuePriority = IssuePropertyRowProps['priority'];\ntype IssueStatus = IssuePropertyRowProps['statuses'][number];\ntype IssueAssignee = NonNullable<\n  IssuePropertyRowProps['assigneeUsers']\n>[number];\ntype IssueCreator = Exclude<IssuePropertyRowProps['creatorUser'], undefined>;\nexport interface KanbanIssueTag extends IssueTagBase {\n  project_id: string;\n}\n\nexport interface IssueFormData {\n  title: string;\n  description: string | null;\n  statusId: string;\n  priority: IssuePriority | null;\n  assigneeIds: string[];\n  tagIds: string[];\n  createDraftWorkspace: boolean;\n}\n\nexport interface LinkedPullRequest extends IssueTagsLinkedPullRequest {}\n\nexport interface KanbanIssueDescriptionEditorProps {\n  placeholder: string;\n  value: string;\n  onChange: (value: string) => void;\n  onCmdEnter?: () => void;\n  onPasteFiles?: (files: File[]) => void;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  className?: string;\n  localAttachments?: LocalAttachmentMetadata[];\n  showStaticToolbar?: boolean;\n  saveStatus?: 'idle' | 'saved';\n  staticToolbarActions?: ReactNode;\n  onRequestEdit?: () => void;\n  hideActions?: boolean;\n}\n\nexport interface KanbanIssuePanelProps {\n  mode: IssuePanelMode;\n  displayId: string;\n\n  // Form data\n  formData: IssueFormData;\n  onFormChange: <K extends keyof IssueFormData>(\n    field: K,\n    value: IssueFormData[K]\n  ) => void;\n\n  // Options for dropdowns\n  statuses: IssueStatus[];\n  tags: KanbanIssueTag[];\n\n  // Resolved assignee profiles for avatar display\n  assigneeUsers?: IssueAssignee[];\n\n  // Edit mode data\n  issueId?: string | null;\n  creatorUser?: IssueCreator;\n  parentIssue?: { id: string; simpleId: string } | null;\n  onParentIssueClick?: () => void;\n  onRemoveParentIssue?: () => void;\n  linkedPrs?: LinkedPullRequest[];\n\n  // Actions\n  onClose: () => void;\n  onSubmit: () => void;\n  onCmdEnterSubmit?: () => void;\n  onDeleteDraft?: () => void;\n\n  // Tag create callback - returns the new tag ID\n  onCreateTag?: (data: { name: string; color: string }) => string;\n  renderAddTagControl?: (\n    props: IssueTagsRowAddTagControlProps<KanbanIssueTag>\n  ) => ReactNode;\n  renderDescriptionEditor: (\n    props: KanbanIssueDescriptionEditorProps\n  ) => ReactNode;\n\n  // Loading states\n  isSubmitting?: boolean;\n\n  // Save status for description field\n  descriptionSaveStatus?: 'idle' | 'saved';\n\n  // Ref for title input (created in container)\n  titleInputRef: RefObject<HTMLTextAreaElement>;\n\n  // Copy link callback (edit mode only)\n  onCopyLink?: () => void;\n\n  // More actions callback (edit mode only) - opens command bar with issue actions\n  onMoreActions?: () => void;\n\n  // Image attachment upload\n  onPasteFiles?: (files: File[]) => void;\n  localAttachments?: LocalAttachmentMetadata[];\n  dropzoneProps?: {\n    getRootProps: () => Record<string, unknown>;\n    getInputProps: () => Record<string, unknown>;\n    isDragActive: boolean;\n  };\n  onBrowseAttachment?: () => void;\n  isUploading?: boolean;\n  attachmentError?: string | null;\n  onDismissAttachmentError?: () => void;\n\n  // Edit-mode section renderers\n  renderWorkspacesSection?: (issueId: string) => ReactNode;\n  renderRelationshipsSection?: (issueId: string) => ReactNode;\n  renderSubIssuesSection?: (issueId: string) => ReactNode;\n  renderCommentsSection?: (issueId: string) => ReactNode;\n}\n\nexport function KanbanIssuePanel({\n  mode,\n  displayId,\n  formData,\n  onFormChange,\n  statuses,\n  tags,\n  assigneeUsers,\n  issueId,\n  creatorUser,\n  parentIssue,\n  onParentIssueClick,\n  onRemoveParentIssue,\n  linkedPrs = [],\n  onClose,\n  onSubmit,\n  onCmdEnterSubmit,\n  onDeleteDraft,\n  onCreateTag,\n  renderAddTagControl,\n  renderDescriptionEditor,\n  isSubmitting,\n  descriptionSaveStatus,\n  titleInputRef,\n  onCopyLink,\n  onMoreActions,\n  onPasteFiles,\n  localAttachments,\n  dropzoneProps,\n  onBrowseAttachment,\n  isUploading,\n  attachmentError,\n  onDismissAttachmentError,\n  renderWorkspacesSection,\n  renderRelationshipsSection,\n  renderSubIssuesSection,\n  renderCommentsSection,\n}: KanbanIssuePanelProps) {\n  const { t } = useTranslation('common');\n  const isCreateMode = mode === 'create';\n  const breadcrumbTextClass =\n    'min-w-0 text-sm text-normal truncate rounded-sm px-1 py-0.5 hover:bg-panel hover:text-high transition-colors';\n  const creatorName =\n    creatorUser?.first_name?.trim() || creatorUser?.username?.trim() || null;\n  const showCreator = !isCreateMode && Boolean(creatorName);\n\n  // Description edit state: in edit mode, show preview by default; in create mode, always editable\n  const [isDescriptionEditing, setIsDescriptionEditing] =\n    useState(isCreateMode);\n  const descriptionContainerRef = useRef<HTMLDivElement>(null);\n\n  // Reset description editing state when switching between create/edit mode or when issue changes\n  useEffect(() => {\n    setIsDescriptionEditing(isCreateMode);\n  }, [isCreateMode, issueId]);\n\n  // Click outside the description area to exit editing\n  const handleDescriptionBlur = useCallback(() => {\n    if (!isCreateMode) {\n      setIsDescriptionEditing(false);\n    }\n  }, [isCreateMode]);\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Escape') {\n      const target = e.target as HTMLElement;\n      const isEditable =\n        target.tagName === 'INPUT' ||\n        target.tagName === 'TEXTAREA' ||\n        target.isContentEditable;\n      if (isEditable) {\n        // If editing description, exit edit mode first\n        if (\n          isDescriptionEditing &&\n          !isCreateMode &&\n          descriptionContainerRef.current?.contains(target)\n        ) {\n          setIsDescriptionEditing(false);\n        }\n        target.blur();\n        (e.currentTarget as HTMLElement).focus();\n        e.stopPropagation();\n      } else {\n        onClose();\n      }\n    }\n  };\n\n  const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {\n      e.preventDefault();\n      onCmdEnterSubmit?.();\n    }\n  };\n\n  return (\n    <div\n      className=\"flex flex-col h-full overflow-hidden outline-none\"\n      onKeyDown={handleKeyDown}\n      tabIndex={-1}\n    >\n      {/* Header */}\n      <div className=\"flex items-center justify-between px-base py-half border-b shrink-0\">\n        <div className=\"flex items-center gap-half min-w-0 font-ibm-plex-mono\">\n          <span className={`${breadcrumbTextClass} shrink-0`}>{displayId}</span>\n          {!isCreateMode && onCopyLink && (\n            <button\n              type=\"button\"\n              onClick={onCopyLink}\n              className=\"p-half rounded-sm text-low hover:text-normal hover:bg-panel transition-colors\"\n              aria-label={t('kanban.copyLink')}\n            >\n              <LinkIcon className=\"size-icon-sm\" weight=\"bold\" />\n            </button>\n          )}\n        </div>\n        <div className=\"flex items-center gap-half\">\n          {!isCreateMode && onMoreActions && (\n            <button\n              type=\"button\"\n              onClick={onMoreActions}\n              className=\"p-half rounded-sm text-low hover:text-normal hover:bg-panel transition-colors\"\n              aria-label={t('kanban.moreActions')}\n            >\n              <DotsThreeIcon className=\"size-icon-sm\" weight=\"bold\" />\n            </button>\n          )}\n          <button\n            type=\"button\"\n            onClick={onClose}\n            className=\"p-half rounded-sm text-low hover:text-normal hover:bg-panel transition-colors\"\n            aria-label={t('kanban.closePanel')}\n          >\n            <XIcon className=\"size-icon-sm\" weight=\"bold\" />\n          </button>\n        </div>\n      </div>\n\n      {/* Scrollable Content */}\n      <div className=\"flex-1 overflow-y-auto\">\n        {/* Property Row */}\n        <div className=\"px-base py-base border-b\">\n          <IssuePropertyRow\n            statusId={formData.statusId}\n            priority={formData.priority}\n            assigneeIds={formData.assigneeIds}\n            assigneeUsers={assigneeUsers}\n            statuses={statuses}\n            creatorUser={showCreator ? creatorUser : undefined}\n            parentIssue={parentIssue}\n            onParentIssueClick={onParentIssueClick}\n            onRemoveParentIssue={onRemoveParentIssue}\n            onStatusClick={() => onFormChange('statusId', formData.statusId)}\n            onPriorityClick={() => onFormChange('priority', formData.priority)}\n            onAssigneeClick={() =>\n              onFormChange('assigneeIds', formData.assigneeIds)\n            }\n            disabled={isSubmitting}\n          />\n        </div>\n\n        {/* Tags Row */}\n        <div className=\"px-base py-base border-b\">\n          <IssueTagsRow\n            selectedTagIds={formData.tagIds}\n            availableTags={tags}\n            linkedPrs={isCreateMode ? [] : linkedPrs}\n            onTagsChange={(tagIds) => onFormChange('tagIds', tagIds)}\n            onCreateTag={onCreateTag}\n            renderAddTagControl={renderAddTagControl}\n            disabled={isSubmitting}\n          />\n        </div>\n\n        {/* Title and Description */}\n        <div className=\"rounded-sm\">\n          {/* Title Input */}\n          <div className=\"w-full mt-base\">\n            <AutoResizeTextarea\n              ref={titleInputRef}\n              value={formData.title}\n              onChange={(value) => onFormChange('title', value)}\n              onKeyDown={handleTitleKeyDown}\n              placeholder=\"Issue Title...\"\n              autoFocus={isCreateMode}\n              aria-label=\"Issue title\"\n              disabled={isSubmitting}\n              className={cn(\n                'px-base text-lg font-medium text-high',\n                'placeholder:text-high/50',\n                isSubmitting && 'opacity-50 pointer-events-none'\n              )}\n            />\n\n            <div\n              className={cn(\n                'pointer-events-none absolute inset-0 px-base',\n                'text-high/50 font-medium text-lg',\n                'hidden',\n                \"[[data-empty='true']_+_&]:block\" // show placeholder when previous sibling data-empty=true\n              )}\n            >\n              {t('kanban.issueTitlePlaceholder')}\n            </div>\n          </div>\n\n          {/* Description WYSIWYG Editor with image dropzone */}\n          <div\n            ref={descriptionContainerRef}\n            {...(isDescriptionEditing ? dropzoneProps?.getRootProps() : {})}\n            className={cn(\n              'relative mt-base',\n              !isDescriptionEditing && !isCreateMode && 'cursor-text'\n            )}\n            onClick={() => {\n              if (!isDescriptionEditing && !isCreateMode && !isSubmitting) {\n                // Don't enter edit mode if the user was selecting text\n                const selection = window.getSelection();\n                if (selection && selection.toString().length > 0) return;\n                setIsDescriptionEditing(true);\n              }\n            }}\n            onBlur={(e) => {\n              // Exit edit mode when focus leaves the description container\n              if (\n                descriptionContainerRef.current &&\n                !descriptionContainerRef.current.contains(\n                  e.relatedTarget as Node\n                )\n              ) {\n                handleDescriptionBlur();\n              }\n            }}\n          >\n            {isDescriptionEditing && (\n              <input\n                {...(dropzoneProps?.getInputProps() as React.InputHTMLAttributes<HTMLInputElement>)}\n                data-dropzone-input\n              />\n            )}\n            {renderDescriptionEditor({\n              placeholder: isDescriptionEditing\n                ? t('kanban.issueDescriptionPlaceholder')\n                : formData.description\n                  ? ''\n                  : t('kanban.issueDescriptionPlaceholder'),\n              value: formData.description ?? '',\n              onChange: (value) => onFormChange('description', value || null),\n              onCmdEnter: onCmdEnterSubmit,\n              onPasteFiles: isDescriptionEditing ? onPasteFiles : undefined,\n              disabled: !isDescriptionEditing || isSubmitting,\n              autoFocus: false,\n              className: cn(\n                'px-base',\n                isDescriptionEditing ? 'min-h-[100px]' : 'min-h-[2rem]',\n                !isDescriptionEditing && !formData.description && 'text-low'\n              ),\n              localAttachments,\n              showStaticToolbar: !isCreateMode || isDescriptionEditing,\n              hideActions: true,\n              saveStatus: descriptionSaveStatus,\n              onRequestEdit: !isCreateMode\n                ? () => setIsDescriptionEditing(true)\n                : undefined,\n              staticToolbarActions: (\n                <>\n                  {isDescriptionEditing && onBrowseAttachment && (\n                    <TooltipProvider>\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <button\n                            type=\"button\"\n                            onMouseDown={(e) => {\n                              e.preventDefault();\n                              if (!isSubmitting && !isUploading) {\n                                onBrowseAttachment();\n                              }\n                            }}\n                            disabled={isSubmitting || isUploading}\n                            className={cn(\n                              'p-half rounded-sm transition-colors',\n                              'text-low hover:text-normal hover:bg-panel/50',\n                              'disabled:opacity-50 disabled:cursor-not-allowed'\n                            )}\n                            title={t('kanban.attachFile')}\n                            aria-label={t('kanban.attachFile')}\n                          >\n                            <PaperclipIcon className=\"size-icon-sm\" />\n                          </button>\n                        </TooltipTrigger>\n                        <TooltipContent>\n                          {t('kanban.attachFileHint')}\n                        </TooltipContent>\n                      </Tooltip>\n                    </TooltipProvider>\n                  )}\n                  {!isCreateMode && (\n                    <TooltipProvider>\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <button\n                            type=\"button\"\n                            onMouseDown={(e) => {\n                              e.preventDefault();\n                              setIsDescriptionEditing(!isDescriptionEditing);\n                            }}\n                            className={cn(\n                              'p-half rounded-sm transition-colors',\n                              'text-low hover:text-normal hover:bg-panel/50'\n                            )}\n                            title={\n                              isDescriptionEditing\n                                ? t('kanban.previewDescription', 'Preview')\n                                : t('kanban.editDescription', 'Edit')\n                            }\n                            aria-label={\n                              isDescriptionEditing\n                                ? t('kanban.previewDescription', 'Preview')\n                                : t('kanban.editDescription', 'Edit')\n                            }\n                          >\n                            {isDescriptionEditing ? (\n                              <EyeIcon className=\"size-icon-sm\" />\n                            ) : (\n                              <PencilSimpleIcon className=\"size-icon-sm\" />\n                            )}\n                          </button>\n                        </TooltipTrigger>\n                        <TooltipContent>\n                          {isDescriptionEditing\n                            ? t('kanban.previewDescription', 'Preview')\n                            : t('kanban.editDescription', 'Edit')}\n                        </TooltipContent>\n                      </Tooltip>\n                    </TooltipProvider>\n                  )}\n                </>\n              ),\n            })}\n            {attachmentError && (\n              <div className=\"px-base\">\n                <ErrorAlert\n                  message={attachmentError}\n                  className=\"mt-half mb-half\"\n                  onDismiss={onDismissAttachmentError}\n                  dismissLabel={t('buttons.close')}\n                />\n              </div>\n            )}\n            {dropzoneProps?.isDragActive && (\n              <div className=\"absolute inset-0 z-50 bg-primary/80 backdrop-blur-sm border-2 border-dashed border-brand rounded flex items-center justify-center pointer-events-none animate-in fade-in-0 duration-150\">\n                <div className=\"text-center\">\n                  <div className=\"mx-auto mb-2 w-10 h-10 rounded-full bg-brand/10 flex items-center justify-center\">\n                    <ImageIcon className=\"h-5 w-5 text-brand\" />\n                  </div>\n                  <p className=\"text-sm font-medium text-high\">\n                    {t('kanban.dropFilesHere')}\n                  </p>\n                  <p className=\"text-xs text-low mt-0.5\">\n                    {t('kanban.fileDropHint')}\n                  </p>\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n\n        {/* Create Draft Workspace Toggle (Create mode only) */}\n        {isCreateMode && (\n          <div className=\"p-base border-t\">\n            <Toggle\n              checked={formData.createDraftWorkspace}\n              onCheckedChange={(checked) =>\n                onFormChange('createDraftWorkspace', checked)\n              }\n              label={t('kanban.createDraftWorkspaceImmediately')}\n              description={t('kanban.createDraftWorkspaceDescription')}\n              disabled={isSubmitting}\n            />\n          </div>\n        )}\n\n        {/* Create Issue Button (Create mode only) */}\n        {isCreateMode && (\n          <div className=\"px-base pb-base flex items-center gap-half\">\n            <PrimaryButton\n              value={t('kanban.createIssue')}\n              onClick={onSubmit}\n              disabled={isSubmitting || isUploading || !formData.title.trim()}\n              actionIcon={isSubmitting ? 'spinner' : undefined}\n              variant=\"default\"\n            />\n            {onDeleteDraft && (\n              <IconButton\n                icon={TrashIcon}\n                onClick={onDeleteDraft}\n                disabled={isSubmitting}\n                aria-label=\"Delete draft\"\n                title=\"Delete draft\"\n                className=\"hover:text-error hover:bg-error/10\"\n              />\n            )}\n          </div>\n        )}\n\n        {/* Workspaces Section (Edit mode only) */}\n        {!isCreateMode && issueId && renderWorkspacesSection && (\n          <div className=\"border-t\">{renderWorkspacesSection(issueId)}</div>\n        )}\n\n        {/* Relationships Section (Edit mode only) */}\n        {!isCreateMode && issueId && renderRelationshipsSection && (\n          <div className=\"border-t\">{renderRelationshipsSection(issueId)}</div>\n        )}\n\n        {/* Sub-Issues Section (Edit mode only) */}\n        {!isCreateMode && issueId && renderSubIssuesSection && (\n          <div className=\"border-t\">{renderSubIssuesSection(issueId)}</div>\n        )}\n\n        {/* Comments Section (Edit mode only) */}\n        {!isCreateMode && issueId && renderCommentsSection && (\n          <div className=\"border-t\">{renderCommentsSection(issueId)}</div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/KeyboardCommandsPlugin.tsx",
    "content": "import { useEffect } from 'react';\nimport { flushSync } from 'react-dom';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  $getSelection,\n  $isRangeSelection,\n  INDENT_CONTENT_COMMAND,\n  KEY_TAB_COMMAND,\n  KEY_MODIFIER_COMMAND,\n  KEY_ENTER_COMMAND,\n  OUTDENT_CONTENT_COMMAND,\n  COMMAND_PRIORITY_NORMAL,\n  COMMAND_PRIORITY_HIGH,\n  type LexicalNode,\n} from 'lexical';\nimport { $convertToMarkdownString, type Transformer } from '@lexical/markdown';\nimport { $isListItemNode } from '@lexical/list';\nimport { useTypeaheadOpen } from './TypeaheadOpenContext';\n\ntype SendMessageShortcut = 'ModifierEnter' | 'Enter';\n\ntype Props = {\n  onCmdEnter?: () => void;\n  onShiftCmdEnter?: () => void;\n  onChange?: (markdown: string) => void;\n  transformers?: Transformer[];\n  sendShortcut?: SendMessageShortcut;\n};\n\nexport function KeyboardCommandsPlugin({\n  onCmdEnter,\n  onShiftCmdEnter,\n  onChange,\n  transformers,\n  sendShortcut = 'ModifierEnter',\n}: Props) {\n  const [editor] = useLexicalComposerContext();\n  const { isOpen: isTypeaheadOpen } = useTypeaheadOpen();\n\n  useEffect(() => {\n    const isNodeInsideListItem = (node: LexicalNode): boolean => {\n      if ($isListItemNode(node)) {\n        return true;\n      }\n      return node.getParents().some($isListItemNode);\n    };\n\n    const isSelectionInsideListItem = (): boolean => {\n      const selection = $getSelection();\n      if (!$isRangeSelection(selection)) {\n        return false;\n      }\n\n      return (\n        isNodeInsideListItem(selection.anchor.getNode()) ||\n        isNodeInsideListItem(selection.focus.getNode())\n      );\n    };\n\n    const getSelectedListItem = (): LexicalNode | null => {\n      const selection = $getSelection();\n      if (!$isRangeSelection(selection)) {\n        return null;\n      }\n\n      // On empty list items Lexical can include adjacent nodes in getNodes().\n      // Prefer the last node so Tab applies to the cursor list item.\n      const nodes = selection.getNodes();\n      for (let i = nodes.length - 1; i >= 0; i--) {\n        const node = nodes[i];\n        if ($isListItemNode(node)) {\n          return node;\n        }\n        const parentListItem = node.getParents().find($isListItemNode);\n        if (parentListItem) {\n          return parentListItem;\n        }\n      }\n\n      const anchorNode = selection.anchor.getNode();\n      if ($isListItemNode(anchorNode)) {\n        return anchorNode;\n      }\n      return anchorNode.getParents().find($isListItemNode) ?? null;\n    };\n\n    const unregisterTab = editor.registerCommand(\n      KEY_TAB_COMMAND,\n      (event: KeyboardEvent) => {\n        // Let typeahead use Tab for option selection.\n        if (isTypeaheadOpen) {\n          return false;\n        }\n\n        if (!isSelectionInsideListItem()) {\n          return false;\n        }\n\n        event.preventDefault();\n        const selection = $getSelection();\n        if (!$isRangeSelection(selection)) {\n          return false;\n        }\n\n        if (!selection.isCollapsed()) {\n          return editor.dispatchCommand(\n            event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,\n            undefined\n          );\n        }\n\n        const listItem = getSelectedListItem();\n        if (!$isListItemNode(listItem)) {\n          return false;\n        }\n\n        if (event.shiftKey) {\n          const indent = listItem.getIndent();\n          if (indent > 0) {\n            listItem.setIndent(indent - 1);\n          }\n          return true;\n        }\n\n        // Match Google Docs behavior: first sibling cannot be indented further.\n        if (!$isListItemNode(listItem.getPreviousSibling())) {\n          return true;\n        }\n\n        listItem.setIndent(listItem.getIndent() + 1);\n        return true;\n      },\n      COMMAND_PRIORITY_NORMAL\n    );\n\n    if (!onCmdEnter && !onShiftCmdEnter) {\n      return unregisterTab;\n    }\n\n    const flushAndSubmit = () => {\n      if (onChange && transformers) {\n        const markdown = editor\n          .getEditorState()\n          .read(() => $convertToMarkdownString(transformers));\n        flushSync(() => {\n          onChange(markdown);\n        });\n      }\n      onCmdEnter?.();\n    };\n\n    const unregisterModifier = editor.registerCommand(\n      KEY_MODIFIER_COMMAND,\n      (event: KeyboardEvent) => {\n        if (!(event.metaKey || event.ctrlKey) || event.key !== 'Enter') {\n          return false;\n        }\n\n        event.preventDefault();\n        event.stopPropagation();\n\n        if (event.shiftKey && onShiftCmdEnter) {\n          onShiftCmdEnter();\n          return true;\n        }\n\n        if (!event.shiftKey && onCmdEnter && sendShortcut === 'ModifierEnter') {\n          flushAndSubmit();\n          return true;\n        }\n\n        return false;\n      },\n      COMMAND_PRIORITY_NORMAL\n    );\n\n    const unregisterEnter = editor.registerCommand(\n      KEY_ENTER_COMMAND,\n      (event: KeyboardEvent | null) => {\n        if (!event) return false;\n\n        // If typeahead is open, let it handle Enter\n        if (isTypeaheadOpen) {\n          return false;\n        }\n\n        if (sendShortcut === 'Enter') {\n          if (event.shiftKey || event.metaKey || event.ctrlKey) {\n            return false;\n          }\n          event.preventDefault();\n          flushAndSubmit();\n          return true;\n        }\n\n        if (event.metaKey || event.ctrlKey) {\n          return true;\n        }\n\n        return false;\n      },\n      COMMAND_PRIORITY_HIGH\n    );\n\n    return () => {\n      unregisterTab();\n      unregisterModifier();\n      unregisterEnter();\n    };\n  }, [\n    editor,\n    onCmdEnter,\n    onShiftCmdEnter,\n    onChange,\n    transformers,\n    sendShortcut,\n    isTypeaheadOpen,\n  ]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/KeyboardDialog.tsx",
    "content": "import * as React from 'react';\nimport { X } from 'lucide-react';\nimport { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook';\nimport { createPortal } from 'react-dom';\n\nimport { cn } from '../lib/cn';\n\nconst DIALOG_SCOPE = 'dialog';\nconst KANBAN_SCOPE = 'kanban';\nconst PROJECTS_SCOPE = 'projects';\n\nfunction assignRef<T>(ref: React.ForwardedRef<T>, value: T | null) {\n  if (typeof ref === 'function') {\n    ref(value);\n    return;\n  }\n  if (ref) {\n    ref.current = value;\n  }\n}\n\nconst Dialog = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & {\n    open?: boolean;\n    onOpenChange?: (open: boolean) => void;\n    uncloseable?: boolean;\n  }\n>(({ className, open, onOpenChange, children, uncloseable, ...props }, ref) => {\n  const { enableScope, disableScope } = useHotkeysContext();\n  const dialogRef = React.useRef<HTMLDivElement | null>(null);\n\n  const setDialogRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      dialogRef.current = node;\n      assignRef(ref, node);\n    },\n    [ref]\n  );\n\n  // Manage dialog scope when open/closed\n  React.useEffect(() => {\n    if (open) {\n      enableScope(DIALOG_SCOPE);\n      disableScope(KANBAN_SCOPE);\n      disableScope(PROJECTS_SCOPE);\n    } else {\n      disableScope(DIALOG_SCOPE);\n      enableScope(KANBAN_SCOPE);\n      enableScope(PROJECTS_SCOPE);\n    }\n    return () => {\n      disableScope(DIALOG_SCOPE);\n      enableScope(KANBAN_SCOPE);\n      enableScope(PROJECTS_SCOPE);\n    };\n  }, [open, enableScope, disableScope]);\n\n  useHotkeys(\n    'esc',\n    (e) => {\n      if (!open) return;\n      if (uncloseable) return;\n\n      const activeElement = document.activeElement as HTMLElement;\n      if (\n        activeElement &&\n        (activeElement.tagName === 'INPUT' ||\n          activeElement.tagName === 'TEXTAREA' ||\n          activeElement.isContentEditable)\n      ) {\n        activeElement.blur();\n        e?.preventDefault();\n        return;\n      }\n\n      onOpenChange?.(false);\n    },\n    {\n      enabled: !!open,\n      scopes: [DIALOG_SCOPE],\n      preventDefault: true,\n    },\n    [open, uncloseable, onOpenChange]\n  );\n\n  useHotkeys(\n    'enter',\n    (e) => {\n      if (!open) return;\n\n      const activeElement = document.activeElement as HTMLElement;\n      if (activeElement?.tagName === 'TEXTAREA') {\n        return;\n      }\n\n      const container = dialogRef.current;\n      if (!container) {\n        return;\n      }\n\n      const submitButton = container.querySelector(\n        'button[type=\"submit\"]'\n      ) as HTMLButtonElement | null;\n      if (submitButton && !submitButton.disabled) {\n        e?.preventDefault();\n        submitButton.click();\n        return;\n      }\n\n      const buttons = Array.from(\n        container.querySelectorAll('button')\n      ) as HTMLButtonElement[];\n      const primaryButton = buttons.find(\n        (btn) =>\n          !btn.disabled &&\n          !btn.textContent?.toLowerCase().includes('cancel') &&\n          !btn.textContent?.toLowerCase().includes('close') &&\n          btn.type !== 'button'\n      );\n\n      if (primaryButton) {\n        e?.preventDefault();\n        primaryButton.click();\n      }\n    },\n    {\n      enabled: !!open,\n      scopes: [DIALOG_SCOPE],\n    },\n    [open]\n  );\n\n  if (!open) return null;\n\n  return createPortal(\n    <div className=\"fixed inset-0 z-[10000] flex items-start justify-center p-4 overflow-y-auto\">\n      <div\n        data-tauri-drag-region\n        className=\"fixed inset-0 bg-black/50\"\n        onClick={() => (uncloseable ? {} : onOpenChange?.(false))}\n      />\n      <div\n        ref={setDialogRef}\n        className={cn(\n          'relative z-[10000] flex flex-col w-full max-w-xl gap-4 bg-primary p-6 shadow-lg duration-200 sm:rounded-lg my-8',\n          className\n        )}\n        {...props}\n      >\n        {!uncloseable && (\n          <button\n            className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 z-10\"\n            onClick={() => onOpenChange?.(false)}\n          >\n            <X className=\"h-4 w-4\" />\n            <span className=\"sr-only\">Close</span>\n          </button>\n        )}\n        {children}\n      </div>\n    </div>,\n    document.body\n  );\n});\nDialog.displayName = 'Dialog';\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col space-y-1.5 text-center sm:text-left',\n      className\n    )}\n    {...props}\n  />\n);\nDialogHeader.displayName = 'DialogHeader';\n\nconst DialogTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h3\n    ref={ref}\n    className={cn(\n      'text-lg font-semibold leading-none tracking-tight',\n      className\n    )}\n    {...props}\n  />\n));\nDialogTitle.displayName = 'DialogTitle';\n\nconst DialogDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <p\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nDialogDescription.displayName = 'DialogDescription';\n\nconst DialogContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('flex flex-col gap-4', className)} {...props} />\n));\nDialogContent.displayName = 'DialogContent';\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2',\n      className\n    )}\n    {...props}\n  />\n);\nDialogFooter.displayName = 'DialogFooter';\n\nexport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n};\n"
  },
  {
    "path": "packages/ui/src/components/Label.tsx",
    "content": "import * as React from 'react';\nimport * as LabelPrimitive from '@radix-ui/react-label';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '../lib/cn';\n\nconst labelVariants = cva(\n  'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'\n);\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n    VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(labelVariants(), className)}\n    {...props}\n  />\n));\nLabel.displayName = LabelPrimitive.Root.displayName;\n\nexport { Label };\n"
  },
  {
    "path": "packages/ui/src/components/Loader.tsx",
    "content": "import { Loader2 } from 'lucide-react';\nimport React from 'react';\n\ninterface LoaderProps {\n  message?: string | React.ReactElement;\n  size?: number;\n  className?: string;\n}\n\nexport const Loader: React.FC<LoaderProps> = ({\n  message,\n  size = 32,\n  className = '',\n}) => (\n  <div\n    className={`flex flex-col items-center justify-center gap-2 ${className}`}\n  >\n    <Loader2\n      className=\"animate-spin text-muted-foreground\"\n      style={{ width: size, height: size }}\n    />\n    {!!message && (\n      <div className=\"text-center text-muted-foreground\">{message}</div>\n    )}\n  </div>\n);\n"
  },
  {
    "path": "packages/ui/src/components/MarkdownInsertPlugin.tsx",
    "content": "import { useEffect } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  FORMAT_TEXT_COMMAND,\n  COMMAND_PRIORITY_HIGH,\n  $getSelection,\n  $isRangeSelection,\n  $isTextNode,\n  $createRangeSelection,\n  $setSelection,\n  createCommand,\n  type LexicalCommand,\n} from 'lexical';\n\nexport type MarkdownListType = 'bullet' | 'number';\n\nexport const INSERT_MARKDOWN_LIST_COMMAND: LexicalCommand<MarkdownListType> =\n  createCommand('INSERT_MARKDOWN_LIST');\n\nconst FORMAT_MARKERS: Record<string, string> = {\n  bold: '**',\n  italic: '*',\n  strikethrough: '~~',\n  code: '`',\n};\n\n/**\n * Intercepts FORMAT_TEXT_COMMAND and inserts markdown syntax as literal text\n * instead of applying Lexical rich text formatting.\n *\n * Also handles INSERT_MARKDOWN_LIST_COMMAND for list prefix insertion.\n */\nexport function MarkdownInsertPlugin() {\n  const [editor] = useLexicalComposerContext();\n\n  useEffect(() => {\n    const unregisterFormat = editor.registerCommand(\n      FORMAT_TEXT_COMMAND,\n      (format: string) => {\n        const marker = FORMAT_MARKERS[format];\n        if (!marker) {\n          // Unsupported format (e.g. underline) — block it\n          return true;\n        }\n\n        const selection = $getSelection();\n        if (!$isRangeSelection(selection)) return false;\n\n        const selectedText = selection.getTextContent();\n\n        if (selectedText.length > 0) {\n          // Wrap selection with markers\n          selection.insertRawText(`${marker}${selectedText}${marker}`);\n        } else {\n          // No selection — insert markers and move cursor between them\n          // by manually splicing the marker text into the text node\n          const anchorNode = selection.anchor.getNode();\n          const anchorOffset = selection.anchor.offset;\n\n          if ($isTextNode(anchorNode)) {\n            const currentText = anchorNode.getTextContent();\n            const before = currentText.slice(0, anchorOffset);\n            const after = currentText.slice(anchorOffset);\n            anchorNode.setTextContent(`${before}${marker}${marker}${after}`);\n            // Place cursor between the two markers using a fresh selection\n            const newOffset = anchorOffset + marker.length;\n            const nodeKey = anchorNode.getKey();\n            const moved = $createRangeSelection();\n            moved.anchor.set(nodeKey, newOffset, 'text');\n            moved.focus.set(nodeKey, newOffset, 'text');\n            $setSelection(moved);\n          } else {\n            // Fallback: just insert both markers (cursor ends up after them)\n            selection.insertRawText(`${marker}${marker}`);\n          }\n        }\n\n        return true;\n      },\n      COMMAND_PRIORITY_HIGH\n    );\n\n    const unregisterList = editor.registerCommand(\n      INSERT_MARKDOWN_LIST_COMMAND,\n      (listType: MarkdownListType) => {\n        const selection = $getSelection();\n        if (!$isRangeSelection(selection)) return false;\n\n        const prefix = listType === 'bullet' ? '- ' : '1. ';\n        const selectedText = selection.getTextContent();\n\n        if (selectedText.length > 0) {\n          // Prefix each line\n          const lines = selectedText.split('\\n');\n          const prefixed = lines\n            .map((line, i) => {\n              if (listType === 'number') {\n                return `${i + 1}. ${line}`;\n              }\n              return `- ${line}`;\n            })\n            .join('\\n');\n          selection.insertRawText(prefixed);\n        } else {\n          selection.insertRawText(prefix);\n        }\n\n        return true;\n      },\n      COMMAND_PRIORITY_HIGH\n    );\n\n    return () => {\n      unregisterFormat();\n      unregisterList();\n    };\n  }, [editor]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/MarkdownListContinuePlugin.tsx",
    "content": "import { useEffect } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  KEY_ENTER_COMMAND,\n  COMMAND_PRIORITY_NORMAL,\n  $getSelection,\n  $isRangeSelection,\n  $isTextNode,\n  $isElementNode,\n  $createRangeSelection,\n  $setSelection,\n} from 'lexical';\nimport { useTypeaheadOpen } from './TypeaheadOpenContext';\n\n// Matches bullet prefixes: \"- \", \"* \", \"+ \"\nconst BULLET_PREFIX_RE = /^(\\s*)([-*+]) $/;\nconst BULLET_LINE_RE = /^(\\s*)([-*+]) (.+)/;\n\n// Matches numbered prefixes: \"1. \", \"12. \", etc.\nconst NUMBER_PREFIX_RE = /^(\\s*)(\\d+)\\. $/;\nconst NUMBER_LINE_RE = /^(\\s*)(\\d+)\\. (.+)/;\n\n/**\n * Auto-continues markdown lists on Enter, like GitHub's editor.\n *\n * When the cursor is at the end of a line that starts with a list prefix:\n * - If the line has content after the prefix, insert a newline + next prefix\n * - If the line is just the prefix (empty item), remove it to end the list\n *\n * Uses the full paragraph text (not just the anchor text node) to detect\n * list prefixes, so that formatted inline content (e.g. code-formatted\n * file references inserted via typeahead) doesn't break list continuation.\n */\nexport function MarkdownListContinuePlugin() {\n  const [editor] = useLexicalComposerContext();\n  const { isOpen: isTypeaheadOpen } = useTypeaheadOpen();\n\n  useEffect(() => {\n    const unregister = editor.registerCommand(\n      KEY_ENTER_COMMAND,\n      (event: KeyboardEvent | null) => {\n        if (!event) return false;\n        // Let typeahead handle Enter when it's open\n        if (isTypeaheadOpen) return false;\n        // Don't interfere with Shift+Enter (line break) or modifier combos\n        if (event.shiftKey || event.metaKey || event.ctrlKey) return false;\n\n        const selection = $getSelection();\n        if (!$isRangeSelection(selection) || !selection.isCollapsed()) {\n          return false;\n        }\n\n        const anchorNode = selection.anchor.getNode();\n        if (!$isTextNode(anchorNode)) return false;\n\n        const anchorText = anchorNode.getTextContent();\n        const offset = selection.anchor.offset;\n\n        // Only handle when cursor is at the end of the anchor text node\n        if (offset !== anchorText.length) return false;\n\n        // Get the parent element (paragraph) and its full text content.\n        // A paragraph may contain multiple text nodes when inline formatting\n        // is present (e.g. code-formatted file names from typeahead).\n        const parent = anchorNode.getParent();\n        if (!parent || !$isElementNode(parent)) return false;\n\n        // Cursor must be at the very end of the paragraph\n        const lastChild = parent.getLastChild();\n        if (!lastChild || lastChild.getKey() !== anchorNode.getKey()) {\n          return false;\n        }\n\n        const text = parent.getTextContent();\n        const currentLineStart = text.lastIndexOf('\\n') + 1;\n        const currentLine = text.slice(currentLineStart);\n        const anchorLineStart = anchorText.lastIndexOf('\\n') + 1;\n\n        const replaceAnchorCurrentLine = (replacement: string) => {\n          const nextText = `${anchorText.slice(0, anchorLineStart)}${replacement}`;\n          anchorNode.setTextContent(nextText);\n          const newSel = $createRangeSelection();\n\n          // Keep caret visually on the blank line when text ends with '\\n'.\n          if (nextText.endsWith('\\n')) {\n            const parentKey = parent.getKey();\n            const childCount = parent.getChildrenSize();\n            newSel.anchor.set(parentKey, childCount, 'element');\n            newSel.focus.set(parentKey, childCount, 'element');\n          } else {\n            const nodeKey = anchorNode.getKey();\n            const newOffset = nextText.length;\n            newSel.anchor.set(nodeKey, newOffset, 'text');\n            newSel.focus.set(nodeKey, newOffset, 'text');\n          }\n\n          $setSelection(newSel);\n        };\n\n        // Check for empty bullet prefix (just \"- \" / \"* \" / \"+ \")\n        const emptyBullet = currentLine.match(BULLET_PREFIX_RE);\n        if (emptyBullet) {\n          event.preventDefault();\n          replaceAnchorCurrentLine(emptyBullet[1]);\n          return true;\n        }\n\n        // Check for empty number prefix (just \"1. \")\n        const emptyNumber = currentLine.match(NUMBER_PREFIX_RE);\n        if (emptyNumber) {\n          event.preventDefault();\n          replaceAnchorCurrentLine(emptyNumber[1]);\n          return true;\n        }\n\n        // Check for bullet line with content\n        const bulletMatch = currentLine.match(BULLET_LINE_RE);\n        if (bulletMatch) {\n          event.preventDefault();\n          const [, indent, marker] = bulletMatch;\n          const prefix = `${indent}${marker} `;\n          selection.insertRawText(`\\n${prefix}`);\n          return true;\n        }\n\n        // Check for numbered line with content\n        const numberMatch = currentLine.match(NUMBER_LINE_RE);\n        if (numberMatch) {\n          event.preventDefault();\n          const [, indent, numStr] = numberMatch;\n          const nextNum = parseInt(numStr, 10) + 1;\n          const prefix = `${indent}${nextNum}. `;\n          selection.insertRawText(`\\n${prefix}`);\n          return true;\n        }\n\n        return false;\n      },\n      COMMAND_PRIORITY_NORMAL\n    );\n\n    return unregister;\n  }, [editor, isTypeaheadOpen]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/MarkdownSyncPlugin.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  $convertToMarkdownString,\n  $convertFromMarkdownString,\n  type Transformer,\n} from '@lexical/markdown';\nimport { $getRoot, type EditorState } from 'lexical';\n\n// Lexical escapes markdown-special characters (*, _, ~, etc.) when they\n// appear as literal text.  In plain-text editing mode the user *intends*\n// those characters so we strip the backslash escapes.\nconst MARKDOWN_ESCAPE_RE = /\\\\([\\\\`*_{}[\\]()#+\\-.!~>|])/g;\n\ntype MarkdownSyncPluginProps = {\n  value: string;\n  onChange?: (markdown: string) => void;\n  onEditorStateChange?: (state: EditorState) => void;\n  editable: boolean;\n  transformers: Transformer[];\n  /** When true, strip backslash-escapes from the exported markdown so that\n   *  literal markdown syntax typed by the user (e.g. **bold**) is preserved\n   *  rather than being escaped to \\*\\*bold\\*\\*. */\n  preserveMarkdownSyntax?: boolean;\n};\n\n/**\n * Handles bidirectional markdown synchronization between Lexical editor and external state.\n *\n * Uses an internal ref to prevent infinite update loops during bidirectional sync.\n */\nexport function MarkdownSyncPlugin({\n  value,\n  onChange,\n  onEditorStateChange,\n  editable,\n  transformers,\n  preserveMarkdownSyntax = false,\n}: MarkdownSyncPluginProps) {\n  const [editor] = useLexicalComposerContext();\n  const lastSerializedRef = useRef<string | undefined>(undefined);\n  const prevTransformersRef = useRef(transformers);\n\n  // Detect transformer changes (e.g., toggling preview mode) and force re-parse\n  if (transformers !== prevTransformersRef.current) {\n    prevTransformersRef.current = transformers;\n    lastSerializedRef.current = undefined;\n  }\n\n  // Handle editable state\n  useEffect(() => {\n    editor.setEditable(editable);\n  }, [editor, editable]);\n\n  // Handle controlled value changes (external → editor)\n  useEffect(() => {\n    if (value === lastSerializedRef.current) return;\n\n    try {\n      editor.update(() => {\n        if (value.trim() === '') {\n          $getRoot().clear();\n        } else {\n          $convertFromMarkdownString(value, transformers);\n        }\n\n        // Only position cursor at end if editor already has focus (user is actively editing)\n        // This prevents unwanted focus when value changes externally (e.g., panel opening)\n        const rootElement = editor.getRootElement();\n        if (rootElement?.contains(document.activeElement)) {\n          const root = $getRoot();\n          const lastNode = root.getLastChild();\n          if (lastNode) {\n            lastNode.selectEnd();\n          }\n        }\n      });\n      lastSerializedRef.current = value;\n    } catch (err) {\n      console.error('Failed to parse markdown', err);\n    }\n  }, [editor, value, transformers]);\n\n  // Handle editor changes (editor → external)\n  useEffect(() => {\n    return editor.registerUpdateListener(({ editorState }) => {\n      onEditorStateChange?.(editorState);\n      if (!onChange) return;\n\n      let markdown = editorState.read(() =>\n        $convertToMarkdownString(transformers)\n      );\n      if (preserveMarkdownSyntax) {\n        markdown = markdown.replace(MARKDOWN_ESCAPE_RE, '$1');\n      }\n\n      if (markdown === lastSerializedRef.current) return;\n\n      lastSerializedRef.current = markdown;\n      onChange(markdown);\n    });\n  }, [\n    editor,\n    onChange,\n    onEditorStateChange,\n    transformers,\n    preserveMarkdownSyntax,\n  ]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/MigrateChooseProjects.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport {\n  ArrowSquareOutIcon,\n  ArrowRightIcon,\n  BuildingsIcon,\n  CaretDownIcon,\n  CloudArrowUpIcon,\n} from '@phosphor-icons/react';\nimport { PrimaryButton } from './PrimaryButton';\nimport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n} from './Dropdown';\nimport { Checkbox } from './Checkbox';\n\nexport interface MigrateChooseProjectsProject {\n  id: string;\n  name: string;\n  remote_project_id: string | null;\n}\n\nexport interface MigrateChooseProjectsOrganization {\n  id: string;\n  name: string;\n}\n\ninterface MigrateChooseProjectsProps {\n  projects: MigrateChooseProjectsProject[];\n  organizations: MigrateChooseProjectsOrganization[];\n  selectedOrgId: string | null;\n  selectedProjectIds: Set<string>;\n  isLoading: boolean;\n  onOrgChange: (orgId: string) => void;\n  onToggleProject: (projectId: string) => void;\n  onSelectAll: () => void;\n  onContinue: () => void;\n  onSkip?: () => void;\n  onGoToCreateWorkspace?: () => void;\n  onViewMigratedProject?: (projectId: string) => void;\n}\n\nexport function MigrateChooseProjects({\n  projects,\n  organizations,\n  selectedOrgId,\n  selectedProjectIds,\n  isLoading,\n  onOrgChange,\n  onToggleProject,\n  onSelectAll,\n  onContinue,\n  onSkip,\n  onGoToCreateWorkspace,\n  onViewMigratedProject,\n}: MigrateChooseProjectsProps) {\n  const { t } = useTranslation('common');\n  const selectedOrg = organizations.find((org) => org.id === selectedOrgId);\n\n  const migrateableProjects = projects.filter((p) => !p.remote_project_id);\n  const migratedProjects = projects.filter((p) => p.remote_project_id);\n\n  const buttonText =\n    selectedProjectIds.size === 0\n      ? 'Select projects to migrate'\n      : `Migrate ${selectedProjectIds.size} project${selectedProjectIds.size === 1 ? '' : 's'}`;\n\n  if (isLoading) {\n    return (\n      <div className=\"max-w-2xl mx-auto py-double px-base\">\n        <p className=\"text-normal\">Loading...</p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"max-w-2xl mx-auto py-double px-base\">\n      {/* Header section */}\n      <div className=\"mb-double\">\n        <h1 className=\"text-xl font-semibold text-high mb-base\">\n          Choose Projects to Migrate\n        </h1>\n        <p className=\"text-base text-normal\">\n          Select which local projects to move to the cloud. A new cloud project\n          will be created in your chosen organization for each one.\n        </p>\n      </div>\n\n      {/* Organization selector */}\n      <div className=\"mb-double\">\n        <label className=\"block text-sm font-medium text-high mb-half\">\n          Destination Organization\n        </label>\n        {organizations.length === 0 ? (\n          <p className=\"text-sm text-low\">\n            No organizations available. Please create an organization first.\n          </p>\n        ) : (\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <button className=\"flex items-center gap-base bg-secondary border rounded px-base py-half w-full max-w-xs text-left\">\n                <BuildingsIcon\n                  className=\"size-icon-sm text-normal\"\n                  weight=\"duotone\"\n                />\n                <span className=\"flex-1 text-sm text-high truncate\">\n                  {selectedOrg?.name ?? 'Select organization'}\n                </span>\n                <CaretDownIcon\n                  className=\"size-icon-xs text-normal\"\n                  weight=\"bold\"\n                />\n              </button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"start\" className=\"w-64\">\n              {organizations.map((org) => (\n                <DropdownMenuItem\n                  key={org.id}\n                  onClick={() => onOrgChange(org.id)}\n                >\n                  {org.name}\n                </DropdownMenuItem>\n              ))}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n      </div>\n\n      {/* Project list */}\n      <div className=\"mb-double\">\n        <label className=\"block text-sm font-medium text-high mb-half\">\n          Local Projects\n        </label>\n        {projects.length === 0 ? (\n          <div className=\"space-y-base\">\n            <p className=\"text-sm text-low\">No local projects found.</p>\n            {onGoToCreateWorkspace ? (\n              <button\n                type=\"button\"\n                onClick={onGoToCreateWorkspace}\n                className=\"inline-flex items-center gap-half text-sm text-brand hover:underline\"\n              >\n                Skip to create a workspace\n                <ArrowSquareOutIcon className=\"size-icon-xs\" weight=\"bold\" />\n              </button>\n            ) : null}\n          </div>\n        ) : (\n          <div className=\"bg-secondary border rounded\">\n            {/* Select all - only for migrateable projects */}\n            {migrateableProjects.length > 0 && (\n              <div className=\"flex items-center gap-base px-base py-half border-b\">\n                <Checkbox\n                  id=\"select-all\"\n                  checked={\n                    selectedProjectIds.size === migrateableProjects.length &&\n                    migrateableProjects.length > 0\n                  }\n                  onCheckedChange={onSelectAll}\n                />\n                <label\n                  htmlFor=\"select-all\"\n                  className=\"text-sm text-normal cursor-pointer\"\n                >\n                  Select all ({migrateableProjects.length} project\n                  {migrateableProjects.length === 1 ? '' : 's'})\n                </label>\n              </div>\n            )}\n\n            {/* Project list */}\n            <div className=\"max-h-64 overflow-y-auto divide-y divide-border\">\n              {/* Migrateable projects first */}\n              {migrateableProjects.map((project) => (\n                <div\n                  key={project.id}\n                  className=\"flex items-center gap-base px-base py-half hover:bg-panel/50\"\n                >\n                  <Checkbox\n                    id={`project-${project.id}`}\n                    checked={selectedProjectIds.has(project.id)}\n                    onCheckedChange={() => onToggleProject(project.id)}\n                  />\n                  <label\n                    htmlFor={`project-${project.id}`}\n                    className=\"flex-1 text-sm text-high cursor-pointer truncate\"\n                  >\n                    {project.name}\n                  </label>\n                </div>\n              ))}\n\n              {/* Already migrated projects */}\n              {migratedProjects.map((project) => (\n                <div\n                  key={project.id}\n                  className=\"flex items-center gap-base px-base py-half bg-panel/30\"\n                >\n                  <span className=\"text-xs text-low whitespace-nowrap\">\n                    Already migrated\n                  </span>\n                  <span className=\"flex-1 text-sm text-low truncate\">\n                    {project.name}\n                  </span>\n                  {project.remote_project_id && onViewMigratedProject ? (\n                    <button\n                      type=\"button\"\n                      onClick={() =>\n                        onViewMigratedProject(project.remote_project_id!)\n                      }\n                      className=\"flex items-center gap-half text-sm text-brand hover:underline whitespace-nowrap\"\n                    >\n                      View\n                      <ArrowSquareOutIcon\n                        className=\"size-icon-xs\"\n                        weight=\"bold\"\n                      />\n                    </button>\n                  ) : null}\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Info box */}\n      <div className=\"mb-double p-base bg-secondary border rounded\">\n        <ul className=\"text-sm text-normal list-disc pl-double space-y-half\">\n          <li>\n            Information about tasks, such as titles and descriptions, will be\n            migrated to equivalent projects in the cloud\n          </li>\n          <li>\n            Your code and agent logs will not be migrated to the cloud and will\n            continue to live entirely locally\n          </li>\n        </ul>\n      </div>\n\n      {/* CTA */}\n      <div className=\"pt-base border-t flex justify-end\">\n        {migrateableProjects.length === 0 && migratedProjects.length > 0 ? (\n          <div className=\"flex items-center gap-base w-full\">\n            <p className=\"text-sm text-normal flex-1\">\n              {t('migration.allProjectsMigrated')}\n            </p>\n            <PrimaryButton onClick={onSkip} actionIcon={ArrowRightIcon}>\n              {t('migration.continueToProjects')}\n            </PrimaryButton>\n          </div>\n        ) : (\n          <PrimaryButton\n            onClick={onContinue}\n            disabled={selectedProjectIds.size === 0 || !selectedOrgId}\n            actionIcon={CloudArrowUpIcon}\n          >\n            {buttonText}\n          </PrimaryButton>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/MigrateFinish.tsx",
    "content": "import {\n  FolderIcon,\n  ArrowSquareOutIcon,\n  ArrowCounterClockwiseIcon,\n} from '@phosphor-icons/react';\nimport { PrimaryButton } from './PrimaryButton';\n\nexport interface MigrateFinishProject {\n  localId: string;\n  localName: string;\n  remoteId: string | null;\n}\n\ninterface MigrateFinishProps {\n  migratedProjects: MigrateFinishProject[];\n  onMigrateMore: () => void;\n  onViewProject: (project: MigrateFinishProject) => void;\n}\n\nexport function MigrateFinish({\n  migratedProjects,\n  onMigrateMore,\n  onViewProject,\n}: MigrateFinishProps) {\n  return (\n    <div className=\"max-w-2xl mx-auto py-double px-base\">\n      {/* Header */}\n      <div className=\"mb-double\">\n        <h1 className=\"text-xl font-semibold text-high mb-base\">\n          Migration Complete!\n        </h1>\n        <p className=\"text-base text-normal\">\n          Your projects have been migrated to the cloud. Click a project below\n          to view it.\n        </p>\n      </div>\n\n      {/* Project list */}\n      <div className=\"mb-double\">\n        <div className=\"bg-secondary border rounded divide-y divide-border\">\n          {migratedProjects.map((project) => (\n            <div\n              key={project.localId}\n              className=\"flex items-center gap-base px-base py-half hover:bg-panel/50\"\n            >\n              <FolderIcon\n                className=\"size-icon-sm text-brand shrink-0\"\n                weight=\"duotone\"\n              />\n              <span className=\"flex-1 text-sm text-high truncate\">\n                {project.localName}\n              </span>\n              <button\n                type=\"button\"\n                onClick={() => onViewProject(project)}\n                className=\"rounded-sm px-base py-half text-cta h-cta flex gap-half items-center bg-brand hover:bg-brand-hover text-on-brand\"\n              >\n                View\n                <ArrowSquareOutIcon className=\"size-icon-xs\" weight=\"bold\" />\n              </button>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      {/* Actions */}\n      <div className=\"pt-base border-t\">\n        <PrimaryButton\n          variant=\"tertiary\"\n          onClick={onMigrateMore}\n          actionIcon={ArrowCounterClockwiseIcon}\n        >\n          Migrate More Projects\n        </PrimaryButton>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/MigrateIntroduction.tsx",
    "content": "import {\n  ArrowRightIcon,\n  UsersIcon,\n  TagIcon,\n  ChatCircleIcon,\n  TreeStructureIcon,\n  GitPullRequestIcon,\n  CloudIcon,\n  SignInIcon,\n} from '@phosphor-icons/react';\nimport { PrimaryButton } from './PrimaryButton';\n\ninterface MigrateIntroductionProps {\n  isSignedIn: boolean;\n  onAction: () => void;\n}\n\nconst features = [\n  {\n    icon: CloudIcon,\n    title: 'Cloud Storage',\n    description: (\n      <>\n        Access your projects from anywhere.{' '}\n        <a\n          href=\"https://www.vibekanban.com/docs/self-hosting/local-development\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-brand hover:underline\"\n        >\n          Self-host\n        </a>{' '}\n        if you prefer.\n      </>\n    ),\n  },\n  {\n    icon: UsersIcon,\n    title: 'Team Collaboration',\n    description: 'Invite teammates and assign work',\n  },\n  {\n    icon: ChatCircleIcon,\n    title: 'Comments',\n    description: 'Comment on issues to keep discussions in context',\n  },\n  {\n    icon: TreeStructureIcon,\n    title: 'Sub-issues',\n    description: 'Break down complex work into smaller pieces',\n  },\n  {\n    icon: GitPullRequestIcon,\n    title: 'GitHub Integration',\n    description: 'Link pull requests directly to issues',\n  },\n  {\n    icon: TagIcon,\n    title: 'Tags & Priorities',\n    description: 'Add tags and priorities to organize work',\n  },\n];\n\nexport function MigrateIntroduction({\n  isSignedIn,\n  onAction,\n}: MigrateIntroductionProps) {\n  return (\n    <div className=\"max-w-2xl mx-auto py-double px-base\">\n      {/* Header section */}\n      <div className=\"mb-double\">\n        <h1 className=\"text-xl font-semibold text-high mb-base\">\n          Move Your Projects to the Cloud\n        </h1>\n        <p className=\"text-base text-normal\">\n          Your local projects are moving to secure cloud storage. This unlocks\n          team collaboration, real-time sync, and access from any device.\n        </p>\n      </div>\n\n      {/* Features grid */}\n      <div className=\"mb-double\">\n        <h2 className=\"text-lg font-medium text-high mb-base\">\n          What you get with cloud projects\n        </h2>\n        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-base\">\n          {features.map((feature) => {\n            const Icon = feature.icon;\n            return (\n              <div\n                key={feature.title}\n                className=\"p-base bg-secondary rounded border\"\n              >\n                <div className=\"flex items-start gap-base\">\n                  <div className=\"p-half bg-panel rounded\">\n                    <Icon\n                      className=\"size-icon-sm text-brand\"\n                      weight=\"duotone\"\n                    />\n                  </div>\n                  <div>\n                    <h3 className=\"text-sm font-medium text-high mb-half\">\n                      {feature.title}\n                    </h3>\n                    <p className=\"text-sm text-low\">{feature.description}</p>\n                  </div>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      {/* CTA */}\n      <div className=\"pt-base border-t\">\n        <p className=\"text-sm text-normal mb-base\">\n          {isSignedIn\n            ? 'Continue to select projects to migrate.'\n            : 'Sign in to migrate your local projects.'}\n        </p>\n        <PrimaryButton\n          onClick={onAction}\n          actionIcon={isSignedIn ? ArrowRightIcon : SignInIcon}\n        >\n          {isSignedIn ? 'Continue' : 'Sign In'}\n        </PrimaryButton>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/MigrateMigrate.tsx",
    "content": "import {\n  SpinnerIcon,\n  CheckCircleIcon,\n  WarningIcon,\n  ArrowRightIcon,\n  ArrowCounterClockwiseIcon,\n} from '@phosphor-icons/react';\nimport { PrimaryButton } from './PrimaryButton';\n\ninterface MigrationEntityReport {\n  total: number;\n  migrated: number;\n  skipped: number;\n}\n\ninterface MigrateMigrationReport {\n  projects: MigrationEntityReport;\n  tasks: MigrationEntityReport;\n  pr_merges: MigrationEntityReport;\n  warnings: string[];\n}\n\ninterface MigrateMigrateProps {\n  orgName: string;\n  projectCount: number;\n  isMigrating: boolean;\n  report: MigrateMigrationReport | null;\n  error: string | null;\n  onRetry: () => void;\n  onContinue: () => void;\n}\n\nfunction formatEntityReport(report: MigrationEntityReport): string {\n  let text = `${report.migrated}/${report.total} migrated`;\n  if (report.skipped > 0) {\n    text += `, ${report.skipped} skipped`;\n  }\n  return text;\n}\n\nexport function MigrateMigrate({\n  orgName,\n  projectCount,\n  isMigrating,\n  report,\n  error,\n  onRetry,\n  onContinue,\n}: MigrateMigrateProps) {\n  // Loading state\n  if (isMigrating) {\n    return (\n      <div className=\"max-w-2xl mx-auto py-double px-base\">\n        <div className=\"mb-double\">\n          <h1 className=\"text-xl font-semibold text-high mb-base\">\n            Migrating Your Projects\n          </h1>\n        </div>\n\n        <div className=\"flex flex-col items-center justify-center py-double\">\n          <SpinnerIcon\n            className=\"size-12 text-brand animate-spin mb-base\"\n            weight=\"bold\"\n          />\n          <p className=\"text-base text-normal text-center mb-half\">\n            Migrating {projectCount} project\n            {projectCount === 1 ? '' : 's'} to \"{orgName}\"...\n          </p>\n          <p className=\"text-sm text-low text-center\">\n            This may take a moment.\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  // Error state\n  if (error) {\n    return (\n      <div className=\"max-w-2xl mx-auto py-double px-base\">\n        <div className=\"mb-double\">\n          <h1 className=\"text-xl font-semibold text-high mb-base\">\n            Migration Failed\n          </h1>\n        </div>\n\n        <div className=\"p-base bg-error/10 border border-error/20 rounded mb-double\">\n          <div className=\"flex items-start gap-base\">\n            <WarningIcon\n              className=\"size-icon-sm text-error shrink-0 mt-half\"\n              weight=\"fill\"\n            />\n            <div>\n              <p className=\"text-sm text-error font-medium mb-half\">\n                An error occurred during migration\n              </p>\n              <p className=\"text-sm text-normal\">{error}</p>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"pt-base border-t flex justify-end\">\n          <PrimaryButton\n            onClick={onRetry}\n            actionIcon={ArrowCounterClockwiseIcon}\n          >\n            Retry Migration\n          </PrimaryButton>\n        </div>\n      </div>\n    );\n  }\n\n  // Success state\n  if (report) {\n    return (\n      <div className=\"max-w-2xl mx-auto py-double px-base\">\n        <div className=\"mb-double\">\n          <h1 className=\"text-xl font-semibold text-high mb-base\">\n            Migration Complete\n          </h1>\n          <p className=\"text-base text-normal\">\n            Your projects have been successfully migrated to the cloud.\n          </p>\n        </div>\n\n        <div className=\"p-base bg-secondary border rounded mb-double\">\n          <div className=\"space-y-half\">\n            <div className=\"flex items-center gap-half\">\n              <CheckCircleIcon\n                className=\"size-icon-xs text-success\"\n                weight=\"fill\"\n              />\n              <span className=\"text-sm text-high\">\n                Projects: {formatEntityReport(report.projects)}\n              </span>\n            </div>\n            <div className=\"flex items-center gap-half\">\n              <CheckCircleIcon\n                className=\"size-icon-xs text-success\"\n                weight=\"fill\"\n              />\n              <span className=\"text-sm text-high\">\n                Tasks: {formatEntityReport(report.tasks)}\n              </span>\n            </div>\n            <div className=\"flex items-center gap-half\">\n              <CheckCircleIcon\n                className=\"size-icon-xs text-success\"\n                weight=\"fill\"\n              />\n              <span className=\"text-sm text-high\">\n                PR Merges: {formatEntityReport(report.pr_merges)}\n              </span>\n            </div>\n          </div>\n\n          {report.warnings.length > 0 && (\n            <div className=\"mt-base pt-base border-t\">\n              <p className=\"text-sm font-medium text-normal mb-half\">\n                Warnings:\n              </p>\n              <ul className=\"list-disc list-inside text-sm text-low\">\n                {report.warnings.map((warning, index) => (\n                  <li key={index}>{warning}</li>\n                ))}\n              </ul>\n            </div>\n          )}\n        </div>\n\n        <div className=\"pt-base border-t flex justify-end\">\n          <PrimaryButton onClick={onContinue} actionIcon={ArrowRightIcon}>\n            Continue\n          </PrimaryButton>\n        </div>\n      </div>\n    );\n  }\n\n  // Should not reach here, but just in case\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/MigrateSidebar.tsx",
    "content": "import {\n  BookOpenIcon,\n  FolderIcon,\n  CloudArrowUpIcon,\n  CheckCircleIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nexport type MigrationStep =\n  | 'introduction'\n  | 'choose-projects'\n  | 'migrate'\n  | 'finish';\n\ninterface MigrateSidebarProps {\n  currentStep: MigrationStep;\n  onStepChange: (step: MigrationStep) => void;\n}\n\nconst steps: Array<{\n  id: MigrationStep;\n  label: string;\n  icon: typeof BookOpenIcon;\n}> = [\n  { id: 'introduction', label: 'Introduction', icon: BookOpenIcon },\n  { id: 'choose-projects', label: 'Choose projects', icon: FolderIcon },\n  { id: 'migrate', label: 'Migrate', icon: CloudArrowUpIcon },\n  { id: 'finish', label: 'Finish', icon: CheckCircleIcon },\n];\n\nexport function MigrateSidebar({\n  currentStep,\n  onStepChange,\n}: MigrateSidebarProps) {\n  const currentIndex = steps.findIndex((s) => s.id === currentStep);\n\n  return (\n    <nav className=\"grid gap-half sm:grid-cols-2 lg:grid-cols-4\">\n      {steps.map((step, index) => {\n        const Icon = step.icon;\n        const isActive = currentStep === step.id;\n        const isPast = currentIndex > index;\n        const isDisabled = !isActive && !isPast;\n\n        return (\n          <button\n            key={step.id}\n            onClick={() => !isDisabled && onStepChange(step.id)}\n            disabled={isDisabled}\n            className={cn(\n              'w-full flex items-center gap-half rounded-sm border px-base py-half text-sm text-left transition-colors',\n              isActive\n                ? 'border-brand bg-brand/10 text-high'\n                : isPast\n                  ? 'border-border bg-secondary text-normal hover:bg-primary hover:text-high cursor-pointer'\n                  : 'border-border bg-secondary text-low cursor-not-allowed opacity-50'\n            )}\n          >\n            <Icon\n              className={cn(\n                'size-icon-sm shrink-0',\n                isActive ? 'text-brand' : isPast ? 'text-success' : 'text-low'\n              )}\n              weight={isActive ? 'fill' : 'regular'}\n            />\n            <span className=\"truncate\">{step.label}</span>\n            {isPast && (\n              <CheckCircleIcon\n                className=\"ml-auto size-icon-xs text-success\"\n                weight=\"fill\"\n              />\n            )}\n          </button>\n        );\n      })}\n    </nav>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/MobileDrawer.tsx",
    "content": "import { type ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\n\nimport { cn } from '../lib/cn';\n\ninterface MobileDrawerProps {\n  open: boolean;\n  onClose: () => void;\n  children: ReactNode;\n}\n\nexport function MobileDrawer({ open, onClose, children }: MobileDrawerProps) {\n  return createPortal(\n    <>\n      {/* Backdrop overlay */}\n      <div\n        data-tauri-drag-region\n        className={cn(\n          'fixed inset-0 bg-black/50 z-[100]',\n          'transition-opacity duration-200 ease-out',\n          open ? 'opacity-100' : 'opacity-0 pointer-events-none'\n        )}\n        onClick={onClose}\n        aria-hidden=\"true\"\n      />\n      {/* Drawer panel */}\n      <div\n        className={cn(\n          'fixed left-0 top-0 h-full w-[280px] bg-primary z-[101]',\n          'pb-[env(safe-area-inset-bottom)]',\n          'transition-transform duration-200 ease-out',\n          open ? 'translate-x-0' : '-translate-x-full pointer-events-none'\n        )}\n      >\n        {children}\n      </div>\n    </>,\n    document.body\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ModelList.tsx",
    "content": "import type { Ref } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { BrainIcon, CaretDownIcon, CheckIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from './Dropdown';\n\nexport interface ModelReasoningOption {\n  id: string;\n  label: string;\n  is_default?: boolean;\n}\n\nexport interface ModelListModel {\n  id: string;\n  name: string;\n  provider_id?: string | null;\n  reasoning_options: ModelReasoningOption[];\n}\n\nfunction toPrettyCase(value: string): string {\n  return value\n    .split('_')\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n    .join(' ');\n}\n\nfunction getReasoningLabel(\n  options: ModelReasoningOption[],\n  selectedId: string | null\n): string | null {\n  if (!selectedId) return null;\n  return (\n    options.find((option) => option.id === selectedId)?.label ??\n    toPrettyCase(selectedId)\n  );\n}\n\nfunction getModelKey(model: ModelListModel): string {\n  return model.provider_id ? `${model.provider_id}/${model.id}` : model.id;\n}\n\ninterface ReasoningDropdownProps {\n  options: ModelReasoningOption[];\n  selectedId: string | null;\n  onSelect: (reasoningId: string | null) => void;\n}\n\nfunction ReasoningDropdown({\n  options,\n  selectedId,\n  onSelect,\n}: ReasoningDropdownProps) {\n  const { t } = useTranslation('common');\n  if (!options.length) return null;\n\n  const selectedLabel =\n    getReasoningLabel(options, selectedId) ?? t('modelSelector.default');\n  const isDefaultSelected = selectedId === null;\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          className={cn(\n            'inline-flex items-center gap-1 rounded-sm border border-border',\n            'bg-secondary/60 px-1.5 py-0.5 text-[10px] font-semibold text-low',\n            'hover:border-brand/40 hover:text-normal transition-colors',\n            'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand'\n          )}\n        >\n          <BrainIcon className=\"size-icon-xs\" weight=\"fill\" />\n          <span className=\"truncate max-w-[90px]\">{selectedLabel}</span>\n          <CaretDownIcon className=\"size-icon-2xs\" />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"end\"\n        data-model-selector-dropdown\n        className=\"min-w-[160px]\"\n      >\n        <DropdownMenuItem\n          icon={isDefaultSelected ? CheckIcon : undefined}\n          onClick={() => onSelect(null)}\n        >\n          {t('modelSelector.default')}\n        </DropdownMenuItem>\n        {options.map((option) => (\n          <DropdownMenuItem\n            key={option.id}\n            icon={option.id === selectedId ? CheckIcon : undefined}\n            onClick={() => onSelect(option.id)}\n          >\n            {option.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nexport interface ModelListProps {\n  models: ModelListModel[];\n  selectedModelId: string | null;\n  searchQuery: string;\n  onSelect: (id: string, providerId?: string) => void;\n  reasoningOptions: ModelReasoningOption[];\n  selectedReasoningId: string | null;\n  onReasoningSelect: (reasoningId: string | null) => void;\n  justifyEnd?: boolean;\n  className?: string;\n  showDefaultOption?: boolean;\n  onSelectDefault?: () => void;\n  scrollRef?: Ref<HTMLDivElement>;\n}\n\nexport function ModelList({\n  models,\n  selectedModelId,\n  searchQuery,\n  onSelect,\n  reasoningOptions,\n  selectedReasoningId,\n  onReasoningSelect,\n  justifyEnd = false,\n  className,\n  showDefaultOption = false,\n  onSelectDefault,\n  scrollRef,\n}: ModelListProps) {\n  const { t } = useTranslation('common');\n  const normalizedSearch = searchQuery.trim().toLowerCase();\n\n  const filteredModels = normalizedSearch\n    ? models.filter((model) => {\n        const name = model.name?.toLowerCase() ?? '';\n        const id = model.id?.toLowerCase() ?? '';\n        return name.includes(normalizedSearch) || id.includes(normalizedSearch);\n      })\n    : models;\n\n  const showEmptyState = filteredModels.length === 0 && !showDefaultOption;\n  const isDefaultSelected = selectedModelId === null;\n  const normalizedSelectedId = selectedModelId?.toLowerCase() ?? null;\n\n  const defaultRow = showDefaultOption ? (\n    <div\n      key=\"__default__\"\n      className={cn(\n        'group flex items-center rounded-sm mx-half',\n        'transition-colors duration-100',\n        'focus-within:bg-secondary',\n        isDefaultSelected\n          ? 'bg-secondary text-high'\n          : cn('text-normal', 'hover:bg-secondary/60')\n      )}\n    >\n      <button\n        type=\"button\"\n        onClick={() => onSelectDefault?.()}\n        className={cn(\n          'flex-1 min-w-0 py-half pl-base pr-half text-left',\n          'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand'\n        )}\n      >\n        <span\n          className={cn(\n            'block text-sm truncate',\n            isDefaultSelected && 'font-semibold'\n          )}\n        >\n          {t('modelSelector.default')}\n        </span>\n      </button>\n    </div>\n  ) : null;\n\n  return (\n    <div\n      ref={scrollRef}\n      className={cn(\n        'flex-1 min-h-0 overflow-y-auto overflow-x-hidden',\n        className\n      )}\n    >\n      {showEmptyState ? (\n        <div className=\"flex h-full items-center justify-center px-base text-sm text-low\">\n          {normalizedSearch ? 'No matches.' : 'No models available.'}\n        </div>\n      ) : (\n        <div\n          className={cn(\n            'flex min-h-full flex-col',\n            justifyEnd && 'justify-end'\n          )}\n        >\n          {filteredModels.map((model) => {\n            const modelKey = getModelKey(model);\n            const isSelected =\n              Boolean(normalizedSelectedId) &&\n              model.id.toLowerCase() === normalizedSelectedId;\n            const isReasoningConfigurable = model.reasoning_options.length > 0;\n            const showReasoningSelector =\n              isSelected &&\n              isReasoningConfigurable &&\n              reasoningOptions.length > 0;\n\n            return (\n              <div\n                key={`${model.provider_id ?? 'default'}/${model.id}`}\n                data-model-key={modelKey}\n                data-model-id={model.id}\n                data-provider-id={model.provider_id ?? ''}\n                className={cn(\n                  'group flex items-center rounded-sm mx-half',\n                  'transition-colors duration-100',\n                  'focus-within:bg-secondary',\n                  isSelected\n                    ? 'bg-secondary text-high'\n                    : cn('text-normal', 'hover:bg-secondary/60')\n                )}\n              >\n                <button\n                  type=\"button\"\n                  onClick={() =>\n                    onSelect(model.id, model.provider_id ?? undefined)\n                  }\n                  className={cn(\n                    'flex-1 min-w-0 py-half pl-base pr-half text-left',\n                    'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand'\n                  )}\n                >\n                  <span\n                    className={cn(\n                      'block text-sm truncate',\n                      isSelected && 'font-semibold'\n                    )}\n                    title={model.name}\n                  >\n                    {model.name}\n                  </span>\n                </button>\n                <div className=\"flex items-center justify-end gap-half pr-base\">\n                  {showReasoningSelector && (\n                    <ReasoningDropdown\n                      options={reasoningOptions}\n                      selectedId={selectedReasoningId}\n                      onSelect={onReasoningSelect}\n                    />\n                  )}\n                  {!showReasoningSelector && isReasoningConfigurable ? (\n                    <span\n                      className={cn(\n                        'inline-flex items-center justify-center',\n                        'size-5 rounded-sm bg-border/80 text-normal',\n                        'dark:bg-secondary/70'\n                      )}\n                      title=\"Reasoning supported\"\n                    >\n                      <BrainIcon className=\"size-icon-xs\" weight=\"fill\" />\n                    </span>\n                  ) : null}\n                </div>\n              </div>\n            );\n          })}\n          {defaultRow}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ModelProviderIcon.tsx",
    "content": "import {\n  GoogleLogoIcon,\n  HardDriveIcon,\n  SparkleIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\ninterface ModelProviderIconProps {\n  providerId: string;\n  theme?: 'light' | 'dark';\n}\n\nexport function ModelProviderIcon({\n  providerId,\n  theme = 'light',\n}: ModelProviderIconProps) {\n  const suffix = theme === 'dark' ? '-dark' : '-light';\n  const id = providerId.toLowerCase();\n  const className = cn('size-icon-sm', 'flex-shrink-0');\n\n  if (id.includes('anthropic') || id.includes('claude')) {\n    return (\n      <img\n        src={`/agents/claude${suffix}.svg`}\n        alt=\"Anthropic\"\n        className={className}\n      />\n    );\n  }\n\n  if (id.includes('openai') || id.includes('gpt')) {\n    return (\n      <img\n        src={`/agents/codex${suffix}.svg`}\n        alt=\"OpenAI\"\n        className={className}\n      />\n    );\n  }\n\n  if (id.includes('google') || id.includes('gemini')) {\n    return <GoogleLogoIcon className={className} />;\n  }\n\n  if (\n    id.includes('local') ||\n    id.includes('ollama') ||\n    id.includes('llama') ||\n    id.includes('server')\n  ) {\n    return <HardDriveIcon className={className} />;\n  }\n\n  return <SparkleIcon className={className} />;\n}\n"
  },
  {
    "path": "packages/ui/src/components/ModelSelectorPopover.tsx",
    "content": "import type { ReactElement, Ref } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { CaretDownIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuLabel,\n  DropdownMenuSearchInput,\n  DropdownMenuTrigger,\n} from './Dropdown';\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from './Accordion';\nimport { ModelProviderIcon } from './ModelProviderIcon';\nimport { ModelList, type ModelListModel } from './ModelList';\n\ntype RecentAlignment = 'top' | 'bottom';\n\ninterface ModelSelectorProvider {\n  id: string;\n  name: string;\n}\n\ninterface ModelSelectorConfigLike {\n  models: ModelListModel[];\n  providers: ModelSelectorProvider[];\n}\n\nexport interface ModelSelectorPopoverProps {\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n  trigger: ReactElement;\n  config: ModelSelectorConfigLike;\n  error?: string | null;\n  selectedProviderId: string | null;\n  selectedModelId: string | null;\n  selectedReasoningId: string | null;\n  searchQuery: string;\n  onSearchChange: (value: string) => void;\n  onModelSelect: (id: string, providerId?: string) => void;\n  onReasoningSelect: (reasoningId: string | null) => void;\n  recentModelEntries?: string[];\n  showDefaultOption?: boolean;\n  onSelectDefault?: () => void;\n  scrollRef?: Ref<HTMLDivElement>;\n  expandedProviderId?: string;\n  onExpandedProviderIdChange?: (id: string) => void;\n  resolvedTheme?: 'light' | 'dark';\n}\n\nconst MODEL_LIST_PAGE_SIZE = 8;\n\nfunction getModelKey(model: ModelListModel): string {\n  return model.provider_id ? `${model.provider_id}/${model.id}` : model.id;\n}\n\nfunction getRecentIndex(\n  recentEntries: string[],\n  model: ModelListModel\n): number {\n  const key = getModelKey(model).toLowerCase();\n  return recentEntries.findIndex((entry) => entry.toLowerCase() === key);\n}\n\nfunction sortByRecency(\n  models: ModelListModel[],\n  recentEntries: string[],\n  align: RecentAlignment = 'bottom'\n): ModelListModel[] {\n  if (recentEntries.length === 0) {\n    return align === 'bottom' ? [...models].reverse() : [...models];\n  }\n\n  const recentMap = new Map(\n    recentEntries.map((entry, idx) => [entry.toLowerCase(), idx])\n  );\n  const nonRecent: ModelListModel[] = [];\n  const recent: { model: ModelListModel; idx: number }[] = [];\n\n  for (const model of models) {\n    const key = getModelKey(model).toLowerCase();\n    const idx = recentMap.get(key) ?? -1;\n    if (idx === -1) {\n      nonRecent.push(model);\n    } else {\n      recent.push({ model, idx });\n    }\n  }\n\n  if (align === 'bottom') {\n    nonRecent.reverse();\n  }\n\n  recent.sort((a, b) => (align === 'bottom' ? a.idx - b.idx : b.idx - a.idx));\n\n  if (align === 'top') {\n    return [...recent.map((entry) => entry.model), ...nonRecent];\n  }\n\n  return [...nonRecent, ...recent.map((entry) => entry.model)];\n}\n\nfunction sortProvidersByRecency(\n  providers: ModelSelectorProvider[],\n  models: ModelListModel[],\n  recentEntries: string[]\n): ModelSelectorProvider[] {\n  const baseProviders = [...providers].reverse();\n  if (recentEntries.length === 0) return baseProviders;\n\n  const recencyByProvider = new Map<string, number>();\n  for (const model of models) {\n    if (!model.provider_id) continue;\n    const idx = getRecentIndex(recentEntries, model);\n    if (idx === -1) continue;\n    const current = recencyByProvider.get(model.provider_id) ?? -1;\n    if (idx > current) {\n      recencyByProvider.set(model.provider_id, idx);\n    }\n  }\n\n  const order = new Map(\n    baseProviders.map((provider, idx) => [provider.id, idx])\n  );\n\n  return [...baseProviders].sort((a, b) => {\n    const aRecent = recencyByProvider.get(a.id) ?? -1;\n    const bRecent = recencyByProvider.get(b.id) ?? -1;\n    if (aRecent === -1 && bRecent === -1) {\n      return (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0);\n    }\n    if (aRecent === -1) return -1;\n    if (bRecent === -1) return 1;\n    if (aRecent === bRecent) {\n      return (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0);\n    }\n    return aRecent - bRecent;\n  });\n}\n\nfunction getSelectedModel(\n  models: ModelListModel[],\n  selectedProviderId: string | null,\n  selectedModelId: string | null\n): ModelListModel | null {\n  if (!selectedModelId) return null;\n  const selectedId = selectedModelId.toLowerCase();\n  if (selectedProviderId) {\n    const providerId = selectedProviderId.toLowerCase();\n    return (\n      models.find(\n        (model) =>\n          model.id.toLowerCase() === selectedId &&\n          model.provider_id?.toLowerCase() === providerId\n      ) ?? null\n    );\n  }\n  return models.find((model) => model.id.toLowerCase() === selectedId) ?? null;\n}\n\nfunction getPopoverWidth(hasProviders: boolean, hasReasoning: boolean): string {\n  if (hasProviders) return 'w-[280px]';\n  if (hasReasoning) return 'w-[230px]';\n  return 'w-[200px]';\n}\n\nfunction matchesSearch(model: ModelListModel, query: string): boolean {\n  const name = model.name?.toLowerCase() ?? '';\n  const id = model.id?.toLowerCase() ?? '';\n  return name.includes(query) || id.includes(query);\n}\n\ninterface ProviderAccordionProps {\n  config: ModelSelectorConfigLike;\n  selectedProviderId: string | null;\n  selectedModelId: string | null;\n  selectedReasoningId: string | null;\n  searchQuery: string;\n  onModelSelect: (id: string, providerId?: string) => void;\n  onReasoningSelect: (reasoningId: string | null) => void;\n  recentModelEntries: string[];\n  showDefaultOption?: boolean;\n  onSelectDefault?: () => void;\n  scrollRef?: Ref<HTMLDivElement>;\n  expandedProviderId: string;\n  onExpandedProviderIdChange: (id: string) => void;\n  resolvedTheme: 'light' | 'dark';\n}\n\nfunction ProviderAccordion({\n  config,\n  selectedProviderId,\n  selectedModelId,\n  selectedReasoningId,\n  searchQuery,\n  onModelSelect,\n  onReasoningSelect,\n  recentModelEntries,\n  showDefaultOption = false,\n  onSelectDefault,\n  scrollRef,\n  expandedProviderId,\n  onExpandedProviderIdChange,\n  resolvedTheme,\n}: ProviderAccordionProps) {\n  const { t } = useTranslation('common');\n  const normalizedSearch = searchQuery.trim().toLowerCase();\n  const selectedModel = getSelectedModel(\n    config.models,\n    selectedProviderId,\n    selectedModelId\n  );\n\n  const modelsByProvider = new Map<string, ModelListModel[]>();\n  for (const model of config.models) {\n    if (!model.provider_id) continue;\n    const list = modelsByProvider.get(model.provider_id) ?? [];\n    list.push(model);\n    modelsByProvider.set(model.provider_id, list);\n  }\n\n  const isDefaultSelected = selectedModelId === null;\n  const providers = sortProvidersByRecency(\n    config.providers,\n    config.models,\n    recentModelEntries\n  );\n\n  return (\n    <div\n      ref={scrollRef}\n      className=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden\"\n    >\n      <div className=\"flex flex-col py-half\">\n        <Accordion\n          type=\"single\"\n          collapsible\n          value={expandedProviderId}\n          onValueChange={onExpandedProviderIdChange}\n        >\n          {providers.map((provider) => {\n            const providerModels = sortByRecency(\n              modelsByProvider.get(provider.id) ?? [],\n              recentModelEntries,\n              'top'\n            );\n            const isSelectedProvider =\n              Boolean(selectedModelId) &&\n              selectedModel?.provider_id?.toLowerCase() ===\n                provider.id.toLowerCase();\n\n            if (\n              normalizedSearch &&\n              !providerModels.some((model) =>\n                matchesSearch(model, normalizedSearch)\n              )\n            ) {\n              return null;\n            }\n\n            return (\n              <AccordionItem key={provider.id} value={provider.id}>\n                <AccordionTrigger\n                  sticky={provider.id === expandedProviderId}\n                  className={cn(\n                    'group gap-2 px-base py-half rounded-sm',\n                    'text-sm font-medium text-low',\n                    'hover:bg-secondary/60 transition-colors',\n                    'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand'\n                  )}\n                >\n                  <ModelProviderIcon\n                    providerId={provider.id}\n                    theme={resolvedTheme}\n                  />\n                  <span className=\"flex-1 text-left truncate\">\n                    {provider.name}\n                  </span>\n                  <CaretDownIcon\n                    className={cn(\n                      'size-icon-2xs text-low transition-transform',\n                      'group-data-[state=open]:rotate-180'\n                    )}\n                    weight=\"bold\"\n                  />\n                </AccordionTrigger>\n                <AccordionContent>\n                  <div className=\"pl-1\">\n                    <ModelList\n                      models={providerModels}\n                      selectedModelId={\n                        isSelectedProvider ? selectedModelId : null\n                      }\n                      searchQuery={searchQuery}\n                      onSelect={onModelSelect}\n                      reasoningOptions={\n                        isSelectedProvider\n                          ? (selectedModel?.reasoning_options ?? [])\n                          : []\n                      }\n                      selectedReasoningId={\n                        isSelectedProvider ? selectedReasoningId : null\n                      }\n                      onReasoningSelect={onReasoningSelect}\n                      justifyEnd={false}\n                    />\n                  </div>\n                </AccordionContent>\n              </AccordionItem>\n            );\n          })}\n        </Accordion>\n        {showDefaultOption && (\n          <div\n            className={cn(\n              'group flex items-center rounded-sm mx-half',\n              'transition-colors duration-100',\n              'focus-within:bg-secondary',\n              isDefaultSelected\n                ? 'bg-secondary text-high'\n                : cn('text-normal', 'hover:bg-secondary/60')\n            )}\n          >\n            <button\n              type=\"button\"\n              onClick={() => onSelectDefault?.()}\n              className={cn(\n                'flex-1 min-w-0 py-half pl-base pr-half text-left',\n                'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand'\n              )}\n            >\n              <span\n                className={cn(\n                  'block text-sm truncate',\n                  isDefaultSelected && 'font-semibold'\n                )}\n              >\n                {t('modelSelector.default')}\n              </span>\n            </button>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport function ModelSelectorPopover({\n  isOpen,\n  onOpenChange,\n  trigger,\n  config,\n  error,\n  selectedProviderId,\n  selectedModelId,\n  selectedReasoningId,\n  searchQuery,\n  onSearchChange,\n  onModelSelect,\n  onReasoningSelect,\n  recentModelEntries = [],\n  showDefaultOption = false,\n  onSelectDefault,\n  scrollRef,\n  expandedProviderId = '',\n  onExpandedProviderIdChange,\n  resolvedTheme = 'light',\n}: ModelSelectorPopoverProps) {\n  const { t } = useTranslation('common');\n  const models = config.models;\n  const hasProviders = config.providers.length > 1;\n  const hasReasoning = models.some(\n    (model) => model.reasoning_options.length > 0\n  );\n  const popoverWidth = getPopoverWidth(hasProviders, hasReasoning);\n  const popoverHeightClass = hasProviders ? 'h-[280px]' : '';\n\n  let showSearch = true;\n  let content: ReactElement;\n\n  if (hasProviders) {\n    content = (\n      <ProviderAccordion\n        config={config}\n        selectedProviderId={selectedProviderId}\n        selectedModelId={selectedModelId}\n        selectedReasoningId={selectedReasoningId}\n        searchQuery={searchQuery}\n        onModelSelect={onModelSelect}\n        onReasoningSelect={onReasoningSelect}\n        recentModelEntries={recentModelEntries}\n        showDefaultOption={showDefaultOption}\n        onSelectDefault={onSelectDefault}\n        scrollRef={scrollRef}\n        expandedProviderId={expandedProviderId}\n        onExpandedProviderIdChange={onExpandedProviderIdChange ?? (() => {})}\n        resolvedTheme={resolvedTheme}\n      />\n    );\n  } else {\n    const sortedModels = sortByRecency(models, recentModelEntries);\n    const selectedModel = getSelectedModel(\n      models,\n      selectedProviderId,\n      selectedModelId\n    );\n    showSearch = models.length > MODEL_LIST_PAGE_SIZE;\n\n    content = (\n      <ModelList\n        models={sortedModels}\n        selectedModelId={selectedModelId}\n        searchQuery={searchQuery}\n        onSelect={onModelSelect}\n        reasoningOptions={selectedModel?.reasoning_options ?? []}\n        selectedReasoningId={selectedReasoningId}\n        onReasoningSelect={onReasoningSelect}\n        justifyEnd\n        className=\"max-h-[233px]\"\n        showDefaultOption={showDefaultOption}\n        onSelectDefault={onSelectDefault}\n        scrollRef={scrollRef}\n      />\n    );\n  }\n\n  return (\n    <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>\n      <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"start\"\n        sideOffset={8}\n        data-model-selector-popover\n        className={cn(\n          'p-0 overflow-hidden flex flex-col',\n          popoverWidth,\n          popoverHeightClass\n        )}\n        onInteractOutside={(event) => {\n          const target = event.target as HTMLElement | null;\n          if (target?.closest('[data-model-selector-dropdown]')) {\n            event.preventDefault();\n          }\n        }}\n      >\n        <div className=\"flex flex-1 flex-col min-h-0 overflow-hidden\">\n          {error && (\n            <div className=\"px-base py-half bg-red-500/10 border-b border-red-500/20\">\n              <span className=\"text-sm text-red-600\">{error}</span>\n            </div>\n          )}\n          <DropdownMenuLabel>{t('modelSelector.model')}</DropdownMenuLabel>\n          <div className=\"flex flex-col flex-1 min-h-0 min-w-0\">\n            {content}\n            {showSearch && (\n              <div className=\"border-t border-border\">\n                <DropdownMenuSearchInput\n                  placeholder=\"Filter by name or ID...\"\n                  value={searchQuery}\n                  onValueChange={onSearchChange}\n                />\n              </div>\n            )}\n          </div>\n        </div>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/MultiSelectCommandBar.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { CheckIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Command,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n} from './Command';\nimport { PrimaryButton } from './PrimaryButton';\n\nexport interface MultiSelectOption<T extends string = string> {\n  value: T;\n  label: string;\n  searchValue?: string;\n  renderOption?: () => ReactNode;\n}\n\ninterface MultiSelectCommandBarProps<T extends string = string> {\n  title: string;\n  options: MultiSelectOption<T>[];\n  selectedValues: T[];\n  onToggle: (value: T) => void;\n  onClose: () => void;\n  search: string;\n  onSearchChange: (search: string) => void;\n}\n\nexport function MultiSelectCommandBar<T extends string = string>({\n  title,\n  options,\n  selectedValues,\n  onToggle,\n  onClose,\n  search,\n  onSearchChange,\n}: MultiSelectCommandBarProps<T>) {\n  const { t } = useTranslation('common');\n\n  return (\n    <Command\n      className=\"rounded-sm border border-border [&_[cmdk-group-heading]]:px-base [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-low [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-half [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-base [&_[cmdk-item]]:py-half\"\n      loop\n      filter={(value, search) => {\n        if (value.toLowerCase().includes(search.toLowerCase())) return 1;\n        return 0;\n      }}\n      onKeyDown={(e) => {\n        if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {\n          e.preventDefault();\n          onClose();\n        }\n      }}\n    >\n      <div className=\"flex items-center border-b border-border\">\n        <CommandInput\n          placeholder={title}\n          value={search}\n          onValueChange={onSearchChange}\n        />\n      </div>\n      <CommandList className=\"min-h-[200px]\">\n        <CommandEmpty>\n          {t('commandBar.noResults', 'No results found')}\n        </CommandEmpty>\n        <CommandGroup>\n          {options.map((option) => {\n            const isSelected = selectedValues.includes(option.value);\n            return (\n              <CommandItem\n                key={option.value}\n                value={option.searchValue ?? `${option.value} ${option.label}`}\n                onSelect={() => onToggle(option.value)}\n              >\n                <div className=\"flex items-center gap-base flex-1\">\n                  {option.renderOption?.() ?? <span>{option.label}</span>}\n                </div>\n                {isSelected && (\n                  <CheckIcon className=\"h-4 w-4 text-brand\" weight=\"bold\" />\n                )}\n              </CommandItem>\n            );\n          })}\n        </CommandGroup>\n      </CommandList>\n      <div className=\"border-t border-border p-base\">\n        <PrimaryButton onClick={onClose} className=\"w-full justify-center\">\n          {t('buttons.close')}\n        </PrimaryButton>\n      </div>\n    </Command>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/MultiSelectDropdown.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../lib/cn';\nimport { CaretDownIcon, type Icon } from '@phosphor-icons/react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n  DropdownMenuCheckboxItem,\n  DropdownMenuSeparator,\n  DropdownMenuLabel,\n} from './Dropdown';\nimport { Badge } from './Badge';\n\nexport interface MultiSelectDropdownOption<T extends string = string> {\n  value: T;\n  label: string;\n  renderOption?: () => ReactNode;\n}\n\nexport interface MultiSelectDropdownProps<T extends string = string> {\n  values: T[];\n  options: MultiSelectDropdownOption<T>[];\n  onChange: (values: T[]) => void;\n  icon: Icon;\n  label: string;\n  menuLabel?: string;\n  disabled?: boolean;\n  renderBadge?: (values: T[]) => ReactNode;\n  /** Show only icon (+ badge) without label or caret */\n  iconOnly?: boolean;\n}\n\nexport function MultiSelectDropdown<T extends string = string>({\n  values,\n  options,\n  onChange,\n  icon: IconComponent,\n  label,\n  menuLabel,\n  disabled,\n  renderBadge,\n  iconOnly,\n}: MultiSelectDropdownProps<T>) {\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild disabled={disabled}>\n        <button\n          type=\"button\"\n          className={cn(\n            'flex items-center gap-half bg-panel rounded-sm',\n            'text-sm text-normal hover:bg-secondary transition-colors',\n            'disabled:opacity-50 disabled:cursor-not-allowed',\n            'py-half',\n            'px-base'\n          )}\n        >\n          <IconComponent className=\"size-icon-xs\" weight=\"bold\" />\n          {!iconOnly && <span>{label}</span>}\n          {values.length > 0 &&\n            (renderBadge ? (\n              renderBadge(values)\n            ) : (\n              <Badge\n                variant=\"secondary\"\n                className=\"px-1.5 py-0 text-xs h-5 min-w-5 justify-center bg-brand border-none\"\n              >\n                {values.length}\n              </Badge>\n            ))}\n          {!iconOnly && (\n            <CaretDownIcon className=\"size-icon-2xs text-low\" weight=\"bold\" />\n          )}\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\">\n        {menuLabel && (\n          <>\n            <DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>\n            <DropdownMenuSeparator />\n          </>\n        )}\n        {options.map((option) => (\n          <DropdownMenuCheckboxItem\n            key={option.value}\n            checked={values.includes(option.value)}\n            onCheckedChange={() => {\n              const newValues = values.includes(option.value)\n                ? values.filter((v) => v !== option.value)\n                : [...values, option.value];\n              onChange(newValues);\n            }}\n          >\n            {option.renderOption?.() ?? option.label}\n          </DropdownMenuCheckboxItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Navbar.tsx",
    "content": "import type { ButtonHTMLAttributes, ReactNode } from 'react';\nimport type { Icon } from '@phosphor-icons/react';\nimport {\n  Layout as LayoutIcon,\n  ChatsTeardrop as ChatsTeardropIcon,\n  GitDiff as GitDiffIcon,\n  Terminal as TerminalIcon,\n  Desktop as DesktopIcon,\n  GitFork as GitForkIcon,\n  List as ListIcon,\n  Gear as GearIcon,\n  Kanban as KanbanIcon,\n  CaretLeft as CaretLeftIcon,\n  ArrowClockwise as ArrowClockwiseIcon,\n  SidebarSimple as SidebarSimpleIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\nimport {\n  SyncErrorIndicator,\n  type SyncErrorIndicatorError,\n} from './SyncErrorIndicator';\n\n/**\n * Action item rendered in the navbar.\n */\nexport interface NavbarActionItem {\n  type?: 'action';\n  id: string;\n  icon: Icon;\n  isActive?: boolean;\n  tooltip?: string;\n  shortcut?: string;\n  disabled?: boolean;\n  onClick?: () => void;\n}\n\n/**\n * Divider item rendered in the navbar.\n */\nexport interface NavbarDividerItem {\n  type: 'divider';\n}\n\nexport type NavbarSectionItem = NavbarActionItem | NavbarDividerItem;\n\nfunction isDivider(item: NavbarSectionItem): item is NavbarDividerItem {\n  return item.type === 'divider';\n}\n\n// NavbarIconButton - inlined from primitives\ninterface NavbarIconButtonProps\n  extends ButtonHTMLAttributes<HTMLButtonElement> {\n  icon: Icon;\n  isActive?: boolean;\n  tooltip?: string;\n  shortcut?: string;\n}\n\nfunction NavbarIconButton({\n  icon: IconComponent,\n  isActive = false,\n  tooltip,\n  shortcut,\n  className,\n  ...props\n}: NavbarIconButtonProps) {\n  const button = (\n    <button\n      type=\"button\"\n      className={cn(\n        'flex items-center justify-center rounded-sm',\n        'text-low hover:text-normal',\n        isActive && 'text-normal',\n        className\n      )}\n      {...props}\n    >\n      <IconComponent\n        className=\"size-icon-base\"\n        weight={isActive ? 'fill' : 'regular'}\n      />\n    </button>\n  );\n\n  return tooltip ? (\n    <Tooltip content={tooltip} shortcut={shortcut}>\n      {button}\n    </Tooltip>\n  ) : (\n    button\n  );\n}\n\nexport type MobileTabId =\n  | 'workspaces'\n  | 'chat'\n  | 'changes'\n  | 'logs'\n  | 'preview'\n  | 'git';\n\nexport const MOBILE_TABS: { id: MobileTabId; icon: Icon; label: string }[] = [\n  { id: 'workspaces', icon: LayoutIcon, label: 'Wksps' },\n  { id: 'chat', icon: ChatsTeardropIcon, label: 'Chat' },\n  { id: 'changes', icon: GitDiffIcon, label: 'Diff' },\n  { id: 'logs', icon: TerminalIcon, label: 'Logs' },\n  { id: 'preview', icon: DesktopIcon, label: 'Preview' },\n  { id: 'git', icon: GitForkIcon, label: 'Git' },\n];\n\nexport interface NavbarBreadcrumbItem {\n  label: string;\n  onClick?: () => void;\n}\n\ninterface NavbarBreadcrumbsProps {\n  breadcrumbs: NavbarBreadcrumbItem[];\n  textClassName: string;\n}\n\nfunction NavbarBreadcrumbs({\n  breadcrumbs,\n  textClassName,\n}: NavbarBreadcrumbsProps) {\n  return (\n    <div className={cn('flex items-center gap-1 min-w-0', textClassName)}>\n      {breadcrumbs.map((crumb, index) => {\n        const isLast = index === breadcrumbs.length - 1;\n        return (\n          <span key={index} className=\"flex items-center gap-1 min-w-0\">\n            {index > 0 && <span className=\"text-low shrink-0\">/</span>}\n            {crumb.onClick && !isLast ? (\n              <button\n                type=\"button\"\n                className=\"text-low hover:text-normal truncate cursor-pointer\"\n                onClick={crumb.onClick}\n              >\n                {crumb.label}\n              </button>\n            ) : (\n              <span\n                className={cn('truncate', isLast ? 'text-normal' : 'text-low')}\n              >\n                {crumb.label}\n              </span>\n            )}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\nexport interface NavbarProps {\n  workspaceTitle?: string;\n  breadcrumbs?: NavbarBreadcrumbItem[];\n  // Items for left side of navbar\n  leftItems?: NavbarSectionItem[];\n  // Items for right side of navbar (with dividers inline)\n  rightItems?: NavbarSectionItem[];\n  // Optional additional content for left side (after leftItems)\n  leftSlot?: ReactNode;\n  // Sync errors shown in the right section\n  syncErrors?: readonly SyncErrorIndicatorError[] | null;\n  className?: string;\n  // Mobile props\n  mobileMode?: boolean;\n  mobileUserSlot?: ReactNode;\n  isOnProjectPage?: boolean;\n  onOpenCommandBar?: () => void;\n  onOpenSettings?: () => void;\n  onNavigateToBoard?: (() => void) | null;\n  onNavigateBack?: () => void;\n  onReload?: () => void;\n  onOpenDrawer?: () => void;\n  isOnProjectSubRoute?: boolean;\n  mobileActiveTab?: MobileTabId;\n  onMobileTabChange?: (tab: MobileTabId) => void;\n  mobileTabs?: { id: MobileTabId; icon: Icon; label: string }[];\n  showMobileTabs?: boolean;\n  mobileShowBack?: boolean;\n}\n\nexport function Navbar({\n  workspaceTitle,\n  breadcrumbs,\n  leftItems = [],\n  rightItems = [],\n  leftSlot,\n  syncErrors,\n  className,\n  mobileMode = false,\n  mobileUserSlot,\n  isOnProjectPage = false,\n  onOpenCommandBar,\n  onOpenSettings,\n  onNavigateToBoard,\n  onNavigateBack,\n  onReload,\n  onOpenDrawer,\n  isOnProjectSubRoute = false,\n  mobileActiveTab = 'chat',\n  onMobileTabChange,\n  mobileTabs,\n  showMobileTabs,\n  mobileShowBack,\n}: NavbarProps) {\n  const renderItem = (item: NavbarSectionItem, key: string) => {\n    // Render divider\n    if (isDivider(item)) {\n      return <div key={key} className=\"h-4 w-px bg-border\" />;\n    }\n\n    const isDisabled = !!item.disabled;\n\n    return (\n      <NavbarIconButton\n        key={key}\n        icon={item.icon}\n        isActive={item.isActive}\n        onClick={item.onClick}\n        aria-label={item.tooltip}\n        tooltip={item.tooltip}\n        shortcut={item.shortcut}\n        disabled={isDisabled}\n        className={isDisabled ? 'opacity-40 cursor-not-allowed' : ''}\n      />\n    );\n  };\n\n  // ---- Mobile layout ----\n  if (mobileMode) {\n    return (\n      <nav\n        className={cn(\n          'flex flex-col bg-secondary border-b shrink-0',\n          className\n        )}\n      >\n        {/* Row 1: Tab bar (workspace pages) or minimal header (project pages) */}\n        <div className=\"flex items-center justify-between px-base py-half\">\n          {isOnProjectPage ? (\n            <div className=\"flex items-center gap-base\">\n              {isOnProjectSubRoute\n                ? onNavigateBack && (\n                    <button\n                      type=\"button\"\n                      className=\"flex items-center justify-center text-low hover:text-normal\"\n                      onClick={onNavigateBack}\n                      aria-label=\"Back\"\n                    >\n                      <CaretLeftIcon className=\"size-icon-base\" />\n                    </button>\n                  )\n                : onOpenDrawer && (\n                    <button\n                      type=\"button\"\n                      className=\"flex items-center justify-center text-low hover:text-normal\"\n                      onClick={onOpenDrawer}\n                      aria-label=\"Open menu\"\n                    >\n                      <SidebarSimpleIcon className=\"size-icon-base\" />\n                    </button>\n                  )}\n              <p className=\"text-base text-normal font-medium truncate cursor-default select-none\">\n                {workspaceTitle}\n              </p>\n            </div>\n          ) : (\n            <div className=\"flex items-center gap-0.5 overflow-x-auto\">\n              {mobileShowBack && onNavigateBack ? (\n                <>\n                  <button\n                    type=\"button\"\n                    className=\"flex items-center justify-center px-1.5 py-1 text-low hover:text-normal\"\n                    onClick={onNavigateBack}\n                    aria-label=\"Back\"\n                  >\n                    <CaretLeftIcon className=\"size-icon-sm\" />\n                  </button>\n                  <div className=\"h-4 w-px bg-border mx-0.5 shrink-0\" />\n                </>\n              ) : (\n                onOpenDrawer && (\n                  <>\n                    <button\n                      type=\"button\"\n                      className=\"flex items-center justify-center px-1.5 py-1 text-low hover:text-normal\"\n                      onClick={onOpenDrawer}\n                      aria-label=\"Projects\"\n                    >\n                      <KanbanIcon className=\"size-icon-sm\" />\n                    </button>\n                    <div className=\"h-4 w-px bg-border mx-0.5 shrink-0\" />\n                  </>\n                )\n              )}\n              {showMobileTabs !== false &&\n                (mobileTabs ?? MOBILE_TABS).map((tab) => {\n                  const TabIcon = tab.icon;\n                  const isActive = mobileActiveTab === tab.id;\n                  return (\n                    <button\n                      key={tab.id}\n                      type=\"button\"\n                      className={cn(\n                        'flex items-center gap-1 px-1.5 py-1 text-xs whitespace-nowrap transition-colors',\n                        isActive\n                          ? 'text-normal border-b-2 border-brand'\n                          : 'text-low hover:text-normal'\n                      )}\n                      onClick={() => onMobileTabChange?.(tab.id)}\n                    >\n                      <TabIcon\n                        className=\"size-icon-sm\"\n                        weight={isActive ? 'fill' : 'regular'}\n                      />\n                      <span className=\"hidden min-[480px]:inline\">\n                        {tab.label}\n                      </span>\n                    </button>\n                  );\n                })}\n              {onNavigateToBoard && (\n                <button\n                  type=\"button\"\n                  className=\"flex items-center gap-1 px-1.5 py-1 text-xs text-low hover:text-normal whitespace-nowrap\"\n                  onClick={onNavigateToBoard}\n                >\n                  <KanbanIcon className=\"size-icon-sm\" />\n                  <span className=\"hidden min-[480px]:inline\">Board</span>\n                </button>\n              )}\n            </div>\n          )}\n\n          {/* Right side: sync indicator + action buttons + user slot */}\n          <div className=\"flex items-center gap-1 shrink-0\">\n            <SyncErrorIndicator errors={syncErrors} />\n            {isOnProjectPage &&\n              rightItems\n                .filter((item): item is NavbarActionItem => !isDivider(item))\n                .map((item) => (\n                  <NavbarIconButton\n                    key={item.id}\n                    icon={item.icon}\n                    isActive={item.isActive}\n                    onClick={item.onClick}\n                    aria-label={item.tooltip}\n                    tooltip={item.tooltip}\n                    disabled={!!item.disabled}\n                    className={\n                      item.disabled ? 'opacity-40 cursor-not-allowed' : ''\n                    }\n                  />\n                ))}\n            {onReload && (\n              <button\n                type=\"button\"\n                className=\"flex items-center justify-center text-low hover:text-normal\"\n                onClick={onReload}\n                aria-label=\"Reload\"\n              >\n                <ArrowClockwiseIcon className=\"size-icon-sm\" />\n              </button>\n            )}\n            {!isOnProjectPage && onOpenSettings && (\n              <button\n                type=\"button\"\n                className=\"flex items-center justify-center text-low hover:text-normal\"\n                onClick={onOpenSettings}\n                aria-label=\"Settings\"\n              >\n                <GearIcon className=\"size-icon-sm\" />\n              </button>\n            )}\n            {!isOnProjectPage && onOpenCommandBar && (\n              <button\n                type=\"button\"\n                className=\"flex items-center justify-center text-low hover:text-normal\"\n                onClick={onOpenCommandBar}\n                aria-label=\"Command bar\"\n              >\n                <ListIcon className=\"size-icon-sm\" />\n              </button>\n            )}\n            {mobileUserSlot && (\n              <div className=\"h-4 w-px bg-border mx-0.5 shrink-0\" />\n            )}\n            {mobileUserSlot}\n          </div>\n        </div>\n\n        {/* Row 2: Info bar with leftSlot + breadcrumbs/title (workspace pages only) */}\n        {!isOnProjectPage && (workspaceTitle || breadcrumbs) && (\n          <div className=\"flex items-center justify-between px-base py-half border-t border-border\">\n            <div className=\"flex items-center gap-base flex-1 min-w-0\">\n              {leftSlot}\n              {breadcrumbs && breadcrumbs.length > 0 ? (\n                <NavbarBreadcrumbs\n                  breadcrumbs={breadcrumbs}\n                  textClassName=\"text-sm\"\n                />\n              ) : (\n                <p className=\"text-sm text-low truncate cursor-default select-none\">\n                  {workspaceTitle}\n                </p>\n              )}\n            </div>\n          </div>\n        )}\n      </nav>\n    );\n  }\n\n  // ---- Desktop layout ----\n  // data-tauri-drag-region must be on every non-interactive element for Tauri 2\n  // window dragging to work (the attribute does not propagate to children).\n  return (\n    <nav\n      data-tauri-drag-region\n      className={cn(\n        'flex items-center justify-between px-base py-half bg-secondary border-b shrink-0',\n        className\n      )}\n    >\n      {/* Left - Archive & Old UI Link + optional slot */}\n      <div data-tauri-drag-region className=\"flex-1 flex items-center gap-base\">\n        {leftItems.map((item, index) =>\n          renderItem(\n            item,\n            `left-${isDivider(item) ? 'divider' : item.id}-${index}`\n          )\n        )}\n        {leftSlot}\n      </div>\n\n      {/* Center - Breadcrumbs or Workspace Title */}\n      <div\n        data-tauri-drag-region\n        className=\"flex-1 flex items-center justify-center min-w-0\"\n      >\n        {breadcrumbs && breadcrumbs.length > 0 ? (\n          <NavbarBreadcrumbs\n            breadcrumbs={breadcrumbs}\n            textClassName=\"text-base\"\n          />\n        ) : (\n          <p\n            data-tauri-drag-region\n            className=\"text-base text-low truncate cursor-default select-none\"\n          >\n            {workspaceTitle ?? ''}\n          </p>\n        )}\n      </div>\n\n      {/* Right - Sync Error Indicator + Diff Controls + Panel Toggles (dividers inline) */}\n      <div\n        data-tauri-drag-region\n        className=\"flex-1 flex items-center justify-end gap-base\"\n      >\n        <SyncErrorIndicator errors={syncErrors} />\n        {rightItems.map((item, index) =>\n          renderItem(\n            item,\n            `right-${isDivider(item) ? 'divider' : item.id}-${index}`\n          )\n        )}\n      </div>\n    </nav>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/OAuthButtons.tsx",
    "content": "import { cn } from '../lib/cn';\nimport { SpinnerIcon } from '@phosphor-icons/react';\nimport { GitHubDark } from 'developer-icons';\nimport { useTranslation } from 'react-i18next';\nimport { GoogleLogo } from './GoogleLogo';\n\nexport type OAuthProvider = 'github' | 'google';\n\ninterface OAuthSignInButtonProps {\n  provider: OAuthProvider;\n  onClick: () => void;\n  disabled?: boolean;\n  loading?: boolean;\n  loadingText?: string;\n  className?: string;\n}\n\nconst providerConfig = {\n  github: {\n    i18nKey: 'oauth.continueWithGitHub' as const,\n    icon: () => <GitHubDark className=\"size-5\" />,\n  },\n  google: {\n    i18nKey: 'oauth.continueWithGoogle' as const,\n    icon: () => <GoogleLogo className=\"size-5\" />,\n  },\n};\n\nexport function OAuthSignInButton({\n  provider,\n  onClick,\n  disabled,\n  loading,\n  loadingText,\n  className,\n}: OAuthSignInButtonProps) {\n  const { t } = useTranslation('common');\n  const config = providerConfig[provider];\n  const ProviderIcon = config.icon;\n\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        'relative flex h-10 min-w-[280px] items-center overflow-hidden rounded-[4px] border px-3',\n        'border-[#dadce0] bg-[#f2f2f2] text-[#1f1f1f] hover:bg-[#e8eaed] active:bg-[#e2e3e5]',\n        'text-[14px] font-medium leading-5 tracking-[0.25px]',\n        'transition-colors duration-150',\n        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a73e8]/40',\n        'disabled:cursor-not-allowed disabled:bg-[#ffffff61] disabled:text-[#1f1f1f]/40 disabled:shadow-none',\n        className\n      )}\n      onClick={onClick}\n      disabled={disabled || loading}\n      style={{ fontFamily: \"'Roboto', Arial, sans-serif\" }}\n    >\n      <span className=\"grid w-full grid-cols-[20px_minmax(0,1fr)_20px] items-center gap-[10px]\">\n        <span className=\"flex h-5 w-5 items-center justify-center\">\n          {loading ? (\n            <SpinnerIcon\n              className=\"size-4 animate-spin text-[#1f1f1f]\"\n              weight=\"bold\"\n            />\n          ) : (\n            <ProviderIcon />\n          )}\n        </span>\n        <span className=\"truncate text-center\">\n          {loading && loadingText ? loadingText : t(config.i18nKey)}\n        </span>\n        <span aria-hidden=\"true\" className=\"h-5 w-5\" />\n      </span>\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/PasteMarkdownPlugin.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  PASTE_COMMAND,\n  COMMAND_PRIORITY_LOW,\n  $getSelection,\n  $isRangeSelection,\n  $createParagraphNode,\n  $setSelection,\n} from 'lexical';\nimport {\n  $convertFromMarkdownString,\n  type Transformer,\n} from '@lexical/markdown';\nimport { getTauriInvoke, isTauriRuntime } from '../lib/platform';\n\ntype Props = {\n  transformers: Transformer[];\n};\n\n/**\n * Plugin that handles paste with markdown conversion.\n *\n * Behavior:\n * - CMD+V with HTML: Let default Lexical handling work\n * - CMD+V with plain text: Convert markdown to formatted nodes, insert at cursor\n * - CMD+SHIFT+V: Insert plain text as-is (raw paste)\n */\nexport function PasteMarkdownPlugin({ transformers }: Props) {\n  const [editor] = useLexicalComposerContext();\n  const shiftHeldRef = useRef(false);\n\n  const readRawClipboardText = async (): Promise<string> => {\n    const tauriInvoke = getTauriInvoke();\n\n    if (tauriInvoke) {\n      try {\n        const text = await tauriInvoke('read_clipboard_text');\n        if (typeof text === 'string') {\n          return text;\n        }\n      } catch {\n        // Fall back to navigator clipboard below.\n      }\n    }\n\n    return navigator.clipboard.readText();\n  };\n\n  useEffect(() => {\n    // Track Shift key state during paste shortcut\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') {\n        const isRawPasteCombo = e.shiftKey;\n        shiftHeldRef.current = e.shiftKey;\n\n        // Tauri/WebKit may not dispatch a paste ClipboardEvent for Cmd+Shift+V.\n        // Fallback: handle raw paste directly from clipboard on keydown.\n        if (isRawPasteCombo) {\n          // Browser should use native paste event path (no clipboard-read\n          // permission prompts). Tauri may not emit paste events, so keep\n          // fallback there only.\n          if (!isTauriRuntime()) {\n            return;\n          }\n\n          const rootElement = editor.getRootElement();\n          const activeEl = document.activeElement;\n          const domSelection = window.getSelection();\n          const hasSelectionInsideEditor =\n            !!rootElement &&\n            !!domSelection?.anchorNode &&\n            rootElement.contains(domSelection.anchorNode);\n          const isEditorFocused =\n            !!rootElement && !!activeEl && rootElement.contains(activeEl);\n          const shouldHandleRawPaste =\n            isEditorFocused || hasSelectionInsideEditor;\n\n          if (!shouldHandleRawPaste) {\n            return;\n          }\n\n          e.preventDefault();\n          e.stopPropagation();\n\n          void readRawClipboardText()\n            .then((text) => {\n              if (!text) return;\n\n              editor.update(() => {\n                const selection = $getSelection();\n                if (!$isRangeSelection(selection)) return;\n                selection.insertRawText(text);\n              });\n            })\n            .catch(() => {});\n        }\n      }\n    };\n\n    const handleKeyUp = () => {\n      shiftHeldRef.current = false;\n    };\n\n    // Use window capture listeners so Tauri/WebKit shortcut handling does not\n    // bypass tracking when the event target is outside the editor root.\n    window.addEventListener('keydown', handleKeyDown, true);\n    window.addEventListener('keyup', handleKeyUp, true);\n\n    const unregisterPaste = editor.registerCommand(\n      PASTE_COMMAND,\n      (event) => {\n        if (!(event instanceof ClipboardEvent)) return false;\n\n        const clipboardData = event.clipboardData;\n        if (!clipboardData) return false;\n\n        const plainText =\n          clipboardData.getData('text/plain') || clipboardData.getData('text');\n        const htmlText = clipboardData.getData('text/html');\n\n        // CMD+SHIFT+V: Raw paste must win even when HTML data is present.\n        if (shiftHeldRef.current) {\n          if (!plainText) return false;\n          event.preventDefault();\n\n          editor.update(() => {\n            const selection = $getSelection();\n            if (!$isRangeSelection(selection)) return;\n            selection.insertRawText(plainText);\n          });\n          shiftHeldRef.current = false;\n          return true;\n        }\n\n        // If HTML exists, let default Lexical handling work.\n        if (htmlText) return false;\n\n        if (!plainText) return false;\n\n        event.preventDefault();\n\n        editor.update(() => {\n          const selection = $getSelection();\n          if (!$isRangeSelection(selection)) return;\n\n          // CMD+V: Convert markdown and insert at cursor\n          // Save selection before any operations that might corrupt it\n          const savedSelection = selection.clone();\n\n          try {\n            const tempContainer = $createParagraphNode();\n            // Note: $convertFromMarkdownString internally calls selectStart() on the container,\n            // which corrupts the current selection - that's why we clone it above\n            $convertFromMarkdownString(plainText, transformers, tempContainer);\n\n            // Restore selection that was corrupted by $convertFromMarkdownString\n            $setSelection(savedSelection);\n\n            const nodes = tempContainer.getChildren();\n            if (nodes.length === 0) {\n              savedSelection.insertRawText(plainText);\n              return;\n            }\n\n            savedSelection.insertNodes(nodes);\n          } catch {\n            // Fallback to raw text on error - restore selection first to ensure\n            // we have a valid selection context for the fallback\n            $setSelection(savedSelection);\n            savedSelection.insertRawText(plainText);\n          }\n        });\n        shiftHeldRef.current = false;\n        return true;\n      },\n      COMMAND_PRIORITY_LOW\n    );\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown, true);\n      window.removeEventListener('keyup', handleKeyUp, true);\n      unregisterPaste();\n    };\n  }, [editor, transformers]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/PierreConversationDiff.tsx",
    "content": "import { type ElementType, type ReactNode, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  CaretDownIcon,\n  FileIcon as DefaultFileIcon,\n} from '@phosphor-icons/react';\nimport { FileDiff, PatchDiff } from '@pierre/diffs/react';\nimport {\n  parseDiffFromFile,\n  type FileContents,\n  type FileDiffMetadata,\n  type ChangeContent,\n} from '@pierre/diffs';\nimport { cn } from '../lib/cn';\nimport { ToolStatusDot, type ToolStatusLike } from './ToolStatusDot';\nimport '../styles/diff-style-overrides.css';\n\n/**\n * CSS overrides for @pierre/diffs to match our app's theme.\n * Injected via unsafeCSS which applies at @layer unsafe (highest priority).\n */\nconst PIERRE_DIFFS_THEME_CSS = `\n  [data-separator=\"line-info\"][data-separator-first] {\n    padding-top: 4px;\n  }\n  [data-separator=\"line-info\"][data-separator-last] {\n    padding-bottom: 4px;\n  }\n\n  [data-disable-line-numbers][data-indicators='classic'] [data-column-content] {\n    padding-inline-start: calc(2ch + 1ch);\n  }\n  [data-disable-line-numbers][data-indicators='classic'] [data-line-type='change-addition'] [data-column-content]::before,\n  [data-disable-line-numbers][data-indicators='classic'] [data-line-type='change-deletion'] [data-column-content]::before {\n    left: 1ch;\n  }\n\n  /* Show scrollbar only on hover */\n  [data-code] {\n    padding-bottom: 0 !important;\n  }\n  [data-code]::-webkit-scrollbar {\n    height: 8px !important;\n    background: transparent !important;\n  }\n  [data-code]::-webkit-scrollbar-track {\n    background: transparent !important;\n  }\n  [data-code]::-webkit-scrollbar-thumb {\n    background-color: transparent !important;\n    border-radius: 4px !important;\n  }\n  [data-code]:hover::-webkit-scrollbar-thumb {\n    background-color: hsl(var(--text-low) / 0.3) !important;\n  }\n\n  /* Light theme overrides */\n  [data-diffs][data-theme-type='light'] {\n    --diffs-gap-style: none !important;\n    \n    /* Background colors - use standard CSS variables */\n    --diffs-light-bg: hsl(var(--bg-primary)) !important;\n    --diffs-bg-context-override: hsl(var(--bg-primary)) !important;\n    --diffs-bg-separator-override: hsl(var(--bg-primary)) !important;\n    \n    /* Addition colors - soft green matching old design */\n    --diffs-light-addition-color: hsl(160, 77%, 35%) !important;\n    --diffs-bg-addition-override: hsl(160, 77%, 88%) !important;\n    --diffs-bg-addition-number-override: hsl(160, 77%, 85%) !important;\n    --diffs-bg-addition-hover-override: hsl(160, 77%, 82%) !important;\n    \n    /* Deletion colors - soft red matching old design */\n    --diffs-light-deletion-color: hsl(10, 100%, 40%) !important;\n    --diffs-bg-deletion-override: hsl(10, 100%, 90%) !important;\n    --diffs-bg-deletion-number-override: hsl(10, 100%, 87%) !important;\n    --diffs-bg-deletion-hover-override: hsl(10, 100%, 84%) !important;\n    \n    /* Line numbers */\n    --diffs-fg-number-override: hsl(var(--text-low)) !important;\n  }\n\n  /* Dark theme overrides */\n  [data-diffs][data-theme-type='dark'] {\n    --diffs-gap-style: none !important;\n    \n    /* Background colors - use standard CSS variables */\n    --diffs-dark-bg: hsl(var(--bg-panel)) !important;\n    --diffs-bg-context-override: hsl(var(--bg-panel)) !important;\n    --diffs-bg-separator-override: hsl(var(--bg-panel)) !important;\n    --diffs-bg-hover-override: hsl(0, 0%, 22%) !important;\n    \n    /* Addition colors - dark green */\n    --diffs-dark-addition-color: hsl(130, 50%, 50%) !important;\n    --diffs-bg-addition-override: hsl(130, 30%, 20%) !important;\n    --diffs-bg-addition-number-override: hsl(130, 30%, 18%) !important;\n    --diffs-bg-addition-hover-override: hsl(130, 30%, 25%) !important;\n    \n    /* Deletion colors - dark red */\n    --diffs-dark-deletion-color: hsl(12, 50%, 55%) !important;\n    --diffs-bg-deletion-override: hsl(12, 30%, 18%) !important;\n    --diffs-bg-deletion-number-override: hsl(12, 30%, 16%) !important;\n    --diffs-bg-deletion-hover-override: hsl(12, 30%, 23%) !important;\n    \n    /* Line numbers */\n    --diffs-fg-number-override: hsl(var(--text-low)) !important;\n  }\n`;\n\n// Discriminated union for input format flexibility\nexport type DiffInput =\n  | {\n      type: 'content';\n      oldContent: string;\n      newContent: string;\n      oldPath?: string;\n      newPath: string;\n    }\n  | {\n      type: 'unified';\n      path: string;\n      unifiedDiff: string;\n      hasLineNumbers?: boolean;\n    };\n\nexport type DiffViewTheme = 'light' | 'dark';\nexport type DiffViewMode = 'unified' | 'split';\n\ninterface DiffViewCardProps {\n  input: DiffInput;\n  expanded?: boolean;\n  onToggle?: () => void;\n  status?: ToolStatusLike;\n  className?: string;\n  fileIcon?: ElementType;\n  theme: DiffViewTheme;\n  diffMode?: DiffViewMode;\n  wrapText?: boolean;\n  ignoreWhitespace?: boolean;\n}\n\ninterface DiffData {\n  fileDiffMetadata: FileDiffMetadata | null;\n  unifiedDiff: string | null;\n  additions: number;\n  deletions: number;\n  filePath: string;\n  isValid: boolean;\n  hideLineNumbers: boolean;\n}\n\nfunction parseDiffStats(unifiedDiff: string): {\n  additions: number;\n  deletions: number;\n} {\n  let additions = 0;\n  let deletions = 0;\n  const lines = unifiedDiff.split('\\n');\n  for (const line of lines) {\n    if (line.startsWith('+') && !line.startsWith('+++')) additions++;\n    else if (line.startsWith('-') && !line.startsWith('---')) deletions++;\n  }\n  return { additions, deletions };\n}\n\n/**\n * Process input to get diff data and statistics\n */\nexport function useDiffData(\n  input: DiffInput,\n  options?: { ignoreWhitespace?: boolean }\n): DiffData {\n  return useMemo(() => {\n    if (input.type === 'content') {\n      const filePath = input.newPath || input.oldPath || 'unknown';\n      const oldContent = input.oldContent || '';\n      const newContent = input.newContent || '';\n\n      if (oldContent === newContent) {\n        return {\n          fileDiffMetadata: null,\n          unifiedDiff: null,\n          additions: 0,\n          deletions: 0,\n          filePath,\n          isValid: false,\n          hideLineNumbers: false,\n        };\n      }\n\n      try {\n        const oldFile: FileContents = {\n          name: input.oldPath || filePath,\n          contents: oldContent,\n        };\n        const newFile: FileContents = {\n          name: filePath,\n          contents: newContent,\n        };\n        const metadata = parseDiffFromFile(\n          oldFile,\n          newFile,\n          options?.ignoreWhitespace ? { ignoreWhitespace: true } : undefined\n        );\n\n        // Calculate additions/deletions from hunks\n        let additions = 0;\n        let deletions = 0;\n        for (const hunk of metadata.hunks) {\n          for (const content of hunk.hunkContent) {\n            if (content.type === 'change') {\n              const change = content as ChangeContent;\n              additions += change.additions.length;\n              deletions += change.deletions.length;\n            }\n          }\n        }\n\n        return {\n          fileDiffMetadata: metadata,\n          unifiedDiff: null,\n          additions,\n          deletions,\n          filePath,\n          isValid: true,\n          hideLineNumbers: false,\n        };\n      } catch (e) {\n        console.error('Failed to generate diff:', e);\n        return {\n          fileDiffMetadata: null,\n          unifiedDiff: null,\n          additions: 0,\n          deletions: 0,\n          filePath,\n          isValid: false,\n          hideLineNumbers: false,\n        };\n      }\n    } else {\n      // Handle unified diff string\n      const { path, unifiedDiff, hasLineNumbers = true } = input;\n      const { additions, deletions } = parseDiffStats(unifiedDiff);\n      const isValid = unifiedDiff.trim().length > 0;\n\n      return {\n        fileDiffMetadata: null,\n        unifiedDiff,\n        additions,\n        deletions,\n        filePath: path,\n        isValid,\n        hideLineNumbers: !hasLineNumbers,\n      };\n    }\n  }, [input, options?.ignoreWhitespace]);\n}\n\nexport function DiffViewCard({\n  input,\n  expanded = false,\n  onToggle,\n  status,\n  className,\n  fileIcon,\n  theme,\n  diffMode = 'unified',\n  wrapText = false,\n  ignoreWhitespace = false,\n}: DiffViewCardProps) {\n  const {\n    fileDiffMetadata,\n    unifiedDiff,\n    additions,\n    deletions,\n    filePath,\n    isValid,\n    hideLineNumbers,\n  } = useDiffData(input, { ignoreWhitespace });\n\n  const FileIcon = fileIcon ?? DefaultFileIcon;\n  const hasStats = additions > 0 || deletions > 0;\n\n  return (\n    <div className={cn('rounded-sm border overflow-hidden', className)}>\n      {/* Header */}\n      <div\n        className={cn(\n          'flex items-center bg-panel p-base w-full',\n          onToggle && 'cursor-pointer'\n        )}\n        onClick={onToggle}\n      >\n        <div className=\"flex-1 flex items-center gap-base min-w-0\">\n          <span className=\"relative shrink-0\">\n            <FileIcon className=\"size-icon-base\" />\n            {status && (\n              <ToolStatusDot\n                status={status}\n                className=\"absolute -bottom-0.5 -right-0.5\"\n              />\n            )}\n          </span>\n          <span className=\"text-sm text-normal truncate font-ibm-plex-mono\">\n            {filePath}\n          </span>\n          {hasStats && (\n            <span className=\"text-sm shrink-0\">\n              {additions > 0 && (\n                <span className=\"text-success\">+{additions}</span>\n              )}\n              {additions > 0 && deletions > 0 && ' '}\n              {deletions > 0 && (\n                <span className=\"text-error\">-{deletions}</span>\n              )}\n            </span>\n          )}\n        </div>\n        {onToggle && (\n          <CaretDownIcon\n            className={cn(\n              'size-icon-xs shrink-0 text-low transition-transform',\n              !expanded && '-rotate-90'\n            )}\n          />\n        )}\n      </div>\n\n      {/* Diff body - shown when expanded */}\n      {expanded && (\n        <DiffViewBody\n          fileDiffMetadata={fileDiffMetadata}\n          unifiedDiff={unifiedDiff}\n          isValid={isValid}\n          hideLineNumbers={hideLineNumbers}\n          theme={theme}\n          diffMode={diffMode}\n          wrapText={wrapText}\n        />\n      )}\n    </div>\n  );\n}\n\n/**\n * Diff body component that renders the actual diff content\n */\nexport function DiffViewBody({\n  fileDiffMetadata,\n  unifiedDiff,\n  isValid,\n  hideLineNumbers,\n  theme,\n  diffMode = 'unified',\n  wrapText,\n  invalidMessage,\n}: {\n  fileDiffMetadata: FileDiffMetadata | null;\n  unifiedDiff: string | null;\n  isValid: boolean;\n  hideLineNumbers?: boolean;\n  theme: DiffViewTheme;\n  diffMode?: DiffViewMode;\n  wrapText?: boolean;\n  invalidMessage?: ReactNode;\n}) {\n  const { t } = useTranslation('tasks');\n\n  const options = useMemo(\n    () => ({\n      diffStyle:\n        diffMode === 'split' ? ('split' as const) : ('unified' as const),\n      diffIndicators: 'classic' as const,\n      themeType: theme,\n      overflow: wrapText ? ('wrap' as const) : ('scroll' as const),\n      hunkSeparators: () => document.createDocumentFragment(),\n      disableFileHeader: true,\n      disableLineNumbers: hideLineNumbers,\n      theme: { dark: 'github-dark', light: 'github-light' } as const,\n      unsafeCSS: PIERRE_DIFFS_THEME_CSS,\n    }),\n    [diffMode, theme, wrapText, hideLineNumbers]\n  );\n\n  if (!isValid) {\n    return (\n      <div className=\"px-base pb-base text-xs font-ibm-plex-mono text-low\">\n        {invalidMessage ?? t('conversation.unableToRenderDiff')}\n      </div>\n    );\n  }\n\n  // For content-based diff\n  if (fileDiffMetadata) {\n    return <FileDiff fileDiff={fileDiffMetadata} options={options} />;\n  }\n\n  // For unified diff string\n  if (unifiedDiff) {\n    return <PatchDiff patch={unifiedDiff} options={options} />;\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/Popover.tsx",
    "content": "import * as PopoverPrimitive from '@radix-ui/react-popover';\nimport {\n  forwardRef,\n  type ComponentPropsWithoutRef,\n  type ElementRef,\n} from 'react';\nimport { cn } from '../lib/cn';\n\nconst Popover = PopoverPrimitive.Root;\n\nconst PopoverTrigger = PopoverPrimitive.Trigger;\n\nconst PopoverAnchor = PopoverPrimitive.Anchor;\n\nconst PopoverClose = PopoverPrimitive.Close;\n\nconst PopoverContent = forwardRef<\n  ElementRef<typeof PopoverPrimitive.Content>,\n  ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        ref={ref}\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          'z-[10000] w-72 overflow-hidden',\n          'bg-panel border border-border rounded-sm p-base shadow-md',\n          'data-[state=open]:animate-in',\n          'data-[state=open]:fade-in-0',\n          'data-[state=open]:zoom-in-95',\n          'data-[state=closed]:animate-out',\n          'data-[state=closed]:fade-out-0',\n          'data-[state=closed]:zoom-out-95',\n          'data-[side=bottom]:slide-in-from-top-2',\n          'data-[side=left]:slide-in-from-right-2',\n          'data-[side=right]:slide-in-from-left-2',\n          'data-[side=top]:slide-in-from-bottom-2',\n          'origin-[--radix-popover-content-transform-origin]',\n          className\n        )}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  );\n});\nPopoverContent.displayName = PopoverPrimitive.Content.displayName;\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose };\n"
  },
  {
    "path": "packages/ui/src/components/PrBadge.tsx",
    "content": "import { cn } from '../lib/cn';\nimport { GitPullRequestIcon } from '@phosphor-icons/react';\n\nexport type PrBadgeStatus = 'open' | 'merged' | 'closed';\n\nexport interface PrBadgeProps {\n  number: number;\n  url: string;\n  status: PrBadgeStatus;\n  className?: string;\n}\n\nexport function PrBadge({ number, url, status, className }: PrBadgeProps) {\n  return (\n    <a\n      href={url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      onClick={(e) => e.stopPropagation()}\n      className={cn(\n        'flex items-center gap-half px-1.5 py-0.5 rounded text-xs font-medium transition-colors',\n        status === 'merged'\n          ? 'bg-merged/10 text-merged hover:bg-merged/20'\n          : status === 'closed'\n            ? 'bg-error/10 text-error hover:bg-error/20'\n            : 'bg-success/10 text-success hover:bg-success/20',\n        className\n      )}\n    >\n      <GitPullRequestIcon className=\"size-icon-2xs\" weight=\"bold\" />\n      <span>#{number}</span>\n    </a>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/PreviewBrowser.tsx",
    "content": "import type { CSSProperties, MouseEvent, RefObject, TouchEvent } from 'react';\nimport {\n  PlayIcon,\n  SpinnerIcon,\n  WrenchIcon,\n  ArrowSquareOutIcon,\n  ArrowClockwiseIcon,\n  CopyIcon,\n  XIcon,\n  CrosshairIcon,\n  MonitorIcon,\n  DeviceMobileIcon,\n  ArrowsOutCardinalIcon,\n  PauseIcon,\n  CheckIcon,\n  TerminalIcon,\n  GlobeIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { PrimaryButton } from './PrimaryButton';\nimport { IconButtonGroup, IconButtonGroupItem } from './IconButtonGroup';\nimport {\n  PreviewNavigation,\n  type PreviewNavigationState,\n} from './PreviewNavigation';\n\nexport const MOBILE_WIDTH = 390;\nexport const MOBILE_HEIGHT = 844;\n// Phone frame adds padding (p-3 = 12px * 2) and rounded corners\nexport const PHONE_FRAME_PADDING = 24;\nconst PREVIEW_IFRAME_SANDBOX =\n  'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals';\n\nexport type PreviewBrowserScreenSize = 'desktop' | 'mobile' | 'responsive';\n\nexport interface PreviewBrowserResponsiveDimensions {\n  width: number;\n  height: number;\n}\n\nexport interface PreviewBrowserRepo {\n  dev_server_script?: string | null;\n}\n\ninterface PreviewBrowserProps {\n  url?: string;\n  autoDetectedUrl?: string;\n  urlInputValue: string;\n  urlInputRef: RefObject<HTMLInputElement>;\n  isUsingOverride?: boolean;\n  onUrlInputChange: (value: string) => void;\n  onUrlSubmit: () => void;\n  onUrlEscape?: () => void;\n  onClearOverride?: () => void;\n  onCopyUrl: () => void;\n  onOpenInNewTab: () => void;\n  onRefresh: () => void;\n  onStart: () => void;\n  onStop: () => void;\n  isStarting: boolean;\n  isStopping: boolean;\n  isServerRunning: boolean;\n  showIframe: boolean;\n  allowManualUrl?: boolean;\n  screenSize: PreviewBrowserScreenSize;\n  localDimensions: PreviewBrowserResponsiveDimensions;\n  onScreenSizeChange: (size: PreviewBrowserScreenSize) => void;\n  onResizeStart: (\n    direction: 'right' | 'bottom' | 'corner'\n  ) => (e: MouseEvent | TouchEvent) => void;\n  isResizing: boolean;\n  containerRef: RefObject<HTMLDivElement>;\n  repos: PreviewBrowserRepo[];\n  handleEditDevScript: () => void;\n  handleFixDevScript?: () => void;\n  hasFailedDevServer?: boolean;\n  mobileScale: number;\n  className?: string;\n  iframeRef: RefObject<HTMLIFrameElement>;\n  navigation: PreviewNavigationState | null;\n  onNavigateBack: () => void;\n  onNavigateForward: () => void;\n  isInspectMode: boolean;\n  onToggleInspectMode: () => void;\n  isErudaVisible: boolean;\n  onToggleEruda: () => void;\n  onIframeLoad?: () => void;\n  isMobile?: boolean;\n  mobileUrlExpanded?: boolean;\n  onMobileUrlExpandedChange?: (expanded: boolean) => void;\n}\n\nexport function PreviewBrowser({\n  url,\n  autoDetectedUrl,\n  urlInputValue,\n  urlInputRef,\n  isUsingOverride,\n  onUrlInputChange,\n  onUrlSubmit,\n  onUrlEscape,\n  onClearOverride,\n  onCopyUrl,\n  onOpenInNewTab,\n  onRefresh,\n  onStart,\n  onStop,\n  isStarting,\n  isStopping,\n  isServerRunning,\n  showIframe,\n  allowManualUrl,\n  screenSize,\n  localDimensions,\n  onScreenSizeChange,\n  onResizeStart,\n  isResizing,\n  containerRef,\n  repos,\n  handleEditDevScript,\n  handleFixDevScript,\n  hasFailedDevServer,\n  mobileScale,\n  className,\n  iframeRef,\n  navigation,\n  onNavigateBack,\n  onNavigateForward,\n  isInspectMode,\n  onToggleInspectMode,\n  isErudaVisible,\n  onToggleEruda,\n  onIframeLoad,\n  isMobile,\n  mobileUrlExpanded,\n  onMobileUrlExpandedChange,\n}: PreviewBrowserProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n  const isLoading = isStarting || (isServerRunning && !url);\n  // Use the showIframe prop from container which handles the 2-second delay\n  const showIframeContent = showIframe && url && !isLoading && isServerRunning;\n  // Show loading when URL detected but waiting for delay\n  const isWaitingForDelay = isServerRunning && url && !showIframe;\n\n  const hasDevScript = repos.some(\n    (repo) => repo.dev_server_script && repo.dev_server_script.trim() !== ''\n  );\n\n  const getIframeContainerStyle = (): CSSProperties => {\n    switch (screenSize) {\n      case 'mobile':\n        return {\n          width: MOBILE_WIDTH,\n          height: MOBILE_HEIGHT,\n        };\n      case 'responsive':\n        return {\n          width: localDimensions.width,\n          height: localDimensions.height,\n        };\n      case 'desktop':\n      default:\n        return {\n          width: '100%',\n          height: '100%',\n        };\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        'bg-brand/20 w-full h-full flex flex-col overflow-hidden',\n        className\n      )}\n    >\n      {/* Floating Toolbar */}\n      <div className=\"p-double\">\n        <div className=\"backdrop-blur-sm bg-primary/80 border border-brand/20 flex items-center gap-base p-base rounded-md shadow-md shrink-0\">\n          {/* Mobile: URL expanded mode — full-width URL input with submit/close */}\n          {isMobile && mobileUrlExpanded ? (\n            <>\n              <div className=\"flex items-center gap-half rounded-sm px-base py-half flex-1 min-w-0\">\n                <input\n                  ref={urlInputRef}\n                  type=\"text\"\n                  value={urlInputValue}\n                  onChange={(e) => onUrlInputChange(e.target.value)}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Enter') {\n                      onUrlSubmit();\n                      onMobileUrlExpandedChange?.(false);\n                    }\n                    if (e.key === 'Escape') {\n                      e.stopPropagation();\n                      onUrlEscape?.();\n                      onMobileUrlExpandedChange?.(false);\n                    }\n                  }}\n                  placeholder={autoDetectedUrl ?? 'Enter URL...'}\n                  disabled={!isServerRunning}\n                  className={cn(\n                    'flex-1 font-mono text-sm bg-transparent border-none outline-none min-w-0',\n                    isUsingOverride\n                      ? 'text-normal'\n                      : 'text-low placeholder:text-low',\n                    !isServerRunning && 'cursor-not-allowed'\n                  )}\n                  autoFocus\n                />\n              </div>\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={CheckIcon}\n                  onClick={() => {\n                    onUrlSubmit();\n                    onMobileUrlExpandedChange?.(false);\n                  }}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.submitUrl')}\n                  title={t('preview.toolbar.submitUrl')}\n                />\n                <IconButtonGroupItem\n                  icon={XIcon}\n                  onClick={() => onMobileUrlExpandedChange?.(false)}\n                  aria-label={t('common:buttons.close')}\n                  title={t('common:buttons.close')}\n                />\n              </IconButtonGroup>\n            </>\n          ) : isMobile ? (\n            /* Mobile: URL collapsed mode — globe, refresh, open-in-tab, start/stop */\n            <>\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={GlobeIcon}\n                  onClick={() => onMobileUrlExpandedChange?.(true)}\n                  disabled={!isServerRunning}\n                  aria-label=\"Show URL\"\n                  title=\"Show URL\"\n                />\n              </IconButtonGroup>\n\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={ArrowClockwiseIcon}\n                  onClick={onRefresh}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.refresh')}\n                  title={t('preview.toolbar.refresh')}\n                />\n                <IconButtonGroupItem\n                  icon={ArrowSquareOutIcon}\n                  onClick={onOpenInNewTab}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.openInTab')}\n                  title={t('preview.toolbar.openInTab')}\n                />\n              </IconButtonGroup>\n\n              <div className=\"flex-1\" />\n\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={\n                    isServerRunning\n                      ? isStopping\n                        ? SpinnerIcon\n                        : PauseIcon\n                      : isStarting\n                        ? SpinnerIcon\n                        : PlayIcon\n                  }\n                  iconClassName={\n                    (isServerRunning && isStopping) ||\n                    (!isServerRunning && isStarting)\n                      ? 'animate-spin'\n                      : undefined\n                  }\n                  onClick={isServerRunning ? onStop : onStart}\n                  disabled={\n                    isServerRunning ? isStopping : isStarting || !hasDevScript\n                  }\n                  aria-label={\n                    isServerRunning\n                      ? t('preview.toolbar.stopDevServer')\n                      : t('preview.toolbar.startDevServer')\n                  }\n                  title={\n                    isServerRunning\n                      ? t('preview.toolbar.stopDevServer')\n                      : t('preview.toolbar.startDevServer')\n                  }\n                />\n              </IconButtonGroup>\n            </>\n          ) : (\n            /* Desktop: Full toolbar */\n            <>\n              {/* Navigation (Back/Forward) */}\n              <PreviewNavigation\n                navigation={navigation}\n                onBack={onNavigateBack}\n                onForward={onNavigateForward}\n                disabled={!isServerRunning}\n              />\n\n              {/* Inspect Mode & DevTools */}\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={CrosshairIcon}\n                  onClick={onToggleInspectMode}\n                  active={isInspectMode}\n                  disabled={!isServerRunning}\n                  aria-label=\"Select element as context\"\n                  title=\"Select element as context\"\n                />\n                <IconButtonGroupItem\n                  icon={TerminalIcon}\n                  onClick={onToggleEruda}\n                  active={isErudaVisible}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.toggleDevTools')}\n                  title={t('preview.toolbar.toggleDevTools')}\n                />\n              </IconButtonGroup>\n\n              {/* URL Input */}\n              <div\n                className={cn(\n                  'flex items-center gap-half rounded-sm px-base py-half flex-1 min-w-0',\n                  !isServerRunning && 'opacity-50'\n                )}\n              >\n                <input\n                  ref={urlInputRef}\n                  type=\"text\"\n                  value={urlInputValue}\n                  onChange={(e) => onUrlInputChange(e.target.value)}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Enter') onUrlSubmit();\n                    if (e.key === 'Escape') {\n                      e.stopPropagation();\n                      onUrlEscape?.();\n                    }\n                  }}\n                  placeholder={autoDetectedUrl ?? 'Enter URL...'}\n                  disabled={!isServerRunning}\n                  className={cn(\n                    'flex-1 font-mono text-sm bg-transparent border-none outline-none min-w-0',\n                    isUsingOverride\n                      ? 'text-normal'\n                      : 'text-low placeholder:text-low',\n                    !isServerRunning && 'cursor-not-allowed'\n                  )}\n                />\n              </div>\n\n              {/* URL Actions */}\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={CheckIcon}\n                  onClick={onUrlSubmit}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.submitUrl')}\n                  title={t('preview.toolbar.submitUrl')}\n                />\n                {isUsingOverride && (\n                  <IconButtonGroupItem\n                    icon={XIcon}\n                    onClick={onClearOverride}\n                    disabled={!isServerRunning}\n                    aria-label={t('preview.toolbar.clearUrlOverride')}\n                    title={t('preview.toolbar.resetUrl')}\n                  />\n                )}\n                <IconButtonGroupItem\n                  icon={CopyIcon}\n                  onClick={onCopyUrl}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.copyUrl')}\n                  title={t('preview.toolbar.copyUrl')}\n                />\n                <IconButtonGroupItem\n                  icon={ArrowSquareOutIcon}\n                  onClick={onOpenInNewTab}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.openInTab')}\n                  title={t('preview.toolbar.openInTab')}\n                />\n                <IconButtonGroupItem\n                  icon={ArrowClockwiseIcon}\n                  onClick={onRefresh}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.refresh')}\n                  title={t('preview.toolbar.refresh')}\n                />\n              </IconButtonGroup>\n\n              {/* Screen Size Toggle */}\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={MonitorIcon}\n                  onClick={() => onScreenSizeChange('desktop')}\n                  active={screenSize === 'desktop'}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.desktopView')}\n                  title={t('preview.toolbar.desktopView')}\n                />\n                <IconButtonGroupItem\n                  icon={DeviceMobileIcon}\n                  onClick={() => onScreenSizeChange('mobile')}\n                  active={screenSize === 'mobile'}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.mobileView')}\n                  title={t('preview.toolbar.mobileView')}\n                />\n                <IconButtonGroupItem\n                  icon={ArrowsOutCardinalIcon}\n                  onClick={() => onScreenSizeChange('responsive')}\n                  active={screenSize === 'responsive'}\n                  disabled={!isServerRunning}\n                  aria-label={t('preview.toolbar.responsiveView')}\n                  title={t('preview.toolbar.responsiveView')}\n                />\n              </IconButtonGroup>\n\n              {/* Dimensions display for responsive mode */}\n              {screenSize === 'responsive' && (\n                <span className=\"text-xs text-low font-mono whitespace-nowrap\">\n                  {Math.round(localDimensions.width)} &times;{' '}\n                  {Math.round(localDimensions.height)}\n                </span>\n              )}\n\n              {/* Start/Stop Button */}\n              <IconButtonGroup>\n                <IconButtonGroupItem\n                  icon={\n                    isServerRunning\n                      ? isStopping\n                        ? SpinnerIcon\n                        : PauseIcon\n                      : isStarting\n                        ? SpinnerIcon\n                        : PlayIcon\n                  }\n                  iconClassName={\n                    (isServerRunning && isStopping) ||\n                    (!isServerRunning && isStarting)\n                      ? 'animate-spin'\n                      : undefined\n                  }\n                  onClick={isServerRunning ? onStop : onStart}\n                  disabled={\n                    isServerRunning ? isStopping : isStarting || !hasDevScript\n                  }\n                  aria-label={\n                    isServerRunning\n                      ? t('preview.toolbar.stopDevServer')\n                      : t('preview.toolbar.startDevServer')\n                  }\n                  title={\n                    isServerRunning\n                      ? t('preview.toolbar.stopDevServer')\n                      : t('preview.toolbar.startDevServer')\n                  }\n                />\n              </IconButtonGroup>\n            </>\n          )}\n        </div>\n      </div>\n\n      {/* Content area */}\n      <div\n        ref={containerRef}\n        className={cn(\n          'flex-1 min-h-0 relative px-double pb-double',\n          screenSize === 'mobile' ? 'overflow-hidden' : 'overflow-auto'\n        )}\n      >\n        {showIframeContent ? (\n          <div\n            className={cn(\n              'h-full',\n              screenSize === 'desktop' ? '' : 'flex items-center justify-center'\n            )}\n          >\n            {screenSize === 'mobile' ? (\n              // Phone frame for mobile mode - scales down to fit container\n              <div\n                className=\"bg-primary rounded-[2rem] p-3 shadow-xl origin-center\"\n                style={{\n                  transform:\n                    mobileScale < 1 ? `scale(${mobileScale})` : undefined,\n                }}\n              >\n                <div\n                  className=\"rounded-[1.5rem] overflow-hidden\"\n                  style={{ width: MOBILE_WIDTH, height: MOBILE_HEIGHT }}\n                >\n                  <iframe\n                    ref={iframeRef}\n                    src={url}\n                    title={t('preview.browser.title')}\n                    className=\"w-full h-full border-0\"\n                    sandbox={PREVIEW_IFRAME_SANDBOX}\n                    referrerPolicy=\"no-referrer\"\n                    onLoad={onIframeLoad}\n                  />\n                </div>\n              </div>\n            ) : (\n              // Desktop and responsive modes\n              <div\n                className={cn(\n                  'rounded-sm border overflow-hidden relative',\n                  screenSize === 'responsive' && 'shadow-lg'\n                )}\n                style={getIframeContainerStyle()}\n              >\n                <iframe\n                  ref={iframeRef}\n                  src={url}\n                  title={t('preview.browser.title')}\n                  className={cn(\n                    'w-full h-full border-0',\n                    isResizing && 'pointer-events-none'\n                  )}\n                  sandbox={PREVIEW_IFRAME_SANDBOX}\n                  referrerPolicy=\"no-referrer\"\n                  onLoad={onIframeLoad}\n                />\n\n                {/* Resize handles for responsive mode */}\n                {screenSize === 'responsive' && (\n                  <>\n                    {/* Right edge handle */}\n                    <div\n                      className=\"absolute top-0 right-0 w-2 h-full cursor-ew-resize hover:bg-brand/30 transition-colors\"\n                      onMouseDown={onResizeStart('right')}\n                      onTouchStart={onResizeStart('right')}\n                    />\n                    {/* Bottom edge handle */}\n                    <div\n                      className=\"absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-brand/30 transition-colors\"\n                      onMouseDown={onResizeStart('bottom')}\n                      onTouchStart={onResizeStart('bottom')}\n                    />\n                    {/* Corner handle */}\n                    <div\n                      className=\"absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize hover:bg-brand/30 transition-colors\"\n                      onMouseDown={onResizeStart('corner')}\n                      onTouchStart={onResizeStart('corner')}\n                    />\n                  </>\n                )}\n              </div>\n            )}\n          </div>\n        ) : (\n          <div className=\"w-full h-full flex flex-col items-center justify-center gap-base text-low\">\n            {isLoading || isWaitingForDelay ? (\n              <>\n                <SpinnerIcon className=\"size-icon-lg animate-spin text-brand\" />\n                <p className=\"text-sm\">\n                  {isStarting\n                    ? t('preview.loading.startingServer')\n                    : isWaitingForDelay\n                      ? t('preview.loading.loadingPreview')\n                      : t('preview.loading.waitingForServer')}\n                </p>\n                {allowManualUrl && !autoDetectedUrl && (\n                  <p className=\"text-sm text-low mt-base\">\n                    {t('preview.loading.manualUrlHint')}\n                  </p>\n                )}\n              </>\n            ) : hasDevScript ? (\n              <>\n                <p>{t('preview.noServer.title')}</p>\n                {hasFailedDevServer && handleFixDevScript ? (\n                  <PrimaryButton\n                    variant=\"tertiary\"\n                    value={t('scriptFixer.fixScript')}\n                    actionIcon={WrenchIcon}\n                    onClick={handleFixDevScript}\n                  />\n                ) : (\n                  <PrimaryButton\n                    value={t('attempt.actions.startDevServer')}\n                    actionIcon={PlayIcon}\n                    onClick={onStart}\n                  />\n                )}\n              </>\n            ) : (\n              <div className=\"flex flex-col gap-double p-double max-w-md\">\n                <div className=\"flex flex-col gap-base\">\n                  <p className=\"text-xl text-high max-w-xs\">\n                    {t('preview.noServer.setupTitle')}\n                  </p>\n                  <p>{t('preview.noServer.setupPrompt')}</p>\n                </div>\n                <div className=\"flex flex-col gap-base\">\n                  <div>\n                    <PrimaryButton\n                      value={t('preview.noServer.editDevScript')}\n                      onClick={handleEditDevScript}\n                    />\n                  </div>\n                  <a\n                    href=\"https://www.vibekanban.com/docs/core-features/testing-your-application\"\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"text-brand hover:text-brand-hover underline\"\n                  >\n                    {t('preview.noServer.learnMore')}\n                  </a>\n                </div>\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/PreviewControls.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { ArrowSquareOutIcon, SpinnerIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\n\nexport interface PreviewControlsProcessTab {\n  id: string;\n  label: string;\n}\n\ninterface PreviewControlsProps {\n  processTabs: PreviewControlsProcessTab[];\n  activeProcessId: string | null;\n  logsContent: ReactNode;\n  onViewFullLogs: () => void;\n  onTabChange: (processId: string) => void;\n  isLoading: boolean;\n  className?: string;\n}\n\nexport function PreviewControls({\n  processTabs,\n  activeProcessId,\n  logsContent,\n  onViewFullLogs,\n  onTabChange,\n  isLoading,\n  className,\n}: PreviewControlsProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n\n  return (\n    <div\n      className={cn(\n        'w-full bg-secondary flex flex-col overflow-hidden',\n        className\n      )}\n    >\n      <div className=\"flex-1 flex flex-col min-h-0\">\n        <div className=\"flex items-center justify-between px-base py-half\">\n          <span className=\"text-xs font-medium text-low\">\n            {t('preview.logs.label')}\n          </span>\n          <button\n            type=\"button\"\n            onClick={onViewFullLogs}\n            className=\"flex items-center gap-half text-xs text-brand hover:text-brand-hover\"\n          >\n            <span>{t('preview.logs.viewFull')}</span>\n            <ArrowSquareOutIcon className=\"size-icon-xs\" />\n          </button>\n        </div>\n\n        {processTabs.length > 1 && (\n          <div className=\"flex border-b border-border mx-base\">\n            {processTabs.map((process) => (\n              <button\n                key={process.id}\n                className={cn(\n                  'px-base py-half text-xs border-b-2 transition-colors',\n                  activeProcessId === process.id\n                    ? 'border-brand text-normal'\n                    : 'border-transparent text-low hover:text-normal'\n                )}\n                onClick={() => onTabChange(process.id)}\n              >\n                {process.label}\n              </button>\n            ))}\n          </div>\n        )}\n\n        <div className=\"flex-1 min-h-0 overflow-hidden\">\n          {isLoading && processTabs.length === 0 ? (\n            <div className=\"h-full flex items-center justify-center text-low\">\n              <SpinnerIcon className=\"size-icon-sm animate-spin\" />\n            </div>\n          ) : processTabs.length > 0 ? (\n            logsContent\n          ) : null}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/PreviewNavigation.tsx",
    "content": "import { ArrowLeftIcon, ArrowRightIcon } from '@phosphor-icons/react';\nimport { IconButtonGroup, IconButtonGroupItem } from './IconButtonGroup';\n\nexport interface PreviewNavigationState {\n  canGoBack: boolean;\n  canGoForward: boolean;\n}\n\ninterface PreviewNavigationProps {\n  navigation: PreviewNavigationState | null;\n  onBack: () => void;\n  onForward: () => void;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function PreviewNavigation({\n  navigation,\n  onBack,\n  onForward,\n  disabled = false,\n  className,\n}: PreviewNavigationProps) {\n  return (\n    <IconButtonGroup className={className}>\n      <IconButtonGroupItem\n        icon={ArrowLeftIcon}\n        onClick={onBack}\n        disabled={!navigation?.canGoBack || disabled}\n        aria-label=\"Go back\"\n        title=\"Go back\"\n      />\n      <IconButtonGroupItem\n        icon={ArrowRightIcon}\n        onClick={onForward}\n        disabled={!navigation?.canGoForward || disabled}\n        aria-label=\"Go forward\"\n        title=\"Go forward\"\n      />\n    </IconButtonGroup>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/PrimaryButton.tsx",
    "content": "import { SpinnerIcon, type Icon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\ninterface PrimaryButtonProps {\n  variant?: 'default' | 'secondary' | 'tertiary';\n  actionIcon?: Icon | 'spinner';\n  value?: string;\n  onClick?: () => void;\n  disabled?: boolean;\n  children?: React.ReactNode;\n  className?: string;\n}\n\nexport function PrimaryButton({\n  variant = 'default',\n  actionIcon: ActionIcon,\n  value,\n  onClick,\n  disabled,\n  children,\n  className,\n}: PrimaryButtonProps) {\n  const variantStyles = disabled\n    ? 'cursor-not-allowed bg-panel'\n    : variant === 'default'\n      ? 'bg-brand hover:bg-brand-hover text-on-brand'\n      : variant === 'secondary'\n        ? 'bg-brand-secondary hover:bg-brand-hover text-on-brand'\n        : 'bg-panel hover:bg-secondary text-normal';\n\n  return (\n    <button\n      className={cn(\n        'rounded-sm px-base py-half text-cta min-h-cta flex gap-half items-center',\n        variantStyles,\n        className\n      )}\n      onClick={onClick}\n      disabled={disabled}\n    >\n      {value}\n      {children}\n      {ActionIcon ? (\n        ActionIcon === 'spinner' ? (\n          <SpinnerIcon className={'size-icon-sm animate-spin'} weight=\"bold\" />\n        ) : (\n          <ActionIcon className={'size-icon-xs'} weight=\"bold\" />\n        )\n      ) : null}\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/PriorityFilterDropdown.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { FunnelIcon } from '@phosphor-icons/react';\nimport {\n  MultiSelectDropdown,\n  type MultiSelectDropdownOption,\n} from './MultiSelectDropdown';\nimport { PriorityIcon } from './PriorityIcon';\n\nexport type PriorityFilterValue = 'urgent' | 'high' | 'medium' | 'low';\n\nconst PRIORITIES: PriorityFilterValue[] = ['urgent', 'high', 'medium', 'low'];\n\nconst priorityLabels: Record<PriorityFilterValue, string> = {\n  urgent: 'Urgent',\n  high: 'High',\n  medium: 'Medium',\n  low: 'Low',\n};\n\nexport interface PriorityFilterDropdownProps {\n  values: PriorityFilterValue[];\n  onChange: (values: PriorityFilterValue[]) => void;\n}\n\nexport function PriorityFilterDropdown({\n  values,\n  onChange,\n}: PriorityFilterDropdownProps) {\n  const { t } = useTranslation('common');\n\n  const options: MultiSelectDropdownOption<PriorityFilterValue>[] =\n    PRIORITIES.map((p) => ({\n      value: p,\n      label: priorityLabels[p],\n      renderOption: () => (\n        <div className=\"flex items-center gap-base\">\n          <PriorityIcon priority={p} />\n          {priorityLabels[p]}\n        </div>\n      ),\n    }));\n\n  return (\n    <MultiSelectDropdown\n      values={values}\n      options={options}\n      onChange={onChange}\n      icon={FunnelIcon}\n      label={t('kanban.priority', 'Priority')}\n      menuLabel={t('kanban.filterByPriority', 'Filter by priority')}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/PriorityIcon.tsx",
    "content": "import { cn } from '../lib/cn';\nimport {\n  ArrowFatLineUpIcon,\n  ArrowUpIcon,\n  ArrowDownIcon,\n  MinusIcon,\n} from '@phosphor-icons/react';\n\nexport type PriorityLevel = 'urgent' | 'high' | 'medium' | 'low';\n\nexport interface PriorityIconProps {\n  priority: PriorityLevel | null;\n  className?: string;\n}\n\nconst priorityConfig: Record<\n  PriorityLevel,\n  { icon: typeof ArrowUpIcon; colorClass: string }\n> = {\n  urgent: { icon: ArrowFatLineUpIcon, colorClass: 'text-error' },\n  high: { icon: ArrowUpIcon, colorClass: 'text-brand' },\n  medium: { icon: MinusIcon, colorClass: 'text-low' },\n  low: { icon: ArrowDownIcon, colorClass: 'text-success' },\n};\n\nexport const PriorityIcon = ({ priority, className }: PriorityIconProps) => {\n  if (!priority) return null;\n  const { icon: IconComponent, colorClass } = priorityConfig[priority];\n  return (\n    <IconComponent\n      className={cn('size-icon-xs', colorClass, className)}\n      weight=\"bold\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/components/ProcessListItem.tsx",
    "content": "import {\n  TerminalIcon,\n  GearIcon,\n  CodeIcon,\n  GlobeIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { RunningDots } from './RunningDots';\n\ninterface ProcessListItemProps {\n  runReason: string;\n  status: string;\n  startedAt: string;\n  selected?: boolean;\n  onClick?: () => void;\n  className?: string;\n}\n\nfunction getRunReasonLabel(runReason: string): string {\n  switch (runReason) {\n    case 'codingagent':\n      return 'Coding Agent';\n    case 'setupscript':\n      return 'Setup Script';\n    case 'cleanupscript':\n      return 'Cleanup Script';\n    case 'archivescript':\n      return 'Archive Script';\n    case 'devserver':\n      return 'Dev Server';\n    default:\n      return runReason;\n  }\n}\n\nfunction getRunReasonIcon(runReason: string): typeof TerminalIcon {\n  switch (runReason) {\n    case 'codingagent':\n      return CodeIcon;\n    case 'setupscript':\n    case 'cleanupscript':\n    case 'archivescript':\n      return GearIcon;\n    case 'devserver':\n      return GlobeIcon;\n    default:\n      return TerminalIcon;\n  }\n}\n\nfunction getStatusColor(status: string): string {\n  switch (status) {\n    case 'running':\n      return 'bg-info';\n    case 'completed':\n      return 'bg-success';\n    case 'failed':\n      return 'bg-destructive';\n    case 'killed':\n      return 'bg-low';\n    default:\n      return 'bg-low';\n  }\n}\n\nfunction formatRelativeElapsed(dateString: string): string {\n  const date = new Date(dateString);\n  if (Number.isNaN(date.getTime())) {\n    return '';\n  }\n\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffSecs = Math.floor(diffMs / 1000);\n  const diffMins = Math.floor(diffSecs / 60);\n  const diffHours = Math.floor(diffMins / 60);\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffSecs < 60) return 'just now';\n  if (diffMins < 60) return `${diffMins}m ago`;\n  if (diffHours < 24) return `${diffHours}h ago`;\n  return `${diffDays}d ago`;\n}\n\nexport function ProcessListItem({\n  runReason,\n  status,\n  startedAt,\n  selected,\n  onClick,\n  className,\n}: ProcessListItemProps) {\n  const IconComponent = getRunReasonIcon(runReason);\n  const label = getRunReasonLabel(runReason);\n  const statusColor = getStatusColor(status);\n\n  const isRunning = status === 'running';\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        'w-full h-[26px] flex items-center gap-half px-half rounded-sm text-left transition-colors',\n        className\n      )}\n    >\n      <IconComponent\n        className=\"size-icon-sm flex-shrink-0 text-low\"\n        weight=\"regular\"\n      />\n      {isRunning ? (\n        <RunningDots />\n      ) : (\n        <span\n          className={cn('size-dot rounded-full flex-shrink-0', statusColor)}\n          title={status}\n        />\n      )}\n      <span\n        className={cn(\n          'text-sm truncate flex-1',\n          selected ? 'text-high' : 'text-normal'\n        )}\n      >\n        {label}\n      </span>\n      <span className=\"text-xs text-low flex-shrink-0\">\n        {formatRelativeElapsed(startedAt)}\n      </span>\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ProjectsGuideDialog.tsx",
    "content": "import { useCallback, useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useTranslation } from 'react-i18next';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport { defineModal, type NoProps } from '../lib/modals';\nimport { GuideDialogShell, type GuideDialogTopic } from './GuideDialogShell';\n\nconst ProjectsGuideDialogImpl = NiceModal.create<NoProps>(() => {\n  const modal = useModal();\n  const { t } = useTranslation('common');\n  const container = typeof document !== 'undefined' ? document.body : null;\n\n  const handleClose = useCallback(() => {\n    modal.hide();\n    modal.resolve();\n    modal.remove();\n  }, [modal]);\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        handleClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [handleClose]);\n\n  if (!container) return null;\n\n  const topics: GuideDialogTopic[] = [\n    {\n      id: 'intro',\n      title: t('kanban.projectsGuide.intro.title', 'Welcome'),\n      content: t(\n        'kanban.projectsGuide.intro.content',\n        'Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.'\n      ),\n      imageSrc: '/guide-images/welcome.png',\n    },\n    {\n      id: 'welcome',\n      title: t('kanban.projectsGuide.welcome.title', 'Projects'),\n      content: t(\n        'kanban.projectsGuide.welcome.content',\n        'The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.'\n      ),\n      imageSrc: '/guide-images/projects-kanban.png',\n    },\n    {\n      id: 'issues',\n      title: t('kanban.projectsGuide.issues.title', 'Issues'),\n      content: t(\n        'kanban.projectsGuide.issues.content',\n        'Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.'\n      ),\n      imageSrc: '/guide-images/projects-issue.png',\n    },\n    {\n      id: 'workspaces',\n      title: t('kanban.projectsGuide.workspaces.title', 'Workspaces'),\n      content: t(\n        'kanban.projectsGuide.workspaces.content',\n        'To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.'\n      ),\n      imageSrc: '/guide-images/projects-workspaces.png',\n    },\n    {\n      id: 'organizations',\n      title: t('kanban.projectsGuide.organizations.title', 'Invite your team'),\n      content: t(\n        'kanban.projectsGuide.organizations.content',\n        \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      ),\n      imageSrc: '/guide-images/projects-org-settings.png',\n    },\n  ];\n\n  return createPortal(\n    <GuideDialogShell\n      topics={topics}\n      closeLabel={t('buttons.close')}\n      onClose={handleClose}\n    />,\n    container\n  );\n});\n\nexport const ProjectsGuideDialog = defineModal<void, void>(\n  ProjectsGuideDialogImpl\n);\n"
  },
  {
    "path": "packages/ui/src/components/PropertyDropdown.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../lib/cn';\nimport { CaretDownIcon, CheckIcon, type Icon } from '@phosphor-icons/react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from './Dropdown';\n\nexport interface PropertyDropdownOption<T extends string = string> {\n  value: T;\n  label: string;\n  renderOption?: () => ReactNode;\n}\n\nexport interface PropertyDropdownProps<T extends string = string> {\n  value: T;\n  options: PropertyDropdownOption<T>[];\n  onChange: (value: T) => void;\n  icon?: Icon;\n  label?: string;\n  disabled?: boolean;\n  /** Show only icon without label, value, or caret */\n  iconOnly?: boolean;\n  /** Value considered \"default\" (no highlight in icon-only mode). Defaults to first option. */\n  defaultValue?: T;\n}\n\nexport function PropertyDropdown<T extends string = string>({\n  value,\n  options,\n  onChange,\n  icon: IconComponent,\n  label,\n  disabled,\n  iconOnly,\n  defaultValue,\n}: PropertyDropdownProps<T>) {\n  const selectedOption = options.find((opt) => opt.value === value);\n  const isNonDefault = value !== (defaultValue ?? options[0]?.value);\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild disabled={disabled}>\n        <button\n          type=\"button\"\n          className={cn(\n            'flex items-center gap-half bg-panel rounded-sm',\n            'text-sm text-normal hover:bg-secondary transition-colors',\n            'disabled:opacity-50 disabled:cursor-not-allowed',\n            'py-half',\n            'px-base',\n            iconOnly && isNonDefault && 'text-brand'\n          )}\n        >\n          {iconOnly && IconComponent ? (\n            <IconComponent className=\"size-icon-xs\" weight=\"bold\" />\n          ) : IconComponent ? (\n            <>\n              <IconComponent className=\"size-icon-xs\" weight=\"bold\" />\n              {label && <span>{label}:</span>}\n              <span>{selectedOption?.label}</span>\n            </>\n          ) : (\n            (selectedOption?.renderOption?.() ?? selectedOption?.label)\n          )}\n          {!iconOnly && (\n            <CaretDownIcon className=\"size-icon-2xs text-low\" weight=\"bold\" />\n          )}\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\">\n        {options.map((option) => (\n          <DropdownMenuItem\n            key={option.value}\n            onClick={() => onChange(option.value)}\n            badge={\n              option.value === value ? (\n                <CheckIcon className=\"size-icon-xs text-brand\" weight=\"bold\" />\n              ) : undefined\n            }\n          >\n            {option.renderOption?.() ?? option.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/RadixTooltip.tsx",
    "content": "import * as React from 'react';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\n\nimport { cn } from '../lib/cn';\n\nconst TooltipProvider = TooltipPrimitive.Provider;\n\nconst Tooltip = TooltipPrimitive.Root;\n\nconst TooltipTrigger = TooltipPrimitive.Trigger;\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Portal>\n    <TooltipPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]',\n        className\n      )}\n      {...props}\n    />\n  </TooltipPrimitive.Portal>\n));\nTooltipContent.displayName = TooltipPrimitive.Content.displayName;\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n"
  },
  {
    "path": "packages/ui/src/components/ReadOnlyLinkPlugin.tsx",
    "content": "import { useEffect } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { LinkNode } from '@lexical/link';\n\n/**\n * Sanitize href to block dangerous protocols.\n * Returns undefined if the href is blocked.\n */\nfunction sanitizeHref(href?: string): string | undefined {\n  if (typeof href !== 'string') return undefined;\n  const trimmed = href.trim();\n  // Block dangerous protocols\n  if (/^(javascript|vbscript|data):/i.test(trimmed)) return undefined;\n  // Allow anchors and common relative forms (but they'll be disabled)\n  if (\n    trimmed.startsWith('#') ||\n    trimmed.startsWith('./') ||\n    trimmed.startsWith('../') ||\n    trimmed.startsWith('/')\n  )\n    return trimmed;\n  // Allow only https\n  if (/^https:\\/\\//i.test(trimmed)) return trimmed;\n  // Block everything else by default\n  return undefined;\n}\n\n/**\n * Check if href is an external HTTPS link.\n */\nfunction isExternalHref(href?: string): boolean {\n  if (!href) return false;\n  return /^https:\\/\\//i.test(href);\n}\n\n/**\n * Plugin that handles link sanitization and security attributes in read-only mode.\n * - Blocks dangerous protocols (javascript:, vbscript:, data:)\n * - External HTTPS links: clickable with target=\"_blank\" and rel=\"noopener noreferrer\"\n * - Internal/relative links: rendered but not clickable\n */\nexport function ReadOnlyLinkPlugin() {\n  const [editor] = useLexicalComposerContext();\n\n  useEffect(() => {\n    // Register a mutation listener to modify link DOM elements\n    const unregister = editor.registerMutationListener(\n      LinkNode,\n      (mutations) => {\n        for (const [nodeKey, mutation] of mutations) {\n          if (mutation === 'destroyed') continue;\n\n          const dom = editor.getElementByKey(nodeKey);\n          if (!dom || !(dom instanceof HTMLAnchorElement)) continue;\n\n          const href = dom.getAttribute('href');\n          const safeHref = sanitizeHref(href ?? undefined);\n\n          if (!safeHref) {\n            // Dangerous protocol - remove href entirely\n            dom.removeAttribute('href');\n            dom.style.cursor = 'not-allowed';\n            dom.style.pointerEvents = 'none';\n            continue;\n          }\n\n          const isExternal = isExternalHref(safeHref);\n\n          if (isExternal) {\n            // External HTTPS link - add security attributes\n            dom.setAttribute('target', '_blank');\n            dom.setAttribute('rel', 'noopener noreferrer');\n            dom.onclick = (e) => e.stopPropagation();\n          } else {\n            // Internal/relative link - disable clicking\n            dom.removeAttribute('href');\n            dom.style.cursor = 'not-allowed';\n            dom.style.pointerEvents = 'none';\n            dom.setAttribute('role', 'link');\n            dom.setAttribute('aria-disabled', 'true');\n            dom.title = href ?? '';\n          }\n        }\n      }\n    );\n\n    // Also handle existing links on mount by triggering a read\n    editor.getEditorState().read(() => {\n      const root = editor.getRootElement();\n      if (!root) return;\n\n      const links = root.querySelectorAll('a');\n      links.forEach((link) => {\n        const href = link.getAttribute('href');\n        const safeHref = sanitizeHref(href ?? undefined);\n\n        if (!safeHref) {\n          link.removeAttribute('href');\n          link.style.cursor = 'not-allowed';\n          link.style.pointerEvents = 'none';\n          return;\n        }\n\n        const isExternal = isExternalHref(safeHref);\n\n        if (isExternal) {\n          link.setAttribute('target', '_blank');\n          link.setAttribute('rel', 'noopener noreferrer');\n          link.onclick = (e) => e.stopPropagation();\n        } else {\n          link.removeAttribute('href');\n          link.style.cursor = 'not-allowed';\n          link.style.pointerEvents = 'none';\n          link.setAttribute('role', 'link');\n          link.setAttribute('aria-disabled', 'true');\n          link.title = href ?? '';\n        }\n      });\n    });\n\n    return unregister;\n  }, [editor]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/ui/src/components/RebaseInProgressDialog.tsx",
    "content": "import { useState, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from './KeyboardDialog';\nimport { Button } from './Button';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '../lib/modals';\n\nexport interface RebaseInProgressDialogProps {\n  targetBranch: string;\n  onContinue: () => Promise<void>;\n  onAbort: () => Promise<void>;\n}\n\nexport type RebaseInProgressDialogResult =\n  | { action: 'continued' }\n  | { action: 'aborted' }\n  | { action: 'cancelled' };\n\nconst RebaseInProgressDialogImpl =\n  NiceModal.create<RebaseInProgressDialogProps>(\n    ({ targetBranch, onContinue, onAbort }) => {\n      const modal = useModal();\n      const { t } = useTranslation(['tasks', 'common']);\n\n      const [isSubmitting, setIsSubmitting] = useState(false);\n      const [error, setError] = useState<string | null>(null);\n\n      const handleContinue = useCallback(async () => {\n        setIsSubmitting(true);\n        setError(null);\n\n        try {\n          await onContinue();\n\n          modal.resolve({\n            action: 'continued',\n          } as RebaseInProgressDialogResult);\n          modal.hide();\n        } catch (err) {\n          console.error('Failed to continue rebase:', err);\n          setError(\n            t(\n              'rebaseInProgress.dialog.continueError',\n              'Failed to continue rebase. There may be unresolved conflicts.'\n            )\n          );\n        } finally {\n          setIsSubmitting(false);\n        }\n      }, [onContinue, modal, t]);\n\n      const handleAbort = useCallback(async () => {\n        setIsSubmitting(true);\n        setError(null);\n\n        try {\n          await onAbort();\n\n          modal.resolve({\n            action: 'aborted',\n          } as RebaseInProgressDialogResult);\n          modal.hide();\n        } catch (err) {\n          console.error('Failed to abort rebase:', err);\n          setError(\n            t(\n              'rebaseInProgress.dialog.abortError',\n              'Failed to abort rebase. Please try again.'\n            )\n          );\n        } finally {\n          setIsSubmitting(false);\n        }\n      }, [onAbort, modal, t]);\n\n      const handleCancel = useCallback(() => {\n        modal.resolve({\n          action: 'cancelled',\n        } as RebaseInProgressDialogResult);\n        modal.hide();\n      }, [modal]);\n\n      const handleOpenChange = (open: boolean) => {\n        if (!open) handleCancel();\n      };\n\n      return (\n        <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n          <DialogContent className=\"sm:max-w-[450px]\">\n            <DialogHeader>\n              <DialogTitle>\n                {t('rebaseInProgress.dialog.title', 'Rebase In Progress')}\n              </DialogTitle>\n              <DialogDescription>\n                {t(\n                  'rebaseInProgress.dialog.description',\n                  'A rebase onto {{targetBranch}} is in progress with no conflicts. Choose how to proceed.',\n                  { targetBranch }\n                )}\n              </DialogDescription>\n            </DialogHeader>\n\n            <div className=\"space-y-4\">\n              {error && <div className=\"text-sm text-destructive\">{error}</div>}\n\n              <div className=\"text-sm text-muted-foreground\">\n                {t(\n                  'rebaseInProgress.dialog.hint',\n                  'You can continue the rebase to complete it, or abort to return to your previous state.'\n                )}\n              </div>\n            </div>\n\n            <DialogFooter className=\"gap-2 sm:gap-0\">\n              <Button\n                variant=\"outline\"\n                onClick={handleCancel}\n                disabled={isSubmitting}\n              >\n                {t('common:buttons.cancel')}\n              </Button>\n              <Button\n                variant=\"destructive\"\n                onClick={handleAbort}\n                disabled={isSubmitting}\n              >\n                {isSubmitting\n                  ? t('rebaseInProgress.dialog.aborting', 'Aborting...')\n                  : t('rebaseInProgress.dialog.abort', 'Abort Rebase')}\n              </Button>\n              <Button onClick={handleContinue} disabled={isSubmitting}>\n                {isSubmitting\n                  ? t('rebaseInProgress.dialog.continuing', 'Continuing...')\n                  : t('rebaseInProgress.dialog.continue', 'Continue Rebase')}\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      );\n    }\n  );\n\nexport const RebaseInProgressDialog = defineModal<\n  RebaseInProgressDialogProps,\n  RebaseInProgressDialogResult\n>(RebaseInProgressDialogImpl);\n"
  },
  {
    "path": "packages/ui/src/components/RelationshipBadge.tsx",
    "content": "'use client';\n\nimport { cn } from '../lib/cn';\nimport {\n  ArrowBendUpRightIcon,\n  ProhibitIcon,\n  ArrowsLeftRightIcon,\n  CopyIcon,\n} from '@phosphor-icons/react';\n\nexport type RelationshipDisplayType =\n  | 'blocks'\n  | 'blocked_by'\n  | 'related'\n  | 'duplicate_of'\n  | 'duplicated_by';\n\nexport interface RelationshipBadgeProps {\n  displayType: RelationshipDisplayType;\n  relatedIssueDisplayId: string;\n  compact?: boolean;\n  className?: string;\n  onClick?: (e: React.MouseEvent) => void;\n}\n\nconst RELATIONSHIP_ICONS = {\n  blocks: ArrowBendUpRightIcon,\n  blocked_by: ProhibitIcon,\n  related: ArrowsLeftRightIcon,\n  duplicate_of: CopyIcon,\n  duplicated_by: CopyIcon,\n} as const;\n\nfunction getRelationshipLabel(displayType: RelationshipDisplayType): string {\n  switch (displayType) {\n    case 'blocks':\n      return 'blocks';\n    case 'blocked_by':\n      return 'blocked by';\n    case 'related':\n      return 'related';\n    case 'duplicate_of':\n      return 'dup of';\n    case 'duplicated_by':\n      return 'dup';\n  }\n}\n\nexport function RelationshipBadge({\n  displayType,\n  relatedIssueDisplayId,\n  compact,\n  className,\n  onClick,\n}: RelationshipBadgeProps) {\n  const Icon = RELATIONSHIP_ICONS[displayType];\n  const label = getRelationshipLabel(displayType);\n  const isBlocking = displayType === 'blocks' || displayType === 'blocked_by';\n\n  return (\n    <span\n      role={onClick ? 'button' : undefined}\n      tabIndex={onClick ? 0 : undefined}\n      onClick={onClick}\n      onKeyDown={\n        onClick\n          ? (e) => {\n              if (e.key === 'Enter' || e.key === ' ') {\n                e.preventDefault();\n                onClick(e as unknown as React.MouseEvent);\n              }\n            }\n          : undefined\n      }\n      className={cn(\n        'inline-flex items-center gap-half',\n        'h-5 px-half',\n        'rounded-sm',\n        'text-sm font-medium',\n        'whitespace-nowrap',\n        isBlocking ? 'bg-error/10 text-error' : 'bg-panel text-low',\n        onClick && 'cursor-pointer hover:opacity-80',\n        className\n      )}\n    >\n      <Icon className=\"size-icon-xs\" weight=\"bold\" />\n      <span>\n        {compact ? relatedIssueDisplayId : `${label} ${relatedIssueDisplayId}`}\n      </span>\n    </span>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/RenameSessionDialog.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from './KeyboardDialog';\nimport { Button } from './Button';\nimport { Input } from './Input';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '../lib/modals';\n\nexport interface RenameSessionDialogProps {\n  currentName: string;\n  onRename: (newName: string) => Promise<void>;\n}\n\nexport type RenameSessionDialogResult = {\n  action: 'confirmed' | 'canceled';\n  name?: string;\n};\n\nconst RenameSessionDialogImpl = NiceModal.create<RenameSessionDialogProps>(\n  ({ currentName, onRename }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['tasks']);\n    const [name, setName] = useState<string>(currentName);\n    const [error, setError] = useState<string | null>(null);\n    const [isSubmitting, setIsSubmitting] = useState(false);\n\n    useEffect(() => {\n      if (modal.visible) {\n        setName(currentName);\n        setError(null);\n        setIsSubmitting(false);\n      }\n    }, [modal.visible, currentName]);\n\n    const handleConfirm = async () => {\n      const trimmedName = name.trim();\n\n      if (trimmedName === currentName) {\n        modal.resolve({ action: 'canceled' } as RenameSessionDialogResult);\n        modal.hide();\n        return;\n      }\n\n      setIsSubmitting(true);\n      setError(null);\n      try {\n        await onRename(trimmedName);\n        modal.resolve({\n          action: 'confirmed',\n          name: trimmedName,\n        } as RenameSessionDialogResult);\n        modal.hide();\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : 'Failed to rename session'\n        );\n      } finally {\n        setIsSubmitting(false);\n      }\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as RenameSessionDialogResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('conversation.sessions.renameTitle')}</DialogTitle>\n            <DialogDescription>\n              {t('conversation.sessions.renameDescription')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Input\n                id=\"session-name\"\n                type=\"text\"\n                value={name}\n                onChange={(e) => {\n                  setName(e.target.value);\n                  setError(null);\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter' && !isSubmitting) {\n                    void handleConfirm();\n                  }\n                }}\n                placeholder={t('conversation.sessions.renamePlaceholder')}\n                disabled={isSubmitting}\n                autoFocus\n              />\n              {error && <p className=\"text-sm text-destructive\">{error}</p>}\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isSubmitting}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              onClick={() => void handleConfirm()}\n              disabled={isSubmitting}\n            >\n              {isSubmitting\n                ? t('conversation.sessions.renaming')\n                : t('conversation.sessions.rename')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const RenameSessionDialog = defineModal<\n  RenameSessionDialogProps,\n  RenameSessionDialogResult\n>(RenameSessionDialogImpl);\n"
  },
  {
    "path": "packages/ui/src/components/RenameWorkspaceDialog.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from './KeyboardDialog';\nimport { Button } from './Button';\nimport { Input } from './Input';\nimport NiceModal, { useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '../lib/modals';\n\nexport interface RenameWorkspaceDialogProps {\n  currentName: string;\n  onRename: (newName: string) => Promise<void>;\n}\n\nexport type RenameWorkspaceDialogResult = {\n  action: 'confirmed' | 'canceled';\n  name?: string;\n};\n\nconst RenameWorkspaceDialogImpl = NiceModal.create<RenameWorkspaceDialogProps>(\n  ({ currentName, onRename }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['common']);\n    const [name, setName] = useState<string>(currentName);\n    const [error, setError] = useState<string | null>(null);\n    const [isSubmitting, setIsSubmitting] = useState(false);\n\n    useEffect(() => {\n      setName(currentName);\n      setError(null);\n    }, [currentName]);\n\n    const handleConfirm = async () => {\n      const trimmedName = name.trim();\n\n      if (trimmedName === currentName) {\n        modal.resolve({ action: 'canceled' } as RenameWorkspaceDialogResult);\n        modal.hide();\n        return;\n      }\n\n      setIsSubmitting(true);\n      setError(null);\n      try {\n        await onRename(trimmedName);\n        modal.resolve({\n          action: 'confirmed',\n          name: trimmedName,\n        } as RenameWorkspaceDialogResult);\n        modal.hide();\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : 'Failed to rename workspace'\n        );\n      } finally {\n        setIsSubmitting(false);\n      }\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as RenameWorkspaceDialogResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('workspaces.rename.title')}</DialogTitle>\n            <DialogDescription>\n              {t('workspaces.rename.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <label htmlFor=\"workspace-name\" className=\"text-sm font-medium\">\n                {t('workspaces.rename.nameLabel')}\n              </label>\n              <Input\n                id=\"workspace-name\"\n                type=\"text\"\n                value={name}\n                onChange={(e) => {\n                  setName(e.target.value);\n                  setError(null);\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter' && !isSubmitting) {\n                    void handleConfirm();\n                  }\n                }}\n                placeholder={t('workspaces.rename.placeholder')}\n                disabled={isSubmitting}\n                autoFocus\n              />\n              {error && <p className=\"text-sm text-destructive\">{error}</p>}\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isSubmitting}\n            >\n              {t('buttons.cancel')}\n            </Button>\n            <Button\n              onClick={() => void handleConfirm()}\n              disabled={isSubmitting}\n            >\n              {isSubmitting\n                ? t('workspaces.rename.renaming')\n                : t('workspaces.rename.action')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const RenameWorkspaceDialog = defineModal<\n  RenameWorkspaceDialogProps,\n  RenameWorkspaceDialogResult\n>(RenameWorkspaceDialogImpl);\n"
  },
  {
    "path": "packages/ui/src/components/RepoCard.tsx",
    "content": "import { useMemo, type ReactNode } from 'react';\nimport {\n  GitBranchIcon,\n  GitPullRequestIcon,\n  ArrowsClockwiseIcon,\n  ArrowUpIcon,\n  ArrowDownIcon,\n  CrosshairIcon,\n  ArrowSquareOutIcon,\n  GitMergeIcon,\n  CheckCircleIcon,\n  SpinnerGapIcon,\n  WarningCircleIcon,\n  DotsThreeIcon,\n  LinkIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  DropdownMenu,\n  DropdownMenuTriggerButton,\n  DropdownMenuContent,\n  DropdownMenuItem,\n} from './Dropdown';\nimport { SplitButton, type SplitButtonOption } from './SplitButton';\n\nexport type RepoAction =\n  | 'pull-request'\n  | 'link-pr'\n  | 'merge'\n  | 'change-target'\n  | 'rebase'\n  | 'push';\n\nconst repoActionOptions: SplitButtonOption<RepoAction>[] = [\n  {\n    value: 'pull-request',\n    label: 'Open pull request',\n    icon: GitPullRequestIcon,\n  },\n  { value: 'link-pr', label: 'Link pull request', icon: LinkIcon },\n  { value: 'merge', label: 'Merge', icon: GitMergeIcon },\n];\n\ninterface RepoCardProps {\n  repoId: string;\n  name: string;\n  targetBranch: string;\n  commitsAhead?: number;\n  commitsBehind?: number;\n  prNumber?: number;\n  prUrl?: string;\n  prStatus?: 'open' | 'merged' | 'closed' | 'unknown';\n  showPushButton?: boolean;\n  isPushPending?: boolean;\n  isPushSuccess?: boolean;\n  isPushError?: boolean;\n  isTargetRemote?: boolean;\n  branchDropdownContent?: ReactNode;\n  selectedAction?: RepoAction;\n  onSelectedActionChange?: (action: RepoAction) => void;\n  onChangeTarget?: () => void;\n  onRebase?: () => void;\n  onActionsClick?: (action: RepoAction) => void;\n  onPushClick?: () => void;\n  onMoreClick?: () => void;\n}\n\nexport function RepoCard({\n  name,\n  targetBranch,\n  commitsAhead = 0,\n  commitsBehind = 0,\n  prNumber,\n  prUrl,\n  prStatus,\n  showPushButton = false,\n  isPushPending = false,\n  isPushSuccess = false,\n  isPushError = false,\n  isTargetRemote = false,\n  branchDropdownContent,\n  selectedAction = 'pull-request',\n  onSelectedActionChange,\n  onChangeTarget,\n  onRebase,\n  onActionsClick,\n  onPushClick,\n  onMoreClick,\n}: RepoCardProps) {\n  const { t } = useTranslation('tasks');\n  const { t: tCommon } = useTranslation('common');\n\n  // Hide \"Open pull request\" and \"Link pull request\" when PR is already open\n  // Hide \"Link pull request\" when any PR is already linked (open or merged)\n  // Hide \"merge\" option when PR is already open or target branch is remote\n  const hasPrOpen = prStatus === 'open';\n  const hasPrLinked = !!prNumber;\n  const availableActionOptions = useMemo(\n    () =>\n      repoActionOptions.filter((opt) => {\n        if (opt.value === 'pull-request' && hasPrOpen) return false;\n        if (opt.value === 'link-pr' && hasPrLinked) return false;\n        if (opt.value === 'merge' && (hasPrOpen || isTargetRemote))\n          return false;\n        return true;\n      }),\n    [hasPrOpen, hasPrLinked, isTargetRemote]\n  );\n\n  // If current selection is unavailable, fall back to the first available option.\n  const effectiveSelectedAction = useMemo(() => {\n    const selectedOption = availableActionOptions.find(\n      (option) => option.value === selectedAction\n    );\n    return (\n      selectedOption?.value ??\n      availableActionOptions[0]?.value ??\n      selectedAction\n    );\n  }, [availableActionOptions, selectedAction]);\n\n  return (\n    <div className=\"bg-primary rounded-sm my-base p-base space-y-base\">\n      <div className=\"font-medium\">{name}</div>\n      {/* Branch row */}\n      <div className=\"flex items-center gap-base\">\n        <div className=\"min-w-0 flex-1\">\n          <DropdownMenu>\n            <DropdownMenuTriggerButton\n              icon={GitBranchIcon}\n              label={targetBranch}\n              className=\"max-w-full\"\n            />\n            <DropdownMenuContent>\n              {branchDropdownContent ?? (\n                <>\n                  <DropdownMenuItem\n                    icon={CrosshairIcon}\n                    onClick={onChangeTarget}\n                  >\n                    {t('git.actions.changeTarget')}\n                  </DropdownMenuItem>\n                  <DropdownMenuItem\n                    icon={ArrowsClockwiseIcon}\n                    onClick={onRebase}\n                  >\n                    {t('rebase.common.action')}\n                  </DropdownMenuItem>\n                </>\n              )}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n\n        {/* Commits ahead/behind indicators */}\n        {commitsAhead > 0 && (\n          <span className=\"inline-flex items-center gap-0.5 text-xs text-success shrink-0\">\n            <ArrowUpIcon className=\"size-icon-xs\" weight=\"bold\" />\n            <span className=\"font-medium\">{commitsAhead}</span>\n          </span>\n        )}\n        {commitsBehind > 0 && (\n          <span className=\"inline-flex items-center gap-0.5 text-xs text-error shrink-0\">\n            <ArrowDownIcon className=\"size-icon-xs\" weight=\"bold\" />\n            <span className=\"font-medium\">{commitsBehind}</span>\n          </span>\n        )}\n\n        <button\n          onClick={onMoreClick}\n          className=\"flex items-center justify-center p-1.5 rounded hover:bg-tertiary text-low hover:text-base transition-colors shrink-0\"\n          title={tCommon('workspaces.more')}\n        >\n          <DotsThreeIcon className=\"size-icon-base\" weight=\"bold\" />\n        </button>\n      </div>\n\n      {/* PR status row */}\n      {prNumber && (\n        <div className=\"flex items-center gap-half my-base\">\n          {prStatus === 'merged' ? (\n            prUrl ? (\n              <button\n                onClick={() => window.open(prUrl, '_blank')}\n                className=\"inline-flex items-center gap-half px-base py-half rounded-sm bg-panel text-success hover:bg-tertiary text-sm font-medium transition-colors\"\n              >\n                <CheckCircleIcon className=\"size-icon-xs\" weight=\"fill\" />\n                {t('git.pr.merged', { prNumber })}\n                <ArrowSquareOutIcon className=\"size-icon-xs\" weight=\"bold\" />\n              </button>\n            ) : (\n              <span className=\"inline-flex items-center gap-half px-base py-half rounded-sm bg-panel text-success text-sm font-medium\">\n                <CheckCircleIcon className=\"size-icon-xs\" weight=\"fill\" />\n                {t('git.pr.merged', { prNumber })}\n              </span>\n            )\n          ) : prUrl ? (\n            <button\n              onClick={() => window.open(prUrl, '_blank')}\n              className=\"inline-flex items-center gap-half px-base py-half rounded-sm bg-panel text-normal hover:bg-tertiary text-sm font-medium transition-colors\"\n            >\n              <GitPullRequestIcon className=\"size-icon-xs\" weight=\"fill\" />\n              {t('git.pr.open', { number: prNumber })}\n              <ArrowSquareOutIcon className=\"size-icon-xs\" weight=\"bold\" />\n            </button>\n          ) : (\n            <span className=\"inline-flex items-center gap-half px-base py-half rounded-sm bg-panel text-normal text-sm font-medium\">\n              <GitPullRequestIcon className=\"size-icon-xs\" weight=\"fill\" />\n              {t('git.pr.open', { number: prNumber })}\n            </span>\n          )}\n          {/* Push button - shows loading/success/error state */}\n          {(showPushButton ||\n            isPushPending ||\n            isPushSuccess ||\n            isPushError) && (\n            <button\n              onClick={onPushClick}\n              disabled={isPushPending || isPushSuccess || isPushError}\n              className={`inline-flex items-center gap-half px-base py-half rounded-sm text-sm font-medium transition-colors disabled:cursor-not-allowed ${\n                isPushSuccess\n                  ? 'bg-success/20 text-success'\n                  : isPushError\n                    ? 'bg-error/20 text-error'\n                    : 'bg-panel text-normal hover:bg-tertiary disabled:opacity-50'\n              }`}\n            >\n              {isPushPending ? (\n                <SpinnerGapIcon className=\"size-icon-xs animate-spin\" />\n              ) : isPushSuccess ? (\n                <CheckCircleIcon className=\"size-icon-xs\" weight=\"fill\" />\n              ) : isPushError ? (\n                <WarningCircleIcon className=\"size-icon-xs\" weight=\"fill\" />\n              ) : (\n                <ArrowUpIcon className=\"size-icon-xs\" weight=\"bold\" />\n              )}\n              {isPushPending\n                ? t('git.states.pushing')\n                : isPushSuccess\n                  ? t('git.states.pushed')\n                  : isPushError\n                    ? t('git.states.pushFailed')\n                    : t('git.states.push')}\n            </button>\n          )}\n        </div>\n      )}\n\n      {/* Actions row - only show when there are available actions */}\n      {availableActionOptions.length > 0 && (\n        <div className=\"my-base\">\n          <SplitButton\n            options={availableActionOptions}\n            selectedValue={effectiveSelectedAction}\n            onSelectionChange={(action) => onSelectedActionChange?.(action)}\n            onAction={(action) => onActionsClick?.(action)}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/RunningDots.tsx",
    "content": "export function RunningDots() {\n  return (\n    <div className=\"flex items-center gap-[2px] shrink-0\">\n      <span className=\"size-dot rounded-full bg-brand animate-running-dot-1\" />\n      <span className=\"size-dot rounded-full bg-brand animate-running-dot-2\" />\n      <span className=\"size-dot rounded-full bg-brand animate-running-dot-3\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/SearchableDropdown.tsx",
    "content": "import type { KeyboardEvent, ReactNode, RefObject } from 'react';\nimport { Virtuoso, VirtuosoHandle } from 'react-virtuoso';\nimport { cn } from '../lib/cn';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSearchInput,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from './Dropdown';\n\ninterface SearchableDropdownProps<T> {\n  /** Array of filtered items to display */\n  filteredItems: T[];\n  /** Currently selected value (matched against getItemKey) */\n  selectedValue?: string | null;\n\n  /** Extract unique key from item */\n  getItemKey: (item: T) => string;\n  /** Extract display label from item */\n  getItemLabel: (item: T) => string;\n\n  /** Called when an item is selected */\n  onSelect: (item: T) => void;\n\n  /** Trigger element (uses asChild pattern) */\n  trigger: ReactNode;\n\n  /** Search state */\n  searchTerm: string;\n  onSearchTermChange: (value: string) => void;\n\n  /** Highlight state */\n  highlightedIndex: number | null;\n  onHighlightedIndexChange: (index: number | null) => void;\n\n  /** Open state */\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n\n  /** Keyboard handler */\n  onKeyDown: (e: KeyboardEvent) => void;\n\n  /** Virtuoso ref for scrolling */\n  virtuosoRef: RefObject<VirtuosoHandle | null>;\n\n  /** Class name for dropdown content */\n  contentClassName?: string;\n  /** Placeholder text for search input */\n  placeholder?: string;\n  /** Message shown when no items match */\n  emptyMessage?: string;\n\n  /** Optional badge text for each item */\n  getItemBadge?: (item: T) => string | undefined;\n\n  /** Optional icon/avatar to render before each item's label */\n  getItemIcon?: (item: T) => ReactNode;\n}\n\nexport function SearchableDropdown<T>({\n  filteredItems,\n  selectedValue,\n  getItemKey,\n  getItemLabel,\n  onSelect,\n  trigger,\n  searchTerm,\n  onSearchTermChange,\n  highlightedIndex,\n  onHighlightedIndexChange,\n  open,\n  onOpenChange,\n  onKeyDown,\n  virtuosoRef,\n  contentClassName,\n  placeholder = 'Search',\n  emptyMessage = 'No items found',\n  getItemBadge,\n  getItemIcon,\n}: SearchableDropdownProps<T>) {\n  return (\n    <DropdownMenu open={open} onOpenChange={onOpenChange}>\n      <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>\n      <DropdownMenuContent className={contentClassName}>\n        <DropdownMenuSearchInput\n          placeholder={placeholder}\n          value={searchTerm}\n          onValueChange={onSearchTermChange}\n          onKeyDown={onKeyDown}\n        />\n        <DropdownMenuSeparator />\n        {filteredItems.length === 0 ? (\n          <div className=\"px-base py-half text-sm text-low text-center\">\n            {emptyMessage}\n          </div>\n        ) : (\n          <Virtuoso\n            ref={virtuosoRef as RefObject<VirtuosoHandle>}\n            style={{ height: '16rem' }}\n            totalCount={filteredItems.length}\n            computeItemKey={(idx: number) =>\n              getItemKey(filteredItems[idx]) ?? String(idx)\n            }\n            itemContent={(idx: number) => {\n              const item = filteredItems[idx];\n              const key = getItemKey(item);\n              const isHighlighted = idx === highlightedIndex;\n              const isSelected = selectedValue === key;\n              return (\n                <DropdownMenuItem\n                  onSelect={() => onSelect(item)}\n                  onMouseEnter={() => onHighlightedIndexChange(idx)}\n                  preventFocusOnHover\n                  badge={getItemBadge?.(item)}\n                  className={cn(\n                    isSelected && 'bg-secondary',\n                    isHighlighted && 'bg-secondary'\n                  )}\n                >\n                  {getItemIcon?.(item)}\n                  {getItemLabel(item)}\n                </DropdownMenuItem>\n              );\n            }}\n          />\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/SearchableTagDropdown.tsx",
    "content": "import type { RefObject } from 'react';\nimport { Virtuoso, VirtuosoHandle } from 'react-virtuoso';\nimport { cn } from '../lib/cn';\nimport { useTranslation } from 'react-i18next';\nimport { PlusIcon, CheckIcon } from '@phosphor-icons/react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuSearchInput,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from './Dropdown';\nimport { InlineColorPicker, PRESET_COLORS } from './ColorPicker';\n\n// Re-export for backwards compatibility\nexport const TAG_COLORS = PRESET_COLORS;\n\nexport interface SearchableTag {\n  id: string;\n  name: string;\n  color: string;\n}\n\ninterface SearchableTagDropdownProps {\n  filteredTags: SearchableTag[];\n  selectedTagIds: string[];\n  onTagToggle: (tagId: string) => void;\n  trigger: React.ReactNode;\n\n  // Search state\n  searchTerm: string;\n  onSearchTermChange: (value: string) => void;\n\n  // Highlight state\n  highlightedIndex: number | null;\n  onHighlightedIndexChange: (index: number | null) => void;\n\n  // Open state\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n\n  // Keyboard handler\n  onKeyDown: (e: React.KeyboardEvent) => void;\n\n  // Virtuoso ref\n  virtuosoRef: RefObject<VirtuosoHandle | null>;\n\n  // Create flow\n  showCreateOption: boolean;\n  createOptionHighlighted: boolean;\n  isCreating: boolean;\n  colorIndex: number;\n  onColorIndexChange: (index: number) => void;\n  onStartCreate: () => void;\n  onConfirmCreate: () => void;\n  onCancelCreate: () => void;\n\n  // Ref for color picker container (for focus management)\n  colorPickerRef: RefObject<HTMLDivElement>;\n\n  contentClassName?: string;\n  disabled?: boolean;\n}\n\nexport function SearchableTagDropdown({\n  filteredTags,\n  selectedTagIds,\n  onTagToggle,\n  trigger,\n  searchTerm,\n  onSearchTermChange,\n  highlightedIndex,\n  onHighlightedIndexChange,\n  open,\n  onOpenChange,\n  onKeyDown,\n  virtuosoRef,\n  showCreateOption,\n  createOptionHighlighted,\n  isCreating,\n  colorIndex,\n  onColorIndexChange,\n  onStartCreate,\n  onConfirmCreate,\n  onCancelCreate,\n  colorPickerRef,\n  contentClassName,\n  disabled,\n}: SearchableTagDropdownProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <DropdownMenu open={open} onOpenChange={onOpenChange}>\n      <DropdownMenuTrigger asChild disabled={disabled}>\n        {trigger}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"start\"\n        className={cn('min-w-[220px]', contentClassName)}\n      >\n        {isCreating ? (\n          // Color picker step\n          <div\n            ref={colorPickerRef}\n            className=\"p-base space-y-base outline-none\"\n            tabIndex={-1}\n            onKeyDown={onKeyDown}\n          >\n            <div className=\"text-sm text-normal\">\n              {t('kanban.selectColorFor')}{' '}\n              <span className=\"font-medium\">{searchTerm}</span>\n            </div>\n            <InlineColorPicker\n              value={TAG_COLORS[colorIndex]}\n              onChange={(color) => {\n                const idx = (TAG_COLORS as readonly string[]).indexOf(color);\n                if (idx !== -1) onColorIndexChange(idx);\n              }}\n              colors={TAG_COLORS}\n            />\n            <div className=\"flex items-center justify-end gap-half pt-half\">\n              <button\n                type=\"button\"\n                onClick={onCancelCreate}\n                className=\"px-base py-half text-sm text-low hover:text-normal hover:bg-panel rounded-sm transition-colors\"\n              >\n                {t('buttons.cancel')}\n              </button>\n              <button\n                type=\"button\"\n                onClick={onConfirmCreate}\n                className=\"px-base py-half text-sm text-high bg-brand hover:bg-brand/90 rounded-sm transition-colors\"\n              >\n                {t('buttons.create')}\n              </button>\n            </div>\n          </div>\n        ) : (\n          // Search and tag list\n          <>\n            <DropdownMenuSearchInput\n              placeholder={t('kanban.searchTags')}\n              value={searchTerm}\n              onValueChange={onSearchTermChange}\n              onKeyDown={onKeyDown}\n            />\n            <DropdownMenuSeparator />\n            {filteredTags.length === 0 && !showCreateOption ? (\n              <div className=\"px-base py-half text-sm text-low text-center\">\n                {t('kanban.noTagsAvailable')}\n              </div>\n            ) : (\n              <>\n                {filteredTags.length > 0 && (\n                  <Virtuoso\n                    ref={virtuosoRef as React.RefObject<VirtuosoHandle>}\n                    style={{ height: Math.min(filteredTags.length * 36, 200) }}\n                    totalCount={filteredTags.length}\n                    computeItemKey={(idx) =>\n                      filteredTags[idx]?.id ?? String(idx)\n                    }\n                    itemContent={(idx) => {\n                      const tag = filteredTags[idx];\n                      const isSelected = selectedTagIds.includes(tag.id);\n                      const isHighlighted = idx === highlightedIndex;\n                      return (\n                        <button\n                          type=\"button\"\n                          onClick={() => onTagToggle(tag.id)}\n                          onMouseEnter={() => onHighlightedIndexChange(idx)}\n                          className={cn(\n                            'flex items-center gap-base w-full px-base py-half text-sm text-left transition-colors',\n                            isHighlighted && 'bg-secondary',\n                            isSelected && 'text-normal'\n                          )}\n                        >\n                          <span\n                            className=\"w-3 h-3 rounded-full shrink-0\"\n                            style={{ backgroundColor: `hsl(${tag.color})` }}\n                          />\n                          <span className=\"flex-1 truncate\">{tag.name}</span>\n                          {isSelected && (\n                            <CheckIcon\n                              className=\"size-icon-sm text-brand shrink-0\"\n                              weight=\"bold\"\n                            />\n                          )}\n                        </button>\n                      );\n                    }}\n                  />\n                )}\n                {showCreateOption && (\n                  <>\n                    {filteredTags.length > 0 && <DropdownMenuSeparator />}\n                    <button\n                      type=\"button\"\n                      onClick={onStartCreate}\n                      className={cn(\n                        'flex items-center gap-base w-full px-base py-half text-sm text-brand hover:bg-secondary transition-colors',\n                        createOptionHighlighted && 'bg-secondary'\n                      )}\n                    >\n                      <PlusIcon className=\"size-icon-sm\" weight=\"bold\" />\n                      <span>\n                        {t('kanban.createTag')} &quot;{searchTerm}&quot;\n                      </span>\n                    </button>\n                  </>\n                )}\n              </>\n            )}\n          </>\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Select.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { Check, ChevronDown, ChevronUp } from 'lucide-react';\n\nimport { cn } from '../lib/cn';\n\nconst Select = SelectPrimitive.Root;\n\nconst SelectGroup = SelectPrimitive.Group;\n\nconst SelectValue = SelectPrimitive.Value;\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'flex h-10 w-full items-center justify-between border border-input px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n));\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName;\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn(\n      'flex cursor-default items-center justify-center py-1',\n      className\n    )}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n));\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn(\n      'flex cursor-default items-center justify-center py-1',\n      className\n    )}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n));\nSelectScrollDownButton.displayName =\n  SelectPrimitive.ScrollDownButton.displayName;\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = 'popper', ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        'relative z-[10000] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden border bg-primary text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]',\n        position === 'popper' &&\n          'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n        className\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          'p-1',\n          position === 'popper' &&\n            'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n));\nSelectContent.displayName = SelectPrimitive.Content.displayName;\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}\n    {...props}\n  />\n));\nSelectLabel.displayName = SelectPrimitive.Label.displayName;\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex w-full cursor-default select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n));\nSelectItem.displayName = SelectPrimitive.Item.displayName;\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 my-1 h-px bg-muted', className)}\n    {...props}\n  />\n));\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName;\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton,\n};\n"
  },
  {
    "path": "packages/ui/src/components/SessionChatBox.tsx",
    "content": "import { type ChangeEvent, type ReactNode, useRef } from 'react';\nimport {\n  type Icon,\n  PaperclipIcon,\n  CheckIcon,\n  ClockIcon,\n  XIcon,\n  PlusIcon,\n  SpinnerIcon,\n  ChatCircleIcon,\n  TrashIcon,\n  WarningIcon,\n  ArrowUpIcon,\n  ArrowsOutIcon,\n  GithubLogoIcon,\n  PencilSimpleIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { ChatBoxBase, VisualVariant, type DropzoneProps } from './ChatBoxBase';\nimport { type EditorProps, type ExecutorProps } from './CreateChatBox';\nimport type { AskUserQuestionItem, QuestionAnswer } from 'shared/types';\nimport {\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n} from './Dropdown';\nimport { PrimaryButton } from './PrimaryButton';\nimport type { LocalAttachmentMetadata } from './WorkspaceContext';\nimport { ToolbarDropdown, ToolbarIconButton } from './Toolbar';\nimport { ContextUsageGauge, type ContextUsageInfo } from './ContextUsageGauge';\nimport { TodoProgressPopup, type TodoProgressItem } from './TodoProgressPopup';\nimport {\n  AskUserQuestionBanner,\n  type AskUserQuestionBannerHandle,\n} from './AskUserQuestionBanner';\n\n// Status enum - single source of truth for execution state\nexport type ExecutionStatus =\n  | 'idle'\n  | 'sending'\n  | 'running'\n  | 'queued'\n  | 'stopping'\n  | 'queue-loading'\n  | 'feedback'\n  | 'edit';\n\ninterface ActionsProps {\n  onSend: () => void;\n  onQueue: () => void;\n  onCancelQueue: () => void;\n  onStop: () => void;\n  onPasteFiles: (files: File[]) => void;\n}\n\nexport interface SessionOption<TExecutor extends string = string> {\n  id: string;\n  name?: string | null;\n  created_at: string | Date;\n  executor?: TExecutor | string | null;\n}\n\ninterface SessionProps<TExecutor extends string = string> {\n  sessions: SessionOption<TExecutor>[];\n  selectedSessionId?: string;\n  onSelectSession: (sessionId: string) => void;\n  isNewSessionMode?: boolean;\n  onNewSession?: () => void;\n  onRenameSession?: (sessionId: string, currentName: string) => void;\n}\n\nexport interface SessionToolbarActionItem {\n  id: string;\n  icon: Icon;\n  label: string;\n  tooltip?: string;\n  onClick: () => void;\n  disabled?: boolean;\n}\n\ninterface ToolbarActionsProps {\n  items: SessionToolbarActionItem[];\n}\n\ninterface StatsProps {\n  filesChanged?: number;\n  linesAdded?: number;\n  linesRemoved?: number;\n  hasConflicts?: boolean;\n  conflictedFilesCount?: number;\n  onResolveConflicts?: () => void;\n}\n\ninterface FeedbackModeProps {\n  isActive: boolean;\n  onSubmitFeedback: () => void;\n  onCancel: () => void;\n  isSubmitting: boolean;\n  error?: string | null;\n  isTimedOut: boolean;\n}\n\ninterface EditModeProps {\n  isActive: boolean;\n  onSubmitEdit: () => void;\n  onCancel: () => void;\n  isSubmitting: boolean;\n}\n\ninterface ApprovalModeProps {\n  isActive: boolean;\n  onApprove: () => void;\n  onRequestChanges: () => void;\n  isSubmitting: boolean;\n  isTimedOut: boolean;\n  error?: string | null;\n}\n\ninterface AskQuestionModeProps {\n  isActive: boolean;\n  questions: AskUserQuestionItem[];\n  onSubmitAnswers: (answers: QuestionAnswer[]) => void;\n  isSubmitting: boolean;\n  isTimedOut: boolean;\n  error?: string | null;\n}\n\ninterface ReviewCommentsProps {\n  /** Number of review comments */\n  count: number;\n  /** Preview markdown of the comments */\n  previewMarkdown: string;\n  /** Clear all comments */\n  onClear: () => void;\n}\n\nexport interface SessionChatBoxEditorRenderProps<\n  TExecutor extends string = string,\n> {\n  focusKey: string;\n  placeholder: string;\n  value: string;\n  onChange: (value: string) => void;\n  onCmdEnter: () => void;\n  disabled: boolean;\n  repoIds?: string[];\n  executor: TExecutor | null;\n  onPasteFiles: (files: File[]) => void;\n  localAttachments?: LocalAttachmentMetadata[];\n}\n\ninterface SessionChatBoxProps<TExecutor extends string = string> {\n  status: ExecutionStatus;\n  editor: EditorProps;\n  renderEditor: (\n    props: SessionChatBoxEditorRenderProps<TExecutor>\n  ) => ReactNode;\n  actions: ActionsProps;\n  session: SessionProps<TExecutor>;\n  stats?: StatsProps;\n  feedbackMode?: FeedbackModeProps;\n  editMode?: EditModeProps;\n  approvalMode?: ApprovalModeProps;\n  askQuestionMode?: AskQuestionModeProps;\n  reviewComments?: ReviewCommentsProps;\n  toolbarActions?: ToolbarActionsProps;\n  modelSelector?: ReactNode;\n  error?: string | null;\n  repoIds?: string[];\n  agent?: TExecutor | null;\n  executor?: ExecutorProps<TExecutor>;\n  formatExecutorLabel?: (executor: TExecutor) => string;\n  emptyExecutorLabel?: string;\n  renderAgentIcon?: (\n    executor: TExecutor | string | null | undefined,\n    className?: string\n  ) => ReactNode;\n  formatSessionDate?: (createdAt: string | Date) => string;\n  todos?: TodoProgressItem[];\n  inProgressTodo?: TodoProgressItem | null;\n  localAttachments?: LocalAttachmentMetadata[];\n  onPrCommentClick?: () => void;\n  onViewCode?: () => void;\n  onOpenWorkspace?: () => void;\n  onScrollToPreviousMessage?: () => void;\n  tokenUsageInfo?: ContextUsageInfo | null;\n  supportsContextUsage?: boolean;\n  dropzone?: DropzoneProps;\n}\n\nfunction defaultExecutorLabel(executor: string) {\n  return executor\n    .replace(/[_-]+/g, ' ')\n    .toLowerCase()\n    .replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\nfunction defaultFormatSessionDate(createdAt: string | Date) {\n  const date = createdAt instanceof Date ? createdAt : new Date(createdAt);\n  if (Number.isNaN(date.getTime())) {\n    return String(createdAt);\n  }\n\n  return date.toLocaleString(undefined, {\n    month: 'short',\n    day: 'numeric',\n    hour: 'numeric',\n    minute: '2-digit',\n  });\n}\n\n/**\n * Full-featured chat box for session mode.\n * Supports queue, stop, attach, feedback mode, stats, and session switching.\n */\nexport function SessionChatBox<TExecutor extends string = string>({\n  status,\n  editor,\n  renderEditor,\n  actions,\n  session,\n  stats,\n  feedbackMode,\n  editMode,\n  approvalMode,\n  askQuestionMode,\n  reviewComments,\n  toolbarActions,\n  modelSelector,\n  error,\n  repoIds,\n  agent,\n  executor,\n  formatExecutorLabel = defaultExecutorLabel,\n  emptyExecutorLabel = 'Select Executor',\n  renderAgentIcon,\n  formatSessionDate = defaultFormatSessionDate,\n  todos,\n  inProgressTodo,\n  localAttachments,\n  onPrCommentClick,\n  onViewCode,\n  onOpenWorkspace,\n  onScrollToPreviousMessage,\n  tokenUsageInfo,\n  supportsContextUsage,\n  dropzone,\n}: SessionChatBoxProps<TExecutor>) {\n  const { t } = useTranslation('tasks');\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const askQuestionBannerRef = useRef<AskUserQuestionBannerHandle>(null);\n\n  // Determine if in feedback mode, edit mode, or approval mode\n  const isInFeedbackMode = feedbackMode?.isActive ?? false;\n  const isInEditMode = editMode?.isActive ?? false;\n  const isInApprovalMode = approvalMode?.isActive ?? false;\n  const isInAskQuestionMode = askQuestionMode?.isActive ?? false;\n\n  // Key to force editor remount when entering feedback/edit/approval/question mode (triggers auto-focus)\n  const focusKey = isInFeedbackMode\n    ? 'feedback'\n    : isInEditMode\n      ? 'edit'\n      : isInApprovalMode\n        ? 'approval'\n        : isInAskQuestionMode\n          ? 'question'\n          : 'normal';\n\n  // Derived state from status\n  const isDisabled = Boolean(\n    status === 'sending' ||\n      status === 'stopping' ||\n      feedbackMode?.isSubmitting ||\n      editMode?.isSubmitting ||\n      approvalMode?.isSubmitting ||\n      askQuestionMode?.isSubmitting\n  );\n  const hasContent =\n    editor.value.trim().length > 0 || (reviewComments?.count ?? 0) > 0;\n  const canSend =\n    hasContent && !['sending', 'stopping', 'queue-loading'].includes(status);\n  const isQueued = status === 'queued';\n  const isRunning = status === 'running' || status === 'queued';\n  const areContentInsertActionsDisabled = isDisabled || isQueued;\n  const showRunningAnimation =\n    (status === 'running' || status === 'queued' || status === 'sending') &&\n    !isInApprovalMode &&\n    !isInAskQuestionMode &&\n    editor.value.trim().length === 0;\n\n  const placeholder = isInFeedbackMode\n    ? 'Provide feedback for the plan...'\n    : isInEditMode\n      ? 'Edit your message...'\n      : isInApprovalMode\n        ? 'Provide feedback to request changes...'\n        : isInAskQuestionMode\n          ? 'Type a different answer...'\n          : session.isNewSessionMode\n            ? 'Start a new conversation...'\n            : 'Continue working on this task...';\n\n  // Cmd+Enter handler\n  const handleCmdEnter = () => {\n    // AskUserQuestion mode: Enter submits custom text as answer\n    if (isInAskQuestionMode && hasContent) {\n      askQuestionBannerRef.current?.submitCustomAnswer(editor.value);\n      editor.onChange('');\n      return;\n    }\n    // Approval mode: Cmd+Enter triggers approve or request changes based on input\n    if (isInApprovalMode && !approvalMode?.isTimedOut) {\n      if (canSend) {\n        approvalMode?.onRequestChanges();\n      } else {\n        approvalMode?.onApprove();\n      }\n      return;\n    }\n    if (isInFeedbackMode && canSend && !feedbackMode?.isTimedOut) {\n      feedbackMode?.onSubmitFeedback();\n    } else if (isInEditMode && canSend) {\n      editMode?.onSubmitEdit();\n    } else if (status === 'running' && canSend) {\n      actions.onQueue();\n    } else if (status === 'idle' && canSend) {\n      actions.onSend();\n    }\n  };\n\n  // File input handlers\n  const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {\n    const files = Array.from(e.target.files || []);\n    if (files.length > 0) {\n      actions.onPasteFiles(files);\n    }\n    e.target.value = '';\n  };\n\n  const handleAttachClick = () => {\n    fileInputRef.current?.click();\n  };\n\n  const {\n    sessions,\n    selectedSessionId,\n    onSelectSession,\n    isNewSessionMode,\n    onNewSession,\n    onRenameSession,\n  } = session;\n  const isLatestSelected =\n    sessions.length > 0 && selectedSessionId === sessions[0].id;\n  const selectedSessionObj = sessions.find((s) => s.id === selectedSessionId);\n  const sessionLabel = isNewSessionMode\n    ? t('conversation.sessions.newSession')\n    : selectedSessionObj?.name\n      ? selectedSessionObj.name\n      : isLatestSelected\n        ? t('conversation.sessions.latest')\n        : t('conversation.sessions.previous');\n\n  // Stats\n  const filesChanged = stats?.filesChanged ?? 0;\n  const linesAdded = stats?.linesAdded;\n  const linesRemoved = stats?.linesRemoved;\n\n  // Render action buttons based on status\n  const renderActionButtons = () => {\n    // Feedback mode takes precedence\n    if (isInFeedbackMode) {\n      if (feedbackMode?.isTimedOut) {\n        return (\n          <PrimaryButton\n            variant=\"secondary\"\n            onClick={feedbackMode.onCancel}\n            value={t('conversation.actions.cancel')}\n          />\n        );\n      }\n      return (\n        <>\n          <PrimaryButton\n            variant=\"secondary\"\n            onClick={feedbackMode?.onCancel}\n            value={t('conversation.actions.cancel')}\n          />\n          <PrimaryButton\n            onClick={feedbackMode?.onSubmitFeedback}\n            disabled={!canSend || feedbackMode?.isSubmitting}\n            actionIcon={feedbackMode?.isSubmitting ? 'spinner' : undefined}\n            value={t('conversation.actions.submitFeedback')}\n          />\n        </>\n      );\n    }\n\n    // Edit mode\n    if (isInEditMode) {\n      return (\n        <>\n          <PrimaryButton\n            variant=\"secondary\"\n            onClick={editMode?.onCancel}\n            value={t('conversation.actions.cancel')}\n          />\n          <PrimaryButton\n            onClick={editMode?.onSubmitEdit}\n            disabled={!canSend || editMode?.isSubmitting}\n            actionIcon={editMode?.isSubmitting ? 'spinner' : undefined}\n            value={t('conversation.retry')}\n          />\n        </>\n      );\n    }\n\n    // Approval mode\n    if (isInApprovalMode) {\n      if (approvalMode?.isTimedOut) {\n        return (\n          <PrimaryButton\n            variant=\"secondary\"\n            onClick={actions.onStop}\n            value={t('conversation.actions.stop')}\n          />\n        );\n      }\n\n      const hasMessage = editor.value.trim().length > 0;\n\n      return (\n        <>\n          <PrimaryButton\n            variant=\"secondary\"\n            onClick={actions.onStop}\n            value={t('conversation.actions.stop')}\n          />\n          {hasMessage ? (\n            <PrimaryButton\n              onClick={approvalMode?.onRequestChanges}\n              disabled={approvalMode?.isSubmitting}\n              actionIcon={approvalMode?.isSubmitting ? 'spinner' : undefined}\n              value={t('conversation.actions.requestChanges')}\n            />\n          ) : (\n            <PrimaryButton\n              onClick={approvalMode?.onApprove}\n              disabled={approvalMode?.isSubmitting}\n              actionIcon={approvalMode?.isSubmitting ? 'spinner' : undefined}\n              value={t('conversation.actions.approve')}\n            />\n          )}\n        </>\n      );\n    }\n\n    // AskUserQuestion mode\n    if (isInAskQuestionMode) {\n      if (askQuestionMode?.isTimedOut) {\n        return (\n          <PrimaryButton\n            variant=\"secondary\"\n            onClick={actions.onStop}\n            value={t('conversation.actions.stop')}\n          />\n        );\n      }\n\n      const hasMessage = editor.value.trim().length > 0;\n\n      return (\n        <>\n          <PrimaryButton\n            variant=\"secondary\"\n            onClick={actions.onStop}\n            value={t('conversation.actions.stop')}\n          />\n          {hasMessage && (\n            <PrimaryButton\n              onClick={() => {\n                askQuestionBannerRef.current?.submitCustomAnswer(editor.value);\n                editor.onChange('');\n              }}\n              disabled={askQuestionMode?.isSubmitting}\n              actionIcon={askQuestionMode?.isSubmitting ? 'spinner' : undefined}\n              value={t('conversation.actions.send')}\n            />\n          )}\n        </>\n      );\n    }\n\n    switch (status) {\n      case 'idle':\n        return (\n          <PrimaryButton\n            onClick={actions.onSend}\n            disabled={!canSend}\n            value={t('conversation.actions.send')}\n          />\n        );\n\n      case 'sending':\n        return (\n          <PrimaryButton\n            onClick={actions.onStop}\n            actionIcon=\"spinner\"\n            value={t('conversation.actions.sending')}\n          />\n        );\n\n      case 'running':\n        return (\n          <>\n            <PrimaryButton\n              onClick={actions.onQueue}\n              disabled={!canSend}\n              value={t('conversation.actions.queue')}\n            />\n            <PrimaryButton\n              onClick={actions.onStop}\n              variant=\"secondary\"\n              value={t('conversation.actions.stop')}\n              actionIcon=\"spinner\"\n            />\n          </>\n        );\n\n      case 'queued':\n        return (\n          <>\n            <PrimaryButton\n              onClick={actions.onCancelQueue}\n              value={t('conversation.actions.cancelQueue')}\n              actionIcon={XIcon}\n            />\n            <PrimaryButton\n              onClick={actions.onStop}\n              variant=\"secondary\"\n              value={t('conversation.actions.stop')}\n              actionIcon=\"spinner\"\n            />\n          </>\n        );\n\n      case 'stopping':\n        return (\n          <PrimaryButton\n            disabled\n            value={t('conversation.actions.stopping')}\n            actionIcon=\"spinner\"\n          />\n        );\n      case 'queue-loading':\n        return (\n          <PrimaryButton\n            disabled\n            value={t('conversation.actions.loading')}\n            actionIcon=\"spinner\"\n          />\n        );\n      case 'feedback':\n      case 'edit':\n        return null;\n    }\n  };\n\n  // Banner content\n  const renderBanner = () => {\n    const banners: ReactNode[] = [];\n\n    // Review comments banner\n    if (reviewComments && reviewComments.count > 0) {\n      banners.push(\n        <div\n          key=\"review-comments\"\n          className=\"bg-accent/5 border-b px-double py-base flex items-center gap-base\"\n        >\n          <ChatCircleIcon className=\"h-4 w-4 text-brand flex-shrink-0\" />\n          <span className=\"text-sm text-normal flex-1\">\n            {t('conversation.reviewComments.count', {\n              count: reviewComments.count,\n            })}\n          </span>\n          <button\n            onClick={reviewComments.onClear}\n            className=\"text-low hover:text-normal transition-colors p-1 -m-1\"\n            title={t('conversation.actions.clearReviewComments')}\n          >\n            <TrashIcon className=\"h-4 w-4\" />\n          </button>\n        </div>\n      );\n    }\n\n    // AskUserQuestion banner (renders above input)\n    if (isInAskQuestionMode && askQuestionMode) {\n      banners.push(\n        <AskUserQuestionBanner\n          key=\"ask-question\"\n          ref={askQuestionBannerRef}\n          questions={askQuestionMode.questions}\n          onSubmitAnswers={askQuestionMode.onSubmitAnswers}\n          isSubmitting={askQuestionMode.isSubmitting}\n          isTimedOut={askQuestionMode.isTimedOut}\n          error={askQuestionMode.error ?? null}\n        />\n      );\n    }\n\n    // Queued message banner\n    if (isQueued) {\n      banners.push(\n        <div\n          key=\"queued\"\n          className=\"bg-secondary border-b px-double py-base flex items-center gap-base\"\n        >\n          <ClockIcon className=\"h-4 w-4 text-low\" />\n          <span className=\"text-sm text-low\">\n            {t('followUp.queuedMessage')}\n          </span>\n        </div>\n      );\n    }\n\n    return banners.length > 0 ? <>{banners}</> : null;\n  };\n\n  // Combine errors\n  const displayError =\n    feedbackMode?.error ??\n    approvalMode?.error ??\n    askQuestionMode?.error ??\n    error;\n\n  // Determine visual variant\n  const getVisualVariant = () => {\n    if (isInFeedbackMode) return VisualVariant.FEEDBACK;\n    if (isInEditMode) return VisualVariant.EDIT;\n    if (isInApprovalMode || isInAskQuestionMode) return VisualVariant.PLAN;\n    return VisualVariant.NORMAL;\n  };\n\n  return (\n    <ChatBoxBase\n      editor={renderEditor({\n        focusKey,\n        placeholder,\n        value: editor.value,\n        onChange: editor.onChange,\n        onCmdEnter: handleCmdEnter,\n        disabled: isDisabled,\n        repoIds,\n        executor: agent || executor?.selected || null,\n        onPasteFiles: actions.onPasteFiles,\n        localAttachments,\n      })}\n      error={displayError}\n      banner={renderBanner()}\n      visualVariant={getVisualVariant()}\n      isRunning={showRunningAnimation}\n      dropzone={dropzone}\n      modelSelector={modelSelector}\n      headerLeft={\n        <>\n          {/* New session mode: agent icon + executor dropdown */}\n          {isNewSessionMode && executor && (\n            <>\n              {renderAgentIcon?.(agent, 'size-icon-xl')}\n              <ToolbarDropdown\n                label={\n                  executor.selected\n                    ? formatExecutorLabel(executor.selected)\n                    : emptyExecutorLabel\n                }\n              >\n                <DropdownMenuLabel>\n                  {t('conversation.executors')}\n                </DropdownMenuLabel>\n                {executor.options.map((exec) => (\n                  <DropdownMenuItem\n                    key={exec}\n                    icon={executor.selected === exec ? CheckIcon : undefined}\n                    onClick={() => executor.onChange(exec)}\n                  >\n                    {formatExecutorLabel(exec)}\n                  </DropdownMenuItem>\n                ))}\n              </ToolbarDropdown>\n            </>\n          )}\n          {/* Existing session mode: show in-progress todo when running, otherwise file stats */}\n          {!isNewSessionMode && (\n            <>\n              {isRunning && inProgressTodo ? (\n                <span className=\"text-sm flex items-center gap-1 min-w-0\">\n                  <SpinnerIcon className=\"size-icon-sm animate-spin flex-shrink-0\" />\n                  <span className=\"truncate\">{inProgressTodo.content}</span>\n                </span>\n              ) : (\n                <>\n                  {stats?.hasConflicts && (\n                    <button\n                      type=\"button\"\n                      className=\"flex items-center gap-1 text-warning text-sm min-w-0 cursor-pointer hover:underline\"\n                      title={t('conversation.approval.conflictWarning')}\n                      onClick={stats.onResolveConflicts}\n                    >\n                      <WarningIcon className=\"size-icon-sm flex-shrink-0\" />\n                      <span className=\"truncate\">\n                        {t('conversation.approval.conflicts', {\n                          count: stats.conflictedFilesCount,\n                        })}\n                      </span>\n                    </button>\n                  )}\n                  {onOpenWorkspace ? (\n                    <PrimaryButton\n                      variant=\"secondary\"\n                      onClick={onOpenWorkspace}\n                      value=\"Open Workspace\"\n                      actionIcon={ArrowsOutIcon}\n                      className=\"min-w-0\"\n                    />\n                  ) : onViewCode ? (\n                    <PrimaryButton\n                      variant=\"tertiary\"\n                      onClick={onViewCode}\n                      className=\"min-w-0\"\n                    >\n                      <span className=\"text-sm space-x-half whitespace-nowrap truncate\">\n                        <span>\n                          {t('diff.filesChanged', { count: filesChanged })}\n                        </span>\n                        {(linesAdded !== undefined ||\n                          linesRemoved !== undefined) && (\n                          <span className=\"space-x-half\">\n                            {linesAdded !== undefined && (\n                              <span className=\"text-success\">\n                                +{linesAdded}\n                              </span>\n                            )}\n                            {linesRemoved !== undefined && (\n                              <span className=\"text-error\">\n                                -{linesRemoved}\n                              </span>\n                            )}\n                          </span>\n                        )}\n                      </span>\n                    </PrimaryButton>\n                  ) : (\n                    <span className=\"text-sm text-low space-x-half whitespace-nowrap truncate min-w-0\">\n                      <span>\n                        {t('diff.filesChanged', { count: filesChanged })}\n                      </span>\n                      {(linesAdded !== undefined ||\n                        linesRemoved !== undefined) && (\n                        <span className=\"space-x-half\">\n                          {linesAdded !== undefined && (\n                            <span className=\"text-success\">+{linesAdded}</span>\n                          )}\n                          {linesRemoved !== undefined && (\n                            <span className=\"text-error\">-{linesRemoved}</span>\n                          )}\n                        </span>\n                      )}\n                    </span>\n                  )}\n                </>\n              )}\n            </>\n          )}\n        </>\n      }\n      headerRight={\n        <>\n          {/* Scroll to previous user message button + Agent icon for existing session mode */}\n          {!isNewSessionMode && (\n            <>\n              {onScrollToPreviousMessage && (\n                <ToolbarIconButton\n                  icon={ArrowUpIcon}\n                  title={t('conversation.actions.scrollToPreviousMessage')}\n                  aria-label={t('conversation.actions.scrollToPreviousMessage')}\n                  onClick={onScrollToPreviousMessage}\n                />\n              )}\n              {renderAgentIcon?.(agent, 'size-icon-xl')}\n            </>\n          )}\n          {/* Todo progress popup - always rendered, disabled when no todos */}\n          <TodoProgressPopup todos={todos ?? []} />\n          {supportsContextUsage && (\n            <ContextUsageGauge tokenUsageInfo={tokenUsageInfo} />\n          )}\n          <ToolbarDropdown\n            label={sessionLabel}\n            disabled={isInFeedbackMode || isInEditMode || isInApprovalMode}\n            className=\"min-w-0 max-w-[120px]\"\n          >\n            {/* New Session option */}\n            <DropdownMenuItem\n              icon={isNewSessionMode ? CheckIcon : PlusIcon}\n              onClick={() => onNewSession?.()}\n            >\n              {t('conversation.sessions.newSession')}\n            </DropdownMenuItem>\n            {sessions.length > 0 && <DropdownMenuSeparator />}\n            {sessions.length > 0 ? (\n              <>\n                <DropdownMenuLabel>\n                  {t('conversation.sessions.label')}\n                </DropdownMenuLabel>\n                {sessions.map((s, index) => (\n                  <DropdownMenuItem\n                    key={s.id}\n                    icon={\n                      !isNewSessionMode && s.id === selectedSessionId\n                        ? CheckIcon\n                        : undefined\n                    }\n                    onClick={() => onSelectSession(s.id)}\n                  >\n                    <span className=\"flex items-center gap-1.5 max-w-[200px]\">\n                      {renderAgentIcon?.(\n                        s.executor ?? null,\n                        'size-icon shrink-0'\n                      )}\n                      <span className=\"truncate\">\n                        {s.name\n                          ? s.name\n                          : index === 0\n                            ? t('conversation.sessions.latest')\n                            : formatSessionDate(s.created_at)}\n                      </span>\n                    </span>\n                  </DropdownMenuItem>\n                ))}\n              </>\n            ) : (\n              <DropdownMenuItem disabled>\n                {t('conversation.sessions.noPreviousSessions')}\n              </DropdownMenuItem>\n            )}\n            {onRenameSession && selectedSessionId && !isNewSessionMode && (\n              <>\n                <DropdownMenuSeparator />\n                <DropdownMenuItem\n                  icon={PencilSimpleIcon}\n                  onClick={() =>\n                    onRenameSession(\n                      selectedSessionId,\n                      selectedSessionObj?.name ?? ''\n                    )\n                  }\n                >\n                  {t('conversation.sessions.rename')}\n                </DropdownMenuItem>\n              </>\n            )}\n          </ToolbarDropdown>\n        </>\n      }\n      footerLeft={\n        <>\n          <ToolbarIconButton\n            icon={PaperclipIcon}\n            aria-label={t('tasks:taskFormDialog.attachFile')}\n            title={t('tasks:taskFormDialog.attachFile')}\n            onClick={handleAttachClick}\n            disabled={areContentInsertActionsDisabled}\n          />\n          <input\n            ref={fileInputRef}\n            type=\"file\"\n            multiple\n            className=\"hidden\"\n            onChange={handleFileInputChange}\n          />\n          {onPrCommentClick && (\n            <ToolbarIconButton\n              icon={GithubLogoIcon}\n              aria-label=\"Add PR Comments\"\n              title=\"Insert PR comments into message\"\n              onClick={onPrCommentClick}\n              disabled={areContentInsertActionsDisabled}\n            />\n          )}\n          {toolbarActions?.items.map((item) => (\n            <ToolbarIconButton\n              key={item.id}\n              icon={item.icon}\n              aria-label={item.label}\n              title={item.tooltip}\n              onClick={item.onClick}\n              disabled={isDisabled || isRunning || Boolean(item.disabled)}\n            />\n          ))}\n        </>\n      }\n      footerRight={renderActionButtons()}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/SlashCommandTypeaheadPlugin.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  LexicalTypeaheadMenuPlugin,\n  MenuOption,\n} from '@lexical/react/LexicalTypeaheadMenuPlugin';\nimport {\n  $createTextNode,\n  KEY_ESCAPE_COMMAND,\n  COMMAND_PRIORITY_NORMAL,\n} from 'lexical';\nimport { TerminalIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { useTypeaheadOpen } from './TypeaheadOpenContext';\nimport { TypeaheadMenu } from './TypeaheadMenu';\n\nexport type SlashCommandDescriptionLike = {\n  name: string;\n  description?: string | null;\n};\n\nclass SlashCommandOption extends MenuOption {\n  command: SlashCommandDescriptionLike;\n\n  constructor(command: SlashCommandDescriptionLike) {\n    super(`slash-command-${command.name}`);\n    this.command = command;\n  }\n}\n\nfunction filterSlashCommands(\n  all: SlashCommandDescriptionLike[],\n  query: string\n): SlashCommandDescriptionLike[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return all;\n\n  const startsWith = all.filter((c) => c.name.toLowerCase().startsWith(q));\n  const includes = all.filter(\n    (c) => !startsWith.includes(c) && c.name.toLowerCase().includes(q)\n  );\n  return [...startsWith, ...includes];\n}\n\nexport function SlashCommandTypeaheadPlugin({\n  enabled,\n  commands,\n  isInitialized,\n  isDiscovering,\n}: {\n  enabled: boolean;\n  commands: SlashCommandDescriptionLike[];\n  isInitialized: boolean;\n  isDiscovering: boolean;\n}) {\n  const [editor] = useLexicalComposerContext();\n  const { t } = useTranslation('common');\n  const { setIsOpen } = useTypeaheadOpen();\n  const [options, setOptions] = useState<SlashCommandOption[]>([]);\n  const [activeQuery, setActiveQuery] = useState<string | null>(null);\n  const closeTypeahead = useCallback(() => {\n    editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown'));\n  }, [editor]);\n\n  const isLoading = !isInitialized && enabled;\n\n  const updateOptions = useCallback(\n    (query: string | null) => {\n      setActiveQuery(query);\n\n      if (!enabled || query === null) {\n        setOptions([]);\n        return;\n      }\n\n      const filtered = filterSlashCommands(commands, query).slice(0, 20);\n      setOptions(filtered.map((c) => new SlashCommandOption(c)));\n    },\n    [enabled, commands]\n  );\n\n  const hasVisibleResults = useMemo(() => {\n    if (!enabled || activeQuery === null) return false;\n    if (isLoading || isDiscovering) return true;\n    if (!activeQuery.trim()) return true;\n    return options.length > 0;\n  }, [enabled, activeQuery, isDiscovering, isLoading, options.length]);\n\n  // If command list loads while menu is open, refresh options.\n  useEffect(() => {\n    if (activeQuery === null) return;\n    updateOptions(activeQuery);\n  }, [activeQuery, updateOptions]);\n\n  return (\n    <LexicalTypeaheadMenuPlugin<SlashCommandOption>\n      commandPriority={COMMAND_PRIORITY_NORMAL}\n      triggerFn={(text) => {\n        const match = /^(\\s*)\\/([^\\s/]*)$/.exec(text);\n        if (!match) return null;\n\n        const slashOffset = match[1].length;\n        return {\n          leadOffset: slashOffset,\n          matchingString: match[2],\n          replaceableString: match[0].slice(slashOffset),\n        };\n      }}\n      options={options}\n      onQueryChange={updateOptions}\n      onOpen={() => setIsOpen(true)}\n      onClose={() => setIsOpen(false)}\n      onSelectOption={(option, nodeToReplace, closeMenu) => {\n        editor.update(() => {\n          if (!nodeToReplace) return;\n\n          const textToInsert = `/${option.command.name}`;\n          const commandNode = $createTextNode(textToInsert);\n          nodeToReplace.replace(commandNode);\n\n          const spaceNode = $createTextNode(' ');\n          commandNode.insertAfter(spaceNode);\n          spaceNode.select(1, 1);\n        });\n\n        closeMenu();\n      }}\n      menuRenderFn={(\n        anchorRef,\n        { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }\n      ) => {\n        if (!anchorRef.current) return null;\n        if (!enabled) return null;\n        if (!hasVisibleResults) return null;\n\n        const isEmpty = !isLoading && !isDiscovering && commands.length === 0;\n        const showLoadingRow = isLoading || isDiscovering;\n        const loadingText = isLoading\n          ? 'Loading commands…'\n          : 'Discovering commands…';\n\n        return createPortal(\n          <TypeaheadMenu\n            anchorEl={anchorRef.current}\n            editorEl={editor.getRootElement()}\n            onClickOutside={closeTypeahead}\n          >\n            <TypeaheadMenu.Header>\n              <TerminalIcon className=\"size-icon-xs\" weight=\"bold\" />\n              {t('typeahead.commands')}\n            </TypeaheadMenu.Header>\n\n            {isEmpty ? (\n              <TypeaheadMenu.Empty>\n                {t('typeahead.noCommands')}\n              </TypeaheadMenu.Empty>\n            ) : options.length === 0 && !showLoadingRow ? null : (\n              <TypeaheadMenu.ScrollArea>\n                {showLoadingRow && (\n                  <div className=\"px-base py-half text-sm text-low select-none\">\n                    {loadingText}\n                  </div>\n                )}\n                {options.map((option, index) => {\n                  const details = option.command.description ?? null;\n\n                  return (\n                    <TypeaheadMenu.Item\n                      key={option.key}\n                      isSelected={index === selectedIndex}\n                      index={index}\n                      setHighlightedIndex={setHighlightedIndex}\n                      onClick={() => selectOptionAndCleanUp(option)}\n                    >\n                      <div className=\"flex items-center gap-half font-medium\">\n                        <span className=\"font-mono\">\n                          /{option.command.name}\n                        </span>\n                      </div>\n                      {details && (\n                        <div className=\"text-xs text-low truncate\">\n                          {details}\n                        </div>\n                      )}\n                    </TypeaheadMenu.Item>\n                  );\n                })}\n              </TypeaheadMenu.ScrollArea>\n            )}\n          </TypeaheadMenu>,\n          document.body\n        );\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/SplitButton.tsx",
    "content": "import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport { CaretDownIcon, CheckIcon, type Icon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n} from './Dropdown';\n\nexport interface SplitButtonOption<T extends string> {\n  value: T;\n  label: string;\n  icon?: Icon;\n}\n\ninterface SplitButtonProps<T extends string = string> {\n  options: SplitButtonOption<T>[];\n  selectedValue: T;\n  onSelectionChange: (value: T) => void;\n  onAction: (value: T) => void;\n  className?: string;\n}\n\nexport function SplitButton<T extends string>({\n  options,\n  selectedValue,\n  onSelectionChange,\n  onAction,\n  className,\n}: SplitButtonProps<T>) {\n  const selectedOption = options.find((opt) => opt.value === selectedValue);\n  const label = selectedOption?.label ?? '';\n\n  return (\n    <div className={cn('flex', className)}>\n      <div className=\"flex items-stretch gap-[2px]\">\n        {/* Primary CTA button */}\n        <button\n          type=\"button\"\n          onClick={() => onAction(selectedValue)}\n          className={cn(\n            'flex-1 bg-panel px-base py-half',\n            'text-sm text-normal',\n            'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand focus-visible:ring-inset',\n            'rounded-l-sm'\n          )}\n        >\n          {label}\n        </button>\n\n        {/* Dropdown trigger */}\n        <DropdownMenu>\n          <DropdownMenuPrimitive.Trigger\n            className={cn(\n              'flex items-center justify-center bg-panel px-base py-half',\n              'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand focus-visible:ring-inset',\n              'rounded-r-sm'\n            )}\n          >\n            <CaretDownIcon className=\"size-icon-xs text-low\" weight=\"bold\" />\n          </DropdownMenuPrimitive.Trigger>\n          <DropdownMenuContent align=\"end\">\n            {options.map((option) => (\n              <DropdownMenuItem\n                disabled={option.value === selectedValue}\n                key={option.value}\n                onClick={() => onSelectionChange(option.value)}\n                icon={option.value === selectedValue ? CheckIcon : option.icon}\n              >\n                {option.label}\n              </DropdownMenuItem>\n            ))}\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/StaticToolbarPlugin.tsx",
    "content": "import { type ReactNode } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { FORMAT_TEXT_COMMAND, UNDO_COMMAND } from 'lexical';\nimport { INSERT_MARKDOWN_LIST_COMMAND } from './MarkdownInsertPlugin';\nimport {\n  TextB,\n  TextItalic,\n  TextStrikethrough,\n  Code,\n  ListBullets,\n  ListNumbers,\n  ArrowCounterClockwise,\n  Eye,\n  PencilSimple,\n  type Icon,\n  CheckIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\ninterface ToolbarButtonProps {\n  onClick: () => void;\n  icon: Icon;\n  label: string;\n  active?: boolean;\n}\n\nfunction ToolbarButton({\n  onClick,\n  icon: Icon,\n  label,\n  active,\n}: ToolbarButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      onMouseDown={(e) => {\n        // Prevent losing selection when clicking toolbar\n        e.preventDefault();\n        onClick();\n      }}\n      aria-label={label}\n      title={label}\n      className={cn(\n        'p-half rounded-sm transition-colors',\n        active\n          ? 'text-normal bg-panel'\n          : 'text-low hover:text-normal hover:bg-panel/50'\n      )}\n    >\n      <Icon className=\"size-icon-sm\" weight=\"bold\" />\n    </button>\n  );\n}\n\ninterface StaticToolbarPluginProps {\n  saveStatus?: 'idle' | 'saved';\n  extraActions?: ReactNode;\n  isPreviewMode?: boolean;\n  onTogglePreview?: () => void;\n  /** Called when a formatting button is clicked while the editor is read-only.\n   *  The parent should switch to edit mode; the command will be dispatched after. */\n  onRequestEdit?: () => void;\n  /** Whether the editor is currently in read-only / preview mode */\n  readOnly?: boolean;\n}\n\nexport function StaticToolbarPlugin({\n  saveStatus,\n  extraActions,\n  isPreviewMode = false,\n  onTogglePreview,\n  onRequestEdit,\n  readOnly,\n}: StaticToolbarPluginProps) {\n  const [editor] = useLexicalComposerContext();\n\n  /** Dispatch a command, switching to edit mode first if needed */\n  const dispatch = (fn: () => void) => {\n    if (readOnly && onRequestEdit) {\n      onRequestEdit();\n      // Dispatch after a tick so the editor becomes editable first\n      requestAnimationFrame(() => {\n        editor.focus();\n        editor.update(fn);\n      });\n    } else {\n      fn();\n    }\n  };\n\n  return (\n    <div className=\"flex items-center gap-half mt-half px-base py-half border-t border-border/50\">\n      {/* Undo button */}\n      <ToolbarButton\n        onClick={() =>\n          dispatch(() => editor.dispatchCommand(UNDO_COMMAND, undefined))\n        }\n        icon={ArrowCounterClockwise}\n        label=\"Undo\"\n      />\n\n      {/* Separator */}\n      <div className=\"w-px h-4 bg-border mx-half\" />\n\n      {/* Text formatting buttons — insert markdown syntax */}\n      <ToolbarButton\n        onClick={() =>\n          dispatch(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'))\n        }\n        icon={TextB}\n        label=\"Bold\"\n      />\n      <ToolbarButton\n        onClick={() =>\n          dispatch(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'))\n        }\n        icon={TextItalic}\n        label=\"Italic\"\n      />\n      <ToolbarButton\n        onClick={() =>\n          dispatch(() =>\n            editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')\n          )\n        }\n        icon={TextStrikethrough}\n        label=\"Strikethrough\"\n      />\n      <ToolbarButton\n        onClick={() =>\n          dispatch(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'))\n        }\n        icon={Code}\n        label=\"Inline Code\"\n      />\n\n      {/* Separator */}\n      <div className=\"w-px h-4 bg-border mx-half\" />\n\n      {/* List buttons */}\n      <ToolbarButton\n        onClick={() =>\n          dispatch(() =>\n            editor.dispatchCommand(INSERT_MARKDOWN_LIST_COMMAND, 'bullet')\n          )\n        }\n        icon={ListBullets}\n        label=\"Bullet List\"\n      />\n      <ToolbarButton\n        onClick={() =>\n          dispatch(() =>\n            editor.dispatchCommand(INSERT_MARKDOWN_LIST_COMMAND, 'number')\n          )\n        }\n        icon={ListNumbers}\n        label=\"Numbered List\"\n      />\n\n      {/* Preview toggle */}\n      {onTogglePreview && (\n        <>\n          <div className=\"w-px h-4 bg-border mx-half\" />\n          <ToolbarButton\n            onClick={onTogglePreview}\n            icon={isPreviewMode ? PencilSimple : Eye}\n            label={isPreviewMode ? 'Edit' : 'Preview'}\n            active={isPreviewMode}\n          />\n        </>\n      )}\n\n      {extraActions && (\n        <>\n          <div className=\"w-px h-4 bg-border mx-half\" />\n          <div className=\"flex items-center gap-half\">{extraActions}</div>\n        </>\n      )}\n\n      {/* Save Status Indicator */}\n      {saveStatus && (\n        <div\n          className={cn(\n            'ml-auto mr-base flex items-center transition-opacity duration-300',\n            saveStatus === 'idle' ? 'opacity-0' : 'opacity-100'\n          )}\n        >\n          <CheckIcon className=\"size-icon-sm text-success\" weight=\"bold\" />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/StatusDot.tsx",
    "content": "import { cn } from '../lib/cn';\n\nexport interface StatusDotProps {\n  color: string;\n  className?: string;\n}\n\nexport const StatusDot = ({ color, className }: StatusDotProps) => (\n  <span\n    className={cn('w-2 h-2 rounded-full shrink-0', className)}\n    style={{ backgroundColor: `hsl(${color})` }}\n  />\n);\n"
  },
  {
    "path": "packages/ui/src/components/SubIssueRow.tsx",
    "content": "'use client';\n\nimport { Draggable } from '@hello-pangea/dnd';\nimport {\n  CircleDashedIcon,\n  DotsSixVerticalIcon,\n  DotsThreeIcon,\n  LinkBreakIcon,\n  TrashIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\nimport { PriorityIcon, type PriorityLevel } from './PriorityIcon';\nimport { StatusDot } from './StatusDot';\nimport { KanbanAssignee, type KanbanAssigneeUser } from './KanbanAssignee';\nimport { useTranslation } from 'react-i18next';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from './DropdownMenu';\n\n/**\n * Formats a date as a relative time string (e.g., \"1d\", \"2h\", \"3m\")\n */\nfunction formatRelativeTime(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMinutes = Math.floor(diffMs / (1000 * 60));\n  const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n\n  if (diffDays > 0) {\n    return `${diffDays}d`;\n  }\n  if (diffHours > 0) {\n    return `${diffHours}h`;\n  }\n  if (diffMinutes > 0) {\n    return `${diffMinutes}m`;\n  }\n  return 'now';\n}\n\nexport interface SubIssueRowProps {\n  id: string;\n  index: number;\n  simpleId: string;\n  title: string;\n  priority: PriorityLevel | null;\n  statusColor: string;\n  assignees: KanbanAssigneeUser[];\n  createdAt: string;\n  onClick?: () => void;\n  onPriorityClick?: (e: React.MouseEvent) => void;\n  onAssigneeClick?: (e: React.MouseEvent) => void;\n  onMarkIndependentClick?: (e: React.MouseEvent) => void;\n  onDeleteClick?: (e: React.MouseEvent) => void;\n  className?: string;\n}\n\nexport function SubIssueRow({\n  id,\n  index,\n  simpleId,\n  title,\n  priority,\n  statusColor,\n  assignees,\n  createdAt,\n  onClick,\n  onPriorityClick,\n  onAssigneeClick,\n  onMarkIndependentClick,\n  onDeleteClick,\n  className,\n}: SubIssueRowProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <Draggable draggableId={id} index={index}>\n      {(provided, snapshot) => (\n        <div\n          ref={provided.innerRef}\n          {...provided.draggableProps}\n          role={onClick ? 'button' : undefined}\n          tabIndex={onClick ? 0 : undefined}\n          onClick={onClick}\n          onKeyDown={(e) => {\n            if (onClick && (e.key === 'Enter' || e.key === ' ')) {\n              e.preventDefault();\n              onClick();\n            }\n          }}\n          className={cn(\n            'flex items-center gap-half px-base py-half rounded-sm transition-colors',\n            onClick && 'cursor-pointer hover:bg-secondary',\n            snapshot.isDragging && 'bg-secondary shadow-lg cursor-grabbing',\n            className\n          )}\n        >\n          {/* Drag handle */}\n          <div\n            {...provided.dragHandleProps}\n            className=\"cursor-grab shrink-0\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            <DotsSixVerticalIcon\n              className=\"size-icon-xs text-low\"\n              weight=\"bold\"\n            />\n          </div>\n\n          {/* Left side: Priority, ID, Status, Title */}\n          <div className=\"flex items-center gap-half flex-1 min-w-0\">\n            {onPriorityClick ? (\n              <button\n                type=\"button\"\n                onClick={onPriorityClick}\n                className=\"flex items-center cursor-pointer hover:bg-secondary rounded-sm transition-colors\"\n              >\n                <PriorityIcon priority={priority} />\n                {!priority && (\n                  <CircleDashedIcon\n                    className=\"size-icon-xs text-low\"\n                    weight=\"bold\"\n                  />\n                )}\n              </button>\n            ) : (\n              <PriorityIcon priority={priority} />\n            )}\n            <span className=\"font-ibm-plex-mono text-sm text-normal shrink-0\">\n              {simpleId}\n            </span>\n            <StatusDot color={statusColor} />\n            <span className=\"text-base text-high truncate\">{title}</span>\n          </div>\n\n          {/* Right side: Assignee, Age */}\n          <div className=\"flex items-center gap-half shrink-0\">\n            {onAssigneeClick ? (\n              <button\n                type=\"button\"\n                onClick={onAssigneeClick}\n                className=\"cursor-pointer hover:bg-secondary rounded-sm transition-colors\"\n              >\n                <KanbanAssignee assignees={assignees} />\n              </button>\n            ) : (\n              <KanbanAssignee assignees={assignees} />\n            )}\n            <span className=\"text-sm text-low\">\n              {formatRelativeTime(createdAt)}\n            </span>\n            {(onMarkIndependentClick || onDeleteClick) && (\n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <button\n                    type=\"button\"\n                    onClick={(e) => e.stopPropagation()}\n                    className=\"p-half rounded-sm text-low hover:text-normal hover:bg-secondary transition-colors\"\n                    aria-label=\"Sub-issue actions\"\n                    title=\"Sub-issue actions\"\n                  >\n                    <DotsThreeIcon className=\"size-icon-xs\" weight=\"bold\" />\n                  </button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\">\n                  {onMarkIndependentClick && (\n                    <DropdownMenuItem\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        onMarkIndependentClick(e);\n                      }}\n                    >\n                      <LinkBreakIcon className=\"size-icon-xs\" />\n                      {t('kanban.markIndependentIssue')}\n                    </DropdownMenuItem>\n                  )}\n                  {onDeleteClick && (\n                    <DropdownMenuItem\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        onDeleteClick(e);\n                      }}\n                      className=\"text-destructive focus:text-destructive\"\n                    >\n                      <TrashIcon className=\"size-icon-xs\" />\n                      {t('buttons.delete')}\n                    </DropdownMenuItem>\n                  )}\n                </DropdownMenuContent>\n              </DropdownMenu>\n            )}\n          </div>\n        </div>\n      )}\n    </Draggable>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Switch.tsx",
    "content": "import * as React from 'react';\nimport * as SwitchPrimitives from '@radix-ui/react-switch';\nimport { cn } from '../lib/cn';\n\nconst switchRootClassName =\n  'peer inline-flex h-[18px] w-8 shrink-0 cursor-pointer items-center ' +\n  'rounded-full border-2 border-transparent transition-colors ' +\n  'data-[state=checked]:bg-foreground ' +\n  'data-[state=unchecked]:bg-foreground/35 ' +\n  'data-[state=unchecked]:border-foreground/15 ' +\n  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +\n  'focus-visible:ring-offset-2 focus-visible:ring-offset-background ' +\n  'disabled:cursor-not-allowed disabled:opacity-50';\n\nconst switchThumbClassName =\n  'pointer-events-none block h-3.5 w-3.5 rounded-full shadow-sm ring-0 ' +\n  'transition-transform data-[state=checked]:translate-x-3.5 ' +\n  'data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-secondary ' +\n  'data-[state=unchecked]:bg-low';\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    ref={ref}\n    className={cn(switchRootClassName, className)}\n    {...props}\n  >\n    <SwitchPrimitives.Thumb className={switchThumbClassName} />\n  </SwitchPrimitives.Root>\n));\nSwitch.displayName = SwitchPrimitives.Root.displayName;\n\nexport { Switch };\n"
  },
  {
    "path": "packages/ui/src/components/SyncErrorIndicator.tsx",
    "content": "import { WarningIcon, ArrowClockwiseIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { Popover, PopoverTrigger, PopoverContent } from './Popover';\n\nexport interface SyncErrorIndicatorError {\n  streamId: string;\n  tableName: string;\n  error: {\n    message: string;\n    status?: string | number | null;\n  };\n}\n\nexport interface SyncErrorIndicatorProps {\n  errors?: readonly SyncErrorIndicatorError[] | null;\n  onRefreshPage?: () => void;\n}\n\n/**\n * Displays a warning indicator when there are sync errors.\n * Shows a popover with error details on click.\n * Returns null when there are no errors.\n */\nexport function SyncErrorIndicator({\n  errors,\n  onRefreshPage,\n}: SyncErrorIndicatorProps) {\n  const { t } = useTranslation('common');\n\n  if (!errors || errors.length === 0) {\n    return null;\n  }\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"flex items-center justify-center rounded-sm text-error hover:text-error/80\"\n          aria-label={`${errors.length} sync error${errors.length > 1 ? 's' : ''}`}\n        >\n          <WarningIcon className=\"size-icon-base\" weight=\"fill\" />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent side=\"bottom\" align=\"end\" className=\"w-80\">\n        <div className=\"space-y-base\">\n          <div className=\"flex items-center justify-between\">\n            <h4 className=\"text-sm font-medium text-normal\">\n              {t('syncError.networkErrors')}\n            </h4>\n            <span className=\"text-xs text-low\">\n              {t('syncError.streamsAffected', { count: errors.length })}\n            </span>\n          </div>\n\n          <div className=\"space-y-half max-h-48 overflow-y-auto\">\n            {errors.map((streamError) => (\n              <div\n                key={streamError.streamId}\n                className=\"rounded-sm bg-error/10 p-half text-xs\"\n              >\n                <div className=\"font-medium text-error\">\n                  {streamError.tableName}\n                </div>\n                <div className=\"text-low mt-quarter truncate\">\n                  {streamError.error.message}\n                  {streamError.error.status && (\n                    <span className=\"ml-1 text-error/70\">\n                      {t('syncError.status', {\n                        status: streamError.error.status,\n                      })}\n                    </span>\n                  )}\n                </div>\n              </div>\n            ))}\n          </div>\n\n          <button\n            type=\"button\"\n            onClick={() => onRefreshPage?.() ?? window.location.reload()}\n            className=\"flex w-full items-center justify-center gap-half rounded-sm bg-primary px-base py-half text-xs font-medium text-primary-foreground hover:bg-primary/90\"\n          >\n            <ArrowClockwiseIcon className=\"size-icon-sm\" />\n            {t('syncError.refreshPage')}\n          </button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Table.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '../lib/cn';\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <table ref={ref} className={cn('w-full text-sm', className)} {...props} />\n));\nTable.displayName = 'Table';\n\nconst TableHead = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead\n    ref={ref}\n    className={cn('uppercase text-muted-foreground', className)}\n    {...props}\n  />\n));\nTableHead.displayName = 'TableHead';\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody ref={ref} className={className} {...props} />\n));\nTableBody.displayName = 'TableBody';\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement> & {\n    clickable?: boolean;\n  }\n>(({ className, clickable, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      'border-t',\n      clickable && 'cursor-pointer hover:bg-muted',\n      className\n    )}\n    {...props}\n  />\n));\nTableRow.displayName = 'TableRow';\n\nconst TableHeaderCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th ref={ref} className={cn('text-left', className)} {...props} />\n));\nTableHeaderCell.displayName = 'TableHeaderCell';\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td ref={ref} className={cn('py-2', className)} {...props} />\n));\nTableCell.displayName = 'TableCell';\n\nconst TableEmpty = ({\n  colSpan,\n  children,\n}: {\n  colSpan: number;\n  children: React.ReactNode;\n}) => (\n  <TableRow>\n    <TableCell colSpan={colSpan} className=\"text-muted-foreground\">\n      {children}\n    </TableCell>\n  </TableRow>\n);\n\nconst TableLoading = ({ colSpan }: { colSpan: number }) => (\n  <TableRow>\n    <TableCell colSpan={colSpan}>\n      <div className=\"h-5 w-full bg-muted/30 rounded animate-pulse\" />\n    </TableCell>\n  </TableRow>\n);\n\nexport {\n  Table,\n  TableHead,\n  TableBody,\n  TableRow,\n  TableHeaderCell,\n  TableCell,\n  TableEmpty,\n  TableLoading,\n};\n"
  },
  {
    "path": "packages/ui/src/components/TerminalPanel.tsx",
    "content": "import type { ReactNode } from 'react';\n\ninterface TerminalPanelProps {\n  tabs: { id: string }[];\n  activeTabId: string | null;\n  renderTab: (tabId: string, isActive: boolean) => ReactNode;\n}\n\nexport function TerminalPanel({\n  tabs,\n  activeTabId,\n  renderTab,\n}: TerminalPanelProps) {\n  return <>{tabs.map((tab) => renderTab(tab.id, tab.id === activeTabId))}</>;\n}\n"
  },
  {
    "path": "packages/ui/src/components/Textarea.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '../lib/cn';\n\nconst Textarea = React.forwardRef<\n  HTMLTextAreaElement,\n  React.ComponentProps<'textarea'>\n>(({ className, ...props }, ref) => {\n  return (\n    <textarea\n      className={cn(\n        'flex min-h-[80px] w-full bg-transparent border px-3 py-2 text-sm ring-offset-background focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      ref={ref}\n      {...props}\n    />\n  );\n});\nTextarea.displayName = 'Textarea';\n\nexport { Textarea };\n"
  },
  {
    "path": "packages/ui/src/components/TodoProgressPopup.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { ListChecksIcon } from '@phosphor-icons/react';\nimport { Circle, Check, CircleDot } from 'lucide-react';\nimport { cn } from '../lib/cn';\nimport { Popover, PopoverTrigger, PopoverContent } from './Popover';\nimport { Tooltip } from './Tooltip';\n\nexport interface TodoProgressItem {\n  content: string;\n  status?: string | null;\n}\n\ninterface TodoProgressPopupProps {\n  todos: TodoProgressItem[];\n  className?: string;\n}\n\nfunction getStatusIcon(status?: string | null) {\n  const s = (status || '').toLowerCase();\n  if (s === 'completed')\n    return <Check aria-hidden className=\"size-icon-sm text-success\" />;\n  if (s === 'in_progress' || s === 'in-progress')\n    return <CircleDot aria-hidden className=\"size-icon-sm text-blue-500\" />;\n  if (s === 'cancelled')\n    return <Circle aria-hidden className=\"size-icon-sm text-gray-400\" />;\n  return <Circle aria-hidden className=\"size-icon-sm text-muted-foreground\" />;\n}\n\nexport function TodoProgressPopup({\n  todos,\n  className,\n}: TodoProgressPopupProps) {\n  const { t } = useTranslation('tasks');\n\n  const isEmpty = todos.length === 0;\n\n  const { completed, total, percentage } = useMemo(() => {\n    const total = todos.length;\n    const completed = todos.filter(\n      (todo) => todo.status?.toLowerCase() === 'completed'\n    ).length;\n    const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;\n    return { completed, total, percentage };\n  }, [todos]);\n\n  const tooltipText = isEmpty\n    ? t('todoPopup.noTasks')\n    : t('todoPopup.progress', { completed, total });\n\n  // When empty, render just a disabled icon without popover\n  if (isEmpty) {\n    return (\n      <Tooltip content={tooltipText} side=\"bottom\">\n        <span className=\"inline-flex\">\n          <button\n            disabled\n            className={cn(\n              'flex items-center justify-center text-lowest opacity-40 cursor-not-allowed',\n              className\n            )}\n            aria-label={t('todoPopup.title')}\n          >\n            <ListChecksIcon className=\"size-icon-base\" />\n          </button>\n        </span>\n      </Tooltip>\n    );\n  }\n\n  return (\n    <Popover>\n      <Tooltip content={tooltipText} side=\"bottom\">\n        <span className=\"inline-flex\">\n          <PopoverTrigger asChild>\n            <button\n              className={cn(\n                'flex items-center justify-center text-low hover:text-normal transition-colors',\n                'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand',\n                className\n              )}\n              aria-label={t('todoPopup.title')}\n            >\n              <div className=\"relative\">\n                <ListChecksIcon className=\"size-icon-base\" />\n                {/* Progress indicator dot - only shown when there are todos */}\n                {percentage < 100 && (\n                  <span className=\"absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-blue-500\" />\n                )}\n                {percentage === 100 && (\n                  <span className=\"absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-success\" />\n                )}\n              </div>\n            </button>\n          </PopoverTrigger>\n        </span>\n      </Tooltip>\n      <PopoverContent\n        align=\"end\"\n        className=\"w-80 max-h-[min(75vh,var(--radix-popover-content-available-height))] flex flex-col\"\n      >\n        <div className=\"flex flex-col gap-base min-h-0\">\n          {/* Header with progress */}\n          <div className=\"flex items-center justify-between shrink-0\">\n            <h4 className=\"text-sm font-medium text-normal\">\n              {t('todoPopup.title')}\n            </h4>\n            <span className=\"text-xs text-low\">\n              {t('todoPopup.progress', { completed, total })}\n            </span>\n          </div>\n\n          {/* Progress bar */}\n          <div className=\"h-1.5 w-full bg-border rounded-full overflow-hidden shrink-0\">\n            <div\n              className={cn(\n                'h-full transition-all duration-300 rounded-full',\n                percentage === 100 ? 'bg-success' : 'bg-blue-500'\n              )}\n              style={{ width: `${percentage}%` }}\n            />\n          </div>\n\n          {/* Todo list */}\n          <ul className=\"space-y-1 overflow-y-auto min-h-0\">\n            {todos.map((todo, index) => (\n              <li\n                key={`${todo.content}-${index}`}\n                className=\"flex items-start gap-2 py-half\"\n              >\n                <span className=\"mt-0.5 size-icon-sm flex items-center justify-center shrink-0\">\n                  {getStatusIcon(todo.status)}\n                </span>\n                <span className=\"text-sm leading-5 break-words text-normal\">\n                  {todo.status?.toLowerCase() === 'cancelled' ? (\n                    <s className=\"text-gray-400\">{todo.content}</s>\n                  ) : (\n                    todo.content\n                  )}\n                </span>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Toggle.tsx",
    "content": "import { Switch } from './Switch';\nimport { cn } from '../lib/cn';\n\ninterface ToggleProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  label: string;\n  description?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function Toggle({\n  checked,\n  onCheckedChange,\n  label,\n  description,\n  disabled,\n  className,\n}: ToggleProps) {\n  return (\n    <div className={cn('flex items-start gap-base', className)}>\n      <Switch\n        checked={checked}\n        onCheckedChange={onCheckedChange}\n        disabled={disabled}\n        className=\"mt-px shrink-0\"\n      />\n      <div className=\"flex flex-col gap-half min-w-0\">\n        <span className=\"text-base text-normal\">{label}</span>\n        {description && <span className=\"text-sm text-low\">{description}</span>}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/ToolStatusDot.tsx",
    "content": "import { cn } from '../lib/cn';\n\nexport interface ToolStatusLike {\n  status: string;\n}\n\ninterface ToolStatusDotProps {\n  status: ToolStatusLike;\n  className?: string;\n}\n\nexport function ToolStatusDot({ status, className }: ToolStatusDotProps) {\n  const statusType = status.status;\n\n  // Map status to visual state\n  const isSuccess = statusType === 'success';\n  const isError =\n    statusType === 'failed' ||\n    statusType === 'denied' ||\n    statusType === 'timed_out';\n  const isPending =\n    statusType === 'created' || statusType === 'pending_approval';\n\n  return (\n    <span className={cn('inline-flex', className)}>\n      <span\n        className={cn(\n          'size-1.5 rounded-full',\n          isSuccess && 'bg-success',\n          isError && 'bg-error',\n          isPending && 'bg-text-low'\n        )}\n      />\n      {isPending && (\n        <span className=\"absolute inset-0 size-1.5 rounded-full bg-text-low animate-ping\" />\n      )}\n    </span>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Toolbar.tsx",
    "content": "import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';\nimport {\n  type Icon,\n  SortAscendingIcon,\n  SortDescendingIcon,\n  CalendarIcon,\n  UserIcon,\n  TagIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTriggerButton,\n} from './Dropdown';\n\ninterface ToolbarProps extends HTMLAttributes<HTMLDivElement> {\n  children: ReactNode;\n}\n\nfunction Toolbar({ children, className, ...props }: ToolbarProps) {\n  return (\n    <div className={cn('flex items-center gap-base', className)} {...props}>\n      {children}\n    </div>\n  );\n}\n\ninterface ToolbarIconButtonProps\n  extends ButtonHTMLAttributes<HTMLButtonElement> {\n  icon: Icon;\n}\n\nfunction ToolbarIconButton({\n  icon: IconComponent,\n  className,\n  disabled,\n  ...props\n}: ToolbarIconButtonProps) {\n  return (\n    <button\n      className={cn(\n        'flex items-center justify-center text-low hover:text-normal',\n        disabled && 'opacity-40 cursor-not-allowed hover:text-low',\n        className\n      )}\n      disabled={disabled}\n      {...props}\n    >\n      <IconComponent className=\"size-icon-base\" />\n    </button>\n  );\n}\n\ninterface ToolbarDropdownProps {\n  label: string;\n  icon?: Icon;\n  children?: ReactNode;\n  className?: string;\n  disabled?: boolean;\n}\n\nfunction ToolbarDropdown({\n  label,\n  icon,\n  children,\n  className,\n  disabled,\n}: ToolbarDropdownProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTriggerButton\n        icon={icon}\n        label={label}\n        className={className}\n        disabled={disabled}\n      />\n      <DropdownMenuContent>\n        {children ?? (\n          <>\n            <DropdownMenuLabel>{t('toolbar.sortBy')}</DropdownMenuLabel>\n            <DropdownMenuItem icon={SortAscendingIcon}>\n              {t('sorting.ascending')}\n            </DropdownMenuItem>\n            <DropdownMenuItem icon={SortDescendingIcon}>\n              {t('sorting.descending')}\n            </DropdownMenuItem>\n            <DropdownMenuSeparator />\n            <DropdownMenuLabel>{t('toolbar.groupBy')}</DropdownMenuLabel>\n            <DropdownMenuItem icon={CalendarIcon}>\n              {t('grouping.date')}\n            </DropdownMenuItem>\n            <DropdownMenuItem icon={UserIcon}>\n              {t('grouping.assignee')}\n            </DropdownMenuItem>\n            <DropdownMenuItem icon={TagIcon}>\n              {t('grouping.label')}\n            </DropdownMenuItem>\n          </>\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nexport { Toolbar, ToolbarIconButton, ToolbarDropdown };\n"
  },
  {
    "path": "packages/ui/src/components/ToolbarPlugin.tsx",
    "content": "import { useCallback, useEffect, useState, useLayoutEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  $getSelection,\n  $isRangeSelection,\n  FORMAT_TEXT_COMMAND,\n  SELECTION_CHANGE_COMMAND,\n  COMMAND_PRIORITY_CRITICAL,\n} from 'lexical';\nimport { Bold, Italic, Strikethrough, Code } from 'lucide-react';\nimport { cn } from '../lib/cn';\n\nconst TOOLBAR_HEIGHT = 36;\nconst GAP = 8;\nconst VIEWPORT_PADDING = 10;\n\nfunction ToolbarButton({\n  onClick,\n  children,\n  title,\n}: {\n  onClick: (e: React.MouseEvent) => void;\n  children: React.ReactNode;\n  title: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onMouseDown={(e) => {\n        // Prevent losing selection when clicking toolbar\n        e.preventDefault();\n        onClick(e);\n      }}\n      title={title}\n      aria-label={title}\n      className={cn(\n        'p-1.5 rounded hover:bg-accent transition-colors bg-secondary/40'\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nexport function ToolbarPlugin() {\n  const [editor] = useLexicalComposerContext();\n\n  // Visibility and position state\n  const [isVisible, setIsVisible] = useState(false);\n  const [position, setPosition] = useState<{\n    top: number;\n    left: number;\n  } | null>(null);\n\n  const updateToolbar = useCallback(() => {\n    const selection = $getSelection();\n\n    if (!$isRangeSelection(selection) || selection.isCollapsed()) {\n      setIsVisible(false);\n      setPosition(null);\n      return;\n    }\n\n    // Check if selection has actual text content\n    const text = selection.getTextContent();\n    if (!text || text.trim().length === 0) {\n      setIsVisible(false);\n      setPosition(null);\n      return;\n    }\n\n    setIsVisible(true);\n  }, []);\n\n  const updatePosition = useCallback(() => {\n    const domSelection = window.getSelection();\n    if (\n      !domSelection ||\n      domSelection.rangeCount === 0 ||\n      domSelection.isCollapsed\n    ) {\n      return;\n    }\n\n    const range = domSelection.getRangeAt(0);\n    const rect = range.getBoundingClientRect();\n\n    // Skip if rect is empty (happens during certain selection states)\n    if (rect.width === 0 && rect.height === 0) {\n      return;\n    }\n\n    // Calculate toolbar width (approximate)\n    const toolbarWidth = 150;\n\n    // Position above selection, centered\n    let top = rect.top - TOOLBAR_HEIGHT - GAP + window.scrollY;\n    let left = rect.left + rect.width / 2 - toolbarWidth / 2 + window.scrollX;\n\n    // Flip below if not enough space above\n    if (rect.top < TOOLBAR_HEIGHT + GAP + VIEWPORT_PADDING) {\n      top = rect.bottom + GAP + window.scrollY;\n    }\n\n    // Keep within viewport horizontally\n    left = Math.max(\n      VIEWPORT_PADDING,\n      Math.min(left, window.innerWidth - toolbarWidth - VIEWPORT_PADDING)\n    );\n\n    setPosition({ top, left });\n  }, []);\n\n  // Update toolbar state on selection change\n  useEffect(() => {\n    return editor.registerCommand(\n      SELECTION_CHANGE_COMMAND,\n      () => {\n        updateToolbar();\n        return false;\n      },\n      COMMAND_PRIORITY_CRITICAL\n    );\n  }, [editor, updateToolbar]);\n\n  // Update toolbar state on editor updates\n  useEffect(() => {\n    return editor.registerUpdateListener(({ editorState }) => {\n      editorState.read(() => {\n        updateToolbar();\n      });\n    });\n  }, [editor, updateToolbar]);\n\n  // Update position when visible\n  useLayoutEffect(() => {\n    if (!isVisible) return;\n\n    updatePosition();\n\n    // Update position on scroll and resize\n    const handleUpdate = () => {\n      requestAnimationFrame(updatePosition);\n    };\n\n    window.addEventListener('scroll', handleUpdate, true);\n    window.addEventListener('resize', handleUpdate);\n\n    return () => {\n      window.removeEventListener('scroll', handleUpdate, true);\n      window.removeEventListener('resize', handleUpdate);\n    };\n  }, [isVisible, updatePosition]);\n\n  // Hide toolbar when editor loses focus\n  useEffect(() => {\n    const rootElement = editor.getRootElement();\n    if (!rootElement) return;\n\n    const handleFocusOut = (e: FocusEvent) => {\n      // Don't hide if focus is moving to the toolbar itself\n      const relatedTarget = e.relatedTarget as HTMLElement | null;\n      if (relatedTarget?.closest('[data-floating-toolbar]')) {\n        return;\n      }\n      setIsVisible(false);\n      setPosition(null);\n    };\n\n    rootElement.addEventListener('focusout', handleFocusOut);\n    return () => {\n      rootElement.removeEventListener('focusout', handleFocusOut);\n    };\n  }, [editor]);\n\n  const iconSize = 16;\n\n  // Don't render until we have both visibility and position\n  if (!isVisible || !position) {\n    return null;\n  }\n\n  return createPortal(\n    <div\n      data-floating-toolbar\n      className=\"fixed z-[10000] flex items-center gap-0.5 px-1.5 py-1 bg-popover bg-panel/20 backdrop-blur-sm  text-popover-foreground border border-border rounded-lg shadow-lg animate-in fade-in-0 duration-100\"\n      style={{\n        top: position.top,\n        left: position.left,\n      }}\n    >\n      <ToolbarButton\n        onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')}\n        title=\"Bold (Cmd+B)\"\n      >\n        <Bold size={iconSize} />\n      </ToolbarButton>\n      <ToolbarButton\n        onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')}\n        title=\"Italic (Cmd+I)\"\n      >\n        <Italic size={iconSize} />\n      </ToolbarButton>\n      <ToolbarButton\n        onClick={() =>\n          editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')\n        }\n        title=\"Strikethrough\"\n      >\n        <Strikethrough size={iconSize} />\n      </ToolbarButton>\n      <ToolbarButton\n        onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')}\n        title=\"Inline Code (Cmd+E)\"\n      >\n        <Code size={iconSize} />\n      </ToolbarButton>\n    </div>,\n    document.body\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/Tooltip.tsx",
    "content": "import type { ReactNode } from 'react';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\nimport { cn } from '../lib/cn';\nimport { getModifierKey } from '../lib/platform';\n\ninterface TooltipProps {\n  children: ReactNode;\n  content: string;\n  shortcut?: string;\n  side?: 'top' | 'bottom' | 'left' | 'right';\n  className?: string;\n}\n\nexport function Tooltip({\n  children,\n  content,\n  shortcut,\n  side = 'bottom',\n  className,\n}: TooltipProps) {\n  const formattedShortcut = shortcut?.replace('{mod}', getModifierKey());\n\n  return (\n    <TooltipPrimitive.Provider delayDuration={300}>\n      <TooltipPrimitive.Root>\n        <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>\n        <TooltipPrimitive.Portal>\n          <TooltipPrimitive.Content\n            side={side}\n            sideOffset={4}\n            className={cn(\n              'z-[10000] flex items-center rounded-sm bg-panel px-base py-half text-xs text-normal shadow-md',\n              'animate-in fade-in-0 zoom-in-95',\n              className\n            )}\n          >\n            <span>{content}</span>\n            {formattedShortcut && (\n              <kbd\n                className={cn(\n                  'ml-2 inline-flex items-center gap-0.5 px-2 py-0.5',\n                  'rounded-sm border border-border bg-secondary',\n                  'font-ibm-plex-mono text-xs text-high'\n                )}\n              >\n                {formattedShortcut}\n              </kbd>\n            )}\n          </TooltipPrimitive.Content>\n        </TooltipPrimitive.Portal>\n      </TooltipPrimitive.Root>\n    </TooltipPrimitive.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/TypeaheadMenu.tsx",
    "content": "import {\n  useRef,\n  useEffect,\n  useLayoutEffect,\n  useCallback,\n  useState,\n  type ReactNode,\n  type MouseEvent,\n  type CSSProperties,\n} from 'react';\n\n// --- Headless Compound Components ---\n\ntype VerticalSide = 'top' | 'bottom';\n\ninterface TypeaheadPlacement {\n  side: VerticalSide;\n  maxHeight: number;\n  left: number;\n  top: number;\n}\n\nfunction getViewportHeight() {\n  return window.visualViewport?.height ?? window.innerHeight;\n}\n\nfunction getViewportWidth() {\n  return window.visualViewport?.width ?? window.innerWidth;\n}\n\nfunction parseLength(value: string): number {\n  const parsed = Number.parseFloat(value);\n  return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction resolveLineHeight(\n  style: CSSStyleDeclaration,\n  fallbackHeight: number\n): number {\n  const explicit = parseLength(style.lineHeight);\n  if (explicit > 0) return explicit;\n\n  const fontSize = parseLength(style.fontSize);\n  if (fontSize > 0) return fontSize;\n\n  return Math.max(fallbackHeight, 0);\n}\n\nfunction round(value: number): number {\n  return Math.round(value);\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction placementsEqual(\n  a: TypeaheadPlacement,\n  b: TypeaheadPlacement\n): boolean {\n  return (\n    a.side === b.side &&\n    a.left === b.left &&\n    a.top === b.top &&\n    a.maxHeight === b.maxHeight\n  );\n}\n\nfunction computePlacement(\n  anchorEl: HTMLElement,\n  menuEl: HTMLElement,\n  editorEl?: HTMLElement | null\n): TypeaheadPlacement {\n  const anchorRect = anchorEl.getBoundingClientRect();\n  const menuRect = menuEl.getBoundingClientRect();\n  const editorRect = editorEl?.getBoundingClientRect();\n  const anchorStyles = window.getComputedStyle(anchorEl);\n  const menuStyles = window.getComputedStyle(menuEl);\n  const viewportWidth = getViewportWidth();\n  const viewportHeight = getViewportHeight();\n  const marginTop = parseLength(menuStyles.marginTop);\n  const marginBottom = parseLength(menuStyles.marginBottom);\n  const marginLeft = parseLength(menuStyles.marginLeft);\n  const marginRight = parseLength(menuStyles.marginRight);\n  const configuredMinHeight = parseLength(menuStyles.minHeight);\n  const configuredMaxHeight = parseLength(menuStyles.maxHeight);\n  const measuredHeight = round(\n    menuRect.height ||\n      parseLength(menuStyles.height) ||\n      parseLength(menuStyles.minHeight)\n  );\n\n  const lineHeight = resolveLineHeight(anchorStyles, anchorRect.height);\n  const cursorTopBoundary = anchorRect.top - lineHeight;\n  const topBoundary = editorRect\n    ? Math.max(editorRect.top, cursorTopBoundary)\n    : cursorTopBoundary;\n  const aboveSpace = topBoundary - marginBottom;\n  const belowSpace = viewportHeight - anchorRect.bottom - marginTop;\n  const side: VerticalSide =\n    belowSpace >= measuredHeight || belowSpace >= aboveSpace ? 'bottom' : 'top';\n  const availableSpace = Math.max(\n    side === 'bottom' ? belowSpace : aboveSpace,\n    0\n  );\n\n  let maxHeight = configuredMaxHeight\n    ? Math.min(configuredMaxHeight, availableSpace)\n    : availableSpace;\n  if (configuredMinHeight) {\n    maxHeight = Math.max(\n      maxHeight,\n      Math.min(configuredMinHeight, availableSpace)\n    );\n  }\n\n  const measuredWidth =\n    round(menuRect.width) ||\n    round(parseLength(menuStyles.width)) ||\n    round(parseLength(menuStyles.minWidth));\n  const minLeft = marginLeft;\n  const maxLeft = Math.max(\n    minLeft,\n    viewportWidth - measuredWidth - marginRight\n  );\n  const left = clamp(round(anchorRect.left), round(minLeft), round(maxLeft));\n  const top =\n    side === 'bottom'\n      ? round(anchorRect.bottom + marginTop)\n      : round(topBoundary - marginBottom);\n\n  return {\n    side,\n    maxHeight: round(maxHeight),\n    left,\n    top,\n  };\n}\n\ninterface TypeaheadMenuProps {\n  anchorEl: HTMLElement;\n  editorEl?: HTMLElement | null;\n  onClickOutside?: () => void;\n  children: ReactNode;\n}\n\nfunction TypeaheadMenuRoot({\n  anchorEl,\n  editorEl,\n  onClickOutside,\n  children,\n}: TypeaheadMenuProps) {\n  const menuRef = useRef<HTMLDivElement>(null);\n  const [placement, setPlacement] = useState<TypeaheadPlacement | null>(null);\n\n  const syncPlacement = useCallback(() => {\n    const menuEl = menuRef.current;\n    if (!menuEl) return;\n\n    const nextPlacement = computePlacement(anchorEl, menuEl, editorEl);\n    setPlacement((previous) => {\n      if (previous && placementsEqual(previous, nextPlacement)) {\n        return previous;\n      }\n\n      return nextPlacement;\n    });\n  }, [anchorEl, editorEl]);\n\n  useLayoutEffect(() => {\n    syncPlacement();\n    const frameId = window.requestAnimationFrame(syncPlacement);\n    return () => window.cancelAnimationFrame(frameId);\n  }, [syncPlacement]);\n\n  useEffect(() => {\n    const updateOnFrame = () => {\n      window.requestAnimationFrame(syncPlacement);\n    };\n\n    window.addEventListener('resize', updateOnFrame);\n    window.addEventListener('scroll', updateOnFrame, true);\n    const vv = window.visualViewport;\n    if (vv) {\n      vv.addEventListener('resize', updateOnFrame);\n      vv.addEventListener('scroll', updateOnFrame);\n    }\n\n    return () => {\n      window.removeEventListener('resize', updateOnFrame);\n      window.removeEventListener('scroll', updateOnFrame, true);\n      if (vv) {\n        vv.removeEventListener('resize', updateOnFrame);\n        vv.removeEventListener('scroll', updateOnFrame);\n      }\n    };\n  }, [syncPlacement]);\n\n  useEffect(() => {\n    const menuEl = menuRef.current;\n    if (!menuEl) return;\n\n    const observer = new ResizeObserver(() => {\n      syncPlacement();\n    });\n\n    observer.observe(anchorEl);\n    observer.observe(menuEl);\n    if (editorEl) {\n      observer.observe(editorEl);\n    }\n\n    return () => {\n      observer.disconnect();\n    };\n  }, [anchorEl, editorEl, syncPlacement]);\n\n  // Click-outside detection\n  useEffect(() => {\n    if (!onClickOutside) return;\n    const handlePointerDown = (e: PointerEvent) => {\n      const menu = menuRef.current;\n      if (menu && !menu.contains(e.target as Node)) {\n        onClickOutside();\n      }\n    };\n    document.addEventListener('pointerdown', handlePointerDown);\n    return () => document.removeEventListener('pointerdown', handlePointerDown);\n  }, [onClickOutside]);\n\n  // When side is 'top' the menu grows upward — use bottom-anchored positioning\n  // so the menu expands upward from a fixed bottom edge.\n  const style: CSSProperties = !placement\n    ? {\n        position: 'fixed',\n        visibility: 'hidden',\n      }\n    : placement.side === 'bottom'\n      ? ({\n          position: 'fixed',\n          left: placement.left,\n          top: placement.top,\n          '--typeahead-menu-max-height': `${placement.maxHeight}px`,\n        } as CSSProperties)\n      : ({\n          position: 'fixed',\n          left: placement.left,\n          bottom: getViewportHeight() - placement.top,\n          '--typeahead-menu-max-height': `${placement.maxHeight}px`,\n        } as CSSProperties);\n\n  return (\n    <div\n      ref={menuRef}\n      style={style}\n      className=\"z-[10000] w-auto min-w-80 max-w-full p-0 overflow-hidden bg-panel border border-border rounded-sm shadow-md flex flex-col\"\n    >\n      {children}\n    </div>\n  );\n}\n\nfunction TypeaheadMenuHeader({\n  children,\n  className,\n}: {\n  children: ReactNode;\n  className?: string;\n}) {\n  return (\n    <div\n      className={`px-base py-half border-b border-border ${className ?? ''}`}\n    >\n      <div className=\"flex items-center gap-half text-xs font-medium text-low\">\n        {children}\n      </div>\n    </div>\n  );\n}\n\nfunction TypeaheadMenuScrollArea({ children }: { children: ReactNode }) {\n  return (\n    <div\n      className=\"py-half overflow-auto flex-1 min-h-0\"\n      style={{ maxHeight: 'var(--typeahead-menu-max-height, 100vh)' }}\n    >\n      {children}\n    </div>\n  );\n}\n\nfunction TypeaheadMenuSectionHeader({ children }: { children: ReactNode }) {\n  return (\n    <div className=\"px-base py-half text-xs font-medium text-low\">\n      {children}\n    </div>\n  );\n}\n\nfunction TypeaheadMenuDivider() {\n  return <div className=\"h-px bg-border my-half\" />;\n}\n\nfunction TypeaheadMenuEmpty({ children }: { children: ReactNode }) {\n  return <div className=\"px-base py-half text-sm text-low\">{children}</div>;\n}\n\ninterface TypeaheadMenuActionProps {\n  onClick: () => void;\n  disabled?: boolean;\n  children: ReactNode;\n}\n\nfunction TypeaheadMenuAction({\n  onClick,\n  disabled = false,\n  children,\n}: TypeaheadMenuActionProps) {\n  return (\n    <button\n      type=\"button\"\n      className=\"w-full px-base py-half text-left text-sm text-low hover:bg-secondary hover:text-high transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n      onClick={onClick}\n      disabled={disabled}\n    >\n      {children}\n    </button>\n  );\n}\n\ninterface TypeaheadMenuItemProps {\n  isSelected: boolean;\n  index: number;\n  setHighlightedIndex: (index: number) => void;\n  onClick: () => void;\n  children: ReactNode;\n}\n\nfunction TypeaheadMenuItemComponent({\n  isSelected,\n  index,\n  setHighlightedIndex,\n  onClick,\n  children,\n}: TypeaheadMenuItemProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const lastMousePositionRef = useRef<{ x: number; y: number } | null>(null);\n\n  useEffect(() => {\n    if (isSelected && ref.current) {\n      ref.current.scrollIntoView({ block: 'nearest' });\n    }\n  }, [isSelected]);\n\n  const handleMouseMove = (event: MouseEvent<HTMLDivElement>) => {\n    const pos = { x: event.clientX, y: event.clientY };\n    const last = lastMousePositionRef.current;\n    if (!last || last.x !== pos.x || last.y !== pos.y) {\n      lastMousePositionRef.current = pos;\n      setHighlightedIndex(index);\n    }\n  };\n\n  return (\n    <div\n      ref={ref}\n      className={`px-base py-half rounded-sm cursor-pointer text-sm transition-colors ${\n        isSelected ? 'bg-secondary text-high' : 'hover:bg-secondary text-normal'\n      }`}\n      onMouseMove={handleMouseMove}\n      onClick={onClick}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport const TypeaheadMenu = Object.assign(TypeaheadMenuRoot, {\n  Header: TypeaheadMenuHeader,\n  ScrollArea: TypeaheadMenuScrollArea,\n  SectionHeader: TypeaheadMenuSectionHeader,\n  Divider: TypeaheadMenuDivider,\n  Empty: TypeaheadMenuEmpty,\n  Action: TypeaheadMenuAction,\n  Item: TypeaheadMenuItemComponent,\n});\n"
  },
  {
    "path": "packages/ui/src/components/TypeaheadOpenContext.tsx",
    "content": "import { createContext, useContext, useState, ReactNode } from 'react';\n\nexport type TypeaheadOpenContextType = {\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n};\n\nexport const TypeaheadOpenContext = createContext<\n  TypeaheadOpenContextType | undefined\n>(undefined);\n\nexport function TypeaheadOpenProvider({ children }: { children: ReactNode }) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <TypeaheadOpenContext.Provider value={{ isOpen, setIsOpen }}>\n      {children}\n    </TypeaheadOpenContext.Provider>\n  );\n}\n\nexport function useTypeaheadOpen() {\n  const context = useContext(TypeaheadOpenContext);\n  if (context === undefined) {\n    throw new Error(\n      'useTypeaheadOpen must be used within a TypeaheadOpenProvider'\n    );\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/ui/src/components/UserAvatar.tsx",
    "content": "'use client';\n\nimport { cn } from '../lib/cn';\nimport { Tooltip } from './Tooltip';\n\nexport type UserAvatarUser = {\n  first_name?: string | null;\n  last_name?: string | null;\n  username?: string | null;\n  avatar_url?: string | null;\n};\n\nexport interface UserAvatarProps {\n  user: UserAvatarUser;\n  className?: string;\n}\n\nconst buildOptimizedImageUrl = (rawUrl: string): string => {\n  try {\n    const url = new URL(rawUrl);\n    url.searchParams.set('width', '64');\n    url.searchParams.set('height', '64');\n    url.searchParams.set('fit', 'crop');\n    url.searchParams.set('quality', '80');\n    return url.toString();\n  } catch {\n    const separator = rawUrl.includes('?') ? '&' : '?';\n    return `${rawUrl}${separator}width=64&height=64&fit=crop&quality=80`;\n  }\n};\n\nconst buildInitials = (user: UserAvatarUser): string => {\n  const first = user.first_name?.trim().charAt(0)?.toUpperCase() ?? '';\n  const last = user.last_name?.trim().charAt(0)?.toUpperCase() ?? '';\n\n  if (first || last) {\n    return `${first}${last}`.trim() || first || last || '?';\n  }\n\n  const handle = user.username?.trim().charAt(0)?.toUpperCase();\n  return handle ?? '?';\n};\n\nconst buildLabel = (user: UserAvatarUser): string => {\n  const name = [user.first_name, user.last_name]\n    .filter((value): value is string => Boolean(value && value.trim()))\n    .join(' ');\n\n  if (name) return name;\n  if (user.username?.trim()) return user.username;\n  return 'User';\n};\n\n// Helper to handle image error by hiding img and showing fallback via DOM\nconst handleImageError = (event: React.SyntheticEvent<HTMLImageElement>) => {\n  const img = event.currentTarget;\n  img.style.display = 'none';\n  const fallback = img.nextElementSibling;\n  if (fallback instanceof HTMLElement) {\n    fallback.style.display = 'flex';\n  }\n};\n\nexport const UserAvatar = ({ user, className }: UserAvatarProps) => {\n  const initials = buildInitials(user);\n  const label = buildLabel(user);\n  const imageUrl = user.avatar_url\n    ? buildOptimizedImageUrl(user.avatar_url)\n    : null;\n\n  return (\n    <Tooltip content={label}>\n      <div\n        className={cn(\n          'flex size-icon-base shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-secondary text-xs font-medium text-low',\n          className\n        )}\n        aria-label={label}\n      >\n        {imageUrl && (\n          <img\n            src={imageUrl}\n            alt={label}\n            className=\"h-full w-full object-cover\"\n            loading=\"lazy\"\n            onError={handleImageError}\n          />\n        )}\n        <span style={imageUrl ? { display: 'none' } : undefined}>\n          {initials}\n        </span>\n      </div>\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/components/ViewNavTabs.tsx",
    "content": "'use client';\n\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { ButtonGroup, ButtonGroupItem } from './IconButtonGroup';\n\nexport type ViewNavMode = 'kanban' | 'list';\n\nexport type ViewNavStatus = {\n  id: string;\n  name: string;\n};\n\nexport interface ViewNavTabsProps {\n  activeView: ViewNavMode;\n  onViewChange: (view: ViewNavMode) => void;\n  hiddenStatuses: ViewNavStatus[];\n  selectedStatusId: string | null;\n  onStatusSelect: (statusId: string | null) => void;\n  className?: string;\n}\n\nexport function ViewNavTabs({\n  activeView,\n  onViewChange,\n  hiddenStatuses,\n  selectedStatusId,\n  onStatusSelect,\n  className,\n}: ViewNavTabsProps) {\n  const { t } = useTranslation('common');\n  const isActiveTab = activeView === 'kanban';\n  const isAllTab = activeView === 'list' && selectedStatusId === null;\n\n  return (\n    <div className=\"flex min-w-0 flex-wrap items-center gap-base\">\n      <ButtonGroup className={cn('flex-wrap', className)}>\n        {/* Active (Kanban) tab */}\n        <ButtonGroupItem\n          active={isActiveTab}\n          onClick={() => {\n            onViewChange('kanban');\n            onStatusSelect(null);\n          }}\n        >\n          {t('kanban.viewTabs.active')}\n        </ButtonGroupItem>\n\n        {/* All (List) tab */}\n        <ButtonGroupItem\n          active={isAllTab}\n          onClick={() => {\n            onViewChange('list');\n            onStatusSelect(null);\n          }}\n        >\n          {t('kanban.viewTabs.all')}\n        </ButtonGroupItem>\n\n        {/* Hidden status tabs */}\n        {hiddenStatuses.map((status) => {\n          const isStatusActive =\n            activeView === 'list' && selectedStatusId === status.id;\n          return (\n            <ButtonGroupItem\n              key={status.id}\n              active={isStatusActive}\n              onClick={() => {\n                onViewChange('list');\n                onStatusSelect(status.id);\n              }}\n            >\n              {status.name}\n            </ButtonGroupItem>\n          );\n        })}\n      </ButtonGroup>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/WorkspaceContext.tsx",
    "content": "import { createContext, useContext } from 'react';\n\nexport const WorkspaceContext = createContext<string | undefined>(undefined);\nexport const SessionContext = createContext<string | undefined>(undefined);\n\nexport function useWorkspaceId() {\n  return useContext(WorkspaceContext);\n}\n\nexport function useSessionId() {\n  return useContext(SessionContext);\n}\n\n// Local attachment metadata for rendering uploaded attachments before they're saved\nexport type LocalAttachmentMetadata = {\n  path: string; // \".vibe-attachments/uuid.png\"\n  proxy_url: string; // \"/api/attachments/{id}/file\"\n  file_name: string;\n  size_bytes: number;\n  format: string;\n  mime_type: string;\n  is_pending?: boolean;\n  pending_status?: 'hashing' | 'uploading' | 'confirming';\n  upload_progress?: number;\n};\n\nexport const LocalAttachmentsContext = createContext<LocalAttachmentMetadata[]>(\n  []\n);\n\nexport function useLocalAttachments() {\n  return useContext(LocalAttachmentsContext);\n}\n"
  },
  {
    "path": "packages/ui/src/components/WorkspaceSummary.tsx",
    "content": "import {\n  PushPinIcon,\n  HandIcon,\n  TriangleIcon,\n  PlayIcon,\n  FileIcon,\n  CircleIcon,\n  GitPullRequestIcon,\n  DotsThreeIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { RunningDots } from './RunningDots';\n\nconst formatRelativeElapsed = (dateString: string): string => {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffSecs = Math.floor(diffMs / 1000);\n  const diffMins = Math.floor(diffSecs / 60);\n  const diffHours = Math.floor(diffMins / 60);\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffSecs < 60) return 'just now';\n  if (diffMins < 60) return `${diffMins}m ago`;\n  if (diffHours < 24) return `${diffHours}h ago`;\n  return `${diffDays}d ago`;\n};\n\nexport interface WorkspaceSummaryProps {\n  name: string;\n  workspaceId?: string;\n  filesChanged?: number;\n  linesAdded?: number;\n  linesRemoved?: number;\n  isActive?: boolean;\n  isRunning?: boolean;\n  isPinned?: boolean;\n  hasPendingApproval?: boolean;\n  hasRunningDevServer?: boolean;\n  hasUnseenActivity?: boolean;\n  latestProcessCompletedAt?: string;\n  latestProcessStatus?: 'running' | 'completed' | 'failed' | 'killed';\n  prStatus?: 'open' | 'merged' | 'closed' | 'unknown';\n  onClick?: () => void;\n  className?: string;\n  summary?: boolean;\n  /** Whether this is a draft workspace (shows \"Draft\" instead of elapsed time) */\n  isDraft?: boolean;\n  onOpenWorkspaceActions?: (workspaceId: string) => void;\n}\n\nexport function WorkspaceSummary({\n  name,\n  workspaceId,\n  filesChanged,\n  linesAdded,\n  linesRemoved,\n  isActive = false,\n  isRunning = false,\n  isPinned = false,\n  hasPendingApproval = false,\n  hasRunningDevServer = false,\n  hasUnseenActivity = false,\n  latestProcessCompletedAt,\n  latestProcessStatus,\n  prStatus,\n  onClick,\n  className,\n  summary = false,\n  isDraft = false,\n  onOpenWorkspaceActions,\n}: WorkspaceSummaryProps) {\n  const { t } = useTranslation('common');\n  const hasChanges = filesChanged !== undefined && filesChanged > 0;\n  const isFailed =\n    latestProcessStatus === 'failed' || latestProcessStatus === 'killed';\n\n  const handleOpenCommandBar = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    if (!workspaceId || !onOpenWorkspaceActions) return;\n    onOpenWorkspaceActions(workspaceId);\n  };\n\n  return (\n    <div\n      className={cn(\n        'group relative rounded-sm transition-all duration-100 overflow-hidden',\n        isActive ? 'bg-tertiary' : '',\n        className\n      )}\n    >\n      {/* Selection indicator - thin colored tab on the left */}\n      <div\n        className={cn(\n          'absolute left-0 top-1 bottom-1 w-0.5 rounded-full transition-colors duration-100',\n          isActive ? 'bg-brand' : 'bg-transparent'\n        )}\n      />\n      <button\n        onClick={onClick}\n        className={cn(\n          'flex w-full cursor-pointer flex-col text-left px-base py-half transition-all duration-150',\n          isActive\n            ? 'text-normal'\n            : 'text-low sm:opacity-60 sm:hover:opacity-100 sm:hover:text-normal'\n        )}\n      >\n        <div\n          className={cn(\n            'overflow-hidden whitespace-nowrap pr-double',\n            !summary && 'text-normal'\n          )}\n          style={{\n            maskImage:\n              'linear-gradient(to right, black calc(100% - 24px), transparent 100%)',\n            WebkitMaskImage:\n              'linear-gradient(to right, black calc(100% - 24px), transparent 100%)',\n          }}\n        >\n          {name}\n        </div>\n        {(!summary || isActive) && (\n          <div className=\"flex w-full items-center gap-base text-sm h-5\">\n            {/* Dev server running - leftmost */}\n            {hasRunningDevServer && (\n              <PlayIcon\n                className=\"size-icon-xs text-brand shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n\n            {/* Failed/killed status (only when not running) */}\n            {!isRunning && isFailed && (\n              <TriangleIcon\n                className=\"size-icon-xs text-error shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n\n            {/* Running dots OR hand icon for pending approval */}\n            {isRunning &&\n              (hasPendingApproval ? (\n                <HandIcon\n                  className=\"size-icon-xs text-brand shrink-0\"\n                  weight=\"fill\"\n                />\n              ) : (\n                <RunningDots />\n              ))}\n\n            {/* Unseen activity indicator (only when not running and not failed) */}\n            {hasUnseenActivity && !isRunning && !isFailed && (\n              <CircleIcon\n                className=\"size-icon-xs text-brand shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n\n            {/* PR status icon */}\n            {prStatus === 'open' && (\n              <GitPullRequestIcon\n                className=\"size-icon-xs text-success shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n            {prStatus === 'merged' && (\n              <GitPullRequestIcon\n                className=\"size-icon-xs text-merged shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n\n            {/* Pin icon */}\n            {isPinned && (\n              <PushPinIcon\n                className=\"size-icon-xs text-brand shrink-0\"\n                weight=\"fill\"\n              />\n            )}\n\n            {/* Time elapsed OR \"Draft\" label (when not running) */}\n            {!isRunning &&\n              (isDraft ? (\n                <span className=\"min-w-0 flex-1 truncate\">\n                  {t('workspaces.draft')}\n                </span>\n              ) : latestProcessCompletedAt ? (\n                <span className=\"min-w-0 flex-1 truncate\">\n                  {formatRelativeElapsed(latestProcessCompletedAt)}\n                </span>\n              ) : (\n                <span className=\"flex-1\" />\n              ))}\n\n            {/* Spacer when running (no elapsed time shown) */}\n            {isRunning && <span className=\"flex-1\" />}\n\n            {/* File count + lines changed on the right */}\n            {hasChanges && (\n              <span className=\"shrink-0 text-right flex items-center gap-half\">\n                <FileIcon className=\"size-icon-xs\" weight=\"fill\" />\n                <span>{filesChanged}</span>\n                {linesAdded !== undefined && (\n                  <span className=\"text-success\">+{linesAdded}</span>\n                )}\n                {linesRemoved !== undefined && (\n                  <span className=\"text-error\">-{linesRemoved}</span>\n                )}\n              </span>\n            )}\n          </div>\n        )}\n      </button>\n\n      {/* Right-side hover action - more options only */}\n      {workspaceId && onOpenWorkspaceActions && (\n        <div className=\"absolute right-0 top-0 bottom-0 flex items-center sm:opacity-0 sm:group-hover:opacity-100\">\n          {/* Gradient fade from transparent to background */}\n          <div className=\"h-full w-6 pointer-events-none bg-gradient-to-r from-transparent to-secondary\" />\n          {/* Single action button */}\n          <div className=\"flex items-center pr-base h-full bg-secondary\">\n            <button\n              onClick={handleOpenCommandBar}\n              onPointerDown={(e) => e.stopPropagation()}\n              className=\"p-1.5 rounded-sm text-low hover:text-normal hover:bg-tertiary\"\n              title={t('workspaces.more')}\n            >\n              <DotsThreeIcon className=\"size-5\" weight=\"bold\" />\n            </button>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/WorkspacesMain.tsx",
    "content": "import type { ReactNode, RefObject } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { ArrowDownIcon, SpinnerIcon } from '@phosphor-icons/react';\nimport { cn } from '../lib/cn';\n\nexport interface WorkspacesMainWorkspace {\n  id: string;\n}\n\ninterface WorkspacesMainProps {\n  workspaceWithSession: WorkspacesMainWorkspace | undefined;\n  isLoading: boolean;\n  containerRef: RefObject<HTMLElement>;\n  conversationContent?: ReactNode;\n  chatBoxContent: ReactNode;\n  contextBarContent?: ReactNode;\n  isAtBottom?: boolean;\n  onAtBottomChange?: (atBottom: boolean) => void;\n  onScrollToBottom?: (behavior?: 'auto' | 'smooth') => void;\n  isMobile?: boolean;\n}\n\nexport function WorkspacesMain({\n  workspaceWithSession,\n  isLoading,\n  containerRef,\n  conversationContent,\n  chatBoxContent,\n  contextBarContent,\n  isAtBottom = true,\n  onScrollToBottom,\n  isMobile,\n}: WorkspacesMainProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n\n  // Always render the main structure to prevent chat box flash during workspace transitions\n  return (\n    <main\n      ref={containerRef}\n      className={cn(\n        'relative flex flex-1 flex-col bg-primary',\n        isMobile ? 'min-h-0' : 'h-full'\n      )}\n    >\n      {/* Conversation content - conditional based on loading/workspace state */}\n      {isLoading ? (\n        <div className=\"flex-1 flex items-center justify-center\">\n          <SpinnerIcon className=\"size-6 animate-spin text-low\" />\n        </div>\n      ) : !workspaceWithSession ? (\n        <div className=\"flex-1 flex items-center justify-center\">\n          <p className=\"text-low\">{t('common:workspaces.selectToStart')}</p>\n        </div>\n      ) : (\n        conversationContent\n      )}\n      {/* Scroll to bottom button */}\n      {workspaceWithSession && !isAtBottom && (\n        <div className=\"flex justify-center pointer-events-none\">\n          <div className=\"w-chat max-w-full relative\">\n            <button\n              type=\"button\"\n              onClick={() => onScrollToBottom?.('auto')}\n              className=\"absolute bottom-2 right-4 z-10 pointer-events-auto flex items-center justify-center size-8 rounded-full bg-secondary/80 backdrop-blur-sm border border-secondary text-low hover:text-normal hover:bg-secondary shadow-md transition-all\"\n              aria-label=\"Scroll to bottom\"\n              title=\"Scroll to bottom\"\n            >\n              <ArrowDownIcon className=\"size-icon-base\" weight=\"bold\" />\n            </button>\n          </div>\n        </div>\n      )}\n      {/* Chat box - always rendered to prevent flash during workspace switch */}\n      <div\n        className=\"flex justify-center @container pl-px\"\n        data-chatbox-container=\"true\"\n      >\n        {chatBoxContent}\n      </div>\n      {/* Context Bar - floating toolbar */}\n      {workspaceWithSession ? contextBarContent : null}\n    </main>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/WorkspacesSidebar.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { useCallback, useMemo, useRef } from 'react';\nimport {\n  PlusIcon,\n  ArrowLeftIcon,\n  ArchiveIcon,\n  StackIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\nimport { InputField } from './InputField';\nimport { WorkspaceSummary } from './WorkspaceSummary';\nimport {\n  CollapsibleSectionHeader,\n  type SectionAction,\n} from './CollapsibleSectionHeader';\n\nexport type WorkspaceLayoutMode = 'flat' | 'accordion';\n\nexport interface WorkspacesSidebarWorkspace {\n  id: string;\n  name: string;\n  filesChanged?: number;\n  linesAdded?: number;\n  linesRemoved?: number;\n  isRunning?: boolean;\n  isPinned?: boolean;\n  hasPendingApproval?: boolean;\n  hasRunningDevServer?: boolean;\n  hasUnseenActivity?: boolean;\n  latestProcessCompletedAt?: string;\n  latestProcessStatus?: 'running' | 'completed' | 'failed' | 'killed';\n  prStatus?: 'open' | 'merged' | 'closed' | 'unknown';\n}\n\nexport interface WorkspacesSidebarPersistKeys {\n  raisedHand: string;\n  notRunning: string;\n  running: string;\n}\n\nconst DEFAULT_PERSIST_KEYS: WorkspacesSidebarPersistKeys = {\n  raisedHand: 'workspaces-sidebar-raised-hand',\n  notRunning: 'workspaces-sidebar-not-running',\n  running: 'workspaces-sidebar-running',\n};\n\nexport interface WorkspacesSidebarProps {\n  workspaces: WorkspacesSidebarWorkspace[];\n  totalWorkspacesCount: number;\n  archivedWorkspaces?: WorkspacesSidebarWorkspace[];\n  selectedWorkspaceId: string | null;\n  onSelectWorkspace: (id: string) => void;\n  onAddWorkspace?: () => void;\n  searchQuery: string;\n  onSearchChange: (value: string) => void;\n  /** Whether we're in create mode */\n  isCreateMode?: boolean;\n  /** Title extracted from draft message (only shown when isCreateMode and non-empty) */\n  draftTitle?: string;\n  /** Handler to navigate back to create mode */\n  onSelectCreate?: () => void;\n  /** Whether to show archived workspaces */\n  showArchive?: boolean;\n  /** Handler for toggling archive view */\n  onShowArchiveChange?: (show: boolean) => void;\n  /** Layout mode for active workspaces */\n  layoutMode?: WorkspaceLayoutMode;\n  /** Handler for toggling layout mode */\n  onToggleLayoutMode?: () => void;\n  /** Handler to load more workspaces on scroll */\n  onLoadMore?: () => void;\n  /** Whether there are more workspaces to load */\n  hasMoreWorkspaces?: boolean;\n  /** Controls rendered beside the search input */\n  searchControls?: ReactNode;\n  /** Callback for opening workspace actions */\n  onOpenWorkspaceActions?: (workspaceId: string) => void;\n  /** Persist keys for collapsible sections */\n  persistKeys?: WorkspacesSidebarPersistKeys;\n}\n\nexport interface WorkspacesSidebarReopenTagProps {\n  active?: boolean;\n  onHoverStart?: () => void;\n  onHoverEnd?: () => void;\n  ariaLabel?: string;\n  className?: string;\n}\n\nexport function WorkspacesSidebarReopenTag({\n  active = false,\n  onHoverStart,\n  onHoverEnd,\n  ariaLabel,\n  className,\n}: WorkspacesSidebarReopenTagProps) {\n  return (\n    <button\n      type=\"button\"\n      onMouseEnter={onHoverStart}\n      onMouseLeave={onHoverEnd}\n      aria-label={ariaLabel ?? 'Preview workspaces sidebar'}\n      title={ariaLabel ?? 'Preview workspaces sidebar'}\n      className={cn(\n        'group inline-flex h-24 w-4 items-center justify-center rounded-md border border-border bg-secondary/95 shadow-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 cursor-e-resize',\n        active ? 'bg-panel text-normal' : 'text-low hover:text-normal',\n        className\n      )}\n    >\n      <span className=\"grid grid-cols-2 gap-[2px]\">\n        <span className=\"size-dot rounded-full bg-low/70 group-hover:bg-low\" />\n        <span className=\"size-dot rounded-full bg-low/70 group-hover:bg-low\" />\n        <span className=\"size-dot rounded-full bg-low/70 group-hover:bg-low\" />\n        <span className=\"size-dot rounded-full bg-low/70 group-hover:bg-low\" />\n        <span className=\"size-dot rounded-full bg-low/70 group-hover:bg-low\" />\n        <span className=\"size-dot rounded-full bg-low/70 group-hover:bg-low\" />\n      </span>\n    </button>\n  );\n}\n\nfunction WorkspaceList({\n  workspaces,\n  selectedWorkspaceId,\n  onSelectWorkspace,\n  onOpenWorkspaceActions,\n}: {\n  workspaces: WorkspacesSidebarWorkspace[];\n  selectedWorkspaceId: string | null;\n  onSelectWorkspace: (id: string) => void;\n  onOpenWorkspaceActions: (workspaceId: string) => void;\n}) {\n  return (\n    <>\n      {workspaces.map((workspace) => (\n        <WorkspaceSummary\n          key={workspace.id}\n          name={workspace.name}\n          workspaceId={workspace.id}\n          filesChanged={workspace.filesChanged}\n          linesAdded={workspace.linesAdded}\n          linesRemoved={workspace.linesRemoved}\n          isActive={selectedWorkspaceId === workspace.id}\n          isRunning={workspace.isRunning}\n          isPinned={workspace.isPinned}\n          hasPendingApproval={workspace.hasPendingApproval}\n          hasRunningDevServer={workspace.hasRunningDevServer}\n          hasUnseenActivity={workspace.hasUnseenActivity}\n          latestProcessCompletedAt={workspace.latestProcessCompletedAt}\n          latestProcessStatus={workspace.latestProcessStatus}\n          prStatus={workspace.prStatus}\n          onOpenWorkspaceActions={onOpenWorkspaceActions}\n          onClick={() => onSelectWorkspace(workspace.id)}\n        />\n      ))}\n    </>\n  );\n}\n\nexport function WorkspacesSidebar({\n  workspaces,\n  totalWorkspacesCount,\n  archivedWorkspaces = [],\n  selectedWorkspaceId,\n  onSelectWorkspace,\n  onAddWorkspace,\n  searchQuery,\n  onSearchChange,\n  isCreateMode = false,\n  draftTitle,\n  onSelectCreate,\n  showArchive = false,\n  onShowArchiveChange,\n  layoutMode = 'flat',\n  onToggleLayoutMode,\n  onLoadMore,\n  hasMoreWorkspaces = false,\n  searchControls,\n  onOpenWorkspaceActions,\n  persistKeys = DEFAULT_PERSIST_KEYS,\n}: WorkspacesSidebarProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n  const handleOpenWorkspaceActions = useCallback(\n    (workspaceId: string) => {\n      onOpenWorkspaceActions?.(workspaceId);\n    },\n    [onOpenWorkspaceActions]\n  );\n\n  // Handle scroll to load more\n  const handleScroll = () => {\n    if (!hasMoreWorkspaces || !onLoadMore) return;\n\n    const container = scrollContainerRef.current;\n    if (!container) return;\n\n    const { scrollTop, scrollHeight, clientHeight } = container;\n    // Load more when scrolled within 100px of the bottom\n    if (scrollHeight - scrollTop - clientHeight < 100) {\n      onLoadMore();\n    }\n  };\n\n  // Categorize workspaces for accordion layout\n  const { raisedHandWorkspaces, idleWorkspaces, runningWorkspaces } =\n    useMemo(() => {\n      // Running workspaces should stay in the \"Running\" section even if unseen.\n      const needsAttention = (ws: WorkspacesSidebarWorkspace) =>\n        ws.hasPendingApproval || (ws.hasUnseenActivity && !ws.isRunning);\n\n      return {\n        raisedHandWorkspaces: workspaces.filter((ws) => needsAttention(ws)),\n        idleWorkspaces: workspaces.filter(\n          (ws) => !ws.isRunning && !needsAttention(ws)\n        ),\n        runningWorkspaces: workspaces.filter(\n          (ws) => ws.isRunning && !needsAttention(ws)\n        ),\n      };\n    }, [workspaces]);\n\n  const headerActions: SectionAction[] = [\n    {\n      icon: StackIcon,\n      onClick: () => onToggleLayoutMode?.(),\n      isActive: layoutMode === 'accordion',\n    },\n    {\n      icon: PlusIcon,\n      onClick: () => onAddWorkspace?.(),\n    },\n  ];\n\n  return (\n    <div className=\"w-full h-full bg-secondary flex flex-col\">\n      {/* Header + Search */}\n      <div className=\"flex flex-col gap-base\">\n        <CollapsibleSectionHeader\n          title={t('common:workspaces.title')}\n          collapsible={false}\n          actions={headerActions}\n          className=\"border-b\"\n        />\n        <div className=\"px-base flex items-stretch gap-half\">\n          <div className=\"flex-1 min-w-0\">\n            <InputField\n              variant=\"search\"\n              value={searchQuery}\n              onChange={onSearchChange}\n              placeholder={t('common:workspaces.searchPlaceholder')}\n            />\n          </div>\n          {searchControls}\n        </div>\n      </div>\n\n      {/* Scrollable workspace list */}\n      <div\n        ref={scrollContainerRef}\n        onScroll={handleScroll}\n        className=\"flex-1 overflow-y-auto py-base\"\n      >\n        {showArchive ? (\n          /* Archived workspaces view */\n          <div className=\"flex flex-col gap-base\">\n            <span className=\"text-sm font-medium text-low px-base\">\n              {t('common:workspaces.archived')}\n            </span>\n            {archivedWorkspaces.length === 0 ? (\n              <span className=\"text-sm text-low opacity-60 px-base\">\n                {t('common:workspaces.noArchived')}\n              </span>\n            ) : (\n              archivedWorkspaces.map((workspace) => (\n                <WorkspaceSummary\n                  summary\n                  key={workspace.id}\n                  name={workspace.name}\n                  workspaceId={workspace.id}\n                  filesChanged={workspace.filesChanged}\n                  linesAdded={workspace.linesAdded}\n                  linesRemoved={workspace.linesRemoved}\n                  isActive={selectedWorkspaceId === workspace.id}\n                  isRunning={workspace.isRunning}\n                  isPinned={workspace.isPinned}\n                  hasPendingApproval={workspace.hasPendingApproval}\n                  hasRunningDevServer={workspace.hasRunningDevServer}\n                  hasUnseenActivity={workspace.hasUnseenActivity}\n                  latestProcessCompletedAt={workspace.latestProcessCompletedAt}\n                  latestProcessStatus={workspace.latestProcessStatus}\n                  prStatus={workspace.prStatus}\n                  onOpenWorkspaceActions={handleOpenWorkspaceActions}\n                  onClick={() => onSelectWorkspace(workspace.id)}\n                />\n              ))\n            )}\n          </div>\n        ) : layoutMode === 'accordion' ? (\n          /* Accordion layout view */\n          <div className=\"flex flex-col gap-base\">\n            {/* Needs Attention section */}\n            <CollapsibleSectionHeader\n              title={t('common:workspaces.needsAttention')}\n              persistKey={persistKeys.raisedHand}\n              defaultExpanded={true}\n            >\n              <div className=\"flex flex-col gap-base py-half\">\n                {draftTitle && (\n                  <WorkspaceSummary\n                    name={draftTitle}\n                    isActive={isCreateMode}\n                    isDraft={true}\n                    onClick={onSelectCreate}\n                  />\n                )}\n                {raisedHandWorkspaces.length === 0 && !draftTitle ? (\n                  <span className=\"text-sm text-low opacity-60 pl-base\">\n                    {t('common:workspaces.noWorkspaces')}\n                  </span>\n                ) : (\n                  <WorkspaceList\n                    workspaces={raisedHandWorkspaces}\n                    selectedWorkspaceId={selectedWorkspaceId}\n                    onSelectWorkspace={onSelectWorkspace}\n                    onOpenWorkspaceActions={handleOpenWorkspaceActions}\n                  />\n                )}\n              </div>\n            </CollapsibleSectionHeader>\n\n            {/* Running section */}\n            <CollapsibleSectionHeader\n              title={t('common:workspaces.running')}\n              persistKey={persistKeys.running}\n              defaultExpanded={true}\n            >\n              <div className=\"flex flex-col gap-base py-half\">\n                {runningWorkspaces.length === 0 ? (\n                  <span className=\"text-sm text-low opacity-60 pl-base\">\n                    {t('common:workspaces.noWorkspaces')}\n                  </span>\n                ) : (\n                  <WorkspaceList\n                    workspaces={runningWorkspaces}\n                    selectedWorkspaceId={selectedWorkspaceId}\n                    onSelectWorkspace={onSelectWorkspace}\n                    onOpenWorkspaceActions={handleOpenWorkspaceActions}\n                  />\n                )}\n              </div>\n            </CollapsibleSectionHeader>\n\n            {/* Idle section */}\n            <CollapsibleSectionHeader\n              title={t('common:workspaces.idle')}\n              persistKey={persistKeys.notRunning}\n              defaultExpanded={true}\n            >\n              <div className=\"flex flex-col gap-base py-half\">\n                {idleWorkspaces.length === 0 ? (\n                  <span className=\"text-sm text-low opacity-60 pl-base\">\n                    {t('common:workspaces.noWorkspaces')}\n                  </span>\n                ) : (\n                  <WorkspaceList\n                    workspaces={idleWorkspaces}\n                    selectedWorkspaceId={selectedWorkspaceId}\n                    onSelectWorkspace={onSelectWorkspace}\n                    onOpenWorkspaceActions={handleOpenWorkspaceActions}\n                  />\n                )}\n              </div>\n            </CollapsibleSectionHeader>\n          </div>\n        ) : (\n          /* Active workspaces flat view */\n          <div className=\"flex flex-col gap-base\">\n            <div className=\"flex items-center justify-between px-base\">\n              <span className=\"text-sm font-medium text-low\">\n                {t('common:workspaces.active')}\n              </span>\n              <span className=\"text-xs text-low\">{totalWorkspacesCount}</span>\n            </div>\n            {draftTitle && (\n              <WorkspaceSummary\n                name={draftTitle}\n                isActive={isCreateMode}\n                isDraft={true}\n                onClick={onSelectCreate}\n              />\n            )}\n            {workspaces.map((workspace) => (\n              <WorkspaceSummary\n                key={workspace.id}\n                name={workspace.name}\n                workspaceId={workspace.id}\n                filesChanged={workspace.filesChanged}\n                linesAdded={workspace.linesAdded}\n                linesRemoved={workspace.linesRemoved}\n                isActive={selectedWorkspaceId === workspace.id}\n                isRunning={workspace.isRunning}\n                isPinned={workspace.isPinned}\n                hasPendingApproval={workspace.hasPendingApproval}\n                hasRunningDevServer={workspace.hasRunningDevServer}\n                hasUnseenActivity={workspace.hasUnseenActivity}\n                latestProcessCompletedAt={workspace.latestProcessCompletedAt}\n                latestProcessStatus={workspace.latestProcessStatus}\n                prStatus={workspace.prStatus}\n                onOpenWorkspaceActions={handleOpenWorkspaceActions}\n                onClick={() => onSelectWorkspace(workspace.id)}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* Fixed footer toggle - only show if there are archived workspaces */}\n      <div className=\"border-t border-primary p-base\">\n        <button\n          onClick={() => onShowArchiveChange?.(!showArchive)}\n          className=\"w-full flex items-center gap-base text-sm text-low hover:text-normal transition-colors duration-100\"\n        >\n          {showArchive ? (\n            <>\n              <ArrowLeftIcon className=\"size-icon-xs\" />\n              <span>{t('common:workspaces.backToActive')}</span>\n            </>\n          ) : (\n            <>\n              <ArchiveIcon className=\"size-icon-xs\" />\n              <span>{t('common:workspaces.viewArchive')}</span>\n              <span className=\"ml-auto text-xs bg-tertiary px-1.5 py-0.5 rounded\">\n                {archivedWorkspaces.length}\n              </span>\n            </>\n          )}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/components/attachment-node.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { NodeKey, SerializedLexicalNode, Spread, $getNodeByKey } from 'lexical';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { Download, File, HelpCircle, X } from 'lucide-react';\nimport {\n  useWorkspaceId,\n  useSessionId,\n  useLocalAttachments,\n  type LocalAttachmentMetadata,\n} from './WorkspaceContext';\nimport {\n  createDecoratorNode,\n  type DecoratorNodeConfig,\n} from './create-decorator-node';\n\nconst ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;\n\ntype AttachmentType = 'file' | 'thumbnail';\n\ninterface AttachmentUrlResult {\n  url: string | null;\n}\n\ninterface AttachmentMetadataLike {\n  exists: boolean;\n  file_name?: string | null;\n  size_bytes?: bigint | null;\n  format?: string | null;\n  proxy_url?: string | null;\n}\n\nexport interface CreateAttachmentNodeOptions {\n  fetchAttachmentUrl: (\n    attachmentId: string,\n    type: AttachmentType\n  ) => Promise<string>;\n}\n\nexport interface AttachmentData {\n  src: string;\n  label: string;\n}\n\nexport type SerializedAttachmentNode = Spread<\n  {\n    src: string;\n    label: string;\n  },\n  SerializedLexicalNode\n>;\n\nfunction truncatePath(path: string, maxLength = 24): string {\n  const filename = path.split('/').pop() || path;\n  if (filename.length <= maxLength) return filename;\n  return filename.slice(0, maxLength - 3) + '...';\n}\n\nfunction formatFileSize(bytes: bigint | number | null | undefined): string {\n  if (!bytes) return '';\n  const num = Number(bytes);\n  if (num < 1024) return `${num} B`;\n  if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`;\n  return `${(num / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction inferFormat(\n  label: string,\n  explicitFormat?: string | null\n): string | null {\n  if (explicitFormat) {\n    return explicitFormat.toUpperCase();\n  }\n\n  const extension = label.split('.').pop()?.trim();\n  if (!extension || extension === label) {\n    return null;\n  }\n\n  return extension.toUpperCase();\n}\n\nfunction toMetadataFromLocalAttachment(\n  localAttachment: LocalAttachmentMetadata | undefined\n): AttachmentMetadataLike | null {\n  if (!localAttachment) return null;\n\n  return {\n    exists: true,\n    file_name: localAttachment.file_name,\n    size_bytes: BigInt(localAttachment.size_bytes),\n    format: localAttachment.format,\n    proxy_url: localAttachment.proxy_url,\n  };\n}\n\nfunction useAttachmentMetadata(\n  workspaceId: string | undefined,\n  sessionId: string | undefined,\n  src: string,\n  localAttachments: LocalAttachmentMetadata[]\n) {\n  const isWorkspaceAttachment = src.startsWith('.vibe-attachments/');\n\n  const localAttachment = useMemo(\n    () => localAttachments.find((attachment) => attachment.path === src),\n    [localAttachments, src]\n  );\n\n  const localAttachmentMetadata = useMemo(\n    () => toMetadataFromLocalAttachment(localAttachment),\n    [localAttachment]\n  );\n\n  const shouldFetch =\n    isWorkspaceAttachment && !!workspaceId && !localAttachment;\n\n  const query = useQuery({\n    queryKey: ['attachment-metadata', workspaceId, sessionId, src],\n    queryFn: async (): Promise<AttachmentMetadataLike | null> => {\n      if (!workspaceId || !sessionId) return null;\n\n      const response = await fetch(\n        `/api/workspaces/${workspaceId}/attachments/metadata?path=${encodeURIComponent(src)}&session_id=${sessionId}`\n      );\n      const payload = await response.json();\n      return payload.data as AttachmentMetadataLike | null;\n    },\n    enabled: shouldFetch && !!sessionId,\n    staleTime: Infinity,\n  });\n\n  return {\n    data: localAttachmentMetadata ?? query.data,\n  };\n}\n\nfunction useAttachmentFileUrl(\n  attachmentId: string | null,\n  fetchAttachmentUrl: CreateAttachmentNodeOptions['fetchAttachmentUrl']\n): AttachmentUrlResult {\n  const query = useQuery({\n    queryKey: ['attachment-url', attachmentId, 'file'],\n    queryFn: () => fetchAttachmentUrl(attachmentId as string, 'file'),\n    enabled: !!attachmentId,\n    staleTime: ATTACHMENT_URL_STALE_TIME,\n  });\n\n  return {\n    url: query.data ?? null,\n  };\n}\n\nexport function createAttachmentNode(options: CreateAttachmentNodeOptions) {\n  function AttachmentComponent({\n    data,\n    nodeKey,\n    onDoubleClickEdit,\n  }: {\n    data: AttachmentData;\n    nodeKey: NodeKey;\n    onDoubleClickEdit: (event: React.MouseEvent) => void;\n  }): JSX.Element {\n    const { t } = useTranslation('common');\n    const { src, label } = data;\n    const workspaceId = useWorkspaceId();\n    const sessionId = useSessionId();\n    const localAttachments = useLocalAttachments();\n    const [editor] = useLexicalComposerContext();\n\n    const isWorkspaceAttachment = src.startsWith('.vibe-attachments/');\n    const isPendingAttachment = src.startsWith('pending-attachment://');\n    const isAttachment = isPendingAttachment || src.startsWith('attachment://');\n    const attachmentId =\n      !isPendingAttachment && isAttachment\n        ? src.replace('attachment://', '')\n        : null;\n\n    const { url: attachmentUrl } = useAttachmentFileUrl(\n      isAttachment && !isPendingAttachment ? attachmentId : null,\n      options.fetchAttachmentUrl\n    );\n\n    const { data: metadata } = useAttachmentMetadata(\n      workspaceId,\n      sessionId,\n      src,\n      localAttachments\n    );\n\n    const resolvedUrl =\n      metadata?.proxy_url ?? (isAttachment ? attachmentUrl : null);\n    const displayName = truncatePath(\n      metadata?.file_name || label || src || t('kanban.previewFile')\n    );\n    const format = inferFormat(\n      label || metadata?.file_name || src,\n      metadata?.format\n    );\n    const sizeText = formatFileSize(metadata?.size_bytes);\n    const localAttachment = localAttachments.find(\n      (attachment) => attachment.path === src\n    );\n    const metadataLine = localAttachment?.is_pending\n      ? ['Uploading', sizeText].filter(Boolean).join(' · ')\n      : metadata?.exists\n        ? format && sizeText\n          ? `${format} · ${sizeText}`\n          : format || sizeText || null\n        : null;\n\n    const openUrl = useCallback(\n      async (event: React.MouseEvent) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        let nextUrl = resolvedUrl;\n        if (!nextUrl && attachmentId) {\n          nextUrl = await options.fetchAttachmentUrl(attachmentId, 'file');\n        }\n\n        if (!nextUrl) return;\n        window.open(nextUrl, '_blank', 'noopener,noreferrer');\n      },\n      [attachmentId, resolvedUrl]\n    );\n\n    const handleDelete = useCallback(\n      (event: React.MouseEvent) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        if (!editor.isEditable()) return;\n\n        editor.update(() => {\n          const node = $getNodeByKey(nodeKey);\n          if (node) {\n            node.remove();\n          }\n        });\n      },\n      [editor, nodeKey]\n    );\n\n    const handleDownload = useCallback(\n      (event: React.MouseEvent) => {\n        openUrl(event).catch((error) => {\n          console.error('Failed to open attachment:', error);\n        });\n      },\n      [openUrl]\n    );\n\n    const icon =\n      isWorkspaceAttachment || isAttachment ? (\n        <File className=\"w-5 h-5 text-muted-foreground\" />\n      ) : (\n        <HelpCircle className=\"w-5 h-5 text-muted-foreground\" />\n      );\n\n    return (\n      <span\n        className=\"group relative inline-flex items-center gap-1.5 pl-1.5 pr-5 py-1 ml-0.5 mr-0.5 bg-muted rounded border cursor-pointer border-border hover:border-muted-foreground transition-colors align-bottom\"\n        onClick={(event) => {\n          openUrl(event).catch((error) => {\n            console.error('Failed to open attachment:', error);\n          });\n        }}\n        onDoubleClick={onDoubleClickEdit}\n        role=\"button\"\n        tabIndex={0}\n      >\n        <span className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n          {icon}\n        </span>\n        <span className=\"flex flex-col min-w-0\">\n          <span className=\"text-xs text-muted-foreground truncate max-w-[120px]\">\n            {displayName}\n          </span>\n          {metadataLine && (\n            <span className=\"text-[10px] text-muted-foreground/70 truncate max-w-[120px]\">\n              {metadataLine}\n            </span>\n          )}\n        </span>\n        {editor.isEditable() && (\n          <button\n            onClick={handleDelete}\n            className=\"absolute top-1 right-1 w-4 h-4 rounded-full bg-foreground/70 hover:bg-destructive flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity\"\n            aria-label={t('kanban.removeImage')}\n            type=\"button\"\n          >\n            <X className=\"w-2.5 h-2.5 text-background\" />\n          </button>\n        )}\n        {resolvedUrl && (\n          <button\n            onClick={handleDownload}\n            className={\n              editor.isEditable()\n                ? 'absolute top-1 right-6 w-4 h-4 rounded-full bg-foreground/70 hover:bg-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity'\n                : 'absolute top-1 right-1 w-4 h-4 rounded-full bg-foreground/70 hover:bg-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity'\n            }\n            aria-label={t('kanban.downloadAttachment')}\n            type=\"button\"\n          >\n            <Download className=\"w-2.5 h-2.5 text-background\" />\n          </button>\n        )}\n      </span>\n    );\n  }\n\n  const config: DecoratorNodeConfig<AttachmentData> = {\n    type: 'attachment',\n    serialization: {\n      format: 'inline',\n      pattern:\n        /(?<!!)\\[([^\\]]+)\\]\\((attachment:\\/\\/[^)]+|pending-attachment:\\/\\/[^)]+|\\.vibe-attachments\\/[^)]+)\\)/,\n      trigger: ')',\n      serialize: (data) => `[${data.label}](${data.src})`,\n      deserialize: (match) => ({ src: match[2], label: match[1] }),\n    },\n    component: AttachmentComponent,\n    domStyle: {\n      display: 'inline-block',\n      paddingLeft: '2px',\n      paddingRight: '2px',\n      verticalAlign: 'bottom',\n    },\n    keyboardSelectable: false,\n    exportDOM: (data) => {\n      const link = document.createElement('a');\n      link.setAttribute('href', data.src);\n      link.textContent = data.label;\n      return link;\n    },\n  };\n\n  const result = createDecoratorNode(config);\n\n  return {\n    AttachmentNode: result.Node,\n    $createAttachmentNode: (src: string, label: string) =>\n      result.createNode({ src, label }),\n    $isAttachmentNode: result.isNode,\n    ATTACHMENT_TRANSFORMER: result.transformers[0],\n  };\n}\n"
  },
  {
    "path": "packages/ui/src/components/component-info-node.tsx",
    "content": "import { NodeKey, SerializedLexicalNode, Spread } from 'lexical';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from './RadixTooltip';\nimport {\n  createDecoratorNode,\n  type DecoratorNodeConfig,\n  type GeneratedDecoratorNode,\n} from './create-decorator-node';\n\n/**\n * Data model for a detected UI component.\n * Serialized as JSON inside a ```vk-component fenced code block.\n */\nexport interface ComponentInfoData {\n  framework: string; // 'react', 'vue', 'svelte', 'astro', 'html'\n  component: string; // Component name: 'Button', 'UserProfile'\n  tagName?: string; // HTML tag: 'button', 'div', 'span'\n  file?: string; // File path: 'src/components/Button.tsx'\n  line?: number; // Line number\n  column?: number; // Column number\n  cssClass?: string; // CSS class: '.btn-primary'\n  stack?: Array<{ name: string; file?: string }>; // Component hierarchy\n  htmlPreview: string; // HTML snippet: '<button class=\"btn\">Click</button>'\n}\n\nexport type SerializedComponentInfoNode = Spread<\n  ComponentInfoData,\n  SerializedLexicalNode\n>;\n\nfunction toRelativePath(absolutePath: string): string {\n  const worktreeMatch = absolutePath.match(/\\/worktrees\\/[^/]+\\/[^/]+\\/(.+)$/);\n  if (worktreeMatch) return worktreeMatch[1];\n\n  const srcMatch = absolutePath.match(/\\/(src\\/.+)$/);\n  if (srcMatch) return srcMatch[1];\n\n  if (absolutePath.startsWith('/') && absolutePath.split('/').length > 4) {\n    return absolutePath.split('/').slice(-3).join('/');\n  }\n\n  return absolutePath;\n}\n\nfunction ComponentInfoComponent({\n  data,\n  onDoubleClickEdit,\n}: {\n  data: ComponentInfoData;\n  nodeKey: NodeKey;\n  onDoubleClickEdit: (event: React.MouseEvent) => void;\n}): JSX.Element {\n  const displayName = data.component || data.tagName || 'unknown';\n\n  let file = data.file;\n  let line = data.line;\n  let column = data.column;\n\n  if (!file && data.stack?.length) {\n    const firstFile = data.stack[0]?.file;\n    if (firstFile) {\n      const match = firstFile.match(\n        /^(.+\\.(?:tsx|ts|jsx|js|vue|svelte)):(\\d+):(\\d+)$/\n      );\n      if (match) {\n        file = match[1];\n        line = parseInt(match[2], 10);\n        column = parseInt(match[3], 10);\n      } else {\n        file = firstFile;\n      }\n    }\n  }\n\n  const displayPath = file ? toRelativePath(file) : null;\n  const fileLine = displayPath\n    ? line != null\n      ? `${displayPath}:${line}`\n      : displayPath\n    : null;\n\n  const fileName = file?.split('/').pop();\n  const badgeLabel = fileName\n    ? line != null\n      ? column != null\n        ? `${fileName}:${line}:${column}`\n        : `${fileName}:${line}`\n      : fileName\n    : displayName;\n\n  const stackBreadcrumb =\n    data.stack && data.stack.length > 1\n      ? data.stack.map((s) => `<${s.name}/>`).join(' \\u2190 ')\n      : null;\n\n  return (\n    <TooltipProvider delayDuration={350}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span\n            className=\"inline-flex items-center gap-half px-half bg-muted rounded-sm border border-border text-xs font-ibm-plex-mono text-muted-foreground cursor-default select-none hover:border-muted-foreground transition-colors\"\n            onDoubleClick={onDoubleClickEdit}\n          >\n            {badgeLabel}\n          </span>\n        </TooltipTrigger>\n        <TooltipContent\n          side=\"top\"\n          className=\"max-w-[400px] px-plusfifty py-base\"\n          style={{ backgroundColor: 'hsl(var(--bg-panel))' }}\n        >\n          <div className=\"flex flex-col gap-half\">\n            <div className=\"flex items-center gap-base\">\n              <span className=\"text-sm text-foreground\">{data.component}</span>\n              <span className=\"text-xs text-muted-foreground bg-muted px-half rounded-sm\">\n                {data.framework}\n              </span>\n            </div>\n\n            {fileLine && (\n              <span className=\"text-xs font-ibm-plex-mono text-muted-foreground break-all leading-relaxed\">\n                {fileLine}\n              </span>\n            )}\n\n            {data.cssClass && (\n              <span className=\"text-xs font-ibm-plex-mono text-muted-foreground break-all\">\n                {data.cssClass}\n              </span>\n            )}\n\n            {stackBreadcrumb && (\n              <div className=\"border-t border-border pt-half\">\n                <span className=\"text-xs text-muted-foreground leading-relaxed break-words\">\n                  {stackBreadcrumb}\n                </span>\n              </div>\n            )}\n\n            {data.htmlPreview && (\n              <div className=\"border-t border-border pt-half\">\n                <pre className=\"text-xs font-ibm-plex-mono text-muted-foreground whitespace-pre overflow-x-auto max-h-[120px] overflow-y-auto leading-relaxed\">\n                  {data.htmlPreview}\n                </pre>\n              </div>\n            )}\n          </div>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n\nconst config: DecoratorNodeConfig<ComponentInfoData> = {\n  type: 'component-info',\n  serialization: {\n    format: 'fenced',\n    language: 'vk-component',\n    serialize: (data) => JSON.stringify(data),\n    deserialize: (content) => JSON.parse(content),\n    validate: (data) =>\n      !!(data.framework && data.component && data.htmlPreview),\n  },\n  component: ComponentInfoComponent,\n  domStyle: {\n    display: 'inline-block',\n    paddingLeft: '2px',\n    paddingRight: '2px',\n    verticalAlign: 'bottom',\n  },\n  keyboardSelectable: false,\n  exportDOM: (data) => {\n    const span = document.createElement('span');\n    span.setAttribute('data-component-info', data.component);\n    span.textContent = `<${data.component}/>`;\n    return span;\n  },\n};\n\nconst result = createDecoratorNode(config);\n\nexport const ComponentInfoNode = result.Node;\nexport type ComponentInfoNodeInstance =\n  GeneratedDecoratorNode<ComponentInfoData>;\nexport const $createComponentInfoNode = result.createNode;\nexport const $isComponentInfoNode = result.isNode;\nexport const [COMPONENT_INFO_EXPORT_TRANSFORMER, COMPONENT_INFO_TRANSFORMER] =\n  result.transformers;\n"
  },
  {
    "path": "packages/ui/src/components/create-decorator-node.tsx",
    "content": "import {\n  DecoratorNode,\n  LexicalNode,\n  NodeKey,\n  SerializedLexicalNode,\n  Spread,\n  $createTextNode,\n  $getNodeByKey,\n  DOMConversionMap,\n  DOMExportOutput,\n  $createParagraphNode,\n} from 'lexical';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n  TextMatchTransformer,\n  MultilineElementTransformer,\n  Transformer,\n} from '@lexical/markdown';\nimport { useCallback } from 'react';\n\n// ====== Types ======\n\nexport type InlineSerialization<T> = {\n  format: 'inline';\n  pattern: RegExp; // e.g., /!\\[([^\\]]*)\\]\\(([^)]+)\\)/\n  trigger: string; // e.g., ')'\n  serialize: (data: T) => string;\n  deserialize: (match: RegExpMatchArray) => T;\n};\n\nexport type FencedSerialization<T> = {\n  format: 'fenced';\n  language: string; // e.g., 'gh-comment'\n  serialize: (data: T) => string;\n  deserialize: (content: string) => T;\n  validate?: (data: T) => boolean;\n};\n\nexport type SerializationConfig<T> =\n  | InlineSerialization<T>\n  | FencedSerialization<T>;\n\n/** Interface for the generated node instance */\nexport interface GeneratedDecoratorNode<T> extends DecoratorNode<JSX.Element> {\n  getData(): T;\n}\n\n/** Type for the generated node class constructor */\nexport type GeneratedDecoratorNodeClass<T> = {\n  new (data: T, key?: NodeKey): GeneratedDecoratorNode<T>;\n  getType(): string;\n  clone(node: GeneratedDecoratorNode<T>): GeneratedDecoratorNode<T>;\n  importJSON(\n    json: Spread<{ data: T }, SerializedLexicalNode>\n  ): GeneratedDecoratorNode<T>;\n  importDOM(): DOMConversionMap | null;\n  // Required by LexicalNode for registration\n  transform(): ((node: LexicalNode) => void) | null;\n};\n\nexport interface DecoratorNodeConfig<T> {\n  type: string;\n  serialization: SerializationConfig<T>;\n  component: React.ComponentType<{\n    data: T;\n    nodeKey: NodeKey;\n    onDoubleClickEdit: (event: React.MouseEvent) => void;\n  }>;\n  // Optional DOM import/export\n  // importDOM receives the createNode function to avoid circular reference issues\n  importDOM?: (createNode: (data: T) => LexicalNode) => DOMConversionMap | null;\n  exportDOM?: (data: T) => HTMLElement;\n  // Optional inline styles for the wrapper DOM element (for cursor spacing)\n  domStyle?: Partial<CSSStyleDeclaration>;\n  // If false, arrow keys skip over the node instead of selecting it (default: true)\n  keyboardSelectable?: boolean;\n}\n\nexport interface DecoratorNodeResult<T> {\n  Node: GeneratedDecoratorNodeClass<T>;\n  createNode: (data: T) => GeneratedDecoratorNode<T>;\n  isNode: (\n    node: LexicalNode | null | undefined\n  ) => node is GeneratedDecoratorNode<T>;\n  transformers: Transformer[]; // 1 for inline, 2 for fenced\n}\n\n// ====== Factory Function ======\n\nexport function createDecoratorNode<T>(\n  config: DecoratorNodeConfig<T>\n): DecoratorNodeResult<T> {\n  const {\n    type,\n    serialization,\n    component: UserComponent,\n    importDOM: importDOMConfig,\n    exportDOM,\n    domStyle,\n    keyboardSelectable = true,\n  } = config;\n\n  // Holder for createNode function - needs to be assigned after class definition\n  // but used via closure in the static importDOM method\n  const nodeFactoryRef: { current: ((data: T) => GeneratedNode) | null } = {\n    current: null,\n  };\n\n  // Generated node class\n  class GeneratedNode extends DecoratorNode<JSX.Element> {\n    __data: T;\n\n    static getType(): string {\n      return type;\n    }\n\n    static clone(node: GeneratedNode): GeneratedNode {\n      return new GeneratedNode(node.__data, node.__key);\n    }\n\n    constructor(data: T, key?: NodeKey) {\n      super(key);\n      this.__data = data;\n    }\n\n    createDOM(): HTMLElement {\n      const el = document.createElement('span');\n      if (domStyle) {\n        Object.assign(el.style, domStyle);\n      }\n      return el;\n    }\n\n    updateDOM(): false {\n      return false;\n    }\n\n    static importJSON(\n      json: Spread<{ data: T }, SerializedLexicalNode>\n    ): GeneratedNode {\n      return new GeneratedNode(json.data);\n    }\n\n    exportJSON(): Spread<{ data: T }, SerializedLexicalNode> {\n      return { type, version: 1, data: this.__data };\n    }\n\n    static importDOM(): DOMConversionMap | null {\n      // nodeFactoryRef.current will be assigned by the time importDOM is called at runtime\n      return importDOMConfig && nodeFactoryRef.current\n        ? importDOMConfig(nodeFactoryRef.current)\n        : null;\n    }\n\n    exportDOM(): DOMExportOutput {\n      if (exportDOM) {\n        return { element: exportDOM(this.__data) };\n      }\n      const span = document.createElement('span');\n      span.textContent = `[${type}]`;\n      return { element: span };\n    }\n\n    getData(): T {\n      return this.__data;\n    }\n\n    decorate(): JSX.Element {\n      return (\n        <NodeComponent\n          data={this.__data}\n          nodeKey={this.__key}\n          isNode={isNode}\n          serialization={serialization}\n          UserComponent={UserComponent}\n        />\n      );\n    }\n\n    isInline(): boolean {\n      return true;\n    }\n\n    isKeyboardSelectable(): boolean {\n      return keyboardSelectable;\n    }\n  }\n\n  // Type guard\n  function isNode(node: LexicalNode | null | undefined): node is GeneratedNode {\n    return node instanceof GeneratedNode;\n  }\n\n  // Factory function\n  function createNode(data: T): GeneratedNode {\n    return new GeneratedNode(data);\n  }\n\n  // Assign to ref for use in importDOM\n  nodeFactoryRef.current = createNode;\n\n  // Wrapper component with double-click edit\n  function NodeComponent({\n    data,\n    nodeKey,\n    isNode: isNodeFn,\n    serialization: serConfig,\n    UserComponent: Component,\n  }: {\n    data: T;\n    nodeKey: NodeKey;\n    isNode: (node: LexicalNode | null | undefined) => node is GeneratedNode;\n    serialization: SerializationConfig<T>;\n    UserComponent: React.ComponentType<{\n      data: T;\n      nodeKey: NodeKey;\n      onDoubleClickEdit: (event: React.MouseEvent) => void;\n    }>;\n  }) {\n    const [editor] = useLexicalComposerContext();\n    const handleDoubleClick = useCallback(\n      (event: React.MouseEvent) => {\n        if (!editor.isEditable()) return;\n\n        event.preventDefault();\n        event.stopPropagation();\n\n        editor.update(() => {\n          const node = $getNodeByKey(nodeKey);\n          if (isNodeFn(node)) {\n            const markdownText =\n              serConfig.format === 'fenced'\n                ? '```' +\n                  serConfig.language +\n                  '\\n' +\n                  serConfig.serialize(node.getData()) +\n                  '\\n```'\n                : serConfig.serialize(node.getData());\n            const textNode = $createTextNode(markdownText);\n            node.replace(textNode);\n            textNode.select(markdownText.length, markdownText.length);\n          }\n        });\n      },\n      [editor, nodeKey, isNodeFn, serConfig]\n    );\n\n    return (\n      <Component\n        data={data}\n        nodeKey={nodeKey}\n        onDoubleClickEdit={handleDoubleClick}\n      />\n    );\n  }\n\n  // Generate transformers based on serialization format\n  const transformers: Transformer[] =\n    serialization.format === 'inline'\n      ? [\n          createInlineTransformer(\n            GeneratedNode,\n            isNode,\n            serialization,\n            createNode\n          ),\n        ]\n      : createFencedTransformers(\n          GeneratedNode,\n          isNode,\n          serialization,\n          createNode\n        );\n\n  return {\n    Node: GeneratedNode as GeneratedDecoratorNodeClass<T>,\n    createNode,\n    isNode,\n    transformers,\n  };\n}\n\n// ====== Transformer Generators ======\n\nfunction createInlineTransformer<T>(\n  NodeClass: unknown,\n  isNode: (node: LexicalNode | null | undefined) => boolean,\n  config: InlineSerialization<T>,\n  createNode: (data: T) => LexicalNode\n): TextMatchTransformer {\n  return {\n    dependencies: [NodeClass as typeof LexicalNode],\n    export: (node) => {\n      if (isNode(node)) {\n        return config.serialize(\n          (node as unknown as { getData(): T }).getData()\n        );\n      }\n      return null;\n    },\n    importRegExp: config.pattern,\n    regExp: new RegExp(config.pattern.source + '$'),\n    replace: (textNode, match) => {\n      const data = config.deserialize(match);\n      textNode.replace(createNode(data));\n    },\n    trigger: config.trigger,\n    type: 'text-match',\n  };\n}\n\nfunction createFencedTransformers<T>(\n  NodeClass: unknown,\n  isNode: (node: LexicalNode | null | undefined) => boolean,\n  config: FencedSerialization<T>,\n  createNode: (data: T) => LexicalNode\n): [TextMatchTransformer, MultilineElementTransformer] {\n  // Export transformer (TextMatchTransformer for DecoratorNodes)\n  const exportTransformer: TextMatchTransformer = {\n    dependencies: [NodeClass as typeof LexicalNode],\n    export: (node) => {\n      if (!isNode(node)) return null;\n      // Add newlines before and after to ensure the code block is on its own lines\n      return (\n        '\\n```' +\n        config.language +\n        '\\n' +\n        config.serialize((node as unknown as { getData(): T }).getData()) +\n        '\\n```\\n'\n      );\n    },\n    importRegExp: /(?!)/, // Never match\n    regExp: /(?!)$/, // Never match\n    replace: () => {},\n    trigger: '',\n    type: 'text-match',\n  };\n\n  // Import transformer (MultilineElementTransformer)\n  const importTransformer: MultilineElementTransformer = {\n    type: 'multiline-element',\n    dependencies: [NodeClass as typeof LexicalNode],\n    regExpStart: new RegExp(`^\\`\\`\\`${config.language}$`),\n    regExpEnd: { optional: true, regExp: /^```$/ },\n    replace: (\n      rootNode,\n      _children,\n      _startMatch,\n      endMatch,\n      linesInBetween,\n      isImport\n    ) => {\n      if (!isImport || !endMatch || !linesInBetween?.length) return false;\n      try {\n        const content = linesInBetween.join('\\n');\n        const data = config.deserialize(content);\n        if (config.validate && !config.validate(data)) {\n          console.error(\n            `Invalid ${config.language} payload: validation failed`\n          );\n          return false;\n        }\n        const node = createNode(data);\n        const paragraph = $createParagraphNode();\n        paragraph.append(node);\n        rootNode.append(paragraph);\n      } catch (e) {\n        console.error(`Failed to parse ${config.language}:`, e);\n        return false;\n      }\n    },\n  };\n\n  return [exportTransformer, importTransformer];\n}\n"
  },
  {
    "path": "packages/ui/src/components/image-node.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { NodeKey, SerializedLexicalNode, Spread, $getNodeByKey } from 'lexical';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { Download, File, HelpCircle, Loader2, X } from 'lucide-react';\nimport {\n  useWorkspaceId,\n  useSessionId,\n  useLocalAttachments,\n  type LocalAttachmentMetadata,\n} from './WorkspaceContext';\nimport {\n  createDecoratorNode,\n  type DecoratorNodeConfig,\n} from './create-decorator-node';\n\nconst ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;\nconst IMAGE_FILE_EXTENSION_REGEX =\n  /\\.(png|jpe?g|gif|webp|bmp|svg|avif|heic|heif)$/i;\n\ntype AttachmentType = 'file' | 'thumbnail';\n\ninterface AttachmentUrlResult {\n  url: string | null;\n  loading: boolean;\n}\n\ninterface ImageMetadataLike {\n  exists: boolean;\n  file_name?: string | null;\n  size_bytes?: bigint | null;\n  format?: string | null;\n  proxy_url?: string | null;\n}\n\nexport interface OpenImagePreviewOptions {\n  imageUrl: string;\n  altText: string;\n  fileName?: string;\n  format?: string;\n  sizeBytes?: bigint | null;\n}\n\nexport interface CreateImageNodeOptions {\n  fetchAttachmentUrl: (\n    attachmentId: string,\n    type: AttachmentType\n  ) => Promise<string>;\n  openImagePreview: (options: OpenImagePreviewOptions) => void;\n}\n\nexport interface ImageData {\n  src: string;\n  altText: string;\n}\n\nexport type SerializedImageNode = Spread<\n  {\n    src: string;\n    altText: string;\n  },\n  SerializedLexicalNode\n>;\n\nfunction isImageLikeFileName(name: string): boolean {\n  const normalized = name.trim();\n  if (!normalized) {\n    return false;\n  }\n\n  return IMAGE_FILE_EXTENSION_REGEX.test(normalized);\n}\n\nfunction truncatePath(path: string, maxLength = 24): string {\n  const filename = path.split('/').pop() || path;\n  if (filename.length <= maxLength) return filename;\n  return filename.slice(0, maxLength - 3) + '...';\n}\n\nfunction formatFileSize(bytes: bigint | number | null | undefined): string {\n  if (!bytes) return '';\n  const num = Number(bytes);\n  if (num < 1024) return `${num} B`;\n  if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`;\n  return `${(num / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nasync function downloadBlobUrl(url: string, filename: string): Promise<void> {\n  const response = await fetch(url, {\n    method: 'GET',\n    mode: 'cors',\n    credentials: 'omit',\n  });\n\n  if (!response.ok) {\n    throw new Error('Failed to download attachment');\n  }\n\n  const blob = await response.blob();\n  const objectUrl = URL.createObjectURL(blob);\n\n  try {\n    const anchor = document.createElement('a');\n    anchor.href = objectUrl;\n    anchor.download = filename;\n    document.body.appendChild(anchor);\n    anchor.click();\n    document.body.removeChild(anchor);\n  } finally {\n    URL.revokeObjectURL(objectUrl);\n  }\n}\n\nfunction toMetadataFromLocalImage(\n  localImage: LocalAttachmentMetadata | undefined\n): ImageMetadataLike | null {\n  if (!localImage) return null;\n\n  return {\n    exists: true,\n    file_name: localImage.file_name,\n    size_bytes: BigInt(localImage.size_bytes),\n    format: localImage.format,\n    proxy_url: localImage.proxy_url,\n  };\n}\n\nfunction useImageMetadata(\n  workspaceId: string | undefined,\n  sessionId: string | undefined,\n  src: string,\n  localAttachments: LocalAttachmentMetadata[]\n) {\n  const isVibeImage = src.startsWith('.vibe-attachments/');\n\n  const localImage = useMemo(\n    () => localAttachments.find((attachment) => attachment.path === src),\n    [localAttachments, src]\n  );\n\n  const localImageMetadata = useMemo(\n    () => toMetadataFromLocalImage(localImage),\n    [localImage]\n  );\n\n  const shouldFetch = isVibeImage && !!workspaceId && !localImage;\n\n  const query = useQuery({\n    queryKey: ['image-metadata', workspaceId, sessionId, src],\n    queryFn: async (): Promise<ImageMetadataLike | null> => {\n      if (!workspaceId || !sessionId) return null;\n\n      const response = await fetch(\n        `/api/workspaces/${workspaceId}/attachments/metadata?path=${encodeURIComponent(src)}&session_id=${sessionId}`\n      );\n      const payload = await response.json();\n      return payload.data as ImageMetadataLike | null;\n    },\n    enabled: shouldFetch && !!sessionId,\n    staleTime: Infinity,\n  });\n\n  return {\n    data: localImageMetadata ?? query.data,\n    isLoading: localImage ? false : query.isLoading,\n  };\n}\n\nfunction useAttachmentUrl(\n  attachmentId: string | null,\n  type: AttachmentType,\n  fetchAttachmentUrl: CreateImageNodeOptions['fetchAttachmentUrl']\n): AttachmentUrlResult {\n  const query = useQuery({\n    queryKey: ['attachment-url', attachmentId, type],\n    queryFn: () => fetchAttachmentUrl(attachmentId as string, type),\n    enabled: !!attachmentId,\n    staleTime: ATTACHMENT_URL_STALE_TIME,\n  });\n\n  return {\n    url: query.data ?? null,\n    loading: query.isLoading,\n  };\n}\n\nexport function createImageNode(options: CreateImageNodeOptions) {\n  function ImageComponent({\n    data,\n    nodeKey,\n    onDoubleClickEdit,\n  }: {\n    data: ImageData;\n    nodeKey: NodeKey;\n    onDoubleClickEdit: (event: React.MouseEvent) => void;\n  }): JSX.Element {\n    const { t } = useTranslation('common');\n    const { src, altText } = data;\n    const workspaceId = useWorkspaceId();\n    const sessionId = useSessionId();\n    const localAttachments = useLocalAttachments();\n    const [editor] = useLexicalComposerContext();\n\n    const isVibeImage = src.startsWith('.vibe-attachments/');\n    const isPendingAttachment = src.startsWith('pending-attachment://');\n    const isAttachment = isPendingAttachment || src.startsWith('attachment://');\n    const attachmentId =\n      !isPendingAttachment && isAttachment\n        ? src.replace('attachment://', '')\n        : null;\n    const localAttachment = useMemo(\n      () => localAttachments.find((attachment) => attachment.path === src),\n      [localAttachments, src]\n    );\n    const isImageAttachment =\n      isAttachment &&\n      (localAttachment?.mime_type?.startsWith('image/') ||\n        isImageLikeFileName(altText));\n\n    const { url: thumbnailUrl, loading: attachmentLoading } = useAttachmentUrl(\n      isImageAttachment && !localAttachment ? attachmentId : null,\n      'thumbnail',\n      options.fetchAttachmentUrl\n    );\n    const { url: fullSizeUrl } = useAttachmentUrl(\n      localAttachment ? null : attachmentId,\n      'file',\n      options.fetchAttachmentUrl\n    );\n\n    const { data: metadata, isLoading: loading } = useImageMetadata(\n      workspaceId,\n      sessionId,\n      src,\n      localAttachments\n    );\n    const workspaceDisplayName =\n      metadata?.file_name || localAttachment?.file_name || altText || src;\n    const isWorkspaceImage =\n      isVibeImage &&\n      ((localAttachment?.mime_type?.startsWith('image/') ?? false) ||\n        isImageLikeFileName(workspaceDisplayName));\n    const showDownloadButton = Boolean(\n      (isAttachment &&\n        (localAttachment?.proxy_url || fullSizeUrl || metadata?.proxy_url)) ||\n        (!isWorkspaceImage && metadata?.proxy_url)\n    );\n\n    const handleClick = useCallback(\n      (event: React.MouseEvent) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        const localAttachmentUrl = localAttachment?.proxy_url ?? null;\n\n        if (isAttachment && (localAttachmentUrl || fullSizeUrl)) {\n          const resolvedFullSizeUrl = localAttachmentUrl || fullSizeUrl;\n          if (!resolvedFullSizeUrl) return;\n\n          if (isImageAttachment && (localAttachmentUrl || thumbnailUrl)) {\n            options.openImagePreview({\n              imageUrl: resolvedFullSizeUrl,\n              altText,\n              fileName: altText || undefined,\n            });\n          } else {\n            window.open(resolvedFullSizeUrl, '_blank', 'noopener,noreferrer');\n          }\n          return;\n        }\n\n        if (metadata?.exists && metadata.proxy_url) {\n          if (isWorkspaceImage) {\n            options.openImagePreview({\n              imageUrl: metadata.proxy_url,\n              altText,\n              fileName: metadata.file_name ?? undefined,\n              format: metadata.format ?? undefined,\n              sizeBytes: metadata.size_bytes,\n            });\n          } else {\n            window.open(metadata.proxy_url, '_blank', 'noopener,noreferrer');\n          }\n        }\n      },\n      [\n        isAttachment,\n        localAttachment?.proxy_url,\n        fullSizeUrl,\n        isImageAttachment,\n        thumbnailUrl,\n        metadata,\n        isWorkspaceImage,\n        altText,\n      ]\n    );\n\n    const handleDownload = useCallback(\n      (event: React.MouseEvent) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        const downloadUrl =\n          localAttachment?.proxy_url ??\n          fullSizeUrl ??\n          (!isWorkspaceImage ? (metadata?.proxy_url ?? null) : null);\n        if (!downloadUrl) return;\n\n        downloadBlobUrl(downloadUrl, altText || 'attachment').catch((error) => {\n          console.error('Failed to download attachment:', error);\n        });\n      },\n      [\n        localAttachment?.proxy_url,\n        fullSizeUrl,\n        isWorkspaceImage,\n        metadata,\n        altText,\n      ]\n    );\n\n    const handleDelete = useCallback(\n      (event: React.MouseEvent) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        if (!editor.isEditable()) return;\n\n        editor.update(() => {\n          const node = $getNodeByKey(nodeKey);\n          if (node) {\n            node.remove();\n          }\n        });\n      },\n      [editor, nodeKey]\n    );\n\n    let thumbnailContent: React.ReactNode;\n    let displayName: string;\n    let metadataLine: string | null = null;\n\n    const hasContext = !!workspaceId;\n    const hasLocalImage = localAttachments.some(\n      (attachment) => attachment.path === src\n    );\n\n    if (isAttachment) {\n      const previewUrl = localAttachment?.proxy_url ?? thumbnailUrl;\n\n      if (isImageAttachment && !localAttachment && attachmentLoading) {\n        thumbnailContent = (\n          <div className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n            <Loader2 className=\"w-5 h-5 text-muted-foreground animate-spin\" />\n          </div>\n        );\n      } else if (isImageAttachment && previewUrl) {\n        thumbnailContent = (\n          <img\n            src={previewUrl}\n            alt={altText}\n            className=\"w-10 h-10 object-cover rounded flex-shrink-0\"\n            draggable={false}\n          />\n        );\n      } else {\n        thumbnailContent = (\n          <div className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n            <File className=\"w-5 h-5 text-muted-foreground\" />\n          </div>\n        );\n      }\n      displayName = truncatePath(\n        altText || t('kanban.imageAttachmentNameFallback')\n      );\n      if (localAttachment?.is_pending) {\n        const parts = ['Uploading'];\n        const sizeText = formatFileSize(localAttachment.size_bytes);\n        if (sizeText) {\n          parts.push(sizeText);\n        }\n        metadataLine = parts.join(' · ');\n      }\n      if (!isImageAttachment) {\n        const format = altText.split('.').pop()?.trim();\n        metadataLine = format ? format.toUpperCase() : null;\n      }\n    } else if (isVibeImage && (hasLocalImage || hasContext)) {\n      if (!isWorkspaceImage) {\n        thumbnailContent = (\n          <div className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n            <File className=\"w-5 h-5 text-muted-foreground\" />\n          </div>\n        );\n        displayName = truncatePath(workspaceDisplayName);\n        const parts: string[] = [];\n        if (metadata?.format) {\n          parts.push(metadata.format.toUpperCase());\n        }\n        const sizeText = formatFileSize(metadata?.size_bytes);\n        if (sizeText) {\n          parts.push(sizeText);\n        }\n        metadataLine = parts.length > 0 ? parts.join(' · ') : null;\n      } else if (loading) {\n        thumbnailContent = (\n          <div className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n            <Loader2 className=\"w-5 h-5 text-muted-foreground animate-spin\" />\n          </div>\n        );\n        displayName = truncatePath(src);\n      } else if (metadata?.exists && metadata.proxy_url) {\n        thumbnailContent = (\n          <img\n            src={metadata.proxy_url}\n            alt={altText}\n            className=\"w-10 h-10 object-cover rounded flex-shrink-0\"\n            draggable={false}\n          />\n        );\n        displayName = truncatePath(workspaceDisplayName);\n\n        const parts: string[] = [];\n        if (metadata.format) {\n          parts.push(metadata.format.toUpperCase());\n        }\n        const sizeText = formatFileSize(metadata.size_bytes);\n        if (sizeText) {\n          parts.push(sizeText);\n        }\n        if (parts.length > 0) {\n          metadataLine = parts.join(' · ');\n        }\n      } else {\n        thumbnailContent = (\n          <div className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n            <HelpCircle className=\"w-5 h-5 text-muted-foreground\" />\n          </div>\n        );\n        displayName = truncatePath(src);\n      }\n    } else if (!isVibeImage) {\n      thumbnailContent = (\n        <div className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n          <HelpCircle className=\"w-5 h-5 text-muted-foreground\" />\n        </div>\n      );\n      displayName = truncatePath(altText || src);\n    } else {\n      thumbnailContent = (\n        <div className=\"w-10 h-10 flex items-center justify-center bg-muted rounded flex-shrink-0\">\n          <HelpCircle className=\"w-5 h-5 text-muted-foreground\" />\n        </div>\n      );\n      displayName = truncatePath(src);\n    }\n\n    return (\n      <span\n        className=\"group relative inline-flex items-center gap-1.5 pl-1.5 pr-5 py-1 ml-0.5 mr-0.5 bg-muted rounded border cursor-pointer border-border hover:border-muted-foreground transition-colors align-bottom\"\n        onClick={handleClick}\n        onDoubleClick={onDoubleClickEdit}\n        role=\"button\"\n        tabIndex={0}\n      >\n        {thumbnailContent}\n        <span className=\"flex flex-col min-w-0\">\n          <span className=\"text-xs text-muted-foreground truncate max-w-[120px]\">\n            {displayName}\n          </span>\n          {metadataLine && (\n            <span className=\"text-[10px] text-muted-foreground/70 truncate max-w-[120px]\">\n              {metadataLine}\n            </span>\n          )}\n        </span>\n        {editor.isEditable() && (\n          <button\n            onClick={handleDelete}\n            className=\"absolute top-1 right-1 w-4 h-4 rounded-full bg-foreground/70 hover:bg-destructive flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity\"\n            aria-label={t('kanban.removeImage')}\n            type=\"button\"\n          >\n            <X className=\"w-2.5 h-2.5 text-background\" />\n          </button>\n        )}\n        {showDownloadButton ? (\n          <button\n            onClick={handleDownload}\n            className={\n              editor.isEditable()\n                ? 'absolute top-1 right-6 w-4 h-4 rounded-full bg-foreground/70 hover:bg-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity'\n                : 'absolute top-1 right-1 w-4 h-4 rounded-full bg-foreground/70 hover:bg-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity'\n            }\n            aria-label={t('kanban.downloadAttachment')}\n            type=\"button\"\n          >\n            <Download className=\"w-2.5 h-2.5 text-background\" />\n          </button>\n        ) : null}\n      </span>\n    );\n  }\n\n  const config: DecoratorNodeConfig<ImageData> = {\n    type: 'image',\n    serialization: {\n      format: 'inline',\n      pattern: /!\\[([^\\]]*)\\]\\(([^)]+)\\)/,\n      trigger: ')',\n      serialize: (data) => `![${data.altText}](${data.src})`,\n      deserialize: (match) => ({ src: match[2], altText: match[1] }),\n    },\n    component: ImageComponent,\n    domStyle: {\n      display: 'inline-block',\n      paddingLeft: '2px',\n      paddingRight: '2px',\n      verticalAlign: 'bottom',\n    },\n    keyboardSelectable: false,\n    importDOM: (createNode) => ({\n      img: () => ({\n        conversion: (element: HTMLElement) => {\n          const imageElement = element as HTMLImageElement;\n          return {\n            node: createNode({\n              src: imageElement.getAttribute('src') || '',\n              altText: imageElement.getAttribute('alt') || '',\n            }),\n          };\n        },\n        priority: 0,\n      }),\n    }),\n    exportDOM: (data) => {\n      const img = document.createElement('img');\n      img.setAttribute('src', data.src);\n      img.setAttribute('alt', data.altText);\n      return img;\n    },\n  };\n\n  const result = createDecoratorNode(config);\n\n  return {\n    ImageNode: result.Node,\n    $createImageNode: (src: string, altText: string) =>\n      result.createNode({ src, altText }),\n    $isImageNode: result.isNode,\n    IMAGE_TRANSFORMER: result.transformers[0],\n  };\n}\n"
  },
  {
    "path": "packages/ui/src/components/pr-comment-card.tsx",
    "content": "import { MessageSquare, Code, ExternalLink } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../lib/cn';\n\nexport interface PrCommentCardProps {\n  author: string;\n  body: string;\n  createdAt: string;\n  url?: string | null;\n  // Optional review-specific fields\n  commentType?: 'general' | 'review';\n  path?: string;\n  line?: number | null;\n  diffHunk?: string | null;\n  /** Display variant: 'compact' for inline chip, 'full' for inline card, 'list' for block card */\n  variant: 'compact' | 'full' | 'list';\n  onClick?: (e: React.MouseEvent) => void;\n  onDoubleClick?: (e: React.MouseEvent) => void;\n  className?: string;\n}\n\nfunction formatDate(dateStr: string): string {\n  try {\n    return new Date(dateStr).toLocaleString();\n  } catch {\n    return dateStr;\n  }\n}\n\nfunction truncateBody(body: string, maxLength: number): string {\n  if (body.length <= maxLength) return body;\n  return body.slice(0, maxLength - 3) + '...';\n}\n\n/**\n * Renders a diff hunk with syntax highlighting for added/removed lines\n */\nfunction DiffHunk({ diffHunk }: { diffHunk: string }) {\n  const lines = diffHunk.split('\\n');\n\n  return (\n    <pre className=\"mt-2 p-2 bg-secondary rounded text-xs font-mono overflow-x-auto max-h-32 overflow-y-auto\">\n      {lines.map((line, i) => {\n        let lineClass = 'block';\n        if (line.startsWith('+') && !line.startsWith('+++')) {\n          lineClass =\n            'block bg-green-500/20 text-green-700 dark:text-green-400';\n        } else if (line.startsWith('-') && !line.startsWith('---')) {\n          lineClass = 'block bg-red-500/20 text-red-700 dark:text-red-400';\n        } else if (line.startsWith('@@')) {\n          lineClass = 'block text-muted-foreground';\n        }\n        return (\n          <code key={i} className={lineClass}>\n            {line}\n          </code>\n        );\n      })}\n    </pre>\n  );\n}\n\n/**\n * Compact variant - inline chip for WYSIWYG editor\n */\nfunction CompactCard({\n  author,\n  body,\n  commentType,\n  path,\n  onClick,\n  onDoubleClick,\n  className,\n}: PrCommentCardProps) {\n  const { t } = useTranslation('tasks');\n  const isReview = commentType === 'review';\n  const Icon = isReview ? Code : MessageSquare;\n  const displayText = isReview && path ? `${path}: ${body}` : body;\n\n  return (\n    <span\n      className={cn(\n        'inline-flex items-center gap-1.5 py-0.5 bg-muted rounded border align-middle cursor-pointer border-border hover:border-muted-foreground max-w-[300px]',\n        className\n      )}\n      onClick={onClick}\n      onDoubleClick={onDoubleClick}\n      role=\"button\"\n      tabIndex={0}\n      title={`@${author}: ${body}\\n\\n${t('prComments.card.tooltip')}`}\n    >\n      <Icon className=\"w-3.5 h-3.5 text-muted-foreground flex-shrink-0\" />\n      <span className=\"text-xs font-medium flex-shrink-0\">@{author}</span>\n      <span className=\"text-xs text-muted-foreground truncate\">\n        {truncateBody(displayText, 50)}\n      </span>\n    </span>\n  );\n}\n\n/**\n * Full variant - card for dialog selection\n */\nfunction FullCard({\n  author,\n  body,\n  createdAt,\n  url,\n  commentType,\n  path,\n  line,\n  diffHunk,\n  onClick,\n  variant,\n  className,\n}: PrCommentCardProps) {\n  const { t } = useTranslation('tasks');\n  const isReview = commentType === 'review';\n  const Icon = isReview ? Code : MessageSquare;\n\n  return (\n    <div\n      className={cn(\n        'p-3 bg-muted/50 rounded-md border border-border cursor-pointer hover:border-muted-foreground transition-colors overflow-hidden',\n        variant === 'full' && 'inline-block align-bottom max-w-md',\n        className\n      )}\n      onClick={onClick}\n      role=\"button\"\n      tabIndex={0}\n    >\n      {/* Header */}\n      <div className=\"flex items-center justify-between gap-2 mb-2\">\n        <div className=\"flex items-center gap-2 min-w-0\">\n          <Icon className=\"w-4 h-4 text-muted-foreground flex-shrink-0\" />\n          <span className=\"font-medium text-sm\">@{author}</span>\n          {isReview && (\n            <span className=\"text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded\">\n              {t('prComments.card.review')}\n            </span>\n          )}\n        </div>\n        <div className=\"flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0\">\n          <span>{formatDate(createdAt)}</span>\n          {url && (\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                e.stopPropagation();\n                window.open(url, '_blank', 'noopener,noreferrer');\n              }}\n              className=\"hover:text-foreground transition-colors\"\n              aria-label=\"Open in browser\"\n            >\n              <ExternalLink className=\"w-3 h-3\" />\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* File path for review comments */}\n      {isReview && path && (\n        <div className=\"text-xs font-mono text-primary/70 mb-1\">\n          {path}\n          {line ? `:${line}` : ''}\n        </div>\n      )}\n\n      {/* Diff hunk for review comments */}\n      {isReview && diffHunk && <DiffHunk diffHunk={diffHunk} />}\n\n      {/* Comment body */}\n      <p className=\"text-sm text-muted-foreground whitespace-pre-wrap break-words mt-2\">\n        {body}\n      </p>\n    </div>\n  );\n}\n\n/**\n * PrCommentCard - Shared presentational component for PR comments\n *\n * @param variant - 'compact' for inline chip, 'full' for inline card, 'list' for block card\n */\nexport function PrCommentCard(props: PrCommentCardProps) {\n  if (props.variant === 'compact') {\n    return <CompactCard {...props} />;\n  }\n  // Both 'full' and 'list' use FullCard, just with different styling\n  return <FullCard {...props} />;\n}\n"
  },
  {
    "path": "packages/ui/src/components/pr-comment-node.tsx",
    "content": "import { useCallback } from 'react';\nimport { NodeKey, SerializedLexicalNode, Spread } from 'lexical';\nimport { PrCommentCard } from './pr-comment-card';\nimport {\n  createDecoratorNode,\n  type DecoratorNodeConfig,\n  type GeneratedDecoratorNode,\n} from './create-decorator-node';\n\n/**\n * Normalized comment data stored in the node.\n * Uses string IDs (bigint converted) and consistent field names.\n */\nexport interface NormalizedComment {\n  id: string;\n  comment_type: 'general' | 'review';\n  author: string;\n  body: string;\n  created_at: string;\n  url?: string | null;\n  // Review-specific (optional)\n  path?: string;\n  line?: number | null;\n  diff_hunk?: string | null;\n}\n\nexport type SerializedPrCommentNode = Spread<\n  NormalizedComment,\n  SerializedLexicalNode\n>;\n\nfunction PrCommentComponent({\n  data,\n  onDoubleClickEdit,\n}: {\n  data: NormalizedComment;\n  nodeKey: NodeKey;\n  onDoubleClickEdit: (event: React.MouseEvent) => void;\n}): JSX.Element {\n  const handleClick = useCallback(\n    (event: React.MouseEvent) => {\n      event.preventDefault();\n      event.stopPropagation();\n      // Open URL in new tab if available\n      if (data.url) {\n        window.open(data.url, '_blank', 'noopener,noreferrer');\n      }\n    },\n    [data.url]\n  );\n\n  return (\n    <PrCommentCard\n      author={data.author}\n      body={data.body}\n      createdAt={data.created_at}\n      url={data.url}\n      commentType={data.comment_type}\n      path={data.path}\n      line={data.line}\n      diffHunk={data.diff_hunk}\n      variant=\"full\"\n      onClick={handleClick}\n      onDoubleClick={onDoubleClickEdit}\n    />\n  );\n}\n\nconst config: DecoratorNodeConfig<NormalizedComment> = {\n  type: 'github-comment',\n  serialization: {\n    format: 'fenced',\n    language: 'gh-comment',\n    serialize: (data) => JSON.stringify(data, null, 2),\n    deserialize: (content) => JSON.parse(content),\n    validate: (data) =>\n      !!(data.id && data.comment_type && data.author && data.body),\n  },\n  component: PrCommentComponent,\n  exportDOM: (data) => {\n    const span = document.createElement('span');\n    span.setAttribute('data-pr-comment-id', data.id);\n    span.textContent = `PR comment by @${data.author}: ${data.body}`;\n    return span;\n  },\n};\n\nconst result = createDecoratorNode(config);\n\nexport const PrCommentNode = result.Node;\nexport type PrCommentNodeInstance = GeneratedDecoratorNode<NormalizedComment>;\nexport const $createPrCommentNode = result.createNode;\nexport const $isPrCommentNode = result.isNode;\nexport const [PR_COMMENT_EXPORT_TRANSFORMER, PR_COMMENT_TRANSFORMER] =\n  result.transformers;\n"
  },
  {
    "path": "packages/ui/src/lib/cn.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\n\nexport function cn(...inputs: ClassValue[]) {\n  return clsx(inputs);\n}\n"
  },
  {
    "path": "packages/ui/src/lib/code-highlight-theme.ts",
    "content": "/**\n * Shared code highlight theme classes for Prism token types.\n * Used by both Lexical's codeHighlight theme and InlineCodeNode rendering.\n */\nexport const CODE_HIGHLIGHT_CLASSES: Record<string, string> = {\n  atrule: 'text-[var(--syntax-keyword)]',\n  attr: 'text-[var(--syntax-constant)]',\n  boolean: 'text-[var(--syntax-constant)]',\n  builtin: 'text-[var(--syntax-variable)]',\n  cdata: 'text-[var(--syntax-comment)]',\n  char: 'text-[var(--syntax-string)]',\n  class: 'text-[var(--syntax-function)]',\n  'class-name': 'text-[var(--syntax-function)]',\n  comment: 'text-[var(--syntax-comment)] italic',\n  constant: 'text-[var(--syntax-constant)]',\n  deleted: 'text-[var(--syntax-deleted)]',\n  doctype: 'text-[var(--syntax-comment)]',\n  entity: 'text-[var(--syntax-function)]',\n  function: 'text-[var(--syntax-function)]',\n  important: 'text-[var(--syntax-keyword)] font-bold',\n  inserted: 'text-[var(--syntax-tag)]',\n  keyword: 'text-[var(--syntax-keyword)]',\n  namespace: 'text-[var(--syntax-comment)]',\n  number: 'text-[var(--syntax-constant)]',\n  operator: 'text-[var(--syntax-constant)]',\n  prolog: 'text-[var(--syntax-comment)]',\n  property: 'text-[var(--syntax-constant)]',\n  punctuation: 'text-[var(--syntax-punctuation)]',\n  regex: 'text-[var(--syntax-string)]',\n  selector: 'text-[var(--syntax-tag)]',\n  string: 'text-[var(--syntax-string)]',\n  symbol: 'text-[var(--syntax-variable)]',\n  tag: 'text-[var(--syntax-tag)]',\n  url: 'text-[var(--syntax-constant)]',\n  variable: 'text-[var(--syntax-variable)]',\n};\n"
  },
  {
    "path": "packages/ui/src/lib/modals.ts",
    "content": "import NiceModal, { type NiceModalHocProps } from '@ebay/nice-modal-react';\nimport type React from 'react';\n\n// Use this instead of {} to avoid ban-types\nexport type NoProps = Record<string, never>;\n\n// Map P for component props: void -> NoProps; otherwise P\ntype ComponentProps<P> = [P] extends [void] ? NoProps : P;\n\n// Map P for .show() args: void -> []; otherwise [props: P]\ntype ShowArgs<P> = [P] extends [void] ? [] : [props: P];\n\n// Modalized component with static show/hide/remove methods\nexport type Modalized<P, R> = React.ComponentType<ComponentProps<P>> & {\n  __modalResult?: R;\n  show: (...args: ShowArgs<P>) => Promise<R>;\n  hide: () => void;\n  remove: () => void;\n};\n\nexport function defineModal<P, R>(\n  component: React.ComponentType<ComponentProps<P> & NiceModalHocProps>\n): Modalized<P, R> {\n  const c = component as unknown as Modalized<P, R>;\n  c.show = ((...args: ShowArgs<P>) =>\n    NiceModal.show(\n      component as React.FC<ComponentProps<P>>,\n      args[0] as ComponentProps<P>\n    ) as Promise<R>) as Modalized<P, R>['show'];\n  c.hide = () => NiceModal.hide(component as React.FC<ComponentProps<P>>);\n  c.remove = () => NiceModal.remove(component as React.FC<ComponentProps<P>>);\n  return c;\n}\n\n// Common modal result types for standardization\nexport type ConfirmResult = 'confirmed' | 'canceled';\nexport type DeleteResult = 'deleted' | 'canceled';\nexport type SaveResult = 'saved' | 'canceled';\n"
  },
  {
    "path": "packages/ui/src/lib/platform.ts",
    "content": "export function isMac(): boolean {\n  // Modern API (Chrome, Edge) - not supported in Safari.\n  const nav = navigator as Navigator & {\n    userAgentData?: { platform?: string };\n  };\n  if (nav.userAgentData?.platform) {\n    return nav.userAgentData.platform === 'macOS';\n  }\n  // Fallback for Safari and older browsers.\n  return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);\n}\n\nexport function getModifierKey(): string {\n  return isMac() ? '\\u2318' : 'Ctrl';\n}\n\ntype TauriInvoke = (\n  cmd: string,\n  args?: Record<string, unknown>\n) => Promise<unknown>;\n\nexport function getTauriInvoke(): TauriInvoke | null {\n  if (typeof window === 'undefined') return null;\n  const maybeInvoke = (\n    window as Window & { __TAURI_INTERNALS__?: { invoke?: TauriInvoke } }\n  ).__TAURI_INTERNALS__?.invoke;\n  return typeof maybeInvoke === 'function' ? maybeInvoke : null;\n}\n\nexport function isTauriRuntime(): boolean {\n  return getTauriInvoke() !== null;\n}\n"
  },
  {
    "path": "packages/ui/src/lib/table-transformer.ts",
    "content": "import {\n  ElementTransformer,\n  $convertFromMarkdownString,\n  TRANSFORMERS,\n} from '@lexical/markdown';\nimport {\n  TableNode,\n  TableRowNode,\n  TableCellNode,\n  $createTableNode,\n  $createTableRowNode,\n  $createTableCellNode,\n  $isTableNode,\n  $isTableRowNode,\n  $isTableCellNode,\n  TableCellHeaderStates,\n} from '@lexical/table';\n\nconst TABLE_ROW_REG_EXP = /^(?:\\|)(.+)(?:\\|)\\s?$/;\nconst TABLE_ROW_DIVIDER_REG_EXP = /^(\\| ?:?-+:? ?)+\\|\\s?$/;\n\nfunction $createTableCell(textContent: string): TableCellNode {\n  textContent = textContent.replace(/\\\\n/g, '\\n');\n  const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);\n  $convertFromMarkdownString(textContent, TRANSFORMERS, cell);\n  return cell;\n}\n\nfunction mapToTableCells(textContent: string): Array<TableCellNode> | null {\n  const cells = textContent\n    .split('|')\n    .map((c) => c.trim())\n    .filter((c) => c.length > 0);\n\n  if (cells.length === 0) return null;\n  return cells.map($createTableCell);\n}\n\nexport const TABLE_TRANSFORMER: ElementTransformer = {\n  dependencies: [TableNode, TableRowNode, TableCellNode],\n  type: 'element',\n  regExp: TABLE_ROW_REG_EXP,\n\n  export: (node, traverseChildren) => {\n    if (!$isTableNode(node)) return null;\n\n    const output: string[] = [];\n    const children = node.getChildren();\n\n    for (let i = 0; i < children.length; i++) {\n      const row = children[i];\n      if (!$isTableRowNode(row)) continue;\n\n      const cells = row.getChildren();\n      const cellTexts = cells.map((cell) => {\n        if (!$isTableCellNode(cell)) return '';\n        return traverseChildren(cell).replace(/\\n/g, '\\\\n');\n      });\n\n      output.push('| ' + cellTexts.join(' | ') + ' |');\n\n      // Add header divider after first row if it contains header cells\n      if (i === 0) {\n        const isHeader = cells.some(\n          (cell) =>\n            $isTableCellNode(cell) &&\n            cell.getHeaderStyles() !== TableCellHeaderStates.NO_STATUS\n        );\n        if (isHeader || children.length > 1) {\n          output.push('| ' + cellTexts.map(() => '---').join(' | ') + ' |');\n        }\n      }\n    }\n\n    return output.join('\\n');\n  },\n\n  replace: (parentNode, _children, match) => {\n    // Handle header divider detection\n    const lineText = parentNode.getTextContent();\n    if (TABLE_ROW_DIVIDER_REG_EXP.test(lineText)) {\n      // Find previous sibling and mark as header\n      const prevSibling = parentNode.getPreviousSibling();\n      if ($isTableNode(prevSibling)) {\n        const firstRow = prevSibling.getFirstChild();\n        if ($isTableRowNode(firstRow)) {\n          firstRow.getChildren().forEach((cell) => {\n            if ($isTableCellNode(cell)) {\n              cell.setHeaderStyles(TableCellHeaderStates.ROW);\n            }\n          });\n        }\n      }\n      parentNode.remove();\n      return;\n    }\n\n    // Parse row cells\n    const cells = mapToTableCells(match[1]);\n    if (!cells) return;\n\n    const tableRow = $createTableRowNode();\n    cells.forEach((cell) => tableRow.append(cell));\n\n    // Check if previous sibling is a table to merge into\n    const prevSibling = parentNode.getPreviousSibling();\n    if ($isTableNode(prevSibling)) {\n      prevSibling.append(tableRow);\n      parentNode.remove();\n    } else {\n      // Create new table\n      const table = $createTableNode();\n      table.append(tableRow);\n      parentNode.replace(table);\n    }\n  },\n};\n"
  },
  {
    "path": "packages/ui/src/styles/diff-style-overrides.css",
    "content": ".light {\n  --line-added: 160 77% 88%;\n  --line-removed: 10 100% 90%;\n  --line-unchanged: var(--bg-primary);\n  --line-number-color: var(--text-low);\n}\n\n.dark {\n  --line-added: 130 30% 20%;\n  --line-removed: 12 30% 18%;\n  --line-unchanged: var(--bg-panel);\n  --line-number-color: var(--text-low);\n}\n\n.diff-tailwindcss-wrapper .container {\n  width: 100%;\n}\n\n@media (min-width: 640px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 640px;\n  }\n}\n\n@media (min-width: 768px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 768px;\n  }\n}\n\n@media (min-width: 1024px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 1024px;\n  }\n}\n\n@media (min-width: 1280px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 1280px;\n  }\n}\n\n@media (min-width: 1536px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 1536px;\n  }\n}\n\n.diff-tailwindcss-wrapper .invisible {\n  visibility: hidden;\n}\n\n.diff-tailwindcss-wrapper .absolute {\n  position: absolute;\n}\n\n.diff-tailwindcss-wrapper .relative {\n  position: relative;\n}\n\n.diff-tailwindcss-wrapper .sticky {\n  position: sticky;\n}\n\n.diff-tailwindcss-wrapper .left-0 {\n  left: 0px;\n}\n\n.diff-tailwindcss-wrapper .left-\\[100\\%\\] {\n  left: 100%;\n}\n\n.diff-tailwindcss-wrapper .right-\\[100\\%\\] {\n  right: 100%;\n}\n\n.diff-tailwindcss-wrapper .top-0 {\n  top: 0px;\n}\n\n.diff-tailwindcss-wrapper .top-\\[1px\\] {\n  top: 1px;\n}\n\n.diff-tailwindcss-wrapper .top-\\[50\\%\\] {\n  top: 50%;\n}\n\n.diff-tailwindcss-wrapper .z-\\[1\\] {\n  z-index: 1;\n}\n\n.diff-tailwindcss-wrapper .ml-\\[-1\\.5em\\] {\n  margin-left: -1.5em;\n}\n\n.diff-tailwindcss-wrapper .block {\n  display: block;\n}\n\n.diff-tailwindcss-wrapper .inline-block {\n  display: inline-block;\n}\n\n.diff-tailwindcss-wrapper .flex {\n  display: flex;\n}\n\n.diff-tailwindcss-wrapper .table {\n  display: table;\n}\n\n.diff-tailwindcss-wrapper .hidden {\n  display: none;\n}\n\n.diff-tailwindcss-wrapper .h-\\[50\\%\\] {\n  height: 50%;\n}\n\n.diff-tailwindcss-wrapper .h-full {\n  height: 100%;\n}\n\n.diff-tailwindcss-wrapper .min-h-\\[28px\\] {\n  min-height: 28px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1\\%\\] {\n  width: 1%;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1\\.5em\\] {\n  width: 1.5em;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1\\.5px\\] {\n  width: 1.5px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[10px\\] {\n  width: 10px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1px\\] {\n  width: 1px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[50\\%\\] {\n  width: 50%;\n}\n\n.diff-tailwindcss-wrapper .w-full {\n  width: 100%;\n}\n\n.diff-tailwindcss-wrapper .w-max {\n  width: -moz-max-content;\n  width: max-content;\n}\n\n.diff-tailwindcss-wrapper .min-w-\\[100px\\] {\n  min-width: 100px;\n}\n\n.diff-tailwindcss-wrapper .min-w-\\[40px\\] {\n  min-width: 40px;\n}\n\n.diff-tailwindcss-wrapper .min-w-full {\n  min-width: 100%;\n}\n\n.diff-tailwindcss-wrapper .flex-shrink-0 {\n  flex-shrink: 0;\n}\n\n.diff-tailwindcss-wrapper .shrink-0 {\n  flex-shrink: 0;\n}\n\n.diff-tailwindcss-wrapper .basis-\\[50\\%\\] {\n  flex-basis: 50%;\n}\n\n.diff-tailwindcss-wrapper .table-fixed {\n  table-layout: fixed;\n}\n\n.diff-tailwindcss-wrapper .border-collapse {\n  border-collapse: collapse;\n}\n\n.diff-tailwindcss-wrapper .border-spacing-0 {\n  --tw-border-spacing-x: 0px;\n  --tw-border-spacing-y: 0px;\n  border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y);\n}\n\n.diff-tailwindcss-wrapper .origin-center {\n  transform-origin: center;\n}\n\n.diff-tailwindcss-wrapper .translate-x-\\[-50\\%\\] {\n  --tw-translate-x: -50%;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .translate-x-\\[50\\%\\] {\n  --tw-translate-x: 50%;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .translate-y-\\[-50\\%\\] {\n  --tw-translate-y: -50%;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .cursor-pointer {\n  cursor: pointer;\n}\n\n.diff-tailwindcss-wrapper .select-none {\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  user-select: none;\n}\n\n.diff-tailwindcss-wrapper .flex-col {\n  flex-direction: column;\n}\n\n.diff-tailwindcss-wrapper .items-start {\n  align-items: flex-start;\n}\n\n.diff-tailwindcss-wrapper .items-center {\n  align-items: center;\n}\n\n.diff-tailwindcss-wrapper .justify-center {\n  justify-content: center;\n}\n\n.diff-tailwindcss-wrapper .overflow-x-auto {\n  overflow-x: auto;\n}\n\n.diff-tailwindcss-wrapper .overflow-y-hidden {\n  overflow-y: hidden;\n}\n\n.diff-tailwindcss-wrapper .whitespace-nowrap {\n  white-space: nowrap;\n}\n\n.diff-tailwindcss-wrapper .break-all {\n  word-break: break-all;\n}\n\n.diff-tailwindcss-wrapper .rounded-\\[0\\.2em\\] {\n  border-radius: 0.2em;\n}\n\n.diff-tailwindcss-wrapper .rounded-\\[2px\\] {\n  border-radius: 2px;\n}\n\n.diff-tailwindcss-wrapper .rounded-md {\n  border-radius: 0.375rem;\n}\n\n.diff-tailwindcss-wrapper .border-l-\\[1px\\] {\n  border-left-width: 1px;\n}\n\n.diff-tailwindcss-wrapper .fill-current {\n  fill: currentColor;\n}\n\n.diff-tailwindcss-wrapper .p-0 {\n  padding: 0px;\n}\n\n.diff-tailwindcss-wrapper .p-\\[1px\\] {\n  padding: 1px;\n}\n\n.diff-tailwindcss-wrapper .px-\\[10px\\] {\n  padding-left: 10px;\n  padding-right: 10px;\n}\n\n.diff-tailwindcss-wrapper .py-\\[2px\\] {\n  padding-top: 2px;\n  padding-bottom: 2px;\n}\n\n.diff-tailwindcss-wrapper .py-\\[6px\\] {\n  padding-top: 6px;\n  padding-bottom: 6px;\n}\n\n.diff-tailwindcss-wrapper .pl-\\[1\\.5em\\] {\n  padding-left: 1.5em;\n}\n\n.diff-tailwindcss-wrapper .pl-\\[10px\\] {\n  padding-left: 10px;\n}\n\n.diff-tailwindcss-wrapper .pl-\\[2\\.0em\\] {\n  padding-left: 2em;\n}\n\n.diff-tailwindcss-wrapper .pr-\\[10px\\] {\n  padding-right: 10px;\n}\n\n.diff-tailwindcss-wrapper .text-right {\n  text-align: right;\n}\n\n.diff-tailwindcss-wrapper .indent-\\[0\\.2em\\] {\n  text-indent: 0.2em;\n}\n\n.diff-tailwindcss-wrapper .align-top {\n  vertical-align: top;\n}\n\n.diff-tailwindcss-wrapper .align-middle {\n  vertical-align: middle;\n}\n\n.diff-tailwindcss-wrapper .text-\\[1\\.2em\\] {\n  font-size: 1.2em;\n}\n\n.diff-tailwindcss-wrapper .leading-\\[1\\.4\\] {\n  line-height: 1.4;\n}\n\n.diff-tailwindcss-wrapper .leading-\\[1\\.6\\] {\n  line-height: 1.6;\n}\n\n.diff-tailwindcss-wrapper .\\!text-red-500 {\n  --tw-text-opacity: 1 !important;\n  color: rgb(239 68 68 / var(--tw-text-opacity, 1)) !important;\n}\n\n.diff-tailwindcss-wrapper .opacity-\\[0\\.5\\] {\n  opacity: 0.5;\n}\n\n.diff-tailwindcss-wrapper .filter {\n  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast)\n    var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate)\n    var(--tw-sepia) var(--tw-drop-shadow);\n}\n\n.diff-tailwindcss-wrapper .transition-transform {\n  transition-property: transform;\n  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n  transition-duration: 150ms;\n}\n\n.diff-tailwindcss-wrapper * {\n  box-sizing: border-box;\n}\n\n.diff-tailwindcss-wrapper .diff-style-root {\n  --diff-border--: var(--border);\n  --diff-add-content--: hsl(var(--line-added));\n  --diff-del-content--: hsl(var(--line-removed));\n  --diff-add-lineNumber--: hsl(var(--line-added));\n  --diff-del-lineNumber--: hsl(var(--line-removed));\n  --diff-plain-content--: hsl(var(--line-unchanged));\n  --diff-expand-content--: hsl(var(--muted));\n  --diff-plain-lineNumber--: hsl(var(--line-unchanged));\n  --diff-expand-lineNumber--: hsl(var(--muted));\n  --diff-plain-lineNumber-color--: hsl(var(--line-number-color));\n  --diff-expand-lineNumber-color--: hsl(var(--muted-foreground));\n  --diff-hunk-content--: hsl(var(--muted));\n  --diff-hunk-lineNumber--: hsl(var(--muted));\n  --diff-hunk-lineNumber-hover--: hsl(var(--muted-foreground) / 0.7);\n  --diff-add-content-highlight--: hsl(var(--line-added) / 0.4);\n  --diff-del-content-highlight--: hsl(var(--line-removed) / 0.4);\n  --diff-add-widget--: hsl(var(--muted-foreground) / 0.7);\n  --diff-add-widget-color--: hsl(var(--muted));\n  --diff-empty-content--: hsl(var(--background));\n  --diff-hunk-content-color--: hsl(var(--muted-foreground) / 0.7);\n}\n\n.diff-tailwindcss-wrapper .diff-style-root .diff-line-syntax-raw *,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw * {\n  color: var(--diff-view-light, inherit);\n  font-weight: var(--diff-view-light-font-weight, inherit);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw * {\n  color: var(--diff-view-dark, inherit);\n  font-weight: var(--diff-view-dark-font-weight, inherit);\n}\n\n.diff-tailwindcss-wrapper table,\n.diff-tailwindcss-wrapper tr,\n.diff-tailwindcss-wrapper td {\n  border-color: transparent;\n  /* border-width: 0px; */\n  text-align: left;\n}\n\n.diff-tailwindcss-wrapper .diff-line-old-num,\n.diff-tailwindcss-wrapper .diff-line-new-num,\n.diff-tailwindcss-wrapper .diff-line-num {\n  text-align: right;\n}\n\n.diff-tailwindcss-wrapper .diff-style-root tr {\n  content-visibility: auto;\n}\n\n.diff-tailwindcss-wrapper .diff-add-widget-wrapper {\n  transform-origin: center;\n  transform: translateX(-50%) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-line-old-content .diff-add-widget-wrapper,\n.diff-tailwindcss-wrapper .diff-line-new-content .diff-add-widget-wrapper {\n  transform: translateX(50%) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-add-widget-wrapper:hover {\n  transform: translateX(-50%) scale(1.1) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-line-old-content .diff-add-widget-wrapper:hover,\n.diff-tailwindcss-wrapper\n  .diff-line-new-content\n  .diff-add-widget-wrapper:hover {\n  transform: translateX(50%) scale(1.1) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip {\n  position: relative;\n}\n\n.diff-tailwindcss-wrapper .diff-add-widget,\n.diff-tailwindcss-wrapper .diff-widget-tooltip {\n  font-family: inherit;\n  font-feature-settings: \"liga\" off;\n  font-variation-settings: inherit;\n  font-size: 100%;\n  font-weight: inherit;\n  line-height: inherit;\n  letter-spacing: inherit;\n  color: inherit;\n  margin: 0;\n  text-transform: none;\n  /* border-width: 0px; */\n  background-color: transparent;\n  background-image: none;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip::after {\n  display: none;\n  box-sizing: border-box;\n  background-color: #555555;\n  position: absolute;\n  content: attr(data-title);\n  font-size: 11px;\n  padding: 1px 2px;\n  border-radius: 4px;\n  overflow: hidden;\n  top: 50%;\n  white-space: nowrap;\n  transform: translateY(-50%);\n  left: calc(100% + 8px);\n  color: #ffffff;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip::before {\n  display: none;\n  box-sizing: border-box;\n  content: \"\";\n  position: absolute;\n  top: 50%;\n  left: calc(100% - 2px);\n  transform: translateY(-50%);\n  border: 6px solid transparent;\n  border-right-color: #555555;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip:hover {\n  background-color: var(--diff-hunk-lineNumber-hover--);\n  color: white;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip:hover::before {\n  display: block;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip:hover::after {\n  display: block;\n}\n\n.diff-line-extend-wrapper * {\n  color: initial;\n}\n\n.diff-line-widget-wrapper * {\n  color: initial;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw pre code.hljs {\n  display: block;\n  overflow-x: auto;\n  padding: 1em;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw code.hljs {\n  padding: 3px 5px;\n}\n\n/*!\n  Theme: GitHub\n  Description: Light theme as seen on github.com\n  Author: github.com\n  Maintainer: @Hirse\n  Updated: 2021-05-15\n\n  Outdated base version: https://github.com/primer/github-syntax-light\n  Current colors taken from GitHub's CSS\n*/\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs {\n  color: var(--syntax-punctuation);\n  background: #ffffff;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-doctag,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-keyword,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-meta .hljs-keyword,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-template-tag,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-template-variable,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-type,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-variable.language_ {\n  /* prettylights-syntax-keyword */\n  color: var(--syntax-keyword);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.class_,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.class_.inherited__,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.function_ {\n  /* prettylights-syntax-entity */\n  color: var(--syntax-function);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-attr,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-attribute,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-literal,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-meta,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-number,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-operator,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-variable,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-attr,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-class,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-id {\n  /* prettylights-syntax-constant */\n  color: var(--syntax-constant);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-regexp,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-string,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-meta .hljs-string {\n  /* prettylights-syntax-string */\n  color: var(--syntax-string);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-built_in,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-symbol {\n  /* prettylights-syntax-variable */\n  color: var(--syntax-variable);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-comment,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-code,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-formula {\n  /* prettylights-syntax-comment */\n  color: var(--syntax-comment);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-name,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-quote,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-tag,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-pseudo {\n  /* prettylights-syntax-entity-tag */\n  color: var(--syntax-tag);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-subst {\n  /* prettylights-syntax-storage-modifier-import */\n  color: var(--syntax-punctuation);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-section {\n  /* prettylights-syntax-markup-heading */\n  color: var(--syntax-constant);\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-bullet {\n  /* prettylights-syntax-markup-list */\n  color: #735c0f;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-emphasis {\n  /* prettylights-syntax-markup-italic */\n  color: var(--syntax-punctuation);\n  font-style: italic;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-strong {\n  /* prettylights-syntax-markup-bold */\n  color: var(--syntax-punctuation);\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-addition {\n  /* prettylights-syntax-markup-inserted */\n  color: var(--syntax-tag);\n  background-color: #f0fff4;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-deletion {\n  /* prettylights-syntax-markup-deleted */\n  color: var(--syntax-deleted);\n  background-color: #ffeef0;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-char.escape_,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-link,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-params,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-property,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-punctuation,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-tag {\n  /* purposely ignored */\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  pre\n  code.hljs {\n  display: block;\n  overflow-x: auto;\n  padding: 1em;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw code.hljs {\n  padding: 3px 5px;\n}\n\n/*!\n  Theme: GitHub Dark\n  Description: Dark theme as seen on github.com\n  Author: github.com\n  Maintainer: @Hirse\n  Updated: 2021-05-15\n\n  Outdated base version: https://github.com/primer/github-syntax-dark\n  Current colors taken from GitHub's CSS\n*/\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs {\n  color: var(--syntax-punctuation);\n  background: #0d1117;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-doctag,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-keyword,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-meta\n  .hljs-keyword,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-template-tag,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-template-variable,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-type,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-variable.language_ {\n  /* prettylights-syntax-keyword */\n  color: var(--syntax-keyword);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-title,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-title.class_,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-title.class_.inherited__,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-title.function_ {\n  /* prettylights-syntax-entity */\n  color: var(--syntax-function);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-attr,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-attribute,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-literal,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-meta,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-number,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-operator,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-variable,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-selector-attr,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-selector-class,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-selector-id {\n  /* prettylights-syntax-constant */\n  color: var(--syntax-constant);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-regexp,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-string,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-meta\n  .hljs-string {\n  /* prettylights-syntax-string */\n  color: var(--syntax-string);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-built_in,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-symbol {\n  /* prettylights-syntax-variable */\n  color: var(--syntax-variable);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-comment,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-code,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-formula {\n  /* prettylights-syntax-comment */\n  color: var(--syntax-comment);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-name,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-quote,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-selector-tag,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-selector-pseudo {\n  /* prettylights-syntax-entity-tag */\n  color: var(--syntax-tag);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-subst {\n  /* prettylights-syntax-storage-modifier-import */\n  color: var(--syntax-punctuation);\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-section {\n  /* prettylights-syntax-markup-heading */\n  color: #1f6feb;\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-bullet {\n  /* prettylights-syntax-markup-list */\n  color: #f2cc60;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-emphasis {\n  /* prettylights-syntax-markup-italic */\n  color: var(--syntax-punctuation);\n  font-style: italic;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-strong {\n  /* prettylights-syntax-markup-bold */\n  color: var(--syntax-punctuation);\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-addition {\n  /* prettylights-syntax-markup-inserted */\n  color: #aff5b4;\n  background-color: #033a16;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-deletion {\n  /* prettylights-syntax-markup-deleted */\n  color: var(--syntax-deleted);\n  background-color: #67060c;\n}\n\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-char.escape_,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-link,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-params,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-property,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"]\n  .diff-line-syntax-raw\n  .hljs-punctuation,\n.diff-tailwindcss-wrapper[data-theme=\"dark\"] .diff-line-syntax-raw .hljs-tag {\n  /* purposely ignored */\n}\n\n.diff-tailwindcss-wrapper .hover\\:scale-110:hover {\n  --tw-scale-x: 1.1;\n  --tw-scale-y: 1.1;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .group:hover .group-hover\\:visible {\n  visibility: visible;\n}\n\n/* =============================================================================\n   Pierre/Diffs Overrides - Remove black bars (padding on [data-code])\n   ============================================================================= */\n\n/* Remove padding that creates black bars above/below diff content */\ndiffs-container [data-code] {\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\n/* Alternative: Set the gap-block variable to 0 */\ndiffs-container {\n  --diffs-gap-block: 0;\n}\n\n/* =============================================================================\n   Pierre/Diffs Design Overrides - Match Old @git-diff-view/react Design\n   ============================================================================= */\n\n/* Font: Use IBM Plex Mono to match old design */\ndiffs-container {\n  --diffs-font-family: \"IBM Plex Mono\", \"SF Mono\", Monaco, Consolas, monospace;\n  --diffs-font-size: 12px;\n  --diffs-line-height: 18px;\n}\n\n/* Light Mode Colors - Match old design */\n.light diffs-container,\ndiffs-container[data-theme-type=\"light\"] {\n  /* Addition colors - soft green (HSL 160 77% 88%) */\n  --diffs-light-addition-color: hsl(160, 77%, 40%);\n  --diffs-bg-addition-override: hsl(160, 77%, 88%);\n  --diffs-bg-addition-number-override: hsl(160, 77%, 85%);\n  --diffs-bg-addition-hover-override: hsl(160, 77%, 82%);\n\n  /* Deletion colors - soft red (HSL 10 100% 90%) */\n  --diffs-light-deletion-color: hsl(10, 100%, 45%);\n  --diffs-bg-deletion-override: hsl(10, 100%, 90%);\n  --diffs-bg-deletion-number-override: hsl(10, 100%, 87%);\n  --diffs-bg-deletion-hover-override: hsl(10, 100%, 84%);\n\n  /* Context/unchanged - white background */\n  --diffs-light-bg: hsl(0, 0%, 100%);\n  --diffs-bg-context-override: hsl(0, 0%, 98%);\n\n  /* Line numbers */\n  --diffs-fg-number-override: hsl(0, 0%, 39%);\n}\n\n/* Dark Mode Colors - Match old design */\n.dark diffs-container,\ndiffs-container[data-theme-type=\"dark\"] {\n  /* Addition colors - dark green (HSL 130 30% 20%) */\n  --diffs-dark-addition-color: hsl(130, 50%, 50%);\n  --diffs-bg-addition-override: hsl(130, 30%, 20%);\n  --diffs-bg-addition-number-override: hsl(130, 30%, 18%);\n  --diffs-bg-addition-hover-override: hsl(130, 30%, 25%);\n\n  /* Deletion colors - dark red (HSL 12 30% 18%) */\n  --diffs-dark-deletion-color: hsl(12, 50%, 55%);\n  --diffs-bg-deletion-override: hsl(12, 30%, 18%);\n  --diffs-bg-deletion-number-override: hsl(12, 30%, 16%);\n  --diffs-bg-deletion-hover-override: hsl(12, 30%, 23%);\n\n  /* Context/unchanged - match app's bg-panel (lighter gray, not black) */\n  /* --diffs-bg directly overrides the base since light-dark() may not pick up --diffs-dark-bg */\n  --diffs-bg: hsl(0, 0%, 20%);\n  --diffs-dark-bg: hsl(0, 0%, 20%);\n  --diffs-bg-context-override: hsl(0, 0%, 20%);\n  --diffs-bg-hover-override: hsl(0, 0%, 25%);\n\n  /* Line numbers */\n  --diffs-fg-number-override: hsl(0, 0%, 56%);\n}\n\n/* =============================================================================\n   Hover Utility Button Positioning\n   ============================================================================= */\n\n/*\n * The [data-hover-slot] in shadow DOM is positioned at right:0 of the number column.\n * The slotted content needs to appear BETWEEN the number and code columns.\n * Using left:100% positions it just to the right of the hover slot container.\n */\ndiffs-container [slot=\"hover-slot\"] {\n  left: 100%;\n  right: auto;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding-left: 4px;\n}\n\n/* Style the hover utility button - no visual changes on hover */\ndiffs-container [slot=\"hover-slot\"] button,\ndiffs-container [slot=\"hover-slot\"] button:hover,\ndiffs-container [slot=\"hover-slot\"] button:focus,\ndiffs-container [slot=\"hover-slot\"] button:active {\n  background-color: hsl(var(--brand) / 0.2);\n  color: hsl(var(--brand));\n  border: none;\n  outline: none;\n  box-shadow: none;\n}\n"
  },
  {
    "path": "packages/ui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"shared/*\": [\"../../shared/*\"]\n    }\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/web-core/.prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"trailingComma\": \"es5\",\n  \"singleQuote\": true,\n  \"printWidth\": 80,\n  \"tabWidth\": 2,\n  \"useTabs\": false\n}\n"
  },
  {
    "path": "packages/web-core/package.json",
    "content": "{\n  \"name\": \"@vibe/web-core\",\n  \"private\": true,\n  \"version\": \"0.1.18\",\n  \"type\": \"module\",\n  \"exports\": {\n    \"./project-fallback-page\": \"./src/project-routes/ProjectFallbackPage.tsx\",\n    \"./project-search\": \"./src/project-routes/project-search.ts\",\n    \"./*\": \"./src/*\"\n  },\n  \"dependencies\": {\n    \"@codemirror/lang-json\": \"^6.0.2\",\n    \"@codemirror/language\": \"^6.11.2\",\n    \"@codemirror/lint\": \"^6.8.5\",\n    \"@codemirror/view\": \"^6.38.1\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@ebay/nice-modal-react\": \"^1.2.13\",\n    \"@git-diff-view/file\": \"^0.0.30\",\n    \"@git-diff-view/react\": \"^0.0.30\",\n    \"@hello-pangea/dnd\": \"^18.0.1\",\n    \"@lexical/code\": \"^0.36.2\",\n    \"@lexical/link\": \"^0.36.2\",\n    \"@lexical/list\": \"^0.36.2\",\n    \"@lexical/markdown\": \"^0.36.2\",\n    \"@lexical/react\": \"^0.36.2\",\n    \"@lexical/rich-text\": \"^0.36.2\",\n    \"@lexical/table\": \"^0.36.2\",\n    \"@noble/curves\": \"^1.9.7\",\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"@pierre/diffs\": \"^1.0.8\",\n    \"@radix-ui/react-accordion\": \"^1.2.1\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-select\": \"^2.2.5\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.0.3\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.7\",\n    \"@rjsf/shadcn\": \"6.1.1\",\n    \"@sentry/react\": \"^9.34.0\",\n    \"@sentry/vite-plugin\": \"^3.5.0\",\n    \"@tanstack/electric-db-collection\": \"^0.2.6\",\n    \"@tanstack/react-db\": \"^0.1.50\",\n    \"@tanstack/react-form\": \"^1.23.8\",\n    \"@tanstack/react-query\": \"^5.85.5\",\n    \"@tanstack/react-router\": \"^1.161.1\",\n    \"@tanstack/react-virtual\": \"^3.13.23\",\n    \"@tanstack/zod-adapter\": \"^1.161.1\",\n    \"@uiw/react-codemirror\": \"^4.25.1\",\n    \"@vibe/ui\": \"workspace:*\",\n    \"@virtuoso.dev/message-list\": \"^1.13.3\",\n    \"@xterm/addon-fit\": \"^0.10.0\",\n    \"@xterm/addon-web-links\": \"^0.11.0\",\n    \"@xterm/xterm\": \"^5.5.0\",\n    \"class-variance-authority\": \"^0.7.0\",\n    \"click-to-react-component\": \"^1.1.2\",\n    \"clsx\": \"^2.0.0\",\n    \"cmdk\": \"^1.1.1\",\n    \"developer-icons\": \"^6.0.4\",\n    \"fancy-ansi\": \"^0.1.3\",\n    \"framer-motion\": \"^12.23.24\",\n    \"i18next\": \"^25.5.2\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"immer\": \"^11.1.3\",\n    \"jwt-decode\": \"^4.0.0\",\n    \"lexical\": \"^0.36.2\",\n    \"lodash\": \"^4.17.21\",\n    \"lucide-react\": \"^0.539.0\",\n    \"posthog-js\": \"^1.276.0\",\n    \"react\": \"^18.2.0\",\n    \"react-compiler-runtime\": \"^1.0.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-dropzone\": \"^14.3.8\",\n    \"react-hotkeys-hook\": \"^5.1.0\",\n    \"react-i18next\": \"^15.7.3\",\n    \"react-resizable-panels\": \"^4.0.13\",\n    \"react-use-websocket\": \"^4.13.0\",\n    \"react-virtuoso\": \"^4.14.0\",\n    \"rfc6902\": \"^5.1.2\",\n    \"simple-icons\": \"^15.16.0\",\n    \"tailwind-merge\": \"^2.2.0\",\n    \"tailwind-scrollbar\": \"^3.1.0\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"wa-sqlite\": \"^1.0.0\",\n    \"zod\": \"^3.25.76\",\n    \"zustand\": \"^4.5.4\"\n  },\n  \"scripts\": {\n    \"check\": \"tsc --noEmit\",\n    \"format\": \"prettier --write \\\"src/**/*.{ts,tsx,js,jsx,json,css,md}\\\"\",\n    \"format:check\": \"prettier --check \\\"src/**/*.{ts,tsx,js,jsx,json,css,md}\\\"\"\n  },\n  \"devDependencies\": {\n    \"@rjsf/core\": \"6.1.1\",\n    \"@rjsf/utils\": \"6.1.1\",\n    \"@rjsf/validator-ajv8\": \"6.1.1\",\n    \"@tailwindcss/container-queries\": \"^0.1.1\",\n    \"@tanstack/router-plugin\": \"^1.161.1\",\n    \"@types/lodash\": \"^4.17.20\",\n    \"@types/react\": \"^18.2.43\",\n    \"@types/react-dom\": \"^18.2.17\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.21.0\",\n    \"@typescript-eslint/parser\": \"^6.21.0\",\n    \"@vitejs/plugin-react\": \"^4.2.1\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"babel-plugin-react-compiler\": \"^1.0.0\",\n    \"eslint\": \"^8.55.0\",\n    \"eslint-config-prettier\": \"^10.1.5\",\n    \"eslint-plugin-check-file\": \"^2.8.0\",\n    \"eslint-plugin-deprecation\": \"^3.0.0\",\n    \"eslint-plugin-eslint-comments\": \"^3.2.0\",\n    \"eslint-plugin-i18next\": \"^6.1.3\",\n    \"eslint-plugin-prettier\": \"^5.5.0\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.5\",\n    \"eslint-plugin-unused-imports\": \"^4.1.4\",\n    \"postcss\": \"^8.4.32\",\n    \"prettier\": \"^3.6.1\",\n    \"tailwindcss\": \"^3.4.0\",\n    \"typescript\": \"^5.9.2\",\n    \"vite\": \"^7.3.1\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/app/styles/diff-style-overrides.css",
    "content": ".light {\n  --line-added: 160 77% 88%;\n  --line-removed: 10 100% 90%;\n  --line-unchanged: var(--bg-primary);\n  --line-number-color: var(--text-low);\n}\n\n.dark {\n  --line-added: 130 30% 20%;\n  --line-removed: 12 30% 18%;\n  --line-unchanged: var(--bg-panel);\n  --line-number-color: var(--text-low);\n}\n\n.diff-tailwindcss-wrapper .container {\n  width: 100%;\n}\n\n@media (min-width: 640px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 640px;\n  }\n}\n\n@media (min-width: 768px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 768px;\n  }\n}\n\n@media (min-width: 1024px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 1024px;\n  }\n}\n\n@media (min-width: 1280px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 1280px;\n  }\n}\n\n@media (min-width: 1536px) {\n  .diff-tailwindcss-wrapper .container {\n    max-width: 1536px;\n  }\n}\n\n.diff-tailwindcss-wrapper .invisible {\n  visibility: hidden;\n}\n\n.diff-tailwindcss-wrapper .absolute {\n  position: absolute;\n}\n\n.diff-tailwindcss-wrapper .relative {\n  position: relative;\n}\n\n.diff-tailwindcss-wrapper .sticky {\n  position: sticky;\n}\n\n.diff-tailwindcss-wrapper .left-0 {\n  left: 0px;\n}\n\n.diff-tailwindcss-wrapper .left-\\[100\\%\\] {\n  left: 100%;\n}\n\n.diff-tailwindcss-wrapper .right-\\[100\\%\\] {\n  right: 100%;\n}\n\n.diff-tailwindcss-wrapper .top-0 {\n  top: 0px;\n}\n\n.diff-tailwindcss-wrapper .top-\\[1px\\] {\n  top: 1px;\n}\n\n.diff-tailwindcss-wrapper .top-\\[50\\%\\] {\n  top: 50%;\n}\n\n.diff-tailwindcss-wrapper .z-\\[1\\] {\n  z-index: 1;\n}\n\n.diff-tailwindcss-wrapper .ml-\\[-1\\.5em\\] {\n  margin-left: -1.5em;\n}\n\n.diff-tailwindcss-wrapper .block {\n  display: block;\n}\n\n.diff-tailwindcss-wrapper .inline-block {\n  display: inline-block;\n}\n\n.diff-tailwindcss-wrapper .flex {\n  display: flex;\n}\n\n.diff-tailwindcss-wrapper .table {\n  display: table;\n}\n\n.diff-tailwindcss-wrapper .hidden {\n  display: none;\n}\n\n.diff-tailwindcss-wrapper .h-\\[50\\%\\] {\n  height: 50%;\n}\n\n.diff-tailwindcss-wrapper .h-full {\n  height: 100%;\n}\n\n.diff-tailwindcss-wrapper .min-h-\\[28px\\] {\n  min-height: 28px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1\\%\\] {\n  width: 1%;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1\\.5em\\] {\n  width: 1.5em;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1\\.5px\\] {\n  width: 1.5px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[10px\\] {\n  width: 10px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[1px\\] {\n  width: 1px;\n}\n\n.diff-tailwindcss-wrapper .w-\\[50\\%\\] {\n  width: 50%;\n}\n\n.diff-tailwindcss-wrapper .w-full {\n  width: 100%;\n}\n\n.diff-tailwindcss-wrapper .w-max {\n  width: -moz-max-content;\n  width: max-content;\n}\n\n.diff-tailwindcss-wrapper .min-w-\\[100px\\] {\n  min-width: 100px;\n}\n\n.diff-tailwindcss-wrapper .min-w-\\[40px\\] {\n  min-width: 40px;\n}\n\n.diff-tailwindcss-wrapper .min-w-full {\n  min-width: 100%;\n}\n\n.diff-tailwindcss-wrapper .flex-shrink-0 {\n  flex-shrink: 0;\n}\n\n.diff-tailwindcss-wrapper .shrink-0 {\n  flex-shrink: 0;\n}\n\n.diff-tailwindcss-wrapper .basis-\\[50\\%\\] {\n  flex-basis: 50%;\n}\n\n.diff-tailwindcss-wrapper .table-fixed {\n  table-layout: fixed;\n}\n\n.diff-tailwindcss-wrapper .border-collapse {\n  border-collapse: collapse;\n}\n\n.diff-tailwindcss-wrapper .border-spacing-0 {\n  --tw-border-spacing-x: 0px;\n  --tw-border-spacing-y: 0px;\n  border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y);\n}\n\n.diff-tailwindcss-wrapper .origin-center {\n  transform-origin: center;\n}\n\n.diff-tailwindcss-wrapper .translate-x-\\[-50\\%\\] {\n  --tw-translate-x: -50%;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .translate-x-\\[50\\%\\] {\n  --tw-translate-x: 50%;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .translate-y-\\[-50\\%\\] {\n  --tw-translate-y: -50%;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .cursor-pointer {\n  cursor: pointer;\n}\n\n.diff-tailwindcss-wrapper .select-none {\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  user-select: none;\n}\n\n.diff-tailwindcss-wrapper .flex-col {\n  flex-direction: column;\n}\n\n.diff-tailwindcss-wrapper .items-start {\n  align-items: flex-start;\n}\n\n.diff-tailwindcss-wrapper .items-center {\n  align-items: center;\n}\n\n.diff-tailwindcss-wrapper .justify-center {\n  justify-content: center;\n}\n\n.diff-tailwindcss-wrapper .overflow-x-auto {\n  overflow-x: auto;\n}\n\n.diff-tailwindcss-wrapper .overflow-y-hidden {\n  overflow-y: hidden;\n}\n\n.diff-tailwindcss-wrapper .whitespace-nowrap {\n  white-space: nowrap;\n}\n\n.diff-tailwindcss-wrapper .break-all {\n  word-break: break-all;\n}\n\n.diff-tailwindcss-wrapper .rounded-\\[0\\.2em\\] {\n  border-radius: 0.2em;\n}\n\n.diff-tailwindcss-wrapper .rounded-\\[2px\\] {\n  border-radius: 2px;\n}\n\n.diff-tailwindcss-wrapper .rounded-md {\n  border-radius: 0.375rem;\n}\n\n.diff-tailwindcss-wrapper .border-l-\\[1px\\] {\n  border-left-width: 1px;\n}\n\n.diff-tailwindcss-wrapper .fill-current {\n  fill: currentColor;\n}\n\n.diff-tailwindcss-wrapper .p-0 {\n  padding: 0px;\n}\n\n.diff-tailwindcss-wrapper .p-\\[1px\\] {\n  padding: 1px;\n}\n\n.diff-tailwindcss-wrapper .px-\\[10px\\] {\n  padding-left: 10px;\n  padding-right: 10px;\n}\n\n.diff-tailwindcss-wrapper .py-\\[2px\\] {\n  padding-top: 2px;\n  padding-bottom: 2px;\n}\n\n.diff-tailwindcss-wrapper .py-\\[6px\\] {\n  padding-top: 6px;\n  padding-bottom: 6px;\n}\n\n.diff-tailwindcss-wrapper .pl-\\[1\\.5em\\] {\n  padding-left: 1.5em;\n}\n\n.diff-tailwindcss-wrapper .pl-\\[10px\\] {\n  padding-left: 10px;\n}\n\n.diff-tailwindcss-wrapper .pl-\\[2\\.0em\\] {\n  padding-left: 2em;\n}\n\n.diff-tailwindcss-wrapper .pr-\\[10px\\] {\n  padding-right: 10px;\n}\n\n.diff-tailwindcss-wrapper .text-right {\n  text-align: right;\n}\n\n.diff-tailwindcss-wrapper .indent-\\[0\\.2em\\] {\n  text-indent: 0.2em;\n}\n\n.diff-tailwindcss-wrapper .align-top {\n  vertical-align: top;\n}\n\n.diff-tailwindcss-wrapper .align-middle {\n  vertical-align: middle;\n}\n\n.diff-tailwindcss-wrapper .text-\\[1\\.2em\\] {\n  font-size: 1.2em;\n}\n\n.diff-tailwindcss-wrapper .leading-\\[1\\.4\\] {\n  line-height: 1.4;\n}\n\n.diff-tailwindcss-wrapper .leading-\\[1\\.6\\] {\n  line-height: 1.6;\n}\n\n.diff-tailwindcss-wrapper .\\!text-red-500 {\n  --tw-text-opacity: 1 !important;\n  color: rgb(239 68 68 / var(--tw-text-opacity, 1)) !important;\n}\n\n.diff-tailwindcss-wrapper .opacity-\\[0\\.5\\] {\n  opacity: 0.5;\n}\n\n.diff-tailwindcss-wrapper .filter {\n  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast)\n    var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate)\n    var(--tw-sepia) var(--tw-drop-shadow);\n}\n\n.diff-tailwindcss-wrapper .transition-transform {\n  transition-property: transform;\n  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n  transition-duration: 150ms;\n}\n\n.diff-tailwindcss-wrapper * {\n  box-sizing: border-box;\n}\n\n.diff-tailwindcss-wrapper .diff-style-root {\n  --diff-border--: var(--border);\n  --diff-add-content--: hsl(var(--line-added));\n  --diff-del-content--: hsl(var(--line-removed));\n  --diff-add-lineNumber--: hsl(var(--line-added));\n  --diff-del-lineNumber--: hsl(var(--line-removed));\n  --diff-plain-content--: hsl(var(--line-unchanged));\n  --diff-expand-content--: hsl(var(--muted));\n  --diff-plain-lineNumber--: hsl(var(--line-unchanged));\n  --diff-expand-lineNumber--: hsl(var(--muted));\n  --diff-plain-lineNumber-color--: hsl(var(--line-number-color));\n  --diff-expand-lineNumber-color--: hsl(var(--muted-foreground));\n  --diff-hunk-content--: hsl(var(--muted));\n  --diff-hunk-lineNumber--: hsl(var(--muted));\n  --diff-hunk-lineNumber-hover--: hsl(var(--muted-foreground) / 0.7);\n  --diff-add-content-highlight--: hsl(var(--line-added) / 0.4);\n  --diff-del-content-highlight--: hsl(var(--line-removed) / 0.4);\n  --diff-add-widget--: hsl(var(--muted-foreground) / 0.7);\n  --diff-add-widget-color--: hsl(var(--muted));\n  --diff-empty-content--: hsl(var(--background));\n  --diff-hunk-content-color--: hsl(var(--muted-foreground) / 0.7);\n}\n\n.diff-tailwindcss-wrapper .diff-style-root .diff-line-syntax-raw *,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw * {\n  color: var(--diff-view-light, inherit);\n  font-weight: var(--diff-view-light-font-weight, inherit);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw * {\n  color: var(--diff-view-dark, inherit);\n  font-weight: var(--diff-view-dark-font-weight, inherit);\n}\n\n.diff-tailwindcss-wrapper table,\n.diff-tailwindcss-wrapper tr,\n.diff-tailwindcss-wrapper td {\n  border-color: transparent;\n  /* border-width: 0px; */\n  text-align: left;\n}\n\n.diff-tailwindcss-wrapper .diff-line-old-num,\n.diff-tailwindcss-wrapper .diff-line-new-num,\n.diff-tailwindcss-wrapper .diff-line-num {\n  text-align: right;\n}\n\n.diff-tailwindcss-wrapper .diff-style-root tr {\n  content-visibility: auto;\n}\n\n.diff-tailwindcss-wrapper .diff-add-widget-wrapper {\n  transform-origin: center;\n  transform: translateX(-50%) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-line-old-content .diff-add-widget-wrapper,\n.diff-tailwindcss-wrapper .diff-line-new-content .diff-add-widget-wrapper {\n  transform: translateX(50%) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-add-widget-wrapper:hover {\n  transform: translateX(-50%) scale(1.1) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-line-old-content .diff-add-widget-wrapper:hover,\n.diff-tailwindcss-wrapper\n  .diff-line-new-content\n  .diff-add-widget-wrapper:hover {\n  transform: translateX(50%) scale(1.1) !important;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip {\n  position: relative;\n}\n\n.diff-tailwindcss-wrapper .diff-add-widget,\n.diff-tailwindcss-wrapper .diff-widget-tooltip {\n  font-family: inherit;\n  font-feature-settings: 'liga' off;\n  font-variation-settings: inherit;\n  font-size: 100%;\n  font-weight: inherit;\n  line-height: inherit;\n  letter-spacing: inherit;\n  color: inherit;\n  margin: 0;\n  text-transform: none;\n  /* border-width: 0px; */\n  background-color: transparent;\n  background-image: none;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip::after {\n  display: none;\n  box-sizing: border-box;\n  background-color: #555555;\n  position: absolute;\n  content: attr(data-title);\n  font-size: 11px;\n  padding: 1px 2px;\n  border-radius: 4px;\n  overflow: hidden;\n  top: 50%;\n  white-space: nowrap;\n  transform: translateY(-50%);\n  left: calc(100% + 8px);\n  color: #ffffff;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip::before {\n  display: none;\n  box-sizing: border-box;\n  content: '';\n  position: absolute;\n  top: 50%;\n  left: calc(100% - 2px);\n  transform: translateY(-50%);\n  border: 6px solid transparent;\n  border-right-color: #555555;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip:hover {\n  background-color: var(--diff-hunk-lineNumber-hover--);\n  color: white;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip:hover::before {\n  display: block;\n}\n\n.diff-tailwindcss-wrapper .diff-widget-tooltip:hover::after {\n  display: block;\n}\n\n.diff-line-extend-wrapper * {\n  color: initial;\n}\n\n.diff-line-widget-wrapper * {\n  color: initial;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw pre code.hljs {\n  display: block;\n  overflow-x: auto;\n  padding: 1em;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw code.hljs {\n  padding: 3px 5px;\n}\n\n/*!\n  Theme: GitHub\n  Description: Light theme as seen on github.com\n  Author: github.com\n  Maintainer: @Hirse\n  Updated: 2021-05-15\n\n  Outdated base version: https://github.com/primer/github-syntax-light\n  Current colors taken from GitHub's CSS\n*/\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs {\n  color: var(--syntax-punctuation);\n  background: #ffffff;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-doctag,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-keyword,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-meta .hljs-keyword,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-template-tag,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-template-variable,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-type,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-variable.language_ {\n  /* prettylights-syntax-keyword */\n  color: var(--syntax-keyword);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.class_,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.class_.inherited__,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.function_ {\n  /* prettylights-syntax-entity */\n  color: var(--syntax-function);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-attr,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-attribute,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-literal,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-meta,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-number,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-operator,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-variable,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-attr,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-class,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-id {\n  /* prettylights-syntax-constant */\n  color: var(--syntax-constant);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-regexp,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-string,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-meta .hljs-string {\n  /* prettylights-syntax-string */\n  color: var(--syntax-string);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-built_in,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-symbol {\n  /* prettylights-syntax-variable */\n  color: var(--syntax-variable);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-comment,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-code,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-formula {\n  /* prettylights-syntax-comment */\n  color: var(--syntax-comment);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-name,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-quote,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-tag,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-pseudo {\n  /* prettylights-syntax-entity-tag */\n  color: var(--syntax-tag);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-subst {\n  /* prettylights-syntax-storage-modifier-import */\n  color: var(--syntax-punctuation);\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-section {\n  /* prettylights-syntax-markup-heading */\n  color: var(--syntax-constant);\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-bullet {\n  /* prettylights-syntax-markup-list */\n  color: #735c0f;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-emphasis {\n  /* prettylights-syntax-markup-italic */\n  color: var(--syntax-punctuation);\n  font-style: italic;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-strong {\n  /* prettylights-syntax-markup-bold */\n  color: var(--syntax-punctuation);\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-addition {\n  /* prettylights-syntax-markup-inserted */\n  color: var(--syntax-tag);\n  background-color: #f0fff4;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-deletion {\n  /* prettylights-syntax-markup-deleted */\n  color: var(--syntax-deleted);\n  background-color: #ffeef0;\n}\n\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-char.escape_,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-link,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-params,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-property,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-punctuation,\n.diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-tag {\n  /* purposely ignored */\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  pre\n  code.hljs {\n  display: block;\n  overflow-x: auto;\n  padding: 1em;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw code.hljs {\n  padding: 3px 5px;\n}\n\n/*!\n  Theme: GitHub Dark\n  Description: Dark theme as seen on github.com\n  Author: github.com\n  Maintainer: @Hirse\n  Updated: 2021-05-15\n\n  Outdated base version: https://github.com/primer/github-syntax-dark\n  Current colors taken from GitHub's CSS\n*/\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs {\n  color: var(--syntax-punctuation);\n  background: #0d1117;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-doctag,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-keyword,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-meta\n  .hljs-keyword,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-template-tag,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-template-variable,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-type,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-variable.language_ {\n  /* prettylights-syntax-keyword */\n  color: var(--syntax-keyword);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-title,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-title.class_,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-title.class_.inherited__,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-title.function_ {\n  /* prettylights-syntax-entity */\n  color: var(--syntax-function);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-attr,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-attribute,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-literal,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-meta,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-number,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-operator,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-variable,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-selector-attr,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-selector-class,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-selector-id {\n  /* prettylights-syntax-constant */\n  color: var(--syntax-constant);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-regexp,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-string,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-meta\n  .hljs-string {\n  /* prettylights-syntax-string */\n  color: var(--syntax-string);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-built_in,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-symbol {\n  /* prettylights-syntax-variable */\n  color: var(--syntax-variable);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-comment,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-code,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-formula {\n  /* prettylights-syntax-comment */\n  color: var(--syntax-comment);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-name,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-quote,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-selector-tag,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-selector-pseudo {\n  /* prettylights-syntax-entity-tag */\n  color: var(--syntax-tag);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-subst {\n  /* prettylights-syntax-storage-modifier-import */\n  color: var(--syntax-punctuation);\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-section {\n  /* prettylights-syntax-markup-heading */\n  color: #1f6feb;\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-bullet {\n  /* prettylights-syntax-markup-list */\n  color: #f2cc60;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-emphasis {\n  /* prettylights-syntax-markup-italic */\n  color: var(--syntax-punctuation);\n  font-style: italic;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-strong {\n  /* prettylights-syntax-markup-bold */\n  color: var(--syntax-punctuation);\n  font-weight: bold;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-addition {\n  /* prettylights-syntax-markup-inserted */\n  color: #aff5b4;\n  background-color: #033a16;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-deletion {\n  /* prettylights-syntax-markup-deleted */\n  color: var(--syntax-deleted);\n  background-color: #67060c;\n}\n\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-char.escape_,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-link,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-params,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-property,\n.diff-tailwindcss-wrapper[data-theme='dark']\n  .diff-line-syntax-raw\n  .hljs-punctuation,\n.diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-tag {\n  /* purposely ignored */\n}\n\n.diff-tailwindcss-wrapper .hover\\:scale-110:hover {\n  --tw-scale-x: 1.1;\n  --tw-scale-y: 1.1;\n  transform: translate(var(--tw-translate-x), var(--tw-translate-y))\n    rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))\n    scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));\n}\n\n.diff-tailwindcss-wrapper .group:hover .group-hover\\:visible {\n  visibility: visible;\n}\n\n/* =============================================================================\n   Pierre/Diffs Overrides - Remove black bars (padding on [data-code])\n   ============================================================================= */\n\n/* Remove padding that creates black bars above/below diff content */\ndiffs-container [data-code] {\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\n/* Alternative: Set the gap-block variable to 0 */\ndiffs-container {\n  --diffs-gap-block: 0;\n}\n\n/* =============================================================================\n   Pierre/Diffs Design Overrides - Match Old @git-diff-view/react Design\n   ============================================================================= */\n\n/* Font: Use IBM Plex Mono to match old design */\ndiffs-container {\n  --diffs-font-family: 'IBM Plex Mono', 'SF Mono', Monaco, Consolas, monospace;\n  --diffs-font-size: 12px;\n  --diffs-line-height: 18px;\n}\n\n/* Light Mode Colors - Match old design */\n.light diffs-container,\ndiffs-container[data-theme-type='light'] {\n  /* Addition colors - soft green (HSL 160 77% 88%) */\n  --diffs-light-addition-color: hsl(160, 77%, 40%);\n  --diffs-bg-addition-override: hsl(160, 77%, 88%);\n  --diffs-bg-addition-number-override: hsl(160, 77%, 85%);\n  --diffs-bg-addition-hover-override: hsl(160, 77%, 82%);\n\n  /* Deletion colors - soft red (HSL 10 100% 90%) */\n  --diffs-light-deletion-color: hsl(10, 100%, 45%);\n  --diffs-bg-deletion-override: hsl(10, 100%, 90%);\n  --diffs-bg-deletion-number-override: hsl(10, 100%, 87%);\n  --diffs-bg-deletion-hover-override: hsl(10, 100%, 84%);\n\n  /* Context/unchanged - white background */\n  --diffs-light-bg: hsl(0, 0%, 100%);\n  --diffs-bg-context-override: hsl(0, 0%, 98%);\n\n  /* Line numbers */\n  --diffs-fg-number-override: hsl(0, 0%, 39%);\n}\n\n/* Dark Mode Colors - Match old design */\n.dark diffs-container,\ndiffs-container[data-theme-type='dark'] {\n  /* Addition colors - dark green (HSL 130 30% 20%) */\n  --diffs-dark-addition-color: hsl(130, 50%, 50%);\n  --diffs-bg-addition-override: hsl(130, 30%, 20%);\n  --diffs-bg-addition-number-override: hsl(130, 30%, 18%);\n  --diffs-bg-addition-hover-override: hsl(130, 30%, 25%);\n\n  /* Deletion colors - dark red (HSL 12 30% 18%) */\n  --diffs-dark-deletion-color: hsl(12, 50%, 55%);\n  --diffs-bg-deletion-override: hsl(12, 30%, 18%);\n  --diffs-bg-deletion-number-override: hsl(12, 30%, 16%);\n  --diffs-bg-deletion-hover-override: hsl(12, 30%, 23%);\n\n  /* Context/unchanged - match app's bg-panel (lighter gray, not black) */\n  /* --diffs-bg directly overrides the base since light-dark() may not pick up --diffs-dark-bg */\n  --diffs-bg: hsl(0, 0%, 20%);\n  --diffs-dark-bg: hsl(0, 0%, 20%);\n  --diffs-bg-context-override: hsl(0, 0%, 20%);\n  --diffs-bg-hover-override: hsl(0, 0%, 25%);\n\n  /* Line numbers */\n  --diffs-fg-number-override: hsl(0, 0%, 56%);\n}\n\n/* =============================================================================\n   Hover Utility Button Positioning\n   ============================================================================= */\n\n/*\n * The [data-hover-slot] in shadow DOM is positioned at right:0 of the number column.\n * The slotted content needs to appear BETWEEN the number and code columns.\n * Using left:100% positions it just to the right of the hover slot container.\n */\ndiffs-container [slot='hover-slot'] {\n  left: 100%;\n  right: auto;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding-left: 4px;\n}\n\n/* Style the hover utility button - no visual changes on hover */\ndiffs-container [slot='hover-slot'] button,\ndiffs-container [slot='hover-slot'] button:hover,\ndiffs-container [slot='hover-slot'] button:focus,\ndiffs-container [slot='hover-slot'] button:active {\n  background-color: hsl(var(--brand) / 0.2);\n  color: hsl(var(--brand));\n  border: none;\n  outline: none;\n  box-shadow: none;\n}\n"
  },
  {
    "path": "packages/web-core/src/app/styles/edit-diff-overrides.css",
    "content": "/* Hide line numbers for replace (old/new) diffs rendered via DiffView */\n.edit-diff-hide-nums .diff-line-old-num,\n.edit-diff-hide-nums .diff-line-new-num,\n.edit-diff-hide-nums .diff-line-num,\n.edit-diff-hide-nums .diff-line-hunk-action {\n  display: none !important;\n}\n\n/* Ensure number gutters don't consume space when hidden */\n.edit-diff-hide-nums .diff-line-old-num + .diff-line-old-content,\n.edit-diff-hide-nums .diff-line-new-num + .diff-line-new-content,\n.edit-diff-hide-nums .diff-line-num + .diff-line-content {\n  padding-left: 0 !important;\n}\n\n.plain-file-content .diff-style-root {\n  /* neutralize addition backgrounds */\n  --diff-add-content--: hsl(var(--background));\n  --diff-add-content-highlight--: hsl(var(--background));\n}\n\n.plain-file-content .diff-line-content-operator {\n  display: none !important; /* hide leading '+' operator column */\n}\n\n.plain-file-content .diff-line-content-item {\n  padding-left: 0 !important; /* remove indent left by operator column */\n}\n\n/* hide unified hunk header rows (e.g. @@ -1,+n @@) */\n.plain-file-content .diff-line-hunk-content {\n  display: none !important;\n}\n"
  },
  {
    "path": "packages/web-core/src/app/styles/new/index.css",
    "content": "@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=IBM+Plex+Mono:ital,wght@0,100..700;1,100..700&family=Noto+Emoji:wght@300..700&family=Roboto:wght@500&display=swap');\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* Disable macOS elastic overscroll bounce (visible in Tauri desktop app) */\nhtml {\n  overscroll-behavior: none;\n}\n\n/* 1) THEME TOKENS */\n@layer base {\n  /* Light defaults */\n  :root {\n    color-scheme: light;\n    --_background: 0 0% 95%;\n    --_foreground: 0 0% 5%;\n    --_primary: 0 0% 5%;\n    --_primary-foreground: 0 0% 100%;\n    --_secondary: 0 0% 95%;\n    --_secondary-foreground: 0 0% 16%;\n    --_muted: 0 0% 89%;\n    --_muted-foreground: 0 0% 56%;\n    --_accent: 0 0% 92%;\n    --_accent-foreground: 0 0% 5%;\n    --_destructive: 0 59% 57%;\n    --_destructive-foreground: 0 0% 100%;\n    --_border: 0 0% 85%;\n    --_input: 0 0% 96%;\n    --_ring: 0 0% 5%;\n    --_radius: 0.125rem;\n\n    /* Status (light) */\n    --_success: 117 38% 50%;\n    --_success-foreground: 117 38% 20%;\n    --_warning: 32 95% 44%;\n    --_warning-foreground: 32 95% 14%;\n    --_info: 217 91% 60%;\n    --_info-foreground: 0 0% 5%;\n    --_neutral: 0 0% 92%;\n    --_neutral-foreground: 0 0% 5%;\n\n    /* Console (light) */\n    --_console-background: 0 0% 100%;\n    --_console-foreground: 0 0% 5%;\n    --_console-success: 117 38% 50%;\n    --_console-error: 0 59% 57%;\n\n    /* Syntax highlighting (light) */\n    --_syntax-keyword: #d73a49;\n    --_syntax-function: #6f42c1;\n    --_syntax-constant: #005cc5;\n    --_syntax-string: #032f62;\n    --_syntax-variable: #e36209;\n    --_syntax-comment: #6a737d;\n    --_syntax-tag: #22863a;\n    --_syntax-punctuation: #24292e;\n    --_syntax-deleted: #b31d28;\n\n    /* Text (light) */\n    --text-high: 0 0% 5%;\n    --text-normal: 0 0% 20%;\n    --text-low: 0 0% 39%;\n\n    /* Background (light) - with VS Code overrides */\n    --_bg-primary-default: 0 0% 100%;\n    --_bg-secondary-default: 0 0% 95%;\n    --_bg-panel-default: 0 0% 89%;\n    --bg-primary: var(--vscode-editor-background, var(--_bg-primary-default));\n    --bg-secondary: var(\n      --vscode-editorWidget-background,\n      var(--_bg-secondary-default)\n    );\n    --bg-panel: var(--vscode-input-background, var(--_bg-panel-default));\n\n    /* Accent (light) */\n    --brand: 25 82% 54%;\n    --brand-hover: 25 75% 62%;\n    --brand-secondary: 25 82% 37%;\n    --error: 0 59% 57%;\n    --success: 117 38% 50%;\n    --merged: 271 81% 46%;\n\n    /* Text on accent */\n    --text-on-brand: 0 0% 100%;\n  }\n\n  /* Dark mode */\n  .dark {\n    color-scheme: dark;\n\n    /* Internal variables (dark) - needed for public tokens */\n    --_background: 0 0% 13%;\n    --_foreground: 0 0% 96%;\n    --_primary: 240 4.8% 95.9%;\n    --_primary-foreground: 240 5.9% 10%;\n    --_secondary: 0 0% 12%;\n    --_secondary-foreground: 0 0% 77%;\n    --_muted: 0 0% 16%;\n    --_muted-foreground: 0 0% 64%;\n    --_accent: 0 0% 20%;\n    --_accent-foreground: 0 0% 96%;\n    --_destructive: 0 59% 57%;\n    --_destructive-foreground: 0 0% 96%;\n    --_border: 0 0% 20%;\n    --_input: 0 0% 20%;\n    --_ring: 0 0% 77%;\n\n    /* Status (dark) */\n    --_success: 117 38% 50%;\n    --_success-foreground: 117 38% 90%;\n    --_warning: 32.2 95% 44.1%;\n    --_warning-foreground: 32.2 95% 90%;\n    --_info: 217.2 91.2% 59.8%;\n    --_info-foreground: 217.2 91.2% 90%;\n    --_neutral: 0 0% 20%;\n    --_neutral-foreground: 0 0% 90%;\n\n    /* Text (dark) */\n    --text-high: 0 0% 96%;\n    --text-normal: 0 0% 77%;\n    --text-low: 0 0% 56%;\n\n    /* Background (dark) - with VS Code overrides */\n    --_bg-primary-default: 0 0% 13%;\n    --_bg-secondary-default: 0 0% 11%;\n    --_bg-panel-default: 0 0% 16%;\n    --bg-primary: var(--vscode-editor-background, var(--_bg-primary-default));\n    --bg-secondary: var(\n      --vscode-editorWidget-background,\n      var(--_bg-secondary-default)\n    );\n    --bg-panel: var(--vscode-input-background, var(--_bg-panel-default));\n\n    /* Accent (dark) */\n    --brand: 25 82% 54%;\n    --brand-hover: 25 75% 62%;\n    --brand-secondary: 25 82% 37%;\n    --error: 0 59% 57%;\n    --success: 117 38% 50%;\n    --merged: 271 81% 66%;\n\n    /* Text on accent */\n    --text-on-brand: 0 0% 100%;\n    /* Console (dark) */\n    --_console-background: 0 0% 0%;\n    --_console-foreground: 210 40% 98%;\n    --_console-success: 138.5 76.5% 47.7%;\n    --_console-error: 0 84.2% 60.2%;\n\n    /* Syntax highlighting (dark) */\n    --_syntax-keyword: #ff7b72;\n    --_syntax-function: #d2a8ff;\n    --_syntax-constant: #79c0ff;\n    --_syntax-string: #a5d6ff;\n    --_syntax-variable: #ffa657;\n    --_syntax-comment: #8b949e;\n    --_syntax-tag: #7ee787;\n    --_syntax-punctuation: #c9d1d9;\n    --_syntax-deleted: #ffdcd7;\n  }\n}\n\n/* 2) PUBLIC TOKENS */\n@layer base {\n  :root {\n    --background: var(--vscode-editor-background, var(--_background));\n    --foreground: var(--vscode-editor-foreground, var(--_foreground));\n\n    --card: var(--muted);\n    --card-foreground: var(--muted-foreground);\n    --popover: var(--background);\n    --popover-foreground: var(--foreground);\n\n    --primary: var(--vscode-button-background, var(--_primary));\n    --primary-foreground: var(\n      --vscode-editor-foreground,\n      var(--_primary-foreground)\n    );\n    --secondary: var(--vscode-input-background, var(--_secondary));\n    --secondary-foreground: var(\n      --vscode-input-foreground,\n      var(--_secondary-foreground)\n    );\n\n    --muted: var(--vscode-editor-background, var(--_muted));\n    --muted-foreground: var(\n      --vscode-descriptionForeground,\n      var(--_muted-foreground)\n    );\n    --accent: var(--vscode-focusBorder, var(--_accent));\n    --accent-foreground: var(\n      --vscode-editor-foreground,\n      var(--_accent-foreground)\n    );\n\n    --destructive: var(--vscode-errorForeground, var(--_destructive));\n    --destructive-foreground: var(\n      --vscode-button-foreground,\n      var(--_destructive-foreground)\n    );\n\n    --border: var(--vscode-input-background, var(--_border));\n    --input: var(--vscode-input-background, var(--_input));\n    --ring: var(--vscode-focusBorder, var(--_ring));\n\n    --radius: var(--_radius);\n\n    /* Status */\n    --success: var(--vscode-testing-iconPassed, var(--_success));\n    --success-foreground: var(\n      --vscode-editor-foreground,\n      var(--_success-foreground)\n    );\n    --warning: var(--vscode-testing-iconQueued, var(--_warning));\n    --warning-foreground: var(\n      --vscode-descriptionForeground,\n      var(--_warning-foreground)\n    );\n    --info: var(--vscode-focusBorder, var(--_info));\n    --info-foreground: var(--vscode-editor-foreground, var(--_info-foreground));\n    --neutral: var(--vscode-input-background, var(--_neutral));\n    --neutral-foreground: var(\n      --vscode-editor-foreground,\n      var(--_neutral-foreground)\n    );\n\n    /* Console/terminal */\n    --console-background: var(\n      --vscode-editor-background,\n      var(--_console-background)\n    );\n    --console-foreground: var(\n      --vscode-terminal-foreground,\n      var(--_console-foreground)\n    );\n    --console-success: var(\n      --vscode-testing-iconPassed,\n      var(--_console-success)\n    );\n    --console-error: var(--vscode-terminal-ansiRed, var(--_console-error));\n\n    /* Syntax highlighting */\n    --syntax-keyword: var(--_syntax-keyword);\n    --syntax-function: var(--_syntax-function);\n    --syntax-constant: var(--_syntax-constant);\n    --syntax-string: var(--_syntax-string);\n    --syntax-variable: var(--_syntax-variable);\n    --syntax-comment: var(--_syntax-comment);\n    --syntax-tag: var(--_syntax-tag);\n    --syntax-punctuation: var(--_syntax-punctuation);\n    --syntax-deleted: var(--_syntax-deleted);\n\n    /* Allotment */\n    --separator-border: hsl(var(--border));\n    --focus-border: hsl(var(--brand));\n  }\n}\n\n/* 3) Base styles */\n@layer base {\n  *,\n  *::before,\n  *::after {\n    @apply border-border;\n  }\n\n  html,\n  body,\n  #root {\n    @apply min-h-screen;\n    @apply font-normal;\n    @apply bg-background text-foreground font-ibm-plex-sans;\n  }\n\n  *:focus {\n    @apply ring-inset;\n  }\n\n  .logo {\n    @apply fill-foreground;\n  }\n\n  a,\n  button,\n  button * {\n    @apply transition-colors duration-200;\n    cursor: pointer;\n  }\n}\n\n/* ANSI color classes for fancy-ansi - optimized for both light and dark modes */\n@layer components {\n  /* Light mode: use darker shades for contrast on light backgrounds */\n  .ansi-red {\n    @apply text-red-700 dark:text-red-400;\n  }\n\n  .ansi-green {\n    @apply text-green-700 dark:text-green-400;\n  }\n\n  .ansi-yellow {\n    @apply text-yellow-700 dark:text-yellow-400;\n  }\n\n  .ansi-blue {\n    @apply text-blue-700 dark:text-blue-400;\n  }\n\n  .ansi-magenta {\n    @apply text-purple-700 dark:text-purple-400;\n  }\n\n  .ansi-cyan {\n    @apply text-cyan-700 dark:text-cyan-400;\n  }\n\n  .ansi-white {\n    @apply text-gray-600 dark:text-gray-200;\n  }\n\n  .ansi-black {\n    @apply text-black dark:text-gray-900;\n  }\n\n  .ansi-bright-red {\n    @apply text-red-600 dark:text-red-300;\n  }\n\n  .ansi-bright-green {\n    @apply text-green-600 dark:text-green-300;\n  }\n\n  .ansi-bright-yellow {\n    @apply text-amber-600 dark:text-yellow-300;\n  }\n\n  .ansi-bright-blue {\n    @apply text-blue-600 dark:text-blue-300;\n  }\n\n  .ansi-bright-magenta {\n    @apply text-purple-600 dark:text-purple-300;\n  }\n\n  .ansi-bright-cyan {\n    @apply text-cyan-600 dark:text-cyan-300;\n  }\n\n  .ansi-bright-white {\n    @apply text-gray-500 dark:text-gray-100;\n  }\n\n  .ansi-bright-black {\n    @apply text-gray-600 dark:text-gray-500;\n  }\n\n  .ansi-bold {\n    @apply font-bold;\n  }\n\n  .ansi-italic {\n    @apply italic;\n  }\n\n  .ansi-underline {\n    @apply underline;\n  }\n}\n\n/* Animated border for running chat box */\n@keyframes border-flash {\n  0% {\n    background-position: 200% 0;\n  }\n\n  100% {\n    background-position: -200% 0;\n  }\n}\n\n@layer components {\n  .chat-box-running {\n    @apply relative;\n  }\n\n  .chat-box-running::before {\n    content: '';\n    @apply absolute inset-0 pointer-events-none rounded-t-md;\n    padding: 1px;\n    padding-bottom: 0;\n    background: linear-gradient(\n      45deg,\n      transparent,\n      hsl(var(--brand-secondary)),\n      transparent\n    );\n    background-size: 200% 100%;\n    animation: border-flash 2s linear infinite;\n    -webkit-mask:\n      linear-gradient(#fff 0 0) content-box,\n      linear-gradient(#fff 0 0);\n    mask:\n      linear-gradient(#fff 0 0) content-box,\n      linear-gradient(#fff 0 0);\n    -webkit-mask-composite: xor;\n    mask-composite: exclude;\n  }\n\n  .create-issue-attention {\n    @apply relative;\n  }\n\n  .create-issue-attention::before {\n    content: '';\n    @apply absolute inset-0 pointer-events-none rounded-sm;\n    padding: 1px;\n    background: linear-gradient(\n      120deg,\n      transparent,\n      hsl(var(--brand)),\n      transparent\n    );\n    background-size: 220% 100%;\n    animation: border-flash 1.2s linear infinite;\n    -webkit-mask:\n      linear-gradient(#fff 0 0) content-box,\n      linear-gradient(#fff 0 0);\n    mask:\n      linear-gradient(#fff 0 0) content-box,\n      linear-gradient(#fff 0 0);\n    -webkit-mask-composite: xor;\n    mask-composite: exclude;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .create-issue-attention::before {\n      animation: none;\n    }\n  }\n}\n\n/* Color emoji utility - uses system color emoji fonts for reactions */\n@layer utilities {\n  .color-emoji {\n    font-family:\n      'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji',\n      'Android Emoji', 'EmojiSymbols', sans-serif;\n  }\n}\n\n@config '../../../../../local-web/tailwind.new.config.js';\n\n/* Hide Virtuoso Message List license watermark warning (scoped to virtuoso wrappers only) */\n.virtuoso-license-wrapper\n  > div[style*='color: red'][style*='pointer-events: none'] {\n  display: none !important;\n}\n\n/* Mobile-specific styles */\n@media (max-width: 767px) {\n  html,\n  body,\n  #root {\n    overscroll-behavior: none;\n  }\n\n  html {\n    font-size: var(--mobile-font-scale, 100%);\n  }\n\n  /* Background colors for PWA safe-area */\n  html,\n  body {\n    background-color: #f2f2f2;\n  }\n\n  html.dark body,\n  html.dark {\n    background-color: #212121;\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/features/create-mode/model/CreateModeProvider.tsx",
    "content": "import { useMemo, type ReactNode } from 'react';\nimport type { CreateModeInitialState } from '@/shared/types/createMode';\nimport { useCreateModeState } from '@/features/create-mode/model/useCreateModeState';\nimport { useWorkspaces } from '@/shared/hooks/useWorkspaces';\nimport { useUserContext } from '@/shared/hooks/useUserContext';\nimport {\n  CreateModeContext,\n  type CreateModeContextValue,\n} from '@/features/create-mode/model/useCreateMode';\n\ninterface CreateModeProviderProps {\n  children: ReactNode;\n  initialState?: CreateModeInitialState | null;\n  draftId?: string | null;\n}\n\nexport function CreateModeProvider({\n  children,\n  initialState,\n  draftId,\n}: CreateModeProviderProps) {\n  // Fetch most recent workspace to seed project selection only\n  const {\n    workspaces: activeWorkspaces,\n    archivedWorkspaces,\n    isLoading: localWorkspacesLoading,\n  } = useWorkspaces();\n  const { workspaces: remoteWorkspaces, isLoading: remoteWorkspacesLoading } =\n    useUserContext();\n  const mostRecentWorkspace = activeWorkspaces[0] ?? archivedWorkspaces[0];\n  const localWorkspaceIds = useMemo(\n    () =>\n      new Set([\n        ...activeWorkspaces.map((workspace) => workspace.id),\n        ...archivedWorkspaces.map((workspace) => workspace.id),\n      ]),\n    [activeWorkspaces, archivedWorkspaces]\n  );\n\n  const state = useCreateModeState({\n    initialState,\n    draftId,\n    lastWorkspaceId: mostRecentWorkspace?.id ?? null,\n    remoteWorkspaces,\n    localWorkspaceIds,\n    localWorkspacesLoading,\n    remoteWorkspacesLoading,\n  });\n\n  const value = useMemo<CreateModeContextValue>(\n    () => ({\n      repos: state.repos,\n      addRepo: state.addRepo,\n      removeRepo: state.removeRepo,\n      clearRepos: state.clearRepos,\n      targetBranches: state.targetBranches,\n      setTargetBranch: state.setTargetBranch,\n      hasResolvedInitialRepoDefaults: state.hasResolvedInitialRepoDefaults,\n      preferredExecutorConfig: state.preferredExecutorConfig,\n      message: state.message,\n      setMessage: state.setMessage,\n      clearDraft: state.clearDraft,\n      hasInitialValue: state.hasInitialValue,\n      linkedIssue: state.linkedIssue,\n      clearLinkedIssue: state.clearLinkedIssue,\n      executorConfig: state.executorConfig,\n      setExecutorConfig: state.setExecutorConfig,\n      attachments: state.attachments,\n      setAttachments: state.setAttachments,\n    }),\n    [\n      state.repos,\n      state.addRepo,\n      state.removeRepo,\n      state.clearRepos,\n      state.targetBranches,\n      state.setTargetBranch,\n      state.hasResolvedInitialRepoDefaults,\n      state.preferredExecutorConfig,\n      state.message,\n      state.setMessage,\n      state.clearDraft,\n      state.hasInitialValue,\n      state.linkedIssue,\n      state.clearLinkedIssue,\n      state.executorConfig,\n      state.setExecutorConfig,\n      state.attachments,\n      state.setAttachments,\n    ]\n  );\n\n  return (\n    <CreateModeContext.Provider value={value}>\n      {children}\n    </CreateModeContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/create-mode/model/createModeBootstrap.ts",
    "content": "import type {\n  DraftWorkspaceData,\n  DraftWorkspaceAttachment,\n  ExecutorConfig,\n  Repo,\n} from 'shared/types';\nimport { repoApi } from '@/shared/lib/api';\nimport type {\n  CreateModeInitialState,\n  LinkedIssue,\n} from '@/shared/types/createMode';\n\nexport interface BootstrapSelectedRepo {\n  repo: Repo;\n  targetBranch: string | null;\n}\n\nexport interface CreateModeBootstrapData {\n  message?: string;\n  linkedIssue?: LinkedIssue | null;\n  repos?: BootstrapSelectedRepo[];\n  executorConfig?: ExecutorConfig | null;\n  attachments?: DraftWorkspaceAttachment[];\n}\n\nexport interface ResolveCreateModeBootstrapParams {\n  seedState: CreateModeInitialState | null;\n  scratchData?: DraftWorkspaceData;\n  defaultExecutorConfig?: ExecutorConfig | null;\n  isValidProfile: (config: ExecutorConfig | null) => boolean;\n}\n\nexport interface ResolveCreateModeBootstrapResult {\n  source: 'seed' | 'scratch' | 'fresh';\n  data: CreateModeBootstrapData;\n}\n\ninterface PreferredRepoInput {\n  repo_id: string;\n  target_branch: string | null;\n}\n\nexport async function resolveBootstrapRepos(\n  preferredRepos: PreferredRepoInput[]\n): Promise<BootstrapSelectedRepo[]> {\n  const reposById = new Map<string, Repo>();\n\n  const missingRepoIds = preferredRepos\n    .map((repo) => repo.repo_id)\n    .filter((repoId) => !reposById.has(repoId));\n\n  if (missingRepoIds.length > 0) {\n    const fetchedRepos = await Promise.all(\n      missingRepoIds.map(async (repoId) => {\n        try {\n          return await repoApi.getById(repoId);\n        } catch {\n          return null;\n        }\n      })\n    );\n\n    for (const repo of fetchedRepos) {\n      if (repo) {\n        reposById.set(repo.id, repo);\n      }\n    }\n  }\n\n  return preferredRepos.flatMap((preferredRepo) => {\n    const repo = reposById.get(preferredRepo.repo_id);\n    if (!repo) return [];\n\n    return [\n      {\n        repo,\n        targetBranch: preferredRepo.target_branch ?? null,\n      },\n    ];\n  });\n}\n\nexport async function resolveCreateModeBootstrap({\n  seedState,\n  scratchData,\n  defaultExecutorConfig,\n  isValidProfile,\n}: ResolveCreateModeBootstrapParams): Promise<ResolveCreateModeBootstrapResult> {\n  const hasInitialPrompt = !!seedState?.initialPrompt;\n  const hasLinkedIssue = !!seedState?.linkedIssue;\n  const hasPreferredRepos = (seedState?.preferredRepos?.length ?? 0) > 0;\n  const hasExecutorConfig = !!seedState?.executorConfig;\n\n  if (\n    hasInitialPrompt ||\n    hasLinkedIssue ||\n    hasPreferredRepos ||\n    hasExecutorConfig\n  ) {\n    const data: CreateModeBootstrapData = {};\n    let appliedSeedState = false;\n\n    if (hasInitialPrompt) {\n      data.message = seedState!.initialPrompt!;\n      appliedSeedState = true;\n    }\n\n    if (hasLinkedIssue) {\n      data.linkedIssue = seedState!.linkedIssue!;\n      appliedSeedState = true;\n    }\n\n    if (seedState?.preferredRepos && seedState.preferredRepos.length > 0) {\n      const resolvedRepos = await resolveBootstrapRepos(\n        seedState.preferredRepos\n      );\n      if (resolvedRepos.length > 0) {\n        data.repos = resolvedRepos;\n        appliedSeedState = true;\n      }\n    }\n\n    if (seedState?.executorConfig && isValidProfile(seedState.executorConfig)) {\n      data.executorConfig = seedState.executorConfig;\n      appliedSeedState = true;\n    }\n\n    if (appliedSeedState) {\n      return {\n        source: 'seed',\n        data,\n      };\n    }\n  }\n\n  if (scratchData) {\n    const data: CreateModeBootstrapData = {};\n\n    if (scratchData.message) {\n      data.message = scratchData.message;\n    }\n\n    if (\n      scratchData.executor_config &&\n      isValidProfile(scratchData.executor_config)\n    ) {\n      data.executorConfig = scratchData.executor_config;\n    }\n\n    if (scratchData.linked_issue) {\n      data.linkedIssue = {\n        issueId: scratchData.linked_issue.issue_id,\n        simpleId: scratchData.linked_issue.simple_id || undefined,\n        title: scratchData.linked_issue.title || undefined,\n        remoteProjectId: scratchData.linked_issue.remote_project_id,\n      };\n    }\n\n    if (scratchData.attachments?.length > 0) {\n      data.attachments = scratchData.attachments;\n    }\n\n    if (scratchData.repos?.length > 0) {\n      const restoredRepos = await resolveBootstrapRepos(\n        scratchData.repos.map((repo) => ({\n          repo_id: repo.repo_id,\n          target_branch: repo.target_branch ?? null,\n        }))\n      );\n\n      if (restoredRepos.length > 0) {\n        data.repos = restoredRepos;\n      }\n    }\n\n    return {\n      source: 'scratch',\n      data,\n    };\n  }\n\n  return {\n    source: 'fresh',\n    data:\n      defaultExecutorConfig && isValidProfile(defaultExecutorConfig)\n        ? { executorConfig: defaultExecutorConfig }\n        : {},\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/create-mode/model/createModeSeedStore.ts",
    "content": "import type { CreateModeInitialState } from '@/shared/types/createMode';\n\n// Synchronous bridge: actions set state here before navigation,\n// WorkspacesLayout consumes it on mount. Bypasses async scratch WebSocket\n// so Priority 1 in initializeState always gets the data.\n\nlet pendingSeedState: CreateModeInitialState | null = null;\nlet seedVersion = 0;\nconst listeners = new Set<() => void>();\n\nfunction notifySeedListeners(): void {\n  for (const listener of listeners) {\n    listener();\n  }\n}\n\nexport function setCreateModeSeedState(\n  state: CreateModeInitialState | null\n): void {\n  pendingSeedState = state;\n  seedVersion += 1;\n  notifySeedListeners();\n}\n\nexport function consumeCreateModeSeedState(): CreateModeInitialState | null {\n  const state = pendingSeedState;\n  pendingSeedState = null;\n  return state;\n}\n\nexport function subscribeCreateModeSeedState(listener: () => void): () => void {\n  listeners.add(listener);\n\n  return () => {\n    listeners.delete(listener);\n  };\n}\n\nexport function getCreateModeSeedVersion(): number {\n  return seedVersion;\n}\n"
  },
  {
    "path": "packages/web-core/src/features/create-mode/model/useCreateMode.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type {\n  DraftWorkspaceAttachment,\n  Repo,\n  ExecutorConfig,\n} from 'shared/types';\n\ninterface LinkedIssue {\n  issueId: string;\n  simpleId?: string;\n  title?: string;\n  remoteProjectId: string;\n}\n\nexport interface CreateModeContextValue {\n  repos: Repo[];\n  addRepo: (repo: Repo) => void;\n  removeRepo: (repoId: string) => void;\n  clearRepos: () => void;\n  targetBranches: Record<string, string | null>;\n  setTargetBranch: (repoId: string, branch: string) => void;\n  hasResolvedInitialRepoDefaults: boolean;\n  preferredExecutorConfig: ExecutorConfig | null;\n  message: string;\n  setMessage: (message: string) => void;\n  clearDraft: () => Promise<void>;\n  /** Whether the initial value has been applied from scratch */\n  hasInitialValue: boolean;\n  /** Issue to link the workspace to when created */\n  linkedIssue: LinkedIssue | null;\n  /** Clear the linked issue */\n  clearLinkedIssue: () => void;\n  /** Persisted executor config (model selector state) */\n  executorConfig: ExecutorConfig | null;\n  /** Update executor config (triggers debounced scratch save) */\n  setExecutorConfig: (config: ExecutorConfig | null) => void;\n  /** Uploaded attachments persisted in the draft */\n  attachments: DraftWorkspaceAttachment[];\n  /** Update draft attachments (triggers debounced scratch save) */\n  setAttachments: (attachments: DraftWorkspaceAttachment[]) => void;\n}\n\nexport const CreateModeContext =\n  createHmrContext<CreateModeContextValue | null>('CreateModeContext', null);\n\nexport function useCreateMode() {\n  const context = useContext(CreateModeContext);\n  if (!context) {\n    throw new Error('useCreateMode must be used within a CreateModeProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/features/create-mode/model/useCreateModeState.ts",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useReducer,\n  useRef,\n  useState,\n} from 'react';\nimport type {\n  DraftWorkspaceData,\n  DraftWorkspaceAttachment,\n  ExecutorConfig,\n  Repo,\n} from 'shared/types';\nimport { ScratchType } from 'shared/types';\nimport {\n  PROJECT_ISSUES_SHAPE,\n  type Workspace as RemoteWorkspace,\n} from 'shared/remote-types';\nimport { useScratch } from '@/shared/hooks/useScratch';\nimport { useDebouncedCallback } from '@/shared/hooks/useDebouncedCallback';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport { repoApi } from '@/shared/lib/api';\nimport { resolveCreateModeBootstrap } from '@/features/create-mode/model/createModeBootstrap';\nimport { useWorkspaceCreateDefaults } from '@/shared/hooks/useWorkspaceCreateDefaults';\nimport { getValidProjectRepoDefaults } from '@/shared/hooks/useProjectRepoDefaults';\nimport type {\n  CreateModeInitialState,\n  LinkedIssue,\n} from '@/shared/types/createMode';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/** Unified repo model - keeps repo and branch together */\ninterface SelectedRepo {\n  repo: Repo;\n  targetBranch: string | null;\n}\n\ntype Phase = 'loading' | 'ready' | 'error';\n\ninterface DraftState {\n  phase: Phase;\n  error: string | null;\n  repos: SelectedRepo[];\n  message: string;\n  linkedIssue: LinkedIssue | null;\n  executorConfig: ExecutorConfig | null;\n  attachments: DraftWorkspaceAttachment[];\n}\n\ntype DraftAction =\n  | {\n      type: 'INIT_COMPLETE';\n      data: Partial<Omit<DraftState, 'phase' | 'error'>>;\n    }\n  | { type: 'INIT_ERROR'; error: string }\n  | { type: 'SET_PROJECT'; projectId: string | null }\n  | { type: 'ADD_REPO'; repo: Repo; targetBranch: string | null }\n  | { type: 'SET_REPOS_IF_EMPTY'; repos: SelectedRepo[] }\n  | { type: 'REMOVE_REPO'; repoId: string }\n  | { type: 'SET_TARGET_BRANCH'; repoId: string; branch: string }\n  | { type: 'SET_MESSAGE'; message: string }\n  | { type: 'CLEAR_REPOS' }\n  | { type: 'CLEAR' }\n  | { type: 'CLEAR_LINKED_ISSUE' }\n  | { type: 'RESOLVE_LINKED_ISSUE'; simpleId: string; title: string }\n  | {\n      type: 'SET_EXECUTOR_CONFIG';\n      config: ExecutorConfig | null;\n    }\n  | { type: 'SET_ATTACHMENTS'; attachments: DraftWorkspaceAttachment[] };\n\n// ============================================================================\n// Reducer\n// ============================================================================\n\nconst draftInitialState: DraftState = {\n  phase: 'loading',\n  error: null,\n  repos: [],\n  message: '',\n  linkedIssue: null,\n  executorConfig: null,\n  attachments: [],\n};\n\nfunction draftReducer(state: DraftState, action: DraftAction): DraftState {\n  switch (action.type) {\n    case 'INIT_COMPLETE':\n      return {\n        ...state,\n        phase: 'ready',\n        error: null,\n        ...action.data,\n      };\n\n    case 'INIT_ERROR':\n      return {\n        ...state,\n        phase: 'error',\n        error: action.error,\n      };\n\n    case 'ADD_REPO': {\n      // Don't add duplicate repos\n      if (state.repos.some((r) => r.repo.id === action.repo.id)) {\n        return state;\n      }\n      return {\n        ...state,\n        repos: [\n          ...state.repos,\n          { repo: action.repo, targetBranch: action.targetBranch },\n        ],\n      };\n    }\n\n    case 'SET_REPOS_IF_EMPTY':\n      if (state.repos.length > 0) {\n        return state;\n      }\n      return { ...state, repos: action.repos };\n\n    case 'REMOVE_REPO':\n      return {\n        ...state,\n        repos: state.repos.filter((r) => r.repo.id !== action.repoId),\n      };\n\n    case 'SET_TARGET_BRANCH':\n      return {\n        ...state,\n        repos: state.repos.map((r) =>\n          r.repo.id === action.repoId\n            ? { ...r, targetBranch: action.branch }\n            : r\n        ),\n      };\n\n    case 'SET_MESSAGE':\n      return { ...state, message: action.message };\n\n    case 'CLEAR_REPOS':\n      return { ...state, repos: [] };\n\n    case 'CLEAR':\n      return { ...draftInitialState, phase: 'ready' };\n\n    case 'CLEAR_LINKED_ISSUE':\n      return { ...state, linkedIssue: null };\n\n    case 'RESOLVE_LINKED_ISSUE':\n      if (!state.linkedIssue) return state;\n      return {\n        ...state,\n        linkedIssue: {\n          ...state.linkedIssue,\n          simpleId: action.simpleId,\n          title: action.title,\n        },\n      };\n\n    case 'SET_EXECUTOR_CONFIG':\n      return { ...state, executorConfig: action.config };\n\n    case 'SET_ATTACHMENTS':\n      return { ...state, attachments: action.attachments };\n\n    default:\n      return state;\n  }\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst DRAFT_WORKSPACE_ID = '00000000-0000-0000-0000-000000000001';\n\nfunction getLatestWorkspaceIdForRemoteProject({\n  remoteWorkspaces,\n  localWorkspaceIds,\n  remoteProjectId,\n}: {\n  remoteWorkspaces: RemoteWorkspace[];\n  localWorkspaceIds: Set<string>;\n  remoteProjectId: string;\n}): string | null {\n  let latestWorkspaceId: string | null = null;\n  let latestUpdatedAt = Number.NEGATIVE_INFINITY;\n\n  for (const workspace of remoteWorkspaces) {\n    if (!workspace.issue_id) continue;\n    if (workspace.project_id !== remoteProjectId) continue;\n    if (!workspace.local_workspace_id) continue;\n    if (!localWorkspaceIds.has(workspace.local_workspace_id)) continue;\n\n    const updatedAt = new Date(workspace.updated_at).getTime();\n    if (updatedAt > latestUpdatedAt) {\n      latestUpdatedAt = updatedAt;\n      latestWorkspaceId = workspace.local_workspace_id;\n    }\n  }\n\n  return latestWorkspaceId;\n}\n\n// ============================================================================\n// Hook\n// ============================================================================\n\ninterface UseCreateModeStateParams {\n  initialState?: CreateModeInitialState | null;\n  draftId?: string | null;\n  lastWorkspaceId: string | null;\n  remoteWorkspaces: RemoteWorkspace[];\n  localWorkspaceIds: Set<string>;\n  localWorkspacesLoading: boolean;\n  remoteWorkspacesLoading: boolean;\n}\n\ninterface UseCreateModeStateResult {\n  repos: Repo[];\n  targetBranches: Record<string, string | null>;\n  hasResolvedInitialRepoDefaults: boolean;\n  preferredExecutorConfig: ExecutorConfig | null;\n  message: string;\n  isLoading: boolean;\n  hasInitialValue: boolean;\n  linkedIssue: LinkedIssue | null;\n  executorConfig: ExecutorConfig | null;\n  setMessage: (message: string) => void;\n  addRepo: (repo: Repo) => void;\n  removeRepo: (repoId: string) => void;\n  clearRepos: () => void;\n  setTargetBranch: (repoId: string, branch: string) => void;\n  clearDraft: () => Promise<void>;\n  clearLinkedIssue: () => void;\n  setExecutorConfig: (config: ExecutorConfig | null) => void;\n  attachments: DraftWorkspaceAttachment[];\n  setAttachments: (attachments: DraftWorkspaceAttachment[]) => void;\n}\n\nexport function useCreateModeState({\n  initialState,\n  draftId,\n  lastWorkspaceId,\n  remoteWorkspaces,\n  localWorkspaceIds,\n  localWorkspacesLoading,\n  remoteWorkspacesLoading,\n}: UseCreateModeStateParams): UseCreateModeStateResult {\n  const { profiles, config, loading: systemLoading } = useUserSystem();\n  const scratchId = draftId ?? DRAFT_WORKSPACE_ID;\n\n  const {\n    scratch,\n    updateScratch,\n    deleteScratch,\n    isLoading: scratchLoading,\n  } = useScratch(ScratchType.DRAFT_WORKSPACE, scratchId);\n\n  const [state, dispatch] = useReducer(draftReducer, draftInitialState);\n\n  // Capture initial seed state once on mount.\n  const seedStateRef = useRef<CreateModeInitialState | null>(\n    initialState ?? null\n  );\n  const hasInitialized = useRef(false);\n\n  // Profile validator\n  const isValidProfile = useCallback(\n    (config: ExecutorConfig | null): boolean => {\n      if (!config || !profiles) return false;\n      const { executor, variant } = config;\n      if (!(executor in profiles)) return false;\n      if (variant === null || variant === undefined) return true;\n      return variant in profiles[executor];\n    },\n    [profiles]\n  );\n\n  // ============================================================================\n  // Single initialization effect\n  // ============================================================================\n  useEffect(() => {\n    if (hasInitialized.current) return;\n    if (scratchLoading) return;\n    if (systemLoading) return;\n    if (!profiles) return;\n\n    hasInitialized.current = true;\n    const seedState = seedStateRef.current;\n    const scratchData: DraftWorkspaceData | undefined =\n      scratch?.payload?.type === 'DRAFT_WORKSPACE'\n        ? scratch.payload.data\n        : undefined;\n\n    void resolveCreateModeBootstrap({\n      seedState,\n      scratchData,\n      defaultExecutorConfig: config?.executor_profile\n        ? {\n            executor: config.executor_profile.executor,\n            variant: config.executor_profile.variant,\n          }\n        : null,\n      isValidProfile,\n    })\n      .then(({ data }) => {\n        dispatch({ type: 'INIT_COMPLETE', data });\n      })\n      .catch((e) => {\n        console.error('[useCreateModeState] Initialization failed:', e);\n        dispatch({\n          type: 'INIT_ERROR',\n          error: e instanceof Error ? e.message : 'Failed to initialize',\n        });\n      });\n  }, [\n    scratchLoading,\n    systemLoading,\n    profiles,\n    config?.executor_profile,\n    scratch,\n    isValidProfile,\n  ]);\n\n  // ============================================================================\n  // Auto-select project when none selected\n  // ============================================================================\n  const hasAttemptedAutoSelect = useRef(false);\n  const repoDefaultsSourceRef = useRef<string | null>(null);\n  const hasAppliedRepoDefaultsRef = useRef(false);\n  const [projectDefaultsStatus, setProjectDefaultsStatus] = useState<\n    'pending' | 'applied' | 'empty' | 'n/a'\n  >('pending');\n  const sourceWorkspaceId = useMemo(() => {\n    if (state.linkedIssue) {\n      const linkedIssueWorkspaceId = getLatestWorkspaceIdForRemoteProject({\n        remoteWorkspaces,\n        localWorkspaceIds,\n        remoteProjectId: state.linkedIssue.remoteProjectId,\n      });\n      return linkedIssueWorkspaceId ?? lastWorkspaceId;\n    }\n    return lastWorkspaceId;\n  }, [state.linkedIssue, remoteWorkspaces, localWorkspaceIds, lastWorkspaceId]);\n\n  const shouldLoadWorkspaceDefaults =\n    state.phase === 'ready' &&\n    !localWorkspacesLoading &&\n    (!state.linkedIssue || !remoteWorkspacesLoading);\n\n  const { preferredRepos, preferredExecutorConfig, hasResolvedPreferredRepos } =\n    useWorkspaceCreateDefaults({\n      sourceWorkspaceId,\n      enabled: shouldLoadWorkspaceDefaults,\n    });\n\n  const hasResolvedInitialRepoDefaults =\n    (state.phase === 'ready' &&\n      !localWorkspacesLoading &&\n      (!state.linkedIssue || !remoteWorkspacesLoading) &&\n      hasResolvedPreferredRepos &&\n      (preferredRepos.length === 0 ||\n        state.repos.length > 0 ||\n        hasAppliedRepoDefaultsRef.current)) ||\n    state.repos.length > 0;\n\n  useEffect(() => {\n    if (state.phase !== 'ready') return;\n    if (hasAttemptedAutoSelect.current) return;\n\n    hasAttemptedAutoSelect.current = true;\n  }, [state.phase]);\n\n  // When no linked issue with a project, mark project defaults as not applicable\n  useEffect(() => {\n    if (state.phase !== 'ready') return;\n    if (!state.linkedIssue?.remoteProjectId) {\n      setProjectDefaultsStatus('n/a');\n    }\n  }, [state.phase, state.linkedIssue?.remoteProjectId]);\n\n  // ============================================================================\n  // Auto-apply repos/branches defaults for fresh drafts\n  // ============================================================================\n  useEffect(() => {\n    if (repoDefaultsSourceRef.current === sourceWorkspaceId) return;\n    repoDefaultsSourceRef.current = sourceWorkspaceId;\n    hasAppliedRepoDefaultsRef.current = false;\n  }, [sourceWorkspaceId]);\n\n  // When project defaults resolve as empty, allow Effect A to fire as fallback\n  useEffect(() => {\n    if (projectDefaultsStatus === 'empty') {\n      hasAppliedRepoDefaultsRef.current = false;\n    }\n  }, [projectDefaultsStatus]);\n\n  useEffect(() => {\n    if (!shouldLoadWorkspaceDefaults) return;\n    if (!hasResolvedPreferredRepos) return;\n    // When a project is linked, wait for project defaults to resolve first\n    if (\n      state.linkedIssue?.remoteProjectId &&\n      projectDefaultsStatus === 'pending'\n    )\n      return;\n    if (hasAppliedRepoDefaultsRef.current) return;\n\n    hasAppliedRepoDefaultsRef.current = true;\n    if (state.repos.length > 0) return;\n    if (preferredRepos.length === 0) return;\n\n    dispatch({\n      type: 'SET_REPOS_IF_EMPTY',\n      repos: preferredRepos.map((repo) => ({\n        repo,\n        targetBranch: repo.target_branch || null,\n      })),\n    });\n  }, [\n    shouldLoadWorkspaceDefaults,\n    hasResolvedPreferredRepos,\n    state.repos.length,\n    preferredRepos,\n    projectDefaultsStatus,\n    state.linkedIssue?.remoteProjectId,\n  ]);\n\n  // ============================================================================\n  // Scratch project-repo defaults (async, non-blocking)\n  // ============================================================================\n  const scratchDefaultsProjectRef = useRef<string | null>(null);\n\n  useEffect(() => {\n    const remoteProjectId = state.linkedIssue?.remoteProjectId;\n    if (!remoteProjectId) return;\n    if (state.repos.length > 0) return;\n    if (scratchDefaultsProjectRef.current === remoteProjectId) return;\n\n    scratchDefaultsProjectRef.current = remoteProjectId;\n    let cancelled = false;\n\n    (async () => {\n      try {\n        const allRepos = await repoApi.list();\n        if (cancelled) return;\n\n        const availableRepoIds = new Set(allRepos.map((r) => r.id));\n        const scratchDefaults = await getValidProjectRepoDefaults(\n          remoteProjectId,\n          availableRepoIds\n        );\n        if (cancelled) return;\n\n        if (scratchDefaults.length === 0) {\n          setProjectDefaultsStatus('empty');\n          return;\n        }\n\n        const reposById = new Map(allRepos.map((r) => [r.id, r]));\n        const selectedRepos = scratchDefaults.flatMap((d) => {\n          const repo = reposById.get(d.repo_id);\n          if (!repo) return [];\n          return [{ repo, targetBranch: d.target_branch || null }];\n        });\n\n        if (selectedRepos.length > 0) {\n          dispatch({ type: 'SET_REPOS_IF_EMPTY', repos: selectedRepos });\n          setProjectDefaultsStatus('applied');\n        } else {\n          setProjectDefaultsStatus('empty');\n        }\n      } catch (err) {\n        console.warn(\n          '[useCreateModeState] Scratch defaults lookup failed:',\n          err\n        );\n        setProjectDefaultsStatus('empty');\n      }\n    })();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [state.linkedIssue?.remoteProjectId, state.repos.length]);\n\n  // ============================================================================\n  // Persistence to scratch (debounced)\n  // ============================================================================\n  const { debounced: debouncedSave } = useDebouncedCallback(\n    async (data: DraftWorkspaceData) => {\n      const isEmpty =\n        !data.message.trim() &&\n        data.repos.length === 0 &&\n        !data.executor_config &&\n        data.attachments.length === 0;\n\n      if (isEmpty && !scratch) return;\n\n      try {\n        await updateScratch({\n          payload: { type: 'DRAFT_WORKSPACE', data },\n        });\n      } catch (e) {\n        console.error('[useCreateModeState] Failed to save:', e);\n      }\n    },\n    500\n  );\n\n  useEffect(() => {\n    if (state.phase !== 'ready') return;\n\n    debouncedSave({\n      message: state.message,\n      repos: state.repos.map((r) => ({\n        repo_id: r.repo.id,\n        target_branch: r.targetBranch ?? '',\n      })),\n      executor_config: state.executorConfig ?? null,\n      linked_issue: state.linkedIssue\n        ? {\n            issue_id: state.linkedIssue.issueId,\n            simple_id: state.linkedIssue.simpleId ?? '',\n            title: state.linkedIssue.title ?? '',\n            remote_project_id: state.linkedIssue.remoteProjectId,\n          }\n        : null,\n      attachments: state.attachments,\n    });\n  }, [\n    state.phase,\n    state.message,\n    state.repos,\n    state.linkedIssue,\n    state.executorConfig,\n    state.attachments,\n    debouncedSave,\n  ]);\n\n  // ============================================================================\n  // Resolve linked issue details from Electric (when simpleId/title are missing)\n  // ============================================================================\n  const needsIssueResolution =\n    !!state.linkedIssue && !state.linkedIssue.simpleId;\n  const issueProjectId = state.linkedIssue?.remoteProjectId ?? '';\n\n  const { data: issuesForResolution } = useShape(\n    PROJECT_ISSUES_SHAPE,\n    { project_id: issueProjectId },\n    { enabled: needsIssueResolution && !!issueProjectId }\n  );\n\n  useEffect(() => {\n    if (!needsIssueResolution || !state.linkedIssue) return;\n    const issue = issuesForResolution.find(\n      (i) => i.id === state.linkedIssue!.issueId\n    );\n    if (issue) {\n      dispatch({\n        type: 'RESOLVE_LINKED_ISSUE',\n        simpleId: issue.simple_id,\n        title: issue.title,\n      });\n    }\n  }, [needsIssueResolution, issuesForResolution, state.linkedIssue]);\n\n  // ============================================================================\n  // Derived state\n  // ============================================================================\n  const repos = useMemo(() => state.repos.map((r) => r.repo), [state.repos]);\n\n  const targetBranches = useMemo(\n    () =>\n      state.repos.reduce(\n        (acc, r) => {\n          acc[r.repo.id] = r.targetBranch;\n          return acc;\n        },\n        {} as Record<string, string | null>\n      ),\n    [state.repos]\n  );\n\n  // ============================================================================\n  // Actions\n  // ============================================================================\n  const setMessage = useCallback((message: string) => {\n    dispatch({ type: 'SET_MESSAGE', message });\n  }, []);\n\n  const addRepo = useCallback((repo: Repo) => {\n    // Branch is always selected manually by the user.\n    dispatch({ type: 'ADD_REPO', repo, targetBranch: null });\n  }, []);\n\n  const removeRepo = useCallback((repoId: string) => {\n    dispatch({ type: 'REMOVE_REPO', repoId });\n  }, []);\n\n  const clearRepos = useCallback(() => {\n    dispatch({ type: 'CLEAR_REPOS' });\n  }, []);\n\n  const setTargetBranch = useCallback((repoId: string, branch: string) => {\n    dispatch({ type: 'SET_TARGET_BRANCH', repoId, branch });\n  }, []);\n\n  const clearDraft = useCallback(async () => {\n    try {\n      await deleteScratch();\n      dispatch({ type: 'CLEAR' });\n    } catch (e) {\n      console.error('[useCreateModeState] Failed to clear:', e);\n    }\n  }, [deleteScratch]);\n\n  const clearLinkedIssue = useCallback(() => {\n    dispatch({ type: 'CLEAR_LINKED_ISSUE' });\n  }, []);\n\n  const setExecutorConfig = useCallback((config: ExecutorConfig | null) => {\n    dispatch({ type: 'SET_EXECUTOR_CONFIG', config });\n  }, []);\n\n  const setAttachments = useCallback(\n    (attachments: DraftWorkspaceAttachment[]) => {\n      dispatch({ type: 'SET_ATTACHMENTS', attachments });\n    },\n    []\n  );\n\n  return {\n    repos,\n    targetBranches,\n    hasResolvedInitialRepoDefaults,\n    preferredExecutorConfig,\n    message: state.message,\n    isLoading: scratchLoading,\n    hasInitialValue: state.phase === 'ready',\n    linkedIssue: state.linkedIssue,\n    executorConfig: state.executorConfig,\n    setMessage,\n    addRepo,\n    removeRepo,\n    clearRepos,\n    setTargetBranch,\n    clearDraft,\n    clearLinkedIssue,\n    setExecutorConfig,\n    attachments: state.attachments,\n    setAttachments,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/kanban/model/hooks/useKanbanFilters.ts",
    "content": "import { useMemo } from 'react';\nimport {\n  KANBAN_ASSIGNEE_FILTER_VALUES,\n  type KanbanFilterState,\n} from '@/shared/stores/useUiPreferencesStore';\nimport type {\n  Issue,\n  IssueAssignee,\n  IssueTag,\n  IssuePriority,\n} from 'shared/remote-types';\n\ntype UseKanbanFiltersParams = {\n  issues: Issue[];\n  issueAssignees: IssueAssignee[];\n  issueTags: IssueTag[];\n  filters: KanbanFilterState;\n  showSubIssues: boolean;\n  currentUserId: string | null;\n};\n\ntype UseKanbanFiltersResult = {\n  filteredIssues: Issue[];\n};\n\nexport const PRIORITY_ORDER: Record<IssuePriority, number> = {\n  urgent: 0,\n  high: 1,\n  medium: 2,\n  low: 3,\n};\n\nexport function useKanbanFilters({\n  issues,\n  issueAssignees,\n  issueTags,\n  filters,\n  showSubIssues,\n  currentUserId,\n}: UseKanbanFiltersParams): UseKanbanFiltersResult {\n  // Create lookup maps for efficient filtering\n  const assigneesByIssue = useMemo(() => {\n    const map: Record<string, string[]> = {};\n    for (const ia of issueAssignees) {\n      if (!map[ia.issue_id]) {\n        map[ia.issue_id] = [];\n      }\n      map[ia.issue_id].push(ia.user_id);\n    }\n    return map;\n  }, [issueAssignees]);\n\n  const tagsByIssue = useMemo(() => {\n    const map: Record<string, string[]> = {};\n    for (const it of issueTags) {\n      if (!map[it.issue_id]) {\n        map[it.issue_id] = [];\n      }\n      map[it.issue_id].push(it.tag_id);\n    }\n    return map;\n  }, [issueTags]);\n\n  // Filter issues\n  const filteredIssues = useMemo(() => {\n    let result = issues;\n\n    // Filter sub-issues based on per-project preference\n    if (!showSubIssues) {\n      result = result.filter((issue) => issue.parent_issue_id === null);\n    }\n\n    // Text search (title + short ID)\n    const query = filters.searchQuery.trim().toLowerCase();\n    if (query) {\n      result = result.filter((issue) => {\n        if (issue.title.toLowerCase().includes(query)) {\n          return true;\n        }\n\n        const simpleId = issue.simple_id.toLowerCase();\n        if (simpleId.includes(query)) {\n          return true;\n        }\n\n        const issueNumber = String(issue.issue_number);\n        return issueNumber.includes(query);\n      });\n    }\n\n    // Priority filter (OR within)\n    if (filters.priorities.length > 0) {\n      result = result.filter(\n        (issue) =>\n          issue.priority !== null && filters.priorities.includes(issue.priority)\n      );\n    }\n\n    // Assignee filter (OR within)\n    if (filters.assigneeIds.length > 0) {\n      const includeUnassigned = filters.assigneeIds.includes(\n        KANBAN_ASSIGNEE_FILTER_VALUES.UNASSIGNED\n      );\n      const selectedAssigneeIds = new Set(\n        filters.assigneeIds.flatMap((assigneeId) => {\n          if (assigneeId === KANBAN_ASSIGNEE_FILTER_VALUES.SELF) {\n            return currentUserId ? [currentUserId] : [];\n          }\n          if (assigneeId === KANBAN_ASSIGNEE_FILTER_VALUES.UNASSIGNED) {\n            return [];\n          }\n          return [assigneeId];\n        })\n      );\n\n      result = result.filter((issue) => {\n        const issueAssigneeIds = assigneesByIssue[issue.id] ?? [];\n\n        // Check for 'unassigned' special case\n        if (includeUnassigned) {\n          if (issueAssigneeIds.length === 0) return true;\n        }\n\n        // Check if any of the issue's assignees match the filter\n        return issueAssigneeIds.some((assigneeId) =>\n          selectedAssigneeIds.has(assigneeId)\n        );\n      });\n    }\n\n    // Tags filter (OR within)\n    if (filters.tagIds.length > 0) {\n      result = result.filter((issue) => {\n        const issueTagIds = tagsByIssue[issue.id] ?? [];\n        return issueTagIds.some((tagId) => filters.tagIds.includes(tagId));\n      });\n    }\n\n    // Note: Sorting is handled in KanbanContainer after grouping by status\n    // so that sort order is applied within each column\n\n    return result;\n  }, [\n    issues,\n    filters,\n    assigneesByIssue,\n    tagsByIssue,\n    showSubIssues,\n    currentUserId,\n  ]);\n\n  return {\n    filteredIssues,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/kanban/ui/BulkActionBarContainer.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { Actions } from '@/shared/actions';\nimport { BulkActionBar } from '@vibe/ui/components/BulkActionBar';\nimport { useIssueSelectionStore } from '@/shared/stores/useIssueSelectionStore';\n\ninterface BulkActionBarContainerProps {\n  projectId: string;\n}\n\nexport function BulkActionBarContainer({\n  projectId,\n}: BulkActionBarContainerProps) {\n  const selectedIssueIds = useIssueSelectionStore((s) => s.selectedIssueIds);\n  const clearSelection = useIssueSelectionStore((s) => s.clearSelection);\n  const {\n    executeAction,\n    openStatusSelection,\n    openPrioritySelection,\n    openAssigneeSelection,\n  } = useActions();\n\n  const issueIds = useMemo(() => [...selectedIssueIds], [selectedIssueIds]);\n\n  const handleChangeStatus = useCallback(async () => {\n    await openStatusSelection(projectId, issueIds);\n  }, [projectId, issueIds, openStatusSelection]);\n\n  const handleChangePriority = useCallback(async () => {\n    await openPrioritySelection(projectId, issueIds);\n  }, [projectId, issueIds, openPrioritySelection]);\n\n  const handleChangeAssignees = useCallback(async () => {\n    await openAssigneeSelection(projectId, issueIds);\n  }, [projectId, issueIds, openAssigneeSelection]);\n\n  const handleDelete = useCallback(async () => {\n    await executeAction(Actions.DeleteIssue, undefined, projectId, issueIds);\n    clearSelection();\n  }, [executeAction, projectId, issueIds, clearSelection]);\n\n  return (\n    <BulkActionBar\n      selectedCount={selectedIssueIds.size}\n      onChangeStatus={handleChangeStatus}\n      onChangePriority={handleChangePriority}\n      onChangeAssignees={handleChangeAssignees}\n      onDelete={handleDelete}\n      onClearSelection={clearSelection}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/kanban/ui/KanbanContainer.tsx",
    "content": "import {\n  useMemo,\n  useCallback,\n  useState,\n  useEffect,\n  useRef,\n  type MouseEvent,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useOrgContext } from '@/shared/hooks/useOrgContext';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useIsMobile } from '@/shared/hooks/useIsMobile';\nimport { cn } from '@/shared/lib/utils';\nimport { useCurrentKanbanRouteState } from '@/shared/hooks/useCurrentKanbanRouteState';\nimport {\n  useUiPreferencesStore,\n  resolveKanbanProjectState,\n  KANBAN_ASSIGNEE_FILTER_VALUES,\n  KANBAN_PROJECT_VIEW_IDS,\n  type KanbanFilterState,\n  type KanbanSortField,\n} from '@/shared/stores/useUiPreferencesStore';\nimport {\n  useKanbanFilters,\n  PRIORITY_ORDER,\n} from '../model/hooks/useKanbanFilters';\nimport {\n  bulkUpdateIssues,\n  type BulkUpdateIssueItem,\n} from '@/shared/lib/remoteApi';\nimport { PlusIcon, DotsThreeIcon } from '@phosphor-icons/react';\nimport { Actions } from '@/shared/actions';\nimport {\n  buildKanbanIssueComposerKey,\n  closeKanbanIssueComposer,\n  openKanbanIssueComposer,\n  type ProjectIssueCreateOptions,\n  useKanbanIssueComposer,\n} from '@/shared/stores/useKanbanIssueComposerStore';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\nimport {\n  KanbanProvider,\n  KanbanBoard,\n  KanbanCard,\n  KanbanCards,\n  KanbanHeader,\n  type DropResult,\n} from '@vibe/ui/components/KanbanBoard';\nimport { KanbanCardContent } from '@vibe/ui/components/KanbanCardContent';\nimport {\n  IssueWorkspaceCard,\n  type WorkspaceWithStats,\n  type WorkspacePr,\n} from '@vibe/ui/components/IssueWorkspaceCard';\nimport { resolveRelationshipsForIssue } from '@/shared/lib/resolveRelationships';\nimport { KanbanFilterBar } from '@vibe/ui/components/KanbanFilterBar';\nimport { ViewNavTabs } from '@vibe/ui/components/ViewNavTabs';\nimport { IssueListView } from '@vibe/ui/components/IssueListView';\nimport { CommandBarDialog } from '@/shared/dialogs/command-bar/CommandBarDialog';\nimport { KanbanFiltersDialog } from '@/shared/dialogs/kanban/KanbanFiltersDialog';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/Dropdown';\nimport { SearchableTagDropdownContainer } from '@/shared/components/SearchableTagDropdownContainer';\nimport type { IssuePriority } from 'shared/remote-types';\nimport { useIssueMultiSelect } from '@/shared/hooks/useIssueMultiSelect';\nimport { useIssueSelectionStore } from '@/shared/stores/useIssueSelectionStore';\nimport { BulkActionBarContainer } from './BulkActionBarContainer';\n\nconst areStringSetsEqual = (left: string[], right: string[]): boolean => {\n  if (left.length !== right.length) {\n    return false;\n  }\n\n  const rightSet = new Set(right);\n  return left.every((value) => rightSet.has(value));\n};\n\nconst areKanbanFiltersEqual = (\n  left: KanbanFilterState,\n  right: KanbanFilterState\n): boolean => {\n  if (left.searchQuery.trim() !== right.searchQuery.trim()) {\n    return false;\n  }\n\n  if (!areStringSetsEqual(left.priorities, right.priorities)) {\n    return false;\n  }\n\n  if (!areStringSetsEqual(left.assigneeIds, right.assigneeIds)) {\n    return false;\n  }\n\n  if (!areStringSetsEqual(left.tagIds, right.tagIds)) {\n    return false;\n  }\n\n  return (\n    left.sortField === right.sortField &&\n    left.sortDirection === right.sortDirection\n  );\n};\n\nfunction LoadingState() {\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"flex items-center justify-center h-full\">\n      <p className=\"text-low\">{t('states.loading')}</p>\n    </div>\n  );\n}\n\n/**\n * KanbanContainer displays the kanban board using data from ProjectContext and OrgContext.\n * Must be rendered within both OrgProvider and ProjectProvider.\n */\nexport function KanbanContainer() {\n  const isMobile = useIsMobile();\n  const { t } = useTranslation('common');\n  const appNavigation = useAppNavigation();\n  const routeState = useCurrentKanbanRouteState();\n\n  // Get data from contexts (set up by WorkspacesLayout)\n  const {\n    projectId,\n    issues,\n    statuses,\n    tags,\n    issueAssignees,\n    issueTags,\n    getTagObjectsForIssue,\n    getTagsForIssue,\n    getPullRequestsForIssue,\n    getWorkspacesForIssue,\n    getRelationshipsForIssue,\n    issuesById,\n    insertIssueTag,\n    removeIssueTag,\n    insertTag,\n    pullRequests,\n    isLoading: projectLoading,\n  } = useProjectContext();\n\n  const {\n    projects,\n    membersWithProfilesById,\n    isLoading: orgLoading,\n  } = useOrgContext();\n  const { activeWorkspaces } = useWorkspaceContext();\n  const { userId } = useAuth();\n\n  // Get project name by finding the project matching current projectId\n  const projectName = projects.find((p) => p.id === projectId)?.name ?? '';\n\n  const selectedKanbanIssueId = routeState.issueId;\n  const issueComposerKey = useMemo(\n    () => buildKanbanIssueComposerKey(routeState.hostId, projectId),\n    [routeState.hostId, projectId]\n  );\n  const issueComposer = useKanbanIssueComposer(issueComposerKey);\n  const isIssueComposerOpen = issueComposer !== null;\n  const openIssue = useCallback(\n    (issueId: string) => {\n      if (isIssueComposerOpen) {\n        closeKanbanIssueComposer(issueComposerKey);\n      }\n\n      appNavigation.goToProjectIssue(projectId, issueId);\n    },\n    [isIssueComposerOpen, issueComposerKey, appNavigation, projectId]\n  );\n  const openIssueWorkspace = useCallback(\n    (issueId: string, workspaceAttemptId: string) => {\n      appNavigation.goToProjectIssueWorkspace(\n        projectId,\n        issueId,\n        workspaceAttemptId\n      );\n    },\n    [appNavigation, projectId]\n  );\n  const startCreate = useCallback(\n    (options?: ProjectIssueCreateOptions) => {\n      openKanbanIssueComposer(issueComposerKey, options);\n    },\n    [issueComposerKey]\n  );\n\n  // Get setter and executor from ActionsContext\n  const {\n    setDefaultCreateStatusId,\n    executeAction,\n    openPrioritySelection,\n    openAssigneeSelection,\n  } = useActions();\n  const openProjectsGuide = useCallback(() => {\n    executeAction(Actions.ProjectsGuide);\n  }, [executeAction]);\n\n  const projectViewSelection = useUiPreferencesStore(\n    (s) => s.kanbanProjectViewSelections[projectId]\n  );\n  const projectViewPreferencesById = useUiPreferencesStore(\n    (s) => s.kanbanProjectViewPreferences[projectId]\n  );\n  const setKanbanProjectView = useUiPreferencesStore(\n    (s) => s.setKanbanProjectView\n  );\n  const setKanbanProjectViewFilters = useUiPreferencesStore(\n    (s) => s.setKanbanProjectViewFilters\n  );\n  const setKanbanProjectViewShowSubIssues = useUiPreferencesStore(\n    (s) => s.setKanbanProjectViewShowSubIssues\n  );\n  const setKanbanProjectViewShowWorkspaces = useUiPreferencesStore(\n    (s) => s.setKanbanProjectViewShowWorkspaces\n  );\n  const clearKanbanProjectViewPreferences = useUiPreferencesStore(\n    (s) => s.clearKanbanProjectViewPreferences\n  );\n  const resolvedProjectState = useMemo(\n    () => resolveKanbanProjectState(projectViewSelection),\n    [projectViewSelection]\n  );\n  const {\n    activeViewId,\n    filters: defaultKanbanFilters,\n    showSubIssues: defaultShowSubIssues,\n    showWorkspaces: defaultShowWorkspaces,\n  } = resolvedProjectState;\n  const projectViewPreferences = projectViewPreferencesById?.[activeViewId];\n  const kanbanFilters = projectViewPreferences?.filters ?? defaultKanbanFilters;\n  const showSubIssues =\n    projectViewPreferences?.showSubIssues ?? defaultShowSubIssues;\n  const showWorkspaces =\n    projectViewPreferences?.showWorkspaces ?? defaultShowWorkspaces;\n\n  const hasActiveFilters = useMemo(\n    () =>\n      !areKanbanFiltersEqual(kanbanFilters, defaultKanbanFilters) ||\n      showSubIssues !== defaultShowSubIssues ||\n      showWorkspaces !== defaultShowWorkspaces,\n    [\n      kanbanFilters,\n      defaultKanbanFilters,\n      showSubIssues,\n      defaultShowSubIssues,\n      showWorkspaces,\n      defaultShowWorkspaces,\n    ]\n  );\n  const shouldAnimateCreateButton = issues.length === 0;\n\n  const { filteredIssues } = useKanbanFilters({\n    issues,\n    issueAssignees,\n    issueTags,\n    filters: kanbanFilters,\n    showSubIssues,\n    currentUserId: userId,\n  });\n\n  const setKanbanSearchQuery = useCallback(\n    (searchQuery: string) => {\n      setKanbanProjectViewFilters(projectId, activeViewId, {\n        ...kanbanFilters,\n        searchQuery,\n      });\n    },\n    [activeViewId, kanbanFilters, projectId, setKanbanProjectViewFilters]\n  );\n\n  const setKanbanPriorities = useCallback(\n    (priorities: IssuePriority[]) => {\n      setKanbanProjectViewFilters(projectId, activeViewId, {\n        ...kanbanFilters,\n        priorities,\n      });\n    },\n    [activeViewId, kanbanFilters, projectId, setKanbanProjectViewFilters]\n  );\n\n  const setKanbanAssignees = useCallback(\n    (assigneeIds: string[]) => {\n      setKanbanProjectViewFilters(projectId, activeViewId, {\n        ...kanbanFilters,\n        assigneeIds,\n      });\n    },\n    [activeViewId, kanbanFilters, projectId, setKanbanProjectViewFilters]\n  );\n\n  const setKanbanTags = useCallback(\n    (tagIds: string[]) => {\n      setKanbanProjectViewFilters(projectId, activeViewId, {\n        ...kanbanFilters,\n        tagIds,\n      });\n    },\n    [activeViewId, kanbanFilters, projectId, setKanbanProjectViewFilters]\n  );\n\n  const setKanbanSort = useCallback(\n    (sortField: KanbanSortField, sortDirection: 'asc' | 'desc') => {\n      setKanbanProjectViewFilters(projectId, activeViewId, {\n        ...kanbanFilters,\n        sortField,\n        sortDirection,\n      });\n    },\n    [activeViewId, kanbanFilters, projectId, setKanbanProjectViewFilters]\n  );\n\n  const setShowSubIssues = useCallback(\n    (show: boolean) => {\n      setKanbanProjectViewShowSubIssues(projectId, activeViewId, show);\n    },\n    [activeViewId, projectId, setKanbanProjectViewShowSubIssues]\n  );\n\n  const setShowWorkspaces = useCallback(\n    (show: boolean) => {\n      setKanbanProjectViewShowWorkspaces(projectId, activeViewId, show);\n    },\n    [activeViewId, projectId, setKanbanProjectViewShowWorkspaces]\n  );\n\n  const clearKanbanFilters = useCallback(() => {\n    clearKanbanProjectViewPreferences(projectId, activeViewId);\n  }, [activeViewId, clearKanbanProjectViewPreferences, projectId]);\n\n  const handleKanbanProjectViewChange = useCallback(\n    (viewId: string) => {\n      setKanbanProjectView(projectId, viewId);\n    },\n    [projectId, setKanbanProjectView]\n  );\n  const kanbanViewMode = useUiPreferencesStore((s) => s.kanbanViewMode);\n  const listViewStatusFilter = useUiPreferencesStore(\n    (s) => s.listViewStatusFilter\n  );\n  const setKanbanViewMode = useUiPreferencesStore((s) => s.setKanbanViewMode);\n  const setListViewStatusFilter = useUiPreferencesStore(\n    (s) => s.setListViewStatusFilter\n  );\n  // Reset view mode when navigating projects\n  const prevProjectIdRef = useRef<string | null>(null);\n\n  // Track when drag-drop sync is in progress to prevent flicker\n  const isSyncingRef = useRef(false);\n\n  useEffect(() => {\n    if (\n      prevProjectIdRef.current !== null &&\n      prevProjectIdRef.current !== projectId\n    ) {\n      setKanbanViewMode('kanban');\n      setListViewStatusFilter(null);\n    }\n\n    prevProjectIdRef.current = projectId;\n  }, [projectId, setKanbanViewMode, setListViewStatusFilter]);\n\n  // Sort all statuses for display settings\n  const sortedStatuses = useMemo(\n    () => [...statuses].sort((a, b) => a.sort_order - b.sort_order),\n    [statuses]\n  );\n\n  // Filter statuses: visible (non-hidden) for kanban, hidden for tabs\n  const visibleStatuses = useMemo(\n    () => sortedStatuses.filter((s) => !s.hidden),\n    [sortedStatuses]\n  );\n\n  // Map status ID to 1-based column index for sort_order calculation\n  const statusColumnIndexMap = useMemo(() => {\n    const map = new Map<string, number>();\n    visibleStatuses.forEach((status, index) => {\n      map.set(status.id, index + 1);\n    });\n    return map;\n  }, [visibleStatuses]);\n\n  const hiddenStatuses = useMemo(\n    () => sortedStatuses.filter((s) => s.hidden),\n    [sortedStatuses]\n  );\n\n  const defaultCreateStatusId = useMemo(() => {\n    if (kanbanViewMode === 'kanban') {\n      return visibleStatuses[0]?.id;\n    }\n    if (listViewStatusFilter) {\n      return listViewStatusFilter;\n    }\n    return sortedStatuses[0]?.id;\n  }, [kanbanViewMode, visibleStatuses, listViewStatusFilter, sortedStatuses]);\n\n  // Update default create status for command bar based on current tab\n  useEffect(() => {\n    setDefaultCreateStatusId(defaultCreateStatusId);\n  }, [defaultCreateStatusId, setDefaultCreateStatusId]);\n\n  const createAssigneeIds = useMemo(() => {\n    const assigneeIds = new Set<string>();\n\n    for (const assigneeId of kanbanFilters.assigneeIds) {\n      if (assigneeId === KANBAN_ASSIGNEE_FILTER_VALUES.UNASSIGNED) {\n        continue;\n      }\n\n      if (assigneeId === KANBAN_ASSIGNEE_FILTER_VALUES.SELF) {\n        if (userId) {\n          assigneeIds.add(userId);\n        }\n        continue;\n      }\n\n      assigneeIds.add(assigneeId);\n    }\n\n    return [...assigneeIds];\n  }, [kanbanFilters.assigneeIds, userId]);\n\n  // Get statuses to display in list view (all or filtered to one)\n  const listViewStatuses = useMemo(() => {\n    if (listViewStatusFilter) {\n      return sortedStatuses.filter((s) => s.id === listViewStatusFilter);\n    }\n    return sortedStatuses;\n  }, [sortedStatuses, listViewStatusFilter]);\n\n  // Track items as arrays of IDs grouped by status\n  const [items, setItems] = useState<Record<string, string[]>>({});\n  const [isFiltersDialogOpen, setIsFiltersDialogOpen] = useState(false);\n\n  // Sync items from filtered issues when they change\n  useEffect(() => {\n    // Skip rebuild during drag-drop sync to prevent flicker\n    if (isSyncingRef.current) {\n      return;\n    }\n\n    const { sortField, sortDirection } = kanbanFilters;\n    const grouped: Record<string, string[]> = {};\n\n    for (const status of statuses) {\n      // Filter issues for this status\n      let statusIssues = filteredIssues.filter(\n        (i) => i.status_id === status.id\n      );\n\n      // Sort within column based on user preference\n      statusIssues = [...statusIssues].sort((a, b) => {\n        let comparison = 0;\n        switch (sortField) {\n          case 'priority':\n            comparison =\n              (a.priority ? PRIORITY_ORDER[a.priority] : Infinity) -\n              (b.priority ? PRIORITY_ORDER[b.priority] : Infinity);\n            break;\n          case 'created_at':\n            comparison =\n              new Date(a.created_at).getTime() -\n              new Date(b.created_at).getTime();\n            break;\n          case 'updated_at':\n            comparison =\n              new Date(a.updated_at).getTime() -\n              new Date(b.updated_at).getTime();\n            break;\n          case 'title':\n            comparison = a.title.localeCompare(b.title);\n            break;\n          case 'sort_order':\n          default:\n            comparison = a.sort_order - b.sort_order;\n        }\n        return sortDirection === 'desc' ? -comparison : comparison;\n      });\n\n      grouped[status.id] = statusIssues.map((i) => i.id);\n    }\n    setItems(grouped);\n  }, [filteredIssues, statuses, kanbanFilters]);\n\n  // Create a lookup map for issue data\n  const issueMap = useMemo(() => {\n    const map: Record<string, (typeof issues)[0]> = {};\n    for (const issue of issues) {\n      map[issue.id] = issue;\n    }\n    return map;\n  }, [issues]);\n\n  // Create a lookup map for issue assignees (issue_id -> OrganizationMemberWithProfile[])\n  const issueAssigneesMap = useMemo(() => {\n    const map: Record<string, OrganizationMemberWithProfile[]> = {};\n    for (const assignee of issueAssignees) {\n      const member = membersWithProfilesById.get(assignee.user_id);\n      if (member) {\n        if (!map[assignee.issue_id]) {\n          map[assignee.issue_id] = [];\n        }\n        map[assignee.issue_id].push(member);\n      }\n    }\n    return map;\n  }, [issueAssignees, membersWithProfilesById]);\n\n  const membersWithProfiles = useMemo(\n    () => [...membersWithProfilesById.values()],\n    [membersWithProfilesById]\n  );\n\n  const localWorkspacesById = useMemo(() => {\n    const map = new Map<string, (typeof activeWorkspaces)[number]>();\n\n    for (const workspace of activeWorkspaces) {\n      map.set(workspace.id, workspace);\n    }\n\n    return map;\n  }, [activeWorkspaces]);\n\n  const prsByWorkspaceId = useMemo(() => {\n    const map = new Map<string, WorkspacePr[]>();\n\n    for (const pr of pullRequests) {\n      if (!pr.workspace_id) continue;\n\n      const prs = map.get(pr.workspace_id) ?? [];\n      prs.push({\n        number: pr.number,\n        url: pr.url,\n        status: pr.status as 'open' | 'merged' | 'closed',\n      });\n      map.set(pr.workspace_id, prs);\n    }\n\n    return map;\n  }, [pullRequests]);\n\n  const workspacesByIssueId = useMemo(() => {\n    if (!showWorkspaces) {\n      return new Map<string, WorkspaceWithStats[]>();\n    }\n\n    const map = new Map<string, WorkspaceWithStats[]>();\n\n    for (const issue of issues) {\n      const nonArchivedWorkspaces = getWorkspacesForIssue(issue.id)\n        .filter(\n          (workspace) =>\n            !workspace.archived &&\n            !!workspace.local_workspace_id &&\n            localWorkspacesById.has(workspace.local_workspace_id)\n        )\n        .map((workspace) => {\n          const localWorkspace = localWorkspacesById.get(\n            workspace.local_workspace_id!\n          );\n\n          return {\n            id: workspace.id,\n            localWorkspaceId: workspace.local_workspace_id,\n            name: workspace.name,\n            archived: workspace.archived,\n            filesChanged: workspace.files_changed ?? 0,\n            linesAdded: workspace.lines_added ?? 0,\n            linesRemoved: workspace.lines_removed ?? 0,\n            prs: prsByWorkspaceId.get(workspace.id) ?? [],\n            owner: membersWithProfilesById.get(workspace.owner_user_id) ?? null,\n            updatedAt: workspace.updated_at,\n            isOwnedByCurrentUser: workspace.owner_user_id === userId,\n            isRunning: localWorkspace?.isRunning,\n            hasPendingApproval: localWorkspace?.hasPendingApproval,\n            hasRunningDevServer: localWorkspace?.hasRunningDevServer,\n            hasUnseenActivity: localWorkspace?.hasUnseenActivity,\n            latestProcessCompletedAt: localWorkspace?.latestProcessCompletedAt,\n            latestProcessStatus: localWorkspace?.latestProcessStatus,\n          };\n        });\n\n      if (nonArchivedWorkspaces.length > 0) {\n        map.set(issue.id, nonArchivedWorkspaces);\n      }\n    }\n\n    return map;\n  }, [\n    showWorkspaces,\n    issues,\n    getWorkspacesForIssue,\n    localWorkspacesById,\n    prsByWorkspaceId,\n    membersWithProfilesById,\n    userId,\n  ]);\n\n  // Calculate sort_order based on column index and issue position\n  // Formula: 1000 * [COLUMN_INDEX] + [ISSUE_INDEX] (both 1-based)\n  const calculateSortOrder = useCallback(\n    (statusId: string, issueIndex: number): number => {\n      const columnIndex = statusColumnIndexMap.get(statusId) ?? 1;\n      return 1000 * columnIndex + (issueIndex + 1);\n    },\n    [statusColumnIndexMap]\n  );\n\n  // Simple onDragEnd handler - the library handles all visual movement\n  const handleDragEnd = useCallback(\n    (result: DropResult) => {\n      const { source, destination } = result;\n\n      // Dropped outside a valid droppable\n      if (!destination) return;\n\n      // No movement\n      if (\n        source.droppableId === destination.droppableId &&\n        source.index === destination.index\n      ) {\n        return;\n      }\n\n      const isManualSort = kanbanFilters.sortField === 'sort_order';\n\n      // Block within-column reordering when not in manual sort mode\n      // (cross-column moves are always allowed for status changes)\n      if (source.droppableId === destination.droppableId && !isManualSort) {\n        return;\n      }\n\n      const sourceId = source.droppableId;\n      const destId = destination.droppableId;\n      const isCrossColumn = sourceId !== destId;\n\n      // Update local state and capture new items for bulk update\n      let newItems: Record<string, string[]> = {};\n      setItems((prev) => {\n        const sourceItems = [...(prev[sourceId] ?? [])];\n        const [moved] = sourceItems.splice(source.index, 1);\n\n        if (!isCrossColumn) {\n          // Within-column reorder\n          sourceItems.splice(destination.index, 0, moved);\n          newItems = { ...prev, [sourceId]: sourceItems };\n        } else {\n          // Cross-column move\n          const destItems = [...(prev[destId] ?? [])];\n          destItems.splice(destination.index, 0, moved);\n          newItems = {\n            ...prev,\n            [sourceId]: sourceItems,\n            [destId]: destItems,\n          };\n        }\n        return newItems;\n      });\n\n      // Build bulk updates for all issues in affected columns\n      const updates: BulkUpdateIssueItem[] = [];\n\n      // Always update destination column\n      const destIssueIds = newItems[destId] ?? [];\n      destIssueIds.forEach((issueId, index) => {\n        updates.push({\n          id: issueId,\n          changes: {\n            status_id: destId,\n            sort_order: calculateSortOrder(destId, index),\n          },\n        });\n      });\n\n      // Update source column if cross-column move\n      if (isCrossColumn) {\n        const sourceIssueIds = newItems[sourceId] ?? [];\n        sourceIssueIds.forEach((issueId, index) => {\n          updates.push({\n            id: issueId,\n            changes: {\n              sort_order: calculateSortOrder(sourceId, index),\n            },\n          });\n        });\n      }\n\n      // Perform bulk update\n      isSyncingRef.current = true;\n      bulkUpdateIssues(updates)\n        .catch((err) => {\n          console.error('Failed to bulk update sort order:', err);\n        })\n        .finally(() => {\n          // Delay clearing flag to let Electric sync complete\n          setTimeout(() => {\n            isSyncingRef.current = false;\n          }, 500);\n        });\n    },\n    [kanbanFilters.sortField, calculateSortOrder]\n  );\n\n  // Multi-select support\n  const {\n    selectedIssueIds,\n    isMultiSelectActive,\n    handleIssueClick,\n    handleCheckboxChange,\n    clearSelection,\n  } = useIssueMultiSelect();\n  const setOrderedIssueIds = useIssueSelectionStore(\n    (s) => s.setOrderedIssueIds\n  );\n  const setAnchor = useIssueSelectionStore((s) => s.setAnchor);\n\n  // Compute ordered issue IDs for range selection\n  const orderedIssueIds = useMemo(() => {\n    const statusOrder =\n      kanbanViewMode === 'kanban' ? visibleStatuses : listViewStatuses;\n    return statusOrder.flatMap((status) => items[status.id] ?? []);\n  }, [kanbanViewMode, visibleStatuses, listViewStatuses, items]);\n\n  // Keep the store's ordered IDs in sync\n  useEffect(() => {\n    setOrderedIssueIds(orderedIssueIds);\n  }, [orderedIssueIds, setOrderedIssueIds]);\n\n  // Clear multi-selection when project or view mode changes\n  useEffect(() => {\n    clearSelection();\n  }, [projectId, kanbanViewMode, clearSelection]);\n\n  // Keep anchor in sync with the currently opened issue (e.g. from URL on\n  // page load) so Shift/Cmd+Click on another issue includes it.\n  useEffect(() => {\n    if (selectedKanbanIssueId) {\n      setAnchor(selectedKanbanIssueId);\n    }\n  }, [selectedKanbanIssueId, setAnchor]);\n\n  const handleCardClick = useCallback(\n    (issueId: string, e?: MouseEvent) => {\n      if (e && (e.metaKey || e.ctrlKey || e.shiftKey)) {\n        handleIssueClick(issueId, e);\n      } else {\n        if (selectedIssueIds.size > 0) {\n          clearSelection();\n        }\n        // Set as anchor so Shift+Click from this issue works\n        setAnchor(issueId);\n        openIssue(issueId);\n      }\n    },\n    [\n      openIssue,\n      handleIssueClick,\n      selectedIssueIds.size,\n      clearSelection,\n      setAnchor,\n    ]\n  );\n\n  const handleAddTask = useCallback(\n    (statusId?: string) => {\n      const createPayload = {\n        statusId: statusId ?? defaultCreateStatusId,\n        ...(createAssigneeIds.length > 0\n          ? { assigneeIds: createAssigneeIds }\n          : {}),\n      };\n      startCreate(createPayload);\n    },\n    [createAssigneeIds, defaultCreateStatusId, startCreate]\n  );\n\n  // Inline editing callbacks for kanban cards\n  // When multi-select is active, apply to all selected issues\n  const handleCardPriorityClick = useCallback(\n    (issueId: string) => {\n      const ids = isMultiSelectActive ? [...selectedIssueIds] : [issueId];\n      openPrioritySelection(projectId, ids);\n    },\n    [projectId, openPrioritySelection, selectedIssueIds, isMultiSelectActive]\n  );\n\n  const handleCardAssigneeClick = useCallback(\n    (issueId: string) => {\n      const ids = isMultiSelectActive ? [...selectedIssueIds] : [issueId];\n      openAssigneeSelection(projectId, ids);\n    },\n    [projectId, openAssigneeSelection, selectedIssueIds, isMultiSelectActive]\n  );\n\n  const handleCardMoreActionsClick = useCallback(\n    (issueId: string) => {\n      const ids = isMultiSelectActive ? [...selectedIssueIds] : [issueId];\n      CommandBarDialog.show({\n        page: 'issueActions',\n        projectId,\n        issueIds: ids,\n      });\n    },\n    [projectId, selectedIssueIds, isMultiSelectActive]\n  );\n\n  const handleCardTagToggle = useCallback(\n    (issueId: string, tagId: string) => {\n      const currentIssueTags = getTagsForIssue(issueId);\n      const existing = currentIssueTags.find((it) => it.tag_id === tagId);\n      if (existing) {\n        removeIssueTag(existing.id);\n      } else {\n        insertIssueTag({ issue_id: issueId, tag_id: tagId });\n      }\n    },\n    [getTagsForIssue, insertIssueTag, removeIssueTag]\n  );\n\n  const getResolvedRelationshipsForIssue = useCallback(\n    (issueId: string) =>\n      resolveRelationshipsForIssue(\n        issueId,\n        getRelationshipsForIssue(issueId),\n        issuesById\n      ),\n    [getRelationshipsForIssue, issuesById]\n  );\n\n  const handleCreateTag = useCallback(\n    (data: { name: string; color: string }): string => {\n      const { data: newTag } = insertTag({\n        project_id: projectId,\n        name: data.name,\n        color: data.color,\n      });\n      return newTag.id;\n    },\n    [insertTag, projectId]\n  );\n\n  const isLoading = projectLoading || orgLoading;\n\n  if (isLoading) {\n    return <LoadingState />;\n  }\n\n  return (\n    <div className=\"flex flex-col h-full space-y-base\">\n      <div\n        className={cn(\n          'px-double pt-double space-y-base',\n          isMobile && 'px-base pt-base'\n        )}\n      >\n        <div className=\"flex items-center gap-half\">\n          <h2 className={cn('text-2xl font-medium', isMobile && 'text-lg')}>\n            {projectName}\n          </h2>\n\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <button\n                type=\"button\"\n                className=\"p-half rounded-sm text-low hover:text-normal hover:bg-secondary transition-colors\"\n                aria-label=\"Project menu\"\n              >\n                <DotsThreeIcon className=\"size-icon-sm\" weight=\"bold\" />\n              </button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuItem onClick={openProjectsGuide}>\n                {t('kanban.openProjectsGuide', 'Projects guide')}\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                onClick={() => executeAction(Actions.ProjectSettings)}\n              >\n                {t('kanban.editProjectSettings', 'Edit project settings')}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n\n        <div\n          className={cn(\n            'flex items-start gap-base',\n            isMobile ? 'flex-col' : 'flex-wrap'\n          )}\n        >\n          <ViewNavTabs\n            activeView={kanbanViewMode}\n            onViewChange={setKanbanViewMode}\n            hiddenStatuses={hiddenStatuses}\n            selectedStatusId={listViewStatusFilter}\n            onStatusSelect={setListViewStatusFilter}\n          />\n          <KanbanFilterBar\n            isFiltersDialogOpen={isFiltersDialogOpen}\n            onFiltersDialogOpenChange={setIsFiltersDialogOpen}\n            tags={tags}\n            users={membersWithProfiles}\n            activeViewId={activeViewId}\n            onViewChange={handleKanbanProjectViewChange}\n            viewIds={KANBAN_PROJECT_VIEW_IDS}\n            projectId={projectId}\n            currentUserId={userId}\n            filters={kanbanFilters}\n            showSubIssues={showSubIssues}\n            showWorkspaces={showWorkspaces}\n            hasActiveFilters={hasActiveFilters}\n            onSearchQueryChange={setKanbanSearchQuery}\n            onPrioritiesChange={setKanbanPriorities}\n            onAssigneesChange={setKanbanAssignees}\n            onTagsChange={setKanbanTags}\n            onSortChange={setKanbanSort}\n            onShowSubIssuesChange={setShowSubIssues}\n            onShowWorkspacesChange={setShowWorkspaces}\n            onClearFilters={clearKanbanFilters}\n            onCreateIssue={handleAddTask}\n            shouldAnimateCreateButton={shouldAnimateCreateButton}\n            renderFiltersDialog={(props) => <KanbanFiltersDialog {...props} />}\n            isMobile={isMobile}\n          />\n        </div>\n      </div>\n\n      {kanbanViewMode === 'kanban' ? (\n        visibleStatuses.length === 0 ? (\n          <div className=\"flex-1 flex items-center justify-center\">\n            <p className=\"text-low\">{t('kanban.noVisibleStatuses')}</p>\n          </div>\n        ) : (\n          <div className=\"flex-1 overflow-x-auto px-double\">\n            <KanbanProvider onDragEnd={handleDragEnd}>\n              {visibleStatuses.map((status) => {\n                const issueIds = items[status.id] ?? [];\n\n                return (\n                  <KanbanBoard key={status.id}>\n                    <KanbanHeader>\n                      <div className=\"border-t sticky border-b top-0 z-20 flex shrink-0 items-center justify-between gap-2 p-base bg-secondary\">\n                        <div className=\"flex items-center gap-2\">\n                          <div\n                            className=\"h-2 w-2 rounded-full shrink-0\"\n                            style={{ backgroundColor: `hsl(${status.color})` }}\n                          />\n                          <p className=\"m-0 text-sm\">{status.name}</p>\n                        </div>\n                        <button\n                          type=\"button\"\n                          onClick={() => handleAddTask(status.id)}\n                          className=\"p-half rounded-sm text-low hover:text-normal hover:bg-secondary transition-colors\"\n                          aria-label=\"Add task\"\n                        >\n                          <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n                        </button>\n                      </div>\n                    </KanbanHeader>\n                    <KanbanCards id={status.id}>\n                      {issueIds.map((issueId, index) => {\n                        const issue = issueMap[issueId];\n                        if (!issue) return null;\n                        const issueWorkspaces =\n                          workspacesByIssueId.get(issue.id) ?? [];\n                        const workspaceIdsShownOnCard = new Set(\n                          issueWorkspaces.map((workspace) => workspace.id)\n                        );\n                        const issueCardPullRequests = getPullRequestsForIssue(\n                          issue.id\n                        ).filter((pr) => {\n                          if (!pr.workspace_id) {\n                            return true;\n                          }\n\n                          // If this PR is already visible under a workspace card,\n                          // do not render it again at the issue level.\n                          return !workspaceIdsShownOnCard.has(pr.workspace_id);\n                        });\n\n                        return (\n                          <KanbanCard\n                            key={issue.id}\n                            id={issue.id}\n                            name={issue.title}\n                            index={index}\n                            className=\"group\"\n                            onClick={(e) => handleCardClick(issue.id, e)}\n                            isOpen={selectedKanbanIssueId === issue.id}\n                            isMobile={isMobile}\n                            isSelected={selectedIssueIds.has(issue.id)}\n                            dragDisabled={isMultiSelectActive}\n                          >\n                            <KanbanCardContent\n                              displayId={issue.simple_id}\n                              title={issue.title}\n                              description={issue.description}\n                              priority={issue.priority}\n                              tags={getTagObjectsForIssue(issue.id)}\n                              assignees={issueAssigneesMap[issue.id] ?? []}\n                              pullRequests={issueCardPullRequests}\n                              relationships={resolveRelationshipsForIssue(\n                                issue.id,\n                                getRelationshipsForIssue(issue.id),\n                                issuesById\n                              )}\n                              isSubIssue={!!issue.parent_issue_id}\n                              isMobile={isMobile}\n                              onPriorityClick={(e) => {\n                                e.stopPropagation();\n                                handleCardPriorityClick(issue.id);\n                              }}\n                              onAssigneeClick={(e) => {\n                                e.stopPropagation();\n                                handleCardAssigneeClick(issue.id);\n                              }}\n                              onMoreActionsClick={() =>\n                                handleCardMoreActionsClick(issue.id)\n                              }\n                              tagEditProps={{\n                                allTags: tags,\n                                selectedTagIds: getTagsForIssue(issue.id).map(\n                                  (it) => it.tag_id\n                                ),\n                                onTagToggle: (tagId) =>\n                                  handleCardTagToggle(issue.id, tagId),\n                                onCreateTag: handleCreateTag,\n                                renderTagEditor: ({\n                                  allTags,\n                                  selectedTagIds,\n                                  onTagToggle,\n                                  onCreateTag,\n                                  trigger,\n                                }) => (\n                                  <SearchableTagDropdownContainer\n                                    tags={allTags}\n                                    selectedTagIds={selectedTagIds}\n                                    onTagToggle={onTagToggle}\n                                    onCreateTag={onCreateTag}\n                                    disabled={false}\n                                    contentClassName=\"\"\n                                    trigger={trigger}\n                                  />\n                                ),\n                              }}\n                            />\n                            {issueWorkspaces.length > 0 && (\n                              <div className=\"mt-base flex flex-col gap-half\">\n                                {issueWorkspaces.map((workspace) => (\n                                  <IssueWorkspaceCard\n                                    key={workspace.id}\n                                    workspace={workspace}\n                                    onClick={\n                                      workspace.localWorkspaceId\n                                        ? () =>\n                                            openIssueWorkspace(\n                                              issue.id,\n                                              workspace.localWorkspaceId!\n                                            )\n                                        : undefined\n                                    }\n                                    showOwner={false}\n                                    showStatusBadge={false}\n                                    showNoPrText={false}\n                                  />\n                                ))}\n                              </div>\n                            )}\n                          </KanbanCard>\n                        );\n                      })}\n                    </KanbanCards>\n                  </KanbanBoard>\n                );\n              })}\n            </KanbanProvider>\n          </div>\n        )\n      ) : (\n        <div className=\"flex-1 overflow-y-auto px-double\">\n          <KanbanProvider onDragEnd={handleDragEnd} className=\"!block !w-full\">\n            <IssueListView\n              statuses={listViewStatuses}\n              items={items}\n              issueMap={issueMap}\n              issueAssigneesMap={issueAssigneesMap}\n              getTagObjectsForIssue={getTagObjectsForIssue}\n              getResolvedRelationshipsForIssue={\n                getResolvedRelationshipsForIssue\n              }\n              onIssueClick={handleCardClick}\n              selectedIssueId={selectedKanbanIssueId}\n              selectedIssueIds={selectedIssueIds}\n              isMultiSelectActive={isMultiSelectActive}\n              onIssueCheckboxChange={handleCheckboxChange}\n            />\n          </KanbanProvider>\n        </div>\n      )}\n\n      {isMultiSelectActive && <BulkActionBarContainer projectId={projectId} />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/migration/model/hooks/useProjects.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { migrationApi } from '@/shared/lib/api';\nimport type { Project } from 'shared/types';\n\ninterface UseProjectsResult {\n  projects: Project[];\n  isLoading: boolean;\n  isError: boolean;\n}\n\nexport function useProjects(): UseProjectsResult {\n  const query = useQuery<Project[]>({\n    queryKey: ['migration', 'projects'],\n    queryFn: migrationApi.listProjects,\n    staleTime: 5 * 60 * 1000,\n  });\n\n  return {\n    projects: query.data ?? [],\n    isLoading: query.isLoading,\n    isError: query.isError,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/migration/ui/MigrateChooseProjectsContainer.tsx",
    "content": "import { useState, useEffect, useMemo, useRef } from 'react';\nimport { useProjects } from '../model/hooks/useProjects';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { MigrateChooseProjects } from '@vibe/ui/components/MigrateChooseProjects';\n\ninterface MigrateChooseProjectsContainerProps {\n  onContinue: (orgId: string, projectIds: string[]) => void;\n  onSkip: () => void;\n}\n\nexport function MigrateChooseProjectsContainer({\n  onContinue,\n  onSkip,\n}: MigrateChooseProjectsContainerProps) {\n  const appNavigation = useAppNavigation();\n  const setSelectedOrgIdInStore = useOrganizationStore(\n    (s) => s.setSelectedOrgId\n  );\n  const { projects, isLoading: projectsLoading } = useProjects();\n  const { data: orgsData, isLoading: orgsLoading } = useUserOrganizations();\n  const organizations = useMemo(\n    () => orgsData?.organizations ?? [],\n    [orgsData?.organizations]\n  );\n\n  const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);\n  const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(\n    new Set()\n  );\n  const hasInitializedSelectionRef = useRef(false);\n\n  // Filter out already-migrated projects for selection purposes\n  const migrateableProjects = useMemo(\n    () => projects.filter((p) => !p.remote_project_id),\n    [projects]\n  );\n\n  // Pre-select first organization when data loads\n  useEffect(() => {\n    if (organizations.length > 0 && !selectedOrgId) {\n      setSelectedOrgId(organizations[0].id);\n    }\n  }, [organizations, selectedOrgId]);\n\n  // Default to all migratable projects selected on first data load.\n  useEffect(() => {\n    if (projectsLoading || hasInitializedSelectionRef.current) {\n      return;\n    }\n\n    setSelectedProjectIds(new Set(migrateableProjects.map((p) => p.id)));\n    hasInitializedSelectionRef.current = true;\n  }, [projectsLoading, migrateableProjects]);\n\n  const handleOrgChange = (orgId: string) => {\n    setSelectedOrgId(orgId);\n  };\n\n  const handleToggleProject = (projectId: string) => {\n    // Only allow toggling non-migrated projects\n    const project = projects.find((p) => p.id === projectId);\n    if (project?.remote_project_id) return;\n\n    setSelectedProjectIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(projectId)) {\n        next.delete(projectId);\n      } else {\n        next.add(projectId);\n      }\n      return next;\n    });\n  };\n\n  const handleSelectAll = () => {\n    if (selectedProjectIds.size === migrateableProjects.length) {\n      setSelectedProjectIds(new Set());\n    } else {\n      setSelectedProjectIds(new Set(migrateableProjects.map((p) => p.id)));\n    }\n  };\n\n  const handleContinue = () => {\n    if (selectedOrgId && selectedProjectIds.size > 0) {\n      onContinue(selectedOrgId, Array.from(selectedProjectIds));\n    }\n  };\n\n  const handleGoToCreateWorkspace = () => {\n    appNavigation.goToWorkspacesCreate();\n  };\n\n  const handleViewMigratedProject = (projectId: string) => {\n    if (selectedOrgId) {\n      setSelectedOrgIdInStore(selectedOrgId);\n    }\n    appNavigation.goToProject(projectId);\n  };\n\n  const migratedProjects = useMemo(\n    () => projects.filter((p) => p.remote_project_id),\n    [projects]\n  );\n\n  const handleSkip = () => {\n    if (migratedProjects.length > 0 && migratedProjects[0].remote_project_id) {\n      appNavigation.goToProject(migratedProjects[0].remote_project_id, {\n        replace: true,\n      });\n    } else {\n      onSkip();\n    }\n  };\n\n  const isLoading = projectsLoading || orgsLoading;\n\n  return (\n    <MigrateChooseProjects\n      projects={projects}\n      organizations={organizations}\n      selectedOrgId={selectedOrgId}\n      selectedProjectIds={selectedProjectIds}\n      isLoading={isLoading}\n      onOrgChange={handleOrgChange}\n      onToggleProject={handleToggleProject}\n      onSelectAll={handleSelectAll}\n      onContinue={handleContinue}\n      onSkip={handleSkip}\n      onGoToCreateWorkspace={handleGoToCreateWorkspace}\n      onViewMigratedProject={handleViewMigratedProject}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/migration/ui/MigrateFinishContainer.tsx",
    "content": "import { useMemo } from 'react';\nimport { useProjects } from '../model/hooks/useProjects';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport {\n  MigrateFinish,\n  type MigrateFinishProject,\n} from '@vibe/ui/components/MigrateFinish';\n\ninterface MigrateFinishContainerProps {\n  orgId: string;\n  projectIds: string[];\n  onMigrateMore: () => void;\n}\n\nexport function MigrateFinishContainer({\n  orgId,\n  projectIds,\n  onMigrateMore,\n}: MigrateFinishContainerProps) {\n  const appNavigation = useAppNavigation();\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n  const { projects } = useProjects();\n\n  const migratedProjects = useMemo(() => {\n    return projectIds\n      .map((id) => projects.find((p) => p.id === id))\n      .filter((p) => p !== undefined)\n      .map((p) => ({\n        localId: p.id,\n        localName: p.name,\n        remoteId: p.remote_project_id,\n      }));\n  }, [projectIds, projects]);\n\n  const handleViewProject = (project: MigrateFinishProject) => {\n    if (project.remoteId) {\n      setSelectedOrgId(orgId);\n      appNavigation.goToProject(project.remoteId);\n      return;\n    }\n\n    appNavigation.goToWorkspaces();\n  };\n\n  return (\n    <MigrateFinish\n      migratedProjects={migratedProjects}\n      onMigrateMore={onMigrateMore}\n      onViewProject={handleViewProject}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/migration/ui/MigrateIntroductionContainer.tsx",
    "content": "import { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { OAuthDialog } from '@/shared/dialogs/global/OAuthDialog';\nimport { MigrateIntroduction } from '@vibe/ui/components/MigrateIntroduction';\n\ninterface MigrateIntroductionContainerProps {\n  onContinue: () => void;\n}\n\nexport function MigrateIntroductionContainer({\n  onContinue,\n}: MigrateIntroductionContainerProps) {\n  const { isSignedIn, isLoaded } = useAuth();\n\n  const handleAction = async () => {\n    if (isSignedIn) {\n      onContinue();\n    } else {\n      const profile = await OAuthDialog.show({});\n      if (profile) {\n        onContinue();\n      }\n    }\n  };\n\n  // Show loading while checking auth status\n  if (!isLoaded) {\n    return (\n      <div className=\"max-w-2xl mx-auto py-double px-base\">\n        <p className=\"text-normal\">Loading...</p>\n      </div>\n    );\n  }\n\n  return (\n    <MigrateIntroduction isSignedIn={isSignedIn} onAction={handleAction} />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/migration/ui/MigrateLayout.tsx",
    "content": "import { useState } from 'react';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport {\n  MigrateSidebar,\n  type MigrationStep,\n} from '@vibe/ui/components/MigrateSidebar';\nimport { MigrateIntroductionContainer } from './MigrateIntroductionContainer';\nimport { MigrateChooseProjectsContainer } from './MigrateChooseProjectsContainer';\nimport { MigrateMigrateContainer } from './MigrateMigrateContainer';\nimport { MigrateFinishContainer } from './MigrateFinishContainer';\n\ninterface MigrationData {\n  orgId: string;\n  projectIds: string[];\n}\n\nexport function MigrateLayout() {\n  const appNavigation = useAppNavigation();\n  const [currentStep, setCurrentStep] = useState<MigrationStep>('introduction');\n  const [migrationData, setMigrationData] = useState<MigrationData | null>(\n    null\n  );\n\n  const handleSkip = () => {\n    appNavigation.goToWorkspacesCreate({\n      replace: true,\n    });\n  };\n\n  const handleChooseProjectsContinue = (\n    orgId: string,\n    projectIds: string[]\n  ) => {\n    setMigrationData({ orgId, projectIds });\n    setCurrentStep('migrate');\n  };\n\n  const renderContent = () => {\n    switch (currentStep) {\n      case 'introduction':\n        return (\n          <MigrateIntroductionContainer\n            onContinue={() => setCurrentStep('choose-projects')}\n          />\n        );\n      case 'choose-projects':\n        return (\n          <MigrateChooseProjectsContainer\n            onContinue={handleChooseProjectsContinue}\n            onSkip={handleSkip}\n          />\n        );\n      case 'migrate':\n        if (!migrationData) {\n          return null;\n        }\n        return (\n          <MigrateMigrateContainer\n            orgId={migrationData.orgId}\n            projectIds={migrationData.projectIds}\n            onContinue={() => setCurrentStep('finish')}\n          />\n        );\n      case 'finish':\n        if (!migrationData) {\n          return null;\n        }\n        return (\n          <MigrateFinishContainer\n            orgId={migrationData.orgId}\n            projectIds={migrationData.projectIds}\n            onMigrateMore={() => setCurrentStep('choose-projects')}\n          />\n        );\n      default:\n        return null;\n    }\n  };\n\n  return (\n    <div className=\"space-y-double\">\n      <MigrateSidebar currentStep={currentStep} onStepChange={setCurrentStep} />\n      <div className=\"rounded-sm border border-border bg-panel\">\n        {renderContent()}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/migration/ui/MigrateMigrateContainer.tsx",
    "content": "import { useState, useEffect, useCallback, useMemo, useRef } from 'react';\nimport { usePostHog } from 'posthog-js/react';\nimport { migrationApi } from '@/shared/lib/api';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { MigrateMigrate } from '@vibe/ui/components/MigrateMigrate';\nimport type { MigrationReport } from 'shared/types';\n\nconst REMOTE_ONBOARDING_EVENTS = {\n  STAGE_SUBMITTED: 'remote_onboarding_ui_stage_submitted',\n  STAGE_COMPLETED: 'remote_onboarding_ui_stage_completed',\n  STAGE_FAILED: 'remote_onboarding_ui_stage_failed',\n} as const;\n\ntype MigrationStartMethod = 'initial' | 'retry';\n\ninterface MigrateMigrateContainerProps {\n  orgId: string;\n  projectIds: string[];\n  onContinue: () => void;\n}\n\nexport function MigrateMigrateContainer({\n  orgId,\n  projectIds,\n  onContinue,\n}: MigrateMigrateContainerProps) {\n  const posthog = usePostHog();\n  const { data: orgsData } = useUserOrganizations();\n  const organizations = useMemo(\n    () => orgsData?.organizations ?? [],\n    [orgsData?.organizations]\n  );\n\n  const [isMigrating, setIsMigrating] = useState(true);\n  const [report, setReport] = useState<MigrationReport | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const hasStartedRef = useRef(false);\n\n  const orgName =\n    organizations.find((org) => org.id === orgId)?.name ?? 'Unknown';\n\n  const trackRemoteOnboardingEvent = useCallback(\n    (eventName: string, properties: Record<string, unknown> = {}) => {\n      posthog?.capture(eventName, {\n        ...properties,\n        flow: 'remote_onboarding_ui',\n        source: 'frontend',\n      });\n    },\n    [posthog]\n  );\n\n  const startMigration = useCallback(\n    async (method: MigrationStartMethod) => {\n      trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_SUBMITTED, {\n        stage: 'migrate',\n        method,\n        organization_id: orgId,\n        project_count: projectIds.length,\n      });\n\n      setIsMigrating(true);\n      setError(null);\n      setReport(null);\n\n      try {\n        const response = await migrationApi.start({\n          organization_id: orgId,\n          project_ids: projectIds,\n        });\n        setReport(response.report);\n\n        trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_COMPLETED, {\n          stage: 'migrate',\n          method,\n          organization_id: orgId,\n          project_count: projectIds.length,\n          migrated_projects: response.report.projects.migrated,\n          skipped_projects: response.report.projects.skipped,\n          warnings_count: response.report.warnings.length,\n        });\n      } catch (err) {\n        setError(err instanceof Error ? err.message : 'Migration failed');\n\n        trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_FAILED, {\n          stage: 'migrate',\n          method,\n          organization_id: orgId,\n          project_count: projectIds.length,\n          reason: 'migration_start_failed',\n        });\n      } finally {\n        setIsMigrating(false);\n      }\n    },\n    [orgId, projectIds, trackRemoteOnboardingEvent]\n  );\n\n  // Start migration on mount (only once)\n  useEffect(() => {\n    if (hasStartedRef.current) {\n      return;\n    }\n    hasStartedRef.current = true;\n    void startMigration('initial');\n  }, [startMigration]);\n\n  const handleRetry = () => {\n    void startMigration('retry');\n  };\n\n  return (\n    <MigrateMigrate\n      orgName={orgName}\n      projectCount={projectIds.length}\n      isMigrating={isMigrating}\n      report={report}\n      error={error}\n      onRetry={handleRetry}\n      onContinue={onContinue}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/onboarding/ui/LandingPage.tsx",
    "content": "import {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from 'react';\nimport {\n  BookOpenIcon,\n  BirdIcon,\n  CheckIcon,\n  CowIcon,\n  DeviceMobileIcon,\n  GithubLogoIcon,\n  MusicNoteIcon,\n  MusicNotesIcon,\n  SpeakerHighIcon,\n  SpeakerXIcon,\n  WarningIcon,\n  WaveformIcon,\n  type Icon,\n} from '@phosphor-icons/react';\nimport type { IconProps } from '@phosphor-icons/react';\nimport { usePostHog } from 'posthog-js/react';\nimport { siDiscord } from 'simple-icons';\nimport {\n  BaseCodingAgent,\n  EditorType,\n  SoundFile,\n  ThemeMode,\n  type EditorConfig,\n} from 'shared/types';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport { AgentIcon, getAgentName } from '@/shared/components/AgentIcon';\nimport { IdeIcon } from '@/shared/components/IdeIcon';\nimport { getIdeName } from '@/shared/lib/ideName';\nimport { cn, playSound } from '@/shared/lib/utils';\nimport { isTauriApp } from '@/shared/lib/platform';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\n\ntype SoundOption = {\n  value: SoundFile;\n  label: string;\n  icon: Icon;\n};\n\nconst SOUND_OPTIONS: SoundOption[] = [\n  {\n    value: SoundFile.ABSTRACT_SOUND1,\n    label: 'Abstract Sound 1',\n    icon: WaveformIcon,\n  },\n  {\n    value: SoundFile.ABSTRACT_SOUND2,\n    label: 'Abstract Sound 2',\n    icon: MusicNoteIcon,\n  },\n  {\n    value: SoundFile.ABSTRACT_SOUND3,\n    label: 'Abstract Sound 3',\n    icon: MusicNotesIcon,\n  },\n  {\n    value: SoundFile.ABSTRACT_SOUND4,\n    label: 'Abstract Sound 4',\n    icon: SpeakerHighIcon,\n  },\n  {\n    value: SoundFile.COW_MOOING,\n    label: 'Cow Mooing',\n    icon: CowIcon,\n  },\n  {\n    value: SoundFile.PHONE_VIBRATION,\n    label: 'Phone Vibration',\n    icon: DeviceMobileIcon,\n  },\n  {\n    value: SoundFile.ROOSTER,\n    label: 'Rooster',\n    icon: BirdIcon,\n  },\n];\n\nconst AGENT_PRIORITY: BaseCodingAgent[] = [\n  BaseCodingAgent.CLAUDE_CODE,\n  BaseCodingAgent.CODEX,\n  BaseCodingAgent.OPENCODE,\n  BaseCodingAgent.GEMINI,\n];\n\nconst DiscordIcon: Icon = forwardRef<SVGSVGElement, IconProps>(\n  ({ className, color = 'currentColor' }, ref) => (\n    <svg\n      ref={ref}\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill={color}\n      aria-hidden=\"true\"\n    >\n      <path d={siDiscord.path} />\n    </svg>\n  )\n);\nDiscordIcon.displayName = 'DiscordIcon';\n\nconst SOCIAL_LINKS = [\n  {\n    label: 'Discord',\n    href: 'https://discord.gg/AC4nwVtJM3',\n    icon: DiscordIcon,\n  },\n  {\n    label: 'GitHub',\n    href: 'https://github.com/BloopAI/vibe-kanban',\n    icon: GithubLogoIcon,\n  },\n  {\n    label: 'Docs',\n    href: 'https://www.vibekanban.com/docs',\n    icon: BookOpenIcon,\n  },\n];\n\nconst REMOTE_ONBOARDING_EVENTS = {\n  STAGE_VIEWED: 'remote_onboarding_ui_stage_viewed',\n  STAGE_SUBMITTED: 'remote_onboarding_ui_stage_submitted',\n  STAGE_COMPLETED: 'remote_onboarding_ui_stage_completed',\n  STAGE_FAILED: 'remote_onboarding_ui_stage_failed',\n} as const;\n\nfunction randomDefaultSoundFile(): SoundFile {\n  const randomIndex = Math.floor(Math.random() * SOUND_OPTIONS.length);\n  return SOUND_OPTIONS[randomIndex]?.value ?? SoundFile.COW_MOOING;\n}\n\nfunction resolveTheme(theme: ThemeMode): 'light' | 'dark' {\n  if (theme === ThemeMode.SYSTEM) {\n    return window.matchMedia('(prefers-color-scheme: dark)').matches\n      ? 'dark'\n      : 'light';\n  }\n  return theme === ThemeMode.DARK ? 'dark' : 'light';\n}\n\nexport function LandingPage() {\n  const appNavigation = useAppNavigation();\n  const { theme } = useTheme();\n  const { config, profiles, updateAndSaveConfig, loading } = useUserSystem();\n  const posthog = usePostHog();\n\n  const [initialized, setInitialized] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [selectedAgent, setSelectedAgent] = useState<BaseCodingAgent>(\n    BaseCodingAgent.CLAUDE_CODE\n  );\n  const [editorType, setEditorType] = useState<EditorType>(EditorType.VS_CODE);\n  const [customCommand, setCustomCommand] = useState('');\n  const [soundEnabled, setSoundEnabled] = useState(true);\n  const [soundFile, setSoundFile] = useState<SoundFile>(randomDefaultSoundFile);\n  const hasTrackedStageViewRef = useRef(false);\n  const hasRedirectedToRootRef = useRef(false);\n\n  const trackRemoteOnboardingEvent = useCallback(\n    (eventName: string, properties: Record<string, unknown> = {}) => {\n      posthog?.capture(eventName, {\n        ...properties,\n        flow: 'remote_onboarding_ui',\n        source: 'frontend',\n      });\n    },\n    [posthog]\n  );\n\n  const logoSrc =\n    resolveTheme(theme) === 'dark'\n      ? '/vibe-kanban-logo-dark.svg'\n      : '/vibe-kanban-logo.svg';\n\n  useEffect(() => {\n    if (!config || initialized) return;\n\n    setSelectedAgent(config.executor_profile.executor);\n    setEditorType(config.editor.editor_type);\n    setCustomCommand(config.editor.custom_command || '');\n    setInitialized(true);\n  }, [config, initialized]);\n\n  useEffect(() => {\n    if (!config || !initialized || hasTrackedStageViewRef.current) return;\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_VIEWED, {\n      stage: 'landing',\n    });\n    hasTrackedStageViewRef.current = true;\n  }, [config, initialized, trackRemoteOnboardingEvent]);\n\n  useEffect(() => {\n    if (\n      !config?.remote_onboarding_acknowledged ||\n      hasRedirectedToRootRef.current\n    ) {\n      return;\n    }\n\n    hasRedirectedToRootRef.current = true;\n    appNavigation.goToRoot({ replace: true });\n  }, [appNavigation, config?.remote_onboarding_acknowledged]);\n\n  const executorOptions = useMemo(() => {\n    const compareAgents = (a: BaseCodingAgent, b: BaseCodingAgent) => {\n      const priorityA = AGENT_PRIORITY.indexOf(a);\n      const priorityB = AGENT_PRIORITY.indexOf(b);\n      const hasPriorityA = priorityA !== -1;\n      const hasPriorityB = priorityB !== -1;\n\n      if (hasPriorityA && hasPriorityB) {\n        return priorityA - priorityB;\n      }\n      if (hasPriorityA) return -1;\n      if (hasPriorityB) return 1;\n\n      return getAgentName(a).localeCompare(getAgentName(b));\n    };\n\n    if (profiles) {\n      return (Object.keys(profiles) as BaseCodingAgent[]).sort(compareAgents);\n    }\n    return [...Object.values(BaseCodingAgent)].sort(compareAgents);\n  }, [profiles]);\n\n  const editorOptions = useMemo(() => Object.values(EditorType), []);\n\n  const previewSound = async (value: SoundFile) => {\n    try {\n      await playSound(`/api/sounds/${value}`);\n    } catch (err) {\n      console.error('Failed to play sound:', err);\n    }\n  };\n\n  const openExternalLink = (url: string) => {\n    window.open(url, '_blank', 'noopener,noreferrer');\n  };\n\n  const handleSoundSelect = (value: SoundFile) => {\n    setSoundEnabled(true);\n    setSoundFile(value);\n    void previewSound(value);\n  };\n\n  const isCustomEditorValid =\n    editorType !== EditorType.CUSTOM || customCommand.trim() !== '';\n  const canContinue = !saving && isCustomEditorValid;\n\n  const handleContinue = async () => {\n    if (!config || !canContinue) return;\n\n    const editorConfig: EditorConfig = {\n      editor_type: editorType,\n      custom_command:\n        editorType === EditorType.CUSTOM ? customCommand.trim() : null,\n      remote_ssh_host: null,\n      remote_ssh_user: null,\n      auto_install_extension: true,\n    };\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_SUBMITTED, {\n      stage: 'landing',\n      method: 'continue',\n      selected_agent: selectedAgent,\n      editor_type: editorType,\n      custom_editor_command_set:\n        editorType === EditorType.CUSTOM && customCommand.trim() !== '',\n      sound_enabled: soundEnabled,\n      sound_file: soundEnabled ? soundFile : null,\n    });\n\n    setSaving(true);\n    const success = await updateAndSaveConfig({\n      onboarding_acknowledged: true,\n      disclaimer_acknowledged: true,\n      executor_profile: {\n        executor: selectedAgent,\n        variant: null,\n      },\n      editor: editorConfig,\n      notifications: {\n        ...config.notifications,\n        sound_enabled: soundEnabled,\n        sound_file: soundFile,\n      },\n    });\n    setSaving(false);\n\n    if (success) {\n      trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_COMPLETED, {\n        stage: 'landing',\n        destination: '/onboarding/sign-in',\n      });\n      appNavigation.goToOnboardingSignIn({\n        replace: true,\n      });\n      return;\n    }\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_FAILED, {\n      stage: 'landing',\n      reason: 'config_save_failed',\n    });\n  };\n\n  if (loading || !config || !initialized) {\n    return (\n      <div className=\"h-screen bg-primary flex items-center justify-center\">\n        <p className=\"text-low\">Loading...</p>\n      </div>\n    );\n  }\n\n  if (config.remote_onboarding_acknowledged) {\n    return null;\n  }\n\n  return (\n    <div className=\"h-screen bg-primary flex items-center justify-center p-double\">\n      {isTauriApp() && (\n        <div\n          data-tauri-drag-region\n          className=\"fixed inset-x-0 top-0 h-10 z-10\"\n        />\n      )}\n      <div className=\"flex max-h-full w-full max-w-5xl flex-col rounded-sm border border-border bg-secondary\">\n        {/* Header */}\n        <header className=\"shrink-0 space-y-base p-double pb-base\">\n          <div className=\"flex items-center justify-between\">\n            <img src={logoSrc} alt=\"Vibe Kanban\" className=\"h-8 w-auto logo\" />\n            <div className=\"flex flex-wrap items-center gap-2\">\n              {SOCIAL_LINKS.map((link) => (\n                <PrimaryButton\n                  key={link.label}\n                  value={link.label}\n                  variant=\"tertiary\"\n                  actionIcon={link.icon}\n                  onClick={() => openExternalLink(link.href)}\n                />\n              ))}\n            </div>\n          </div>\n          <div className=\"rounded-sm border border-brand bg-brand/20 p-base\">\n            <div className=\"flex items-start gap-base\">\n              <WarningIcon\n                className=\"size-icon-sm text-brand shrink-0 mt-[2px]\"\n                weight=\"fill\"\n              />\n              <p className=\"text-sm text-normal\">\n                Vibe Kanban runs AI coding agents with{' '}\n                <code>--dangerously-skip-permissions</code> /{' '}\n                <code>--yolo</code> by default. Always review what agents are\n                doing.{' '}\n                <a\n                  href=\"https://www.vibekanban.com/docs/getting-started#safety-notice\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-brand hover:underline\"\n                >\n                  Learn more\n                </a>\n                .\n              </p>\n            </div>\n          </div>\n        </header>\n\n        {/* 3-column grid */}\n        <div className=\"min-h-0 flex-1 overflow-y-auto px-double pb-double\">\n          <div className=\"grid grid-cols-3 gap-double\">\n            {/* Column 1: Coding Agent */}\n            <section className=\"space-y-half\">\n              <h2 className=\"text-sm font-medium text-high\">Coding Agent</h2>\n              <div className=\"grid gap-1.5\">\n                {executorOptions.map((agent) => {\n                  const selected = selectedAgent === agent;\n\n                  return (\n                    <button\n                      key={agent}\n                      type=\"button\"\n                      onClick={() => setSelectedAgent(agent)}\n                      className={cn(\n                        'flex items-center gap-base rounded-sm border px-base py-half text-left',\n                        selected\n                          ? 'border-brand bg-brand/10'\n                          : 'border-border bg-panel hover:bg-primary'\n                      )}\n                    >\n                      <AgentIcon\n                        agent={agent}\n                        className=\"size-icon-xl shrink-0\"\n                      />\n                      <span className=\"text-sm text-normal flex-1 truncate\">\n                        {getAgentName(agent)}\n                      </span>\n                      {selected && (\n                        <CheckIcon\n                          className=\"size-icon-xs text-brand shrink-0\"\n                          weight=\"bold\"\n                        />\n                      )}\n                    </button>\n                  );\n                })}\n              </div>\n            </section>\n\n            {/* Column 2: Code Editor */}\n            <section className=\"space-y-half\">\n              <h2 className=\"text-sm font-medium text-high\">Code Editor</h2>\n              <div className=\"grid gap-1.5\">\n                {editorOptions.map((editor) => {\n                  const selected = editorType === editor;\n\n                  return (\n                    <button\n                      key={editor}\n                      type=\"button\"\n                      onClick={() => setEditorType(editor)}\n                      className={cn(\n                        'flex items-center gap-base rounded-sm border px-base py-half text-left',\n                        selected\n                          ? 'border-brand bg-brand/10'\n                          : 'border-border bg-panel hover:bg-primary'\n                      )}\n                    >\n                      <IdeIcon\n                        editorType={editor}\n                        className=\"size-icon-sm shrink-0\"\n                      />\n                      <span className=\"text-sm text-normal flex-1 truncate\">\n                        {getIdeName(editor)}\n                      </span>\n                      {selected && (\n                        <CheckIcon\n                          className=\"size-icon-xs text-brand shrink-0\"\n                          weight=\"bold\"\n                        />\n                      )}\n                    </button>\n                  );\n                })}\n              </div>\n\n              {editorType === EditorType.CUSTOM && (\n                <div className=\"space-y-half\">\n                  <label className=\"text-sm font-medium text-normal\">\n                    Custom Command\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={customCommand}\n                    onChange={(e) => setCustomCommand(e.target.value)}\n                    placeholder=\"e.g. code --wait\"\n                    className={cn(\n                      'w-full bg-panel border rounded-sm px-base py-half text-sm text-high',\n                      'placeholder:text-low placeholder:opacity-80 focus:outline-none',\n                      'focus:ring-1 focus:ring-brand',\n                      customCommand.trim() === ''\n                        ? 'border-warning/60'\n                        : 'border-border'\n                    )}\n                  />\n                </div>\n              )}\n            </section>\n\n            {/* Column 3: Notification Sound */}\n            <section className=\"space-y-half\">\n              <h2 className=\"text-sm font-medium text-high\">\n                Notification Sound\n              </h2>\n              <div className=\"grid gap-1.5\">\n                {SOUND_OPTIONS.map((option) => {\n                  const Icon = option.icon;\n                  const selected = soundEnabled && soundFile === option.value;\n\n                  return (\n                    <button\n                      key={option.value}\n                      type=\"button\"\n                      onClick={() => handleSoundSelect(option.value)}\n                      className={cn(\n                        'flex items-center gap-base rounded-sm border px-base py-half text-left',\n                        selected\n                          ? 'border-brand bg-brand/10'\n                          : 'border-border bg-panel hover:bg-primary'\n                      )}\n                    >\n                      <Icon\n                        className={cn(\n                          'size-icon-sm shrink-0',\n                          selected ? 'text-brand' : 'text-normal'\n                        )}\n                        weight={selected ? 'fill' : 'bold'}\n                      />\n                      <span className=\"text-sm text-normal flex-1 truncate\">\n                        {option.label}\n                      </span>\n                      {selected && (\n                        <CheckIcon\n                          className=\"size-icon-xs text-brand shrink-0\"\n                          weight=\"bold\"\n                        />\n                      )}\n                    </button>\n                  );\n                })}\n                <button\n                  type=\"button\"\n                  onClick={() => setSoundEnabled(false)}\n                  className={cn(\n                    'flex items-center gap-base rounded-sm border px-base py-half text-left',\n                    !soundEnabled\n                      ? 'border-brand bg-brand/10'\n                      : 'border-border bg-panel hover:bg-primary'\n                  )}\n                >\n                  <SpeakerXIcon\n                    className={cn(\n                      'size-icon-sm shrink-0',\n                      !soundEnabled ? 'text-brand' : 'text-normal'\n                    )}\n                    weight={!soundEnabled ? 'fill' : 'bold'}\n                  />\n                  <span className=\"text-sm text-normal flex-1\">No sound</span>\n                  {!soundEnabled && (\n                    <CheckIcon\n                      className=\"size-icon-xs text-brand shrink-0\"\n                      weight=\"bold\"\n                    />\n                  )}\n                </button>\n              </div>\n            </section>\n          </div>\n        </div>\n\n        {/* Footer */}\n        <div className=\"shrink-0 border-t border-border p-double pt-base flex items-center justify-between gap-base\">\n          <p className=\"text-xs text-low\">\n            By continuing you agree to the{' '}\n            <a\n              href=\"https://www.vibekanban.com/terms\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-brand hover:underline\"\n            >\n              terms and conditions\n            </a>{' '}\n            and{' '}\n            <a\n              href=\"https://www.vibekanban.com/privacy\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-brand hover:underline\"\n            >\n              privacy policy\n            </a>\n            .\n          </p>\n          <PrimaryButton\n            value={saving ? 'Saving...' : 'Continue'}\n            onClick={handleContinue}\n            disabled={!canContinue}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/onboarding/ui/OnboardingSignInPage.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { CheckIcon, XIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport { ThemeMode } from 'shared/types';\nimport {\n  OAuthDialog,\n  type OAuthProvider,\n} from '@/shared/dialogs/global/OAuthDialog';\nimport { usePostHog } from 'posthog-js/react';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport { OAuthSignInButton } from '@vibe/ui/components/OAuthButtons';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { getFirstProjectDestination } from '@/shared/lib/firstProjectDestination';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { isTauriApp } from '@/shared/lib/platform';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\n\ntype OnboardingDestination =\n  | { kind: 'workspaces-create' }\n  | { kind: 'project'; projectId: string };\n\nconst COMPARISON_ROWS = [\n  {\n    feature: 'Use kanban board to track issues',\n    signedIn: true,\n    skip: false,\n  },\n  {\n    feature: 'Invite team to collaborate',\n    signedIn: true,\n    skip: false,\n  },\n  {\n    feature: 'Organise work into projects and organizations',\n    signedIn: true,\n    skip: false,\n  },\n  {\n    feature: 'Create workspaces',\n    signedIn: true,\n    skip: true,\n  },\n];\n\nconst REMOTE_ONBOARDING_EVENTS = {\n  STAGE_VIEWED: 'remote_onboarding_ui_stage_viewed',\n  STAGE_SUBMITTED: 'remote_onboarding_ui_stage_submitted',\n  STAGE_COMPLETED: 'remote_onboarding_ui_stage_completed',\n  STAGE_FAILED: 'remote_onboarding_ui_stage_failed',\n  PROVIDER_CLICKED: 'remote_onboarding_ui_sign_in_provider_clicked',\n  PROVIDER_RESULT: 'remote_onboarding_ui_sign_in_provider_result',\n  MORE_OPTIONS_OPENED: 'remote_onboarding_ui_sign_in_more_options_opened',\n} as const;\n\ntype SignInCompletionMethod =\n  | 'continue_logged_in'\n  | 'skip_sign_in'\n  | 'oauth_github'\n  | 'oauth_google';\nfunction resolveTheme(theme: ThemeMode): 'light' | 'dark' {\n  if (theme === ThemeMode.SYSTEM) {\n    return window.matchMedia('(prefers-color-scheme: dark)').matches\n      ? 'dark'\n      : 'light';\n  }\n  return theme === ThemeMode.DARK ? 'dark' : 'light';\n}\n\nexport function OnboardingSignInPage() {\n  const appNavigation = useAppNavigation();\n  const { t } = useTranslation('common');\n  const { theme } = useTheme();\n  const posthog = usePostHog();\n  const { config, loginStatus, loading, updateAndSaveConfig } = useUserSystem();\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n\n  const [showComparison, setShowComparison] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const isCompletingOnboardingRef = useRef(false);\n  const hasTrackedStageViewRef = useRef(false);\n  const hasRedirectedToRootRef = useRef(false);\n  const [pendingProvider, setPendingProvider] = useState<OAuthProvider | null>(\n    null\n  );\n\n  const trackRemoteOnboardingEvent = useCallback(\n    (eventName: string, properties: Record<string, unknown> = {}) => {\n      posthog?.capture(eventName, {\n        ...properties,\n        flow: 'remote_onboarding_ui',\n        source: 'frontend',\n      });\n    },\n    [posthog]\n  );\n\n  const logoSrc =\n    resolveTheme(theme) === 'dark'\n      ? '/vibe-kanban-logo-dark.svg'\n      : '/vibe-kanban-logo.svg';\n\n  const isLoggedIn = loginStatus?.status === 'loggedin';\n\n  useEffect(() => {\n    if (loading || !config || hasTrackedStageViewRef.current) return;\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_VIEWED, {\n      stage: 'sign_in',\n      is_logged_in: isLoggedIn,\n    });\n    hasTrackedStageViewRef.current = true;\n  }, [config, isLoggedIn, loading, trackRemoteOnboardingEvent]);\n\n  useEffect(() => {\n    if (!config?.remote_onboarding_acknowledged) {\n      return;\n    }\n    if (isCompletingOnboardingRef.current || hasRedirectedToRootRef.current) {\n      return;\n    }\n\n    hasRedirectedToRootRef.current = true;\n    appNavigation.goToRoot({ replace: true });\n  }, [appNavigation, config?.remote_onboarding_acknowledged]);\n\n  const getOnboardingDestination = async (): Promise<OnboardingDestination> => {\n    const firstProjectDestination =\n      await getFirstProjectDestination(setSelectedOrgId);\n    if (\n      !firstProjectDestination ||\n      firstProjectDestination.kind !== 'project'\n    ) {\n      trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_FAILED, {\n        stage: 'sign_in',\n        reason: 'destination_lookup_failed',\n      });\n      return { kind: 'workspaces-create' };\n    }\n\n    return firstProjectDestination;\n  };\n\n  const finishOnboarding = async (options: {\n    method: SignInCompletionMethod;\n  }) => {\n    if (!config || saving || isCompletingOnboardingRef.current) return;\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_SUBMITTED, {\n      stage: 'sign_in',\n      method: options.method,\n      is_logged_in: isLoggedIn,\n    });\n\n    isCompletingOnboardingRef.current = true;\n    setSaving(true);\n    const success = await updateAndSaveConfig({\n      remote_onboarding_acknowledged: true,\n      onboarding_acknowledged: true,\n      disclaimer_acknowledged: true,\n    });\n\n    if (!success) {\n      trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_FAILED, {\n        stage: 'sign_in',\n        method: options.method,\n        reason: 'config_save_failed',\n      });\n      isCompletingOnboardingRef.current = false;\n      setSaving(false);\n      return;\n    }\n\n    const destination = await getOnboardingDestination();\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_COMPLETED, {\n      stage: 'sign_in',\n      method: options.method,\n      destination_kind: destination.kind,\n      destination_project_id:\n        destination.kind === 'project' ? destination.projectId : null,\n    });\n    switch (destination.kind) {\n      case 'workspaces-create':\n        appNavigation.goToWorkspacesCreate({ replace: true });\n        return;\n      case 'project':\n        appNavigation.goToProject(destination.projectId, { replace: true });\n        return;\n    }\n  };\n\n  const handleProviderSignIn = async (provider: OAuthProvider) => {\n    if (saving || pendingProvider) return;\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.PROVIDER_CLICKED, {\n      stage: 'sign_in',\n      provider,\n    });\n\n    setPendingProvider(provider);\n    const profile = await OAuthDialog.show({ initialProvider: provider });\n    setPendingProvider(null);\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.PROVIDER_RESULT, {\n      stage: 'sign_in',\n      provider,\n      result: profile ? 'success' : 'cancelled',\n    });\n\n    if (profile) {\n      await finishOnboarding({\n        method: provider === 'github' ? 'oauth_github' : 'oauth_google',\n      });\n    }\n  };\n\n  if (loading || !config) {\n    return (\n      <div className=\"h-screen bg-primary flex items-center justify-center\">\n        <p className=\"text-low\">Loading...</p>\n      </div>\n    );\n  }\n\n  if (\n    config.remote_onboarding_acknowledged &&\n    !isCompletingOnboardingRef.current\n  ) {\n    return null;\n  }\n\n  return (\n    <div className=\"h-screen overflow-auto bg-primary\">\n      {isTauriApp() && (\n        <div\n          data-tauri-drag-region\n          className=\"fixed inset-x-0 top-0 h-10 z-10\"\n        />\n      )}\n      <div className=\"mx-auto flex min-h-full w-full max-w-3xl flex-col justify-center px-base py-double\">\n        <div className=\"rounded-sm border border-border bg-secondary p-double space-y-double\">\n          <header className=\"space-y-double text-center\">\n            <div className=\"flex justify-center\">\n              <img\n                src={logoSrc}\n                alt=\"Vibe Kanban\"\n                className=\"h-8 w-auto logo\"\n              />\n            </div>\n            {!isLoggedIn && (\n              <p className=\"text-sm text-low\">\n                {t('onboardingSignIn.subtitle')}\n              </p>\n            )}\n          </header>\n\n          {isLoggedIn ? (\n            <section className=\"space-y-base\">\n              <p className=\"text-sm text-normal text-center\">\n                {t('onboardingSignIn.signedInAs', {\n                  name:\n                    loginStatus.profile.username || loginStatus.profile.email,\n                })}\n              </p>\n              <div className=\"flex justify-end\">\n                <PrimaryButton\n                  value={saving ? 'Continuing...' : 'Continue'}\n                  onClick={() =>\n                    void finishOnboarding({ method: 'continue_logged_in' })\n                  }\n                  disabled={saving}\n                />\n              </div>\n            </section>\n          ) : (\n            <>\n              <section className=\"flex flex-col items-center gap-2\">\n                <OAuthSignInButton\n                  provider=\"github\"\n                  onClick={() => void handleProviderSignIn('github')}\n                  disabled={saving || pendingProvider !== null}\n                  loading={pendingProvider === 'github'}\n                  loadingText=\"Opening GitHub...\"\n                />\n                <OAuthSignInButton\n                  provider=\"google\"\n                  onClick={() => void handleProviderSignIn('google')}\n                  disabled={saving || pendingProvider !== null}\n                  loading={pendingProvider === 'google'}\n                  loadingText=\"Opening Google...\"\n                />\n              </section>\n\n              <div className=\"flex justify-center\">\n                <button\n                  type=\"button\"\n                  className=\"text-sm text-low hover:text-normal underline underline-offset-2\"\n                  onClick={() => {\n                    if (!showComparison) {\n                      trackRemoteOnboardingEvent(\n                        REMOTE_ONBOARDING_EVENTS.MORE_OPTIONS_OPENED,\n                        {\n                          stage: 'sign_in',\n                        }\n                      );\n                    }\n                    setShowComparison(true);\n                  }}\n                  disabled={saving || pendingProvider !== null}\n                >\n                  {t('onboardingSignIn.moreOptions')}\n                </button>\n              </div>\n            </>\n          )}\n\n          {showComparison && !isLoggedIn && (\n            <section className=\"space-y-base rounded-sm border border-border bg-panel p-base\">\n              <div className=\"overflow-x-auto rounded-sm border border-border\">\n                <table className=\"w-full border-collapse\">\n                  <thead className=\"bg-secondary text-xs font-medium text-low\">\n                    <tr>\n                      <th className=\"px-base py-half text-left\">\n                        {t('onboardingSignIn.featureHeader')}\n                      </th>\n                      <th className=\"px-base py-half text-left border-l border-border\">\n                        {t('onboardingSignIn.signedInHeader')}\n                      </th>\n                      <th className=\"px-base py-half text-left border-l border-border\">\n                        {t('onboardingSignIn.skipSignInHeader')}\n                      </th>\n                    </tr>\n                  </thead>\n                  <tbody className=\"text-sm\">\n                    {COMPARISON_ROWS.map((row, index) => (\n                      <tr\n                        key={row.feature}\n                        className={index > 0 ? 'border-t border-border' : ''}\n                      >\n                        <td className=\"px-base py-half text-normal align-top\">\n                          {row.feature}\n                        </td>\n                        <td className=\"px-base py-half align-top border-l border-border text-center\">\n                          {row.signedIn ? (\n                            <>\n                              <CheckIcon\n                                className=\"size-icon-xs text-success inline\"\n                                weight=\"bold\"\n                              />\n                              <span className=\"sr-only\">\n                                {t('onboardingSignIn.yes')}\n                              </span>\n                            </>\n                          ) : (\n                            <>\n                              <XIcon\n                                className=\"size-icon-xs text-warning inline\"\n                                weight=\"bold\"\n                              />\n                              <span className=\"sr-only\">\n                                {t('onboardingSignIn.no')}\n                              </span>\n                            </>\n                          )}\n                        </td>\n                        <td className=\"px-base py-half align-top border-l border-border text-center\">\n                          {row.skip ? (\n                            <>\n                              <CheckIcon\n                                className=\"size-icon-xs text-success inline\"\n                                weight=\"bold\"\n                              />\n                              <span className=\"sr-only\">\n                                {t('onboardingSignIn.yes')}\n                              </span>\n                            </>\n                          ) : (\n                            <>\n                              <XIcon\n                                className=\"size-icon-xs text-warning inline\"\n                                weight=\"bold\"\n                              />\n                              <span className=\"sr-only\">\n                                {t('onboardingSignIn.no')}\n                              </span>\n                            </>\n                          )}\n                        </td>\n                      </tr>\n                    ))}\n                  </tbody>\n                </table>\n              </div>\n              <div className=\"flex justify-end\">\n                <PrimaryButton\n                  value={\n                    saving\n                      ? 'Continuing...'\n                      : 'I understand, continue without signing in'\n                  }\n                  variant=\"tertiary\"\n                  onClick={() =>\n                    void finishOnboarding({ method: 'skip_sign_in' })\n                  }\n                  disabled={saving || pendingProvider !== null}\n                />\n              </div>\n            </section>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace/model/hooks/usePreviewDevServer.ts",
    "content": "import { useMemo } from 'react';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi, executionProcessesApi } from '@/shared/lib/api';\nimport { useWorkspaceExecution } from '@/shared/hooks/useWorkspaceExecution';\nimport {\n  filterRunningDevServers,\n  filterDevServerProcesses,\n  deduplicateDevServersByWorkingDir,\n} from '@/shared/lib/devServerUtils';\nimport { workspaceSummaryKeys } from '@/shared/hooks/workspaceSummaryKeys';\n\ninterface UsePreviewDevServerOptions {\n  onStartSuccess?: () => void;\n  onStartError?: (err: unknown) => void;\n  onStopSuccess?: () => void;\n  onStopError?: (err: unknown) => void;\n}\n\nexport function usePreviewDevServer(\n  workspaceId: string | undefined,\n  options?: UsePreviewDevServerOptions\n) {\n  const queryClient = useQueryClient();\n  const { attemptData } = useWorkspaceExecution(workspaceId);\n\n  const runningDevServers = useMemo(\n    () => filterRunningDevServers(attemptData.processes),\n    [attemptData.processes]\n  );\n\n  const devServerProcesses = useMemo(\n    () =>\n      deduplicateDevServersByWorkingDir(\n        filterDevServerProcesses(attemptData.processes)\n      ),\n    [attemptData.processes]\n  );\n\n  const startMutation = useMutation({\n    mutationKey: ['startDevServer', workspaceId],\n    mutationFn: async () => {\n      if (!workspaceId) return;\n      await workspacesApi.startDevServer(workspaceId);\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({\n        queryKey: ['executionProcesses', workspaceId],\n      });\n      queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      options?.onStartSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to start dev server:', err);\n      options?.onStartError?.(err);\n    },\n  });\n\n  const stopMutation = useMutation({\n    mutationKey: ['stopDevServer', workspaceId],\n    mutationFn: async () => {\n      if (runningDevServers.length === 0) return;\n      await Promise.all(\n        runningDevServers.map((ds) =>\n          executionProcessesApi.stopExecutionProcess(ds.id)\n        )\n      );\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({\n        queryKey: ['executionProcesses', workspaceId],\n      });\n      for (const ds of runningDevServers) {\n        queryClient.invalidateQueries({\n          queryKey: ['processDetails', ds.id],\n        });\n      }\n      queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      options?.onStopSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to stop dev server:', err);\n      options?.onStopError?.(err);\n    },\n  });\n\n  return {\n    start: startMutation.mutate,\n    stop: stopMutation.mutate,\n    isStarting: startMutation.isPending,\n    isStopping: stopMutation.isPending,\n    runningDevServers,\n    devServerProcesses,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace/model/hooks/useWorkspaceNotes.ts",
    "content": "import { useCallback, useState, useEffect, useRef } from 'react';\nimport { ScratchType, type WorkspaceNotesData } from 'shared/types';\nimport { useScratch } from '@/shared/hooks/useScratch';\nimport { useDebouncedCallback } from '@/shared/hooks/useDebouncedCallback';\n\nexport interface UseWorkspaceNotesResult {\n  content: string;\n  isLoading: boolean;\n  isConnected: boolean;\n  error: string | null;\n  setContent: (content: string) => void;\n}\n\n/**\n * Hook for managing workspace notes stored in scratch memory.\n * Provides debounced saves and local state for immediate UI feedback.\n */\nexport function useWorkspaceNotes(\n  workspaceId: string | undefined\n): UseWorkspaceNotesResult {\n  const {\n    scratch,\n    updateScratch,\n    isLoading: isScratchLoading,\n    isConnected,\n    error,\n  } = useScratch(ScratchType.WORKSPACE_NOTES, workspaceId ?? '', {\n    enabled: !!workspaceId,\n  });\n\n  // Local state for immediate UI feedback\n  const [localContent, setLocalContent] = useState('');\n\n  // Track if user is actively editing to prevent server overwrites\n  const isEditingRef = useRef(false);\n\n  // Extract content from scratch payload\n  const scratchData: WorkspaceNotesData | undefined =\n    scratch?.payload?.type === 'WORKSPACE_NOTES'\n      ? scratch.payload.data\n      : undefined;\n\n  // Sync from server when scratch loads (but not while editing)\n  useEffect(() => {\n    if (isScratchLoading) return;\n    if (isEditingRef.current) return;\n    setLocalContent(scratchData?.content ?? '');\n  }, [isScratchLoading, scratchData?.content]);\n\n  // Debounced save to server\n  const { debounced: saveContent } = useDebouncedCallback(\n    useCallback(\n      async (content: string) => {\n        if (!workspaceId) return;\n        try {\n          await updateScratch({\n            payload: {\n              type: 'WORKSPACE_NOTES',\n              data: { content },\n            },\n          });\n        } catch (e) {\n          console.error('Failed to save workspace notes', e);\n        }\n        isEditingRef.current = false;\n      },\n      [workspaceId, updateScratch]\n    ),\n    500\n  );\n\n  const setContent = useCallback(\n    (content: string) => {\n      isEditingRef.current = true;\n      setLocalContent(content);\n      saveContent(content);\n    },\n    [saveContent]\n  );\n\n  return {\n    content: localContent,\n    isLoading: isScratchLoading,\n    isConnected,\n    error,\n    setContent,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/contexts/ApprovalFeedbackContext.tsx",
    "content": "import {\n  useContext,\n  useState,\n  useEffect,\n  useCallback,\n  useMemo,\n  ReactNode,\n} from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport { useApprovalMutation } from '../hooks/useApprovalMutation';\n\ninterface ActiveApproval {\n  approvalId: string;\n  executionProcessId: string;\n  timeoutAt: string;\n  requestedAt: string;\n}\n\ninterface ApprovalFeedbackContextType {\n  activeApproval: ActiveApproval | null;\n  enterFeedbackMode: (approval: ActiveApproval) => void;\n  exitFeedbackMode: () => void;\n  submitFeedback: (message: string) => Promise<void>;\n  isSubmitting: boolean;\n  error: string | null;\n  isTimedOut: boolean;\n}\n\nconst ApprovalFeedbackContext =\n  createHmrContext<ApprovalFeedbackContextType | null>(\n    'ApprovalFeedbackContext',\n    null\n  );\n\nexport function useApprovalFeedback() {\n  const context = useContext(ApprovalFeedbackContext);\n  if (!context) {\n    throw new Error(\n      'useApprovalFeedback must be used within ApprovalFeedbackProvider'\n    );\n  }\n  return context;\n}\n\n// Optional hook that doesn't throw - for components that may render outside provider\nexport function useApprovalFeedbackOptional() {\n  return useContext(ApprovalFeedbackContext);\n}\n\nexport function ApprovalFeedbackProvider({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const [activeApproval, setActiveApproval] = useState<ActiveApproval | null>(\n    null\n  );\n  const [nowTs, setNowTs] = useState(() => Date.now());\n  const { denyAsync, isDenying, denyError, reset } = useApprovalMutation();\n\n  useEffect(() => {\n    if (!activeApproval) {\n      setNowTs(Date.now());\n      return;\n    }\n\n    const timeoutAtMs = new Date(activeApproval.timeoutAt).getTime();\n    if (!Number.isFinite(timeoutAtMs)) {\n      setNowTs(Date.now());\n      return;\n    }\n\n    const delay = Math.max(timeoutAtMs - Date.now(), 0);\n    const timer = setTimeout(() => {\n      setNowTs(Date.now());\n    }, delay + 10);\n\n    return () => {\n      clearTimeout(timer);\n    };\n  }, [activeApproval]);\n\n  const timeoutAtMs = activeApproval\n    ? new Date(activeApproval.timeoutAt).getTime()\n    : Number.NaN;\n\n  const isTimedOut = activeApproval\n    ? Number.isFinite(timeoutAtMs) && nowTs > timeoutAtMs\n    : false;\n\n  const enterFeedbackMode = useCallback(\n    (approval: ActiveApproval) => {\n      setActiveApproval(approval);\n      setNowTs(Date.now());\n      reset();\n    },\n    [reset]\n  );\n\n  const exitFeedbackMode = useCallback(() => {\n    setActiveApproval(null);\n    setNowTs(Date.now());\n    reset();\n  }, [reset]);\n\n  const submitFeedback = useCallback(\n    async (message: string) => {\n      if (!activeApproval) return;\n\n      // Check timeout before submitting\n      if (new Date() > new Date(activeApproval.timeoutAt)) {\n        throw new Error('Approval has timed out');\n      }\n\n      await denyAsync({\n        approvalId: activeApproval.approvalId,\n        executionProcessId: activeApproval.executionProcessId,\n        reason: message.trim() || undefined,\n      });\n      setActiveApproval(null);\n    },\n    [activeApproval, denyAsync]\n  );\n\n  const value = useMemo(\n    () => ({\n      activeApproval,\n      enterFeedbackMode,\n      exitFeedbackMode,\n      submitFeedback,\n      isSubmitting: isDenying,\n      error: denyError?.message ?? null,\n      isTimedOut,\n    }),\n    [\n      activeApproval,\n      enterFeedbackMode,\n      exitFeedbackMode,\n      submitFeedback,\n      isDenying,\n      denyError?.message,\n      isTimedOut,\n    ]\n  );\n\n  return (\n    <ApprovalFeedbackContext.Provider value={value}>\n      {children}\n    </ApprovalFeedbackContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/contexts/EntriesContext.tsx",
    "content": "import { useContext, useState, useMemo, useCallback, ReactNode } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { PatchTypeWithKey } from '@/shared/hooks/useConversationHistory/types';\nimport type { TokenUsageInfo } from 'shared/types';\n\n// ---------------------------------------------------------------------------\n// Entries context — changes on every streaming update\n// ---------------------------------------------------------------------------\n\ninterface EntriesContextType {\n  entries: PatchTypeWithKey[];\n  setEntries: (entries: PatchTypeWithKey[]) => void;\n  reset: () => void;\n}\n\ninterface EntriesActionsContextType {\n  setEntries: (entries: PatchTypeWithKey[]) => void;\n  reset: () => void;\n}\n\nconst EntriesContext = createHmrContext<EntriesContextType | null>(\n  'EntriesContext',\n  null\n);\n\nconst EntriesActionsContext =\n  createHmrContext<EntriesActionsContextType | null>(\n    'EntriesActionsContext',\n    null\n  );\n\n// ---------------------------------------------------------------------------\n// Token-usage context — changes only when token stats update (much rarer)\n// ---------------------------------------------------------------------------\n\ninterface TokenUsageContextType {\n  tokenUsageInfo: TokenUsageInfo | null;\n  setTokenUsageInfo: (info: TokenUsageInfo | null) => void;\n}\n\nconst TokenUsageContext = createHmrContext<TokenUsageContextType | null>(\n  'TokenUsageContext',\n  null\n);\n\n// ---------------------------------------------------------------------------\n// Provider — nested contexts, single component\n// ---------------------------------------------------------------------------\n\ninterface EntriesProviderProps {\n  children: ReactNode;\n}\n\nexport const EntriesProvider = ({ children }: EntriesProviderProps) => {\n  const [entries, setEntriesState] = useState<PatchTypeWithKey[]>([]);\n  const [tokenUsageInfo, setTokenUsageInfoState] =\n    useState<TokenUsageInfo | null>(null);\n\n  const setEntries = useCallback((newEntries: PatchTypeWithKey[]) => {\n    setEntriesState(newEntries);\n  }, []);\n\n  const setTokenUsageInfo = useCallback((info: TokenUsageInfo | null) => {\n    setTokenUsageInfoState(info);\n  }, []);\n\n  const reset = useCallback(() => {\n    setEntriesState([]);\n    setTokenUsageInfoState(null);\n  }, []);\n\n  const entriesValue = useMemo(\n    () => ({ entries, setEntries, reset }),\n    [entries, setEntries, reset]\n  );\n\n  const entriesActionsValue = useMemo(\n    () => ({ setEntries, reset }),\n    [setEntries, reset]\n  );\n\n  const tokenUsageValue = useMemo(\n    () => ({ tokenUsageInfo, setTokenUsageInfo }),\n    [tokenUsageInfo, setTokenUsageInfo]\n  );\n\n  return (\n    <EntriesActionsContext.Provider value={entriesActionsValue}>\n      <EntriesContext.Provider value={entriesValue}>\n        <TokenUsageContext.Provider value={tokenUsageValue}>\n          {children}\n        </TokenUsageContext.Provider>\n      </EntriesContext.Provider>\n    </EntriesActionsContext.Provider>\n  );\n};\n\n// ---------------------------------------------------------------------------\n// Hooks\n// ---------------------------------------------------------------------------\n\nexport const useEntries = (): EntriesContextType => {\n  const context = useContext(EntriesContext);\n  if (!context) {\n    throw new Error('useEntries must be used within an EntriesProvider');\n  }\n  return context;\n};\n\nexport const useEntriesActions = (): EntriesActionsContextType => {\n  const context = useContext(EntriesActionsContext);\n  if (!context) {\n    throw new Error('useEntriesActions must be used within an EntriesProvider');\n  }\n  return context;\n};\n\n/**\n * Read token-usage info without subscribing to entries changes.\n * This context only updates when the token stats themselves change,\n * not on every streaming entry update.\n */\nexport const useTokenUsage = (): TokenUsageInfo | null => {\n  const context = useContext(TokenUsageContext);\n  if (!context) {\n    throw new Error('useTokenUsage must be used within an EntriesProvider');\n  }\n  return context.tokenUsageInfo;\n};\n\n/**\n * Get the setTokenUsageInfo setter without subscribing to entries.\n * Used by useConversationHistory to push token stats into context.\n */\nexport const useSetTokenUsageInfo = (): ((\n  info: TokenUsageInfo | null\n) => void) => {\n  const context = useContext(TokenUsageContext);\n  if (!context) {\n    throw new Error(\n      'useSetTokenUsageInfo must be used within an EntriesProvider'\n    );\n  }\n  return context.setTokenUsageInfo;\n};\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/contexts/MessageEditContext.tsx",
    "content": "import React, { useCallback, useContext, useMemo, useState } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport { useEntries } from './EntriesContext';\n\ninterface EditState {\n  entryKey: string;\n  processId: string;\n  originalMessage: string;\n}\n\ninterface MessageEditContextType {\n  activeEdit: EditState | null;\n  startEdit: (\n    entryKey: string,\n    processId: string,\n    originalMessage: string\n  ) => void;\n  cancelEdit: () => void;\n  isEntryGreyed: (entryKey: string) => boolean;\n  isInEditMode: boolean;\n}\n\nconst MessageEditContext = createHmrContext<MessageEditContextType | null>(\n  'MessageEditContext',\n  null\n);\n\nconst EMPTY_ORDER: Record<string, number> = {};\nconst NOOP_IS_GREYED = () => false;\n\nexport function MessageEditProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const [activeEdit, setActiveEdit] = useState<EditState | null>(null);\n  const { entries } = useEntries();\n\n  // Build entry order map only when actively editing.\n  // When inactive, return a stable empty reference to prevent\n  // downstream useMemo/useCallback deps from changing on every\n  // streaming entries update.\n  const entryOrder = useMemo(() => {\n    if (!activeEdit) return EMPTY_ORDER;\n    const order: Record<string, number> = {};\n    entries.forEach((entry, idx) => {\n      order[entry.patchKey] = idx;\n    });\n    return order;\n  }, [entries, activeEdit]);\n\n  const startEdit = useCallback(\n    (entryKey: string, processId: string, originalMessage: string) => {\n      setActiveEdit({ entryKey, processId, originalMessage });\n    },\n    []\n  );\n\n  const cancelEdit = useCallback(() => {\n    setActiveEdit(null);\n  }, []);\n\n  // When not editing, return a stable no-op to avoid context value churn.\n  // The entryOrder dep would otherwise create a new callback reference\n  // on every entries update even though it always returns false.\n  const isEntryGreyed = useCallback(\n    (entryKey: string) => {\n      if (!activeEdit) return false;\n      const activeOrder = entryOrder[activeEdit.entryKey];\n      const thisOrder = entryOrder[entryKey];\n      return thisOrder > activeOrder;\n    },\n    [activeEdit, entryOrder]\n  );\n\n  const stableIsEntryGreyed = activeEdit ? isEntryGreyed : NOOP_IS_GREYED;\n  const isInEditMode = activeEdit !== null;\n\n  const value = useMemo(\n    () => ({\n      activeEdit,\n      startEdit,\n      cancelEdit,\n      isEntryGreyed: stableIsEntryGreyed,\n      isInEditMode,\n    }),\n    [activeEdit, startEdit, cancelEdit, stableIsEntryGreyed, isInEditMode]\n  );\n\n  return (\n    <MessageEditContext.Provider value={value}>\n      {children}\n    </MessageEditContext.Provider>\n  );\n}\n\nexport function useMessageEditContext() {\n  const ctx = useContext(MessageEditContext);\n  if (!ctx) {\n    return {\n      activeEdit: null,\n      startEdit: () => {},\n      cancelEdit: () => {},\n      isEntryGreyed: () => false,\n      isInEditMode: false,\n    } as MessageEditContextType;\n  }\n  return ctx;\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/contexts/RetryUiContext.tsx",
    "content": "import React, { useCallback, useMemo, useState } from 'react';\nimport { useExecutionProcessesContext } from '@/shared/hooks/useExecutionProcessesContext';\nimport {\n  RetryUiContext,\n  type RetryUiContextType,\n} from '@/shared/hooks/useRetryUi';\n\nexport function RetryUiProvider({\n  children,\n}: {\n  workspaceId?: string;\n  children: React.ReactNode;\n}) {\n  const { executionProcessesAll: executionProcesses } =\n    useExecutionProcessesContext();\n\n  const [activeRetryProcessId, setActiveRetryProcessId] = useState<\n    string | null\n  >(null);\n\n  const processOrder = useMemo(() => {\n    const order: Record<string, number> = {};\n    executionProcesses.forEach((p, idx) => {\n      order[p.id] = idx;\n    });\n    return order;\n  }, [executionProcesses]);\n\n  const isProcessGreyed = useCallback(\n    (processId?: string) => {\n      if (!activeRetryProcessId || !processId) return false;\n      const activeOrder = processOrder[activeRetryProcessId];\n      const thisOrder = processOrder[processId];\n      // Grey out processes that come AFTER the retry target\n      return thisOrder > activeOrder;\n    },\n    [activeRetryProcessId, processOrder]\n  );\n\n  const value: RetryUiContextType = useMemo(\n    () => ({\n      activeRetryProcessId,\n      setActiveRetryProcessId,\n      processOrder,\n      isProcessGreyed,\n    }),\n    [\n      activeRetryProcessId,\n      setActiveRetryProcessId,\n      processOrder,\n      isProcessGreyed,\n    ]\n  );\n\n  return (\n    <RetryUiContext.Provider value={value}>{children}</RetryUiContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/conversation-row-model.ts",
    "content": "/**\n * Conversation Row Model\n *\n * Semantic row identity and metadata for the conversation list.\n * It wraps `DisplayEntry` with stable keys, row families, and size hints.\n */\n\nimport type { DisplayEntry } from '@/shared/hooks/useConversationHistory/types';\n\n// ---------------------------------------------------------------------------\n// Row Family\n// ---------------------------------------------------------------------------\n\n/**\n * Exhaustive list of visual row families used by the renderer.\n */\nexport type RowFamily =\n  // Atomic NormalizedEntry types\n  | 'user_message'\n  | 'assistant_message'\n  | 'system_message'\n  | 'thinking'\n  | 'error_message'\n  | 'loading'\n  | 'next_action'\n  | 'token_usage_info'\n  | 'user_feedback'\n  | 'user_answered_questions'\n  // Tool-use sub-variants (dispatched by action_type.action)\n  | 'tool_summary' // file_read, search, web_fetch, command_run (non-script), generic tool\n  | 'file_edit'\n  | 'script' // Setup Script, Cleanup Script, Archive Script, Tool Install Script\n  | 'plan' // plan_presentation\n  | 'todo' // todo_management\n  | 'subagent' // task_create\n  | 'approval' // any tool_use with status === 'pending_approval' (generic)\n  // Aggregated group types\n  | 'aggregated_tool' // AGGREGATED_GROUP\n  | 'aggregated_diff' // AGGREGATED_DIFF_GROUP\n  | 'aggregated_thinking'; // AGGREGATED_THINKING_GROUP\n\n// ---------------------------------------------------------------------------\n// Size Estimation Hint\n// ---------------------------------------------------------------------------\n\n/**\n * Coarse height bucket used before real DOM measurement is available.\n */\nexport type SizeEstimationHint =\n  | 'compact'\n  | 'medium'\n  | 'tall'\n  | 'dynamic'\n  | 'hidden';\n\n// ---------------------------------------------------------------------------\n// Conversation Row\n// ---------------------------------------------------------------------------\n\n/**\n * A single semantic row consumed by the conversation renderer.\n */\nexport interface ConversationRow {\n  /**\n   * Stable identity for the row. Used by the virtualizer and row renderer.\n   */\n  readonly semanticKey: string;\n\n  /** Classification of what this row renders. */\n  readonly rowFamily: RowFamily;\n\n  /**\n   * The execution process this row belongs to, or `null` for rows that\n   * span processes (e.g., `next_action`).\n   */\n  readonly processId: string | null;\n\n  /** Coarse height estimate for the virtualizer's `estimateSize`. */\n  readonly estimationHint: SizeEstimationHint;\n\n  /**\n   * Whether this row is a user message. Pre-computed flag to enable\n   * O(1) checks during `scrollToPreviousUserMessage` scans.\n   */\n  readonly isUserMessage: boolean;\n\n  /**\n   * The original `DisplayEntry` this row wraps. Passed through to the\n   * row renderer unchanged.\n   */\n  readonly entry: DisplayEntry;\n}\n\n// ---------------------------------------------------------------------------\n// Row Family Detection\n// ---------------------------------------------------------------------------\n\nconst SCRIPT_TOOL_NAMES = new Set([\n  'Setup Script',\n  'Cleanup Script',\n  'Archive Script',\n  'Tool Install Script',\n]);\n\n/**\n * Determine the renderer family for a display entry.\n */\nexport function classifyRowFamily(entry: DisplayEntry): RowFamily {\n  // Aggregated group types\n  if (entry.type === 'AGGREGATED_GROUP') return 'aggregated_tool';\n  if (entry.type === 'AGGREGATED_DIFF_GROUP') return 'aggregated_diff';\n  if (entry.type === 'AGGREGATED_THINKING_GROUP') return 'aggregated_thinking';\n\n  // Non-normalized entries (STDOUT/STDERR/DIFF) — treat as tool summary\n  if (entry.type !== 'NORMALIZED_ENTRY') return 'tool_summary';\n\n  const entryType = entry.content.entry_type;\n\n  switch (entryType.type) {\n    case 'user_message':\n      return 'user_message';\n    case 'assistant_message':\n      return 'assistant_message';\n    case 'system_message':\n      return 'system_message';\n    case 'thinking':\n      return 'thinking';\n    case 'error_message':\n      return 'error_message';\n    case 'loading':\n      return 'loading';\n    case 'next_action':\n      return 'next_action';\n    case 'token_usage_info':\n      return 'token_usage_info';\n    case 'user_feedback':\n      return 'user_feedback';\n    case 'user_answered_questions':\n      return 'user_answered_questions';\n    case 'tool_use': {\n      // Check pending_approval first — generic approval card overrides\n      // specific tool renderers (except file_edit and plan_presentation\n      // which have their own approval handling).\n      const { action_type, status, tool_name } = entryType;\n\n      if (action_type.action === 'file_edit') return 'file_edit';\n      if (action_type.action === 'plan_presentation') return 'plan';\n      if (action_type.action === 'todo_management') return 'todo';\n      if (action_type.action === 'task_create') return 'subagent';\n\n      // Script entries\n      if (\n        action_type.action === 'command_run' &&\n        SCRIPT_TOOL_NAMES.has(tool_name)\n      ) {\n        return 'script';\n      }\n\n      // Generic approval (non-file_edit, non-plan)\n      if (status.status === 'pending_approval') return 'approval';\n\n      return 'tool_summary';\n    }\n    default:\n      return 'tool_summary';\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Size Estimation\n// ---------------------------------------------------------------------------\n\n/** Default estimated sizes in pixels for each hint bucket. */\nexport const SIZE_ESTIMATE_PX: Record<SizeEstimationHint, number> = {\n  compact: 40,\n  medium: 80,\n  tall: 280,\n  dynamic: 150,\n  hidden: 0,\n};\n\n// ---------------------------------------------------------------------------\n// Width-Aware Content-Based Size Estimation\n// ---------------------------------------------------------------------------\n\nconst TEXT_CONTENT_FAMILIES = new Set<RowFamily>([\n  'user_message',\n  'assistant_message',\n  'thinking',\n]);\n\nconst AVG_CHAR_WIDTH_PX = 7.2; // avg proportional font char width\nconst LINE_HEIGHT_PX = 22;\nconst MESSAGE_CHROME_PX = 64; // avatar + timestamp + margins\nconst MESSAGE_HORIZONTAL_PADDING_PX = 100; // avatar + padding + scrollbar\nconst MIN_TEXT_ESTIMATE_PX = 60;\nconst MAX_TEXT_ESTIMATE_PX = 12_000;\n\nfunction estimateTextRowHeight(\n  textLength: number,\n  containerWidthPx: number,\n  fallback: number\n): number {\n  if (textLength <= 0 || containerWidthPx <= 0) return fallback;\n\n  const textAreaWidth = Math.max(\n    100,\n    containerWidthPx - MESSAGE_HORIZONTAL_PADDING_PX\n  );\n  const charsPerLine = Math.max(\n    20,\n    Math.floor(textAreaWidth / AVG_CHAR_WIDTH_PX)\n  );\n  const lineCount = Math.max(1, Math.ceil(textLength / charsPerLine));\n  const estimated = MESSAGE_CHROME_PX + lineCount * LINE_HEIGHT_PX;\n\n  return Math.min(\n    Math.max(estimated, MIN_TEXT_ESTIMATE_PX),\n    MAX_TEXT_ESTIMATE_PX\n  );\n}\n\nfunction getEntryTextLength(\n  entry: import('@/shared/hooks/useConversationHistory/types').DisplayEntry\n): number {\n  if (entry.type !== 'NORMALIZED_ENTRY') return 0;\n  return entry.content.content?.length ?? 0;\n}\n\nexport function estimateSizeForRow(\n  row: ConversationRow,\n  containerWidthPx?: number | null\n): number {\n  const base = SIZE_ESTIMATE_PX[row.estimationHint];\n\n  if (\n    containerWidthPx != null &&\n    containerWidthPx > 0 &&\n    TEXT_CONTENT_FAMILIES.has(row.rowFamily)\n  ) {\n    const textLength = getEntryTextLength(row.entry);\n    if (textLength > 0) {\n      return estimateTextRowHeight(textLength, containerWidthPx, base);\n    }\n  }\n\n  return base;\n}\n\n/**\n * Map a `RowFamily` to a `SizeEstimationHint`.\n *\n * This is deliberately coarse — real sizes vary based on content length,\n * expansion state, etc. The hint just gives the virtualizer a reasonable\n * starting point to reduce initial layout jank.\n */\nexport function estimationHintForFamily(family: RowFamily): SizeEstimationHint {\n  switch (family) {\n    // Compact: single-line or minimal-height rows\n    case 'tool_summary':\n    case 'loading':\n    case 'token_usage_info':\n    case 'todo':\n      return 'compact';\n\n    // Medium: multi-line but bounded rows\n    case 'user_message':\n    case 'error_message':\n    case 'user_feedback':\n    case 'user_answered_questions':\n    case 'script':\n      return 'medium';\n\n    case 'system_message':\n      return 'compact';\n\n    // Tall: potentially large content\n    case 'assistant_message':\n    case 'plan':\n    case 'subagent':\n    case 'approval':\n      return 'tall';\n\n    // Aggregated groups start collapsed (useState(false)) with ~40-60px\n    // actual height. Estimating 'tall' (280px) caused ~6x overestimate\n    // during streaming aggregation transitions (individual→grouped),\n    // producing scroll jitter under follow-bottom. 'compact' matches the\n    // default collapsed state; ResizeObserver corrects on user expand.\n    case 'aggregated_tool':\n    case 'aggregated_diff':\n    case 'aggregated_thinking':\n      return 'compact';\n\n    // Dynamic: height changes significantly based on state\n    case 'file_edit':\n    case 'thinking':\n      return 'dynamic';\n\n    // Hidden: filtered before reaching the list\n    case 'next_action':\n      return 'hidden';\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Semantic Key Generation\n// ---------------------------------------------------------------------------\n\n/**\n * Produce a stable semantic key for a `DisplayEntry`.\n *\n * This replaces the ad-hoc `'conv-' + data.patchKey` pattern with\n * explicitly namespaced keys that encode the row's origin:\n *\n * - Aggregated groups: `conv-agg:{firstEntryKey}`,\n *   `conv-agg-diff:{firstEntryKey}`, `conv-agg-thinking:{firstEntryKey}`\n * - Regular entries: `conv-{patchKey}`\n *\n * The `conv-` prefix is preserved for backward compatibility with\n * persisted expansion state keys in `useUiPreferencesStore`.\n */\nexport function semanticKeyForEntry(entry: DisplayEntry): string {\n  // The patchKey already contains the aggregation prefix\n  // (e.g., `agg:`, `agg-diff:`, `agg-thinking:`) for aggregated groups.\n  // We just need to add the `conv-` prefix.\n  return `conv-${entry.patchKey}`;\n}\n\n// ---------------------------------------------------------------------------\n// Row Builder\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a `DisplayEntry` into a `ConversationRow`.\n *\n * This is the primary entry point for consumers that want to build the\n * row model from an existing `DisplayEntry[]` pipeline.\n */\nexport function buildConversationRow(entry: DisplayEntry): ConversationRow {\n  const rowFamily = classifyRowFamily(entry);\n  const hint = estimationHintForFamily(rowFamily);\n\n  return {\n    semanticKey: semanticKeyForEntry(entry),\n    rowFamily,\n    processId: entry.executionProcessId || null,\n    estimationHint: hint,\n    isUserMessage: rowFamily === 'user_message',\n    entry,\n  };\n}\n\n/**\n * Convert a `DisplayEntry[]` into `ConversationRow[]`.\n *\n * Preserves order. Can be used as a drop-in transformation step in the\n * data pipeline between `aggregateConsecutiveEntries` and the virtualizer.\n */\nexport function buildConversationRows(\n  entries: DisplayEntry[]\n): ConversationRow[] {\n  return entries.map(buildConversationRow);\n}\n\n/**\n * Incremental row builder that reuses previous `ConversationRow` objects\n * when the underlying `DisplayEntry` reference is unchanged.\n *\n * During streaming, most entries are stable (same object reference from\n * the previous emit). Only the tail entries change (new appends or\n * aggregation boundary shifts). Reusing row objects means:\n * 1. Less GC pressure from short-lived objects.\n * 2. TanStack Virtual's internal diffing can skip unchanged rows faster.\n */\nexport function buildConversationRowsIncremental(\n  entries: DisplayEntry[],\n  prevEntries: DisplayEntry[],\n  prevRows: ConversationRow[]\n): ConversationRow[] {\n  const len = entries.length;\n  const rows: ConversationRow[] = new Array(len);\n\n  for (let i = 0; i < len; i++) {\n    if (i < prevRows.length && entries[i] === prevEntries[i]) {\n      rows[i] = prevRows[i];\n    } else {\n      rows[i] = buildConversationRow(entries[i]);\n    }\n  }\n\n  return rows;\n}\n\n// ---------------------------------------------------------------------------\n// Row Queries\n// ---------------------------------------------------------------------------\n\n/**\n * Find the index of the previous user-message row before `beforeIndex`.\n *\n * Used by `scrollToPreviousUserMessage` to locate the scroll target\n * without re-scanning entry internals.\n *\n * @returns The index of the previous user message, or -1 if none found.\n */\nexport function findPreviousUserMessageIndex(\n  rows: ConversationRow[],\n  beforeIndex: number\n): number {\n  for (let i = beforeIndex - 1; i >= 0; i--) {\n    if (rows[i].isUserMessage) return i;\n  }\n  return -1;\n}\n\n// ---------------------------------------------------------------------------\n// Key Contract Audit Notes\n// ---------------------------------------------------------------------------\n\n/**\n * KEY CONTRACT AUDIT\n *\n * Traced all key production paths. Findings:\n *\n * ## Stable keys\n * - Backend entries: `{processId}:{index}` — stable as long as the entry\n *   array for a process doesn't get reordered (it doesn't; entries are\n *   append-only within a process).\n * - Synthetic user messages: `{processId}:user` — unique per process.\n * - Synthetic loading: `{processId}:loading` — unique per process.\n * - Script entries: `{processId}:script` — unique per process. The\n *   `:script` suffix provides semantic clarity and avoids collision\n *   with index-based keys. TanStack's measureElement + ResizeObserver\n *   handles height changes during streaming->completed transitions.\n * - Next action: `next_action` — singleton.\n * - Aggregated groups: `agg:{firstEntryKey}` — stable because the first\n *   entry's key is stable and aggregation only groups *consecutive*\n *   entries (the first entry of a group is always the same entry).\n *\n * ## Potential instabilities\n * 1. **Index-based keys during streaming**: While a process is streaming,\n *    entries are re-indexed on every `onEntries` callback (entries.map\n *    with index). If entries are *replaced* (not appended), keys could\n *    shift. In practice, the stream appears to be append-only, so this\n *    is low risk but worth monitoring.\n *\n * 2. **Process reload on status transition**: When a process transitions\n *    from running to completed, entries are reloaded from the historic\n *    endpoint. The reloaded entries get fresh index-based keys. This\n *    is intentional — the full entry set replaces the streaming set —\n *    but means all keys for that process change in one batch. The\n *    virtualizer must handle this as a bulk replacement, not an update.\n *\n * 3. **Setup-script user message deduplication**: The initial user\n *    message can appear from either the script branch or the coding\n *    agent branch. Both use the key `{processId}:user` but with\n *    different processIds, so there's no collision. The suppression\n *    logic (`isInitialWithSetup`) ensures only one is emitted.\n *\n * 4. **Aggregation boundary shifts**: When a new entry arrives that\n *    matches the aggregation type of the previous entry, a single\n *    entry becomes a group. The group's key is `agg:{singleEntryKey}`,\n *    which is different from the original entry's key. This means the\n *    virtualizer sees the old key removed and a new key added. This\n *    is correct behavior but the virtualizer must not try to animate\n *    between the old and new states.\n */\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/conversation-scroll-commands.ts",
    "content": "/**\n * Conversation Scroll Commands\n *\n * Declarative scroll intent model for the conversation list.\n */\n\nimport type { AddEntryType } from '@/shared/hooks/useConversationHistory/types';\n\n// ---------------------------------------------------------------------------\n// Near-Bottom Threshold\n// ---------------------------------------------------------------------------\n\n/**\n * Pixel distance from bottom within which the user is considered \"at bottom\".\n * Accounts for sub-pixel rounding, scroll inertia, and minor content growth.\n */\nexport const NEAR_BOTTOM_THRESHOLD_PX = 64;\n\n// ---------------------------------------------------------------------------\n// Scroll Intent\n// ---------------------------------------------------------------------------\n\n/**\n * Jump to bottom on first load and invalidate estimated sizes.\n */\nexport interface InitialBottomIntent {\n  readonly type: 'initial-bottom';\n  readonly purgeEstimatedSizes: true;\n}\n\n/**\n * Follow the bottom while streaming if the user is already there.\n */\nexport interface FollowBottomIntent {\n  readonly type: 'follow-bottom';\n  readonly behavior: ScrollBehavior;\n}\n\n/**\n * Preserve the current viewport while history changes above it.\n */\nexport interface PreserveAnchorIntent {\n  readonly type: 'preserve-anchor';\n}\n\n/** Scroll so the last item's top is visible (plan presentation). */\nexport interface PlanRevealIntent {\n  readonly type: 'plan-reveal';\n  readonly align: 'start';\n}\n\n/** Explicit user action to return to bottom (scroll-to-bottom button). */\nexport interface JumpToBottomIntent {\n  readonly type: 'jump-to-bottom';\n  readonly behavior: ScrollBehavior;\n}\n\n/** Scroll to a specific row index (previous-user-message, jump-to-item). */\nexport interface JumpToIndexIntent {\n  readonly type: 'jump-to-index';\n  readonly index: number;\n  readonly align: 'start' | 'center' | 'end';\n  readonly behavior: ScrollBehavior;\n}\n\nexport type ScrollIntent =\n  | InitialBottomIntent\n  | FollowBottomIntent\n  | PreserveAnchorIntent\n  | PlanRevealIntent\n  | JumpToBottomIntent\n  | JumpToIndexIntent;\n\n// ---------------------------------------------------------------------------\n// Scroll State\n// ---------------------------------------------------------------------------\n\n/**\n * Single source of truth for conversation scroll behaviour.\n */\nexport interface ScrollState {\n  /** Whether the user is at (or near) the bottom of the list. */\n  readonly isAtBottom: boolean;\n\n  /** Intent waiting to be applied after virtualizer measurement. */\n  readonly pendingIntent: ScrollIntent | null;\n\n  /** Last successfully applied intent (for deduplication). */\n  readonly lastAppliedIntent: ScrollIntent | null;\n}\n\n// ---------------------------------------------------------------------------\n// State Factory\n// ---------------------------------------------------------------------------\n\nexport function createInitialScrollState(): ScrollState {\n  return {\n    isAtBottom: true,\n    pendingIntent: null,\n    lastAppliedIntent: null,\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Intent Resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Map a data update to the scroll intent that should be applied next.\n */\nexport function resolveScrollIntent(\n  addType: AddEntryType,\n  isInitialLoad: boolean,\n  isAtBottom: boolean\n): ScrollIntent {\n  if (isInitialLoad) {\n    return { type: 'initial-bottom', purgeEstimatedSizes: true };\n  }\n\n  if (addType === 'plan') {\n    return { type: 'plan-reveal', align: 'start' };\n  }\n\n  if (addType === 'running') {\n    return isAtBottom\n      ? { type: 'follow-bottom', behavior: 'auto' }\n      : { type: 'preserve-anchor' };\n  }\n\n  return isAtBottom\n    ? { type: 'follow-bottom', behavior: 'auto' }\n    : { type: 'preserve-anchor' };\n}\n\n// ---------------------------------------------------------------------------\n// Auto-Follow Predicate\n// ---------------------------------------------------------------------------\n\n/**\n * Whether a new update should auto-follow the bottom.\n */\nexport function shouldAutoFollow(\n  state: ScrollState,\n  addType: AddEntryType\n): boolean {\n  if (!state.isAtBottom) return false;\n  if (addType === 'plan') return false;\n  return true;\n}\n\n// ---------------------------------------------------------------------------\n// State Transitions\n// ---------------------------------------------------------------------------\n\n/** Set a new pending intent, replacing any existing one. */\nexport function setPendingIntent(\n  state: ScrollState,\n  intent: ScrollIntent\n): ScrollState {\n  return { ...state, pendingIntent: intent };\n}\n\n/** Mark pending intent as applied and move it to `lastAppliedIntent`. */\nexport function markIntentApplied(state: ScrollState): ScrollState {\n  return {\n    ...state,\n    lastAppliedIntent: state.pendingIntent,\n    pendingIntent: null,\n  };\n}\n\n/** Update `isAtBottom` from a scroll event. */\nexport function updateIsAtBottom(\n  state: ScrollState,\n  isAtBottom: boolean\n): ScrollState {\n  if (state.isAtBottom === isAtBottom) return state;\n  return { ...state, isAtBottom };\n}\n\n/** Clear pending intent without marking it as applied (intent went stale). */\nexport function clearPendingIntent(state: ScrollState): ScrollState {\n  if (state.pendingIntent === null) return state;\n  return { ...state, pendingIntent: null };\n}\n\n// ---------------------------------------------------------------------------\n// Near-Bottom Detection\n// ---------------------------------------------------------------------------\n\n/**\n * Check whether a scroll container is within `NEAR_BOTTOM_THRESHOLD_PX`\n * of the bottom. Returns true for non-finite inputs (unmounted containers).\n */\nexport function isNearBottom(\n  scrollTop: number,\n  clientHeight: number,\n  scrollHeight: number\n): boolean {\n  if (\n    !Number.isFinite(scrollTop) ||\n    !Number.isFinite(clientHeight) ||\n    !Number.isFinite(scrollHeight)\n  ) {\n    return true;\n  }\n\n  const distanceFromBottom = scrollHeight - clientHeight - scrollTop;\n  return distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_PX;\n}\n\n// ---------------------------------------------------------------------------\n// Intent Equality (for deduplication)\n// ---------------------------------------------------------------------------\n\n/** Structural equality check for scroll intents. */\nexport function intentsEqual(\n  a: ScrollIntent | null,\n  b: ScrollIntent | null\n): boolean {\n  if (a === b) return true;\n  if (a === null || b === null) return false;\n  if (a.type !== b.type) return false;\n\n  switch (a.type) {\n    case 'initial-bottom':\n    case 'preserve-anchor':\n    case 'plan-reveal':\n      return true;\n    case 'follow-bottom':\n      return (b as FollowBottomIntent).behavior === a.behavior;\n    case 'jump-to-bottom':\n      return (b as JumpToBottomIntent).behavior === a.behavior;\n    case 'jump-to-index': {\n      const bIdx = b as JumpToIndexIntent;\n      return (\n        bIdx.index === a.index &&\n        bIdx.align === a.align &&\n        bIdx.behavior === a.behavior\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/deriveConversationEntries.ts",
    "content": "import { NormalizedEntry, PatchType, TokenUsageInfo } from 'shared/types';\n\nimport {\n  makeLoadingPatch,\n  nextActionPatch,\n} from '@/shared/hooks/useConversationHistory/constants';\nimport type { PatchTypeWithKey } from '@/shared/hooks/useConversationHistory/types';\nimport {\n  deriveConversationTurns,\n  type ConversationAgentTurn,\n  type ConversationScriptTurn,\n  type ConversationTurn,\n} from './deriveConversationTurns';\n\nexport interface DerivedConversationEntriesResult {\n  readonly entries: PatchTypeWithKey[];\n  readonly hasRunningProcess: boolean;\n  readonly hasSetupScriptRun: boolean;\n  readonly hasCleanupScriptRun: boolean;\n  readonly latestTokenUsageInfo: TokenUsageInfo | null;\n}\n\ninterface DeriveConversationEntriesParams {\n  readonly source: import('@/shared/hooks/useConversationHistory/types').ConversationTimelineSource;\n  readonly scriptOutputCache: Map<string, { count: number; output: string }>;\n}\n\nfunction patchWithKey(\n  patch: PatchType,\n  executionProcessId: string,\n  index: number | 'user' | 'script'\n): PatchTypeWithKey {\n  return {\n    ...patch,\n    patchKey: `${executionProcessId}:${index}`,\n    executionProcessId,\n  };\n}\n\nfunction appendAgentTurnEntries(\n  turn: ConversationAgentTurn,\n  turnEntries: PatchTypeWithKey[]\n) {\n  if (turn.shouldEmitUserMessage && turn.prompt) {\n    const userNormalizedEntry: NormalizedEntry = {\n      entry_type: { type: 'user_message' },\n      content: turn.prompt,\n      timestamp: null,\n    };\n\n    turnEntries.push(\n      patchWithKey(\n        { type: 'NORMALIZED_ENTRY', content: userNormalizedEntry },\n        turn.process.executionProcess.id,\n        'user'\n      )\n    );\n  }\n\n  turnEntries.push(...turn.visibleEntries);\n\n  if (turn.shouldEmitLoading) {\n    turnEntries.push(makeLoadingPatch(turn.process.executionProcess.id));\n  }\n}\n\nfunction appendScriptTurnEntries(\n  turn: ConversationScriptTurn,\n  turnEntries: PatchTypeWithKey[],\n  scriptOutputCache: Map<string, { count: number; output: string }>\n) {\n  for (const process of turn.processes) {\n    const processId = process.process.executionProcess.id;\n    const entryCount = process.process.rawEntries.length;\n    const cachedOutput = scriptOutputCache.get(processId);\n    const output =\n      cachedOutput && cachedOutput.count === entryCount\n        ? cachedOutput.output\n        : process.process.rawEntries.map((entry) => entry.content).join('\\n');\n\n    scriptOutputCache.set(processId, {\n      count: entryCount,\n      output,\n    });\n\n    const scriptAction = process.process.executionProcess.executor_action.typ;\n    if (scriptAction.type !== 'ScriptRequest') {\n      continue;\n    }\n\n    const toolNormalizedEntry: NormalizedEntry = {\n      entry_type: {\n        type: 'tool_use',\n        tool_name: process.toolName,\n        action_type: {\n          action: 'command_run',\n          command: scriptAction.script,\n          result: {\n            output,\n            exit_status: process.exitStatus,\n          },\n          category: 'other',\n        },\n        status: process.toolStatus,\n      },\n      content: process.toolName,\n      timestamp: null,\n    };\n\n    turnEntries.push(\n      patchWithKey(\n        { type: 'NORMALIZED_ENTRY', content: toolNormalizedEntry },\n        processId,\n        'script'\n      )\n    );\n\n    if (\n      process.shouldEmitInitialPromptAfterSetup &&\n      process.initialPromptAfterSetup\n    ) {\n      turnEntries.push(\n        patchWithKey(\n          {\n            type: 'NORMALIZED_ENTRY',\n            content: {\n              entry_type: { type: 'user_message' },\n              content: process.initialPromptAfterSetup,\n              timestamp: null,\n            },\n          },\n          processId,\n          'user'\n        )\n      );\n    }\n  }\n}\n\nfunction isAgentTurn(turn: ConversationTurn): turn is ConversationAgentTurn {\n  return (\n    turn.kind === 'agent_idle' ||\n    turn.kind === 'agent_running' ||\n    turn.kind === 'agent_pending_approval' ||\n    turn.kind === 'agent_failed'\n  );\n}\n\n// This stage serializes already-derived turn meaning into visible conversation entries.\n\nexport function deriveConversationEntries({\n  source,\n  scriptOutputCache,\n}: DeriveConversationEntriesParams): DerivedConversationEntriesResult {\n  const conversationTurns = deriveConversationTurns(source);\n\n  let hasPendingApproval = false;\n  let hasRunningProcess = false;\n  let lastProcessFailedOrKilled = false;\n  let needsSetup = false;\n  let setupHelpText: string | undefined;\n  let latestTokenUsageInfo: TokenUsageInfo | null = null;\n  let hasSetupScriptRun = false;\n  let hasCleanupScriptRun = false;\n\n  const entries = conversationTurns.turns.flatMap((turn, index) => {\n    const turnEntries: PatchTypeWithKey[] = [];\n\n    if (isAgentTurn(turn)) {\n      if (turn.latestTokenUsageInfo) {\n        latestTokenUsageInfo = turn.latestTokenUsageInfo;\n      }\n\n      if (turn.kind === 'agent_pending_approval') {\n        hasPendingApproval = true;\n      }\n\n      if (turn.kind === 'agent_running') {\n        hasRunningProcess = true;\n      }\n\n      if (\n        turn.kind === 'agent_failed' &&\n        index === conversationTurns.turns.length - 1\n      ) {\n        lastProcessFailedOrKilled = true;\n        if (turn.needsSetup) {\n          needsSetup = true;\n          setupHelpText = turn.setupHelpText;\n        }\n      }\n\n      appendAgentTurnEntries(turn, turnEntries);\n      return turnEntries;\n    }\n\n    if (turn.kind === 'setup_script') {\n      hasSetupScriptRun = true;\n    } else if (turn.kind === 'cleanup_script') {\n      hasCleanupScriptRun = true;\n    }\n\n    if (turn.processes.some((process) => process.process.isRunning)) {\n      hasRunningProcess = true;\n    }\n\n    if (\n      turn.processes.some((process) => process.process.failedOrKilled) &&\n      index === conversationTurns.turns.length - 1\n    ) {\n      lastProcessFailedOrKilled = true;\n    }\n\n    appendScriptTurnEntries(turn, turnEntries, scriptOutputCache);\n    return turnEntries;\n  });\n\n  if (!hasRunningProcess && !hasPendingApproval) {\n    entries.push(\n      nextActionPatch(\n        lastProcessFailedOrKilled,\n        conversationTurns.turns.length,\n        needsSetup,\n        setupHelpText\n      )\n    );\n  }\n\n  return {\n    entries,\n    hasRunningProcess,\n    hasSetupScriptRun,\n    hasCleanupScriptRun,\n    latestTokenUsageInfo,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/deriveConversationSemanticTimeline.ts",
    "content": "import { ExecutionProcessStatus, type ExecutionProcess } from 'shared/types';\n\nimport type {\n  ConversationTimelineSource,\n  ExecutionProcessState,\n  PatchTypeWithKey,\n} from '@/shared/hooks/useConversationHistory/types';\n\nexport type ConversationSemanticProcessKind = 'agent' | 'script' | 'unknown';\n\nexport interface ConversationSemanticProcessItem {\n  readonly executionProcessId: string;\n  readonly executionProcess: ExecutionProcessState['executionProcess'];\n  readonly kind: ConversationSemanticProcessKind;\n  readonly liveExecutionProcess: ExecutionProcess | null;\n  readonly rawEntries: PatchTypeWithKey[];\n  readonly visibleEntries: PatchTypeWithKey[];\n  readonly latestTokenUsageEntry: PatchTypeWithKey | null;\n  readonly hasPendingApprovalEntry: boolean;\n  readonly isRunning: boolean;\n  readonly failedOrKilled: boolean;\n}\n\nexport interface ConversationSemanticTimeline {\n  readonly processes: ConversationSemanticProcessItem[];\n  readonly hasSetupScriptProcess: boolean;\n  readonly hasSetupScriptWithPrompt: boolean;\n}\n\nfunction extractPromptFromActionChain(\n  action: ExecutionProcessState['executionProcess']['executor_action'] | null\n): string | null {\n  let current = action;\n  while (current) {\n    const typ = current.typ;\n    if (\n      typ.type === 'CodingAgentInitialRequest' ||\n      typ.type === 'CodingAgentFollowUpRequest' ||\n      typ.type === 'ReviewRequest'\n    ) {\n      return typ.prompt;\n    }\n    current = current.next_action;\n  }\n  return null;\n}\n\n// This is the first semantic reshape after the raw source model.\n// It keeps process-level information but removes direct store traversal from later stages.\n\nfunction toConversationSemanticProcessKind(\n  executionProcess: ExecutionProcessState['executionProcess']\n): ConversationSemanticProcessKind {\n  const actionType = executionProcess.executor_action.typ.type;\n\n  if (\n    actionType === 'CodingAgentInitialRequest' ||\n    actionType === 'CodingAgentFollowUpRequest' ||\n    actionType === 'ReviewRequest'\n  ) {\n    return 'agent';\n  }\n\n  if (actionType === 'ScriptRequest') {\n    return 'script';\n  }\n\n  return 'unknown';\n}\n\nexport function deriveConversationSemanticTimeline(\n  source: ConversationTimelineSource\n): ConversationSemanticTimeline {\n  const liveExecutionProcessesById = new Map(\n    source.liveExecutionProcesses.map((process) => [process.id, process])\n  );\n\n  const processes = Object.values(source.executionProcessState)\n    .sort(\n      (a, b) =>\n        new Date(a.executionProcess.created_at as unknown as string).getTime() -\n        new Date(b.executionProcess.created_at as unknown as string).getTime()\n    )\n    .map((processState) => {\n      const executionProcessId = processState.executionProcess.id;\n      const liveExecutionProcess =\n        liveExecutionProcessesById.get(executionProcessId) ?? null;\n      const latestTokenUsageEntry =\n        processState.entries.findLast(\n          (entry) =>\n            entry.type === 'NORMALIZED_ENTRY' &&\n            entry.content.entry_type.type === 'token_usage_info'\n        ) ?? null;\n\n      const visibleEntries = processState.entries.filter(\n        (entry) =>\n          entry.type !== 'NORMALIZED_ENTRY' ||\n          (entry.content.entry_type.type !== 'user_message' &&\n            entry.content.entry_type.type !== 'token_usage_info')\n      );\n\n      const hasPendingApprovalEntry = visibleEntries.some((entry) => {\n        if (entry.type !== 'NORMALIZED_ENTRY') return false;\n        const entryType = entry.content.entry_type;\n        return (\n          entryType.type === 'tool_use' &&\n          entryType.status.status === 'pending_approval'\n        );\n      });\n\n      return {\n        executionProcessId,\n        executionProcess: processState.executionProcess,\n        kind: toConversationSemanticProcessKind(processState.executionProcess),\n        liveExecutionProcess,\n        rawEntries: processState.entries,\n        visibleEntries,\n        latestTokenUsageEntry,\n        hasPendingApprovalEntry,\n        isRunning:\n          liveExecutionProcess?.status === ExecutionProcessStatus.running,\n        failedOrKilled:\n          liveExecutionProcess?.status === ExecutionProcessStatus.failed ||\n          liveExecutionProcess?.status === ExecutionProcessStatus.killed,\n      } satisfies ConversationSemanticProcessItem;\n    });\n\n  return {\n    processes,\n    hasSetupScriptProcess: processes.some(\n      (process) =>\n        process.executionProcess.executor_action.typ.type === 'ScriptRequest' &&\n        process.executionProcess.executor_action.typ.context === 'SetupScript'\n    ),\n    hasSetupScriptWithPrompt: processes.some(\n      (process) =>\n        process.executionProcess.executor_action.typ.type === 'ScriptRequest' &&\n        process.executionProcess.executor_action.typ.context ===\n          'SetupScript' &&\n        extractPromptFromActionChain(\n          process.executionProcess.executor_action\n        ) !== null\n    ),\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/deriveConversationTimeline.ts",
    "content": "import { aggregateConsecutiveEntries } from '@/shared/lib/aggregateEntries';\nimport type {\n  DisplayEntry,\n  PatchTypeWithKey,\n} from '@/shared/hooks/useConversationHistory/types';\n\nimport {\n  buildConversationRowsIncremental,\n  type ConversationRow,\n} from './conversation-row-model';\n\nexport interface DerivedConversationTimeline {\n  readonly displayEntries: DisplayEntry[];\n  readonly rows: ConversationRow[];\n}\n\nfunction isRenderableConversationEntry(entry: DisplayEntry): boolean {\n  if (\n    entry.type === 'NORMALIZED_ENTRY' &&\n    typeof entry.content !== 'string' &&\n    'entry_type' in entry.content\n  ) {\n    const entryType = entry.content.entry_type.type;\n    return entryType !== 'next_action' && entryType !== 'token_usage_info';\n  }\n\n  return (\n    entry.type === 'NORMALIZED_ENTRY' ||\n    entry.type === 'STDOUT' ||\n    entry.type === 'STDERR' ||\n    entry.type === 'AGGREGATED_GROUP' ||\n    entry.type === 'AGGREGATED_DIFF_GROUP' ||\n    entry.type === 'AGGREGATED_THINKING_GROUP'\n  );\n}\n\n// Final UI-facing timeline step: aggregate display entries and build stable rows\n// for virtualization, navigation, and scroll orchestration.\n\nexport function deriveConversationTimeline(\n  entries: PatchTypeWithKey[],\n  previousDisplayEntries: DisplayEntry[],\n  previousRows: ConversationRow[]\n): DerivedConversationTimeline {\n  const displayEntries = aggregateConsecutiveEntries(entries).filter(\n    isRenderableConversationEntry\n  );\n\n  const rows = buildConversationRowsIncremental(\n    displayEntries,\n    previousDisplayEntries,\n    previousRows\n  );\n\n  return {\n    displayEntries,\n    rows,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/deriveConversationTurns.ts",
    "content": "import {\n  type CommandExitStatus,\n  type ExecutorAction,\n  type TokenUsageInfo,\n  type ToolStatus,\n} from 'shared/types';\n\nimport type { ConversationSemanticProcessItem } from './deriveConversationSemanticTimeline';\nimport { deriveConversationSemanticTimeline } from './deriveConversationSemanticTimeline';\nimport type { ConversationTimelineSource } from '@/shared/hooks/useConversationHistory/types';\n\ntype ScriptTurnKind =\n  | 'setup_script'\n  | 'cleanup_script'\n  | 'archive_script'\n  | 'tool_install_script';\n\nexport interface ConversationAgentTurn {\n  readonly key: string;\n  readonly kind:\n    | 'agent_idle'\n    | 'agent_running'\n    | 'agent_pending_approval'\n    | 'agent_failed';\n  readonly process: ConversationSemanticProcessItem;\n  readonly prompt: string | null;\n  readonly shouldEmitUserMessage: boolean;\n  readonly visibleEntries: ConversationSemanticProcessItem['visibleEntries'];\n  readonly latestTokenUsageInfo: TokenUsageInfo | null;\n  readonly shouldEmitLoading: boolean;\n  readonly failedOrKilled: boolean;\n  readonly needsSetup: boolean;\n  readonly setupHelpText?: string;\n}\n\nexport interface ConversationScriptTurnProcess {\n  readonly process: ConversationSemanticProcessItem;\n  readonly toolName: string;\n  readonly exitStatus: CommandExitStatus | null;\n  readonly toolStatus: ToolStatus;\n  readonly shouldEmitInitialPromptAfterSetup: boolean;\n  readonly initialPromptAfterSetup: string | null;\n}\n\nexport interface ConversationScriptTurn {\n  readonly key: string;\n  readonly kind: ScriptTurnKind;\n  readonly processes: ReadonlyArray<ConversationScriptTurnProcess>;\n}\n\nexport type ConversationTurn = ConversationAgentTurn | ConversationScriptTurn;\n\nexport interface ConversationTurns {\n  readonly turns: ConversationTurn[];\n  readonly hasSetupScriptProcess: boolean;\n  readonly hasSetupScriptWithPrompt: boolean;\n}\n\n// Turns are the first product-shaped model in the pipeline.\n// From this point on, the code reasons about conversation meaning instead of raw process order.\n\nfunction isAgentTurn(turn: ConversationTurn): turn is ConversationAgentTurn {\n  return (\n    turn.kind === 'agent_idle' ||\n    turn.kind === 'agent_running' ||\n    turn.kind === 'agent_pending_approval' ||\n    turn.kind === 'agent_failed'\n  );\n}\n\nfunction getPromptFromActionChain(\n  action: ExecutorAction | null\n): string | null {\n  let current = action;\n  while (current) {\n    const typ = current.typ;\n    if (\n      typ.type === 'CodingAgentInitialRequest' ||\n      typ.type === 'CodingAgentFollowUpRequest' ||\n      typ.type === 'ReviewRequest'\n    ) {\n      return typ.prompt;\n    }\n    current = current.next_action;\n  }\n  return null;\n}\n\nfunction getLatestTokenUsageInfo(\n  process: ConversationSemanticProcessItem\n): TokenUsageInfo | null {\n  if (process.latestTokenUsageEntry?.type !== 'NORMALIZED_ENTRY') {\n    return null;\n  }\n\n  return process.latestTokenUsageEntry.content.entry_type as TokenUsageInfo;\n}\n\nfunction getSetupRequiredHelp(\n  process: ConversationSemanticProcessItem\n): string | undefined {\n  const setupRequiredEntry = process.visibleEntries.find((entry) => {\n    if (entry.type !== 'NORMALIZED_ENTRY') return false;\n    return (\n      entry.content.entry_type.type === 'error_message' &&\n      entry.content.entry_type.error_type.type === 'setup_required'\n    );\n  });\n\n  return setupRequiredEntry?.type === 'NORMALIZED_ENTRY'\n    ? setupRequiredEntry.content.content\n    : undefined;\n}\n\nfunction deriveAgentTurn(\n  process: ConversationSemanticProcessItem,\n  hasSetupScriptWithPrompt: boolean,\n  isLastTurn: boolean\n): ConversationAgentTurn {\n  const executorActionType = process.executionProcess.executor_action.typ;\n  const prompt = getPromptFromActionChain(\n    process.executionProcess.executor_action\n  );\n  const setupHelpText = process.failedOrKilled\n    ? getSetupRequiredHelp(process)\n    : undefined;\n  const needsSetup = Boolean(setupHelpText);\n  const shouldEmitUserMessage = !(\n    executorActionType.type === 'CodingAgentInitialRequest' &&\n    hasSetupScriptWithPrompt\n  );\n\n  if (process.hasPendingApprovalEntry) {\n    return {\n      key: process.executionProcessId,\n      kind: 'agent_pending_approval',\n      process,\n      prompt,\n      shouldEmitUserMessage,\n      visibleEntries: process.visibleEntries,\n      latestTokenUsageInfo: getLatestTokenUsageInfo(process),\n      shouldEmitLoading: false,\n      failedOrKilled: process.failedOrKilled && isLastTurn,\n      needsSetup: isLastTurn ? needsSetup : false,\n      setupHelpText: isLastTurn ? setupHelpText : undefined,\n    };\n  }\n\n  if (process.isRunning) {\n    return {\n      key: process.executionProcessId,\n      kind: 'agent_running',\n      process,\n      prompt,\n      shouldEmitUserMessage,\n      visibleEntries: process.visibleEntries,\n      latestTokenUsageInfo: getLatestTokenUsageInfo(process),\n      shouldEmitLoading: true,\n      failedOrKilled: false,\n      needsSetup: false,\n    };\n  }\n\n  if (process.failedOrKilled && isLastTurn) {\n    return {\n      key: process.executionProcessId,\n      kind: 'agent_failed',\n      process,\n      prompt,\n      shouldEmitUserMessage,\n      visibleEntries: process.visibleEntries,\n      latestTokenUsageInfo: getLatestTokenUsageInfo(process),\n      shouldEmitLoading: false,\n      failedOrKilled: true,\n      needsSetup,\n      setupHelpText,\n    };\n  }\n\n  return {\n    key: process.executionProcessId,\n    kind: 'agent_idle',\n    process,\n    prompt,\n    shouldEmitUserMessage,\n    visibleEntries: process.visibleEntries,\n    latestTokenUsageInfo: getLatestTokenUsageInfo(process),\n    shouldEmitLoading: false,\n    failedOrKilled: false,\n    needsSetup: false,\n  };\n}\n\nfunction toScriptTurnKind(\n  process: ConversationSemanticProcessItem\n): ScriptTurnKind | null {\n  const action = process.executionProcess.executor_action.typ;\n  if (action.type !== 'ScriptRequest') return null;\n\n  switch (action.context) {\n    case 'SetupScript':\n      return 'setup_script';\n    case 'CleanupScript':\n      return 'cleanup_script';\n    case 'ArchiveScript':\n      return 'archive_script';\n    case 'ToolInstallScript':\n      return 'tool_install_script';\n    default:\n      return null;\n  }\n}\n\nfunction toScriptToolName(kind: ScriptTurnKind): string {\n  switch (kind) {\n    case 'setup_script':\n      return 'Setup Script';\n    case 'cleanup_script':\n      return 'Cleanup Script';\n    case 'archive_script':\n      return 'Archive Script';\n    case 'tool_install_script':\n      return 'Tool Install Script';\n  }\n}\n\nfunction deriveScriptTurnProcess(\n  process: ConversationSemanticProcessItem,\n  kind: ScriptTurnKind,\n  isFirstTurn: boolean\n): ConversationScriptTurnProcess {\n  const exitCode = Number(process.liveExecutionProcess?.exit_code) || 0;\n  const exitStatus: CommandExitStatus | null = process.isRunning\n    ? null\n    : {\n        type: 'exit_code',\n        code: exitCode,\n      };\n  const toolStatus: ToolStatus = process.isRunning\n    ? { status: 'created' }\n    : exitCode === 0\n      ? { status: 'success' }\n      : { status: 'failed' };\n\n  const shouldEmitInitialPromptAfterSetup =\n    kind === 'setup_script' && isFirstTurn && !process.isRunning;\n\n  return {\n    process,\n    toolName: toScriptToolName(kind),\n    exitStatus,\n    toolStatus,\n    shouldEmitInitialPromptAfterSetup,\n    initialPromptAfterSetup: shouldEmitInitialPromptAfterSetup\n      ? getPromptFromActionChain(process.executionProcess.executor_action)\n      : null,\n  };\n}\n\nexport function deriveConversationTurns(\n  source: ConversationTimelineSource\n): ConversationTurns {\n  const semanticTimeline = deriveConversationSemanticTimeline(source);\n  const turns: ConversationTurn[] = [];\n  const typedProcesses = semanticTimeline.processes\n    .map((process) => {\n      const scriptKind = toScriptTurnKind(process);\n      return {\n        process,\n        scriptKind,\n      };\n    })\n    .filter(\n      (\n        item\n      ): item is {\n        process: ConversationSemanticProcessItem;\n        scriptKind: ScriptTurnKind | null;\n      } => item.process.kind === 'agent' || item.scriptKind !== null\n    );\n\n  for (const [index, item] of typedProcesses.entries()) {\n    const isLastTurn = index === typedProcesses.length - 1;\n\n    if (item.process.kind === 'agent') {\n      turns.push(\n        deriveAgentTurn(\n          item.process,\n          semanticTimeline.hasSetupScriptWithPrompt,\n          isLastTurn\n        )\n      );\n      continue;\n    }\n\n    const kind = item.scriptKind;\n    if (!kind) continue;\n\n    const previousTurn = turns.at(-1);\n    if (\n      previousTurn &&\n      !isAgentTurn(previousTurn) &&\n      previousTurn.kind === kind\n    ) {\n      turns[turns.length - 1] = {\n        ...previousTurn,\n        processes: [\n          ...previousTurn.processes,\n          deriveScriptTurnProcess(item.process, kind, index === 0),\n        ],\n      };\n      continue;\n    }\n\n    turns.push({\n      key: item.process.executionProcessId,\n      kind,\n      processes: [deriveScriptTurnProcess(item.process, kind, index === 0)],\n    });\n  }\n\n  return {\n    turns,\n    hasSetupScriptProcess: semanticTimeline.hasSetupScriptProcess,\n    hasSetupScriptWithPrompt: semanticTimeline.hasSetupScriptWithPrompt,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useApprovalMutation.ts",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { approvalsApi } from '@/shared/lib/api';\nimport type { QuestionAnswer } from 'shared/types';\n\ninterface ApproveParams {\n  approvalId: string;\n  executionProcessId: string;\n}\n\ninterface DenyParams extends ApproveParams {\n  reason?: string;\n}\n\ninterface AnswerParams extends ApproveParams {\n  answers: QuestionAnswer[];\n}\n\nexport function useApprovalMutation() {\n  const approveMutation = useMutation({\n    mutationFn: ({ approvalId, executionProcessId }: ApproveParams) =>\n      approvalsApi.respond(approvalId, {\n        execution_process_id: executionProcessId,\n        status: { status: 'approved' },\n      }),\n    onError: (err) => {\n      console.error('Failed to approve:', err);\n    },\n  });\n\n  const denyMutation = useMutation({\n    mutationFn: ({ approvalId, executionProcessId, reason }: DenyParams) =>\n      approvalsApi.respond(approvalId, {\n        execution_process_id: executionProcessId,\n        status: {\n          status: 'denied',\n          reason: reason || 'User denied this request.',\n        },\n      }),\n    onError: (err) => {\n      console.error('Failed to deny:', err);\n    },\n  });\n\n  const answerMutation = useMutation({\n    mutationFn: ({ approvalId, executionProcessId, answers }: AnswerParams) =>\n      approvalsApi.respond(approvalId, {\n        execution_process_id: executionProcessId,\n        status: { status: 'answered', answers },\n      }),\n    onError: (err) => {\n      console.error('Failed to answer:', err);\n    },\n  });\n\n  return {\n    approve: approveMutation.mutate,\n    approveAsync: approveMutation.mutateAsync,\n    deny: denyMutation.mutate,\n    denyAsync: denyMutation.mutateAsync,\n    answer: answerMutation.mutate,\n    answerAsync: answerMutation.mutateAsync,\n    isApproving: approveMutation.isPending,\n    isDenying: denyMutation.isPending,\n    isAnswering: answerMutation.isPending,\n    isResponding:\n      approveMutation.isPending ||\n      denyMutation.isPending ||\n      answerMutation.isPending,\n    approveError: approveMutation.error,\n    denyError: denyMutation.error,\n    answerError: answerMutation.error,\n    reset: () => {\n      approveMutation.reset();\n      denyMutation.reset();\n      answerMutation.reset();\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useConversationHistory.ts",
    "content": "import {\n  ExecutionProcess,\n  ExecutionProcessStatus,\n  PatchType,\n} from 'shared/types';\nimport { useExecutionProcessesContext } from '@/shared/hooks/useExecutionProcessesContext';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { streamJsonPatchEntries } from '@/shared/lib/streamJsonPatchEntries';\nimport type {\n  AddEntryType,\n  ConversationTimelineSource,\n  ExecutionProcessStateStore,\n  PatchTypeWithKey,\n  UseConversationHistoryParams,\n} from '@/shared/hooks/useConversationHistory/types';\n\n// Result type for the new UI's conversation history hook\nexport interface UseConversationHistoryResult {\n  /** Whether the conversation only has a single coding agent turn (no follow-ups) */\n  isFirstTurn: boolean;\n  /** Whether background batches are still loading older history entries */\n  isLoadingHistory: boolean;\n}\nimport {\n  MIN_INITIAL_ENTRIES,\n  REMAINING_BATCH_SIZE,\n} from '@/shared/hooks/useConversationHistory/constants';\n\nexport const useConversationHistory = ({\n  onTimelineUpdated,\n  scopeKey,\n}: UseConversationHistoryParams): UseConversationHistoryResult => {\n  const {\n    executionProcessesVisible: executionProcessesRaw,\n    isLoading,\n    isConnected,\n  } = useExecutionProcessesContext();\n  const executionProcesses = useRef<ExecutionProcess[]>(executionProcessesRaw);\n  const displayedExecutionProcesses = useRef<ExecutionProcessStateStore>({});\n  const loadedInitialEntries = useRef(false);\n  const emittedEmptyInitialRef = useRef(false);\n  const streamingProcessIdsRef = useRef<Set<string>>(new Set());\n  const onTimelineUpdatedRef = useRef<\n    UseConversationHistoryParams['onTimelineUpdated'] | null\n  >(null);\n  const previousStatusMapRef = useRef<Map<string, ExecutionProcessStatus>>(\n    new Map()\n  );\n  const [isLoadingHistoryState, setIsLoadingHistory] = useState(false);\n\n  // Derive whether this is the first turn (no follow-up processes exist)\n  const isFirstTurn = useMemo(() => {\n    const codingAgentProcessCount = executionProcessesRaw.filter(\n      (ep) =>\n        ep.executor_action.typ.type === 'CodingAgentInitialRequest' ||\n        ep.executor_action.typ.type === 'CodingAgentFollowUpRequest'\n    ).length;\n    return codingAgentProcessCount <= 1;\n  }, [executionProcessesRaw]);\n\n  const mergeIntoDisplayed = (\n    mutator: (state: ExecutionProcessStateStore) => void\n  ) => {\n    const state = displayedExecutionProcesses.current;\n    mutator(state);\n  };\n\n  // The hook owns transport, loading, and reconciliation.\n  // It emits a source model that later derivation layers can transform further.\n\n  const buildTimelineSource = useCallback(\n    (\n      executionProcessState: ExecutionProcessStateStore\n    ): ConversationTimelineSource => ({\n      executionProcessState,\n      liveExecutionProcesses: executionProcesses.current,\n    }),\n    []\n  );\n\n  useEffect(() => {\n    onTimelineUpdatedRef.current = onTimelineUpdated;\n  }, [onTimelineUpdated]);\n\n  // Keep executionProcesses up to date\n  useEffect(() => {\n    executionProcesses.current = executionProcessesRaw.filter(\n      (ep) =>\n        ep.run_reason === 'setupscript' ||\n        ep.run_reason === 'cleanupscript' ||\n        ep.run_reason === 'archivescript' ||\n        ep.run_reason === 'codingagent'\n    );\n  }, [executionProcessesRaw]);\n\n  const loadEntriesForHistoricExecutionProcess = (\n    executionProcess: ExecutionProcess\n  ) => {\n    let url = '';\n    if (executionProcess.executor_action.typ.type === 'ScriptRequest') {\n      url = `/api/execution-processes/${executionProcess.id}/raw-logs/ws`;\n    } else {\n      url = `/api/execution-processes/${executionProcess.id}/normalized-logs/ws`;\n    }\n\n    return new Promise<PatchType[]>((resolve) => {\n      const controller = streamJsonPatchEntries<PatchType>(url, {\n        onFinished: (allEntries) => {\n          controller.close();\n          resolve(allEntries);\n        },\n        onError: (err) => {\n          console.warn(\n            `Error loading entries for historic execution process ${executionProcess.id}`,\n            err\n          );\n          controller.close();\n          resolve([]);\n        },\n      });\n    });\n  };\n\n  const patchWithKey = (\n    patch: PatchType,\n    executionProcessId: string,\n    index: number\n  ) => {\n    return {\n      ...patch,\n      patchKey: `${executionProcessId}:${index}`,\n      executionProcessId,\n    };\n  };\n\n  const flattenEntries = (\n    executionProcessState: ExecutionProcessStateStore\n  ): PatchTypeWithKey[] => {\n    return Object.values(executionProcessState)\n      .filter(\n        (p) =>\n          p.executionProcess.executor_action.typ.type ===\n            'CodingAgentFollowUpRequest' ||\n          p.executionProcess.executor_action.typ.type ===\n            'CodingAgentInitialRequest' ||\n          p.executionProcess.executor_action.typ.type === 'ReviewRequest'\n      )\n      .sort(\n        (a, b) =>\n          new Date(\n            a.executionProcess.created_at as unknown as string\n          ).getTime() -\n          new Date(b.executionProcess.created_at as unknown as string).getTime()\n      )\n      .flatMap((p) => p.entries);\n  };\n\n  const getActiveAgentProcesses = (): ExecutionProcess[] => {\n    return (\n      executionProcesses?.current.filter(\n        (p) =>\n          p.status === ExecutionProcessStatus.running &&\n          p.run_reason !== 'devserver'\n      ) ?? []\n    );\n  };\n\n  const emitEntries = useCallback(\n    (\n      executionProcessState: ExecutionProcessStateStore,\n      addEntryType: AddEntryType,\n      loading: boolean\n    ) => {\n      const timelineSource = buildTimelineSource(executionProcessState);\n      let modifiedAddEntryType = addEntryType;\n\n      const latestEntry = Object.values(executionProcessState)\n        .sort(\n          (a, b) =>\n            new Date(\n              a.executionProcess.created_at as unknown as string\n            ).getTime() -\n            new Date(\n              b.executionProcess.created_at as unknown as string\n            ).getTime()\n        )\n        .flatMap((processState) => processState.entries)\n        .at(-1);\n\n      if (\n        latestEntry?.type === 'NORMALIZED_ENTRY' &&\n        latestEntry.content.entry_type.type === 'tool_use' &&\n        latestEntry.content.entry_type.tool_name === 'ExitPlanMode'\n      ) {\n        modifiedAddEntryType = 'plan';\n      }\n\n      onTimelineUpdatedRef.current?.(\n        timelineSource,\n        modifiedAddEntryType,\n        loading\n      );\n    },\n    [buildTimelineSource]\n  );\n\n  // This emits its own events as they are streamed\n  const loadRunningAndEmit = useCallback(\n    (executionProcess: ExecutionProcess): Promise<void> => {\n      return new Promise((resolve, reject) => {\n        let url = '';\n        if (executionProcess.executor_action.typ.type === 'ScriptRequest') {\n          url = `/api/execution-processes/${executionProcess.id}/raw-logs/ws`;\n        } else {\n          url = `/api/execution-processes/${executionProcess.id}/normalized-logs/ws`;\n        }\n        const controller = streamJsonPatchEntries<PatchType>(url, {\n          onEntries(entries) {\n            const patchesWithKey = entries.map((entry, index) =>\n              patchWithKey(entry, executionProcess.id, index)\n            );\n            mergeIntoDisplayed((state) => {\n              state[executionProcess.id] = {\n                executionProcess,\n                entries: patchesWithKey,\n              };\n            });\n            emitEntries(displayedExecutionProcesses.current, 'running', false);\n          },\n          onFinished: () => {\n            emitEntries(displayedExecutionProcesses.current, 'running', false);\n            controller.close();\n            resolve();\n          },\n          onError: () => {\n            controller.close();\n            reject();\n          },\n        });\n      });\n    },\n    [emitEntries]\n  );\n\n  // Sometimes it can take a few seconds for the stream to start, wrap the loadRunningAndEmit method\n  const loadRunningAndEmitWithBackoff = useCallback(\n    async (executionProcess: ExecutionProcess) => {\n      for (let i = 0; i < 20; i++) {\n        try {\n          await loadRunningAndEmit(executionProcess);\n          break;\n        } catch (_) {\n          await new Promise((resolve) => setTimeout(resolve, 500));\n        }\n      }\n    },\n    [loadRunningAndEmit]\n  );\n\n  const loadHistoricEntries = useCallback(\n    async (maxEntries?: number): Promise<ExecutionProcessStateStore> => {\n      const localDisplayedExecutionProcesses: ExecutionProcessStateStore = {};\n\n      if (!executionProcesses?.current) return localDisplayedExecutionProcesses;\n\n      for (const executionProcess of [\n        ...executionProcesses.current,\n      ].reverse()) {\n        if (executionProcess.status === ExecutionProcessStatus.running)\n          continue;\n\n        const entries =\n          await loadEntriesForHistoricExecutionProcess(executionProcess);\n        const entriesWithKey = entries.map((e, idx) =>\n          patchWithKey(e, executionProcess.id, idx)\n        );\n\n        localDisplayedExecutionProcesses[executionProcess.id] = {\n          executionProcess,\n          entries: entriesWithKey,\n        };\n\n        if (\n          maxEntries != null &&\n          flattenEntries(localDisplayedExecutionProcesses).length > maxEntries\n        ) {\n          break;\n        }\n      }\n\n      return localDisplayedExecutionProcesses;\n    },\n    [executionProcesses]\n  );\n\n  const loadRemainingEntriesInBatches = useCallback(\n    async (batchSize: number): Promise<boolean> => {\n      if (!executionProcesses?.current) return false;\n\n      let anyUpdated = false;\n      for (const executionProcess of [\n        ...executionProcesses.current,\n      ].reverse()) {\n        const current = displayedExecutionProcesses.current;\n        if (\n          current[executionProcess.id] ||\n          executionProcess.status === ExecutionProcessStatus.running\n        )\n          continue;\n\n        const entries =\n          await loadEntriesForHistoricExecutionProcess(executionProcess);\n        const entriesWithKey = entries.map((e, idx) =>\n          patchWithKey(e, executionProcess.id, idx)\n        );\n\n        mergeIntoDisplayed((state) => {\n          state[executionProcess.id] = {\n            executionProcess,\n            entries: entriesWithKey,\n          };\n        });\n\n        if (\n          flattenEntries(displayedExecutionProcesses.current).length > batchSize\n        ) {\n          anyUpdated = true;\n          break;\n        }\n        anyUpdated = true;\n      }\n      return anyUpdated;\n    },\n    [executionProcesses]\n  );\n\n  const ensureProcessVisible = useCallback((p: ExecutionProcess) => {\n    mergeIntoDisplayed((state) => {\n      if (!state[p.id]) {\n        state[p.id] = {\n          executionProcess: {\n            id: p.id,\n            created_at: p.created_at,\n            updated_at: p.updated_at,\n            executor_action: p.executor_action,\n          },\n          entries: [],\n        };\n      }\n    });\n  }, []);\n\n  const idListKey = useMemo(\n    () => executionProcessesRaw?.map((p) => p.id).join(','),\n    [executionProcessesRaw]\n  );\n\n  const idStatusKey = useMemo(\n    () => executionProcessesRaw?.map((p) => `${p.id}:${p.status}`).join(','),\n    [executionProcessesRaw]\n  );\n\n  // Clean up entries for processes that have been removed (e.g., after reset)\n  useEffect(() => {\n    if (isLoading || !isConnected) return;\n    const visibleProcessIds = new Set(executionProcessesRaw.map((p) => p.id));\n    const displayedIds = Object.keys(displayedExecutionProcesses.current);\n    let changed = false;\n\n    for (const id of displayedIds) {\n      if (!visibleProcessIds.has(id)) {\n        delete displayedExecutionProcesses.current[id];\n        changed = true;\n      }\n    }\n\n    if (changed) {\n      emitEntries(displayedExecutionProcesses.current, 'historic', false);\n    }\n  }, [idListKey, executionProcessesRaw, emitEntries, isLoading, isConnected]);\n\n  useEffect(() => {\n    displayedExecutionProcesses.current = {};\n    loadedInitialEntries.current = false;\n    emittedEmptyInitialRef.current = false;\n    streamingProcessIdsRef.current.clear();\n    previousStatusMapRef.current.clear();\n    emitEntries(displayedExecutionProcesses.current, 'initial', true);\n  }, [scopeKey, emitEntries]);\n\n  useEffect(() => {\n    let cancelled = false;\n    (async () => {\n      if (loadedInitialEntries.current) return;\n\n      if (isLoading) return;\n\n      if (executionProcesses.current.length === 0) {\n        if (emittedEmptyInitialRef.current) return;\n        emittedEmptyInitialRef.current = true;\n        loadedInitialEntries.current = true;\n        emitEntries(displayedExecutionProcesses.current, 'initial', false);\n        return;\n      }\n\n      emittedEmptyInitialRef.current = false;\n      loadedInitialEntries.current = true;\n\n      const allInitialEntries = await loadHistoricEntries(MIN_INITIAL_ENTRIES);\n      if (cancelled) return;\n      mergeIntoDisplayed((state) => {\n        Object.assign(state, allInitialEntries);\n      });\n      emitEntries(displayedExecutionProcesses.current, 'initial', false);\n\n      setIsLoadingHistory(true);\n      while (\n        !cancelled &&\n        (await loadRemainingEntriesInBatches(REMAINING_BATCH_SIZE))\n      ) {\n        if (cancelled) return;\n        emitEntries(displayedExecutionProcesses.current, 'historic', false);\n      }\n      if (!cancelled) setIsLoadingHistory(false);\n    })();\n    return () => {\n      cancelled = true;\n    };\n  }, [\n    scopeKey,\n    idListKey,\n    isLoading,\n    loadHistoricEntries,\n    loadRemainingEntriesInBatches,\n    emitEntries,\n  ]); // include idListKey so new processes trigger reload\n\n  useEffect(() => {\n    const activeProcesses = getActiveAgentProcesses();\n    if (activeProcesses.length === 0) return;\n\n    for (const activeProcess of activeProcesses) {\n      if (!displayedExecutionProcesses.current[activeProcess.id]) {\n        const runningOrInitial =\n          Object.keys(displayedExecutionProcesses.current).length > 1\n            ? 'running'\n            : 'initial';\n        ensureProcessVisible(activeProcess);\n        emitEntries(\n          displayedExecutionProcesses.current,\n          runningOrInitial,\n          false\n        );\n      }\n\n      if (\n        activeProcess.status === ExecutionProcessStatus.running &&\n        !streamingProcessIdsRef.current.has(activeProcess.id)\n      ) {\n        streamingProcessIdsRef.current.add(activeProcess.id);\n        loadRunningAndEmitWithBackoff(activeProcess).finally(() => {\n          streamingProcessIdsRef.current.delete(activeProcess.id);\n        });\n      }\n    }\n  }, [\n    scopeKey,\n    idStatusKey,\n    emitEntries,\n    ensureProcessVisible,\n    loadRunningAndEmitWithBackoff,\n  ]);\n\n  useEffect(() => {\n    if (!executionProcessesRaw) return;\n\n    const processesToReload: ExecutionProcess[] = [];\n\n    for (const process of executionProcessesRaw) {\n      const previousStatus = previousStatusMapRef.current.get(process.id);\n      const currentStatus = process.status;\n\n      if (\n        previousStatus === ExecutionProcessStatus.running &&\n        currentStatus !== ExecutionProcessStatus.running &&\n        displayedExecutionProcesses.current[process.id]\n      ) {\n        processesToReload.push(process);\n      }\n\n      previousStatusMapRef.current.set(process.id, currentStatus);\n    }\n\n    if (processesToReload.length === 0) return;\n\n    (async () => {\n      let anyUpdated = false;\n\n      for (const process of processesToReload) {\n        const entries = await loadEntriesForHistoricExecutionProcess(process);\n        if (entries.length === 0) continue;\n\n        const entriesWithKey = entries.map((e, idx) =>\n          patchWithKey(e, process.id, idx)\n        );\n\n        mergeIntoDisplayed((state) => {\n          state[process.id] = {\n            executionProcess: process,\n            entries: entriesWithKey,\n          };\n        });\n        anyUpdated = true;\n      }\n\n      if (anyUpdated) {\n        emitEntries(displayedExecutionProcesses.current, 'running', false);\n      }\n    })();\n  }, [idStatusKey, executionProcessesRaw, emitEntries]);\n\n  // If an execution process is removed, remove it from the state\n  useEffect(() => {\n    if (!executionProcessesRaw) return;\n\n    const removedProcessIds = Object.keys(\n      displayedExecutionProcesses.current\n    ).filter((id) => !executionProcessesRaw.some((p) => p.id === id));\n\n    if (removedProcessIds.length > 0) {\n      mergeIntoDisplayed((state) => {\n        removedProcessIds.forEach((id) => {\n          delete state[id];\n        });\n      });\n    }\n  }, [scopeKey, idListKey, executionProcessesRaw]);\n\n  return { isFirstTurn, isLoadingHistory: isLoadingHistoryState };\n};\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useCreateSession.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { sessionsApi } from '@/shared/lib/api';\nimport type {\n  Session,\n  CreateFollowUpAttempt,\n  ExecutorConfig,\n} from 'shared/types';\n\ninterface CreateSessionParams {\n  workspaceId: string;\n  prompt: string;\n  executorConfig: ExecutorConfig;\n}\n\n/**\n * Hook for creating a new session and sending the first message.\n * Uses TanStack Query mutation for proper cache management.\n */\nexport function useCreateSession() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: async ({\n      workspaceId,\n      prompt,\n      executorConfig,\n    }: CreateSessionParams): Promise<Session> => {\n      const session = await sessionsApi.create({\n        workspace_id: workspaceId,\n      });\n\n      const body: CreateFollowUpAttempt = {\n        prompt,\n        executor_config: executorConfig,\n        retry_process_id: null,\n        force_when_dirty: null,\n        perform_git_reset: null,\n      };\n      await sessionsApi.followUp(session.id, body);\n\n      return session;\n    },\n    onSuccess: (session) => {\n      // Invalidate session queries to refresh the list\n      queryClient.invalidateQueries({\n        queryKey: ['workspaceSessions', session.workspace_id],\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { sessionsApi } from '@/shared/lib/api';\nimport {\n  RestoreLogsDialog,\n  type RestoreLogsDialogResult,\n} from '@/shared/dialogs/tasks/RestoreLogsDialog';\nimport type {\n  RepoBranchStatus,\n  ExecutionProcess,\n  ExecutorConfig,\n} from 'shared/types';\n\nexport interface MessageEditRetryParams {\n  message: string;\n  executorConfig: ExecutorConfig;\n  executionProcessId: string;\n  branchStatus: RepoBranchStatus[] | undefined;\n  processes: ExecutionProcess[] | undefined;\n}\n\nclass EditDialogCancelledError extends Error {\n  constructor() {\n    super('Edit dialog was cancelled');\n    this.name = 'EditDialogCancelledError';\n  }\n}\n\nexport function useMessageEditRetry(\n  sessionId: string,\n  onSuccess?: () => void,\n  onError?: (err: unknown) => void\n) {\n  return useMutation({\n    mutationFn: async ({\n      message,\n      executorConfig,\n      executionProcessId,\n      branchStatus,\n      processes,\n    }: MessageEditRetryParams) => {\n      // Ask user for confirmation - dialog fetches its own preflight data\n      let modalResult: RestoreLogsDialogResult | undefined;\n      try {\n        modalResult = await RestoreLogsDialog.show({\n          executionProcessId,\n          branchStatus,\n          processes,\n        });\n      } catch {\n        throw new EditDialogCancelledError();\n      }\n      if (!modalResult || modalResult.action !== 'confirmed') {\n        throw new EditDialogCancelledError();\n      }\n\n      // Send the retry request with the edited message\n      await sessionsApi.followUp(sessionId, {\n        prompt: message,\n        executor_config: executorConfig,\n        retry_process_id: executionProcessId,\n        force_when_dirty: modalResult.forceWhenDirty ?? false,\n        perform_git_reset: modalResult.performGitReset ?? true,\n      });\n    },\n    onSuccess: () => {\n      onSuccess?.();\n    },\n    onError: (err) => {\n      // Don't report cancellation as an error\n      if (err instanceof EditDialogCancelledError) {\n        return;\n      }\n      console.error('Failed to send edited message:', err);\n      onError?.(err);\n    },\n  });\n}\n\nexport { EditDialogCancelledError };\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useResetProcess.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useExecutionProcessesContext } from '@/shared/hooks/useExecutionProcessesContext';\nimport { useBranchStatus } from '@/shared/hooks/useBranchStatus';\nimport { isCodingAgent } from '@/shared/constants/processes';\nimport { useResetProcessMutation } from './useResetProcessMutation';\n\nexport interface UseResetProcessResult {\n  resetProcess: (executionProcessId: string) => void;\n  canResetProcess: (executionProcessId: string) => boolean;\n  isResetPending: boolean;\n}\n\n/**\n * @param workspaceId - passed explicitly to avoid subscribing to WorkspaceContext\n * @param selectedSessionId - passed explicitly to avoid subscribing to WorkspaceContext\n */\nexport function useResetProcess(\n  workspaceId: string | undefined,\n  selectedSessionId: string | undefined\n): UseResetProcessResult {\n  const { data: branchStatus } = useBranchStatus(workspaceId);\n  const { executionProcessesAll: processes } = useExecutionProcessesContext();\n\n  const resetMutation = useResetProcessMutation(selectedSessionId ?? '');\n  const isResetPending = resetMutation.isPending;\n\n  const hasCodingProcess = useMemo(\n    () =>\n      processes.some(\n        (process) => !process.dropped && isCodingAgent(process.run_reason)\n      ),\n    [processes]\n  );\n\n  const canResetProcess = useCallback(\n    (executionProcessId: string) => hasCodingProcess && !!executionProcessId,\n    [hasCodingProcess]\n  );\n\n  const resetProcess = useCallback(\n    (executionProcessId: string) => {\n      if (!selectedSessionId) return;\n      resetMutation.mutate({\n        executionProcessId,\n        branchStatus,\n        processes,\n      });\n    },\n    [branchStatus, processes, resetMutation, selectedSessionId]\n  );\n\n  return useMemo(\n    () => ({\n      resetProcess,\n      canResetProcess,\n      isResetPending,\n    }),\n    [resetProcess, canResetProcess, isResetPending]\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useResetProcessMutation.ts",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { sessionsApi } from '@/shared/lib/api';\nimport {\n  RestoreLogsDialog,\n  type RestoreLogsDialogResult,\n} from '@/shared/dialogs/tasks/RestoreLogsDialog';\nimport type { RepoBranchStatus, ExecutionProcess } from 'shared/types';\n\nexport interface ResetProcessParams {\n  executionProcessId: string;\n  branchStatus: RepoBranchStatus[] | undefined;\n  processes: ExecutionProcess[] | undefined;\n}\n\nclass ResetDialogCancelledError extends Error {\n  constructor() {\n    super('Reset dialog was cancelled');\n    this.name = 'ResetDialogCancelledError';\n  }\n}\n\nexport function useResetProcessMutation(\n  sessionId: string,\n  onSuccess?: () => void,\n  onError?: (err: unknown) => void\n) {\n  return useMutation({\n    mutationKey: ['reset-process', sessionId],\n    mutationFn: async ({\n      executionProcessId,\n      branchStatus,\n      processes,\n    }: ResetProcessParams) => {\n      let modalResult: RestoreLogsDialogResult | undefined;\n      try {\n        modalResult = await RestoreLogsDialog.show({\n          executionProcessId,\n          branchStatus,\n          processes,\n          mode: 'reset',\n        });\n      } catch {\n        throw new ResetDialogCancelledError();\n      }\n      if (!modalResult || modalResult.action !== 'confirmed') {\n        throw new ResetDialogCancelledError();\n      }\n\n      await sessionsApi.reset(sessionId, {\n        process_id: executionProcessId,\n        force_when_dirty: modalResult.forceWhenDirty ?? false,\n        perform_git_reset: modalResult.performGitReset ?? true,\n      });\n    },\n    onSuccess: () => {\n      onSuccess?.();\n    },\n    onError: (err) => {\n      if (err instanceof ResetDialogCancelledError) {\n        return;\n      }\n      console.error('Failed to reset process:', err);\n      onError?.(err);\n    },\n  });\n}\n\nexport { ResetDialogCancelledError };\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useSessionAttachments.ts",
    "content": "import { useCallback, useState } from 'react';\nimport { attachmentsApi } from '@/shared/lib/api';\nimport type { LocalAttachmentMetadata } from '@vibe/ui/components/WorkspaceContext';\nimport {\n  buildWorkspaceAttachmentMarkdown,\n  toLocalAttachmentMetadata,\n} from '@/shared/lib/workspaceAttachments';\nimport type { AttachmentResponse } from 'shared/types';\n\n/**\n * Hook for handling attachments in session follow-up messages.\n * Uploads attachments to the workspace and calls back with markdown to insert.\n * Also tracks uploaded attachments for immediate preview in the editor.\n */\nexport function useSessionAttachments(\n  workspaceId: string | undefined,\n  sessionId: string | undefined,\n  onInsertMarkdown: (markdown: string) => void\n) {\n  const [uploadedAttachments, setUploadedAttachments] = useState<\n    AttachmentResponse[]\n  >([]);\n\n  const uploadFiles = useCallback(\n    async (files: File[]) => {\n      if (!workspaceId || !sessionId) return;\n\n      const uploadResults: AttachmentResponse[] = [];\n\n      for (const file of files) {\n        try {\n          const response = await attachmentsApi.uploadForAttempt(\n            workspaceId,\n            sessionId,\n            file\n          );\n          uploadResults.push(response);\n        } catch (error) {\n          console.error('Failed to upload attachment:', error);\n        }\n      }\n\n      if (uploadResults.length > 0) {\n        setUploadedAttachments((prev) => [...prev, ...uploadResults]);\n        const allMarkdown = uploadResults\n          .map(buildWorkspaceAttachmentMarkdown)\n          .join('\\n\\n');\n        onInsertMarkdown(allMarkdown);\n      }\n    },\n    [workspaceId, sessionId, onInsertMarkdown]\n  );\n\n  const clearUploadedAttachments = useCallback(() => {\n    setUploadedAttachments([]);\n  }, []);\n\n  const localAttachments: LocalAttachmentMetadata[] = uploadedAttachments.map(\n    toLocalAttachmentMetadata\n  );\n\n  return { uploadFiles, localAttachments, clearUploadedAttachments };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useSessionMessageEditor.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport {\n  ScratchType,\n  type DraftFollowUpData,\n  type ExecutorConfig,\n} from 'shared/types';\nimport { useScratch } from '@/shared/hooks/useScratch';\nimport { useDebouncedCallback } from '@/shared/hooks/useDebouncedCallback';\n\ninterface UseSessionMessageEditorOptions {\n  /** Scratch ID (workspaceId for new session, sessionId for existing) */\n  scratchId: string | undefined;\n}\n\ninterface UseSessionMessageEditorResult {\n  /** Current message value */\n  localMessage: string;\n  /** Set local message directly */\n  setLocalMessage: (value: string) => void;\n  /** Scratch data (message and variant) */\n  scratchData: DraftFollowUpData | undefined;\n  /** Whether scratch is loading */\n  isScratchLoading: boolean;\n  /** Whether the initial value has been applied from scratch */\n  hasInitialValue: boolean;\n  /** Save message and executor config to scratch */\n  saveToScratch: (\n    message: string,\n    executorConfig: ExecutorConfig\n  ) => Promise<void>;\n  /** Delete the draft scratch */\n  clearDraft: () => Promise<void>;\n  /** Cancel pending debounced save */\n  cancelDebouncedSave: () => void;\n  /** Handle message change with debounced save */\n  handleMessageChange: (value: string, executorConfig: ExecutorConfig) => void;\n}\n\n/**\n * Hook to manage message editing with draft persistence.\n * Handles local state, debounced saves to scratch, and sync on load.\n */\nexport function useSessionMessageEditor({\n  scratchId,\n}: UseSessionMessageEditorOptions): UseSessionMessageEditorResult {\n  const {\n    scratch,\n    updateScratch,\n    deleteScratch,\n    isLoading: isScratchLoading,\n  } = useScratch(ScratchType.DRAFT_FOLLOW_UP, scratchId ?? '');\n\n  const scratchData: DraftFollowUpData | undefined =\n    scratch?.payload?.type === 'DRAFT_FOLLOW_UP'\n      ? scratch.payload.data\n      : undefined;\n\n  const [localMessage, setLocalMessage] = useState('');\n  const [hasInitialValue, setHasInitialValue] = useState(false);\n\n  const saveToScratch = useCallback(\n    async (message: string, executorConfig: ExecutorConfig) => {\n      if (!scratchId) return;\n      try {\n        await updateScratch({\n          payload: {\n            type: 'DRAFT_FOLLOW_UP',\n            data: {\n              message,\n              executor_config: executorConfig,\n            },\n          },\n        });\n      } catch (e) {\n        console.error('Failed to save follow-up draft', e);\n      }\n    },\n    [scratchId, updateScratch]\n  );\n\n  const { debounced: debouncedSave, cancel: cancelDebouncedSave } =\n    useDebouncedCallback(saveToScratch, 500);\n\n  // Track whether initial load has happened to avoid re-syncing during typing\n  const hasLoadedRef = useRef(false);\n\n  // Reset load state and clear message when scratchId changes (e.g., switching to approval mode)\n  useEffect(() => {\n    hasLoadedRef.current = false;\n    setHasInitialValue(false);\n    setLocalMessage('');\n  }, [scratchId]);\n\n  // Sync local message from scratch only on initial load\n  useEffect(() => {\n    if (isScratchLoading) return;\n    if (hasLoadedRef.current) return;\n    hasLoadedRef.current = true;\n    setLocalMessage(scratchData?.message ?? '');\n    setHasInitialValue(true);\n  }, [isScratchLoading, scratchData?.message]);\n\n  // Handle message change with debounced save\n  // Pass executor profile at call-time to avoid stale closure\n  const handleMessageChange = useCallback(\n    (value: string, executorConfig: ExecutorConfig) => {\n      setLocalMessage(value);\n      debouncedSave(value, executorConfig);\n    },\n    [debouncedSave]\n  );\n\n  return {\n    localMessage,\n    setLocalMessage,\n    scratchData,\n    isScratchLoading,\n    hasInitialValue,\n    saveToScratch,\n    clearDraft: deleteScratch,\n    cancelDebouncedSave,\n    handleMessageChange,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts",
    "content": "import { useCallback } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { queueApi } from '@/shared/lib/api';\nimport type { ExecutorConfig, QueueStatus } from 'shared/types';\n\ninterface UseSessionQueueInteractionOptions {\n  /** Session ID for queue operations */\n  sessionId: string | undefined;\n}\n\ninterface UseSessionQueueInteractionResult {\n  /** Whether a message is currently queued */\n  isQueued: boolean;\n  /** The queued message content, if any */\n  queuedMessage: string | null;\n  /** The executor config from the queued message, if any */\n  queuedConfig: ExecutorConfig | null;\n  /** Whether a queue operation is in progress */\n  isQueueLoading: boolean;\n  /** Queue a message for later execution */\n  queueMessage: (\n    message: string,\n    executorConfig: ExecutorConfig\n  ) => Promise<void>;\n  /** Cancel the queued message */\n  cancelQueue: () => Promise<void>;\n  /** Refresh queue status from server */\n  refreshQueueStatus: () => Promise<void>;\n}\n\nconst QUEUE_STATUS_KEY = 'queue-status';\n\n/**\n * Hook to manage queue interaction for session messages.\n * Uses TanStack Query for caching and mutation handling.\n */\nexport function useSessionQueueInteraction({\n  sessionId,\n}: UseSessionQueueInteractionOptions): UseSessionQueueInteractionResult {\n  const queryClient = useQueryClient();\n\n  // Query for queue status\n  const { data: queueStatus = { status: 'empty' as const }, refetch } =\n    useQuery<QueueStatus>({\n      queryKey: [QUEUE_STATUS_KEY, sessionId],\n      queryFn: () => queueApi.getStatus(sessionId!),\n      enabled: !!sessionId,\n    });\n\n  const isQueued = queueStatus.status === 'queued';\n  const queuedMessageData = isQueued\n    ? (queueStatus as Extract<QueueStatus, { status: 'queued' }>).message\n    : null;\n  const queuedMessage = queuedMessageData?.data.message ?? null;\n  const queuedConfig: ExecutorConfig | null =\n    queuedMessageData?.data.executor_config ?? null;\n\n  // Mutation for queueing a message\n  const queueMutation = useMutation({\n    mutationFn: ({\n      message,\n      executorConfig,\n    }: {\n      message: string;\n      executorConfig: ExecutorConfig;\n    }) =>\n      queueApi.queue(sessionId!, {\n        message,\n        executor_config: executorConfig,\n      }),\n    onSuccess: (status) => {\n      queryClient.setQueryData([QUEUE_STATUS_KEY, sessionId], status);\n    },\n  });\n\n  // Mutation for cancelling the queue\n  const cancelMutation = useMutation({\n    mutationFn: () => queueApi.cancel(sessionId!),\n    onSuccess: (status) => {\n      queryClient.setQueryData([QUEUE_STATUS_KEY, sessionId], status);\n    },\n  });\n\n  const queueMessage = useCallback(\n    async (message: string, executorConfig: ExecutorConfig) => {\n      if (!sessionId) return;\n      await queueMutation.mutateAsync({\n        message,\n        executorConfig,\n      });\n    },\n    [sessionId, queueMutation]\n  );\n\n  const cancelQueue = useCallback(async () => {\n    if (!sessionId) return;\n    await cancelMutation.mutateAsync();\n  }, [sessionId, cancelMutation]);\n\n  const refreshQueueStatus = useCallback(async () => {\n    if (!sessionId) return;\n    await refetch();\n  }, [sessionId, refetch]);\n\n  return {\n    isQueued,\n    queuedMessage,\n    queuedConfig,\n    isQueueLoading: queueMutation.isPending || cancelMutation.isPending,\n    queueMessage,\n    cancelQueue,\n    refreshQueueStatus,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useSessionSend.ts",
    "content": "import { useCallback, useState } from 'react';\nimport type { ExecutorConfig } from 'shared/types';\nimport { sessionsApi } from '@/shared/lib/api';\nimport { useCreateSession } from './useCreateSession';\n\ninterface UseSessionSendOptions {\n  /** Session ID for existing sessions */\n  sessionId: string | undefined;\n  /** Workspace ID for creating new sessions */\n  workspaceId: string | undefined;\n  /** Whether in new session mode */\n  isNewSessionMode: boolean;\n  /** Callback when session is selected (to exit new session mode) */\n  onSelectSession?: (sessionId: string) => void;\n  /** Unified executor config (executor + variant + overrides) */\n  executorConfig?: ExecutorConfig | null;\n}\n\ninterface UseSessionSendResult {\n  /** Send a message. Returns true on success, false on failure. */\n  send: (message: string) => Promise<boolean>;\n  /** Whether a send operation is in progress */\n  isSending: boolean;\n  /** Error message if send failed */\n  error: string | null;\n  /** Clear the error */\n  clearError: () => void;\n}\n\n/**\n * Hook for sending messages in SessionChatBoxContainer.\n * Handles both new session creation and existing session follow-up.\n *\n * Unlike useFollowUpSend, this hook:\n * - Takes message/variant as parameters to send() (not captured in closure)\n * - Returns boolean for success/failure (caller handles cleanup)\n * - Has no prompt composition (no conflict/review/clicked markdown)\n */\nexport function useSessionSend({\n  sessionId,\n  workspaceId,\n  isNewSessionMode,\n  onSelectSession,\n  executorConfig,\n}: UseSessionSendOptions): UseSessionSendResult {\n  const { mutateAsync: createSession, isPending: isCreatingSession } =\n    useCreateSession();\n  const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const send = useCallback(\n    async (message: string): Promise<boolean> => {\n      const trimmed = message.trim();\n      if (!trimmed) return false;\n      if (!executorConfig) {\n        setError('No executor selected');\n        return false;\n      }\n\n      setError(null);\n\n      if (isNewSessionMode) {\n        // New session flow\n        if (!workspaceId) {\n          setError('No workspace selected');\n          return false;\n        }\n        try {\n          const session = await createSession({\n            workspaceId,\n            prompt: trimmed,\n            executorConfig,\n          });\n          onSelectSession?.(session.id);\n          return true;\n        } catch (e: unknown) {\n          const err = e as { message?: string };\n          setError(\n            `Failed to create session: ${err.message ?? 'Unknown error'}`\n          );\n          return false;\n        }\n      } else {\n        // Existing session flow\n        if (!sessionId) return false;\n        setIsSendingFollowUp(true);\n        try {\n          await sessionsApi.followUp(sessionId, {\n            prompt: trimmed,\n            executor_config: executorConfig,\n            retry_process_id: null,\n            force_when_dirty: null,\n            perform_git_reset: null,\n          });\n          return true;\n        } catch (e: unknown) {\n          const err = e as { message?: string };\n          setError(`Failed to send: ${err.message ?? 'Unknown error'}`);\n          return false;\n        } finally {\n          setIsSendingFollowUp(false);\n        }\n      }\n    },\n    [\n      sessionId,\n      workspaceId,\n      isNewSessionMode,\n      createSession,\n      onSelectSession,\n      executorConfig,\n    ]\n  );\n\n  const clearError = useCallback(() => setError(null), []);\n\n  return {\n    send,\n    isSending: isSendingFollowUp || isCreatingSession,\n    error,\n    clearError,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useTodos.ts",
    "content": "import { useMemo } from 'react';\nimport type { TodoItem, NormalizedEntry } from 'shared/types';\nimport type { PatchTypeWithKey } from '@/shared/hooks/useConversationHistory/types';\n\ninterface UseTodosResult {\n  todos: TodoItem[];\n  inProgressTodo: TodoItem | null;\n  lastUpdated: string | null;\n}\n\n/**\n * Hook that extracts and maintains the latest TODO state from normalized conversation entries.\n * Filters for TodoManagement ActionType entries and returns the most recent todo list,\n * along with the currently in-progress todo item.\n */\nexport const useTodos = (entries: PatchTypeWithKey[]): UseTodosResult => {\n  return useMemo(() => {\n    let latestTodos: TodoItem[] = [];\n    let lastUpdatedTime: string | null = null;\n\n    for (const entry of entries) {\n      if (entry.type === 'NORMALIZED_ENTRY' && entry.content) {\n        const normalizedEntry = entry.content as NormalizedEntry;\n\n        if (\n          normalizedEntry.entry_type?.type === 'tool_use' &&\n          normalizedEntry.entry_type?.action_type?.action === 'todo_management'\n        ) {\n          const actionType = normalizedEntry.entry_type.action_type;\n          const partialTodos = actionType.todos || [];\n          const currentTimestamp =\n            normalizedEntry.timestamp || new Date().toISOString();\n\n          // Only update latestTodos if we have meaningful content OR this is our first entry\n          const hasMeaningfulTodos =\n            partialTodos.length > 0 &&\n            partialTodos.every(\n              (todo: TodoItem) =>\n                todo.content && todo.content.trim().length > 0 && todo.status\n            );\n          const isNewerThanLatest =\n            !lastUpdatedTime || currentTimestamp >= lastUpdatedTime;\n\n          if (\n            hasMeaningfulTodos ||\n            (isNewerThanLatest && latestTodos.length === 0)\n          ) {\n            latestTodos = partialTodos;\n            lastUpdatedTime = currentTimestamp;\n          }\n        }\n      }\n    }\n\n    // Find the currently in-progress todo\n    const inProgressTodo =\n      latestTodos.find((todo) => {\n        const status = todo.status?.toLowerCase();\n        return status === 'in_progress' || status === 'in-progress';\n      }) ?? null;\n\n    return {\n      todos: latestTodos,\n      inProgressTodo,\n      lastUpdated: lastUpdatedTime,\n    };\n  }, [entries]);\n};\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/hooks/useWorkspaceBranch.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\n\nexport function useWorkspaceBranch(workspaceId?: string) {\n  const query = useQuery({\n    queryKey: ['attemptBranch', workspaceId],\n    queryFn: async () => {\n      const attempt = await workspacesApi.get(workspaceId!);\n      return attempt.branch ?? null;\n    },\n    enabled: !!workspaceId,\n  });\n\n  return {\n    branch: query.data ?? null,\n    isLoading: query.isLoading,\n    refetch: query.refetch,\n  } as const;\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/store/useInspectModeStore.ts",
    "content": "import { create } from 'zustand';\n\ninterface InspectModeState {\n  isInspectMode: boolean;\n  setInspectMode: (active: boolean) => void;\n  toggleInspectMode: () => void;\n  pendingComponentMarkdown: string | null;\n  setPendingComponentMarkdown: (markdown: string | null) => void;\n  clearPendingComponentMarkdown: () => void;\n}\n\nexport const useInspectModeStore = create<InspectModeState>((set) => ({\n  isInspectMode: false,\n  setInspectMode: (active) => set({ isInspectMode: active }),\n  toggleInspectMode: () => set((s) => ({ isInspectMode: !s.isInspectMode })),\n  pendingComponentMarkdown: null,\n  setPendingComponentMarkdown: (markdown) =>\n    set({\n      pendingComponentMarkdown: markdown,\n      ...(markdown !== null ? { isInspectMode: false } : {}),\n    }),\n  clearPendingComponentMarkdown: () => set({ pendingComponentMarkdown: null }),\n}));\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/useConversationVirtualizer.ts",
    "content": "/**\n * Conversation Virtualizer Hook\n *\n * Shared TanStack Virtual configuration for the conversation list.\n * Owns the virtualizer instance, measurement, and imperative scroll helpers.\n */\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type RefObject,\n} from 'react';\nimport {\n  useVirtualizer,\n  measureElement as defaultMeasureElement,\n} from '@tanstack/react-virtual';\nimport type { Virtualizer, VirtualItem } from '@tanstack/react-virtual';\n\nimport {\n  type ConversationRow,\n  SIZE_ESTIMATE_PX,\n  estimateSizeForRow,\n  findPreviousUserMessageIndex,\n} from './conversation-row-model';\nimport {\n  NEAR_BOTTOM_THRESHOLD_PX,\n  isNearBottom,\n} from './conversation-scroll-commands';\n\n// TanStack Virtual's ScrollBehavior ('auto' | 'smooth' | 'instant') shadows\n// the DOM ScrollBehavior. Use a narrow type to avoid TS2322 mismatches.\ntype ScrollToOptionsBehavior = 'auto' | 'smooth';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Number of items to render beyond the visible area in each direction. */\nconst OVERSCAN = 8;\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ConversationVirtualizerOptions {\n  /** The semantic row model driving the list (virtualized head only). */\n  rows: ConversationRow[];\n\n  /**\n   * Total number of conversation rows (virtualized + unvirtualized tail).\n   * The bottom-lock correction must fire when ANY row is added — including\n   * unvirtualized tail rows that don't change `rows.length` or `totalSize`.\n   * Without this, streaming entries appended to the tail silently grow the\n   * scroll container while the correction never fires.\n   */\n  totalRowCount: number;\n\n  /** Ref to the scrollable container element. */\n  scrollContainerRef: RefObject<HTMLDivElement | null>;\n\n  /**\n   * Called when the at-bottom state changes. Shells use this to show/hide\n   * the scroll-to-bottom affordance.\n   */\n  onAtBottomChange?: (atBottom: boolean) => void;\n\n  shouldSuppressSizeAdjustment?: () => boolean;\n}\n\nexport interface ConversationVirtualizerResult {\n  /** The TanStack Virtual virtualizer instance. */\n  virtualizer: Virtualizer<HTMLDivElement, Element>;\n\n  /** Virtual items currently in the render window (including overscan). */\n  virtualItems: VirtualItem[];\n\n  /** Total pixel size of all items (for the scroll spacer). */\n  totalSize: number;\n\n  /**\n   * Ref callback for row DOM elements. Attach to each rendered row's\n   * container element alongside `data-index={virtualItem.index}`.\n   * TanStack Virtual uses this to measure real DOM heights and attach\n   * a ResizeObserver for automatic re-measurement on size changes.\n   */\n  measureElement: (node: Element | null) => void;\n\n  /** Scroll to the absolute bottom of the list. */\n  scrollToBottom: (behavior?: ScrollToOptionsBehavior) => void;\n\n  /** Scroll to a specific row index. */\n  scrollToIndex: (\n    index: number,\n    options?: {\n      align?: 'start' | 'center' | 'end';\n      behavior?: ScrollToOptionsBehavior;\n    }\n  ) => void;\n\n  /**\n   * Scroll to the previous user message relative to the first visible item.\n   * Returns true if a target was found and scrolled to, false otherwise.\n   */\n  scrollToPreviousUserMessage: () => boolean;\n\n  /**\n   * Whether the scroll container is currently near the bottom.\n   * Reactive — updates via scroll event listener, not just point-in-time.\n   */\n  isAtBottom: boolean;\n\n  /** Point-in-time check (non-reactive). Reads DOM directly. */\n  checkIsAtBottom: () => boolean;\n\n  /**\n   * Release the bottom-lock. Call when navigating away from the\n   * bottom (e.g., scrollToPreviousUserMessage).\n   */\n  releaseBottomLock: () => void;\n\n  /**\n   * Look up the ConversationRow index for a given virtual item.\n   * Since our virtualizer uses identity mapping (no lane reordering),\n   * this is simply `virtualItem.index`.\n   */\n  rowIndexForVirtualItem: (item: VirtualItem) => number;\n\n  /**\n   * Look up the ConversationRow for a given virtual item.\n   * Returns undefined if the index is out of bounds.\n   */\n  rowForVirtualItem: (item: VirtualItem) => ConversationRow | undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\n/**\n * Configure and return a TanStack Virtual virtualizer for the conversation list.\n *\n * This hook is the single source of virtualizer configuration. It is consumed\n * by `ConversationListContainer` and must not be duplicated across shells.\n */\nexport function useConversationVirtualizer({\n  rows,\n  totalRowCount,\n  scrollContainerRef,\n  onAtBottomChange,\n  shouldSuppressSizeAdjustment,\n}: ConversationVirtualizerOptions): ConversationVirtualizerResult {\n  const bottomLockedRef = useRef(false);\n  const smoothScrollDeadlineRef = useRef(0);\n\n  const isBottomScrollCorrectionActive = useCallback(\n    () => bottomLockedRef.current,\n    []\n  );\n\n  // -------------------------------------------------------------------------\n  // Virtualizer instance\n  // -------------------------------------------------------------------------\n\n  const virtualizer = useVirtualizer({\n    count: rows.length,\n    getScrollElement: () => scrollContainerRef.current,\n    estimateSize: (index) => {\n      const row = rows[index];\n      if (!row) return SIZE_ESTIMATE_PX.medium;\n      const containerWidth = scrollContainerRef.current?.clientWidth ?? null;\n      return estimateSizeForRow(row, containerWidth);\n    },\n    getItemKey: (index) => {\n      const row = rows[index];\n      return row ? row.semanticKey : index;\n    },\n    overscan: OVERSCAN,\n    measureElement: defaultMeasureElement,\n    useAnimationFrameWithResizeObserver: false,\n  });\n\n  // -------------------------------------------------------------------------\n  // shouldAdjustScrollPositionOnItemSizeChange\n  //\n  // Preserve the reader's position only when a row fully above the viewport\n  // changes size. Mid-list flicker happens when we compensate for rows that\n  // are still visible or below the viewport, because those corrections can\n  // move the render window and trigger another measurement pass.\n  // -------------------------------------------------------------------------\n\n  useEffect(() => {\n    virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (\n      item,\n      _delta,\n      instance\n    ) => {\n      const scrollElement = scrollContainerRef.current;\n      const viewportHeight =\n        scrollElement?.clientHeight ?? instance.scrollRect?.height ?? 0;\n      const scrollOffset =\n        scrollElement?.scrollTop ?? instance.scrollOffset ?? 0;\n      const totalScrollableSize =\n        scrollElement?.scrollHeight ?? instance.getTotalSize();\n      const remainingDistance =\n        totalScrollableSize - (scrollOffset + viewportHeight);\n      const isItemFullyAboveViewport = item.end <= scrollOffset;\n      const isBottomLocked = bottomLockedRef.current;\n\n      const shouldAdjust =\n        !isBottomLocked &&\n        !shouldSuppressSizeAdjustment?.() &&\n        isItemFullyAboveViewport &&\n        remainingDistance > NEAR_BOTTOM_THRESHOLD_PX;\n\n      return shouldAdjust;\n    };\n\n    return () => {\n      virtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;\n    };\n  }, [shouldSuppressSizeAdjustment, virtualizer]);\n\n  // -------------------------------------------------------------------------\n  // Container resize invalidation\n  //\n  // Width change → text wrapping changes → all row heights stale.\n  // virtualizer.measure() invalidates cached sizes so rows re-measure.\n  // -------------------------------------------------------------------------\n\n  useEffect(() => {\n    const el = scrollContainerRef.current;\n    if (!el) return;\n\n    let lastWidth = el.clientWidth;\n\n    const ro = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const newWidth = Math.round(\n          entry.contentBoxSize?.[0]?.inlineSize ?? el.clientWidth\n        );\n        if (newWidth !== lastWidth) {\n          lastWidth = newWidth;\n          virtualizer.measure();\n        }\n      }\n    });\n\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [scrollContainerRef, virtualizer]);\n\n  // -------------------------------------------------------------------------\n  // Reactive isAtBottom state\n  // -------------------------------------------------------------------------\n\n  const [isAtBottomState, setIsAtBottomState] = useState(true);\n  const onAtBottomChangeRef = useRef(onAtBottomChange);\n  onAtBottomChangeRef.current = onAtBottomChange;\n  const lastAtBottomRef = useRef(true);\n\n  const syncIsAtBottom = useCallback(() => {\n    const el = scrollContainerRef.current;\n    const nextValue = isBottomScrollCorrectionActive()\n      ? true\n      : el\n        ? isNearBottom(el.scrollTop, el.clientHeight, el.scrollHeight)\n        : true;\n\n    if (nextValue !== lastAtBottomRef.current) {\n      lastAtBottomRef.current = nextValue;\n      setIsAtBottomState(nextValue);\n      onAtBottomChangeRef.current?.(nextValue);\n      return;\n    }\n\n    setIsAtBottomState((current) =>\n      current === nextValue ? current : nextValue\n    );\n  }, [isBottomScrollCorrectionActive, scrollContainerRef]);\n\n  useEffect(() => {\n    const el = scrollContainerRef.current;\n    if (!el) return;\n\n    const handleScroll = () => {\n      syncIsAtBottom();\n    };\n\n    const handleWheel = (event: WheelEvent) => {\n      if (bottomLockedRef.current && event.deltaY < 0) {\n        bottomLockedRef.current = false;\n      }\n    };\n\n    el.addEventListener('scroll', handleScroll, { passive: true });\n    el.addEventListener('wheel', handleWheel, { passive: true });\n    handleScroll();\n\n    return () => {\n      el.removeEventListener('scroll', handleScroll);\n      el.removeEventListener('wheel', handleWheel);\n    };\n  }, [scrollContainerRef, syncIsAtBottom]);\n\n  // -------------------------------------------------------------------------\n  // Derived state\n  // -------------------------------------------------------------------------\n\n  const virtualItems = virtualizer.getVirtualItems();\n  const totalSize = virtualizer.getTotalSize();\n\n  useLayoutEffect(() => {\n    syncIsAtBottom();\n\n    if (!bottomLockedRef.current) return;\n    if (performance.now() < smoothScrollDeadlineRef.current) return;\n\n    const el = scrollContainerRef.current;\n    if (!el) return;\n\n    const maxScroll = el.scrollHeight - el.clientHeight;\n    if (maxScroll > 0 && Math.abs(maxScroll - el.scrollTop) > 1) {\n      el.scrollTop = maxScroll;\n    }\n  }, [\n    rows.length,\n    totalRowCount,\n    totalSize,\n    syncIsAtBottom,\n    scrollContainerRef,\n  ]);\n\n  // -------------------------------------------------------------------------\n  // Imperative helpers\n  // -------------------------------------------------------------------------\n\n  const scrollToBottom = useCallback(\n    (behavior: ScrollToOptionsBehavior = 'smooth') => {\n      const el = scrollContainerRef.current;\n      if (!el) return;\n\n      bottomLockedRef.current = true;\n\n      if (behavior === 'smooth') {\n        smoothScrollDeadlineRef.current = performance.now() + 500;\n        el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });\n      } else {\n        el.scrollTop = el.scrollHeight - el.clientHeight;\n      }\n    },\n    [scrollContainerRef, virtualizer]\n  );\n\n  const scrollToIndex = useCallback(\n    (\n      index: number,\n      options?: {\n        align?: 'start' | 'center' | 'end';\n        behavior?: ScrollToOptionsBehavior;\n      }\n    ) => {\n      if (bottomLockedRef.current) {\n        bottomLockedRef.current = false;\n      }\n\n      virtualizer.scrollToIndex(index, {\n        align: options?.align ?? 'start',\n        behavior: options?.behavior ?? 'smooth',\n      });\n    },\n    [virtualizer]\n  );\n\n  const scrollToPreviousUserMessage = useCallback((): boolean => {\n    const scrollEl = scrollContainerRef.current;\n    const items = virtualizer.getVirtualItems();\n    if (items.length === 0 || rows.length === 0 || !scrollEl) return false;\n\n    const firstVisibleIndex =\n      virtualizer.getVirtualItemForOffset(scrollEl.scrollTop)?.index ??\n      items[0].index;\n    const targetIndex = findPreviousUserMessageIndex(rows, firstVisibleIndex);\n\n    if (targetIndex < 0) return false;\n\n    virtualizer.scrollToIndex(targetIndex, {\n      align: 'start',\n      behavior: 'smooth',\n    });\n    return true;\n  }, [scrollContainerRef, virtualizer, rows]);\n\n  const checkIsAtBottom = useCallback((): boolean => {\n    const el = scrollContainerRef.current;\n    if (!el) return true;\n    return isNearBottom(el.scrollTop, el.clientHeight, el.scrollHeight);\n  }, [scrollContainerRef]);\n\n  const releaseBottomLock = useCallback(() => {\n    if (!bottomLockedRef.current) return;\n    bottomLockedRef.current = false;\n  }, []);\n\n  // -------------------------------------------------------------------------\n  // Row ↔ VirtualItem mapping\n  // -------------------------------------------------------------------------\n\n  const rowIndexForVirtualItem = useCallback(\n    (item: VirtualItem): number => item.index,\n    []\n  );\n\n  const rowForVirtualItem = useCallback(\n    (item: VirtualItem): ConversationRow | undefined => rows[item.index],\n    [rows]\n  );\n\n  const measureElement = useCallback(\n    (node: Element | null) => {\n      virtualizer.measureElement(node);\n    },\n    [virtualizer]\n  );\n\n  // -------------------------------------------------------------------------\n  // Return\n  // -------------------------------------------------------------------------\n\n  return {\n    virtualizer,\n    virtualItems,\n    totalSize,\n    measureElement,\n    scrollToBottom,\n    scrollToIndex,\n    scrollToPreviousUserMessage,\n    isAtBottom: isAtBottomState,\n    checkIsAtBottom,\n    releaseBottomLock,\n    rowIndexForVirtualItem,\n    rowForVirtualItem,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/model/useScrollCommandExecutor.ts",
    "content": "/**\n * Scroll Command Executor\n *\n * Bridges the declarative scroll-intent model (conversation-scroll-commands.ts)\n * to TanStack Virtual's imperative scrollToIndex API.\n *\n * Lifecycle:\n * 1. Entry data arrives → `onEntriesChanged` resolves intent via `resolveScrollIntent`\n * 2. Intent is stored as pending in `ScrollState`\n * 3. React re-renders, TanStack Virtual measures new items\n * 4. `useLayoutEffect` reads the pending intent and dispatches the scroll command\n * 5. `markIntentApplied` clears the pending intent\n *\n * No setTimeout chains. All sequencing is via React lifecycle.\n */\n\nimport { useCallback, useLayoutEffect, useRef } from 'react';\n\nimport type { Virtualizer } from '@tanstack/react-virtual';\n\nimport type { AddEntryType } from '@/shared/hooks/useConversationHistory/types';\n\n// TanStack Virtual only accepts 'auto' | 'smooth', not DOM's full ScrollBehavior\ntype TanStackScrollBehavior = 'auto' | 'smooth';\ntype TanStackScrollAlign = 'start' | 'center' | 'end';\n\nfunction toTanStackBehavior(behavior: ScrollBehavior): TanStackScrollBehavior {\n  return behavior === 'instant' ? 'auto' : behavior;\n}\n\nimport {\n  type ScrollIntent,\n  type ScrollState,\n  createInitialScrollState,\n  markIntentApplied,\n  resolveScrollIntent,\n  setPendingIntent,\n  updateIsAtBottom,\n} from './conversation-scroll-commands';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ScrollCommandExecutorOptions {\n  /** The TanStack Virtual virtualizer instance. */\n  virtualizer: Virtualizer<HTMLDivElement, Element>;\n\n  /** Current number of items in the list. */\n  itemCount: number;\n\n  dataVersion: number;\n\n  /** Reactive isAtBottom from the virtualizer hook. */\n  isAtBottom: boolean;\n\n  scrollToBottom: (behavior?: TanStackScrollBehavior) => void;\n\n  scrollToAbsoluteIndex?: (\n    index: number,\n    align?: TanStackScrollAlign,\n    behavior?: TanStackScrollBehavior\n  ) => boolean;\n}\n\nexport interface ScrollCommandExecutorResult {\n  /**\n   * Call when entries are updated. Resolves the appropriate scroll intent\n   * based on addType, initial load state, and isAtBottom.\n   */\n  onEntriesChanged: (addType: AddEntryType, isInitialLoad: boolean) => void;\n\n  /**\n   * Imperatively request a jump-to-bottom (e.g., from the scroll-to-bottom button).\n   */\n  requestJumpToBottom: (behavior?: ScrollBehavior) => void;\n\n  /**\n   * Imperatively request scrolling to a specific index.\n   */\n  requestJumpToIndex: (\n    index: number,\n    align?: 'start' | 'center' | 'end',\n    behavior?: ScrollBehavior\n  ) => void;\n\n  /**\n   * Read-only access to the current pending intent (for debugging/testing).\n   */\n  pendingIntent: ScrollIntent | null;\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function useScrollCommandExecutor({\n  virtualizer,\n  itemCount,\n  dataVersion,\n  isAtBottom,\n  scrollToBottom,\n  scrollToAbsoluteIndex,\n}: ScrollCommandExecutorOptions): ScrollCommandExecutorResult {\n  // -------------------------------------------------------------------------\n  // Scroll state lives in a ref to avoid re-render cascades.\n  // The only consumer of pendingIntent is the useLayoutEffect below,\n  // which runs synchronously after every render anyway.\n  // -------------------------------------------------------------------------\n\n  const stateRef = useRef<ScrollState>(createInitialScrollState());\n\n  // Keep isAtBottom in sync with the virtualizer's reactive value\n  const prevIsAtBottom = useRef(isAtBottom);\n  if (isAtBottom !== prevIsAtBottom.current) {\n    prevIsAtBottom.current = isAtBottom;\n    stateRef.current = updateIsAtBottom(stateRef.current, isAtBottom);\n  }\n\n  const prevDataVersionRef = useRef(dataVersion);\n\n  // -------------------------------------------------------------------------\n  // Intent resolution (called by the container when entries update)\n  // -------------------------------------------------------------------------\n\n  const onEntriesChanged = useCallback(\n    (addType: AddEntryType, isInitialLoad: boolean) => {\n      const intent = resolveScrollIntent(\n        addType,\n        isInitialLoad,\n        stateRef.current.isAtBottom\n      );\n      stateRef.current = setPendingIntent(stateRef.current, intent);\n    },\n    []\n  );\n\n  // -------------------------------------------------------------------------\n  // Imperative intent setters (for UI buttons)\n  // -------------------------------------------------------------------------\n\n  const requestJumpToBottom = useCallback(\n    (behavior: ScrollBehavior = 'smooth') => {\n      const intent: ScrollIntent = {\n        type: 'jump-to-bottom',\n        behavior,\n      };\n      stateRef.current = setPendingIntent(stateRef.current, intent);\n      executeIntent(\n        virtualizer,\n        intent,\n        itemCount,\n        scrollToBottom,\n        scrollToAbsoluteIndex\n      );\n      stateRef.current = markIntentApplied(stateRef.current);\n    },\n    [itemCount, scrollToAbsoluteIndex, scrollToBottom, virtualizer]\n  );\n\n  const requestJumpToIndex = useCallback(\n    (\n      index: number,\n      align: 'start' | 'center' | 'end' = 'start',\n      behavior: ScrollBehavior = 'smooth'\n    ) => {\n      const intent: ScrollIntent = {\n        type: 'jump-to-index',\n        index,\n        align,\n        behavior,\n      };\n      stateRef.current = setPendingIntent(stateRef.current, intent);\n      executeIntent(\n        virtualizer,\n        intent,\n        itemCount,\n        scrollToBottom,\n        scrollToAbsoluteIndex\n      );\n      stateRef.current = markIntentApplied(stateRef.current);\n    },\n    [itemCount, scrollToAbsoluteIndex, scrollToBottom, virtualizer]\n  );\n\n  // -------------------------------------------------------------------------\n  // Intent execution — runs after React commit + TanStack measurement\n  //\n  // useLayoutEffect fires synchronously after DOM mutations but before paint,\n  // ensuring the virtualizer has measured new items before we scroll.\n  // -------------------------------------------------------------------------\n\n  useLayoutEffect(() => {\n    const state = stateRef.current;\n    const intent = state.pendingIntent;\n    if (!intent) return;\n\n    const isImperativeIntent =\n      intent.type === 'jump-to-bottom' || intent.type === 'jump-to-index';\n    if (!isImperativeIntent && dataVersion === prevDataVersionRef.current) {\n      return;\n    }\n\n    executeIntent(\n      virtualizer,\n      intent,\n      itemCount,\n      scrollToBottom,\n      scrollToAbsoluteIndex\n    );\n    stateRef.current = markIntentApplied(stateRef.current);\n    prevDataVersionRef.current = dataVersion;\n  }, [\n    dataVersion,\n    itemCount,\n    scrollToAbsoluteIndex,\n    scrollToBottom,\n    virtualizer,\n  ]);\n\n  // -------------------------------------------------------------------------\n  // Return\n  // -------------------------------------------------------------------------\n\n  return {\n    onEntriesChanged,\n    requestJumpToBottom,\n    requestJumpToIndex,\n    pendingIntent: stateRef.current.pendingIntent,\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Intent Dispatch (pure function, no hooks)\n// ---------------------------------------------------------------------------\n\n/**\n * Execute a scroll intent against the TanStack Virtual virtualizer.\n *\n * Each intent type maps to a specific scrollToIndex configuration:\n *\n * | Intent          | scrollToIndex call                                    |\n * |-----------------|-------------------------------------------------------|\n * | initial-bottom  | last index, align: 'end' (instant, purge sizes)       |\n * | follow-bottom   | last index, align: 'end', behavior from intent        |\n * | preserve-anchor | no-op (shouldAdjustScrollPositionOnItemSizeChange)     |\n * | plan-reveal     | last index, align: 'start'                            |\n * | jump-to-bottom  | last index, align: 'end', behavior from intent        |\n * | jump-to-index   | intent.index, intent.align, intent.behavior           |\n */\nfunction executeIntent(\n  virtualizer: Virtualizer<HTMLDivElement, Element>,\n  intent: ScrollIntent,\n  itemCount: number,\n  scrollToBottom: (behavior?: TanStackScrollBehavior) => void,\n  scrollToAbsoluteIndex?: (\n    index: number,\n    align?: TanStackScrollAlign,\n    behavior?: TanStackScrollBehavior\n  ) => boolean\n): void {\n  if (itemCount === 0) return;\n\n  const lastIndex = itemCount - 1;\n  const virtualizedCount = virtualizer.options.count;\n\n  switch (intent.type) {\n    case 'initial-bottom': {\n      scrollToBottom('auto');\n      break;\n    }\n\n    case 'follow-bottom': {\n      scrollToBottom(toTanStackBehavior(intent.behavior));\n      break;\n    }\n\n    case 'preserve-anchor': {\n      break;\n    }\n\n    case 'plan-reveal': {\n      if (virtualizedCount === 0 || lastIndex >= virtualizedCount) {\n        if (scrollToAbsoluteIndex?.(lastIndex, 'start', 'auto')) {\n          break;\n        }\n        scrollToBottom('auto');\n        break;\n      }\n\n      virtualizer.scrollToIndex(lastIndex, {\n        align: 'start',\n        behavior: 'auto',\n      });\n      break;\n    }\n\n    case 'jump-to-bottom': {\n      scrollToBottom(toTanStackBehavior(intent.behavior));\n      break;\n    }\n\n    case 'jump-to-index': {\n      if (virtualizedCount === 0) {\n        if (\n          scrollToAbsoluteIndex?.(\n            intent.index,\n            intent.align,\n            toTanStackBehavior(intent.behavior)\n          )\n        ) {\n          break;\n        }\n        scrollToBottom(toTanStackBehavior(intent.behavior));\n        break;\n      }\n\n      if (intent.index >= virtualizedCount) {\n        if (\n          scrollToAbsoluteIndex?.(\n            intent.index,\n            intent.align,\n            toTanStackBehavior(intent.behavior)\n          )\n        ) {\n          break;\n        }\n        scrollToBottom(toTanStackBehavior(intent.behavior));\n        break;\n      }\n\n      virtualizer.scrollToIndex(intent.index, {\n        align: intent.align,\n        behavior: toTanStackBehavior(intent.behavior),\n      });\n      break;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx",
    "content": "import {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type MouseEvent,\n} from 'react';\nimport { SpinnerIcon } from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n  findPreviousUserMessageIndex,\n  type ConversationRow,\n} from '../model/conversation-row-model';\nimport { deriveConversationEntries } from '../model/deriveConversationEntries';\nimport { deriveConversationTimeline } from '../model/deriveConversationTimeline';\nimport { useConversationVirtualizer } from '../model/useConversationVirtualizer';\nimport { useScrollCommandExecutor } from '../model/useScrollCommandExecutor';\n\nimport DisplayConversationEntry from './DisplayConversationEntry';\nimport { ApprovalFormProvider } from '@/shared/hooks/ApprovalForm';\nimport { useEntriesActions } from '../model/contexts/EntriesContext';\nimport {\n  useResetProcess,\n  type UseResetProcessResult,\n} from '../model/hooks/useResetProcess';\nimport type {\n  AddEntryType,\n  ConversationTimelineSource,\n  DisplayEntry,\n} from '@/shared/hooks/useConversationHistory/types';\nimport {\n  isAggregatedGroup,\n  isAggregatedDiffGroup,\n  isAggregatedThinkingGroup,\n} from '@/shared/hooks/useConversationHistory/types';\nimport { useConversationHistory } from '../model/hooks/useConversationHistory';\nimport { useSetTokenUsageInfo } from '../model/contexts/EntriesContext';\nimport type { WorkspaceWithSession } from '@/shared/types/attempt';\nimport type { RepoWithTargetBranch } from 'shared/types';\nimport { ChatEmptyState } from '@vibe/ui/components/ChatEmptyState';\nimport { ChatScriptPlaceholder } from '@vibe/ui/components/ChatScriptPlaceholder';\nimport { ScriptFixerDialog } from '@/shared/dialogs/scripts/ScriptFixerDialog';\n\ninterface ConversationListProps {\n  attempt: WorkspaceWithSession;\n  repos?: RepoWithTargetBranch[];\n  onAtBottomChange?: (atBottom: boolean) => void;\n  sessionScopeId?: string;\n}\n\nexport interface ConversationListHandle {\n  scrollToPreviousUserMessage: () => void;\n  scrollToBottom: (behavior?: 'auto' | 'smooth') => void;\n  adjustScrollBy: (delta: number) => void;\n}\n\nconst ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;\nconst STREAMING_UNVIRTUALIZED_BUFFER_ROWS = 24;\n\nfunction renderRowContent(\n  entry: DisplayEntry,\n  attempt: WorkspaceWithSession,\n  resetAction: UseResetProcessResult,\n  repos: RepoWithTargetBranch[]\n): React.ReactNode {\n  if (isAggregatedGroup(entry)) {\n    return (\n      <DisplayConversationEntry\n        expansionKey={entry.patchKey}\n        aggregatedGroup={entry}\n        aggregatedDiffGroup={null}\n        aggregatedThinkingGroup={null}\n        entry={null}\n        executionProcessId={entry.executionProcessId}\n        workspaceWithSession={attempt}\n        resetAction={resetAction}\n        repos={repos}\n      />\n    );\n  }\n\n  if (isAggregatedDiffGroup(entry)) {\n    return (\n      <DisplayConversationEntry\n        expansionKey={entry.patchKey}\n        aggregatedGroup={null}\n        aggregatedDiffGroup={entry}\n        aggregatedThinkingGroup={null}\n        entry={null}\n        executionProcessId={entry.executionProcessId}\n        workspaceWithSession={attempt}\n        resetAction={resetAction}\n        repos={repos}\n      />\n    );\n  }\n\n  if (isAggregatedThinkingGroup(entry)) {\n    return (\n      <DisplayConversationEntry\n        expansionKey={entry.patchKey}\n        aggregatedGroup={null}\n        aggregatedDiffGroup={null}\n        aggregatedThinkingGroup={entry}\n        entry={null}\n        executionProcessId={entry.executionProcessId}\n        workspaceWithSession={attempt}\n        resetAction={resetAction}\n        repos={repos}\n      />\n    );\n  }\n\n  if (entry.type === 'STDOUT') {\n    return <p>{entry.content}</p>;\n  }\n  if (entry.type === 'STDERR') {\n    return <p>{entry.content}</p>;\n  }\n\n  if (entry.type === 'NORMALIZED_ENTRY') {\n    return (\n      <DisplayConversationEntry\n        expansionKey={entry.patchKey}\n        entry={entry.content}\n        aggregatedGroup={null}\n        aggregatedDiffGroup={null}\n        aggregatedThinkingGroup={null}\n        executionProcessId={entry.executionProcessId}\n        workspaceWithSession={attempt}\n        resetAction={resetAction}\n        repos={repos}\n      />\n    );\n  }\n\n  return null;\n}\n\nexport const ConversationList = forwardRef<\n  ConversationListHandle,\n  ConversationListProps\n>(function ConversationList(\n  { attempt, repos: reposProp = [], onAtBottomChange, sessionScopeId },\n  ref\n) {\n  const { t } = useTranslation('common');\n  const repos = reposProp;\n  const resetAction = useResetProcess(attempt.id, attempt.session?.id);\n  const conversationScopeKey = `${attempt.id}:${sessionScopeId ?? attempt.session?.id ?? 'new'}`;\n  const [filteredEntries, setFilteredEntries] = useState<DisplayEntry[]>([]);\n  const [dataVersion, setDataVersion] = useState(0);\n  const [loading, setLoading] = useState(true);\n  const [hasSetupScriptRun, setHasSetupScriptRun] = useState(false);\n  const [hasCleanupScriptRun, setHasCleanupScriptRun] = useState(false);\n  const [hasRunningProcess, setHasRunningProcess] = useState(false);\n  const lastSettledTailStartIndexRef = useRef<number | null>(null);\n  const { setEntries, reset } = useEntriesActions();\n  const setTokenUsageInfo = useSetTokenUsageInfo();\n  const scriptOutputCacheRef = useRef<\n    Map<string, { count: number; output: string }>\n  >(new Map());\n  const scrollOnEntriesChangedRef = useRef<\n    ((addType: AddEntryType, isInitialLoad: boolean) => void) | null\n  >(null);\n  const pendingUpdateRef = useRef<{\n    source: ConversationTimelineSource;\n    addType: AddEntryType;\n    loading: boolean;\n    isInitialLoad: boolean;\n  } | null>(null);\n  // rAF throttle: at most one state update per animation frame.\n  // Replaces the previous 100ms trailing debounce which never fired during\n  // continuous streaming (upstream rAF in streamJsonPatchEntries reset the\n  // timer every ~16ms). TanStack Virtual has no internal batching — unlike\n  // Virtuoso — so we need to drive renders explicitly via React state.\n  // rAF naturally limits updates to the display refresh rate (~60fps) while\n  // ensuring every frame reflects the latest data.\n  const rafIdRef = useRef<number | null>(null);\n  const pendingInteractionAnchorRef = useRef<{\n    element: HTMLElement;\n    top: number;\n  } | null>(null);\n  const pendingInteractionAnchorFrameRef = useRef<number | null>(null);\n  const pendingInteractionAnchorDeadlineRef = useRef(0);\n\n  // Use ref to access current repos without causing callback recreation\n  const reposRef = useRef(repos);\n  reposRef.current = repos;\n\n  // Check if any repo has setup or cleanup scripts configured\n  const hasSetupScript = repos.some((repo) => repo.setup_script);\n  const hasCleanupScript = repos.some((repo) => repo.cleanup_script);\n\n  // Handlers to open script fixer dialog for setup/cleanup scripts\n  const handleConfigureSetup = useCallback(() => {\n    const currentRepos = reposRef.current;\n    if (currentRepos.length === 0) return;\n\n    ScriptFixerDialog.show({\n      scriptType: 'setup',\n      repos: currentRepos,\n      workspaceId: attempt.id,\n      sessionId: attempt.session?.id,\n    });\n  }, [attempt.id, attempt.session?.id]);\n\n  const handleConfigureCleanup = useCallback(() => {\n    const currentRepos = reposRef.current;\n    if (currentRepos.length === 0) return;\n\n    ScriptFixerDialog.show({\n      scriptType: 'cleanup',\n      repos: currentRepos,\n      workspaceId: attempt.id,\n      sessionId: attempt.session?.id,\n    });\n  }, [attempt.id, attempt.session?.id]);\n\n  // Determine if configure buttons should be shown\n  const canConfigure = repos.length > 0;\n\n  useEffect(() => {\n    if (rafIdRef.current !== null) {\n      cancelAnimationFrame(rafIdRef.current);\n      rafIdRef.current = null;\n    }\n    pendingUpdateRef.current = null;\n    scriptOutputCacheRef.current.clear();\n    setLoading(true);\n    setHasSetupScriptRun(false);\n    setHasCleanupScriptRun(false);\n    setHasRunningProcess(false);\n    setFilteredEntries([]);\n    setDataVersion(0);\n    lastSettledTailStartIndexRef.current = null;\n    reset();\n  }, [conversationScopeKey, reset]);\n\n  useEffect(() => {\n    return () => {\n      if (rafIdRef.current !== null) {\n        cancelAnimationFrame(rafIdRef.current);\n      }\n    };\n  }, []);\n\n  // ---- TanStack Virtual plumbing ----\n  const tanstackScrollRef = useRef<HTMLDivElement | null>(null);\n\n  const clearPendingInteractionAnchor = useCallback(() => {\n    if (pendingInteractionAnchorFrameRef.current !== null) {\n      cancelAnimationFrame(pendingInteractionAnchorFrameRef.current);\n      pendingInteractionAnchorFrameRef.current = null;\n    }\n    pendingInteractionAnchorDeadlineRef.current = 0;\n    pendingInteractionAnchorRef.current = null;\n  }, []);\n\n  const programmaticScrollDeadlineRef = useRef(0);\n\n  const shouldSuppressInteractionDrivenSizeAdjustment = useCallback(\n    () =>\n      performance.now() < programmaticScrollDeadlineRef.current ||\n      (pendingInteractionAnchorRef.current !== null &&\n        performance.now() < pendingInteractionAnchorDeadlineRef.current),\n    []\n  );\n\n  const runInteractionAnchorCorrection = useCallback(() => {\n    pendingInteractionAnchorFrameRef.current = null;\n\n    const anchor = pendingInteractionAnchorRef.current;\n    const activeScrollContainer = tanstackScrollRef.current;\n    if (!anchor || !activeScrollContainer || !anchor.element.isConnected) {\n      clearPendingInteractionAnchor();\n      return;\n    }\n\n    const currentTop = anchor.element.getBoundingClientRect().top;\n    const delta = currentTop - anchor.top;\n    if (Math.abs(delta) >= 0.5) {\n      activeScrollContainer.scrollTop += delta;\n    }\n\n    if (performance.now() < pendingInteractionAnchorDeadlineRef.current) {\n      pendingInteractionAnchorFrameRef.current = requestAnimationFrame(\n        runInteractionAnchorCorrection\n      );\n      return;\n    }\n\n    clearPendingInteractionAnchor();\n  }, [clearPendingInteractionAnchor]);\n\n  const handleConversationClickCapture = useCallback(\n    (event: MouseEvent<HTMLDivElement>) => {\n      const target = event.target;\n      if (!(target instanceof Element)) return;\n\n      const trigger = target.closest<HTMLElement>(\n        'button, summary, [role=\"button\"], [data-scroll-anchor-target]'\n      );\n      if (!trigger || trigger.closest('[data-scroll-anchor-ignore]')) return;\n\n      const scrollContainer = tanstackScrollRef.current;\n      if (!scrollContainer || !scrollContainer.contains(trigger)) return;\n\n      clearPendingInteractionAnchor();\n      pendingInteractionAnchorRef.current = {\n        element: trigger,\n        top: trigger.getBoundingClientRect().top,\n      };\n\n      pendingInteractionAnchorDeadlineRef.current = performance.now() + 250;\n      pendingInteractionAnchorFrameRef.current = requestAnimationFrame(\n        runInteractionAnchorCorrection\n      );\n    },\n    [clearPendingInteractionAnchor, runInteractionAnchorCorrection]\n  );\n\n  const flushPendingUpdate = () => {\n    rafIdRef.current = null;\n    const pending = pendingUpdateRef.current;\n    if (!pending) return;\n\n    const derivedEntries = deriveConversationEntries({\n      source: pending.source,\n      scriptOutputCache: scriptOutputCacheRef.current,\n    });\n\n    setHasSetupScriptRun(derivedEntries.hasSetupScriptRun);\n    setHasCleanupScriptRun(derivedEntries.hasCleanupScriptRun);\n    setHasRunningProcess(derivedEntries.hasRunningProcess);\n    setTokenUsageInfo(derivedEntries.latestTokenUsageInfo);\n\n    const derivedTimeline = deriveConversationTimeline(\n      derivedEntries.entries,\n      prevEntriesRef.current,\n      prevRowsRef.current\n    );\n\n    prevEntriesRef.current = derivedTimeline.displayEntries;\n    prevRowsRef.current = derivedTimeline.rows;\n\n    setFilteredEntries(derivedTimeline.displayEntries);\n    setDataVersion((current) => current + 1);\n    setEntries(derivedEntries.entries);\n\n    scrollOnEntriesChangedRef.current?.(pending.addType, pending.isInitialLoad);\n\n    if (loading) {\n      setLoading(pending.loading);\n    }\n  };\n\n  const onTimelineUpdated = (\n    source: ConversationTimelineSource,\n    addType: AddEntryType,\n    newLoading: boolean\n  ) => {\n    pendingUpdateRef.current = {\n      source,\n      addType,\n      loading: newLoading,\n      isInitialLoad: addType === 'initial',\n    };\n\n    if (rafIdRef.current === null) {\n      rafIdRef.current = requestAnimationFrame(flushPendingUpdate);\n    }\n  };\n\n  const { isFirstTurn, isLoadingHistory } = useConversationHistory({\n    attempt,\n    onTimelineUpdated,\n    scopeKey: conversationScopeKey,\n  });\n\n  const prevEntriesRef = useRef<DisplayEntry[]>([]);\n  const prevRowsRef = useRef<ConversationRow[]>([]);\n  const conversationRows = useMemo(\n    () => prevRowsRef.current,\n    [filteredEntries]\n  );\n\n  const hasActiveStreamingTurn = useMemo(\n    () =>\n      hasRunningProcess ||\n      conversationRows.some((row) => row.rowFamily === 'loading'),\n    [conversationRows, hasRunningProcess]\n  );\n\n  const candidateFirstUnvirtualizedRowIndex = useMemo(() => {\n    const firstTailRowIndex = Math.max(\n      conversationRows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS,\n      0\n    );\n\n    if (!hasActiveStreamingTurn) {\n      return firstTailRowIndex;\n    }\n\n    for (let index = conversationRows.length - 1; index >= 0; index -= 1) {\n      if (conversationRows[index]?.isUserMessage) {\n        return Math.min(index, firstTailRowIndex);\n      }\n    }\n\n    return firstTailRowIndex;\n  }, [conversationRows, hasActiveStreamingTurn]);\n\n  const streamingFirstUnvirtualizedRowIndex = useMemo(() => {\n    const lastSettledTailStartIndex = lastSettledTailStartIndexRef.current;\n    if (lastSettledTailStartIndex == null) {\n      return candidateFirstUnvirtualizedRowIndex;\n    }\n\n    return Math.min(\n      lastSettledTailStartIndex,\n      candidateFirstUnvirtualizedRowIndex\n    );\n  }, [candidateFirstUnvirtualizedRowIndex]);\n\n  useEffect(() => {\n    if (!hasActiveStreamingTurn) {\n      lastSettledTailStartIndexRef.current =\n        candidateFirstUnvirtualizedRowIndex;\n    }\n  }, [candidateFirstUnvirtualizedRowIndex, hasActiveStreamingTurn]);\n\n  const firstUnvirtualizedRowIndex = hasActiveStreamingTurn\n    ? Math.max(\n        0,\n        streamingFirstUnvirtualizedRowIndex -\n          STREAMING_UNVIRTUALIZED_BUFFER_ROWS\n      )\n    : candidateFirstUnvirtualizedRowIndex;\n\n  const virtualizedRows = useMemo(\n    () => conversationRows.slice(0, firstUnvirtualizedRowIndex),\n    [conversationRows, firstUnvirtualizedRowIndex]\n  );\n\n  const unvirtualizedTailRows = useMemo(\n    () => conversationRows.slice(firstUnvirtualizedRowIndex),\n    [conversationRows, firstUnvirtualizedRowIndex]\n  );\n\n  const conversationVirtualizer = useConversationVirtualizer({\n    rows: virtualizedRows,\n    totalRowCount: conversationRows.length,\n    scrollContainerRef: tanstackScrollRef,\n    onAtBottomChange,\n    shouldSuppressSizeAdjustment: shouldSuppressInteractionDrivenSizeAdjustment,\n  });\n\n  // NOTE: Do NOT call conversationVirtualizer.virtualizer.measure() when\n  // firstUnvirtualizedRowIndex changes. measure() wipes ALL cached item sizes,\n  // triggering a massive re-measurement storm and multi-second jitter.\n  // TanStack Virtual handles count changes automatically via getItemKey.\n\n  const scrollToAbsoluteIndex = useCallback(\n    (\n      index: number,\n      align: 'start' | 'center' | 'end' = 'start',\n      behavior: 'auto' | 'smooth' = 'smooth'\n    ): boolean => {\n      if (index < 0 || index >= conversationRows.length) return false;\n\n      const scrollEl = tanstackScrollRef.current;\n      if (!scrollEl) return false;\n\n      const targetNode = scrollEl.querySelector<HTMLElement>(\n        `[data-row-index=\"${index}\"]`\n      );\n\n      if (targetNode) {\n        let top = targetNode.offsetTop;\n\n        if (align === 'center') {\n          top =\n            targetNode.offsetTop -\n            scrollEl.clientHeight / 2 +\n            targetNode.offsetHeight / 2;\n        } else if (align === 'end') {\n          top =\n            targetNode.offsetTop -\n            scrollEl.clientHeight +\n            targetNode.offsetHeight;\n        }\n\n        scrollEl.scrollTo({ top: Math.max(0, top), behavior });\n        return true;\n      }\n\n      if (index < virtualizedRows.length) {\n        conversationVirtualizer.scrollToIndex(index, { align, behavior });\n        return true;\n      }\n\n      return false;\n    },\n    [conversationRows.length, conversationVirtualizer, virtualizedRows.length]\n  );\n\n  const scrollExecutor = useScrollCommandExecutor({\n    virtualizer: conversationVirtualizer.virtualizer,\n    itemCount: conversationRows.length,\n    dataVersion,\n    isAtBottom: conversationVirtualizer.isAtBottom,\n    scrollToBottom: conversationVirtualizer.scrollToBottom,\n    scrollToAbsoluteIndex,\n  });\n  scrollOnEntriesChangedRef.current = scrollExecutor.onEntriesChanged;\n\n  // Determine if there are entries to show placeholders\n  const hasEntries = conversationRows.length > 0;\n\n  // Show placeholders only if script not configured AND not already run AND first turn\n  const showSetupPlaceholder =\n    !hasSetupScript && !hasSetupScriptRun && hasEntries;\n  const showCleanupPlaceholder =\n    !hasCleanupScript &&\n    !hasCleanupScriptRun &&\n    !hasRunningProcess &&\n    hasEntries &&\n    isFirstTurn;\n\n  // Expose scroll functionality via ref — delegates to TanStack Virtual\n  const scrollToPreviousUserMessage = useCallback(() => {\n    conversationVirtualizer.releaseBottomLock();\n\n    const scrollEl = tanstackScrollRef.current;\n    if (!scrollEl || conversationRows.length === 0) return;\n\n    const containerTop = scrollEl.getBoundingClientRect().top;\n    const rowNodes = Array.from(\n      scrollEl.querySelectorAll<HTMLElement>('[data-row-index]')\n    );\n\n    let firstVisibleIndex = conversationRows.length - 1;\n\n    for (const node of rowNodes) {\n      const rect = node.getBoundingClientRect();\n      if (rect.bottom <= containerTop + 1) continue;\n      const indexAttr = node.dataset.rowIndex;\n      if (!indexAttr) continue;\n      const parsedIndex = Number.parseInt(indexAttr, 10);\n      if (!Number.isFinite(parsedIndex)) continue;\n      firstVisibleIndex = parsedIndex;\n      break;\n    }\n\n    const targetIndex = findPreviousUserMessageIndex(\n      conversationRows,\n      firstVisibleIndex\n    );\n\n    if (targetIndex < 0) return;\n\n    programmaticScrollDeadlineRef.current = performance.now() + 1000;\n\n    let attempts = 0;\n    const maxAttempts = 6;\n\n    const correctScroll = () => {\n      if (attempts >= maxAttempts) return;\n      attempts++;\n\n      programmaticScrollDeadlineRef.current = performance.now() + 500;\n\n      const node = scrollEl.querySelector<HTMLElement>(\n        `[data-row-index=\"${targetIndex}\"]`\n      );\n      if (!node) {\n        if (attempts === 1) {\n          conversationVirtualizer.scrollToIndex(targetIndex, {\n            align: 'start',\n            behavior: 'auto',\n          });\n        }\n        requestAnimationFrame(correctScroll);\n        return;\n      }\n\n      const nodeRect = node.getBoundingClientRect();\n      const contRect = scrollEl.getBoundingClientRect();\n      const delta = nodeRect.top - contRect.top;\n\n      if (Math.abs(delta) < 2) return;\n\n      scrollEl.scrollTop += delta;\n      requestAnimationFrame(correctScroll);\n    };\n\n    correctScroll();\n  }, [\n    conversationRows,\n    firstUnvirtualizedRowIndex,\n    conversationVirtualizer,\n    scrollToAbsoluteIndex,\n  ]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      scrollToPreviousUserMessage: () => {\n        scrollToPreviousUserMessage();\n      },\n      scrollToBottom: (behavior = 'smooth') => {\n        conversationVirtualizer.scrollToBottom(behavior);\n      },\n      adjustScrollBy: (delta) => {\n        if (Math.abs(delta) < 0.5) return;\n        const scrollElement = tanstackScrollRef.current;\n        if (!scrollElement) return;\n        scrollElement.scrollTop += delta;\n      },\n    }),\n    [conversationVirtualizer, scrollToPreviousUserMessage]\n  );\n\n  const showLoader = loading && conversationRows.length === 0;\n  const showEmptyState = !loading && conversationRows.length === 0;\n\n  const { virtualItems, totalSize, measureElement } = conversationVirtualizer;\n\n  useEffect(() => {\n    return () => {\n      clearPendingInteractionAnchor();\n    };\n  }, [clearPendingInteractionAnchor]);\n\n  return (\n    <ApprovalFormProvider>\n      <div className=\"relative h-full overflow-hidden\">\n        {showLoader && (\n          <div className=\"absolute inset-0 flex items-center justify-center z-10\">\n            <SpinnerIcon className=\"size-6 animate-spin text-low\" />\n          </div>\n        )}\n        <div\n          ref={tanstackScrollRef}\n          className=\"h-full overflow-y-auto scrollbar-none\"\n          style={{ overflowAnchor: 'none', contain: 'strict' }}\n          onClickCapture={handleConversationClickCapture}\n        >\n          <div className=\"pt-2\">\n            {showSetupPlaceholder && (\n              <div className=\"my-base px-double\">\n                <ChatScriptPlaceholder\n                  type=\"setup\"\n                  onConfigure={canConfigure ? handleConfigureSetup : undefined}\n                />\n              </div>\n            )}\n          </div>\n\n          {isLoadingHistory && !showLoader && (\n            <div className=\"flex flex-col items-center gap-2 px-double py-3\">\n              <div className=\"flex w-full max-w-md flex-col gap-1.5\">\n                <div className=\"flex items-center gap-2\">\n                  <div className=\"h-2.5 w-16 animate-pulse rounded-full bg-foreground/10\" />\n                  <div className=\"h-2.5 flex-1 animate-pulse rounded-full bg-foreground/[0.06]\" />\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <div\n                    className=\"h-2.5 w-24 animate-pulse rounded-full bg-foreground/[0.07]\"\n                    style={{ animationDelay: '150ms' }}\n                  />\n                  <div\n                    className=\"h-2.5 w-32 animate-pulse rounded-full bg-foreground/[0.05]\"\n                    style={{ animationDelay: '150ms' }}\n                  />\n                </div>\n              </div>\n              <span className=\"text-xs text-low\">\n                {t('conversation.loadingEarlierMessages')}\n              </span>\n            </div>\n          )}\n\n          {showEmptyState && (\n            <div className=\"flex min-h-full items-center justify-center px-double py-12\">\n              <ChatEmptyState\n                title={t('conversation.emptyTitle', {\n                  defaultValue: 'Send a message to start the conversation.',\n                })}\n                description={t('conversation.emptyDescription', {\n                  defaultValue:\n                    'Your workspace conversation will appear here once a new turn starts.',\n                })}\n              />\n            </div>\n          )}\n\n          {virtualizedRows.length > 0 && (\n            <div\n              style={{\n                height: `${totalSize}px`,\n                width: '100%',\n                position: 'relative',\n              }}\n            >\n              {virtualItems.map((virtualItem) => {\n                const row = virtualizedRows[virtualItem.index];\n                if (!row) return null;\n                return (\n                  <div\n                    key={row.semanticKey}\n                    data-index={virtualItem.index}\n                    data-row-index={virtualItem.index}\n                    data-semantic-key={row.semanticKey}\n                    ref={measureElement}\n                    style={{\n                      position: 'absolute',\n                      top: 0,\n                      left: 0,\n                      width: '100%',\n                      transform: `translateY(${virtualItem.start}px)`,\n                    }}\n                  >\n                    {renderRowContent(row.entry, attempt, resetAction, repos)}\n                  </div>\n                );\n              })}\n            </div>\n          )}\n\n          {unvirtualizedTailRows.map((row, tailIndex) => {\n            const rowIndex = firstUnvirtualizedRowIndex + tailIndex;\n            return (\n              <div\n                key={row.semanticKey}\n                data-row-index={rowIndex}\n                data-semantic-key={row.semanticKey}\n              >\n                {renderRowContent(row.entry, attempt, resetAction, repos)}\n              </div>\n            );\n          })}\n\n          {/* Footer placeholder */}\n          <div className=\"pb-2\">\n            {showCleanupPlaceholder && (\n              <div className=\"my-base px-double\">\n                <ChatScriptPlaceholder\n                  type=\"cleanup\"\n                  onConfigure={\n                    canConfigure ? handleConfigureCleanup : undefined\n                  }\n                />\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </ApprovalFormProvider>\n  );\n});\n\nexport default ConversationList;\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/ui/DisplayConversationEntry.tsx",
    "content": "import { useMemo, useCallback, useLayoutEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { TFunction } from 'i18next';\nimport {\n  ActionType,\n  BaseAgentCapability,\n  NormalizedEntry,\n  ToolStatus,\n  ToolResult,\n  TodoItem,\n  type RepoWithTargetBranch,\n} from 'shared/types';\nimport type { WorkspaceWithSession } from '@/shared/types/attempt';\nimport { parseDiffStats } from '@/shared/lib/diffStatsParser';\nimport {\n  usePersistedExpanded,\n  type PersistKey,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { getActualTheme } from '@/shared/lib/theme';\nimport { getFileIcon } from '@/shared/lib/fileTypeIcon';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { useMessageEditContext } from '../model/contexts/MessageEditContext';\nimport type { UseResetProcessResult } from '../model/hooks/useResetProcess';\nimport { useChangesViewActions } from '@/shared/hooks/useChangesView';\nimport { useLogsPanelActions } from '@/shared/hooks/useLogsPanel';\nimport { cn } from '@/shared/lib/utils';\nimport {\n  ScriptFixerDialog,\n  type ScriptType,\n} from '@/shared/dialogs/scripts/ScriptFixerDialog';\nimport { ChatToolSummary } from '@vibe/ui/components/ChatToolSummary';\nimport { ChatTodoList } from '@vibe/ui/components/ChatTodoList';\nimport {\n  ChatFileEntry,\n  type ChatFileEntryDiffInput,\n} from '@vibe/ui/components/ChatFileEntry';\nimport { ChatApprovalCard } from '@vibe/ui/components/ChatApprovalCard';\nimport { ChatUserMessage } from '@vibe/ui/components/ChatUserMessage';\nimport { ChatAssistantMessage } from '@vibe/ui/components/ChatAssistantMessage';\nimport { ChatSystemMessage } from '@vibe/ui/components/ChatSystemMessage';\nimport { ChatThinkingMessage } from '@vibe/ui/components/ChatThinkingMessage';\nimport { ChatErrorMessage } from '@vibe/ui/components/ChatErrorMessage';\nimport { ChatScriptEntry } from '@vibe/ui/components/ChatScriptEntry';\nimport { ChatSubagentEntry } from '@vibe/ui/components/ChatSubagentEntry';\nimport { ChatAggregatedToolEntries } from '@vibe/ui/components/ChatAggregatedToolEntries';\nimport { ChatAggregatedDiffEntries } from '@vibe/ui/components/ChatAggregatedDiffEntries';\nimport { ChatCollapsedThinking } from '@vibe/ui/components/ChatCollapsedThinking';\nimport { ChatMarkdown } from '@vibe/ui/components/ChatMarkdown';\nimport {\n  DiffViewBody,\n  useDiffData,\n} from '@vibe/ui/components/PierreConversationDiff';\nimport { inIframe, openFileInVSCode } from '@/integrations/vscode/bridge';\nimport { useDiffViewMode } from '@/shared/stores/useDiffViewStore';\nimport type {\n  AggregatedPatchGroup,\n  AggregatedDiffGroup,\n  AggregatedThinkingGroup,\n} from '@/shared/hooks/useConversationHistory/types';\nimport {\n  CaretDownIcon,\n  FileTextIcon,\n  ListMagnifyingGlassIcon,\n  GlobeIcon,\n  PencilSimpleIcon,\n} from '@phosphor-icons/react';\n\ntype Props = {\n  expansionKey: string;\n  executionProcessId: string;\n  workspaceWithSession: WorkspaceWithSession;\n  resetAction: UseResetProcessResult;\n  repos: RepoWithTargetBranch[];\n  entry: NormalizedEntry | null;\n  aggregatedGroup: AggregatedPatchGroup | null;\n  aggregatedDiffGroup: AggregatedDiffGroup | null;\n  aggregatedThinkingGroup: AggregatedThinkingGroup | null;\n};\n\ntype FileEditAction = Extract<ActionType, { action: 'file_edit' }>;\n\n/**\n * Generate tool summary text from action type\n */\nfunction getToolSummary(\n  entryType: Extract<NormalizedEntry['entry_type'], { type: 'tool_use' }>,\n  t: TFunction<'common'>\n): string {\n  const { action_type, tool_name } = entryType;\n\n  switch (action_type.action) {\n    case 'file_read':\n      return t('conversation.toolSummary.read', { path: action_type.path });\n    case 'search':\n      return t('conversation.toolSummary.searched', {\n        query: action_type.query,\n      });\n    case 'web_fetch':\n      return t('conversation.toolSummary.fetched', { url: action_type.url });\n    case 'command_run':\n      return action_type.command || t('conversation.toolSummary.ranCommand');\n    case 'task_create':\n      return t('conversation.toolSummary.createdTask', {\n        description: action_type.description,\n      });\n    case 'todo_management':\n      return t('conversation.toolSummary.todoOperation', {\n        operation: action_type.operation,\n      });\n    case 'tool':\n      return tool_name || t('conversation.tool');\n    default:\n      return tool_name || t('conversation.tool');\n  }\n}\n\n/**\n * Extract the actual tool output from action_type.result\n * The output location depends on the action type:\n * - command_run: result.output\n * - tool: result.value (JSON stringified if object)\n * - others: fall back to entry.content\n */\nfunction getToolOutput(\n  entryType: Extract<NormalizedEntry['entry_type'], { type: 'tool_use' }>,\n  entryContent: string\n): string {\n  const { action_type } = entryType;\n\n  switch (action_type.action) {\n    case 'command_run':\n      return action_type.result?.output ?? entryContent;\n    case 'tool':\n      if (action_type.result?.value != null) {\n        return typeof action_type.result.value === 'string'\n          ? action_type.result.value\n          : JSON.stringify(action_type.result.value, null, 2);\n      }\n      return entryContent;\n    default:\n      return entryContent;\n  }\n}\n\n/**\n * Extract the command from action_type for command_run actions\n */\nfunction getToolCommand(\n  entryType: Extract<NormalizedEntry['entry_type'], { type: 'tool_use' }>\n): string | undefined {\n  const { action_type } = entryType;\n\n  if (action_type.action === 'command_run') {\n    return action_type.command;\n  }\n  return undefined;\n}\n\n/**\n * Render tool_use entry types with appropriate components\n */\nfunction renderToolUseEntry(\n  entryType: Extract<NormalizedEntry['entry_type'], { type: 'tool_use' }>,\n  entry: NormalizedEntry,\n  props: Props,\n  t: TFunction<'common'>\n): React.ReactNode {\n  const { expansionKey, executionProcessId, workspaceWithSession, repos } =\n    props;\n  const sessionId = workspaceWithSession?.session?.id;\n  const { action_type, status } = entryType;\n\n  // File edit - use ChatFileEntry\n  if (action_type.action === 'file_edit') {\n    const fileEditAction = action_type as FileEditAction;\n    return (\n      <>\n        {fileEditAction.changes.map((change, idx) => (\n          <FileEditEntry\n            key={idx}\n            path={fileEditAction.path}\n            change={change}\n            expansionKey={`edit:${expansionKey}:${idx}`}\n            status={status}\n          />\n        ))}\n      </>\n    );\n  }\n\n  // Plan presentation - use ChatApprovalCard\n  if (action_type.action === 'plan_presentation') {\n    return (\n      <PlanEntry\n        plan={action_type.plan}\n        expansionKey={expansionKey}\n        workspaceId={workspaceWithSession?.id}\n        sessionId={sessionId}\n        status={status}\n      />\n    );\n  }\n\n  // Todo management - use ChatTodoList\n  if (action_type.action === 'todo_management') {\n    return (\n      <TodoManagementEntry\n        todos={action_type.todos}\n        expansionKey={expansionKey}\n      />\n    );\n  }\n\n  // Task/Subagent - use ChatSubagentEntry\n  if (action_type.action === 'task_create') {\n    return (\n      <SubagentEntry\n        description={action_type.description}\n        subagentType={action_type.subagent_type}\n        result={action_type.result}\n        expansionKey={expansionKey}\n        status={status}\n        workspaceId={workspaceWithSession?.id}\n        sessionId={sessionId}\n      />\n    );\n  }\n\n  // Script entries (Setup Script, Cleanup Script, Archive Script, Tool Install Script)\n  const scriptToolNames = [\n    'Setup Script',\n    'Cleanup Script',\n    'Archive Script',\n    'Tool Install Script',\n  ];\n  if (\n    action_type.action === 'command_run' &&\n    scriptToolNames.includes(entryType.tool_name)\n  ) {\n    const exitCode =\n      action_type.result?.exit_status?.type === 'exit_code'\n        ? action_type.result.exit_status.code\n        : null;\n\n    return (\n      <ScriptEntryWithFix\n        title={entryType.tool_name}\n        command={action_type.command}\n        processId={executionProcessId ?? ''}\n        exitCode={exitCode}\n        status={status}\n        workspaceId={workspaceWithSession?.id}\n        sessionId={sessionId}\n        repos={repos}\n      />\n    );\n  }\n\n  // Generic tool pending approval - use plan-style card\n  if (status.status === 'pending_approval') {\n    return (\n      <GenericToolApprovalEntry\n        toolName={entryType.tool_name}\n        content={entry.content}\n        expansionKey={expansionKey}\n        workspaceId={workspaceWithSession?.id}\n        sessionId={sessionId}\n        status={status}\n      />\n    );\n  }\n\n  // Other tool uses - use ChatToolSummary\n  return (\n    <ToolSummaryEntry\n      summary={getToolSummary(entryType, t)}\n      expansionKey={expansionKey}\n      status={status}\n      content={getToolOutput(entryType, entry.content)}\n      toolName={entryType.tool_name}\n      command={getToolCommand(entryType)}\n      actionType={action_type.action}\n    />\n  );\n}\n\nfunction DisplayConversationEntry(props: Props) {\n  const { t } = useTranslation('common');\n  const { capabilities } = useUserSystem();\n  const {\n    entry,\n    aggregatedGroup,\n    aggregatedDiffGroup,\n    aggregatedThinkingGroup,\n    expansionKey,\n    executionProcessId,\n    workspaceWithSession,\n    resetAction,\n  } = props;\n  const sessionId = workspaceWithSession?.session?.id;\n  const executorCanFork = !!(\n    workspaceWithSession?.session?.executor &&\n    capabilities?.[workspaceWithSession.session.executor]?.includes(\n      BaseAgentCapability.SESSION_FORK\n    )\n  );\n\n  // Handle aggregated groups (consecutive file_read or search entries)\n  if (aggregatedGroup) {\n    return <AggregatedGroupEntry group={aggregatedGroup} />;\n  }\n\n  // Handle aggregated diff groups (consecutive file_edit entries for same file)\n  if (aggregatedDiffGroup) {\n    return <AggregatedDiffGroupEntry group={aggregatedDiffGroup} />;\n  }\n\n  // Handle aggregated thinking groups (thinking entries in previous turns)\n  if (aggregatedThinkingGroup) {\n    return (\n      <AggregatedThinkingGroupEntry\n        group={aggregatedThinkingGroup}\n        workspaceId={workspaceWithSession?.id}\n        sessionId={sessionId}\n      />\n    );\n  }\n\n  // If no entry, return null (shouldn't happen in normal usage)\n  if (!entry) {\n    return null;\n  }\n\n  const entryType = entry.entry_type;\n\n  switch (entryType.type) {\n    case 'tool_use':\n      return renderToolUseEntry(entryType, entry, props, t);\n\n    case 'user_message':\n      return (\n        <UserMessageEntry\n          content={entry.content}\n          expansionKey={expansionKey}\n          workspaceId={workspaceWithSession?.id}\n          sessionId={sessionId}\n          executionProcessId={executionProcessId}\n          executorCanFork={executorCanFork}\n          resetAction={resetAction}\n        />\n      );\n\n    case 'assistant_message':\n      return (\n        <AssistantMessageEntry\n          content={entry.content}\n          workspaceId={workspaceWithSession?.id}\n          sessionId={sessionId}\n        />\n      );\n\n    case 'system_message':\n      return (\n        <SystemMessageEntry\n          content={entry.content}\n          expansionKey={expansionKey}\n        />\n      );\n\n    case 'thinking':\n      return (\n        <ChatThinkingMessage\n          content={entry.content}\n          workspaceId={workspaceWithSession?.id}\n          renderMarkdown={({ content, workspaceId, className }) => (\n            <AppChatMarkdown\n              content={content}\n              workspaceId={workspaceId}\n              sessionId={sessionId}\n              className={className}\n              maxWidth={undefined}\n            />\n          )}\n        />\n      );\n\n    case 'error_message':\n      return (\n        <ErrorMessageEntry\n          content={entry.content}\n          expansionKey={expansionKey}\n        />\n      );\n\n    case 'next_action':\n      // The new design doesn't need the next action bar\n      return null;\n\n    case 'token_usage_info':\n      // Displayed in the chat header as the context-usage gauge\n      return null;\n\n    case 'user_feedback':\n      return (\n        <UserFeedbackEntry\n          content={entry.content}\n          deniedTool={entryType.denied_tool}\n          workspaceId={workspaceWithSession?.id}\n          sessionId={sessionId}\n        />\n      );\n\n    case 'user_answered_questions':\n      return (\n        <UserAnsweredQuestionsEntry\n          answers={entryType.answers}\n          expansionKey={expansionKey}\n        />\n      );\n\n    case 'loading':\n      return <LoadingEntry />;\n\n    default: {\n      // Exhaustive check - TypeScript will error if a case is missing\n      const _exhaustiveCheck: never = entryType;\n      return _exhaustiveCheck;\n    }\n  }\n}\n\n/**\n * File edit entry with expandable diff\n */\nfunction FileEntryDiffBody({\n  diffContent,\n}: {\n  diffContent: ChatFileEntryDiffInput;\n}) {\n  const { theme } = useTheme();\n  const actualTheme = getActualTheme(theme);\n  const diffMode = useDiffViewMode();\n  const diffData = useDiffData(diffContent);\n\n  if (!diffData.isValid) {\n    return null;\n  }\n\n  return (\n    <DiffViewBody\n      fileDiffMetadata={diffData.fileDiffMetadata}\n      unifiedDiff={diffData.unifiedDiff}\n      isValid={diffData.isValid}\n      hideLineNumbers={diffData.hideLineNumbers}\n      theme={actualTheme}\n      diffMode={diffMode}\n    />\n  );\n}\n\nfunction AppChatMarkdown({\n  content,\n  workspaceId,\n  sessionId,\n  className,\n  maxWidth,\n}: {\n  content: string;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n  className: string | undefined;\n  maxWidth: string | undefined;\n}) {\n  const { viewFileInChanges, findMatchingDiffPath } = useChangesViewActions();\n\n  return (\n    <ChatMarkdown\n      content={content}\n      workspaceId={workspaceId}\n      className={className}\n      maxWidth={maxWidth}\n      renderContent={({ content, className, workspaceId }) => (\n        <WYSIWYGEditor\n          value={content}\n          disabled\n          className={className}\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n          findMatchingDiffPath={findMatchingDiffPath}\n          onCodeClick={viewFileInChanges}\n        />\n      )}\n    />\n  );\n}\n\nfunction FileEditEntry({\n  path,\n  change,\n  expansionKey,\n  status,\n}: {\n  path: string;\n  change: FileEditAction['changes'][number];\n  expansionKey: string;\n  status: ToolStatus;\n}) {\n  // Auto-expand when pending approval\n  const pendingApproval = status.status === 'pending_approval';\n  const [expanded, toggle] = usePersistedExpanded(\n    expansionKey as PersistKey,\n    pendingApproval\n  );\n  const { theme } = useTheme();\n  const actualTheme = getActualTheme(theme);\n  const { viewFileInChanges, hasDiffPath } = useChangesViewActions();\n  const FileIcon = useMemo(\n    () => getFileIcon(path, actualTheme),\n    [path, actualTheme]\n  );\n  const isVSCode = inIframe();\n\n  // Calculate diff stats for edit changes\n  const { additions, deletions } = useMemo(() => {\n    if (change.action === 'edit' && change.unified_diff) {\n      return parseDiffStats(change.unified_diff);\n    }\n    return { additions: undefined, deletions: undefined };\n  }, [change]);\n\n  // For write actions, count as all additions\n  const writeAdditions =\n    change.action === 'write' ? change.content.split('\\n').length : undefined;\n\n  // Build diff content for rendering when expanded\n  const diffContent: ChatFileEntryDiffInput | undefined = useMemo(() => {\n    if (change.action === 'edit' && change.unified_diff) {\n      return {\n        type: 'unified',\n        path,\n        unifiedDiff: change.unified_diff,\n        hasLineNumbers: change.has_line_numbers ?? true,\n      };\n    }\n    // For write actions, use content-based diff (empty old, new content)\n    if (change.action === 'write' && change.content) {\n      return {\n        type: 'content',\n        oldContent: '',\n        newContent: change.content,\n        newPath: path,\n      };\n    }\n    return undefined;\n  }, [change, path]);\n  const diffPreviewData = useDiffData(\n    diffContent ?? { type: 'unified', path, unifiedDiff: '' }\n  );\n  const hasDiffContent = Boolean(diffContent && diffPreviewData.isValid);\n\n  // Only show \"open in changes\" button if the file exists in current diffs\n  const handleOpenInChanges = useCallback(() => {\n    if (!hasDiffPath(path)) return;\n    viewFileInChanges(path);\n  }, [viewFileInChanges, hasDiffPath, path]);\n  const handleOpenInVSCode = useCallback((filename: string) => {\n    openFileInVSCode(filename, { openAsDiff: false });\n  }, []);\n\n  return (\n    <ChatFileEntry\n      filename={path}\n      additions={additions ?? writeAdditions}\n      deletions={deletions}\n      expanded={expanded}\n      onToggle={toggle}\n      status={status}\n      fileIcon={FileIcon}\n      isVSCode={isVSCode}\n      onOpenInVSCode={handleOpenInVSCode}\n      diffContent={hasDiffContent ? diffContent : undefined}\n      renderDiffBody={\n        hasDiffContent\n          ? (entryDiffContent) => (\n              <FileEntryDiffBody diffContent={entryDiffContent} />\n            )\n          : undefined\n      }\n      onOpenInChanges={handleOpenInChanges}\n    />\n  );\n}\n\n/**\n * Plan entry with expandable content\n */\nfunction PlanEntry({\n  plan,\n  expansionKey,\n  workspaceId,\n  sessionId,\n  status,\n}: {\n  plan: string;\n  expansionKey: string;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n  status: ToolStatus;\n}) {\n  const { t } = useTranslation('common');\n  // Expand plans by default when pending approval\n  const pendingApproval = status.status === 'pending_approval';\n  const [expanded, toggle] = usePersistedExpanded(\n    `plan:${expansionKey}`,\n    pendingApproval\n  );\n\n  // Extract title from plan content (first line or default)\n  const title = useMemo(() => {\n    const firstLine = plan.split('\\n')[0];\n    // Remove markdown heading markers\n    const cleanTitle = firstLine.replace(/^#+\\s*/, '').trim();\n    return cleanTitle || t('conversation.plan');\n  }, [plan, t]);\n\n  return (\n    <ChatApprovalCard\n      title={title}\n      content={plan}\n      expanded={expanded}\n      onToggle={toggle}\n      workspaceId={workspaceId}\n      status={status}\n      renderMarkdown={({ content, workspaceId }) => (\n        <AppChatMarkdown\n          content={content}\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n          className={undefined}\n          maxWidth={undefined}\n        />\n      )}\n    />\n  );\n}\n\n/**\n * Generic tool approval entry - renders with plan-style card when pending approval\n */\nfunction GenericToolApprovalEntry({\n  toolName,\n  content,\n  expansionKey,\n  workspaceId,\n  sessionId,\n  status,\n}: {\n  toolName: string;\n  content: string;\n  expansionKey: string;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n  status: ToolStatus;\n}) {\n  const [expanded, toggle] = usePersistedExpanded(\n    `tool:${expansionKey}`,\n    true // auto-expand for pending approval\n  );\n\n  return (\n    <ChatApprovalCard\n      title={toolName}\n      content={content}\n      expanded={expanded}\n      onToggle={toggle}\n      workspaceId={workspaceId}\n      status={status}\n      renderMarkdown={({ content, workspaceId }) => (\n        <AppChatMarkdown\n          content={content}\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n          className={undefined}\n          maxWidth={undefined}\n        />\n      )}\n    />\n  );\n}\n\n/**\n * User message entry with expandable content\n */\nfunction UserMessageEntry({\n  content,\n  expansionKey,\n  workspaceId,\n  sessionId,\n  executionProcessId,\n  executorCanFork,\n  resetAction,\n}: {\n  content: string;\n  expansionKey: string;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n  executionProcessId: string | undefined;\n  executorCanFork: boolean;\n  resetAction: UseResetProcessResult;\n}) {\n  const [expanded, toggle] = usePersistedExpanded(`user:${expansionKey}`, true);\n  const { startEdit, isEntryGreyed, isInEditMode } = useMessageEditContext();\n  const { resetProcess, canResetProcess, isResetPending } = resetAction;\n\n  const isGreyed = isEntryGreyed(expansionKey);\n\n  const handleEdit = () => {\n    if (executionProcessId) {\n      startEdit(expansionKey, executionProcessId, content);\n    }\n  };\n\n  const handleReset = () => {\n    if (executionProcessId) {\n      resetProcess(executionProcessId);\n    }\n  };\n\n  // Only show actions when we have a process ID and not already in edit mode\n  const canShowActions =\n    !!executionProcessId && !isInEditMode && !isResetPending;\n  // Edit/retry/reset is not supported when the executor doesn't have the fork capability\n  const canEdit = canShowActions && executorCanFork;\n  // Only show reset if we have a process ID, not in edit mode, and not pending\n  const canReset = canEdit && canResetProcess(executionProcessId);\n\n  return (\n    <ChatUserMessage\n      content={content}\n      expanded={expanded}\n      onToggle={toggle}\n      workspaceId={workspaceId}\n      onEdit={canEdit ? handleEdit : undefined}\n      onReset={canReset ? handleReset : undefined}\n      isGreyed={isGreyed}\n      renderMarkdown={({ content, workspaceId }) => (\n        <AppChatMarkdown\n          content={content}\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n          className={undefined}\n          maxWidth={undefined}\n        />\n      )}\n    />\n  );\n}\n\n/**\n * User feedback entry for denied tool calls\n */\nfunction UserFeedbackEntry({\n  content,\n  deniedTool,\n  workspaceId,\n  sessionId,\n}: {\n  content: string;\n  deniedTool: string;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n}) {\n  const { t } = useTranslation('common');\n\n  return (\n    <div className=\"py-2\">\n      <div className=\"bg-background px-4 py-2 text-sm border-y border-dashed\">\n        <div\n          className=\"text-xs mb-1 opacity-70\"\n          style={{ color: 'hsl(var(--destructive))' }}\n        >\n          {t('conversation.deniedByUser', { toolName: deniedTool })}\n        </div>\n        <WYSIWYGEditor\n          value={content}\n          disabled\n          className=\"whitespace-pre-wrap break-words flex flex-col gap-1 font-light py-3\"\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n        />\n      </div>\n    </div>\n  );\n}\n\n/**\n * User answered questions entry with expandable Q&A list\n */\nfunction UserAnsweredQuestionsEntry({\n  answers,\n  expansionKey,\n}: {\n  answers: Extract<\n    NormalizedEntry['entry_type'],\n    { type: 'user_answered_questions' }\n  >['answers'];\n  expansionKey: string;\n}) {\n  const { t } = useTranslation('common');\n  const [expanded, toggle] = usePersistedExpanded(\n    `entry:${expansionKey}`,\n    false\n  );\n\n  return (\n    <div className=\"py-2\">\n      <div className=\"bg-background px-4 py-2 text-sm border-y border-dashed\">\n        <button\n          onClick={() => toggle()}\n          className=\"flex items-center gap-1 text-xs opacity-70 w-full cursor-pointer\"\n        >\n          <CaretDownIcon\n            className={cn(\n              'size-3 transition-transform',\n              !expanded && '-rotate-90'\n            )}\n          />\n          <span>\n            {t('askQuestion.answeredCount', { count: answers.length })}\n          </span>\n        </button>\n        {expanded &&\n          answers.map((qa, i) => (\n            <div key={i} className=\"mt-2\">\n              <div className=\"font-semibold text-sm\">{qa.question}</div>\n              <div className=\"text-sm font-light\">{qa.answer.join(', ')}</div>\n            </div>\n          ))}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Loading placeholder entry\n */\nfunction LoadingEntry() {\n  return (\n    <div className=\"px-4 py-2 text-sm\">\n      <div className=\"flex animate-pulse space-x-2 items-center\">\n        <div className=\"size-3 bg-foreground/10\" />\n        <div className=\"flex-1 h-3 bg-foreground/10\" />\n        <div className=\"flex-1 h-3\" />\n        <div className=\"flex-1 h-3\" />\n      </div>\n    </div>\n  );\n}\n\n/**\n * Assistant message entry with expandable content\n */\nfunction AssistantMessageEntry({\n  content,\n  workspaceId,\n  sessionId,\n}: {\n  content: string;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n}) {\n  return (\n    <ChatAssistantMessage\n      content={content}\n      workspaceId={workspaceId}\n      renderMarkdown={({ content, workspaceId }) => (\n        <AppChatMarkdown\n          content={content}\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n          className={undefined}\n          maxWidth={undefined}\n        />\n      )}\n    />\n  );\n}\n\n/**\n * Tool summary entry with collapsible content for multi-line summaries\n */\nfunction ToolSummaryEntry({\n  summary,\n  expansionKey,\n  status,\n  content,\n  toolName,\n  command,\n  actionType,\n}: {\n  summary: string;\n  expansionKey: string;\n  status: ToolStatus;\n  content: string;\n  toolName: string;\n  command: string | undefined;\n  actionType: string;\n}) {\n  const [expanded, toggle] = usePersistedExpanded(\n    `tool:${expansionKey}`,\n    false\n  );\n  const { viewToolContentInPanel } = useLogsPanelActions();\n  const textRef = useRef<HTMLSpanElement>(null);\n  const [isTruncated, setIsTruncated] = useState(false);\n\n  useLayoutEffect(() => {\n    const el = textRef.current;\n    if (el && !expanded) {\n      setIsTruncated(el.scrollWidth > el.clientWidth);\n    }\n  }, [summary, expanded]);\n\n  // Any tool with output can open the logs panel\n  const hasOutput = content && content.trim().length > 0;\n\n  const handleViewContent = useCallback(() => {\n    viewToolContentInPanel(toolName, content, command);\n  }, [viewToolContentInPanel, toolName, content, command]);\n\n  return (\n    <ChatToolSummary\n      ref={textRef}\n      summary={summary}\n      expanded={expanded}\n      onToggle={toggle}\n      status={status}\n      onViewContent={hasOutput ? handleViewContent : undefined}\n      toolName={toolName}\n      isTruncated={isTruncated}\n      actionType={actionType}\n    />\n  );\n}\n\n/**\n * Todo management entry with expandable list of todos\n */\nfunction TodoManagementEntry({\n  todos,\n  expansionKey,\n}: {\n  todos: TodoItem[];\n  expansionKey: string;\n}) {\n  const [expanded, toggle] = usePersistedExpanded(\n    `todo:${expansionKey}`,\n    false\n  );\n\n  return <ChatTodoList todos={todos} expanded={expanded} onToggle={toggle} />;\n}\n\n/**\n * Subagent/Task entry with expandable output\n */\nfunction SubagentEntry({\n  description,\n  subagentType,\n  result,\n  expansionKey,\n  status,\n  workspaceId,\n  sessionId,\n}: {\n  description: string;\n  subagentType: string | null | undefined;\n  result: ToolResult | null | undefined;\n  expansionKey: string;\n  status: ToolStatus;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n}) {\n  // Only auto-expand if there's a result to show\n  const hasResult = Boolean(result?.value);\n  const [expanded, toggle] = usePersistedExpanded(\n    `subagent:${expansionKey}`,\n    false\n  );\n\n  return (\n    <ChatSubagentEntry\n      description={description}\n      subagentType={subagentType}\n      result={result}\n      expanded={expanded}\n      onToggle={hasResult ? toggle : undefined}\n      status={status}\n      workspaceId={workspaceId}\n      renderMarkdown={({ content, workspaceId }) => (\n        <AppChatMarkdown\n          content={content}\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n          className={undefined}\n          maxWidth={undefined}\n        />\n      )}\n    />\n  );\n}\n\n/**\n * System message entry with expandable content\n */\nfunction SystemMessageEntry({\n  content,\n  expansionKey,\n}: {\n  content: string;\n  expansionKey: string;\n}) {\n  const [expanded, toggle] = usePersistedExpanded(\n    `system:${expansionKey}`,\n    false\n  );\n\n  return (\n    <ChatSystemMessage\n      content={content}\n      expanded={expanded}\n      onToggle={toggle}\n    />\n  );\n}\n\n/**\n * Script entry with fix button for failed scripts\n */\nfunction ScriptEntryWithFix({\n  title,\n  command,\n  processId,\n  exitCode,\n  status,\n  workspaceId,\n  sessionId,\n  repos,\n}: {\n  title: string;\n  command?: string;\n  processId: string;\n  exitCode: number | null;\n  status: ToolStatus;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n  repos: RepoWithTargetBranch[];\n}) {\n  const { viewProcessInPanel } = useLogsPanelActions();\n\n  const reposRef = useRef(repos);\n  reposRef.current = repos;\n\n  const handleFix = useCallback(() => {\n    const currentRepos = reposRef.current;\n    if (!workspaceId || currentRepos.length === 0) return;\n\n    // Determine script type based on title\n    const scriptType: ScriptType =\n      title === 'Setup Script'\n        ? 'setup'\n        : title === 'Cleanup Script'\n          ? 'cleanup'\n          : title === 'Archive Script'\n            ? 'archive'\n            : 'dev_server';\n\n    ScriptFixerDialog.show({\n      scriptType,\n      repos: currentRepos,\n      workspaceId,\n      sessionId,\n      initialRepoId: currentRepos.length === 1 ? currentRepos[0].id : undefined,\n    });\n  }, [title, workspaceId, sessionId]);\n\n  // Only show fix button if we have the necessary context\n  const canFix = workspaceId && repos.length > 0;\n\n  return (\n    <ChatScriptEntry\n      title={title}\n      command={command}\n      processId={processId}\n      exitCode={exitCode}\n      status={status}\n      onViewProcess={viewProcessInPanel}\n      onFix={canFix ? handleFix : undefined}\n    />\n  );\n}\n\n/**\n * Error message entry with expandable content\n */\nfunction ErrorMessageEntry({\n  content,\n  expansionKey,\n}: {\n  content: string;\n  expansionKey: string;\n}) {\n  const [expanded, toggle] = usePersistedExpanded(\n    `error:${expansionKey}`,\n    false\n  );\n\n  return (\n    <ChatErrorMessage content={content} expanded={expanded} onToggle={toggle} />\n  );\n}\n\n/**\n * Aggregated group entry for consecutive file_read, search, or web_fetch entries\n */\nfunction AggregatedGroupEntry({ group }: { group: AggregatedPatchGroup }) {\n  const { viewToolContentInPanel } = useLogsPanelActions();\n  const [expanded, toggle] = usePersistedExpanded(\n    `tool:${group.patchKey}`,\n    false\n  );\n  const [isHovered, setIsHovered] = useState(false);\n\n  // Extract summary and status from each entry in the group\n  const aggregatedEntries = useMemo(() => {\n    return group.entries.map((patchEntry) => {\n      if (patchEntry.type !== 'NORMALIZED_ENTRY') {\n        return {\n          summary: '',\n          status: undefined,\n          expansionKey: patchEntry.patchKey,\n          content: '',\n          toolName: '',\n        };\n      }\n\n      const entryType = patchEntry.content.entry_type;\n      if (entryType.type !== 'tool_use') {\n        return {\n          summary: '',\n          status: undefined,\n          expansionKey: patchEntry.patchKey,\n          content: '',\n          toolName: '',\n        };\n      }\n\n      const { action_type, status, tool_name } = entryType;\n      let summary = '';\n      let content = patchEntry.content.content;\n      let command: string | undefined;\n      if (action_type.action === 'file_read') {\n        summary = action_type.path;\n      } else if (action_type.action === 'search') {\n        summary = action_type.query;\n      } else if (action_type.action === 'web_fetch') {\n        summary = action_type.url;\n      } else if (action_type.action === 'command_run') {\n        summary = action_type.command;\n        command = action_type.command;\n        content = action_type.result?.output ?? '';\n      }\n\n      return {\n        summary,\n        status,\n        expansionKey: patchEntry.patchKey,\n        content,\n        toolName: tool_name,\n        command,\n      };\n    });\n  }, [group.entries]);\n\n  const handleViewContent = useCallback(\n    (index: number) => {\n      const entry = aggregatedEntries[index];\n      if (entry && entry.content) {\n        viewToolContentInPanel(entry.toolName, entry.content, entry.command);\n      }\n    },\n    [aggregatedEntries, viewToolContentInPanel]\n  );\n\n  const handleToggle = useCallback(() => {\n    toggle();\n  }, [toggle]);\n\n  const handleHoverChange = useCallback((hovered: boolean) => {\n    setIsHovered(hovered);\n  }, []);\n\n  // Get the label, icon, and unit based on aggregation type\n  const getDisplayProps = () => {\n    switch (group.aggregationType) {\n      case 'file_read':\n        return { label: 'Read', icon: FileTextIcon, unit: 'file' };\n      case 'search':\n        return { label: 'Search', icon: ListMagnifyingGlassIcon, unit: 'file' };\n      case 'web_fetch':\n        return { label: 'Fetched', icon: GlobeIcon, unit: 'URL' };\n      case 'command_run_read':\n        return { label: 'Read', icon: FileTextIcon, unit: 'command' };\n      case 'command_run_search':\n        return {\n          label: 'Search',\n          icon: ListMagnifyingGlassIcon,\n          unit: 'command',\n        };\n      case 'command_run_edit':\n        return { label: 'Edit', icon: PencilSimpleIcon, unit: 'command' };\n      case 'command_run_fetch':\n        return { label: 'Fetch', icon: GlobeIcon, unit: 'command' };\n    }\n  };\n  const { label, icon, unit } = getDisplayProps();\n\n  return (\n    <ChatAggregatedToolEntries\n      entries={aggregatedEntries}\n      expanded={expanded}\n      isHovered={isHovered}\n      onToggle={handleToggle}\n      onHoverChange={handleHoverChange}\n      onViewContent={handleViewContent}\n      label={label}\n      icon={icon}\n      unit={unit}\n    />\n  );\n}\n\n/**\n * Aggregated thinking group entry for thinking entries in previous turns\n */\nfunction AggregatedThinkingGroupEntry({\n  group,\n  workspaceId,\n  sessionId,\n}: {\n  group: AggregatedThinkingGroup;\n  workspaceId: string | undefined;\n  sessionId: string | undefined;\n}) {\n  const [expanded, toggle] = usePersistedExpanded(\n    `entry:${group.patchKey}`,\n    false\n  );\n  const [isHovered, setIsHovered] = useState(false);\n\n  // Extract thinking entries from the group\n  const thinkingEntries = useMemo(() => {\n    return group.entries\n      .filter((entry) => entry.type === 'NORMALIZED_ENTRY')\n      .map((entry) => ({\n        content: entry.type === 'NORMALIZED_ENTRY' ? entry.content.content : '',\n        expansionKey: entry.patchKey,\n      }));\n  }, [group.entries]);\n\n  const handleToggle = useCallback(() => {\n    toggle();\n  }, [toggle]);\n\n  const handleHoverChange = useCallback((hovered: boolean) => {\n    setIsHovered(hovered);\n  }, []);\n\n  return (\n    <ChatCollapsedThinking\n      entries={thinkingEntries}\n      expanded={expanded}\n      isHovered={isHovered}\n      onToggle={handleToggle}\n      onHoverChange={handleHoverChange}\n      workspaceId={workspaceId}\n      renderMarkdown={({ content, workspaceId, className }) => (\n        <AppChatMarkdown\n          content={content}\n          workspaceId={workspaceId}\n          sessionId={sessionId}\n          className={className}\n          maxWidth={undefined}\n        />\n      )}\n    />\n  );\n}\n\nfunction AggregatedDiffGroupEntry({ group }: { group: AggregatedDiffGroup }) {\n  const { theme } = useTheme();\n  const actualTheme = getActualTheme(theme);\n  const { viewFileInChanges, hasDiffPath } = useChangesViewActions();\n  const [expanded, toggle] = usePersistedExpanded(\n    `diff:${group.patchKey}`,\n    false\n  );\n  const [isHovered, setIsHovered] = useState(false);\n  const FileIcon = useMemo(\n    () => getFileIcon(group.filePath, actualTheme),\n    [group.filePath, actualTheme]\n  );\n  const isVSCode = inIframe();\n\n  // Extract change data and status from each entry\n  const aggregatedDiffEntries = useMemo(() => {\n    return group.entries.flatMap((patchEntry, entryIdx) => {\n      if (patchEntry.type !== 'NORMALIZED_ENTRY') {\n        return [];\n      }\n\n      const entryType = patchEntry.content.entry_type;\n      if (entryType.type !== 'tool_use') {\n        return [];\n      }\n\n      const { action_type, status } = entryType;\n      if (action_type.action !== 'file_edit') {\n        return [];\n      }\n\n      // Each file_edit entry can have multiple changes\n      return action_type.changes.map((change, changeIdx) => ({\n        change,\n        status,\n        expansionKey: `${patchEntry.patchKey}:${entryIdx}:${changeIdx}`,\n      }));\n    });\n  }, [group.entries]);\n\n  const handleToggle = useCallback(() => {\n    toggle();\n  }, [toggle]);\n\n  const handleHoverChange = useCallback((hovered: boolean) => {\n    setIsHovered(hovered);\n  }, []);\n\n  const handleOpenInChanges = useCallback(() => {\n    if (!hasDiffPath(group.filePath)) return;\n    viewFileInChanges(group.filePath);\n  }, [viewFileInChanges, hasDiffPath, group.filePath]);\n  const handleOpenInVSCode = useCallback((filePath: string) => {\n    openFileInVSCode(filePath, { openAsDiff: false });\n  }, []);\n\n  return (\n    <ChatAggregatedDiffEntries\n      filePath={group.filePath}\n      entries={aggregatedDiffEntries}\n      expanded={expanded}\n      isHovered={isHovered}\n      onToggle={handleToggle}\n      onHoverChange={handleHoverChange}\n      onOpenInChanges={handleOpenInChanges}\n      fileIcon={FileIcon}\n      isVSCode={isVSCode}\n      onOpenInVSCode={handleOpenInVSCode}\n      renderDiffBody={({ diffContent }) =>\n        diffContent ? <FileEntryDiffBody diffContent={diffContent} /> : null\n      }\n    />\n  );\n}\n\nconst DisplayConversationEntrySpaced = (props: Props) => {\n  const { isEntryGreyed } = useMessageEditContext();\n  const isGreyed = isEntryGreyed(props.expansionKey);\n\n  return (\n    <div\n      className={cn(\n        'py-base px-double',\n        isGreyed && 'opacity-50 pointer-events-none'\n      )}\n    >\n      <DisplayConversationEntry {...props} />\n    </div>\n  );\n};\n\nexport default DisplayConversationEntrySpaced;\n"
  },
  {
    "path": "packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useDropzone } from 'react-dropzone';\nimport {\n  type AskUserQuestionItem,\n  BaseAgentCapability,\n  type Session,\n  type BaseCodingAgent,\n  ExecutionProcessStatus,\n} from 'shared/types';\nimport { AgentIcon } from '@/shared/components/AgentIcon';\nimport { useWorkspaceExecution } from '@/shared/hooks/useWorkspaceExecution';\nimport { useWorkspaceRepo } from '@/shared/hooks/useWorkspaceRepo';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { useApprovalFeedbackOptional } from '../model/contexts/ApprovalFeedbackContext';\nimport { useMessageEditContext } from '../model/contexts/MessageEditContext';\nimport { useEntries, useTokenUsage } from '../model/contexts/EntriesContext';\nimport { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses';\nimport { useReviewOptional } from '@/shared/hooks/useReview';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useTodos } from '../model/hooks/useTodos';\nimport { getLatestConfigFromProcesses } from '@/shared/lib/executor';\nimport { useExecutorConfig } from '@/shared/hooks/useExecutorConfig';\nimport { useSessionMessageEditor } from '../model/hooks/useSessionMessageEditor';\nimport { useSessionQueueInteraction } from '../model/hooks/useSessionQueueInteraction';\nimport { useSessionSend } from '../model/hooks/useSessionSend';\nimport { useSessionAttachments } from '../model/hooks/useSessionAttachments';\nimport { useMessageEditRetry } from '../model/hooks/useMessageEditRetry';\nimport { useBranchStatus } from '@/shared/hooks/useBranchStatus';\nimport { useWorkspaceBranch } from '../model/hooks/useWorkspaceBranch';\nimport { useApprovalMutation } from '../model/hooks/useApprovalMutation';\nimport { useApprovals } from '@/shared/hooks/useApprovals';\nimport { ResolveConflictsDialog } from '@/shared/dialogs/tasks/ResolveConflictsDialog';\nimport { workspaceSummaryKeys } from '@/shared/hooks/workspaceSummaryKeys';\nimport { buildAgentPrompt } from '@/shared/lib/promptMessage';\nimport { formatDateShortWithTime } from '@/shared/lib/date';\nimport { toPrettyCase } from '@/shared/lib/string';\nimport {\n  SessionChatBox,\n  type ExecutionStatus,\n  type SessionChatBoxEditorRenderProps,\n} from '@vibe/ui/components/SessionChatBox';\nimport { ModelSelectorContainer } from '@/shared/components/ModelSelectorContainer';\nimport {\n  useWorkspacePanelState,\n  RIGHT_MAIN_PANEL_MODES,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { useInspectModeStore } from '../model/store/useInspectModeStore';\nimport { Actions } from '@/shared/actions';\nimport {\n  isSpecialIcon,\n  getActionTooltip,\n  isActionEnabled,\n  isActionVisible,\n  type ActionDefinition,\n} from '@/shared/types/actions';\nimport { SettingsDialog } from '@/shared/dialogs/settings/SettingsDialog';\nimport { useActionVisibilityContext } from '@/shared/hooks/useActionVisibilityContext';\nimport { PrCommentsDialog } from '@/shared/dialogs/tasks/PrCommentsDialog';\nimport type { NormalizedComment } from '@vibe/ui/components/pr-comment-node';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { sessionsApi } from '@/shared/lib/api';\nimport { RenameSessionDialog } from '@vibe/ui/components/RenameSessionDialog';\n\n/** Compute execution status from boolean flags */\nfunction computeExecutionStatus(params: {\n  isInFeedbackMode: boolean;\n  isInEditMode: boolean;\n  isStopping: boolean;\n  isQueueLoading: boolean;\n  isSendingFollowUp: boolean;\n  isQueued: boolean;\n  isAttemptRunning: boolean;\n}): ExecutionStatus {\n  if (params.isInFeedbackMode) return 'feedback';\n  if (params.isInEditMode) return 'edit';\n  if (params.isStopping) return 'stopping';\n  if (params.isQueueLoading) return 'queue-loading';\n  if (params.isSendingFollowUp) return 'sending';\n  if (params.isQueued) return 'queued';\n  if (params.isAttemptRunning) return 'running';\n  return 'idle';\n}\n\n/** Shared props across all modes */\ninterface SharedProps {\n  /** Available sessions for this workspace */\n  sessions: Session[];\n  /** Number of files changed in current session */\n  filesChanged: number;\n  /** Number of lines added */\n  linesAdded: number;\n  /** Number of lines removed */\n  linesRemoved: number;\n  /** Callback to scroll to previous user message */\n  onScrollToPreviousMessage: () => void;\n  /** Callback to scroll to bottom of conversation */\n  onScrollToBottom: (behavior?: 'auto' | 'smooth') => void;\n  /** Disable the \"view code\" click handler (for VS Code extension) */\n  disableViewCode: boolean;\n  /** Replace diff stats with an \"Open Workspace\" button in header */\n  showOpenWorkspaceButton: boolean;\n}\n\n/** Props for existing session mode */\ninterface ExistingSessionProps extends SharedProps {\n  mode: 'existing-session';\n  /** The current session */\n  session: Session;\n  /** Called when a session is selected */\n  onSelectSession: (sessionId: string) => void;\n  /** Callback to start new session mode */\n  onStartNewSession: (() => void) | undefined;\n}\n\n/** Props for new session mode */\ninterface NewSessionProps extends SharedProps {\n  mode: 'new-session';\n  /** Workspace ID for creating new sessions */\n  workspaceId: string;\n  /** Called when a session is selected */\n  onSelectSession: (sessionId: string) => void;\n}\n\n/** Props for placeholder mode (no workspace selected) */\ninterface PlaceholderProps extends SharedProps {\n  mode: 'placeholder';\n}\n\ntype SessionChatBoxContainerProps =\n  | ExistingSessionProps\n  | NewSessionProps\n  | PlaceholderProps;\n\nexport function SessionChatBoxContainer(props: SessionChatBoxContainerProps) {\n  const {\n    mode,\n    sessions,\n    filesChanged,\n    linesAdded,\n    linesRemoved,\n    onScrollToPreviousMessage,\n    onScrollToBottom,\n    disableViewCode = false,\n    showOpenWorkspaceButton,\n  } = props;\n\n  // Extract mode-specific values\n  const session = mode === 'existing-session' ? props.session : undefined;\n  const workspaceId =\n    mode === 'existing-session'\n      ? props.session.workspace_id\n      : mode === 'new-session'\n        ? props.workspaceId\n        : undefined;\n  const isNewSessionMode = mode === 'new-session';\n  const onSelectSession =\n    mode === 'placeholder' ? undefined : props.onSelectSession;\n  const onStartNewSession =\n    mode === 'existing-session' ? props.onStartNewSession : undefined;\n\n  const sessionId = session?.id;\n  const queryClient = useQueryClient();\n\n  const handleRenameSession = useCallback(\n    (targetSessionId: string, currentName: string) => {\n      void RenameSessionDialog.show({\n        currentName,\n        onRename: async (newName: string) => {\n          await sessionsApi.update(targetSessionId, { name: newName });\n          void queryClient.invalidateQueries({\n            queryKey: ['workspaceSessions', workspaceId],\n          });\n        },\n      });\n    },\n    [queryClient, workspaceId]\n  );\n  const appNavigation = useAppNavigation();\n\n  const { executeAction } = useActions();\n  const actionCtx = useActionVisibilityContext();\n  const { rightMainPanelMode, setRightMainPanelMode } =\n    useWorkspacePanelState(workspaceId);\n\n  const handleViewCode = useCallback(() => {\n    setRightMainPanelMode(\n      rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES\n        ? null\n        : RIGHT_MAIN_PANEL_MODES.CHANGES\n    );\n  }, [rightMainPanelMode, setRightMainPanelMode]);\n\n  const handleOpenWorkspace = useCallback(() => {\n    if (!workspaceId) return;\n    appNavigation.goToWorkspace(workspaceId);\n  }, [appNavigation, workspaceId]);\n\n  // Get entries early to extract pending approval for scratch key\n  const { entries } = useEntries();\n  const tokenUsageInfo = useTokenUsage();\n\n  // Execution state\n  const { isAttemptRunning, stopExecution, isStopping, processes } =\n    useWorkspaceExecution(workspaceId);\n\n  // Approvals state\n  const { getPendingForProcess } = useApprovals();\n\n  // Get pending approval from running processes\n  const pendingApproval = useMemo(() => {\n    const runningProcesses = processes.filter(\n      (p) => p.status === ExecutionProcessStatus.running\n    );\n    for (const proc of runningProcesses) {\n      const info = getPendingForProcess(proc.id);\n      if (info) {\n        let questions: AskUserQuestionItem[] | undefined;\n        for (const entry of entries) {\n          if (entry.type !== 'NORMALIZED_ENTRY') continue;\n          const entryType = entry.content.entry_type;\n          if (\n            entryType.type === 'tool_use' &&\n            entryType.status.status === 'pending_approval' &&\n            entryType.status.approval_id === info.approval_id &&\n            entryType.action_type.action === 'ask_user_question'\n          ) {\n            questions = entryType.action_type.questions;\n            break;\n          }\n        }\n        return {\n          approvalId: info.approval_id,\n          timeoutAt: info.timeout_at,\n          executionProcessId: info.execution_process_id,\n          questions,\n        };\n      }\n    }\n    return null;\n  }, [processes, getPendingForProcess, entries]);\n\n  // Use approval_id as scratch key when pending approval exists to avoid\n  // prefilling approval response with queued follow-up message\n  const scratchId = useMemo(() => {\n    if (pendingApproval?.approvalId) {\n      return pendingApproval.approvalId;\n    }\n    return isNewSessionMode ? workspaceId : sessionId;\n  }, [pendingApproval?.approvalId, isNewSessionMode, workspaceId, sessionId]);\n\n  // Get repos for file search\n  const { repos } = useWorkspaceRepo(workspaceId);\n  const repoIds = repos.map((r) => r.id);\n\n  // Approval feedback context\n  const feedbackContext = useApprovalFeedbackOptional();\n  const isInFeedbackMode = !!feedbackContext?.activeApproval;\n\n  // Message edit context\n  const editContext = useMessageEditContext();\n  const isInEditMode = editContext.isInEditMode;\n\n  // Get todos from entries\n  const { todos, inProgressTodo } = useTodos(entries);\n\n  // Review comments context (optional - only available when ReviewProvider wraps this)\n  const reviewContext = useReviewOptional();\n  const reviewMarkdown = useMemo(\n    () => reviewContext?.generateReviewMarkdown() ?? '',\n    [reviewContext]\n  );\n  const hasReviewComments = (reviewContext?.comments.length ?? 0) > 0;\n\n  // Approval mutation for approve/deny/answer actions\n  const {\n    approveAsync,\n    denyAsync,\n    answerAsync,\n    isApproving,\n    isDenying,\n    isAnswering,\n    denyError,\n    answerError,\n  } = useApprovalMutation();\n\n  // Branch status for edit retry and conflict detection\n  const { data: branchStatus } = useBranchStatus(workspaceId);\n\n  // Derive conflict state from branch status\n  const hasConflicts = useMemo(() => {\n    return (\n      branchStatus?.some((r) => (r.conflicted_files?.length ?? 0) > 0) ?? false\n    );\n  }, [branchStatus]);\n\n  const conflictedFilesCount = useMemo(() => {\n    return (\n      branchStatus?.reduce(\n        (sum, r) => sum + (r.conflicted_files?.length ?? 0),\n        0\n      ) ?? 0\n    );\n  }, [branchStatus]);\n\n  // Get workspace branch for conflict resolution dialog\n  const { branch: attemptBranch } = useWorkspaceBranch(workspaceId);\n\n  // Find the first repo with conflicts (for the resolve dialog)\n  const repoWithConflicts = useMemo(\n    () =>\n      branchStatus?.find(\n        (r) => r.is_rebase_in_progress || (r.conflicted_files?.length ?? 0) > 0\n      ),\n    [branchStatus]\n  );\n\n  const handleResolveConflicts = useCallback(() => {\n    if (!workspaceId || !repoWithConflicts) return;\n    ResolveConflictsDialog.show({\n      workspaceId,\n      conflictOp: repoWithConflicts.conflict_op ?? 'rebase',\n      sourceBranch: attemptBranch,\n      targetBranch: repoWithConflicts.target_branch_name,\n      conflictedFiles: repoWithConflicts.conflicted_files ?? [],\n      repoName: repoWithConflicts.repo_name,\n    });\n  }, [workspaceId, repoWithConflicts, attemptBranch]);\n\n  // User profiles, config preference, and latest executor from processes\n  const { profiles, config, capabilities } = useUserSystem();\n\n  // Fetch processes from last session to get full profile (only in new session mode)\n  const lastSessionId = isNewSessionMode ? sessions?.[0]?.id : undefined;\n  const { executionProcesses: lastSessionProcesses } =\n    useExecutionProcesses(lastSessionId);\n\n  // Compute latestConfig: current processes > last session processes > session metadata\n  const latestConfig = useMemo(() => {\n    // Current session's processes take priority (full ExecutorConfig)\n    const fromProcesses = getLatestConfigFromProcesses(processes);\n    if (fromProcesses) return fromProcesses;\n\n    // Try full config from last session's processes\n    const fromLastSession = getLatestConfigFromProcesses(lastSessionProcesses);\n    if (fromLastSession) return fromLastSession;\n\n    // Fallback: just executor from session metadata\n    const lastSessionExecutor = sessions?.[0]?.executor;\n    if (lastSessionExecutor) {\n      return {\n        executor: lastSessionExecutor as BaseCodingAgent,\n      };\n    }\n\n    return null;\n  }, [processes, lastSessionProcesses, sessions]);\n\n  const needsExecutorSelection =\n    isNewSessionMode || (!session?.executor && !latestConfig?.executor);\n\n  // Message editor state\n  const {\n    localMessage,\n    setLocalMessage,\n    scratchData,\n    isScratchLoading,\n    hasInitialValue,\n    saveToScratch,\n    clearDraft,\n    cancelDebouncedSave,\n    handleMessageChange,\n  } = useSessionMessageEditor({ scratchId });\n\n  // Ref to access current message value for attachment handler\n  const localMessageRef = useRef(localMessage);\n  useEffect(() => {\n    localMessageRef.current = localMessage;\n  }, [localMessage]);\n\n  // Attachment handling - insert markdown when attachments are uploaded\n  const handleInsertMarkdown = useCallback(\n    (markdown: string) => {\n      const currentMessage = localMessageRef.current;\n      const newMessage = currentMessage.trim()\n        ? `${currentMessage}\\n\\n${markdown}`\n        : markdown;\n      setLocalMessage(newMessage);\n    },\n    [setLocalMessage]\n  );\n\n  // Auto-paste component context from inspect mode\n  const pendingComponentMarkdown = useInspectModeStore(\n    (s) => s.pendingComponentMarkdown\n  );\n  const clearPendingComponentMarkdown = useInspectModeStore(\n    (s) => s.clearPendingComponentMarkdown\n  );\n\n  useEffect(() => {\n    if (pendingComponentMarkdown) {\n      handleInsertMarkdown(pendingComponentMarkdown);\n      clearPendingComponentMarkdown();\n    }\n  }, [\n    pendingComponentMarkdown,\n    handleInsertMarkdown,\n    clearPendingComponentMarkdown,\n  ]);\n\n  const { uploadFiles, localAttachments, clearUploadedAttachments } =\n    useSessionAttachments(workspaceId, sessionId, handleInsertMarkdown);\n\n  // Unified executor + variant + model selector options resolution\n  const {\n    executorConfig,\n    effectiveExecutor,\n    selectedVariant,\n    executorOptions,\n    variantOptions,\n    presetOptions,\n    setExecutor: handleExecutorChange,\n    setVariant: setSelectedVariant,\n    setOverrides: setExecutorOverrides,\n  } = useExecutorConfig({\n    profiles,\n    lastUsedConfig: latestConfig,\n    scratchConfig: scratchData?.executor_config ?? undefined,\n    configExecutorProfile: config?.executor_profile,\n    onPersist: (cfg) => void saveToScratch(localMessageRef.current, cfg),\n  });\n\n  const supportsContextUsage =\n    !!effectiveExecutor &&\n    capabilities?.[effectiveExecutor]?.includes(\n      BaseAgentCapability.CONTEXT_USAGE\n    );\n\n  // Navigate to agent settings to customise variants\n  const handleCustomise = () => {\n    SettingsDialog.show({ initialSection: 'agents' });\n  };\n\n  // Queue interaction\n  const {\n    isQueued,\n    queuedMessage,\n    queuedConfig,\n    isQueueLoading,\n    queueMessage,\n    cancelQueue,\n    refreshQueueStatus,\n  } = useSessionQueueInteraction({ sessionId });\n\n  // Send actions\n  const {\n    send,\n    isSending,\n    error: sendError,\n    clearError,\n  } = useSessionSend({\n    sessionId,\n    workspaceId,\n    isNewSessionMode,\n    onSelectSession,\n    executorConfig,\n  });\n\n  const handleSend = useCallback(async () => {\n    const { prompt, isSlashCommand } = buildAgentPrompt(localMessage, [\n      reviewMarkdown,\n    ]);\n\n    onScrollToBottom('auto');\n\n    const success = await send(prompt);\n    if (success) {\n      cancelDebouncedSave();\n      setLocalMessage('');\n      clearUploadedAttachments();\n      if (isNewSessionMode) await clearDraft();\n      if (!isSlashCommand) {\n        reviewContext?.clearComments();\n      }\n      requestAnimationFrame(() => {\n        requestAnimationFrame(() => {\n          onScrollToBottom('auto');\n        });\n      });\n    }\n  }, [\n    onScrollToBottom,\n    send,\n    localMessage,\n    reviewMarkdown,\n    cancelDebouncedSave,\n    setLocalMessage,\n    clearUploadedAttachments,\n    isNewSessionMode,\n    clearDraft,\n    reviewContext,\n  ]);\n\n  // Track previous process count for queue refresh\n  const prevProcessCountRef = useRef(processes.length);\n\n  // Refresh queue status when execution stops or new process starts\n  useEffect(() => {\n    const prevCount = prevProcessCountRef.current;\n    prevProcessCountRef.current = processes.length;\n\n    if (!workspaceId) return;\n\n    if (!isAttemptRunning) {\n      refreshQueueStatus();\n      return;\n    }\n\n    if (processes.length > prevCount) {\n      refreshQueueStatus();\n    }\n  }, [isAttemptRunning, workspaceId, processes.length, refreshQueueStatus]);\n\n  // Queue message handler\n  const handleQueueMessage = useCallback(async () => {\n    // Allow queueing if there's a message OR review comments, and we have a config\n    if ((!localMessage.trim() && !reviewMarkdown) || !executorConfig) return;\n\n    const { prompt } = buildAgentPrompt(localMessage, [reviewMarkdown]);\n\n    cancelDebouncedSave();\n    await saveToScratch(localMessage, executorConfig);\n    await queueMessage(prompt, executorConfig);\n\n    // Clear local state after queueing (same as handleSend)\n    setLocalMessage('');\n    clearUploadedAttachments();\n    reviewContext?.clearComments();\n  }, [\n    localMessage,\n    reviewMarkdown,\n    executorConfig,\n    queueMessage,\n    cancelDebouncedSave,\n    saveToScratch,\n    setLocalMessage,\n    clearUploadedAttachments,\n    reviewContext,\n  ]);\n\n  // Editor change handler\n  const handleEditorChange = useCallback(\n    (value: string) => {\n      if (isQueued) cancelQueue();\n      if (executorConfig) {\n        handleMessageChange(value, executorConfig);\n      } else {\n        setLocalMessage(value);\n      }\n      if (sendError) clearError();\n    },\n    [\n      isQueued,\n      cancelQueue,\n      handleMessageChange,\n      executorConfig,\n      sendError,\n      clearError,\n      setLocalMessage,\n    ]\n  );\n\n  // Handle feedback submission\n  const handleSubmitFeedback = useCallback(async () => {\n    if (!feedbackContext || !localMessage.trim()) return;\n    try {\n      await feedbackContext.submitFeedback(localMessage);\n      cancelDebouncedSave();\n      setLocalMessage('');\n      await clearDraft();\n    } catch {\n      // Error is handled in context\n    }\n  }, [\n    feedbackContext,\n    localMessage,\n    cancelDebouncedSave,\n    setLocalMessage,\n    clearDraft,\n  ]);\n\n  // Handle cancel feedback mode\n  const handleCancelFeedback = useCallback(() => {\n    feedbackContext?.exitFeedbackMode();\n  }, [feedbackContext]);\n\n  // Handle cancel queue - restore message to editor\n  const handleCancelQueue = useCallback(async () => {\n    if (queuedMessage) {\n      setLocalMessage(queuedMessage);\n    }\n    if (queuedConfig) {\n      setExecutorOverrides(queuedConfig);\n    }\n    await cancelQueue();\n  }, [\n    queuedMessage,\n    queuedConfig,\n    setLocalMessage,\n    setExecutorOverrides,\n    cancelQueue,\n  ]);\n\n  // Message edit retry mutation\n  const editRetryMutation = useMessageEditRetry(sessionId ?? '', () => {\n    // On success, clear edit mode and reset editor\n    editContext.cancelEdit();\n    cancelDebouncedSave();\n    setLocalMessage('');\n  });\n\n  const areAttachmentInputsDisabled =\n    mode === 'placeholder' ||\n    isQueued ||\n    isSending ||\n    isStopping ||\n    !!feedbackContext?.isSubmitting ||\n    editRetryMutation.isPending ||\n    isApproving ||\n    isDenying ||\n    isAnswering;\n\n  const onDrop = useCallback(\n    (acceptedFiles: File[]) => {\n      if (acceptedFiles.length > 0) {\n        uploadFiles(acceptedFiles);\n      }\n    },\n    [uploadFiles]\n  );\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    onDrop,\n    disabled: areAttachmentInputsDisabled,\n    noClick: true,\n    noKeyboard: true,\n  });\n\n  // Handle edit submission\n  const handleSubmitEdit = useCallback(async () => {\n    if (!editContext.activeEdit || !localMessage.trim() || !executorConfig)\n      return;\n    editRetryMutation.mutate({\n      message: localMessage,\n      executorConfig,\n      executionProcessId: editContext.activeEdit.processId,\n      branchStatus,\n      processes,\n    });\n  }, [\n    editContext.activeEdit,\n    localMessage,\n    executorConfig,\n    branchStatus,\n    processes,\n    editRetryMutation,\n  ]);\n\n  // Handle cancel edit mode\n  const handleCancelEdit = useCallback(() => {\n    editContext.cancelEdit();\n    setLocalMessage('');\n  }, [editContext, setLocalMessage]);\n\n  // Populate editor with original message when entering edit mode\n  const prevEditRef = useRef(editContext.activeEdit);\n  useEffect(() => {\n    if (editContext.activeEdit && !prevEditRef.current) {\n      // Just entered edit mode - populate with original message\n      setLocalMessage(editContext.activeEdit.originalMessage);\n    }\n    prevEditRef.current = editContext.activeEdit;\n  }, [editContext.activeEdit, setLocalMessage]);\n\n  // Handle inserting PR comments into the message editor\n  const handleInsertPrComments = useCallback(async () => {\n    if (!workspaceId) return;\n    const repoId = repos[0]?.id;\n    if (!repoId) return;\n\n    const result = await PrCommentsDialog.show({\n      workspaceId: workspaceId,\n      repoId,\n    });\n    if (result.comments.length > 0) {\n      const markdownBlocks = result.comments.map((comment) => {\n        const payload: NormalizedComment = {\n          id:\n            comment.comment_type === 'general'\n              ? comment.id\n              : comment.id.toString(),\n          comment_type: comment.comment_type,\n          author: comment.author,\n          body: comment.body,\n          created_at: comment.created_at,\n          url: comment.url,\n          ...(comment.comment_type === 'review' && {\n            path: comment.path,\n            line: comment.line != null ? Number(comment.line) : null,\n            diff_hunk: comment.diff_hunk,\n          }),\n        };\n        return '```gh-comment\\n' + JSON.stringify(payload, null, 2) + '\\n```';\n      });\n      handleInsertMarkdown(markdownBlocks.join('\\n\\n'));\n    }\n  }, [workspaceId, repos, handleInsertMarkdown]);\n\n  // Toolbar actions handler\n  const handleToolbarAction = useCallback(\n    (action: ActionDefinition) => {\n      if (action.requiresTarget && workspaceId) {\n        executeAction(action, workspaceId);\n      } else {\n        executeAction(action);\n      }\n    },\n    [executeAction, workspaceId]\n  );\n\n  // Define which actions appear in the toolbar\n  const toolbarActionsList = useMemo(\n    () =>\n      [Actions.StartReview].filter((action) =>\n        isActionVisible(action, actionCtx)\n      ),\n    [actionCtx]\n  );\n\n  const toolbarActionItems = useMemo(\n    () =>\n      toolbarActionsList.flatMap((action) => {\n        if (isSpecialIcon(action.icon)) {\n          return [];\n        }\n\n        const label = action.label;\n\n        return [\n          {\n            id: action.id,\n            icon: action.icon,\n            label,\n            tooltip: getActionTooltip(action, actionCtx),\n            disabled: !isActionEnabled(action, actionCtx),\n            onClick: () => handleToolbarAction(action),\n          },\n        ];\n      }),\n    [toolbarActionsList, actionCtx, handleToolbarAction]\n  );\n\n  // Handle approve action\n  const handleApprove = useCallback(async () => {\n    if (!pendingApproval) return;\n\n    // Exit feedback mode if active\n    feedbackContext?.exitFeedbackMode();\n\n    try {\n      await approveAsync({\n        approvalId: pendingApproval.approvalId,\n        executionProcessId: pendingApproval.executionProcessId,\n      });\n\n      // Invalidate workspace summary cache to update sidebar\n      queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      onScrollToBottom();\n    } catch {\n      // Error is handled by mutation\n    }\n  }, [\n    pendingApproval,\n    feedbackContext,\n    approveAsync,\n    queryClient,\n    onScrollToBottom,\n  ]);\n\n  // Handle request changes (deny with feedback)\n  const handleRequestChanges = useCallback(async () => {\n    if (!pendingApproval || !localMessage.trim()) return;\n\n    try {\n      await denyAsync({\n        approvalId: pendingApproval.approvalId,\n        executionProcessId: pendingApproval.executionProcessId,\n        reason: localMessage.trim(),\n      });\n      cancelDebouncedSave();\n      setLocalMessage('');\n      await clearDraft();\n\n      // Invalidate workspace summary cache to update sidebar\n      queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      onScrollToBottom();\n    } catch {\n      // Error is handled by mutation\n    }\n  }, [\n    pendingApproval,\n    localMessage,\n    denyAsync,\n    cancelDebouncedSave,\n    setLocalMessage,\n    clearDraft,\n    queryClient,\n    onScrollToBottom,\n  ]);\n\n  // Handle AskUserQuestion answer submission\n  const handleAnswerQuestion = useCallback(\n    async (answers: Array<{ question: string; answer: string[] }>) => {\n      if (!pendingApproval) return;\n\n      try {\n        await answerAsync({\n          approvalId: pendingApproval.approvalId,\n          executionProcessId: pendingApproval.executionProcessId,\n          answers,\n        });\n        queryClient.invalidateQueries({\n          queryKey: workspaceSummaryKeys.all,\n        });\n        onScrollToBottom();\n      } catch {\n        // Error is handled by mutation\n      }\n    },\n    [pendingApproval, answerAsync, queryClient, onScrollToBottom]\n  );\n\n  // Check if approval is timed out\n  const isApprovalTimedOut = pendingApproval\n    ? new Date() > new Date(pendingApproval.timeoutAt)\n    : false;\n\n  const status = computeExecutionStatus({\n    isInFeedbackMode,\n    isInEditMode,\n    isStopping,\n    isQueueLoading,\n    isSendingFollowUp: isSending,\n    isQueued,\n    isAttemptRunning,\n  });\n\n  // During loading, render with empty editor to preserve container UI\n  // In approval mode, don't show queued message - it's for follow-up, not approval response\n  const editorValue = useMemo(() => {\n    if (isScratchLoading || !hasInitialValue) return '';\n    if (pendingApproval) return localMessage;\n    return queuedMessage ?? localMessage;\n  }, [\n    isScratchLoading,\n    hasInitialValue,\n    pendingApproval,\n    queuedMessage,\n    localMessage,\n  ]);\n\n  const renderEditor = useCallback(\n    ({\n      focusKey,\n      placeholder,\n      value,\n      onChange,\n      onCmdEnter,\n      disabled,\n      repoIds,\n      executor,\n      onPasteFiles,\n      localAttachments,\n    }: SessionChatBoxEditorRenderProps<BaseCodingAgent>) => (\n      <WYSIWYGEditor\n        key={focusKey}\n        placeholder={placeholder}\n        value={value}\n        onChange={onChange}\n        onCmdEnter={onCmdEnter}\n        disabled={disabled}\n        className=\"min-h-double max-h-[50vh] overflow-y-auto\"\n        repoIds={repoIds}\n        executor={executor}\n        sessionId={sessionId}\n        autoFocus\n        onPasteFiles={onPasteFiles}\n        localAttachments={localAttachments}\n        sendShortcut={config?.send_message_shortcut}\n      />\n    ),\n    [config?.send_message_shortcut, sessionId]\n  );\n\n  const modelSelectorNode = effectiveExecutor ? (\n    <ModelSelectorContainer\n      agent={effectiveExecutor}\n      workspaceId={workspaceId}\n      sessionId={sessionId}\n      onAdvancedSettings={handleCustomise}\n      presets={variantOptions}\n      selectedPreset={selectedVariant}\n      onPresetSelect={setSelectedVariant}\n      onOverrideChange={setExecutorOverrides}\n      executorConfig={executorConfig}\n      presetOptions={presetOptions}\n    />\n  ) : undefined;\n\n  // In placeholder mode, render a disabled version to maintain visual structure\n  if (mode === 'placeholder') {\n    return (\n      <SessionChatBox<BaseCodingAgent>\n        status=\"idle\"\n        renderEditor={renderEditor}\n        repoIds={repoIds}\n        tokenUsageInfo={tokenUsageInfo}\n        supportsContextUsage={false}\n        formatExecutorLabel={toPrettyCase}\n        formatSessionDate={(createdAt) =>\n          formatDateShortWithTime(\n            createdAt instanceof Date ? createdAt.toISOString() : createdAt\n          )\n        }\n        renderAgentIcon={(executor, className) => (\n          <AgentIcon\n            agent={executor as BaseCodingAgent | null | undefined}\n            className={className}\n          />\n        )}\n        editor={{\n          value: '',\n          onChange: () => {},\n        }}\n        actions={{\n          onSend: () => {},\n          onQueue: () => {},\n          onCancelQueue: () => {},\n          onStop: () => {},\n          onPasteFiles: () => {},\n        }}\n        session={{\n          sessions: [],\n          selectedSessionId: undefined,\n          onSelectSession: () => {},\n          isNewSessionMode: false,\n          onNewSession: undefined,\n        }}\n        stats={{\n          filesChanged: 0,\n          linesAdded: 0,\n          linesRemoved: 0,\n        }}\n        onViewCode={disableViewCode ? undefined : handleViewCode}\n      />\n    );\n  }\n\n  return (\n    <SessionChatBox<BaseCodingAgent>\n      status={status}\n      onViewCode={disableViewCode ? undefined : handleViewCode}\n      onOpenWorkspace={\n        showOpenWorkspaceButton && workspaceId ? handleOpenWorkspace : undefined\n      }\n      onScrollToPreviousMessage={onScrollToPreviousMessage}\n      renderEditor={renderEditor}\n      repoIds={repoIds}\n      tokenUsageInfo={tokenUsageInfo}\n      supportsContextUsage={supportsContextUsage}\n      formatExecutorLabel={toPrettyCase}\n      formatSessionDate={(createdAt) =>\n        formatDateShortWithTime(\n          createdAt instanceof Date ? createdAt.toISOString() : createdAt\n        )\n      }\n      renderAgentIcon={(executor, className) => (\n        <AgentIcon\n          agent={executor as BaseCodingAgent | null | undefined}\n          className={className}\n        />\n      )}\n      editor={{\n        value: editorValue,\n        onChange: handleEditorChange,\n      }}\n      actions={{\n        onSend: handleSend,\n        onQueue: handleQueueMessage,\n        onCancelQueue: handleCancelQueue,\n        onStop: stopExecution,\n        onPasteFiles: uploadFiles,\n      }}\n      session={{\n        sessions,\n        selectedSessionId: sessionId,\n        onSelectSession: onSelectSession ?? (() => {}),\n        isNewSessionMode: needsExecutorSelection,\n        onNewSession: onStartNewSession,\n        onRenameSession: handleRenameSession,\n      }}\n      toolbarActions={{\n        items: toolbarActionItems,\n      }}\n      onPrCommentClick={\n        actionCtx.hasOpenPR ? handleInsertPrComments : undefined\n      }\n      stats={{\n        filesChanged,\n        linesAdded,\n        linesRemoved,\n        hasConflicts,\n        conflictedFilesCount,\n        onResolveConflicts: handleResolveConflicts,\n      }}\n      error={sendError}\n      agent={effectiveExecutor}\n      todos={todos}\n      inProgressTodo={inProgressTodo}\n      executor={\n        needsExecutorSelection\n          ? {\n              selected: effectiveExecutor,\n              options: executorOptions,\n              onChange: handleExecutorChange,\n            }\n          : undefined\n      }\n      feedbackMode={\n        feedbackContext\n          ? {\n              isActive: isInFeedbackMode,\n              onSubmitFeedback: handleSubmitFeedback,\n              onCancel: handleCancelFeedback,\n              isSubmitting: feedbackContext.isSubmitting,\n              error: feedbackContext.error,\n              isTimedOut: feedbackContext.isTimedOut,\n            }\n          : undefined\n      }\n      approvalMode={\n        pendingApproval && !pendingApproval.questions\n          ? {\n              isActive: true,\n              onApprove: handleApprove,\n              onRequestChanges: handleRequestChanges,\n              isSubmitting: isApproving || isDenying,\n              isTimedOut: isApprovalTimedOut,\n              error: denyError?.message ?? null,\n            }\n          : undefined\n      }\n      askQuestionMode={\n        pendingApproval?.questions\n          ? {\n              isActive: true,\n              questions: pendingApproval.questions,\n              onSubmitAnswers: handleAnswerQuestion,\n              isSubmitting: isAnswering,\n              isTimedOut: isApprovalTimedOut,\n              error: answerError?.message ?? null,\n            }\n          : undefined\n      }\n      editMode={{\n        isActive: isInEditMode,\n        onSubmitEdit: handleSubmitEdit,\n        onCancel: handleCancelEdit,\n        isSubmitting: editRetryMutation.isPending,\n      }}\n      reviewComments={\n        hasReviewComments && reviewContext\n          ? {\n              count: reviewContext.comments.length,\n              previewMarkdown: reviewMarkdown,\n              onClear: reviewContext.clearComments,\n            }\n          : undefined\n      }\n      localAttachments={localAttachments}\n      dropzone={{ getRootProps, getInputProps, isDragActive }}\n      modelSelector={modelSelectorNode}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/config.ts",
    "content": "import i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\nimport LanguageDetector from 'i18next-browser-languagedetector';\nimport { SUPPORTED_I18N_CODES, uiLanguageToI18nCode } from './languages';\n\n// Import translation files\nimport enCommon from './locales/en/common.json';\nimport enSettings from './locales/en/settings.json';\nimport enProjects from './locales/en/projects.json';\nimport enTasks from './locales/en/tasks.json';\nimport enOrganization from './locales/en/organization.json';\nimport frCommon from './locales/fr/common.json';\nimport frSettings from './locales/fr/settings.json';\nimport frProjects from './locales/fr/projects.json';\nimport frTasks from './locales/fr/tasks.json';\nimport frOrganization from './locales/fr/organization.json';\nimport jaCommon from './locales/ja/common.json';\nimport jaSettings from './locales/ja/settings.json';\nimport jaProjects from './locales/ja/projects.json';\nimport jaTasks from './locales/ja/tasks.json';\nimport jaOrganization from './locales/ja/organization.json';\nimport esCommon from './locales/es/common.json';\nimport esSettings from './locales/es/settings.json';\nimport esProjects from './locales/es/projects.json';\nimport esTasks from './locales/es/tasks.json';\nimport esOrganization from './locales/es/organization.json';\nimport koCommon from './locales/ko/common.json';\nimport koSettings from './locales/ko/settings.json';\nimport koProjects from './locales/ko/projects.json';\nimport koTasks from './locales/ko/tasks.json';\nimport koOrganization from './locales/ko/organization.json';\nimport zhHansCommon from './locales/zh-Hans/common.json';\nimport zhHansSettings from './locales/zh-Hans/settings.json';\nimport zhHansProjects from './locales/zh-Hans/projects.json';\nimport zhHansTasks from './locales/zh-Hans/tasks.json';\nimport zhHansOrganization from './locales/zh-Hans/organization.json';\nimport zhHantCommon from './locales/zh-Hant/common.json';\nimport zhHantSettings from './locales/zh-Hant/settings.json';\nimport zhHantProjects from './locales/zh-Hant/projects.json';\nimport zhHantTasks from './locales/zh-Hant/tasks.json';\nimport zhHantOrganization from './locales/zh-Hant/organization.json';\n\nconst resources = {\n  en: {\n    common: enCommon,\n    settings: enSettings,\n    projects: enProjects,\n    tasks: enTasks,\n    organization: enOrganization,\n  },\n  fr: {\n    common: frCommon,\n    settings: frSettings,\n    projects: frProjects,\n    tasks: frTasks,\n    organization: frOrganization,\n  },\n  ja: {\n    common: jaCommon,\n    settings: jaSettings,\n    projects: jaProjects,\n    tasks: jaTasks,\n    organization: jaOrganization,\n  },\n  es: {\n    common: esCommon,\n    settings: esSettings,\n    projects: esProjects,\n    tasks: esTasks,\n    organization: esOrganization,\n  },\n  ko: {\n    common: koCommon,\n    settings: koSettings,\n    projects: koProjects,\n    tasks: koTasks,\n    organization: koOrganization,\n  },\n  'zh-Hans': {\n    common: zhHansCommon,\n    settings: zhHansSettings,\n    projects: zhHansProjects,\n    tasks: zhHansTasks,\n    organization: zhHansOrganization,\n  },\n  'zh-Hant': {\n    common: zhHantCommon,\n    settings: zhHantSettings,\n    projects: zhHantProjects,\n    tasks: zhHantTasks,\n    organization: zhHantOrganization,\n  },\n};\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .init({\n    resources,\n    fallbackLng: {\n      'zh-TW': ['zh-Hant'],\n      'zh-HK': ['zh-Hant'],\n      'zh-MO': ['zh-Hant'],\n      zh: ['zh-Hans'], // Map generic Chinese to Simplified Chinese\n      default: ['en'],\n    },\n    defaultNS: 'common',\n    debug: import.meta.env.DEV,\n    // Include 'zh' + Traditional Chinese locales for browser detection\n    supportedLngs: [...SUPPORTED_I18N_CODES, 'zh', 'zh-TW', 'zh-HK', 'zh-MO'],\n    nonExplicitSupportedLngs: true, // Accept zh -> zh-Hans mapping\n    load: 'currentOnly', // Load exact language code\n\n    interpolation: {\n      escapeValue: false, // React already escapes\n    },\n\n    react: {\n      useSuspense: false, // Avoid suspense for now to simplify initial setup\n    },\n\n    detection: {\n      order: ['navigator', 'htmlTag'],\n      caches: [], // Disable localStorage cache - we'll handle this via config\n    },\n  });\n\n// Debug logging in development\nif (import.meta.env.DEV) {\n  console.log('i18n initialized:', i18n.isInitialized);\n  console.log('i18n language:', i18n.language);\n  console.log('i18n namespaces:', i18n.options.ns);\n  console.log('Common bundle loaded:', i18n.hasResourceBundle('en', 'common'));\n}\n\n// Function to update language from config\nexport const updateLanguageFromConfig = (configLanguage: string) => {\n  if (configLanguage === 'BROWSER') {\n    // Use browser detection\n    const detected = i18n.services.languageDetector?.detect();\n    const detectedLang = Array.isArray(detected) ? detected[0] : detected;\n    i18n.changeLanguage(detectedLang || 'en');\n  } else {\n    // Use explicit language selection with proper mapping\n    const langCode = uiLanguageToI18nCode(configLanguage);\n    if (langCode) {\n      i18n.changeLanguage(langCode);\n    } else {\n      console.warn(\n        `Unknown UI language: ${configLanguage}, falling back to 'en'`\n      );\n      i18n.changeLanguage('en');\n    }\n  }\n};\n\nexport default i18n;\n"
  },
  {
    "path": "packages/web-core/src/i18n/index.ts",
    "content": "import './config';\nexport { default } from './config';\n"
  },
  {
    "path": "packages/web-core/src/i18n/languages.ts",
    "content": "/**\n * Centralized language configuration for the i18n system.\n * This eliminates duplicate language names in translation files and provides\n * a single source of truth for supported languages.\n */\n\nexport const UI_TO_I18N = {\n  EN: 'en',\n  FR: 'fr',\n  JA: 'ja',\n  ES: 'es',\n  KO: 'ko',\n  ZH_HANS: 'zh-Hans',\n  ZH_HANT: 'zh-Hant',\n} as const;\n\nconst SUPPORTED_UI_LANGUAGES = [\n  'BROWSER',\n  'EN',\n  'FR',\n  'JA',\n  'ES',\n  'KO',\n  'ZH_HANS',\n  'ZH_HANT',\n] as const;\nexport const SUPPORTED_I18N_CODES = Object.values(UI_TO_I18N);\n\nconst FALLBACK_ENDONYMS = {\n  en: 'English',\n  fr: 'Français',\n  ja: '日本語',\n  es: 'Español',\n  ko: '한국어',\n  'zh-Hans': '简体中文',\n  'zh-Hant': '繁體中文',\n} as const;\n\n/**\n * Convert UiLanguage enum value to i18next language code\n */\nexport function uiLanguageToI18nCode(uiLang: string): string | undefined {\n  return uiLang === 'BROWSER'\n    ? undefined\n    : UI_TO_I18N[uiLang as keyof typeof UI_TO_I18N];\n}\n\n/**\n * Get the native name (endonym) of a language using Intl.DisplayNames\n */\nfunction getEndonym(langCode: string): string {\n  try {\n    return (\n      new Intl.DisplayNames([langCode], { type: 'language' }).of(langCode) ||\n      FALLBACK_ENDONYMS[langCode as keyof typeof FALLBACK_ENDONYMS] ||\n      langCode\n    );\n  } catch {\n    return (\n      FALLBACK_ENDONYMS[langCode as keyof typeof FALLBACK_ENDONYMS] || langCode\n    );\n  }\n}\n\n/**\n * Get language options for dropdown with proper display names\n */\nexport function getLanguageOptions(browserDefaultLabel: string) {\n  return SUPPORTED_UI_LANGUAGES.map((ui) => ({\n    value: ui,\n    label:\n      ui === 'BROWSER'\n        ? browserDefaultLabel\n        : getEndonym(UI_TO_I18N[ui as keyof typeof UI_TO_I18N]),\n  }));\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/en/common.json",
    "content": "{\n  \"editorNames\": {\n    \"custom\": \"Custom\"\n  },\n  \"buttons\": {\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"send\": \"Send\",\n    \"delete\": \"Delete\",\n    \"edit\": \"Edit\",\n    \"create\": \"Create\",\n    \"continue\": \"Continue\",\n    \"reset\": \"Reset\",\n    \"manage\": \"Manage\",\n    \"connect\": \"Connect\",\n    \"disconnect\": \"Disconnect\",\n    \"close\": \"Close\",\n    \"replay\": \"Replay\",\n    \"discard\": \"Discard\",\n    \"addItem\": \"Add Item\",\n    \"reply\": \"Reply\",\n    \"retry\": \"Retry\",\n    \"add\": \"Add\"\n  },\n  \"form\": {\n    \"notSpecified\": \"Not specified\",\n    \"selectOption\": \"Select an option...\"\n  },\n  \"states\": {\n    \"loading\": \"Loading...\",\n    \"loadingHistory\": \"Loading History\",\n    \"saving\": \"Saving...\",\n    \"error\": \"Error\",\n    \"success\": \"Success\",\n    \"reconnecting\": \"Reconnecting\"\n  },\n  \"ok\": \"OK\",\n  \"error\": \"Error\",\n  \"errors\": {\n    \"generic\": \"An unexpected error occurred\"\n  },\n  \"language\": {\n    \"browserDefault\": \"Browser Default\"\n  },\n  \"conversation\": {\n    \"plan\": \"Plan\",\n    \"output\": \"Output\",\n    \"deniedByUser\": \"{{toolName}} denied by user\",\n    \"tool\": \"Tool\",\n    \"thinking\": \"Thinking\",\n    \"toolSummary\": {\n      \"read\": \"Read {{path}}\",\n      \"searched\": \"Searched for \\\"{{query}}\\\"\",\n      \"fetched\": \"Fetched {{url}}\",\n      \"ranCommand\": \"Ran command\",\n      \"createdTask\": \"Created task: {{description}}\",\n      \"todoOperation\": \"{{operation}} todos\"\n    },\n    \"subagent\": {\n      \"defaultType\": \"Subagent\"\n    },\n    \"loadingEarlierMessages\": \"Loading earlier messages\"\n  },\n  \"folderPicker\": {\n    \"legend\": \"Click folder names to navigate • Use action buttons to select\",\n    \"manualPathLabel\": \"Enter path manually:\",\n    \"go\": \"Go\",\n    \"searchLabel\": \"Search current directory:\",\n    \"selectCurrent\": \"Select Current\",\n    \"gitRepo\": \"git repo\",\n    \"selectPath\": \"Select Path\"\n  },\n  \"branchSelector\": {\n    \"placeholder\": \"Select a branch\",\n    \"searchPlaceholder\": \"Search branches...\",\n    \"empty\": \"No branches found\",\n    \"badges\": {\n      \"current\": \"current\",\n      \"remote\": \"remote\"\n    },\n    \"currentDisabled\": \"Cannot select the current branch\"\n  },\n  \"orgSwitcher\": {\n    \"organizations\": \"Organizations\",\n    \"createOrganization\": \"Create organization\",\n    \"orgSettings\": \"Organization settings\"\n  },\n  \"personal\": \"Personal\",\n  \"signIn\": \"Sign in\",\n  \"signOut\": \"Sign out\",\n  \"oauth\": {\n    \"title\": \"Sign in to Vibe Kanban\",\n    \"description\": \"Sign in to join organizations and share tasks with your team\",\n    \"continueWithGitHub\": \"Continue with GitHub\",\n    \"continueWithGoogle\": \"Continue with Google\",\n    \"waitingTitle\": \"Complete Authentication\",\n    \"waitingDescription\": \"A popup window has been opened for authentication\",\n    \"waitingForAuth\": \"Waiting for authentication...\",\n    \"popupInstructions\": \"If the popup window didn't open, please check your popup blocker settings.\",\n    \"back\": \"Back\",\n    \"successTitle\": \"Authentication Successful!\",\n    \"welcomeBack\": \"Welcome back, {{name}}\",\n    \"errorTitle\": \"Authentication Failed\",\n    \"errorDescription\": \"There was a problem authenticating your account\",\n    \"tryAgain\": \"Try Again\"\n  },\n  \"onboardingSignIn\": {\n    \"subtitle\": \"Sign in to continue\",\n    \"signedInAs\": \"Signed in as {{name}}.\",\n    \"moreOptions\": \"More options\",\n    \"featureHeader\": \"Feature\",\n    \"signedInHeader\": \"Signed in\",\n    \"skipSignInHeader\": \"Don't sign in\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\"\n  },\n  \"toolbar\": {\n    \"sortBy\": \"Sort by\",\n    \"groupBy\": \"Group by\"\n  },\n  \"sorting\": {\n    \"ascending\": \"Ascending\",\n    \"descending\": \"Descending\"\n  },\n  \"grouping\": {\n    \"date\": \"Date\",\n    \"assignee\": \"Assignee\",\n    \"label\": \"Label\"\n  },\n  \"workspaces\": {\n    \"title\": \"Workspaces\",\n    \"searchPlaceholder\": \"Search...\",\n    \"active\": \"Active\",\n    \"archived\": \"Archived\",\n    \"loading\": \"Loading...\",\n    \"notFound\": \"Workspace not found\",\n    \"selectToStart\": \"Select a workspace to get started\",\n    \"draft\": \"Draft\",\n    \"viewArchive\": \"View Archive\",\n    \"backToActive\": \"Back to Active\",\n    \"noArchived\": \"No archived workspaces\",\n    \"noWorkspaces\": \"No workspaces\",\n    \"newWorkspace\": \"New workspace\",\n    \"needsAttention\": \"Needs Attention\",\n    \"idle\": \"Idle\",\n    \"running\": \"Running\",\n    \"pin\": \"Pin\",\n    \"unpin\": \"Unpin\",\n    \"archive\": \"Archive\",\n    \"more\": \"More actions\",\n    \"rename\": {\n      \"title\": \"Rename Workspace\",\n      \"description\": \"Enter a new name for this workspace.\",\n      \"nameLabel\": \"Name\",\n      \"placeholder\": \"Enter workspace name\",\n      \"action\": \"Rename\",\n      \"renaming\": \"Renaming...\"\n    },\n    \"unlinkFromIssue\": \"Unlink from issue\",\n    \"deleteWorkspace\": \"Delete workspace\",\n    \"unlink\": \"Unlink\",\n    \"delete\": \"Delete\",\n    \"unlinkConfirmMessage\": \"Are you sure you want to unlink this workspace from the issue? The workspace will still exist but will no longer be associated with this issue.\",\n    \"deleteConfirmMessage\": \"Are you sure you want to delete this workspace? This will unlink it from the issue and delete the local workspace. This action cannot be undone.\",\n    \"unlinkError\": \"Failed to unlink workspace\",\n    \"deleteError\": \"Failed to delete workspace\",\n    \"linkError\": \"Failed to link workspace\",\n    \"filesChanged\": \"{{count}} files\",\n    \"deleteDialog\": {\n      \"title\": \"Delete Workspace\",\n      \"description\": \"Are you sure you want to delete this workspace? This action cannot be undone.\",\n      \"deleteBranchLabel\": \"Delete branch\",\n      \"cannotDeleteOpenPr\": \"Cannot delete branch while PR is open\",\n      \"unlinkFromIssueLabel\": \"Also unlink from issue\"\n    }\n  },\n  \"fileTree\": {\n    \"searchPlaceholder\": \"Search files...\",\n    \"noResults\": \"No matching files\",\n    \"title\": \"Files\",\n    \"showGitHubComments\": \"Show GitHub comments\",\n    \"hideGitHubComments\": \"Hide GitHub comments\",\n    \"prevGitHubComment\": \"Previous file with comments\",\n    \"nextGitHubComment\": \"Next file with comments\"\n  },\n  \"sections\": {\n    \"changes\": \"Changes\",\n    \"repositories\": \"Repositories\",\n    \"addRepositories\": \"Add Repositories\",\n    \"project\": \"Project\",\n    \"processes\": \"Processes\",\n    \"devServer\": \"Dev Server\",\n    \"advanced\": \"Advanced\",\n    \"workingBranch\": \"Working Branch\",\n    \"recent\": \"Recent\",\n    \"other\": \"Other\",\n    \"devServerPreview\": \"Dev Server Preview\",\n    \"terminal\": \"Terminal\",\n    \"notes\": \"Notes\"\n  },\n  \"notes\": {\n    \"placeholder\": \"Add notes about this workspace...\",\n    \"selectWorkspace\": \"Select a workspace to view notes\"\n  },\n  \"actions\": {\n    \"copyPath\": \"Copy path\",\n    \"cancel\": \"Cancel\",\n    \"saveChanges\": \"Save Changes\",\n    \"copied\": \"Copied\",\n    \"collapse\": \"Collapse\"\n  },\n  \"comments\": {\n    \"addReviewComment\": \"Add Review Comment\",\n    \"addPlaceholder\": \"Add a comment...\",\n    \"editPlaceholder\": \"Edit comment...\",\n    \"copyToReview\": \"Copy to review\"\n  },\n  \"confirm\": {\n    \"defaultConfirm\": \"Confirm\",\n    \"defaultCancel\": \"Cancel\"\n  },\n  \"dialogs\": {\n    \"selectGitRepository\": \"Select Git Repository\",\n    \"chooseExistingRepo\": \"Choose an existing repository from your file system\"\n  },\n  \"empty\": {\n    \"noChanges\": \"No changes to display\"\n  },\n  \"commandBar\": {\n    \"noResults\": \"No results found.\",\n    \"back\": \"Back\",\n    \"defaultPlaceholder\": \"Type a command or search...\",\n    \"selectBranch\": \"Select Branch\",\n    \"selectBranchFor\": \"Select Branch for {{repoName}}\"\n  },\n  \"workspacesGuide\": {\n    \"welcome\": {\n      \"title\": \"Welcome\",\n      \"content\": \"Welcome to the Workspaces page. One view of the workspaces across all of your projects, and the fastest way to review work from your agents. Share feedback anytime via the navbar icon.\"\n    },\n    \"commandBar\": {\n      \"title\": \"Command Bar\",\n      \"content\": \"The command bar is your central hub for navigation. Open it with CMD+K to search and access every action available in a workspace.\"\n    },\n    \"contextBar\": {\n      \"title\": \"Context Bar\",\n      \"content\": \"The context bar lets you switch between panes quickly. Drag it wherever works best for you.\"\n    },\n    \"sidebar\": {\n      \"title\": \"Workspace Sidebar\",\n      \"content\": \"See the status of all your workspaces at a glance. Notifications highlight which ones need attention. Archive merged workspaces to keep your sidebar clean.\"\n    },\n    \"multiRepo\": {\n      \"title\": \"Multi-Repo Support\",\n      \"content\": \"Add multiple repos to a single workspace. Reference code from one repo while working in another, or implement changes across several repos at once.\"\n    },\n    \"sessions\": {\n      \"title\": \"Multiple Sessions\",\n      \"content\": \"Create multiple agent conversation sessions within a single workspace, including sessions with different agents. This helps you work around conversation limits or spin up review agents in separate threads.\"\n    },\n    \"preview\": {\n      \"title\": \"Preview Changes\",\n      \"content\": \"Preview your work in a built-in browser without switching contexts. Test across desktop, mobile, and custom viewport sizes.\"\n    },\n    \"diffs\": {\n      \"title\": \"Diffs and Comments\",\n      \"content\": \"The redesigned diffs panel includes a file tree of your changes. Comment directly on diffs to give feedback to the agent, and view GitHub comments when your workspace is linked to a PR.\"\n    },\n    \"classicUi\": {\n      \"title\": \"Return to Classic UI\",\n      \"content\": \"Click the exit icon on the left side of the navbar to return to the classic kanban board. To disable the new UI entirely, update the \\\"Enable Workspaces Beta\\\" option under \\\"Beta Features\\\" in settings.\"\n    }\n  },\n  \"logs\": {\n    \"searchLogs\": \"Search logs\",\n    \"selectProcessToView\": \"Select a process to view logs\"\n  },\n  \"processes\": {\n    \"noProcesses\": \"No processes\",\n    \"terminal\": \"Terminal\"\n  },\n  \"search\": {\n    \"matchCount\": \"{{current}} of {{total}}\",\n    \"noMatches\": \"No matches\"\n  },\n  \"contextUsage\": {\n    \"label\": \"Context usage\",\n    \"emptyTooltip\": \"Context usage appears after the next reply\",\n    \"tooltip\": \"Context: {{percentage}}% · {{used}} / {{total}} tokens\",\n    \"ariaLabel\": \"Context usage: {{percentage}}%\"\n  },\n  \"shortcuts\": {\n    \"title\": \"Keyboard Shortcuts\",\n    \"inWorkspace\": \"(in workspace)\",\n    \"sequentialHint\": \"Sequential shortcuts: Press the first key, then the second within 500ms.\",\n    \"configurableHint\": \"Configurable in Settings → General → Message Input\",\n    \"groups\": {\n      \"quickActions\": \"Quick Actions\",\n      \"navigation\": \"Navigation\",\n      \"modifiers\": \"Modifiers\",\n      \"goTo\": \"Go To (G ...)\",\n      \"workspace\": \"Workspace (W ...)\",\n      \"view\": \"View (V ...)\",\n      \"issues\": \"Issues (I ...)\",\n      \"git\": \"Git (X ...)\",\n      \"yank\": \"Yank (Y ...)\",\n      \"toggle\": \"Toggle (T ...)\",\n      \"run\": \"Run (R ...)\"\n    },\n    \"actions\": {\n      \"showHelp\": \"Show this help\",\n      \"closeCancel\": \"Close/cancel\",\n      \"createNewTask\": \"Create new task\",\n      \"deleteSelected\": \"Delete selected\",\n      \"focusSearch\": \"Focus search\",\n      \"moveDown\": \"Move down\",\n      \"moveUp\": \"Move up\",\n      \"moveLeft\": \"Move left\",\n      \"moveRight\": \"Move right\",\n      \"openCommandBar\": \"Open command bar\",\n      \"formatInlineCode\": \"Format inline code\",\n      \"sendMessage\": \"Send message\",\n      \"settings\": \"Go to Settings\",\n      \"new-workspace\": \"Go to New Workspace\",\n      \"duplicate-workspace\": \"Duplicate workspace\",\n      \"rename-workspace\": \"Rename workspace\",\n      \"pin-workspace\": \"Pin/Unpin workspace\",\n      \"archive-workspace\": \"Archive workspace\",\n      \"delete-workspace\": \"Delete workspace\",\n      \"toggle-changes-mode\": \"Toggle Changes panel\",\n      \"toggle-logs-mode\": \"Toggle Logs panel\",\n      \"toggle-preview-mode\": \"Toggle Preview panel\",\n      \"toggle-left-sidebar\": \"Toggle Left Sidebar\",\n      \"toggle-left-main-panel\": \"Toggle Chat panel\",\n      \"create-issue\": \"Create Issue\",\n      \"change-issue-status\": \"Change Status\",\n      \"change-issue-priority\": \"Change Priority\",\n      \"change-assignees\": \"Change Assignees\",\n      \"make-sub-issue-of\": \"Make Sub-issue of\",\n      \"add-sub-issue\": \"Add Sub-issue\",\n      \"remove-parent-issue\": \"Remove Parent\",\n      \"link-workspace\": \"Link Workspace\",\n      \"duplicate-issue\": \"Duplicate Issue\",\n      \"delete-issue\": \"Delete Issue\",\n      \"git-create-pr\": \"Create Pull Request\",\n      \"git-merge\": \"Merge branch\",\n      \"git-rebase\": \"Rebase branch\",\n      \"git-push\": \"Push changes\",\n      \"copy-path\": \"Copy path\",\n      \"copy-raw-logs\": \"Copy raw logs\",\n      \"toggle-dev-server\": \"Toggle dev server\",\n      \"toggle-wrap-lines\": \"Toggle line wrapping\",\n      \"run-setup-script\": \"Run setup script\",\n      \"run-cleanup-script\": \"Run cleanup script\",\n      \"run-archive-script\": \"Run archive script\"\n    }\n  },\n  \"typeahead\": {\n    \"tags\": \"Tags\",\n    \"files\": \"Files\",\n    \"commands\": \"Commands\",\n    \"chooseRepo\": \"Choose repo\",\n    \"selectedRepo\": \"Selected repo: {{repoName}}\",\n    \"missingRepo\": \"Selected repo is no longer available.\",\n    \"noTagsOrFiles\": \"No tags or files found\",\n    \"createTag\": \"Create new tag\",\n    \"noCommands\": \"No commands available for this agent.\"\n  },\n  \"kanban\": {\n    \"noVisibleStatuses\": \"All statuses are hidden. Use display settings or switch to another tab to view issues.\",\n    \"noProjectFound\": \"No project found\",\n    \"unassigned\": \"Unassigned\",\n    \"noTagsAvailable\": \"No tags available\",\n    \"createNewIssue\": \"Create new issue\",\n    \"searchTags\": \"Search tags...\",\n    \"selectColorFor\": \"Select color for\",\n    \"createTag\": \"Create\",\n    \"noPrCreated\": \"No PR created\",\n    \"noCommentsYet\": \"No comments yet\",\n    \"createdBy\": \"Created by\",\n    \"comments\": \"Comments\",\n    \"enterCommentPlaceholder\": \"Enter your comment here...\",\n    \"attachFile\": \"Attach file\",\n    \"attachFileHint\": \"Attach files (max 20MB each)\",\n    \"dropFilesHere\": \"Drop files here\",\n    \"fileDropHint\": \"Any file type up to 20MB\",\n    \"unknownUser\": \"Unknown User\",\n    \"deletedUser\": \"Deleted User\",\n    \"replyQuotePrefix\": \"wrote:\",\n    \"moreActions\": \"More actions\",\n    \"closePanel\": \"Close panel\",\n    \"copyLink\": \"Copy link\",\n    \"issueTitlePlaceholder\": \"Issue Title...\",\n    \"issueDescriptionPlaceholder\": \"Enter task description here...\",\n    \"createDraftWorkspaceImmediately\": \"Create draft workspace immediately\",\n    \"createDraftWorkspaceDescription\": \"After creating the issue, open the workspace creation form pre-filled with the issue details\",\n    \"createIssue\": \"Create Issue\",\n    \"newIssue\": \"New Issue\",\n    \"previewCodeBlock\": \"[Code block]\",\n    \"previewImage\": \"[Image]\",\n    \"previewImageWithName\": \"[Image: {{name}}]\",\n    \"previewFile\": \"[File]\",\n    \"previewFileWithName\": \"[File: {{name}}]\",\n    \"imageAttachmentNameFallback\": \"Attachment\",\n    \"removeImage\": \"Remove image\",\n    \"maxFilesAtOnce\": \"Maximum {{count}} files at once\",\n    \"fileExceedsLimit\": \"File {{filename}} exceeds 20MB limit\",\n    \"unknownError\": \"Unknown error\",\n    \"failedToUploadFile\": \"Failed to upload {{filename}}: {{message}}\",\n    \"downloadAttachment\": \"Download attachment\",\n    \"subIssues\": \"Sub-issues\",\n    \"noSubIssues\": \"No sub-issues\",\n    \"markIndependentIssue\": \"Mark as independent issue\",\n    \"parentIssue\": \"Parent\",\n    \"subIssueIndicator\": \"↳\",\n    \"viewTabs\": {\n      \"active\": \"Active\",\n      \"all\": \"All\"\n    },\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Please sign in to view this project.\",\n      \"action\": \"Sign in\"\n    },\n    \"team\": \"Team\",\n    \"personal\": \"Personal\",\n    \"searchPlaceholder\": \"Search issues...\",\n    \"filters\": \"Filters\",\n    \"clearFilters\": \"Clear filters\",\n    \"filtersDescription\": \"Adjust filters and sorting for this board view.\",\n    \"filterByTag\": \"Filter by tag\",\n    \"filterByPriority\": \"Filter by priority\",\n    \"tags\": \"Tags\",\n    \"sortBy\": \"Sort\",\n    \"workspaceSidebar\": {\n      \"sortDialogTitle\": \"Sort workspaces\",\n      \"sortDialogDescription\": \"Choose how workspaces are ordered in the sidebar.\",\n      \"sortByLabel\": \"Sort by\",\n      \"sortOrderLabel\": \"Sort order\",\n      \"sortUpdatedAt\": \"Updated at\",\n      \"sortCreatedAt\": \"Created at\",\n      \"filterDialogTitle\": \"Filter workspaces\",\n      \"filterDialogDescription\": \"Narrow down workspaces by project and PR state.\",\n      \"projectFilterLabel\": \"Project\",\n      \"prFilterLabel\": \"PR\",\n      \"prFilterAll\": \"All\",\n      \"prFilterHasPr\": \"Has PR\",\n      \"prFilterNoPr\": \"No PR\",\n      \"noProject\": \"No project\",\n      \"sortButtonTitle\": \"Sort workspaces\",\n      \"filterButtonTitle\": \"Filter workspaces\"\n    },\n    \"sortAscending\": \"Ascending\",\n    \"sortDescending\": \"Descending\",\n    \"assignee\": \"Assignee\",\n    \"self\": \"Me\",\n    \"priority\": \"Priority\",\n    \"subIssuesFilterLabel\": \"Sub-issues\",\n    \"workspacesFilterLabel\": \"Workspaces\",\n    \"selectAssignees\": \"Select assignees...\",\n    \"relationships\": \"Relationships\",\n    \"noRelationships\": \"No relationships\",\n    \"openProjectsGuide\": \"Projects guide\",\n    \"editProjectSettings\": \"Edit project settings\",\n    \"linkWorkspace\": \"Link workspace...\",\n    \"createNewWorkspace\": \"Create new workspace\",\n    \"workspaces\": \"Workspaces\",\n    \"showingWorkspaces\": \"Showing {{count}} of {{total}}\",\n    \"changeColor\": \"Change color\",\n    \"editName\": \"Edit name\",\n    \"deleteStatus\": \"Delete status\",\n    \"cannotDeleteWithIssues\": \"Move issues first\",\n    \"lastVisibleStatus\": \"At least one status must be visible\",\n    \"showStatus\": \"Show status\",\n    \"hideStatus\": \"Hide status\",\n    \"newStatus\": \"New Status\",\n    \"visibleColumns\": \"Visible Columns\",\n    \"dragToRearrange\": \"Drag to re-arrange\",\n    \"addColumn\": \"Add column\",\n    \"projectsGuide\": {\n      \"intro\": {\n        \"title\": \"Welcome\",\n        \"content\": \"Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.\"\n      },\n      \"welcome\": {\n        \"title\": \"Projects\",\n        \"content\": \"The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.\"\n      },\n      \"issues\": {\n        \"title\": \"Issues\",\n        \"content\": \"Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.\"\n      },\n      \"workspaces\": {\n        \"title\": \"Workspaces\",\n        \"content\": \"To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.\"\n      },\n      \"organizations\": {\n        \"title\": \"Invite your team\",\n        \"content\": \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      }\n    }\n  },\n  \"createMode\": {\n    \"headings\": {\n      \"repoStep\": \"Which repositories would you like to work on?\",\n      \"chatStep\": \"What would you like to work on?\"\n    },\n    \"repoPicker\": {\n      \"actions\": {\n        \"recent\": \"Recent\",\n        \"browse\": \"Browse\",\n        \"create\": \"Create\"\n      },\n      \"setupHintTitle\": \"Setup Scripts\",\n      \"setupHint\": \"Tip: Configure setup scripts for this repository in Settings → Repositories\",\n      \"setupHintDismiss\": \"Dismiss\",\n      \"setupHintLink\": \"Configure in Settings\"\n    }\n  },\n  \"modelSelector\": {\n    \"preset\": \"Preset\",\n    \"custom\": \"Custom\",\n    \"permissions\": \"Permissions\",\n    \"permissionAuto\": \"Auto\",\n    \"permissionAsk\": \"Ask\",\n    \"permissionPlan\": \"Plan\",\n    \"agent\": \"Agent\",\n    \"default\": \"Default\",\n    \"model\": \"Model\"\n  },\n  \"syncError\": {\n    \"networkErrors\": \"Network Errors\",\n    \"streamsAffected\": \"{{count}} stream affected\",\n    \"streamsAffected_other\": \"{{count}} streams affected\",\n    \"status\": \"(status {{status}})\",\n    \"refreshPage\": \"Refresh Page\"\n  },\n  \"migration\": {\n    \"allProjectsMigrated\": \"All your projects have already been migrated to the cloud.\",\n    \"continueToProjects\": \"Continue to Projects\"\n  },\n  \"appBar\": {\n    \"kanban\": {\n      \"tooltip\": \"Kanban\",\n      \"title\": \"Kanban Boards\",\n      \"description\": \"Sign in to organise your coding agents with kanban boards.\",\n      \"migrateOldProjects\": \"Migrate old projects\"\n    }\n  },\n  \"askQuestion\": {\n    \"title\": \"Agent is asking a question\",\n    \"selectMultiple\": \"select multiple\",\n    \"confirmSelection\": \"Confirm selection\",\n    \"submitting\": \"Submitting answers...\",\n    \"answeredCount\": \"Answered {{count}} question\",\n    \"answeredCount_other\": \"Answered {{count}} questions\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/en/organization.json",
    "content": "{\n  \"createDialog\": {\n    \"title\": \"Create New Organization\",\n    \"description\": \"Create a new organization to collaborate with your team.\",\n    \"nameLabel\": \"Organization Name\",\n    \"namePlaceholder\": \"e.g., Acme Corporation\",\n    \"slugLabel\": \"Slug\",\n    \"slugPlaceholder\": \"e.g., acme-corporation\",\n    \"slugHelper\": \"Used in URLs. Lowercase letters, numbers, and hyphens only.\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Organization\"\n  },\n  \"inviteDialog\": {\n    \"title\": \"Invite Member\",\n    \"description\": \"Send an invitation to join your organization.\",\n    \"emailLabel\": \"Email Address\",\n    \"emailPlaceholder\": \"colleague@example.com\",\n    \"roleLabel\": \"Role\",\n    \"rolePlaceholder\": \"Select a role\",\n    \"roleHelper\": \"Admins can manage members and organization settings.\",\n    \"sending\": \"Sending...\",\n    \"sendButton\": \"Send Invitation\",\n    \"subscriptionRequired\": \"Subscription required to add more members\",\n    \"upgradePrompt\": \"Upgrade your organization's plan to invite additional members.\",\n    \"upgradeButton\": \"Upgrade Plan\"\n  },\n  \"roles\": {\n    \"member\": \"Member\",\n    \"admin\": \"Admin\"\n  },\n  \"memberList\": {\n    \"title\": \"Members\",\n    \"description\": \"Manage members and their roles in {{orgName}}\",\n    \"inviteButton\": \"Invite Member\",\n    \"loading\": \"Loading members...\",\n    \"none\": \"No members found\",\n    \"you\": \"You\"\n  },\n  \"invitationList\": {\n    \"title\": \"Pending Invitations\",\n    \"description\": \"View pending invitations for {{orgName}}\",\n    \"loading\": \"Loading invitations...\",\n    \"invited\": \"Invited {{date}}\",\n    \"pending\": \"Pending\"\n  },\n  \"settings\": {\n    \"title\": \"Organization Settings\",\n    \"description\": \"Manage organization members and permissions\",\n    \"selectLabel\": \"Select Organization\",\n    \"selectPlaceholder\": \"Select an organization\",\n    \"selectHelper\": \"Choose an organization to view and manage its members\",\n    \"noOrganizations\": \"No organizations available\",\n    \"loadingOrganizations\": \"Loading organizations...\",\n    \"loadError\": \"Failed to load organizations\",\n    \"dangerZone\": \"Danger Zone\",\n    \"dangerZoneDescription\": \"Irreversible and destructive actions\",\n    \"deleteOrganization\": \"Delete Organization\",\n    \"deleteOrganizationDescription\": \"Permanently delete this organization and all its data\",\n    \"confirmDelete\": \"Are you sure you want to delete {{orgName}}? This action cannot be undone.\",\n    \"deleteSuccess\": \"Organization deleted successfully\",\n    \"deleteError\": \"Failed to delete organization\"\n  },\n  \"loginRequired\": {\n    \"title\": \"Sign in required\",\n    \"description\": \"You need to sign in to manage organization settings.\",\n    \"action\": \"Sign in\"\n  },\n  \"confirmRemoveMember\": \"Are you sure you want to remove this member from the organization?\",\n  \"billing\": {\n    \"title\": \"Billing & Subscription\",\n    \"description\": \"Manage your organization's subscription and billing settings\",\n    \"manageButton\": \"Manage Billing\",\n    \"openInBrowser\": \"Opens in a new browser tab\"\n  },\n  \"personalOrg\": {\n    \"cannotInvite\": \"Personal organizations cannot have additional members.\",\n    \"createOrgPrompt\": \"Create an organization to collaborate with others.\",\n    \"createOrgButton\": \"Create Organization\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/en/projects.json",
    "content": "{\n  \"createProjectDialog\": {\n    \"title\": \"Create Project\",\n    \"description\": \"Create a new project in this organization.\",\n    \"nameLabel\": \"Project name\",\n    \"namePlaceholder\": \"Enter project name\",\n    \"selectColor\": \"Select project color\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Project\"\n  },\n  \"deleteProjectDialog\": {\n    \"title\": \"Delete Project?\",\n    \"description\": \"This will permanently delete \\\"{{name}}\\\" and all its issues. This action cannot be undone.\",\n    \"error\": \"Failed to delete project. Please try again.\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/en/settings.json",
    "content": "{\n  \"settings\": {\n    \"common\": {\n      \"unsavedChanges\": \"• You have unsaved changes\",\n      \"discardChangesConfirm\": \"Discard unsaved changes?\"\n    },\n    \"unsavedChanges\": {\n      \"title\": \"Unsaved Changes\",\n      \"message\": \"You have unsaved changes. Are you sure you want to close without saving?\",\n      \"discard\": \"Discard Changes\",\n      \"cancel\": \"Keep Editing\"\n    },\n    \"layout\": {\n      \"nav\": {\n        \"title\": \"Settings\",\n        \"general\": \"General\",\n        \"generalDesc\": \"Theme, notifications, and preferences\",\n        \"projects\": \"Projects\",\n        \"projectsDesc\": \"Project repositories and configuration\",\n        \"repos\": \"Repositories\",\n        \"reposDesc\": \"Repository scripts and configuration\",\n        \"agents\": \"Agents\",\n        \"agentsDesc\": \"Coding agent configurations\",\n        \"mcp\": \"MCP Servers\",\n        \"mcpDesc\": \"Model Context Protocol servers\",\n        \"organizations\": \"Organization Settings\",\n        \"organizationsDesc\": \"Manage organization members and permissions\",\n        \"remote-projects\": \"Projects\",\n        \"remote-projectsDesc\": \"Manage projects\",\n        \"relay\": \"Remote Access\",\n        \"relayDesc\": \"Relay tunnel and enrollment\"\n      }\n    },\n    \"general\": {\n      \"loading\": \"Loading settings...\",\n      \"loadError\": \"Failed to load configuration.\",\n      \"save\": {\n        \"button\": \"Save Settings\",\n        \"success\": \"✓ Settings saved successfully!\",\n        \"error\": \"Failed to save configuration\",\n        \"unsavedChanges\": \"• You have unsaved changes\",\n        \"discard\": \"Discard\"\n      },\n      \"appearance\": {\n        \"title\": \"Appearance\",\n        \"description\": \"Customize how the application looks and feels.\",\n        \"theme\": {\n          \"label\": \"Theme\",\n          \"placeholder\": \"Select theme\",\n          \"helper\": \"Choose your preferred color scheme.\"\n        },\n        \"language\": {\n          \"label\": \"Language\",\n          \"placeholder\": \"Select language\",\n          \"helper\": \"Choose your preferred language. Browser Default follows your system language.\"\n        }\n      },\n      \"taskExecution\": {\n        \"title\": \"Default Coding Agent\",\n        \"description\": \"Choose the default coding agent for tasks.\",\n        \"executor\": {\n          \"label\": \"Default Agent Configuration\",\n          \"placeholder\": \"Select profile\",\n          \"helper\": \"Choose the default agent configuration to use when creating a workspace.\"\n        },\n        \"variant\": \"DEFAULT\",\n        \"defaultLabel\": \"Default\"\n      },\n      \"editor\": {\n        \"title\": \"Editor\",\n        \"description\": \"Configure your code editing experience.\",\n        \"type\": {\n          \"label\": \"Editor Type\",\n          \"placeholder\": \"Select editor\",\n          \"helper\": \"Choose your preferred code editor interface.\"\n        },\n        \"customCommand\": {\n          \"label\": \"Custom Editor Command\",\n          \"placeholder\": \"e.g., code, subl, vim\",\n          \"helper\": \"Enter the command to launch your custom editor. This will be used to open files.\"\n        },\n        \"remoteSsh\": {\n          \"host\": {\n            \"label\": \"Remote SSH Host (Optional)\",\n            \"placeholder\": \"e.g., hostname or IP address\",\n            \"helper\": \"Set this if Vibe Kanban is running on a remote server. When set, clicking \\\"Open in Editor\\\" will generate a URL to open your editor via SSH instead of spawning a local command.\"\n          },\n          \"user\": {\n            \"label\": \"Remote SSH User (Optional)\",\n            \"placeholder\": \"e.g., username\",\n            \"helper\": \"SSH username for the remote connection. If not set, VS Code will use your SSH config or prompt you.\"\n          }\n        },\n        \"autoInstallExtension\": {\n          \"label\": \"Auto-install VS Code Extension\",\n          \"helper\": \"Automatically install the Vibe Kanban extension when opening a project in VS Code or Cursor. This runs in the background and has no effect if the extension is already installed.\"\n        },\n        \"availability\": {\n          \"checking\": \"Checking availability...\",\n          \"available\": \"Available\",\n          \"notFound\": \"Not found in PATH\"\n        }\n      },\n      \"github\": {\n        \"title\": \"GitHub Integration\",\n        \"cliSetup\": {\n          \"title\": \"GitHub CLI Setup\",\n          \"description\": \"GitHub CLI authentication is required to create pull requests and interact with GitHub repositories.\",\n          \"setupWillTitle\": \"This setup will:\",\n          \"steps\": {\n            \"checkInstalled\": \"Check if GitHub CLI (gh) is installed\",\n            \"installHomebrew\": \"Install it via Homebrew if needed (macOS)\",\n            \"authenticate\": \"Authenticate with GitHub using OAuth\"\n          },\n          \"setupNote\": \"The setup will run in the chat window. You'll need to complete the authentication in your browser.\",\n          \"runSetup\": \"Run Setup\",\n          \"running\": \"Running...\",\n          \"errors\": {\n            \"brewMissing\": \"Homebrew is not installed. Install it to enable automatic setup.\",\n            \"notSupported\": \"Automatic setup is not supported on this platform. Install GitHub CLI manually.\",\n            \"setupFailed\": \"Failed to run GitHub CLI setup.\"\n          },\n          \"help\": {\n            \"homebrew\": {\n              \"description\": \"Automatic installation requires Homebrew. Install Homebrew from\",\n              \"brewSh\": \"brew.sh\",\n              \"manualInstall\": \"and then rerun the setup. Alternatively, install GitHub CLI manually with:\",\n              \"afterInstall\": \"After installation, authenticate with:\"\n            },\n            \"manual\": {\n              \"description\": \"Install GitHub CLI from the\",\n              \"officialDocs\": \"official documentation\",\n              \"andAuthenticate\": \"and then authenticate with your GitHub account.\"\n            }\n          }\n        }\n      },\n      \"git\": {\n        \"title\": \"Git\",\n        \"description\": \"Configure git branch naming preferences\",\n        \"branchPrefix\": {\n          \"label\": \"Branch Prefix\",\n          \"placeholder\": \"vk\",\n          \"helper\": \"Prefix for auto-generated branch names. Leave empty for no prefix.\",\n          \"preview\": \"Preview:\",\n          \"previewWithPrefix\": \"{{prefix}}/1a2b-task-name\",\n          \"previewNoPrefix\": \"1a2b-task-name\",\n          \"errors\": {\n            \"slash\": \"Prefix cannot contain '/'.\",\n            \"startsWithDot\": \"Prefix cannot start with '.'.\",\n            \"endsWithDot\": \"Prefix cannot end with '.' or '.lock'.\",\n            \"invalidSequence\": \"Contains invalid sequence (.., @{).\",\n            \"invalidChars\": \"Contains invalid characters.\",\n            \"controlChars\": \"Contains control characters.\"\n          }\n        },\n        \"workspaceDir\": {\n          \"label\": \"Workspace Directory\",\n          \"placeholder\": \"~/\",\n          \"helper\": \"Workspaces will be created in a .vibe-kanban-workspaces subdirectory within this path. Leave empty to use the system default. Changes require an app restart.\",\n          \"browse\": \"Browse\",\n          \"dialogTitle\": \"Select Workspace Directory\",\n          \"dialogDescription\": \"Choose a directory. Workspaces will be created in a .vibe-kanban-workspaces subdirectory within it.\"\n        }\n      },\n      \"pullRequests\": {\n        \"title\": \"Pull Requests\",\n        \"description\": \"Configure PR creation behavior\",\n        \"autoDescription\": {\n          \"label\": \"Auto-generate PR description by default\",\n          \"helper\": \"When enabled, the AI agent will automatically update the PR title and description after creation.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"Use custom prompt\",\n          \"helper\": \"Custom prompt for the AI agent when generating PR descriptions. Use {pr_number} and {pr_url} as placeholders.\"\n        }\n      },\n      \"commits\": {\n        \"title\": \"Commits\",\n        \"description\": \"Configure commit behavior for workspaces\",\n        \"reminder\": {\n          \"label\": \"Commit reminder\",\n          \"helper\": \"Prompt supported agents to commit changes before stopping.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"Use custom prompt\",\n          \"helper\": \"Custom prompt for the commit reminder. The git status will be appended automatically.\"\n        }\n      },\n      \"notifications\": {\n        \"title\": \"Notifications\",\n        \"description\": \"Control when and how you receive notifications.\",\n        \"sound\": {\n          \"label\": \"Sound Notifications\",\n          \"helper\": \"Play a sound when workspaces finish running.\",\n          \"fileLabel\": \"Sound\",\n          \"filePlaceholder\": \"Select sound\",\n          \"fileHelper\": \"Choose the sound to play when tasks complete. Click the volume button to preview.\"\n        },\n        \"push\": {\n          \"label\": \"Push Notifications\",\n          \"helper\": \"Show system notifications when workspaces finish running.\"\n        }\n      },\n      \"messageInput\": {\n        \"title\": \"Message Input\",\n        \"description\": \"Configure how messages are sent in the chat input.\",\n        \"shortcut\": {\n          \"label\": \"Send message with\",\n          \"helper\": \"Choose the keyboard shortcut to send messages.\",\n          \"enterLabel\": \"Enter (Shift+Enter for new line)\"\n        }\n      },\n      \"privacy\": {\n        \"title\": \"Privacy\",\n        \"description\": \"Help improve Vibe-Kanban by sharing anonymous usage data.\",\n        \"telemetry\": {\n          \"label\": \"Enable Telemetry\",\n          \"helper\": \"Enables anonymous usage events tracking to help improve the application. No prompts or project information are collected.\"\n        }\n      },\n      \"taskTemplates\": {\n        \"title\": \"Tags\",\n        \"description\": \"Create reusable text snippets that can be inserted into task descriptions using @tag_name.\"\n      },\n      \"tags\": {\n        \"manager\": {\n          \"title\": \"Task Tags\",\n          \"addTag\": \"Add Tag\",\n          \"noTags\": \"No tags yet. Create reusable text snippets for common task descriptions. Use @tag_name in any task.\",\n          \"table\": {\n            \"tagName\": \"Tag Name\",\n            \"content\": \"Content\",\n            \"actions\": \"Actions\"\n          },\n          \"actions\": {\n            \"editTag\": \"Edit tag\",\n            \"deleteTag\": \"Delete tag\"\n          },\n          \"deleteConfirm\": \"Are you sure you want to delete the tag \\\"{{tagName}}\\\"?\"\n        },\n        \"dialog\": {\n          \"createTitle\": \"Create Tag\",\n          \"editTitle\": \"Edit Tag\",\n          \"tagName\": {\n            \"label\": \"Tag Name\",\n            \"required\": \"*\",\n            \"hint\": \"Use this name with @ in task descriptions: @{{tagName}}\",\n            \"placeholder\": \"e.g., bug_fix, test_plan, api_docs\",\n            \"error\": \"Tag name cannot contain spaces. Use underscores instead (e.g., my_tag)\"\n          },\n          \"content\": {\n            \"label\": \"Content\",\n            \"required\": \"*\",\n            \"hint\": \"Text that will be inserted when you use @{{tagName}} in task descriptions\",\n            \"placeholder\": \"Enter the text that will be inserted when you use this tag\"\n          },\n          \"errors\": {\n            \"nameRequired\": \"Tag name is required\",\n            \"saveFailed\": \"Failed to save tag\"\n          },\n          \"buttons\": {\n            \"cancel\": \"Cancel\",\n            \"create\": \"Create\",\n            \"update\": \"Update\"\n          }\n        }\n      },\n      \"safety\": {\n        \"title\": \"Safety & Disclaimers\",\n        \"description\": \"Reset acknowledgments for safety warnings and onboarding.\",\n        \"disclaimer\": {\n          \"title\": \"Disclaimer Acknowledgment\",\n          \"description\": \"Reset the safety disclaimer.\",\n          \"button\": \"Reset\"\n        },\n        \"onboarding\": {\n          \"title\": \"Onboarding\",\n          \"description\": \"Reset the onboarding flow.\",\n          \"button\": \"Reset\"\n        }\n      },\n      \"beta\": {\n        \"title\": \"Beta Features\",\n        \"description\": \"Try out experimental features before they're released.\",\n        \"workspaces\": {\n          \"label\": \"Enable Workspaces Beta\",\n          \"helper\": \"Use the new workspaces interface when viewing workspaces. Tasks will open in task view first, and attempts will open in the new workspaces view.\"\n        }\n      }\n    },\n    \"agents\": {\n      \"title\": \"Coding Agent Configurations\",\n      \"description\": \"Customize the behavior of coding agents with different configurations.\",\n      \"loading\": \"Loading agent configurations...\",\n      \"selectAgent\": \"Select agent\",\n      \"save\": {\n        \"button\": \"Save Agent Configurations\",\n        \"success\": \"✓ Executor configurations saved successfully!\",\n        \"unsavedChanges\": \"• You have unsaved changes\"\n      },\n      \"availability\": {\n        \"checking\": \"Checking...\",\n        \"checkingAvailability\": \"Checking availability...\",\n        \"available\": \"Agent available\",\n        \"notFoundSimple\": \"Agent not found\",\n        \"loginDetected\": \"Recent Usage Detected\",\n        \"loginDetectedTooltip\": \"Recent authentication credentials found for this agent\",\n        \"installationFound\": \"Previous Usage Detected\",\n        \"installationFoundTooltip\": \"Agent configuration found. You may need to log in to use it.\"\n      },\n      \"editor\": {\n        \"formLabel\": \"Edit JSON\",\n        \"agentLabel\": \"Agent\",\n        \"agentPlaceholder\": \"Select executor type\",\n        \"configLabel\": \"Configuration\",\n        \"configPlaceholder\": \"Select configuration\",\n        \"createNew\": \"Create new...\",\n        \"deleteTitle\": \"Cannot delete the last configuration\",\n        \"deleteButton\": \"Delete {{name}}\",\n        \"deleteText\": \"Delete\",\n        \"makeDefault\": \"Make Default\",\n        \"isDefault\": \"Default\",\n        \"jsonLabel\": \"Agent Configuration (JSON)\",\n        \"jsonPlaceholder\": \"Loading profiles...\",\n        \"jsonLoading\": \"Loading...\",\n        \"pathLabel\": \"Configuration file location:\"\n      },\n      \"errors\": {\n        \"deleteFailed\": \"Failed to delete configuration. Please try again.\",\n        \"saveFailed\": \"Failed to save agent configurations. Please try again.\",\n        \"saveConfigFailed\": \"Failed to save configuration. Please try again.\",\n        \"schemaNotFound\": \"Schema not found for executor type: {{executor}}\"\n      },\n      \"tree\": {\n        \"search\": \"Search configurations...\",\n        \"expandAll\": \"Expand All\",\n        \"collapseAll\": \"Collapse All\",\n        \"noResults\": \"No matching configurations\",\n        \"noConfigs\": \"No configurations available\",\n        \"selectConfig\": \"Select a configuration from the sidebar to view and edit its settings.\"\n      },\n      \"deleteConfigDialog\": {\n        \"title\": \"Delete Configuration?\",\n        \"description\": \"This will permanently remove \\\"{{configName}}\\\" from the {{executorType}} executor. You can't undo this action.\"\n      }\n    },\n    \"mcp\": {\n      \"title\": \"MCP Server Configuration\",\n      \"description\": \"Configure Model Context Protocol servers to extend coding agent capabilities with custom tools and resources.\",\n      \"loading\": \"Loading MCP configuration...\",\n      \"applying\": \"Applying configuration...\",\n      \"labels\": {\n        \"agent\": \"Agent\",\n        \"agentPlaceholder\": \"Select executor\",\n        \"agentHelper\": \"Choose which agent to configure MCP servers for.\",\n        \"serverConfig\": \"Server Configuration (JSON)\",\n        \"popularServers\": \"Popular servers\",\n        \"serverHelper\": \"Click a card to insert that MCP Server into the JSON above.\",\n        \"saveLocation\": \"Changes will be saved to:\"\n      },\n      \"loadingStates\": {\n        \"jsonEditor\": \"Loading...\",\n        \"configuration\": \"Loading current MCP server configuration...\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Failed to load configuration.\",\n        \"invalidJson\": \"Invalid JSON format\",\n        \"validationError\": \"Validation error\",\n        \"saveFailed\": \"Failed to save MCP servers\",\n        \"applyFailed\": \"Failed to apply MCP server configuration\",\n        \"addServerFailed\": \"Failed to add preconfigured server\",\n        \"mcpError\": \"MCP Configuration Error: {{error}}\",\n        \"notSupported\": \"MCP Not Supported\",\n        \"supportMessage\": \"To use MCP servers, please select a different executor that supports MCP (Claude, Amp, Gemini, Codex, or Opencode) above.\"\n      },\n      \"save\": {\n        \"button\": \"Save MCP Configuration\",\n        \"success\": \"Settings Saved!\",\n        \"successMessage\": \"✓ MCP configuration saved successfully!\",\n        \"loading\": \"Loading current MCP server configuration...\",\n        \"unsavedChanges\": \"• You have unsaved changes\"\n      }\n    },\n    \"projects\": {\n      \"title\": \"Project Configuration\",\n      \"description\": \"Configure project-specific scripts and settings.\",\n      \"loading\": \"Loading projects...\",\n      \"loadError\": \"Failed to load projects.\",\n      \"selector\": {\n        \"label\": \"Select Project\",\n        \"placeholder\": \"Choose a project to configure\",\n        \"helper\": \"Select a project to view and edit its configuration.\",\n        \"noProjects\": \"No projects available\"\n      },\n      \"general\": {\n        \"title\": \"General Settings\",\n        \"description\": \"Configure basic project information.\",\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\",\n          \"helper\": \"A display name for this project.\"\n        },\n        \"repoPath\": {\n          \"label\": \"Git Repository Path\",\n          \"placeholder\": \"/path/to/your/existing/repo\",\n          \"helper\": \"The absolute path to your git repository on disk.\"\n        }\n      },\n      \"save\": {\n        \"button\": \"Save Project Settings\",\n        \"saving\": \"Saving...\",\n        \"success\": \"✓ Project settings saved successfully!\",\n        \"error\": \"Failed to save project settings\",\n        \"unsavedChanges\": \"• You have unsaved changes\",\n        \"discard\": \"Discard\",\n        \"confirmSwitch\": \"You have unsaved changes. Are you sure you want to switch projects? Your changes will be lost.\"\n      },\n      \"repositories\": {\n        \"title\": \"Repositories\",\n        \"description\": \"Manage the git repositories in this project\",\n        \"noRepositories\": \"No repositories configured\",\n        \"addRepository\": \"Add Repository\"\n      }\n    },\n    \"repos\": {\n      \"title\": \"Repository Configuration\",\n      \"description\": \"Configure scripts that run when this repository is used in workspaces.\",\n      \"loading\": \"Loading repositories...\",\n      \"loadError\": \"Failed to load repositories.\",\n      \"addRepo\": {\n        \"button\": \"Add Repository\",\n        \"dialogTitle\": \"Select Git Repository\",\n        \"dialogDescription\": \"Choose an existing git repository to register.\",\n        \"error\": \"Failed to register repository\"\n      },\n      \"selector\": {\n        \"label\": \"Select Repository\",\n        \"placeholder\": \"Choose a repository to configure\",\n        \"helper\": \"Select a repository to view and edit its configuration.\",\n        \"noRepos\": \"No repositories available\"\n      },\n      \"general\": {\n        \"title\": \"General Settings\",\n        \"description\": \"Configure basic repository information.\",\n        \"displayName\": {\n          \"label\": \"Display Name\",\n          \"placeholder\": \"Enter display name\",\n          \"helper\": \"A friendly name for this repository.\"\n        },\n        \"path\": {\n          \"label\": \"Repository Path\"\n        },\n        \"defaultWorkingDir\": {\n          \"label\": \"Default Working Directory\",\n          \"placeholder\": \"e.g., packages/frontend\",\n          \"helper\": \"Subdirectory relative to the repository root where the coding agent runs for single-repo workspaces. Set when the workspace is created. Leave empty to use the repository root.\"\n        },\n        \"defaultTargetBranch\": {\n          \"label\": \"Default Target Branch\",\n          \"placeholder\": \"Select a branch\",\n          \"helper\": \"The default base branch for new workspaces. Worktrees branch off from this branch and PRs will target it.\",\n          \"search\": \"Search branches\",\n          \"noBranches\": \"No branches found\",\n          \"loading\": \"Loading branches...\",\n          \"useCurrent\": \"Use current branch\"\n        }\n      },\n      \"scripts\": {\n        \"title\": \"Scripts & Configuration\",\n        \"description\": \"Configure dev server, setup, cleanup, and copy files for this repository. These scripts run whenever the repository is used in any workspace.\",\n        \"setup\": {\n          \"label\": \"Setup Script\",\n          \"helper\": \"This script runs from within the worktree after it's created and before the coding agent starts. Use it for setup tasks like installing dependencies or preparing the environment.\",\n          \"parallelLabel\": \"Run setup script in parallel with coding agent\",\n          \"parallelHelper\": \"When enabled, the setup script runs simultaneously with the coding agent instead of waiting for setup to complete first.\"\n        },\n        \"cleanup\": {\n          \"label\": \"Cleanup Script\",\n          \"helper\": \"This script runs from within the worktree after coding agent execution, only if changes were made. Use it for quality assurance tasks like running linters, formatters, tests, or other validation steps.\"\n        },\n        \"archive\": {\n          \"label\": \"Archive Script\",\n          \"helper\": \"This script runs from within the worktree when the workspace is archived. Use it for cleanup tasks like stopping services, releasing resources, or saving state.\"\n        },\n        \"copyFiles\": {\n          \"label\": \"Copy Files\",\n          \"helper\": \"Comma-separated list of files to copy from the original repository directory to the worktree. Useful for environment files like .env. Make sure these are gitignored!\",\n          \"placeholder\": \"File paths or glob patterns (e.g., .env, config/*.json)\"\n        },\n        \"devServer\": {\n          \"label\": \"Dev Server Script\",\n          \"helper\": \"Starts a development server for this repository. Scripts execute from within the repository's worktree directory.\"\n        }\n      },\n      \"linkedProjects\": {\n        \"title\": \"Linked Projects\",\n        \"description\": \"Projects that use this repository in their default workspace configuration.\",\n        \"loading\": \"Checking projects…\",\n        \"none\": \"No projects linked\"\n      },\n      \"remove\": {\n        \"title\": \"Remove Repository\",\n        \"description\": \"Unlink this repository from Vibe Kanban. Files on disk will not be deleted.\",\n        \"button\": \"Remove\",\n        \"confirm\": \"Remove Repository\",\n        \"dialogTitle\": \"Remove \\\"{{name}}\\\"?\",\n        \"dialogDescription\": \"This will unlink the repository from Vibe Kanban. Your files on disk will not be affected. You can re-add it later.\",\n        \"success\": \"Repository removed successfully.\",\n        \"error\": \"Failed to remove repository.\"\n      },\n      \"save\": {\n        \"button\": \"Save Repository Settings\",\n        \"success\": \"Repository settings saved successfully!\",\n        \"error\": \"Failed to save repository settings\",\n        \"unsavedChanges\": \"You have unsaved changes\",\n        \"discard\": \"Discard\",\n        \"confirmSwitch\": \"You have unsaved changes. Are you sure you want to switch repositories? Your changes will be lost.\"\n      }\n    },\n    \"relay\": {\n      \"title\": \"Remote Access\",\n      \"description\": \"Allow access to this instance from the web via relay tunnel.\",\n      \"enabled\": {\n        \"label\": \"Enable remote access\",\n        \"helper\": \"When enabled, this instance can be accessed remotely through a relay tunnel.\"\n      },\n      \"enrollmentCode\": {\n        \"label\": \"Pairing Code\",\n        \"helper\": \"Enter this code in the browser to pair with this instance.\",\n        \"loginRequired\": \"Log in to generate a pairing code.\",\n        \"fetchError\": \"Failed to fetch pairing code.\",\n        \"show\": \"Show pairing code\"\n      }\n    },\n    \"remoteProjects\": {\n      \"title\": \"Remote Projects\",\n      \"description\": \"Manage cloud-synced projects across organizations.\",\n      \"loading\": \"Loading remote projects...\",\n      \"loadError\": \"Failed to load organizations\",\n      \"noProjects\": \"No projects yet. Create one to get started.\",\n      \"selectOrg\": \"Select an organization\",\n      \"createSuccess\": \"Project created successfully\",\n      \"deleteSuccess\": \"Project deleted successfully\",\n      \"nameRequired\": \"Project name is required\",\n      \"saveSuccess\": \"Project updated successfully\",\n      \"saveError\": \"Failed to update project\",\n      \"columns\": {\n        \"organizations\": \"Organizations\",\n        \"projects\": \"Projects\"\n      },\n      \"actions\": {\n        \"addProject\": \"Add Project\"\n      },\n      \"form\": {\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\"\n        },\n        \"color\": {\n          \"label\": \"Project Color\"\n        },\n        \"statuses\": {\n          \"label\": \"Project Statuses\",\n          \"description\": \"Manage kanban columns for this project.\"\n        },\n        \"defaultRepos\": {\n          \"label\": \"Default Repositories\",\n          \"description\": \"Repositories that are automatically added when creating workspaces from this project's issues.\",\n          \"empty\": \"No default repositories configured\",\n          \"addButton\": \"Add\",\n          \"addNew\": \"Add new repository...\",\n          \"branchLabel\": \"Branch\",\n          \"noBranches\": \"No branches found for this repository\",\n          \"fetchError\": \"Could not load repositories\",\n          \"saveError\": \"Failed to save default repositories\"\n        }\n      },\n      \"loginRequired\": {\n        \"title\": \"Sign in required\",\n        \"description\": \"Sign in to manage your remote projects.\",\n        \"action\": \"Sign in\"\n      }\n    }\n  },\n  \"integrations\": {\n    \"github\": {\n      \"cliSetup\": {\n        \"title\": \"GitHub CLI Setup\",\n        \"description\": \"GitHub CLI authentication is required to create pull requests and interact with GitHub repositories.\",\n        \"setupWillTitle\": \"This setup will:\",\n        \"steps\": {\n          \"checkInstalled\": \"Check if GitHub CLI (gh) is installed\",\n          \"installHomebrew\": \"Install it via Homebrew if needed (macOS)\",\n          \"authenticate\": \"Authenticate with GitHub using OAuth\"\n        },\n        \"setupNote\": \"The setup will run in the chat window. You'll need to complete the authentication in your browser.\",\n        \"runSetup\": \"Run Setup\",\n        \"running\": \"Running...\",\n        \"errors\": {\n          \"brewMissing\": \"Homebrew is not installed. Install it to enable automatic setup.\",\n          \"notSupported\": \"Automatic setup is not supported on this platform. Install GitHub CLI manually.\",\n          \"setupFailed\": \"Failed to run GitHub CLI setup.\"\n        },\n        \"help\": {\n          \"homebrew\": {\n            \"description\": \"Automatic installation requires Homebrew. Install Homebrew from\",\n            \"brewSh\": \"brew.sh\",\n            \"manualInstall\": \"and then rerun the setup. Alternatively, install GitHub CLI manually with:\",\n            \"afterInstall\": \"After installation, authenticate with:\"\n          },\n          \"manual\": {\n            \"description\": \"Install GitHub CLI from the\",\n            \"officialDocs\": \"official documentation\",\n            \"andAuthenticate\": \"and then authenticate with your GitHub account.\"\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/en/tasks.json",
    "content": "{\n  \"dropzone\": {\n    \"dropImagesHere\": \"Drop images here\",\n    \"supportedFormats\": \"PNG, JPG, GIF, WebP, SVG supported\"\n  },\n  \"gitPanel\": {\n    \"advanced\": {\n      \"placeholder\": \"e.g acme Corp\"\n    }\n  },\n  \"loading\": \"Loading tasks...\",\n  \"actions\": {\n    \"addTask\": \"Add task\"\n  },\n  \"rebase\": {\n    \"common\": {\n      \"action\": \"Rebase\",\n      \"inProgress\": \"Rebasing...\"\n    },\n    \"dialog\": {\n      \"title\": \"Rebase Task Attempt\",\n      \"description\": \"Choose a new base branch to rebase this workspace onto.\",\n      \"upstreamLabel\": \"Upstream Branch\",\n      \"upstreamPlaceholder\": \"Select an upstream branch\",\n      \"targetLabel\": \"Target Branch\",\n      \"targetPlaceholder\": \"Select a target branch\",\n      \"advanced\": \"Advanced\"\n    }\n  },\n  \"branches\": {\n    \"changeTarget\": {\n      \"dialog\": {\n        \"title\": \"Change target branch\",\n        \"description\": \"Choose a new target branch for the workspace.\",\n        \"placeholder\": \"Select a target branch\",\n        \"action\": \"Change Branch\",\n        \"inProgress\": \"Changing...\"\n      }\n    }\n  },\n  \"repos\": {\n    \"selector\": {\n      \"placeholder\": \"Select repository\",\n      \"empty\": \"No repositories available\"\n    }\n  },\n  \"preview\": {\n    \"noServer\": {\n      \"title\": \"No dev server running\",\n      \"setupTitle\": \"You must set up a dev server script to use the preview feature\",\n      \"setupPrompt\": \"To use the live preview and click-to-edit, please add a dev server script to this project.\",\n      \"editDevScript\": \"Edit Dev Server Script\",\n      \"learnMore\": \"Learn more about testing applications\"\n    },\n    \"logs\": {\n      \"label\": \"Logs\",\n      \"viewFull\": \"View full logs\"\n    },\n    \"toolbar\": {\n      \"refresh\": \"Refresh preview\",\n      \"copyUrl\": \"Copy URL\",\n      \"openInTab\": \"Open in new tab\",\n      \"stopDevServer\": \"Stop dev server\",\n      \"resetUrl\": \"Reset to detected URL\",\n      \"clearUrlOverride\": \"Clear URL override\",\n      \"desktopView\": \"Desktop view\",\n      \"mobileView\": \"Mobile view (390x844)\",\n      \"responsiveView\": \"Responsive view (resizable)\",\n      \"startDevServer\": \"Start dev server\",\n      \"submitUrl\": \"Submit URL\",\n      \"toggleDevTools\": \"Toggle DevTools\"\n    },\n    \"loading\": {\n      \"startingServer\": \"Starting dev server...\",\n      \"waitingForServer\": \"Waiting for server...\",\n      \"loadingPreview\": \"Loading preview...\",\n      \"manualUrlHint\": \"No URL detected yet. You can manually enter a URL in the toolbar above.\"\n    },\n    \"browser\": {\n      \"title\": \"Dev Server Preview\",\n      \"devServerFallback\": \"Dev Server\"\n    }\n  },\n  \"diff\": {\n    \"filesChanged_one\": \"{{count}} file changed\",\n    \"filesChanged_other\": \"{{count}} files changed\",\n    \"largeDiff\": {\n      \"title\": \"Large file\",\n      \"linesChanged\": \"{{count}} lines changed\",\n      \"loadAnyway\": \"Load diff anyway\",\n      \"warning\": \"Large diffs may slow down your browser.\"\n    }\n  },\n  \"processes\": {\n    \"noLogsAvailable\": \"No logs available\",\n    \"selectAttempt\": \"Select an attempt to view execution processes.\",\n    \"errorLoadingUpdates\": \"Failed to load live updates for processes.\",\n    \"reconnecting\": \"Reconnecting...\",\n    \"loading\": \"Loading execution processes...\",\n    \"noProcesses\": \"No execution processes found for this attempt.\",\n    \"processId\": \"Process ID: {{id}}\",\n    \"deleted\": \"Deleted\",\n    \"deletedTooltip\": \"Deleted by restore: timeline was restored to a checkpoint and later executions were removed\",\n    \"agent\": \"Agent:\",\n    \"exit\": \"Exit: {{code}}\",\n    \"started\": \"Started: {{date}}\",\n    \"completed\": \"Completed: {{date}}\",\n    \"detailsTitle\": \"Process Details\",\n    \"backToList\": \"Back to list\",\n    \"loadingDetails\": \"Loading process details...\",\n    \"errorLoadingDetails\": \"Failed to load process details. Please try again.\",\n    \"copyLogs\": \"Copy logs\",\n    \"logsCopied\": \"Copied!\"\n  },\n  \"followUp\": {\n    \"queuedMessage\": \"Message queued - will execute when current run finishes\"\n  },\n  \"todoPopup\": {\n    \"title\": \"Tasks\",\n    \"progress\": \"{{completed}}/{{total}} completed\",\n    \"noTasks\": \"No tasks\"\n  },\n  \"conversation\": {\n    \"executors\": \"Executors\",\n    \"saveAsDefault\": \"Save as default\",\n    \"you\": \"You\",\n    \"thinking\": \"Thinking\",\n    \"todo\": \"Todo\",\n    \"todos\": \"Todos\",\n    \"completed\": \"completed\",\n    \"incomplete\": \"incomplete\",\n    \"pending\": \"pending\",\n    \"inProgress\": \"in progress\",\n    \"skipped\": \"skipped\",\n    \"error\": \"Error\",\n    \"retry\": \"Retry\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\",\n    \"actions\": {\n      \"cancel\": \"Cancel\",\n      \"submitFeedback\": \"Submit Feedback\",\n      \"stop\": \"Stop\",\n      \"stopping\": \"Stopping\",\n      \"loading\": \"Loading\",\n      \"send\": \"Send\",\n      \"sending\": \"Sending\",\n      \"queue\": \"Queue\",\n      \"cancelQueue\": \"Cancel Queue\",\n      \"requestChanges\": \"Request Changes\",\n      \"approve\": \"Approve\",\n      \"clearReviewComments\": \"Clear review comments\",\n      \"edit\": \"Edit message\",\n      \"scrollToPreviousMessage\": \"Go to previous message\",\n      \"reset\": \"Reset\",\n      \"resetTooltip\": \"Reset to this point\"\n    },\n    \"approval\": {\n      \"conflictWarning\": \"Conflicted files need manual resolution\",\n      \"conflicts_one\": \"{{count}} conflict\",\n      \"conflicts_other\": \"{{count}} conflicts\"\n    },\n    \"sessions\": {\n      \"newSession\": \"New Session\",\n      \"latest\": \"Latest\",\n      \"previous\": \"Previous\",\n      \"label\": \"Sessions\",\n      \"noPreviousSessions\": \"No previous sessions\",\n      \"rename\": \"Rename\",\n      \"renameTitle\": \"Rename Session\",\n      \"renameDescription\": \"Enter a new name for this session.\",\n      \"renamePlaceholder\": \"Enter session name\",\n      \"renaming\": \"Renaming...\"\n    },\n    \"reviewComments\": {\n      \"count_one\": \"{{count}} review comment will be included\",\n      \"count_other\": \"{{count}} review comments will be included\"\n    },\n    \"workspace\": {\n      \"create\": \"Create\",\n      \"creating\": \"Creating...\"\n    },\n    \"fileEntry\": {\n      \"created\": \"Created\",\n      \"modified\": \"Modified\",\n      \"deleted\": \"Deleted\",\n      \"renamed\": \"Renamed\"\n    },\n    \"script\": {\n      \"running\": \"Running...\",\n      \"exitCode\": \"Exit code: {{code}}\",\n      \"completedSuccessfully\": \"Completed successfully\",\n      \"clickToViewLogs\": \"Click to view logs\"\n    },\n    \"scriptPlaceholder\": {\n      \"setupTitle\": \"Setup Script\",\n      \"setupDescription\": \"No setup script configured. Setup scripts run before the coding agent starts.\",\n      \"cleanupTitle\": \"Cleanup Script\",\n      \"cleanupDescription\": \"No cleanup script configured. Cleanup scripts run after the coding agent finishes.\",\n      \"configure\": \"Configure\"\n    },\n    \"updatedTodos\": \"Updated Todos\",\n    \"viewInChangesPanel\": \"View in changes panel\",\n    \"unableToRenderDiff\": \"Unable to render diff.\"\n  },\n  \"attempt\": {\n    \"actions\": {\n      \"startDevServer\": \"Start dev server\"\n    }\n  },\n  \"git\": {\n    \"labels\": {\n      \"taskBranch\": \"Task Branch\"\n    },\n    \"branch\": {\n      \"current\": \"current\"\n    },\n    \"forcePushDialog\": {\n      \"title\": \"Force Push Required\",\n      \"description\": \"The remote branch{{branchLabel}} has diverged from your local branch. A regular push was rejected.\",\n      \"warning\": \"Force pushing will overwrite the remote changes with your local changes. This action cannot be undone.\",\n      \"note\": \"Only proceed if you're certain you want to replace the remote branch history.\",\n      \"error\": \"Failed to force push\"\n    },\n    \"status\": {\n      \"commits_one\": \"commit\",\n      \"commits_other\": \"commits\",\n      \"conflicts\": \"Conflicts\",\n      \"upToDate\": \"Up to date\",\n      \"ahead\": \"ahead\",\n      \"behind\": \"behind\"\n    },\n    \"states\": {\n      \"merged\": \"Merged!\",\n      \"merging\": \"Merging...\",\n      \"merge\": \"Merge\",\n      \"rebasing\": \"Rebasing...\",\n      \"rebase\": \"Rebase\",\n      \"pushed\": \"Pushed!\",\n      \"pushing\": \"Pushing...\",\n      \"push\": \"Push\",\n      \"pushFailed\": \"Failed\",\n      \"forcePush\": \"Force Push\",\n      \"forcePushing\": \"Force Pushing...\",\n      \"creating\": \"Creating...\",\n      \"createPr\": \"Create PR\"\n    },\n    \"errors\": {\n      \"changeTargetBranch\": \"Failed to change target branch\",\n      \"pushChanges\": \"Failed to push changes\",\n      \"mergeChanges\": \"Failed to merge changes\",\n      \"rebaseBranch\": \"Failed to rebase branch\",\n      \"branchStatusUnavailable\": \"Unable to fetch branch status. You can still change the target branch.\"\n    },\n    \"pr\": {\n      \"open\": \"Open PR #{{number}}\",\n      \"number\": \"PR #{{number}}\",\n      \"merged\": \"Merged PR #{{prNumber}}\"\n    },\n    \"actions\": {\n      \"title\": \"Git Actions\",\n      \"changeTarget\": \"Change target\",\n      \"prMerged\": \"PR #{{number}} is already merged\",\n      \"loginRequired\": {\n        \"title\": \"Sign in to manage git actions\",\n        \"description\": \"Sign in to Vibe Kanban so you can push branches, merge changes, or open pull requests for this task.\",\n        \"action\": \"Sign in\"\n      }\n    },\n    \"mergeDialog\": {\n      \"title\": \"Merge Changes\",\n      \"description\": \"This will merge your changes into the target branch. Are you sure you want to continue?\"\n    },\n    \"createRepo\": {\n      \"dialog\": {\n        \"title\": \"Create New Repository\",\n        \"description\": \"Initialize a new git repository\"\n      },\n      \"form\": {\n        \"nameLabel\": \"Name\",\n        \"namePlaceholder\": \"my-project\",\n        \"locationLabel\": \"Location\",\n        \"locationPlaceholder\": \"Current directory\"\n      },\n      \"browseDialog\": {\n        \"title\": \"Select Parent Directory\",\n        \"description\": \"Choose where to create the new repository\"\n      },\n      \"errors\": {\n        \"nameRequired\": \"Repository name is required\",\n        \"createFailed\": \"Failed to create repository\"\n      },\n      \"buttons\": {\n        \"createRepository\": \"Create Repository\"\n      },\n      \"states\": {\n        \"creating\": \"Creating...\"\n      }\n    }\n  },\n  \"repoBranchSelector\": {\n    \"label\": \"Base branch\"\n  },\n  \"viewProcessesDialog\": {\n    \"title\": \"Execution processes\"\n  },\n  \"editBranchName\": {\n    \"dialog\": {\n      \"title\": \"Edit Branch Name\",\n      \"description\": \"Enter a new name for the branch. Cannot rename if an open PR exists.\",\n      \"branchNameLabel\": \"Branch Name\",\n      \"placeholder\": \"e.g., feature/my-branch\",\n      \"renaming\": \"Renaming...\",\n      \"action\": \"Rename Branch\"\n    }\n  },\n  \"startReviewDialog\": {\n    \"title\": \"Start Review\",\n    \"description\": \"Ask the coding agent to review your changes and provide feedback.\",\n    \"additionalInstructions\": \"Additional Instructions (optional)\",\n    \"reviewComments\": \"Review Comments ({{count}})\",\n    \"includeGitContext\": \"Include git context\",\n    \"includeGitContextDescription\": \"Tells the agent how to view all changes made on this branch\",\n    \"newSession\": \"New Session\",\n    \"sessionName\": \"Review\"\n  },\n  \"actionsMenu\": {\n    \"startReview\": \"Start Review\",\n    \"startingReview\": \"Starting Review...\"\n  },\n  \"resolveConflicts\": {\n    \"dialog\": {\n      \"title\": \"Resolve Conflicts\",\n      \"description\": \"Conflicts were detected. Choose how you want the agent to resolve them.\",\n      \"newSession\": \"Start a new session\",\n      \"resolve\": \"Resolve Conflicts\",\n      \"resolving\": \"Starting...\",\n      \"filesWithConflicts_one\": \"{{count}} file has conflicts\",\n      \"filesWithConflicts_other\": \"{{count}} files have conflicts\",\n      \"andMore\": \"...and {{count}} more\",\n      \"sessionName\": \"Resolve conflicts\"\n    }\n  },\n  \"rebaseInProgress\": {\n    \"dialog\": {\n      \"title\": \"Rebase In Progress\",\n      \"description\": \"A rebase onto {{targetBranch}} is in progress with no conflicts. Choose how to proceed.\",\n      \"hint\": \"You can continue the rebase to complete it, or abort to return to your previous state.\",\n      \"continue\": \"Continue Rebase\",\n      \"continuing\": \"Continuing...\",\n      \"abort\": \"Abort Rebase\",\n      \"aborting\": \"Aborting...\",\n      \"continueError\": \"Failed to continue rebase. There may be unresolved conflicts.\",\n      \"abortError\": \"Failed to abort rebase. Please try again.\"\n    }\n  },\n  \"createPrDialog\": {\n    \"title\": \"Create Pull Request\",\n    \"description\": \"Create a pull request for this workspace.\",\n    \"titleLabel\": \"Title\",\n    \"titlePlaceholder\": \"Enter PR title\",\n    \"descriptionLabel\": \"Description (optional)\",\n    \"descriptionPlaceholder\": \"Enter PR description\",\n    \"baseBranchLabel\": \"Base Branch\",\n    \"loadingBranches\": \"Loading branches...\",\n    \"selectBaseBranch\": \"Select base branch\",\n    \"draftLabel\": \"Create as draft\",\n    \"autoGenerateLabel\": \"Auto-generate PR description with AI\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create PR\",\n    \"errors\": {\n      \"failedToCreate\": \"Failed to create PR\",\n      \"gitCliNotLoggedIn\": \"Git is not authenticated. Run \\\"gh auth login\\\" (or configure Git credentials) and try again.\",\n      \"gitCliNotInstalled\": \"Git CLI is not installed. Install Git to create a PR.\",\n      \"targetBranchNotFound\": \"Target branch '{{branch}}' does not exist on remote. Please ensure the branch exists before creating a pull request.\"\n    }\n  },\n  \"prComments\": {\n    \"dialog\": {\n      \"title\": \"Select PR Comments\",\n      \"noComments\": \"No comments found on this PR\",\n      \"selectAll\": \"Select All\",\n      \"deselectAll\": \"Deselect All\",\n      \"add\": \"Add\",\n      \"selectedCount\": \"{{selected}} of {{total}} selected\"\n    },\n    \"card\": {\n      \"review\": \"Review\",\n      \"tooltip\": \"Click to view, double-click to edit\"\n    }\n  },\n  \"taskFormDialog\": {\n    \"createTitle\": \"Create New Task\",\n    \"editTitle\": \"Edit Task\",\n    \"titlePlaceholder\": \"Task title...\",\n    \"descriptionPlaceholder\": \"Add more details (optional). Type @ to search files.\",\n    \"statusLabel\": \"Status\",\n    \"statusOptions\": {\n      \"todo\": \"To Do\",\n      \"inprogress\": \"In Progress\",\n      \"inreview\": \"In Review\",\n      \"done\": \"Done\",\n      \"cancelled\": \"Cancelled\"\n    },\n    \"startLabel\": \"Start\",\n    \"attachFile\": \"Attach file\",\n    \"dropImagesHere\": \"Drop images here\",\n    \"updating\": \"Updating...\",\n    \"updateTask\": \"Update Task\",\n    \"starting\": \"Starting...\",\n    \"creating\": \"Creating...\",\n    \"create\": \"Create\",\n    \"discardDialog\": {\n      \"title\": \"Discard unsaved changes?\",\n      \"description\": \"You have unsaved changes. Are you sure you want to discard them?\",\n      \"continueEditing\": \"Continue Editing\",\n      \"discardChanges\": \"Discard Changes\"\n    }\n  },\n  \"restoreLogsDialog\": {\n    \"title\": \"Confirm Retry\",\n    \"titleReset\": \"Confirm Reset\",\n    \"historyChange\": {\n      \"title\": \"History change\",\n      \"willDelete\": \"Will delete this process\",\n      \"willDeleteProcesses_one\": \"Will delete {{count}} process\",\n      \"willDeleteProcesses_other\": \"Will delete {{count}} processes\",\n      \"andLaterProcesses_one\": \"and {{count}} later process\",\n      \"andLaterProcesses_other\": \"and {{count}} later processes\",\n      \"fromHistory\": \"from history.\",\n      \"codingAgentRuns_one\": \"{{count}} coding agent run\",\n      \"codingAgentRuns_other\": \"{{count}} coding agent runs\",\n      \"scriptProcesses_one\": \"{{count}} script process\",\n      \"scriptProcesses_other\": \"{{count}} script processes\",\n      \"setupCleanupBreakdown\": \"({{setup}} setup, {{cleanup}} cleanup)\",\n      \"permanentWarning\": \"This permanently alters history and cannot be undone.\"\n    },\n    \"uncommittedChanges\": {\n      \"title\": \"Uncommitted changes detected\",\n      \"description_one\": \"You have {{count}} uncommitted change\",\n      \"description_other\": \"You have {{count}} uncommitted changes\",\n      \"andUntracked_one\": \" and {{count}} untracked file\",\n      \"andUntracked_other\": \" and {{count}} untracked files\",\n      \"acknowledgeLabel\": \"I understand these changes may be affected\"\n    },\n    \"resetWorktree\": {\n      \"title\": \"Reset worktree\",\n      \"enabled\": \"Enabled\",\n      \"disabled\": \"Disabled\",\n      \"disabledUncommitted\": \"Disabled (uncommitted changes detected)\",\n      \"restoreDescription\": \"Your worktree will be restored to this commit.\",\n      \"discardChanges_one\": \"Discard {{count}} uncommitted change.\",\n      \"discardChanges_other\": \"Discard {{count}} uncommitted changes.\",\n      \"untrackedPresent_one\": \"{{count}} untracked file present (not affected by reset).\",\n      \"untrackedPresent_other\": \"{{count}} untracked files present (not affected by reset).\",\n      \"forceReset\": \"Force reset (discard uncommitted changes)\",\n      \"uncommittedWillDiscard\": \"Uncommitted changes will be discarded.\",\n      \"uncommittedPresentHint\": \"Uncommitted changes present. Turn on Force reset or commit/stash to proceed.\"\n    },\n    \"buttons\": {\n      \"retry\": \"Retry\",\n      \"reset\": \"Reset\"\n    }\n  },\n  \"scriptFixer\": {\n    \"setupScriptTitle\": \"Fix Setup Script\",\n    \"cleanupScriptTitle\": \"Fix Cleanup Script\",\n    \"archiveScriptTitle\": \"Fix Archive Script\",\n    \"devServerTitle\": \"Fix Dev Server Script\",\n    \"scriptLabel\": \"Script (edit)\",\n    \"logsLabel\": \"Last Execution Logs\",\n    \"saveButton\": \"Save\",\n    \"saveAndTestButton\": \"Save and Test\",\n    \"noLogs\": \"No execution logs available\",\n    \"selectRepo\": \"Repository\",\n    \"fixScript\": \"Fix Script\",\n    \"statusRunning\": \"Running...\",\n    \"statusSuccess\": \"Completed successfully\",\n    \"statusFailed\": \"Failed with exit code {{exitCode}}\",\n    \"statusKilled\": \"Process was killed\"\n  },\n  \"shareDialog\": {\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Sign in to access this feature.\",\n      \"action\": \"Sign in\"\n    }\n  },\n  \"createWorkspaceFromPr\": {\n    \"title\": \"Create Workspace from PR\",\n    \"description\": \"Select an open pull request to create a workspace from. A task will be created automatically using the PR title.\",\n    \"repositoryLabel\": \"Repository\",\n    \"remoteLabel\": \"Remote\",\n    \"pullRequestLabel\": \"Pull Request\",\n    \"loadingRepositories\": \"Loading repositories...\",\n    \"loadingRemotes\": \"Loading remotes...\",\n    \"noRepositoriesFound\": \"No repositories found\",\n    \"loadingPullRequests\": \"Loading pull requests...\",\n    \"selectRepositoryFirst\": \"Select a repository first\",\n    \"noPullRequestsFound\": \"No open pull requests found\",\n    \"runSetupScript\": \"Run setup script\",\n    \"creating\": \"Creating...\",\n    \"createWorkspace\": \"Create Workspace\",\n    \"selectRepository\": \"Select a repository\",\n    \"selectRemote\": \"Select a remote\",\n    \"selectPullRequest\": \"Select a pull request\",\n    \"searchPrsPlaceholder\": \"Search PRs by number or title...\",\n    \"noMatchingPrs\": \"No matching pull requests\",\n    \"default\": \"default\",\n    \"openPrInBrowser\": \"Open PR in browser\",\n    \"errors\": {\n      \"cliNotInstalled\": \"{{provider}} CLI is not installed\",\n      \"unsupportedProvider\": \"Git provider not supported\",\n      \"failedToLoadPrs\": \"Failed to load pull requests\",\n      \"prNotFound\": \"Pull request not found\",\n      \"failedToCreateWorkspace\": \"Failed to create workspace\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/es/common.json",
    "content": "{\n  \"editorNames\": {\n    \"custom\": \"Personalizado\"\n  },\n  \"folderPicker\": {\n    \"legend\": \"Haz clic en los nombres de carpetas para navegar • Usa los botones de acción para seleccionar\",\n    \"manualPathLabel\": \"Ingresar ruta manualmente:\",\n    \"go\": \"Ir\",\n    \"searchLabel\": \"Buscar en el directorio actual:\",\n    \"selectCurrent\": \"Seleccionar actual\",\n    \"gitRepo\": \"repositorio git\",\n    \"selectPath\": \"Seleccionar ruta\"\n  },\n  \"branchSelector\": {\n    \"badges\": {\n      \"current\": \"actual\",\n      \"remote\": \"remota\"\n    },\n    \"currentDisabled\": \"No se puede seleccionar la rama actual\",\n    \"empty\": \"No se encontraron ramas\",\n    \"placeholder\": \"Seleccionar una rama\",\n    \"searchPlaceholder\": \"Buscar ramas...\"\n  },\n  \"buttons\": {\n    \"cancel\": \"Cancelar\",\n    \"close\": \"Cerrar\",\n    \"connect\": \"Conectar\",\n    \"continue\": \"Continuar\",\n    \"create\": \"Crear\",\n    \"delete\": \"Eliminar\",\n    \"disconnect\": \"Desconectar\",\n    \"discard\": \"Descartar\",\n    \"edit\": \"Editar\",\n    \"manage\": \"Gestionar\",\n    \"replay\": \"Reproducir\",\n    \"reset\": \"Restablecer\",\n    \"save\": \"Guardar\",\n    \"send\": \"Enviar\",\n    \"addItem\": \"Añadir elemento\",\n    \"reply\": \"Responder\",\n    \"retry\": \"Retry\",\n    \"add\": \"Agregar\"\n  },\n  \"form\": {\n    \"notSpecified\": \"No especificado\",\n    \"selectOption\": \"Seleccionar una opción...\"\n  },\n  \"conversation\": {\n    \"deniedByUser\": \"{{toolName}} denegado por el usuario\",\n    \"output\": \"Salida\",\n    \"plan\": \"Plan\",\n    \"tool\": \"Herramienta\",\n    \"thinking\": \"Pensando\",\n    \"toolSummary\": {\n      \"read\": \"Leyó {{path}}\",\n      \"searched\": \"Buscó \\\"{{query}}\\\"\",\n      \"fetched\": \"Obtuvo {{url}}\",\n      \"ranCommand\": \"Ejecutó comando\",\n      \"createdTask\": \"Creó tarea: {{description}}\",\n      \"todoOperation\": \"{{operation}} tareas pendientes\"\n    },\n    \"subagent\": {\n      \"defaultType\": \"Subagente\"\n    },\n    \"loadingEarlierMessages\": \"Cargando mensajes anteriores\"\n  },\n  \"language\": {\n    \"browserDefault\": \"Predeterminado del navegador\"\n  },\n  \"states\": {\n    \"error\": \"Error\",\n    \"loading\": \"Cargando...\",\n    \"loadingHistory\": \"Cargando historial\",\n    \"reconnecting\": \"Reconectando\",\n    \"saving\": \"Guardando...\",\n    \"success\": \"Éxito\"\n  },\n  \"orgSwitcher\": {\n    \"organizations\": \"Organizaciones\",\n    \"createOrganization\": \"Crear organización\",\n    \"orgSettings\": \"Configuración de la organización\"\n  },\n  \"ok\": \"OK\",\n  \"error\": \"Error\",\n  \"signIn\": \"Iniciar sesión\",\n  \"signOut\": \"Cerrar sesión\",\n  \"oauth\": {\n    \"title\": \"Iniciar sesión en Vibe Kanban\",\n    \"description\": \"Inicia sesión para unirte a organizaciones y compartir tareas con tu equipo\",\n    \"continueWithGitHub\": \"Continuar con GitHub\",\n    \"continueWithGoogle\": \"Continuar con Google\",\n    \"waitingTitle\": \"Completar Autenticación\",\n    \"waitingDescription\": \"Se ha abierto una ventana emergente para la autenticación\",\n    \"waitingForAuth\": \"Esperando autenticación...\",\n    \"popupInstructions\": \"Si la ventana emergente no se abrió, por favor revisa la configuración de bloqueo de ventanas emergentes.\",\n    \"back\": \"Atrás\",\n    \"successTitle\": \"¡Autenticación exitosa!\",\n    \"welcomeBack\": \"Bienvenido de nuevo, {{name}}\",\n    \"errorTitle\": \"Falló la autenticación\",\n    \"errorDescription\": \"Hubo un problema al autenticar tu cuenta\",\n    \"tryAgain\": \"Intentar de nuevo\"\n  },\n  \"toolbar\": {\n    \"sortBy\": \"Ordenar por\",\n    \"groupBy\": \"Agrupar por\"\n  },\n  \"sorting\": {\n    \"ascending\": \"Ascendente\",\n    \"descending\": \"Descendente\"\n  },\n  \"grouping\": {\n    \"date\": \"Fecha\",\n    \"assignee\": \"Asignado\",\n    \"label\": \"Etiqueta\"\n  },\n  \"workspaces\": {\n    \"title\": \"Espacios de trabajo\",\n    \"searchPlaceholder\": \"Buscar...\",\n    \"active\": \"Activo\",\n    \"archived\": \"Archivado\",\n    \"loading\": \"Cargando...\",\n    \"notFound\": \"Espacio de trabajo no encontrado\",\n    \"selectToStart\": \"Selecciona un espacio de trabajo para comenzar\",\n    \"draft\": \"Borrador\",\n    \"viewArchive\": \"Ver archivo\",\n    \"backToActive\": \"Volver a activos\",\n    \"noArchived\": \"No hay espacios de trabajo archivados\",\n    \"noWorkspaces\": \"Sin espacios de trabajo\",\n    \"newWorkspace\": \"Nuevo espacio de trabajo\",\n    \"needsAttention\": \"Necesita Atención\",\n    \"idle\": \"Inactivo\",\n    \"running\": \"En Ejecución\",\n    \"pin\": \"Fijar\",\n    \"unpin\": \"Desfijar\",\n    \"archive\": \"Archivar\",\n    \"more\": \"Más acciones\",\n    \"rename\": {\n      \"title\": \"Renombrar espacio de trabajo\",\n      \"description\": \"Ingresa un nuevo nombre para este espacio de trabajo.\",\n      \"nameLabel\": \"Nombre\",\n      \"placeholder\": \"Ingresa el nombre del espacio de trabajo\",\n      \"action\": \"Renombrar\",\n      \"renaming\": \"Renombrando...\"\n    },\n    \"unlinkFromIssue\": \"Desvincular del problema\",\n    \"deleteWorkspace\": \"Eliminar espacio de trabajo\",\n    \"unlink\": \"Desvincular\",\n    \"delete\": \"Eliminar\",\n    \"unlinkConfirmMessage\": \"¿Estás seguro de que deseas desvincular este espacio de trabajo del problema? El espacio de trabajo seguirá existiendo pero ya no estará asociado con este problema.\",\n    \"deleteConfirmMessage\": \"¿Estás seguro de que deseas eliminar este espacio de trabajo? Esto lo desvinculará del problema y eliminará el espacio de trabajo local. Esta acción no se puede deshacer.\",\n    \"unlinkError\": \"Error al desvincular el espacio de trabajo\",\n    \"deleteError\": \"Error al eliminar el espacio de trabajo\",\n    \"filesChanged\": \"{{count}} archivos\",\n    \"deleteDialog\": {\n      \"title\": \"Eliminar espacio de trabajo\",\n      \"description\": \"¿Estás seguro de que deseas eliminar este espacio de trabajo? Esta acción no se puede deshacer.\",\n      \"deleteBranchLabel\": \"Eliminar rama\",\n      \"cannotDeleteOpenPr\": \"No se puede eliminar la rama mientras el PR está abierto\",\n      \"unlinkFromIssueLabel\": \"También desvincular del issue\"\n    },\n    \"linkError\": \"Failed to link workspace\"\n  },\n  \"fileTree\": {\n    \"searchPlaceholder\": \"Buscar archivos...\",\n    \"noResults\": \"No hay archivos coincidentes\",\n    \"title\": \"Archivos\",\n    \"showGitHubComments\": \"Mostrar comentarios de GitHub\",\n    \"hideGitHubComments\": \"Ocultar comentarios de GitHub\",\n    \"prevGitHubComment\": \"Archivo anterior con comentarios\",\n    \"nextGitHubComment\": \"Siguiente archivo con comentarios\"\n  },\n  \"sections\": {\n    \"changes\": \"Cambios\",\n    \"repositories\": \"Repositorios\",\n    \"addRepositories\": \"Agregar repositorios\",\n    \"project\": \"Proyecto\",\n    \"processes\": \"Procesos\",\n    \"devServer\": \"Servidor de desarrollo\",\n    \"advanced\": \"Avanzado\",\n    \"workingBranch\": \"Rama de trabajo\",\n    \"recent\": \"Reciente\",\n    \"other\": \"Otro\",\n    \"devServerPreview\": \"Vista previa del servidor de desarrollo\",\n    \"terminal\": \"Terminal\",\n    \"notes\": \"Notas\"\n  },\n  \"notes\": {\n    \"placeholder\": \"Agregar notas sobre este espacio de trabajo...\",\n    \"selectWorkspace\": \"Selecciona un espacio de trabajo para ver notas\"\n  },\n  \"actions\": {\n    \"copyPath\": \"Copiar ruta\",\n    \"cancel\": \"Cancelar\",\n    \"saveChanges\": \"Guardar cambios\",\n    \"copied\": \"Copiado\",\n    \"collapse\": \"Contraer\"\n  },\n  \"comments\": {\n    \"addReviewComment\": \"Agregar comentario de revisión\",\n    \"addPlaceholder\": \"Agregar un comentario...\",\n    \"editPlaceholder\": \"Editar comentario...\",\n    \"copyToReview\": \"Copiar a revisión\"\n  },\n  \"confirm\": {\n    \"defaultConfirm\": \"Confirmar\",\n    \"defaultCancel\": \"Cancelar\"\n  },\n  \"dialogs\": {\n    \"selectGitRepository\": \"Seleccionar repositorio Git\",\n    \"chooseExistingRepo\": \"Elige un repositorio existente de tu sistema de archivos\"\n  },\n  \"empty\": {\n    \"noChanges\": \"No hay cambios para mostrar\"\n  },\n  \"commandBar\": {\n    \"noResults\": \"No se encontraron resultados.\",\n    \"back\": \"Atrás\",\n    \"defaultPlaceholder\": \"Escribe un comando o busca...\",\n    \"selectBranch\": \"Select Branch\",\n    \"selectBranchFor\": \"Select Branch for {{repoName}}\"\n  },\n  \"workspacesGuide\": {\n    \"welcome\": {\n      \"title\": \"Bienvenido\",\n      \"content\": \"Bienvenido a Workspaces, una interfaz rediseñada para Vibe Kanban. Estamos lanzando esto a usuarios seleccionados para recibir comentarios tempranos. Comparte tus opiniones en cualquier momento usando el ícono de comentarios en la barra de navegación.\"\n    },\n    \"commandBar\": {\n      \"title\": \"Barra de comandos\",\n      \"content\": \"La barra de comandos es tu centro principal de navegación. Ábrela con CMD+K para buscar y acceder a cada acción disponible en un espacio de trabajo.\"\n    },\n    \"contextBar\": {\n      \"title\": \"Barra de contexto\",\n      \"content\": \"La barra de contexto te permite cambiar entre paneles rápidamente. Arrástrala a donde mejor te funcione.\"\n    },\n    \"sidebar\": {\n      \"title\": \"Barra lateral de espacios de trabajo\",\n      \"content\": \"Ve el estado de todos tus espacios de trabajo de un vistazo. Las notificaciones resaltan cuáles necesitan atención. Archiva los espacios de trabajo fusionados para mantener tu barra lateral limpia.\"\n    },\n    \"multiRepo\": {\n      \"title\": \"Soporte multi-repositorio\",\n      \"content\": \"Agrega múltiples repositorios a un solo espacio de trabajo. Referencia código de un repositorio mientras trabajas en otro, o implementa cambios en varios repositorios a la vez.\"\n    },\n    \"sessions\": {\n      \"title\": \"Múltiples sesiones\",\n      \"content\": \"Crea múltiples sesiones de conversación con agentes dentro de un solo espacio de trabajo, incluyendo sesiones con diferentes agentes. Esto te ayuda a superar los límites de conversación o iniciar agentes de revisión en hilos separados.\"\n    },\n    \"preview\": {\n      \"title\": \"Vista previa de cambios\",\n      \"content\": \"Previsualiza tu trabajo en un navegador integrado sin cambiar de contexto. Prueba en escritorio, móvil y tamaños de ventana personalizados.\"\n    },\n    \"diffs\": {\n      \"title\": \"Diferencias y comentarios\",\n      \"content\": \"El panel de diferencias rediseñado incluye un árbol de archivos con tus cambios. Comenta directamente en las diferencias para dar retroalimentación al agente, y ve los comentarios de GitHub cuando tu espacio de trabajo está vinculado a un PR.\"\n    },\n    \"classicUi\": {\n      \"title\": \"Volver a la interfaz clásica\",\n      \"content\": \"Haz clic en el icono de salida en el lado izquierdo de la barra de navegación para volver al tablero kanban clásico. Para desactivar la nueva interfaz por completo, actualiza la opción \\\"Habilitar Beta de Workspaces\\\" en \\\"Funciones Beta\\\" en la configuración.\"\n    }\n  },\n  \"logs\": {\n    \"searchLogs\": \"Buscar registros\",\n    \"selectProcessToView\": \"Seleccione un proceso para ver los registros\"\n  },\n  \"processes\": {\n    \"noProcesses\": \"Sin procesos\",\n    \"terminal\": \"Terminal\"\n  },\n  \"search\": {\n    \"matchCount\": \"{{current}} de {{total}}\",\n    \"noMatches\": \"Sin coincidencias\"\n  },\n  \"contextUsage\": {\n    \"label\": \"Uso del contexto\",\n    \"emptyTooltip\": \"El uso del contexto aparece después de la próxima respuesta\",\n    \"tooltip\": \"Contexto: {{percentage}}% · {{used}} / {{total}} tokens\",\n    \"ariaLabel\": \"Uso del contexto: {{percentage}}%\"\n  },\n  \"shortcuts\": {\n    \"title\": \"Atajos de Teclado\",\n    \"inWorkspace\": \"(en espacio de trabajo)\",\n    \"sequentialHint\": \"Atajos secuenciales: Presiona la primera tecla, luego la segunda en 500ms.\",\n    \"configurableHint\": \"Configurable en Ajustes → General → Entrada de Mensaje\",\n    \"groups\": {\n      \"quickActions\": \"Acciones Rápidas\",\n      \"navigation\": \"Navegación\",\n      \"modifiers\": \"Modificadores\",\n      \"goTo\": \"Ir a (G ...)\",\n      \"workspace\": \"Espacio de trabajo (W ...)\",\n      \"view\": \"Vista (V ...)\",\n      \"issues\": \"Issues (I ...)\",\n      \"git\": \"Git (X ...)\",\n      \"yank\": \"Copiar (Y ...)\",\n      \"toggle\": \"Alternar (T ...)\",\n      \"run\": \"Ejecutar (R ...)\"\n    },\n    \"actions\": {\n      \"showHelp\": \"Mostrar esta ayuda\",\n      \"closeCancel\": \"Cerrar/cancelar\",\n      \"createNewTask\": \"Crear nueva tarea\",\n      \"deleteSelected\": \"Eliminar seleccionado\",\n      \"focusSearch\": \"Enfocar búsqueda\",\n      \"moveDown\": \"Mover abajo\",\n      \"moveUp\": \"Mover arriba\",\n      \"moveLeft\": \"Mover a la izquierda\",\n      \"moveRight\": \"Mover a la derecha\",\n      \"openCommandBar\": \"Abrir barra de comandos\",\n      \"formatInlineCode\": \"Format inline code\",\n      \"sendMessage\": \"Enviar mensaje\",\n      \"settings\": \"Ir a Ajustes\",\n      \"new-workspace\": \"Ir a Nuevo espacio de trabajo\",\n      \"duplicate-workspace\": \"Duplicar espacio de trabajo\",\n      \"rename-workspace\": \"Renombrar espacio de trabajo\",\n      \"pin-workspace\": \"Fijar/Desfijar espacio de trabajo\",\n      \"archive-workspace\": \"Archivar espacio de trabajo\",\n      \"delete-workspace\": \"Eliminar espacio de trabajo\",\n      \"toggle-changes-mode\": \"Alternar panel de cambios\",\n      \"toggle-logs-mode\": \"Alternar panel de registros\",\n      \"toggle-preview-mode\": \"Alternar panel de vista previa\",\n      \"toggle-left-sidebar\": \"Alternar barra lateral izquierda\",\n      \"toggle-left-main-panel\": \"Alternar panel de chat\",\n      \"create-issue\": \"Crear issue\",\n      \"change-issue-status\": \"Cambiar estado\",\n      \"change-issue-priority\": \"Cambiar prioridad\",\n      \"change-assignees\": \"Cambiar asignados\",\n      \"make-sub-issue-of\": \"Hacer sub-issue de\",\n      \"add-sub-issue\": \"Agregar sub-issue\",\n      \"remove-parent-issue\": \"Quitar padre\",\n      \"link-workspace\": \"Vincular espacio de trabajo\",\n      \"duplicate-issue\": \"Duplicar issue\",\n      \"delete-issue\": \"Eliminar issue\",\n      \"git-create-pr\": \"Crear Pull Request\",\n      \"git-merge\": \"Fusionar rama\",\n      \"git-rebase\": \"Rebase de rama\",\n      \"git-push\": \"Enviar cambios\",\n      \"copy-path\": \"Copiar ruta\",\n      \"copy-raw-logs\": \"Copiar registros sin formato\",\n      \"toggle-dev-server\": \"Alternar servidor de desarrollo\",\n      \"toggle-wrap-lines\": \"Alternar ajuste de línea\",\n      \"run-setup-script\": \"Ejecutar script de configuración\",\n      \"run-cleanup-script\": \"Ejecutar script de limpieza\",\n      \"run-archive-script\": \"Ejecutar script de archivado\"\n    }\n  },\n  \"typeahead\": {\n    \"tags\": \"Etiquetas\",\n    \"files\": \"Archivos\",\n    \"commands\": \"Comandos\",\n    \"chooseRepo\": \"Elegir repositorio\",\n    \"selectedRepo\": \"Repositorio seleccionado: {{repoName}}\",\n    \"missingRepo\": \"El repositorio seleccionado ya no está disponible.\",\n    \"noTagsOrFiles\": \"No se encontraron etiquetas ni archivos\",\n    \"createTag\": \"Crear nueva etiqueta\",\n    \"noCommands\": \"No hay comandos disponibles para este agente.\"\n  },\n  \"kanban\": {\n    \"noVisibleStatuses\": \"Todos los estados están ocultos. Usa la configuración de visualización o cambia a otra pestaña para ver los problemas.\",\n    \"noProjectFound\": \"No se encontró el proyecto\",\n    \"unassigned\": \"Sin asignar\",\n    \"noTagsAvailable\": \"No hay etiquetas disponibles\",\n    \"createNewIssue\": \"Crear nuevo issue\",\n    \"searchTags\": \"Buscar etiquetas...\",\n    \"selectColorFor\": \"Seleccionar color para\",\n    \"createTag\": \"Crear\",\n    \"noPrCreated\": \"No se creó PR\",\n    \"noCommentsYet\": \"Sin comentarios aún\",\n    \"createdBy\": \"Creado por\",\n    \"comments\": \"Comentarios\",\n    \"enterCommentPlaceholder\": \"Ingresa tu comentario aquí...\",\n    \"attachFile\": \"Adjuntar archivo\",\n    \"attachFileHint\": \"Adjuntar archivos (máx. 20MB por archivo)\",\n    \"dropFilesHere\": \"Suelta archivos aquí\",\n    \"fileDropHint\": \"Cualquier tipo de archivo hasta 20MB\",\n    \"unknownUser\": \"Usuario desconocido\",\n    \"deletedUser\": \"Usuario eliminado\",\n    \"replyQuotePrefix\": \"escribió:\",\n    \"moreActions\": \"Más acciones\",\n    \"closePanel\": \"Cerrar panel\",\n    \"copyLink\": \"Copiar enlace\",\n    \"issueTitlePlaceholder\": \"Título del issue...\",\n    \"issueDescriptionPlaceholder\": \"Ingresa la descripción de la tarea...\",\n    \"createDraftWorkspaceImmediately\": \"Crear workspace borrador inmediatamente\",\n    \"createDraftWorkspaceDescription\": \"Después de crear la incidencia, abrir el formulario de creación de workspace con los datos de la incidencia\",\n    \"createIssue\": \"Crear tarea\",\n    \"newIssue\": \"Nuevo issue\",\n    \"previewCodeBlock\": \"[Bloque de código]\",\n    \"previewImage\": \"[Imagen]\",\n    \"previewImageWithName\": \"[Imagen: {{name}}]\",\n    \"previewFile\": \"[Archivo]\",\n    \"previewFileWithName\": \"[Archivo: {{name}}]\",\n    \"imageAttachmentNameFallback\": \"Adjunto\",\n    \"removeImage\": \"Eliminar imagen\",\n    \"maxFilesAtOnce\": \"Máximo {{count}} archivos a la vez\",\n    \"fileExceedsLimit\": \"El archivo {{filename}} supera el límite de 20MB\",\n    \"unknownError\": \"Error desconocido\",\n    \"failedToUploadFile\": \"No se pudo subir {{filename}}: {{message}}\",\n    \"downloadAttachment\": \"Descargar archivo adjunto\",\n    \"subIssues\": \"Subtareas\",\n    \"noSubIssues\": \"Sin subtareas\",\n    \"markIndependentIssue\": \"Marcar como problema independiente\",\n    \"parentIssue\": \"Padre\",\n    \"subIssueIndicator\": \"↳\",\n    \"viewTabs\": {\n      \"active\": \"Activo\",\n      \"all\": \"Todo\"\n    },\n    \"loginRequired\": {\n      \"title\": \"Se requiere iniciar sesión\",\n      \"description\": \"Por favor inicia sesión para ver este proyecto.\",\n      \"action\": \"Iniciar sesión\"\n    },\n    \"team\": \"Team\",\n    \"personal\": \"Personal\",\n    \"searchPlaceholder\": \"Search issues...\",\n    \"filters\": \"Filters\",\n    \"clearFilters\": \"Clear filters\",\n    \"filtersDescription\": \"Adjust filters and sorting for this board view.\",\n    \"filterByTag\": \"Filter by tag\",\n    \"filterByPriority\": \"Filter by priority\",\n    \"tags\": \"Tags\",\n    \"sortBy\": \"Sort\",\n    \"workspaceSidebar\": {\n      \"sortDialogTitle\": \"Sort workspaces\",\n      \"sortDialogDescription\": \"Choose how workspaces are ordered in the sidebar.\",\n      \"sortByLabel\": \"Sort by\",\n      \"sortOrderLabel\": \"Sort order\",\n      \"sortUpdatedAt\": \"Updated at\",\n      \"sortCreatedAt\": \"Created at\",\n      \"filterDialogTitle\": \"Filter workspaces\",\n      \"filterDialogDescription\": \"Narrow down workspaces by project and PR state.\",\n      \"projectFilterLabel\": \"Project\",\n      \"prFilterLabel\": \"PR\",\n      \"prFilterAll\": \"All\",\n      \"prFilterHasPr\": \"Has PR\",\n      \"prFilterNoPr\": \"No PR\",\n      \"noProject\": \"No project\",\n      \"sortButtonTitle\": \"Sort workspaces\",\n      \"filterButtonTitle\": \"Filter workspaces\"\n    },\n    \"sortAscending\": \"Ascending\",\n    \"sortDescending\": \"Descending\",\n    \"assignee\": \"Assignee\",\n    \"self\": \"Me\",\n    \"priority\": \"Priority\",\n    \"subIssuesFilterLabel\": \"Sub-issues\",\n    \"workspacesFilterLabel\": \"Workspaces\",\n    \"selectAssignees\": \"Select assignees...\",\n    \"relationships\": \"Relationships\",\n    \"noRelationships\": \"No relationships\",\n    \"openProjectsGuide\": \"Projects guide\",\n    \"editProjectSettings\": \"Edit project settings\",\n    \"linkWorkspace\": \"Link workspace...\",\n    \"createNewWorkspace\": \"Create new workspace\",\n    \"workspaces\": \"Workspaces\",\n    \"showingWorkspaces\": \"Showing {{count}} of {{total}}\",\n    \"changeColor\": \"Change color\",\n    \"editName\": \"Edit name\",\n    \"deleteStatus\": \"Delete status\",\n    \"cannotDeleteWithIssues\": \"Move issues first\",\n    \"lastVisibleStatus\": \"At least one status must be visible\",\n    \"showStatus\": \"Show status\",\n    \"hideStatus\": \"Hide status\",\n    \"newStatus\": \"New Status\",\n    \"visibleColumns\": \"Visible Columns\",\n    \"dragToRearrange\": \"Drag to re-arrange\",\n    \"addColumn\": \"Add column\",\n    \"projectsGuide\": {\n      \"intro\": {\n        \"title\": \"Welcome\",\n        \"content\": \"Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.\"\n      },\n      \"welcome\": {\n        \"title\": \"Projects\",\n        \"content\": \"The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.\"\n      },\n      \"issues\": {\n        \"title\": \"Issues\",\n        \"content\": \"Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.\"\n      },\n      \"workspaces\": {\n        \"title\": \"Workspaces\",\n        \"content\": \"To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.\"\n      },\n      \"organizations\": {\n        \"title\": \"Invite your team\",\n        \"content\": \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      }\n    }\n  },\n  \"createMode\": {\n    \"headings\": {\n      \"repoStep\": \"¿En qué repositorios te gustaría trabajar?\",\n      \"chatStep\": \"¿En qué te gustaría trabajar?\"\n    },\n    \"repoPicker\": {\n      \"actions\": {\n        \"recent\": \"Recientes\",\n        \"browse\": \"Explorar\",\n        \"create\": \"Crear\"\n      },\n      \"setupHintTitle\": \"Scripts de Configuración\",\n      \"setupHint\": \"Consejo: Configura los scripts de instalación para este repositorio en Ajustes → Repositorios\",\n      \"setupHintDismiss\": \"Cerrar\",\n      \"setupHintLink\": \"Configure in Settings\"\n    }\n  },\n  \"modelSelector\": {\n    \"preset\": \"Preajuste\",\n    \"custom\": \"Personalizar\",\n    \"permissions\": \"Permisos\",\n    \"permissionAuto\": \"Auto\",\n    \"permissionAsk\": \"Preguntar\",\n    \"permissionPlan\": \"Plan\",\n    \"agent\": \"Agente\",\n    \"default\": \"Predeterminado\",\n    \"model\": \"Modelo\"\n  },\n  \"syncError\": {\n    \"networkErrors\": \"Errores de red\",\n    \"streamsAffected\": \"{{count}} flujo afectado\",\n    \"streamsAffected_other\": \"{{count}} flujos afectados\",\n    \"status\": \"(estado {{status}})\",\n    \"refreshPage\": \"Actualizar página\"\n  },\n  \"onboardingSignIn\": {\n    \"subtitle\": \"Sign in to continue\",\n    \"signedInAs\": \"Signed in as {{name}}.\",\n    \"moreOptions\": \"More options\",\n    \"featureHeader\": \"Feature\",\n    \"signedInHeader\": \"Signed in\",\n    \"skipSignInHeader\": \"Don't sign in\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\"\n  },\n  \"migration\": {\n    \"allProjectsMigrated\": \"Todos tus proyectos ya han sido migrados a la nube.\",\n    \"continueToProjects\": \"Continuar a los proyectos\"\n  },\n  \"appBar\": {\n    \"kanban\": {\n      \"tooltip\": \"Kanban\",\n      \"title\": \"Tableros Kanban\",\n      \"description\": \"Inicia sesión para organizar tus agentes de codificación con tableros kanban.\",\n      \"migrateOldProjects\": \"Migrar proyectos antiguos\"\n    }\n  },\n  \"errors\": {\n    \"generic\": \"An unexpected error occurred\"\n  },\n  \"personal\": \"Personal\",\n  \"askQuestion\": {\n    \"title\": \"El agente está haciendo una pregunta\",\n    \"selectMultiple\": \"seleccionar varios\",\n    \"confirmSelection\": \"Confirmar selección\",\n    \"submitting\": \"Enviando respuestas...\",\n    \"answeredCount\": \"Respondió {{count}} pregunta\",\n    \"answeredCount_other\": \"Respondió {{count}} preguntas\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/es/organization.json",
    "content": "{\n  \"createDialog\": {\n    \"title\": \"Create New Organization\",\n    \"description\": \"Create a new organization to collaborate with your team.\",\n    \"nameLabel\": \"Organization Name\",\n    \"namePlaceholder\": \"e.g., Acme Corporation\",\n    \"slugLabel\": \"Slug\",\n    \"slugPlaceholder\": \"e.g., acme-corporation\",\n    \"slugHelper\": \"Used in URLs. Lowercase letters, numbers, and hyphens only.\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Organization\"\n  },\n  \"inviteDialog\": {\n    \"title\": \"Invite Member\",\n    \"description\": \"Send an invitation to join your organization.\",\n    \"emailLabel\": \"Email Address\",\n    \"emailPlaceholder\": \"colleague@example.com\",\n    \"roleLabel\": \"Role\",\n    \"rolePlaceholder\": \"Select a role\",\n    \"roleHelper\": \"Admins can manage members and organization settings.\",\n    \"sending\": \"Sending...\",\n    \"sendButton\": \"Send Invitation\",\n    \"subscriptionRequired\": \"Se requiere suscripción para agregar más miembros\",\n    \"upgradePrompt\": \"Actualiza el plan de tu organización para invitar miembros adicionales.\",\n    \"upgradeButton\": \"Actualizar plan\"\n  },\n  \"roles\": {\n    \"member\": \"Member\",\n    \"admin\": \"Admin\"\n  },\n  \"memberList\": {\n    \"title\": \"Members\",\n    \"description\": \"Manage members and their roles in {{orgName}}\",\n    \"inviteButton\": \"Invite Member\",\n    \"loading\": \"Loading members...\",\n    \"none\": \"No members found\",\n    \"you\": \"You\"\n  },\n  \"invitationList\": {\n    \"title\": \"Pending Invitations\",\n    \"description\": \"View pending invitations for {{orgName}}\",\n    \"loading\": \"Loading invitations...\",\n    \"invited\": \"Invited {{date}}\",\n    \"pending\": \"Pending\"\n  },\n  \"settings\": {\n    \"title\": \"Organization Settings\",\n    \"description\": \"Manage organization members and permissions\",\n    \"selectLabel\": \"Select Organization\",\n    \"selectPlaceholder\": \"Select an organization\",\n    \"selectHelper\": \"Choose an organization to view and manage its members\",\n    \"noOrganizations\": \"No organizations available\",\n    \"loadingOrganizations\": \"Loading organizations...\",\n    \"loadError\": \"Failed to load organizations\",\n    \"dangerZone\": \"Danger Zone\",\n    \"dangerZoneDescription\": \"Irreversible and destructive actions\",\n    \"deleteOrganization\": \"Delete Organization\",\n    \"deleteOrganizationDescription\": \"Permanently delete this organization and all its data\",\n    \"confirmDelete\": \"Are you sure you want to delete {{orgName}}? This action cannot be undone.\",\n    \"deleteSuccess\": \"Organization deleted successfully\",\n    \"deleteError\": \"Failed to delete organization\"\n  },\n  \"loginRequired\": {\n    \"title\": \"Inicio de sesión requerido\",\n    \"description\": \"Debes iniciar sesión para administrar la configuración de la organización.\",\n    \"action\": \"Iniciar sesión\"\n  },\n  \"confirmRemoveMember\": \"Are you sure you want to remove this member from the organization?\",\n  \"billing\": {\n    \"title\": \"Facturación y suscripción\",\n    \"description\": \"Administra la suscripción y configuración de facturación de tu organización\",\n    \"manageButton\": \"Administrar facturación\",\n    \"openInBrowser\": \"Se abre en una nueva pestaña del navegador\"\n  },\n  \"personalOrg\": {\n    \"cannotInvite\": \"Las organizaciones personales no pueden tener miembros adicionales.\",\n    \"createOrgPrompt\": \"Crea una organización para colaborar con otros.\",\n    \"createOrgButton\": \"Crear organización\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/es/projects.json",
    "content": "{\n  \"createProjectDialog\": {\n    \"title\": \"Create Project\",\n    \"description\": \"Create a new project in this organization.\",\n    \"nameLabel\": \"Project name\",\n    \"namePlaceholder\": \"Enter project name\",\n    \"selectColor\": \"Select project color\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Project\"\n  },\n  \"deleteProjectDialog\": {\n    \"title\": \"Delete Project?\",\n    \"description\": \"This will permanently delete \\\"{{name}}\\\" and all its issues. This action cannot be undone.\",\n    \"error\": \"Failed to delete project. Please try again.\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/es/settings.json",
    "content": "{\n  \"settings\": {\n    \"common\": {\n      \"unsavedChanges\": \"• Tienes cambios sin guardar\",\n      \"discardChangesConfirm\": \"Discard unsaved changes?\"\n    },\n    \"unsavedChanges\": {\n      \"title\": \"Cambios sin guardar\",\n      \"message\": \"Tienes cambios sin guardar. ¿Estás seguro de que deseas cerrar sin guardar?\",\n      \"discard\": \"Descartar cambios\",\n      \"cancel\": \"Seguir editando\"\n    },\n    \"layout\": {\n      \"nav\": {\n        \"title\": \"Configuración\",\n        \"general\": \"General\",\n        \"generalDesc\": \"Tema, notificaciones y preferencias\",\n        \"projects\": \"Proyectos\",\n        \"projectsDesc\": \"Repositorios de proyectos y configuración\",\n        \"repos\": \"Repositorios\",\n        \"reposDesc\": \"Scripts y configuración de repositorios\",\n        \"agents\": \"Agentes\",\n        \"agentsDesc\": \"Configuraciones de agentes\",\n        \"mcp\": \"Servidores MCP\",\n        \"mcpDesc\": \"Servidores de Protocolo de Contexto de Modelo (MCP)\",\n        \"organizations\": \"Organization Settings\",\n        \"organizationsDesc\": \"Manage organization members and permissions\",\n        \"remote-projects\": \"Proyectos\",\n        \"remote-projectsDesc\": \"Gestionar proyectos\",\n        \"relay\": \"Remote Access\",\n        \"relayDesc\": \"Relay tunnel and enrollment\"\n      }\n    },\n    \"general\": {\n      \"loading\": \"Cargando configuración...\",\n      \"loadError\": \"Error al cargar la configuración.\",\n      \"save\": {\n        \"button\": \"Guardar Configuración\",\n        \"success\": \"✓ ¡Configuración guardada!\",\n        \"error\": \"Error al guardar la configuración\",\n        \"unsavedChanges\": \"• Tienes cambios sin guardar\",\n        \"discard\": \"Descartar\"\n      },\n      \"appearance\": {\n        \"title\": \"Apariencia\",\n        \"description\": \"Personaliza cómo se ve la aplicación.\",\n        \"theme\": {\n          \"label\": \"Tema\",\n          \"placeholder\": \"Seleccionar tema\",\n          \"helper\": \"Elige tus colores preferidos.\"\n        },\n        \"language\": {\n          \"label\": \"Idioma\",\n          \"placeholder\": \"Selecciona tu idioma\",\n          \"helper\": \"Elige tu idioma preferido. El predeterminado del navegador sigue el idioma de tu sistema.\"\n        }\n      },\n      \"taskExecution\": {\n        \"title\": \"Agente de Código Predeterminado\",\n        \"description\": \"Elige el agente de código predeterminado para las tareas.\",\n        \"executor\": {\n          \"label\": \"Configuración predeterminada del Agente\",\n          \"placeholder\": \"Seleccionar perfil\",\n          \"helper\": \"Define la configuración predeterminada del agente que se usará al iniciar una tarea.\"\n        },\n        \"variant\": \"PREDETERMINADO\",\n        \"defaultLabel\": \"Predeterminado\"\n      },\n      \"editor\": {\n        \"title\": \"Editor\",\n        \"description\": \"Configura cómo quieres editar tu código.\",\n        \"type\": {\n          \"label\": \"Tipo de Editor\",\n          \"placeholder\": \"Seleccionar editor\",\n          \"helper\": \"Elige tu editor de código preferido.\"\n        },\n        \"customCommand\": {\n          \"label\": \"Comando de Editor Personalizado\",\n          \"placeholder\": \"ej., code, subl, vim\",\n          \"helper\": \"Ingresa el comando para lanzar tu editor personalizado. Se utilizará para abrir archivos.\"\n        },\n        \"remoteSsh\": {\n          \"host\": {\n            \"label\": \"Host SSH Remoto (Opcional)\",\n            \"placeholder\": \"ej., nombre de host o dirección IP\",\n            \"helper\": \"Configura esto si Vibe Kanban se ejecuta en un servidor remoto. Cuando se establece, al hacer clic en \\\"Abrir en Editor\\\" se generará una URL para abrir tu editor a través de SSH en lugar de ejecutar un comando local.\"\n          },\n          \"user\": {\n            \"label\": \"Usuario SSH Remoto (Opcional)\",\n            \"placeholder\": \"ej., nombre de usuario\",\n            \"helper\": \"Nombre de usuario SSH para la conexión remota. Si no se establece, VS Code usará tu configuración SSH o te lo pedirá.\"\n          }\n        },\n        \"autoInstallExtension\": {\n          \"label\": \"Auto-install VS Code Extension\",\n          \"helper\": \"Automatically install the Vibe Kanban extension when opening a project in VS Code or Cursor. This runs in the background and has no effect if the extension is already installed.\"\n        },\n        \"availability\": {\n          \"checking\": \"Verificando disponibilidad...\",\n          \"available\": \"Disponible\",\n          \"notFound\": \"No encontrado en PATH\"\n        }\n      },\n      \"github\": {\n        \"title\": \"Integración con GitHub\",\n        \"cliSetup\": {\n          \"title\": \"Configuración de GitHub CLI\",\n          \"description\": \"La autenticación de GitHub CLI es necesaria para crear pull requests e interactuar con repositorios de GitHub.\",\n          \"setupWillTitle\": \"Esta configuración:\",\n          \"steps\": {\n            \"checkInstalled\": \"Verificará si GitHub CLI (gh) está instalado\",\n            \"installHomebrew\": \"Lo instalará vía Homebrew si es necesario (macOS)\",\n            \"authenticate\": \"Autenticará con GitHub usando OAuth\"\n          },\n          \"setupNote\": \"La configuración se ejecutará en la ventana de chat. Necesitarás completar la autenticación en tu navegador.\",\n          \"runSetup\": \"Ejecutar Configuración\",\n          \"running\": \"Ejecutando...\",\n          \"errors\": {\n            \"brewMissing\": \"Homebrew no está instalado. Instálalo para habilitar la configuración automática.\",\n            \"notSupported\": \"La configuración automática no es compatible con esta plataforma. Instala GitHub CLI manualmente.\",\n            \"setupFailed\": \"Error al ejecutar la configuración de GitHub CLI.\"\n          },\n          \"help\": {\n            \"homebrew\": {\n              \"description\": \"La instalación automática requiere Homebrew. Instala Homebrew desde\",\n              \"brewSh\": \"brew.sh\",\n              \"manualInstall\": \"y luego vuelve a ejecutar la configuración. Alternativamente, instala GitHub CLI manualmente con:\",\n              \"afterInstall\": \"Después de la instalación, autentica con:\"\n            },\n            \"manual\": {\n              \"description\": \"Instala GitHub CLI desde la\",\n              \"officialDocs\": \"documentación oficial\",\n              \"andAuthenticate\": \"y luego autentica con tu cuenta de GitHub.\"\n            }\n          }\n        }\n      },\n      \"git\": {\n        \"title\": \"Git\",\n        \"description\": \"Configurar preferencias de nombres de ramas git\",\n        \"branchPrefix\": {\n          \"label\": \"Prefijo de Rama\",\n          \"placeholder\": \"vk\",\n          \"helper\": \"Prefijo para nombres de ramas generadas automáticamente. Dejar vacío para no usar prefijo.\",\n          \"preview\": \"Vista previa:\",\n          \"previewWithPrefix\": \"{{prefix}}/1a2b-nombre-tarea\",\n          \"previewNoPrefix\": \"1a2b-nombre-tarea\",\n          \"errors\": {\n            \"slash\": \"El prefijo no puede contener '/'.\",\n            \"startsWithDot\": \"El prefijo no puede comenzar con '.'.\",\n            \"endsWithDot\": \"El prefijo no puede terminar con '.' o '.lock'.\",\n            \"invalidSequence\": \"Contiene secuencia no válida (.., @{).\",\n            \"invalidChars\": \"Contiene caracteres no válidos.\",\n            \"controlChars\": \"Contiene caracteres de control.\"\n          }\n        },\n        \"workspaceDir\": {\n          \"label\": \"Directorio de Espacios de Trabajo\",\n          \"placeholder\": \"~/\",\n          \"helper\": \"Los espacios de trabajo se crearán en un subdirectorio .vibe-kanban-workspaces dentro de esta ruta. Dejar vacío para usar el valor predeterminado del sistema. Los cambios requieren reiniciar la aplicación.\",\n          \"browse\": \"Explorar\",\n          \"dialogTitle\": \"Seleccionar Directorio de Espacios de Trabajo\",\n          \"dialogDescription\": \"Elija un directorio. Los espacios de trabajo se crearán en un subdirectorio .vibe-kanban-workspaces dentro de él.\"\n        }\n      },\n      \"pullRequests\": {\n        \"title\": \"Pull Requests\",\n        \"description\": \"Configura el comportamiento de creación de PR\",\n        \"autoDescription\": {\n          \"label\": \"Auto-generar descripción de PR por defecto\",\n          \"helper\": \"Cuando está habilitado, el agente de IA actualizará automáticamente el título y la descripción del PR después de la creación.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"Usar prompt personalizado\",\n          \"helper\": \"Prompt personalizado para el agente de IA al generar descripciones de PR. Usa {pr_number} y {pr_url} como marcadores de posición.\"\n        }\n      },\n      \"commits\": {\n        \"title\": \"Commits\",\n        \"description\": \"Configura el comportamiento de commits para los intentos de tareas\",\n        \"reminder\": {\n          \"label\": \"Recordatorio de commit\",\n          \"helper\": \"Solicitar a los agentes compatibles que confirmen los cambios antes de detenerse.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"Usar prompt personalizado\",\n          \"helper\": \"Prompt personalizado para el recordatorio de commit. El estado de git se añadirá automáticamente.\"\n        }\n      },\n      \"notifications\": {\n        \"title\": \"Notificaciones\",\n        \"description\": \"Controla cuándo y cómo recibes notificaciones.\",\n        \"sound\": {\n          \"label\": \"Notificaciones de Sonido\",\n          \"helper\": \"Reproduce un sonido cuando una tarea termina de ejecutarse.\",\n          \"fileLabel\": \"Sonido\",\n          \"filePlaceholder\": \"Seleccionar sonido\",\n          \"fileHelper\": \"Elige el sonido que se reproducirá al completar las tareas. Haz clic en el botón de volumen para escucharlo.\"\n        },\n        \"push\": {\n          \"label\": \"Notificaciones Push\",\n          \"helper\": \"Muestra notificaciones del sistema cuando las tareas terminan de ejecutarse.\"\n        }\n      },\n      \"messageInput\": {\n        \"title\": \"Entrada de mensajes\",\n        \"description\": \"Configura cómo se envían los mensajes en el chat.\",\n        \"shortcut\": {\n          \"label\": \"Enviar mensaje con\",\n          \"helper\": \"Elige el atajo de teclado para enviar mensajes.\",\n          \"enterLabel\": \"Enter (Shift+Enter para nueva línea)\"\n        }\n      },\n      \"privacy\": {\n        \"title\": \"Privacidad\",\n        \"description\": \"Ayuda a mejorar Vibe-Kanban compartiendo datos de uso anónimos.\",\n        \"telemetry\": {\n          \"label\": \"Habilitar Telemetría\",\n          \"helper\": \"Habilita el seguimiento anónimo para ayudar a mejorar la aplicación. No se recopilan prompts ni información del proyecto.\"\n        }\n      },\n      \"taskTemplates\": {\n        \"title\": \"Etiquetas\",\n        \"description\": \"Crea fragmentos de texto reutilizables que se pueden insertar en descripciones de tareas usando @nombre_etiqueta.\"\n      },\n      \"tags\": {\n        \"manager\": {\n          \"title\": \"Etiquetas de Tareas\",\n          \"addTag\": \"Agregar Etiqueta\",\n          \"noTags\": \"Aún no hay etiquetas. Crea fragmentos de texto reutilizables para descripciones de tareas comunes. Usa @nombre_etiqueta en cualquier tarea.\",\n          \"table\": {\n            \"tagName\": \"Nombre de Etiqueta\",\n            \"content\": \"Contenido\",\n            \"actions\": \"Acciones\"\n          },\n          \"actions\": {\n            \"editTag\": \"Editar etiqueta\",\n            \"deleteTag\": \"Eliminar etiqueta\"\n          },\n          \"deleteConfirm\": \"¿Estás seguro de que deseas eliminar la etiqueta \\\"{{tagName}}\\\"?\"\n        },\n        \"dialog\": {\n          \"createTitle\": \"Crear Etiqueta\",\n          \"editTitle\": \"Editar Etiqueta\",\n          \"tagName\": {\n            \"label\": \"Nombre de Etiqueta\",\n            \"required\": \"*\",\n            \"hint\": \"Usa este nombre con @ en descripciones de tareas: @{{tagName}}\",\n            \"placeholder\": \"ej., corrección_error, plan_prueba, docs_api\",\n            \"error\": \"El nombre de la etiqueta no puede contener espacios. Usa guiones bajos en su lugar (ej., mi_etiqueta)\"\n          },\n          \"content\": {\n            \"label\": \"Contenido\",\n            \"required\": \"*\",\n            \"hint\": \"Texto que se insertará cuando uses @{{tagName}} en descripciones de tareas\",\n            \"placeholder\": \"Ingresa el texto que se insertará cuando uses esta etiqueta\"\n          },\n          \"errors\": {\n            \"nameRequired\": \"El nombre de la etiqueta es obligatorio\",\n            \"saveFailed\": \"Error al guardar la etiqueta\"\n          },\n          \"buttons\": {\n            \"cancel\": \"Cancelar\",\n            \"create\": \"Crear\",\n            \"update\": \"Actualizar\"\n          }\n        }\n      },\n      \"safety\": {\n        \"title\": \"Avisos legales y de seguridad\",\n        \"description\": \"Reinicia las confirmaciones de seguridad y de introducción.\",\n        \"disclaimer\": {\n          \"title\": \"Confirmación de Descargo\",\n          \"description\": \"Restablecer el aviso de seguridad.\",\n          \"button\": \"Restablecer\"\n        },\n        \"onboarding\": {\n          \"title\": \"Introducción\",\n          \"description\": \"Restablece el flujo de Introducción.\",\n          \"button\": \"Restablecer\"\n        }\n      },\n      \"beta\": {\n        \"title\": \"Funciones Beta\",\n        \"description\": \"Prueba funciones experimentales antes de su lanzamiento.\",\n        \"workspaces\": {\n          \"label\": \"Habilitar Beta de Workspaces\",\n          \"helper\": \"Usa la nueva interfaz de workspaces al ver intentos de tareas. Las tareas se abrirán primero en la vista de tareas, y los intentos se abrirán en la nueva vista de workspaces.\"\n        }\n      }\n    },\n    \"agents\": {\n      \"title\": \"Configuraciones de Agentes de Código\",\n      \"description\": \"Personaliza el comportamiento de los agentes con diferentes configuraciones.\",\n      \"loading\": \"Cargando configuraciones de agentes...\",\n      \"selectAgent\": \"Seleccionar agente\",\n      \"save\": {\n        \"button\": \"Guardar Configuraciones de Agentes\",\n        \"success\": \"✓ ¡Configuración guardada con éxito!\",\n        \"unsavedChanges\": \"• Tienes cambios sin guardar\"\n      },\n      \"availability\": {\n        \"checking\": \"Comprobando...\",\n        \"checkingAvailability\": \"Comprobando disponibilidad...\",\n        \"available\": \"Agente disponible\",\n        \"notFoundSimple\": \"Agente no encontrado\",\n        \"loginDetected\": \"Uso reciente detectado\",\n        \"loginDetectedTooltip\": \"Se encontraron credenciales de autenticación recientes para este agente\",\n        \"installationFound\": \"Uso previo detectado\",\n        \"installationFoundTooltip\": \"Se encontró la configuración del agente. Es posible que debas iniciar sesión para usarlo.\"\n      },\n      \"editor\": {\n        \"formLabel\": \"Editar JSON\",\n        \"agentLabel\": \"Agente\",\n        \"agentPlaceholder\": \"Seleccionar tipo\",\n        \"configLabel\": \"Configuración\",\n        \"configPlaceholder\": \"Seleccionar configuración\",\n        \"createNew\": \"Crear nuevo...\",\n        \"deleteTitle\": \"No se puede eliminar la última configuración\",\n        \"deleteButton\": \"Eliminar {{name}}\",\n        \"deleteText\": \"Eliminar\",\n        \"makeDefault\": \"Establecer por defecto\",\n        \"isDefault\": \"Por defecto\",\n        \"jsonLabel\": \"Configuración de Agente (JSON)\",\n        \"jsonPlaceholder\": \"Cargando perfiles...\",\n        \"jsonLoading\": \"Cargando...\",\n        \"pathLabel\": \"Ubicación del archivo de configuración:\"\n      },\n      \"errors\": {\n        \"deleteFailed\": \"Error al eliminar la configuración. Por favor, inténtalo de nuevo.\",\n        \"saveFailed\": \"Error al guardar la configuración de los agentes. Por favor, inténtalo de nuevo.\",\n        \"saveConfigFailed\": \"Error al guardar la configuración. Por favor, inténtalo de nuevo.\",\n        \"schemaNotFound\": \"Esquema no encontrado para el tipo de ejecutor: {{executor}}\"\n      },\n      \"tree\": {\n        \"search\": \"Buscar configuraciones...\",\n        \"expandAll\": \"Expandir todo\",\n        \"collapseAll\": \"Contraer todo\",\n        \"noResults\": \"No se encontraron configuraciones\",\n        \"noConfigs\": \"No hay configuraciones disponibles\",\n        \"selectConfig\": \"Selecciona una configuración de la barra lateral para ver y editar sus ajustes.\"\n      },\n      \"deleteConfigDialog\": {\n        \"title\": \"¿Eliminar configuración?\",\n        \"description\": \"Esto eliminará permanentemente \\\"{{configName}}\\\" del ejecutor {{executorType}}. Esta acción no se puede deshacer.\"\n      }\n    },\n    \"mcp\": {\n      \"title\": \"Configuración de Servidor MCP\",\n      \"description\": \"Configura los servidores del Protocolo de Contexto de Modelos (MCP) para ampliar las capacidades del agente de codificación con herramientas y recursos personalizados.\",\n      \"loading\": \"Cargando configuración MCP...\",\n      \"applying\": \"Aplicando configuración...\",\n      \"loadingStates\": {\n        \"jsonEditor\": \"Cargando...\",\n        \"configuration\": \"Cargando configuración actual del servidor MCP...\"\n      },\n      \"labels\": {\n        \"agent\": \"Agente\",\n        \"agentPlaceholder\": \"Seleccionar\",\n        \"agentHelper\": \"Elige para qué agente configurar los servidores MCP.\",\n        \"serverConfig\": \"Configuración de Servidor (JSON)\",\n        \"popularServers\": \"Servidores populares\",\n        \"serverHelper\": \"Haz clic en una tarjeta para insertar ese Servidor MCP en el JSON de arriba.\",\n        \"saveLocation\": \"Los cambios se guardarán en:\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Error al cargar la configuración.\",\n        \"invalidJson\": \"Formato JSON inválido\",\n        \"validationError\": \"Error de validación\",\n        \"saveFailed\": \"Error al guardar servidores MCP\",\n        \"applyFailed\": \"Error al aplicar la configuración del servidor MCP\",\n        \"addServerFailed\": \"Error al agregar servidor preconfigurado\",\n        \"mcpError\": \"Error de Configuración MCP: {{error}}\",\n        \"notSupported\": \"MCP No Soportado\",\n        \"supportMessage\": \"Para usar servidores MCP, por favor selecciona un agente diferente que soporte MCP (Claude, Amp, Gemini, Codex, o Opencode) arriba.\"\n      },\n      \"save\": {\n        \"button\": \"Guardar Configuración MCP\",\n        \"success\": \"¡Configuración Guardada!\",\n        \"successMessage\": \"✓ ¡Configuración MCP guardada exitosamente!\",\n        \"loading\": \"Cargando configuración actual del servidor MCP...\",\n        \"unsavedChanges\": \"• Tienes cambios sin guardar\"\n      }\n    },\n    \"projects\": {\n      \"title\": \"Configuración de Proyectos\",\n      \"description\": \"Configura scripts y ajustes específicos del proyecto.\",\n      \"loading\": \"Cargando proyectos...\",\n      \"loadError\": \"Error al cargar proyectos.\",\n      \"selector\": {\n        \"label\": \"Seleccionar Proyecto\",\n        \"placeholder\": \"Elige un proyecto para configurar\",\n        \"helper\": \"Selecciona un proyecto para ver y editar su configuración.\",\n        \"noProjects\": \"No hay proyectos disponibles\"\n      },\n      \"general\": {\n        \"title\": \"Configuración General\",\n        \"description\": \"Configura la información básica del proyecto.\",\n        \"name\": {\n          \"label\": \"Nombre del Proyecto\",\n          \"placeholder\": \"Ingresa el nombre del proyecto\",\n          \"helper\": \"Un nombre para mostrar para este proyecto.\"\n        },\n        \"repoPath\": {\n          \"label\": \"Ruta del Repositorio Git\",\n          \"placeholder\": \"/ruta/a/tu/repositorio/existente\",\n          \"helper\": \"La ruta absoluta a tu repositorio git en disco.\"\n        }\n      },\n      \"save\": {\n        \"button\": \"Guardar Configuración del Proyecto\",\n        \"success\": \"✓ ¡Configuración del proyecto guardada exitosamente!\",\n        \"error\": \"Error al guardar la configuración del proyecto\",\n        \"unsavedChanges\": \"• Tienes cambios sin guardar\",\n        \"discard\": \"Descartar\",\n        \"confirmSwitch\": \"Tienes cambios sin guardar. ¿Estás seguro de que quieres cambiar de proyecto? Tus cambios se perderán.\",\n        \"saving\": \"Saving...\"\n      },\n      \"repositories\": {\n        \"title\": \"Repositorios\",\n        \"description\": \"Administra los repositorios git en este proyecto\",\n        \"noRepositories\": \"No hay repositorios configurados\",\n        \"addRepository\": \"Agregar Repositorio\"\n      }\n    },\n    \"repos\": {\n      \"title\": \"Configuración del Repositorio\",\n      \"description\": \"Configura los scripts que se ejecutan cuando este repositorio se usa en workspaces.\",\n      \"loading\": \"Cargando repositorios...\",\n      \"loadError\": \"Error al cargar repositorios.\",\n      \"addRepo\": {\n        \"button\": \"Agregar Repositorio\",\n        \"dialogTitle\": \"Seleccionar Repositorio Git\",\n        \"dialogDescription\": \"Elige un repositorio git existente para registrar.\",\n        \"error\": \"Error al registrar el repositorio\"\n      },\n      \"selector\": {\n        \"label\": \"Seleccionar Repositorio\",\n        \"placeholder\": \"Elige un repositorio para configurar\",\n        \"helper\": \"Selecciona un repositorio para ver y editar su configuración.\",\n        \"noRepos\": \"No hay repositorios disponibles\"\n      },\n      \"general\": {\n        \"title\": \"Configuración General\",\n        \"description\": \"Configura la información básica del repositorio.\",\n        \"displayName\": {\n          \"label\": \"Nombre para Mostrar\",\n          \"placeholder\": \"Ingresa el nombre para mostrar\",\n          \"helper\": \"Un nombre amigable para este repositorio.\"\n        },\n        \"path\": {\n          \"label\": \"Ruta del Repositorio\"\n        },\n        \"defaultWorkingDir\": {\n          \"label\": \"Directorio de Trabajo Predeterminado\",\n          \"placeholder\": \"ej., packages/frontend\",\n          \"helper\": \"Subdirectorio relativo a la raíz del repositorio donde se ejecuta el agente de codificación para espacios de trabajo de un solo repositorio. Se establece al crear el espacio de trabajo. Déjelo vacío para usar la raíz del repositorio.\"\n        },\n        \"defaultTargetBranch\": {\n          \"label\": \"Rama Objetivo Predeterminada\",\n          \"placeholder\": \"Seleccionar una rama\",\n          \"helper\": \"La rama base predeterminada para nuevos workspaces. Los worktrees se ramifican desde esta rama y los PRs la tendrán como objetivo.\",\n          \"search\": \"Buscar ramas\",\n          \"noBranches\": \"No se encontraron ramas\",\n          \"loading\": \"Cargando ramas...\",\n          \"useCurrent\": \"Usar rama actual\"\n        }\n      },\n      \"scripts\": {\n        \"title\": \"Scripts y Configuración\",\n        \"description\": \"Configura los scripts de instalación, limpieza y archivos a copiar para este repositorio. Estos scripts se ejecutan cada vez que el repositorio se usa en cualquier workspace.\",\n        \"setup\": {\n          \"label\": \"Script de Instalación\",\n          \"helper\": \"Este script se ejecuta desde dentro del worktree después de crearse y antes de que comience el agente de codificación. Úsalo para tareas de configuración como instalar dependencias o preparar el entorno.\",\n          \"parallelLabel\": \"Ejecutar script de instalación en paralelo con el agente de codificación\",\n          \"parallelHelper\": \"Cuando está habilitado, el script de instalación se ejecuta simultáneamente con el agente de codificación en lugar de esperar a que se complete la configuración primero.\"\n        },\n        \"cleanup\": {\n          \"label\": \"Script de Limpieza\",\n          \"helper\": \"Este script se ejecuta desde dentro del worktree después de la ejecución del agente de codificación, solo si se realizaron cambios. Úsalo para tareas de garantía de calidad como ejecutar linters, formateadores, pruebas u otros pasos de validación.\"\n        },\n        \"archive\": {\n          \"label\": \"Script de Archivo\",\n          \"helper\": \"Este script se ejecuta desde dentro del worktree cuando se archiva el espacio de trabajo. Úsalo para tareas de limpieza como detener servicios, liberar recursos o guardar estado.\"\n        },\n        \"copyFiles\": {\n          \"label\": \"Copiar Archivos\",\n          \"helper\": \"Lista separada por comas de archivos para copiar del directorio del repositorio original al worktree. Útil para archivos de entorno como .env. ¡Asegúrate de que estén en gitignore!\",\n          \"placeholder\": \"Rutas de archivos o patrones glob (ej., .env, config/*.json)\"\n        },\n        \"devServer\": {\n          \"label\": \"Script del Servidor de Desarrollo\",\n          \"helper\": \"Inicia un servidor de desarrollo para este repositorio. Los scripts se ejecutan desde el directorio worktree del repositorio.\"\n        }\n      },\n      \"linkedProjects\": {\n        \"title\": \"Proyectos Vinculados\",\n        \"description\": \"Proyectos que usan este repositorio en su configuración de workspace predeterminada.\",\n        \"loading\": \"Verificando proyectos…\",\n        \"none\": \"No hay proyectos vinculados\"\n      },\n      \"remove\": {\n        \"title\": \"Eliminar Repositorio\",\n        \"description\": \"Desvincular este repositorio de Vibe Kanban. Los archivos en disco no se eliminarán.\",\n        \"button\": \"Eliminar\",\n        \"confirm\": \"Eliminar Repositorio\",\n        \"dialogTitle\": \"¿Eliminar \\\"{{name}}\\\"?\",\n        \"dialogDescription\": \"Esto desvinculará el repositorio de Vibe Kanban. Tus archivos en disco no se verán afectados. Puedes volver a agregarlo más tarde.\",\n        \"success\": \"Repositorio eliminado exitosamente.\",\n        \"error\": \"Error al eliminar el repositorio.\"\n      },\n      \"save\": {\n        \"button\": \"Guardar Configuración del Repositorio\",\n        \"success\": \"¡Configuración del repositorio guardada exitosamente!\",\n        \"error\": \"Error al guardar la configuración del repositorio\",\n        \"unsavedChanges\": \"Tienes cambios sin guardar\",\n        \"discard\": \"Descartar\",\n        \"confirmSwitch\": \"Tienes cambios sin guardar. ¿Estás seguro de que quieres cambiar de repositorio? Tus cambios se perderán.\"\n      }\n    },\n    \"relay\": {\n      \"title\": \"Remote Access\",\n      \"description\": \"Allow access to this instance from the web via relay tunnel.\",\n      \"enabled\": {\n        \"label\": \"Enable remote access\",\n        \"helper\": \"When enabled, this instance can be accessed remotely through a relay tunnel.\"\n      },\n      \"enrollmentCode\": {\n        \"label\": \"Pairing Code\",\n        \"helper\": \"Enter this code in the browser to pair with this instance.\",\n        \"loginRequired\": \"Log in to generate a pairing code.\",\n        \"fetchError\": \"Failed to fetch pairing code.\",\n        \"show\": \"Show pairing code\"\n      }\n    },\n    \"remoteProjects\": {\n      \"title\": \"Remote Projects\",\n      \"description\": \"Manage cloud-synced projects across organizations.\",\n      \"loading\": \"Loading remote projects...\",\n      \"loadError\": \"Failed to load organizations\",\n      \"noProjects\": \"No projects yet. Create one to get started.\",\n      \"selectOrg\": \"Select an organization\",\n      \"createSuccess\": \"Project created successfully\",\n      \"deleteSuccess\": \"Project deleted successfully\",\n      \"nameRequired\": \"Project name is required\",\n      \"saveSuccess\": \"Project updated successfully\",\n      \"saveError\": \"Failed to update project\",\n      \"columns\": {\n        \"organizations\": \"Organizations\",\n        \"projects\": \"Projects\"\n      },\n      \"actions\": {\n        \"addProject\": \"Add Project\"\n      },\n      \"form\": {\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\"\n        },\n        \"color\": {\n          \"label\": \"Project Color\"\n        },\n        \"statuses\": {\n          \"label\": \"Project Statuses\",\n          \"description\": \"Manage kanban columns for this project.\"\n        },\n        \"defaultRepos\": {\n          \"label\": \"Default Repositories\",\n          \"description\": \"Repositories that are automatically added when creating workspaces from this project's issues.\",\n          \"empty\": \"No default repositories configured\",\n          \"addButton\": \"Add\",\n          \"addNew\": \"Add new repository...\",\n          \"branchLabel\": \"Branch\",\n          \"noBranches\": \"No branches found for this repository\",\n          \"fetchError\": \"Could not load repositories\",\n          \"saveError\": \"Failed to save default repositories\"\n        }\n      },\n      \"loginRequired\": {\n        \"title\": \"Sign in required\",\n        \"description\": \"Sign in to manage your remote projects.\",\n        \"action\": \"Sign in\"\n      }\n    }\n  },\n  \"integrations\": {\n    \"github\": {\n      \"cliSetup\": {\n        \"title\": \"Configuración de GitHub CLI\",\n        \"description\": \"Se requiere autenticación de GitHub CLI para crear pull requests e interactuar con repositorios de GitHub.\",\n        \"setupWillTitle\": \"Esta configuración:\",\n        \"steps\": {\n          \"checkInstalled\": \"Verificar si GitHub CLI (gh) está instalado\",\n          \"installHomebrew\": \"Instalarlo a través de Homebrew si es necesario (macOS)\",\n          \"authenticate\": \"Autenticar con GitHub usando OAuth\"\n        },\n        \"setupNote\": \"La configuración se ejecutará en la ventana de chat. Necesitarás completar la autenticación en tu navegador.\",\n        \"runSetup\": \"Ejecutar Configuración\",\n        \"running\": \"Ejecutando...\",\n        \"errors\": {\n          \"brewMissing\": \"Homebrew no está instalado. Instálalo para habilitar la configuración automática.\",\n          \"notSupported\": \"La configuración automática no está soportada en esta plataforma. Instala GitHub CLI manualmente.\",\n          \"setupFailed\": \"Error al ejecutar la configuración de GitHub CLI.\"\n        },\n        \"help\": {\n          \"homebrew\": {\n            \"description\": \"La instalación automática requiere Homebrew. Instala Homebrew desde\",\n            \"brewSh\": \"brew.sh\",\n            \"manualInstall\": \"y luego vuelve a ejecutar la configuración. Alternativamente, instala GitHub CLI manualmente con:\",\n            \"afterInstall\": \"Después de la instalación, autentica con:\"\n          },\n          \"manual\": {\n            \"description\": \"Instala GitHub CLI desde la\",\n            \"officialDocs\": \"documentación oficial\",\n            \"andAuthenticate\": \"y luego autentica con tu cuenta de GitHub.\"\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/es/tasks.json",
    "content": "{\n  \"dropzone\": {\n    \"dropImagesHere\": \"Suelta las imágenes aquí\",\n    \"supportedFormats\": \"PNG, JPG, GIF, WebP, SVG compatibles\"\n  },\n  \"gitPanel\": {\n    \"advanced\": {\n      \"placeholder\": \"ej. acme Corp\"\n    }\n  },\n  \"actions\": {\n    \"addTask\": \"Agregar tarea\"\n  },\n  \"editBranchName\": {\n    \"dialog\": {\n      \"title\": \"Editar nombre de rama\",\n      \"description\": \"Ingrese un nuevo nombre para la rama. No se puede renombrar si existe un PR abierto.\",\n      \"branchNameLabel\": \"Nombre de rama\",\n      \"placeholder\": \"ej., feature/mi-rama\",\n      \"renaming\": \"Renombrando...\",\n      \"action\": \"Renombrar rama\"\n    }\n  },\n  \"startReviewDialog\": {\n    \"title\": \"Iniciar revisión\",\n    \"description\": \"Pide al agente de codificación que revise tus cambios y proporcione comentarios.\",\n    \"additionalInstructions\": \"Instrucciones adicionales (opcional)\",\n    \"reviewComments\": \"Comentarios de revisión ({{count}})\",\n    \"includeGitContext\": \"Incluir contexto de git\",\n    \"includeGitContextDescription\": \"Indica al agente cómo ver todos los cambios realizados en esta rama\",\n    \"newSession\": \"Nueva sesión\",\n    \"sessionName\": \"Revisión\"\n  },\n  \"actionsMenu\": {\n    \"startReview\": \"Iniciar revisión\",\n    \"startingReview\": \"Iniciando revisión...\"\n  },\n  \"attempt\": {\n    \"actions\": {\n      \"startDevServer\": \"Iniciar servidor de desarrollo\"\n    }\n  },\n  \"resolveConflicts\": {\n    \"dialog\": {\n      \"title\": \"Resolver Conflictos\",\n      \"description\": \"Se detectaron conflictos. Elige cómo quieres que el agente los resuelva.\",\n      \"newSession\": \"Nueva sesión\",\n      \"resolve\": \"Resolver Conflictos\",\n      \"resolving\": \"Iniciando...\",\n      \"filesWithConflicts_one\": \"{{count}} archivo tiene conflictos\",\n      \"filesWithConflicts_other\": \"{{count}} archivos tienen conflictos\",\n      \"andMore\": \"...y {{count}} más\",\n      \"sessionName\": \"Resolver conflictos\"\n    }\n  },\n  \"rebaseInProgress\": {\n    \"dialog\": {\n      \"title\": \"Rebase en Progreso\",\n      \"description\": \"Un rebase hacia {{targetBranch}} está en progreso sin conflictos. Elige cómo proceder.\",\n      \"hint\": \"Puedes continuar el rebase para completarlo, o cancelar para volver al estado anterior.\",\n      \"continue\": \"Continuar Rebase\",\n      \"continuing\": \"Continuando...\",\n      \"abort\": \"Cancelar Rebase\",\n      \"aborting\": \"Cancelando...\",\n      \"continueError\": \"Error al continuar el rebase. Puede haber conflictos sin resolver.\",\n      \"abortError\": \"Error al cancelar el rebase. Por favor, inténtalo de nuevo.\"\n    }\n  },\n  \"createPrDialog\": {\n    \"title\": \"Crear Pull Request\",\n    \"description\": \"Crea un pull request para este intento de tarea.\",\n    \"titleLabel\": \"Título\",\n    \"titlePlaceholder\": \"Ingresar título del PR\",\n    \"descriptionLabel\": \"Descripción (opcional)\",\n    \"descriptionPlaceholder\": \"Ingresar descripción del PR\",\n    \"baseBranchLabel\": \"Rama Base\",\n    \"loadingBranches\": \"Cargando ramas...\",\n    \"selectBaseBranch\": \"Seleccionar rama base\",\n    \"draftLabel\": \"Crear como borrador\",\n    \"autoGenerateLabel\": \"Pedir al agente de IA que genere una mejor descripción del PR\",\n    \"creating\": \"Creando...\",\n    \"createButton\": \"Crear PR\",\n    \"errors\": {\n      \"failedToCreate\": \"Error al crear PR\",\n      \"gitCliNotLoggedIn\": \"Git no está autenticado. Ejecuta \\\"gh auth login\\\" (o configura las credenciales de Git) e inténtalo de nuevo.\",\n      \"gitCliNotInstalled\": \"Git CLI no está instalado. Instala Git para crear una PR.\",\n      \"targetBranchNotFound\": \"La rama objetivo '{{branch}}' no existe en el remoto. Por favor, asegúrese de que la rama exista antes de crear una solicitud de extracción.\"\n    }\n  },\n  \"branches\": {\n    \"changeTarget\": {\n      \"dialog\": {\n        \"action\": \"Cambiar rama\",\n        \"description\": \"Elige una nueva rama de destino para el intento de tarea.\",\n        \"inProgress\": \"Cambiando...\",\n        \"placeholder\": \"Selecciona una rama de destino\",\n        \"title\": \"Cambiar rama de destino\"\n      }\n    }\n  },\n  \"repos\": {\n    \"selector\": {\n      \"placeholder\": \"Seleccionar repositorio\",\n      \"empty\": \"No hay repositorios disponibles\"\n    }\n  },\n  \"repoBranchSelector\": {\n    \"label\": \"Rama base\"\n  },\n  \"diff\": {\n    \"filesChanged_one\": \"{{count}} file changed\",\n    \"filesChanged_other\": \"{{count}} files changed\",\n    \"largeDiff\": {\n      \"title\": \"Archivo grande\",\n      \"linesChanged\": \"{{count}} líneas cambiadas\",\n      \"loadAnyway\": \"Cargar diff de todos modos\",\n      \"warning\": \"Los diffs grandes pueden ralentizar tu navegador.\"\n    }\n  },\n  \"followUp\": {\n    \"queuedMessage\": \"Mensaje en cola - se ejecutará cuando finalice la ejecución actual\"\n  },\n  \"git\": {\n    \"branch\": {\n      \"current\": \"actual\"\n    },\n    \"forcePushDialog\": {\n      \"title\": \"Se requiere push forzado\",\n      \"description\": \"La rama remota{{branchLabel}} se ha desviado de tu rama local. Se rechazó un push normal.\",\n      \"warning\": \"El push forzado sobrescribirá los cambios remotos con tus cambios locales. Esta acción no se puede deshacer.\",\n      \"note\": \"Solo continúa si estás seguro de que deseas reemplazar el historial remoto de la rama.\",\n      \"error\": \"No se pudo hacer el push forzado\"\n    },\n    \"errors\": {\n      \"changeTargetBranch\": \"Error al cambiar rama de destino\",\n      \"mergeChanges\": \"Error al fusionar cambios\",\n      \"pushChanges\": \"Error al enviar cambios\",\n      \"rebaseBranch\": \"Error al hacer rebase de la rama\",\n      \"branchStatusUnavailable\": \"No se puede obtener el estado de la rama. Aún puedes cambiar la rama de destino.\"\n    },\n    \"labels\": {\n      \"taskBranch\": \"Rama de tarea\"\n    },\n    \"pr\": {\n      \"number\": \"PR #{{number}}\",\n      \"open\": \"Open PR #{{number}}\",\n      \"merged\": \"PR #{{prNumber}} fusionado\"\n    },\n    \"createRepo\": {\n      \"dialog\": {\n        \"title\": \"Crear Nuevo Repositorio\",\n        \"description\": \"Inicializar un nuevo repositorio git\"\n      },\n      \"form\": {\n        \"nameLabel\": \"Nombre\",\n        \"namePlaceholder\": \"mi-proyecto\",\n        \"locationLabel\": \"Ubicación\",\n        \"locationPlaceholder\": \"Directorio actual\"\n      },\n      \"browseDialog\": {\n        \"title\": \"Seleccionar Directorio Principal\",\n        \"description\": \"Elige dónde crear el nuevo repositorio\"\n      },\n      \"errors\": {\n        \"nameRequired\": \"El nombre del repositorio es obligatorio\",\n        \"createFailed\": \"Error al crear el repositorio\"\n      },\n      \"buttons\": {\n        \"createRepository\": \"Crear Repositorio\"\n      },\n      \"states\": {\n        \"creating\": \"Creando...\"\n      }\n    },\n    \"actions\": {\n      \"title\": \"Acciones de Git\",\n      \"prMerged\": \"PR #{{number}} ya está fusionado\",\n      \"changeTarget\": \"Cambiar destino\",\n      \"loginRequired\": {\n        \"title\": \"Inicia sesión para gestionar acciones de Git\",\n        \"description\": \"Inicia sesión en Vibe Kanban para enviar ramas, fusionar cambios o abrir pull requests para esta tarea.\",\n        \"action\": \"Iniciar sesión\"\n      }\n    },\n    \"mergeDialog\": {\n      \"title\": \"Fusionar cambios\",\n      \"description\": \"Esto fusionará sus cambios en la rama de destino. ¿Está seguro de que desea continuar?\"\n    },\n    \"states\": {\n      \"createPr\": \"Crear PR\",\n      \"forcePush\": \"Push forzado\",\n      \"forcePushing\": \"Push forzado en curso...\",\n      \"creating\": \"Creando...\",\n      \"merge\": \"Fusionar\",\n      \"merged\": \"¡Fusionado!\",\n      \"merging\": \"Fusionando...\",\n      \"push\": \"Enviar\",\n      \"pushed\": \"¡Enviado!\",\n      \"pushing\": \"Enviando...\",\n      \"pushFailed\": \"Falló\",\n      \"rebase\": \"Rebase\",\n      \"rebasing\": \"Rebaseando...\"\n    },\n    \"status\": {\n      \"ahead\": \"adelante\",\n      \"behind\": \"atrás\",\n      \"commits_one\": \"commit\",\n      \"commits_other\": \"commits\",\n      \"conflicts\": \"Conflictos\",\n      \"upToDate\": \"Al día\"\n    }\n  },\n  \"loading\": \"Cargando tareas...\",\n  \"preview\": {\n    \"logs\": {\n      \"label\": \"Registros\",\n      \"viewFull\": \"Ver registros completos\"\n    },\n    \"browser\": {\n      \"title\": \"Vista previa del servidor de desarrollo\",\n      \"devServerFallback\": \"Servidor de desarrollo\"\n    },\n    \"noServer\": {\n      \"setupTitle\": \"Debes configurar un script de servidor de desarrollo para usar la función de vista previa\",\n      \"setupPrompt\": \"Para usar la vista previa en vivo y la función de hacer clic y editar, por favor agrega un script de servidor de desarrollo a este proyecto.\",\n      \"title\": \"No hay servidor de desarrollo en ejecución\",\n      \"editDevScript\": \"Editar Script de Servidor de Desarrollo\",\n      \"learnMore\": \"Más información sobre cómo probar aplicaciones\"\n    },\n    \"toolbar\": {\n      \"copyUrl\": \"Copiar URL\",\n      \"openInTab\": \"Abrir en nueva pestaña\",\n      \"refresh\": \"Actualizar vista previa\",\n      \"stopDevServer\": \"Detener servidor de desarrollo\",\n      \"resetUrl\": \"Restablecer a URL detectada\",\n      \"clearUrlOverride\": \"Limpiar anulación de URL\",\n      \"desktopView\": \"Vista de escritorio\",\n      \"mobileView\": \"Vista móvil (390x844)\",\n      \"responsiveView\": \"Vista adaptable (redimensionable)\",\n      \"startDevServer\": \"Iniciar servidor de desarrollo\",\n      \"submitUrl\": \"Enviar URL\",\n      \"toggleDevTools\": \"Alternar DevTools\"\n    },\n    \"loading\": {\n      \"startingServer\": \"Iniciando servidor de desarrollo...\",\n      \"waitingForServer\": \"Esperando servidor...\",\n      \"loadingPreview\": \"Cargando vista previa...\",\n      \"manualUrlHint\": \"No se detectó ninguna URL aún. Puedes ingresar una URL manualmente en la barra de herramientas de arriba.\"\n    }\n  },\n  \"processes\": {\n    \"noLogsAvailable\": \"No hay registros disponibles\",\n    \"agent\": \"Agent:\",\n    \"backToList\": \"Back to list\",\n    \"completed\": \"Completed: {{date}}\",\n    \"deleted\": \"Deleted\",\n    \"deletedTooltip\": \"Deleted by restore: timeline was restored to a checkpoint and later executions were removed\",\n    \"detailsTitle\": \"Process Details\",\n    \"errorLoadingDetails\": \"Failed to load process details. Please try again.\",\n    \"errorLoadingUpdates\": \"Failed to load live updates for processes.\",\n    \"exit\": \"Exit: {{code}}\",\n    \"loading\": \"Loading execution processes...\",\n    \"loadingDetails\": \"Loading process details...\",\n    \"noProcesses\": \"No execution processes found for this attempt.\",\n    \"processId\": \"Process ID: {{id}}\",\n    \"reconnecting\": \"Reconnecting...\",\n    \"selectAttempt\": \"Select an attempt to view execution processes.\",\n    \"started\": \"Started: {{date}}\",\n    \"copyLogs\": \"Copiar registros\",\n    \"logsCopied\": \"¡Copiado!\"\n  },\n  \"rebase\": {\n    \"common\": {\n      \"action\": \"Rebase\",\n      \"inProgress\": \"Rebaseando...\"\n    },\n    \"dialog\": {\n      \"advanced\": \"Avanzado\",\n      \"description\": \"Elige una nueva rama base para hacer rebase de este intento de tarea.\",\n      \"targetLabel\": \"Rama de destino\",\n      \"targetPlaceholder\": \"Selecciona una rama de destino\",\n      \"title\": \"Rebase del intento de tarea\",\n      \"upstreamLabel\": \"Rama upstream\",\n      \"upstreamPlaceholder\": \"Selecciona una rama upstream\"\n    }\n  },\n  \"todoPopup\": {\n    \"title\": \"Tareas\",\n    \"progress\": \"{{completed}}/{{total}} completadas\",\n    \"noTasks\": \"Sin tareas\"\n  },\n  \"viewProcessesDialog\": {\n    \"title\": \"Execution processes\"\n  },\n  \"prComments\": {\n    \"dialog\": {\n      \"title\": \"Seleccionar comentarios del PR\",\n      \"noComments\": \"No se encontraron comentarios en este PR\",\n      \"selectAll\": \"Seleccionar todo\",\n      \"deselectAll\": \"Deseleccionar todo\",\n      \"add\": \"Agregar\",\n      \"selectedCount\": \"{{selected}} de {{total}} seleccionados\"\n    },\n    \"card\": {\n      \"review\": \"Revisión\",\n      \"tooltip\": \"Clic para ver, doble clic para editar\"\n    }\n  },\n  \"taskFormDialog\": {\n    \"createTitle\": \"Crear Nueva Tarea\",\n    \"editTitle\": \"Editar Tarea\",\n    \"titlePlaceholder\": \"Título de la tarea\",\n    \"descriptionPlaceholder\": \"Agrega más detalles (opcional). Escribe @ para buscar archivos.\",\n    \"statusLabel\": \"Estado\",\n    \"statusOptions\": {\n      \"todo\": \"Por Hacer\",\n      \"inprogress\": \"En Progreso\",\n      \"inreview\": \"En Revisión\",\n      \"done\": \"Completado\",\n      \"cancelled\": \"Cancelado\"\n    },\n    \"startLabel\": \"Iniciar\",\n    \"attachFile\": \"Adjuntar archivo\",\n    \"dropImagesHere\": \"Suelta las imágenes aquí\",\n    \"updating\": \"Actualizando...\",\n    \"updateTask\": \"Actualizar Tarea\",\n    \"starting\": \"Iniciando...\",\n    \"creating\": \"Creando...\",\n    \"create\": \"Crear\",\n    \"discardDialog\": {\n      \"title\": \"¿Descartar cambios sin guardar?\",\n      \"description\": \"Tienes cambios sin guardar. ¿Estás seguro de que deseas descartarlos?\",\n      \"continueEditing\": \"Continuar Editando\",\n      \"discardChanges\": \"Descartar Cambios\"\n    }\n  },\n  \"restoreLogsDialog\": {\n    \"title\": \"Confirmar Reintento\",\n    \"titleReset\": \"Confirmar Restablecimiento\",\n    \"historyChange\": {\n      \"title\": \"Cambio de historial\",\n      \"willDelete\": \"Eliminará este proceso\",\n      \"willDeleteProcesses_one\": \"Eliminará {{count}} proceso\",\n      \"willDeleteProcesses_other\": \"Eliminará {{count}} procesos\",\n      \"andLaterProcesses_one\": \"y {{count}} proceso posterior\",\n      \"andLaterProcesses_other\": \"y {{count}} procesos posteriores\",\n      \"fromHistory\": \"del historial.\",\n      \"codingAgentRuns_one\": \"{{count}} ejecución de agente de codificación\",\n      \"codingAgentRuns_other\": \"{{count}} ejecuciones de agente de codificación\",\n      \"scriptProcesses_one\": \"{{count}} proceso de script\",\n      \"scriptProcesses_other\": \"{{count}} procesos de script\",\n      \"setupCleanupBreakdown\": \"({{setup}} configuración, {{cleanup}} limpieza)\",\n      \"permanentWarning\": \"Esto altera permanentemente el historial y no se puede deshacer.\"\n    },\n    \"uncommittedChanges\": {\n      \"title\": \"Cambios sin confirmar detectados\",\n      \"description_one\": \"Tienes {{count}} cambio sin confirmar\",\n      \"description_other\": \"Tienes {{count}} cambios sin confirmar\",\n      \"andUntracked_one\": \" y {{count}} archivo sin rastrear\",\n      \"andUntracked_other\": \" y {{count}} archivos sin rastrear\",\n      \"acknowledgeLabel\": \"Entiendo que estos cambios pueden verse afectados\"\n    },\n    \"resetWorktree\": {\n      \"title\": \"Restablecer worktree\",\n      \"enabled\": \"Habilitado\",\n      \"disabled\": \"Deshabilitado\",\n      \"disabledUncommitted\": \"Deshabilitado (cambios sin confirmar detectados)\",\n      \"restoreDescription\": \"Tu worktree será restaurado a este commit.\",\n      \"discardChanges_one\": \"Descartar {{count}} cambio sin confirmar.\",\n      \"discardChanges_other\": \"Descartar {{count}} cambios sin confirmar.\",\n      \"untrackedPresent_one\": \"{{count}} archivo sin rastrear presente (no afectado por el restablecimiento).\",\n      \"untrackedPresent_other\": \"{{count}} archivos sin rastrear presentes (no afectados por el restablecimiento).\",\n      \"forceReset\": \"Forzar restablecimiento (descartar cambios sin confirmar)\",\n      \"uncommittedWillDiscard\": \"Los cambios sin confirmar serán descartados.\",\n      \"uncommittedPresentHint\": \"Hay cambios sin confirmar. Activa Forzar restablecimiento o confirma/guarda para continuar.\"\n    },\n    \"buttons\": {\n      \"retry\": \"Reintentar\",\n      \"reset\": \"Restablecer\"\n    }\n  },\n  \"conversation\": {\n    \"you\": \"Tú\",\n    \"thinking\": \"Pensando\",\n    \"todo\": \"Tarea pendiente\",\n    \"todos\": \"Tareas pendientes\",\n    \"completed\": \"completado\",\n    \"incomplete\": \"incompleto\",\n    \"pending\": \"pendiente\",\n    \"inProgress\": \"en progreso\",\n    \"skipped\": \"omitido\",\n    \"error\": \"Error\",\n    \"retry\": \"Reintentar\",\n    \"showMore\": \"Mostrar más\",\n    \"showLess\": \"Mostrar menos\",\n    \"actions\": {\n      \"cancel\": \"Cancelar\",\n      \"submitFeedback\": \"Enviar comentarios\",\n      \"stop\": \"Detener\",\n      \"stopping\": \"Deteniendo\",\n      \"loading\": \"Cargando\",\n      \"send\": \"Enviar\",\n      \"sending\": \"Enviando\",\n      \"queue\": \"Poner en cola\",\n      \"cancelQueue\": \"Cancelar cola\",\n      \"requestChanges\": \"Solicitar cambios\",\n      \"approve\": \"Aprobar\",\n      \"clearReviewComments\": \"Limpiar comentarios de revisión\",\n      \"edit\": \"Editar mensaje\",\n      \"scrollToPreviousMessage\": \"Go to previous message\",\n      \"reset\": \"Restablecer\",\n      \"resetTooltip\": \"Restablecer a este punto\"\n    },\n    \"approval\": {\n      \"conflictWarning\": \"Los archivos en conflicto necesitan resolución manual\",\n      \"conflicts_one\": \"{{count}} archivo en conflicto necesita resolución manual\",\n      \"conflicts_other\": \"{{count}} archivos en conflicto necesitan resolución manual\"\n    },\n    \"executors\": \"Ejecutores\",\n    \"saveAsDefault\": \"Guardar como predeterminado\",\n    \"script\": {\n      \"clickToViewLogs\": \"Haz clic para ver registros\",\n      \"completedSuccessfully\": \"Completado exitosamente\",\n      \"exitCode\": \"Código de salida: {{code}}\",\n      \"running\": \"Ejecutando...\"\n    },\n    \"scriptPlaceholder\": {\n      \"setupTitle\": \"Script de Configuración\",\n      \"setupDescription\": \"No hay script de configuración. Los scripts de configuración se ejecutan antes de que el agente de codificación comience.\",\n      \"cleanupTitle\": \"Script de Limpieza\",\n      \"cleanupDescription\": \"No hay script de limpieza. Los scripts de limpieza se ejecutan después de que el agente de codificación termine.\",\n      \"configure\": \"Configurar\"\n    },\n    \"unableToRenderDiff\": \"No se puede mostrar la diferencia.\",\n    \"updatedTodos\": \"Tareas actualizadas\",\n    \"viewInChangesPanel\": \"Ver en panel de cambios\",\n    \"sessions\": {\n      \"newSession\": \"Nueva sesión\",\n      \"latest\": \"Última\",\n      \"previous\": \"Anterior\",\n      \"label\": \"Sesiones\",\n      \"noPreviousSessions\": \"No hay sesiones anteriores\",\n      \"rename\": \"Renombrar\",\n      \"renameTitle\": \"Renombrar sesión\",\n      \"renameDescription\": \"Ingrese un nuevo nombre para esta sesión.\",\n      \"renamePlaceholder\": \"Ingrese el nombre de la sesión\",\n      \"renaming\": \"Renombrando...\"\n    },\n    \"reviewComments\": {\n      \"count_one\": \"{{count}} comentario de revisión será incluido\",\n      \"count_other\": \"{{count}} comentarios de revisión serán incluidos\"\n    },\n    \"workspace\": {\n      \"create\": \"Crear\",\n      \"creating\": \"Creando...\"\n    },\n    \"fileEntry\": {\n      \"created\": \"Creado\",\n      \"modified\": \"Modificado\",\n      \"deleted\": \"Eliminado\",\n      \"renamed\": \"Renombrado\"\n    }\n  },\n  \"scriptFixer\": {\n    \"setupScriptTitle\": \"Corregir Script de Configuración\",\n    \"cleanupScriptTitle\": \"Corregir Script de Limpieza\",\n    \"archiveScriptTitle\": \"Corregir Script de Archivo\",\n    \"devServerTitle\": \"Corregir Script del Servidor de Desarrollo\",\n    \"scriptLabel\": \"Script (editar)\",\n    \"logsLabel\": \"Últimos Registros de Ejecución\",\n    \"saveButton\": \"Guardar\",\n    \"saveAndTestButton\": \"Guardar y Probar\",\n    \"noLogs\": \"No hay registros de ejecución disponibles\",\n    \"selectRepo\": \"Repositorio\",\n    \"fixScript\": \"Corregir Script\",\n    \"statusRunning\": \"Ejecutando...\",\n    \"statusSuccess\": \"Completado exitosamente\",\n    \"statusFailed\": \"Falló con código de salida {{exitCode}}\",\n    \"statusKilled\": \"El proceso fue terminado\"\n  },\n  \"createWorkspaceFromPr\": {\n    \"title\": \"Crear Espacio de Trabajo desde PR\",\n    \"description\": \"Selecciona una solicitud de extracción abierta para crear un espacio de trabajo. Se creará una tarea automáticamente usando el título del PR.\",\n    \"repositoryLabel\": \"Repositorio\",\n    \"remoteLabel\": \"Remoto\",\n    \"pullRequestLabel\": \"Solicitud de Extracción\",\n    \"loadingRepositories\": \"Cargando repositorios...\",\n    \"loadingRemotes\": \"Cargando remotos...\",\n    \"noRepositoriesFound\": \"No se encontraron repositorios\",\n    \"loadingPullRequests\": \"Cargando solicitudes de extracción...\",\n    \"selectRepositoryFirst\": \"Selecciona un repositorio primero\",\n    \"noPullRequestsFound\": \"No se encontraron solicitudes de extracción abiertas\",\n    \"runSetupScript\": \"Ejecutar script de configuración\",\n    \"creating\": \"Creando...\",\n    \"createWorkspace\": \"Crear Espacio de Trabajo\",\n    \"selectRepository\": \"Seleccionar un repositorio\",\n    \"selectRemote\": \"Seleccionar un remoto\",\n    \"selectPullRequest\": \"Seleccionar una solicitud de extracción\",\n    \"searchPrsPlaceholder\": \"Buscar PRs por número o título...\",\n    \"noMatchingPrs\": \"No hay solicitudes de extracción coincidentes\",\n    \"default\": \"predeterminado\",\n    \"openPrInBrowser\": \"Abrir PR en el navegador\",\n    \"errors\": {\n      \"cliNotInstalled\": \"{{provider}} CLI no está instalado\",\n      \"unsupportedProvider\": \"Proveedor Git no soportado\",\n      \"failedToLoadPrs\": \"Error al cargar solicitudes de extracción\",\n      \"prNotFound\": \"Solicitud de extracción no encontrada\",\n      \"failedToCreateWorkspace\": \"Error al crear espacio de trabajo\"\n    }\n  },\n  \"shareDialog\": {\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Sign in to access this feature.\",\n      \"action\": \"Sign in\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/fr/common.json",
    "content": "{\n  \"editorNames\": {\n    \"custom\": \"Personnalisé\"\n  },\n  \"buttons\": {\n    \"save\": \"Enregistrer\",\n    \"cancel\": \"Annuler\",\n    \"send\": \"Envoyer\",\n    \"delete\": \"Supprimer\",\n    \"edit\": \"Modifier\",\n    \"create\": \"Créer\",\n    \"continue\": \"Continuer\",\n    \"reset\": \"Réinitialiser\",\n    \"manage\": \"Gérer\",\n    \"connect\": \"Connecter\",\n    \"disconnect\": \"Déconnecter\",\n    \"close\": \"Fermer\",\n    \"replay\": \"Rejouer\",\n    \"discard\": \"Abandonner\",\n    \"addItem\": \"Ajouter un élément\",\n    \"reply\": \"Répondre\",\n    \"retry\": \"Retry\",\n    \"add\": \"Ajouter\"\n  },\n  \"form\": {\n    \"notSpecified\": \"Non spécifié\",\n    \"selectOption\": \"Sélectionner une option...\"\n  },\n  \"states\": {\n    \"loading\": \"Chargement...\",\n    \"loadingHistory\": \"Chargement de l'historique\",\n    \"saving\": \"Enregistrement...\",\n    \"error\": \"Erreur\",\n    \"success\": \"Succès\",\n    \"reconnecting\": \"Reconnexion\"\n  },\n  \"language\": {\n    \"browserDefault\": \"Par défaut du navigateur\"\n  },\n  \"conversation\": {\n    \"plan\": \"Plan\",\n    \"output\": \"Sortie\",\n    \"deniedByUser\": \"{{toolName}} refusé par l'utilisateur\",\n    \"tool\": \"Outil\",\n    \"thinking\": \"Réflexion\",\n    \"toolSummary\": {\n      \"read\": \"Lu {{path}}\",\n      \"searched\": \"Recherché \\\"{{query}}\\\"\",\n      \"fetched\": \"Récupéré {{url}}\",\n      \"ranCommand\": \"Commande exécutée\",\n      \"createdTask\": \"Tâche créée : {{description}}\",\n      \"todoOperation\": \"{{operation}} tâches à faire\"\n    },\n    \"subagent\": {\n      \"defaultType\": \"Sous-agent\"\n    },\n    \"loadingEarlierMessages\": \"Chargement des messages pr\\u00e9c\\u00e9dents\"\n  },\n  \"folderPicker\": {\n    \"legend\": \"Cliquez sur les noms de dossiers pour naviguer • Utilisez les boutons d'action pour sélectionner\",\n    \"manualPathLabel\": \"Saisir le chemin manuellement :\",\n    \"go\": \"Aller\",\n    \"searchLabel\": \"Rechercher dans le répertoire actuel :\",\n    \"selectCurrent\": \"Sélectionner l'actuel\",\n    \"gitRepo\": \"dépôt git\",\n    \"selectPath\": \"Sélectionner le chemin\"\n  },\n  \"branchSelector\": {\n    \"placeholder\": \"Sélectionner une branche\",\n    \"searchPlaceholder\": \"Rechercher des branches...\",\n    \"empty\": \"Aucune branche trouvée\",\n    \"badges\": {\n      \"current\": \"actuelle\",\n      \"remote\": \"distante\"\n    },\n    \"currentDisabled\": \"Impossible de sélectionner la branche actuelle\"\n  },\n  \"orgSwitcher\": {\n    \"organizations\": \"Organisations\",\n    \"createOrganization\": \"Créer une organisation\",\n    \"orgSettings\": \"Paramètres de l'organisation\"\n  },\n  \"ok\": \"OK\",\n  \"error\": \"Erreur\",\n  \"signIn\": \"Se connecter\",\n  \"signOut\": \"Se déconnecter\",\n  \"oauth\": {\n    \"title\": \"Se connecter à Vibe Kanban\",\n    \"description\": \"Connectez-vous pour rejoindre des organisations et partager des tâches avec votre équipe\",\n    \"continueWithGitHub\": \"Continuer avec GitHub\",\n    \"continueWithGoogle\": \"Continuer avec Google\",\n    \"waitingTitle\": \"Terminer l'authentification\",\n    \"waitingDescription\": \"Une fenêtre popup a été ouverte pour l'authentification\",\n    \"waitingForAuth\": \"En attente de l'authentification...\",\n    \"popupInstructions\": \"Si la fenêtre popup ne s'est pas ouverte, veuillez vérifier les paramètres de votre bloqueur de popups.\",\n    \"back\": \"Retour\",\n    \"successTitle\": \"Authentification réussie !\",\n    \"welcomeBack\": \"Bienvenue, {{name}}\",\n    \"errorTitle\": \"Échec de l'authentification\",\n    \"errorDescription\": \"Un problème est survenu lors de l'authentification de votre compte\",\n    \"tryAgain\": \"Réessayer\"\n  },\n  \"toolbar\": {\n    \"sortBy\": \"Trier par\",\n    \"groupBy\": \"Grouper par\"\n  },\n  \"sorting\": {\n    \"ascending\": \"Croissant\",\n    \"descending\": \"Décroissant\"\n  },\n  \"grouping\": {\n    \"date\": \"Date\",\n    \"assignee\": \"Assigné\",\n    \"label\": \"Libellé\"\n  },\n  \"workspaces\": {\n    \"title\": \"Espaces de travail\",\n    \"searchPlaceholder\": \"Rechercher...\",\n    \"active\": \"Actif\",\n    \"archived\": \"Archivé\",\n    \"loading\": \"Chargement...\",\n    \"notFound\": \"Espace de travail introuvable\",\n    \"selectToStart\": \"Sélectionnez un espace de travail pour commencer\",\n    \"draft\": \"Brouillon\",\n    \"viewArchive\": \"Voir les archives\",\n    \"backToActive\": \"Retour aux actifs\",\n    \"noArchived\": \"Aucun espace de travail archivé\",\n    \"noWorkspaces\": \"Aucun espace de travail\",\n    \"newWorkspace\": \"Nouvel espace de travail\",\n    \"needsAttention\": \"Nécessite Attention\",\n    \"idle\": \"Inactif\",\n    \"running\": \"En Cours\",\n    \"pin\": \"Épingler\",\n    \"unpin\": \"Désépingler\",\n    \"archive\": \"Archiver\",\n    \"more\": \"Plus d'actions\",\n    \"rename\": {\n      \"title\": \"Renommer l'espace de travail\",\n      \"description\": \"Saisissez un nouveau nom pour cet espace de travail.\",\n      \"nameLabel\": \"Nom\",\n      \"placeholder\": \"Saisir le nom de l'espace de travail\",\n      \"action\": \"Renommer\",\n      \"renaming\": \"Renommage...\"\n    },\n    \"unlinkFromIssue\": \"Dissocier du problème\",\n    \"deleteWorkspace\": \"Supprimer l'espace de travail\",\n    \"unlink\": \"Dissocier\",\n    \"delete\": \"Supprimer\",\n    \"unlinkConfirmMessage\": \"Êtes-vous sûr de vouloir dissocier cet espace de travail du problème ? L'espace de travail existera toujours mais ne sera plus associé à ce problème.\",\n    \"deleteConfirmMessage\": \"Êtes-vous sûr de vouloir supprimer cet espace de travail ? Cela le dissociera du problème et supprimera l'espace de travail local. Cette action est irréversible.\",\n    \"unlinkError\": \"Échec de la dissociation de l'espace de travail\",\n    \"deleteError\": \"Échec de la suppression de l'espace de travail\",\n    \"filesChanged\": \"{{count}} fichiers\",\n    \"deleteDialog\": {\n      \"title\": \"Supprimer l'espace de travail\",\n      \"description\": \"Êtes-vous sûr de vouloir supprimer cet espace de travail ? Cette action est irréversible.\",\n      \"deleteBranchLabel\": \"Supprimer la branche\",\n      \"cannotDeleteOpenPr\": \"Impossible de supprimer la branche tant que la PR est ouverte\",\n      \"unlinkFromIssueLabel\": \"Également dissocier du ticket\"\n    },\n    \"linkError\": \"Failed to link workspace\"\n  },\n  \"fileTree\": {\n    \"searchPlaceholder\": \"Rechercher des fichiers...\",\n    \"noResults\": \"Aucun fichier correspondant\",\n    \"title\": \"Fichiers\",\n    \"showGitHubComments\": \"Afficher les commentaires GitHub\",\n    \"hideGitHubComments\": \"Masquer les commentaires GitHub\",\n    \"prevGitHubComment\": \"Fichier précédent avec commentaires\",\n    \"nextGitHubComment\": \"Fichier suivant avec commentaires\"\n  },\n  \"sections\": {\n    \"changes\": \"Modifications\",\n    \"repositories\": \"Dépôts\",\n    \"addRepositories\": \"Ajouter des dépôts\",\n    \"project\": \"Projet\",\n    \"processes\": \"Processus\",\n    \"devServer\": \"Serveur de développement\",\n    \"advanced\": \"Avancé\",\n    \"workingBranch\": \"Branche de travail\",\n    \"recent\": \"Récent\",\n    \"other\": \"Autre\",\n    \"devServerPreview\": \"Aperçu du serveur de développement\",\n    \"terminal\": \"Terminal\",\n    \"notes\": \"Notes\"\n  },\n  \"notes\": {\n    \"placeholder\": \"Ajouter des notes sur cet espace de travail...\",\n    \"selectWorkspace\": \"Sélectionnez un espace de travail pour voir les notes\"\n  },\n  \"actions\": {\n    \"copyPath\": \"Copier le chemin\",\n    \"cancel\": \"Annuler\",\n    \"saveChanges\": \"Enregistrer les modifications\",\n    \"copied\": \"Copié\",\n    \"collapse\": \"Réduire\"\n  },\n  \"comments\": {\n    \"addReviewComment\": \"Ajouter un commentaire de révision\",\n    \"addPlaceholder\": \"Ajouter un commentaire...\",\n    \"editPlaceholder\": \"Modifier le commentaire...\",\n    \"copyToReview\": \"Copier vers la révision\"\n  },\n  \"confirm\": {\n    \"defaultConfirm\": \"Confirmer\",\n    \"defaultCancel\": \"Annuler\"\n  },\n  \"dialogs\": {\n    \"selectGitRepository\": \"Sélectionner un dépôt Git\",\n    \"chooseExistingRepo\": \"Choisissez un dépôt existant depuis votre système de fichiers\"\n  },\n  \"empty\": {\n    \"noChanges\": \"Aucune modification à afficher\"\n  },\n  \"commandBar\": {\n    \"noResults\": \"Aucun résultat trouvé.\",\n    \"back\": \"Retour\",\n    \"defaultPlaceholder\": \"Tapez une commande ou recherchez...\",\n    \"selectBranch\": \"Select Branch\",\n    \"selectBranchFor\": \"Select Branch for {{repoName}}\"\n  },\n  \"workspacesGuide\": {\n    \"welcome\": {\n      \"title\": \"Bienvenue\",\n      \"content\": \"Bienvenue dans les Espaces de travail, une interface repensée pour Vibe Kanban. Nous la déployons auprès d'utilisateurs sélectionnés pour recueillir leurs premiers retours. Partagez vos impressions à tout moment en utilisant l'icône de feedback dans la barre de navigation.\"\n    },\n    \"commandBar\": {\n      \"title\": \"Barre de commandes\",\n      \"content\": \"La barre de commandes est votre hub central de navigation. Ouvrez-la avec CMD+K pour rechercher et accéder à toutes les actions disponibles dans un espace de travail.\"\n    },\n    \"contextBar\": {\n      \"title\": \"Barre de contexte\",\n      \"content\": \"La barre de contexte vous permet de basculer rapidement entre les panneaux. Faites-la glisser là où cela vous convient le mieux.\"\n    },\n    \"sidebar\": {\n      \"title\": \"Barre latérale des espaces de travail\",\n      \"content\": \"Visualisez l'état de tous vos espaces de travail d'un coup d'œil. Les notifications mettent en évidence ceux qui nécessitent votre attention. Archivez les espaces de travail fusionnés pour garder votre barre latérale propre.\"\n    },\n    \"multiRepo\": {\n      \"title\": \"Support multi-dépôts\",\n      \"content\": \"Ajoutez plusieurs dépôts à un seul espace de travail. Référencez le code d'un dépôt tout en travaillant dans un autre, ou implémentez des modifications sur plusieurs dépôts à la fois.\"\n    },\n    \"sessions\": {\n      \"title\": \"Sessions multiples\",\n      \"content\": \"Créez plusieurs sessions de conversation d'agent au sein d'un même espace de travail, y compris des sessions avec différents agents. Cela vous aide à contourner les limites de conversation ou à lancer des agents de révision dans des fils séparés.\"\n    },\n    \"preview\": {\n      \"title\": \"Prévisualiser les modifications\",\n      \"content\": \"Prévisualisez votre travail dans un navigateur intégré sans changer de contexte. Testez sur ordinateur, mobile et des tailles de viewport personnalisées.\"\n    },\n    \"diffs\": {\n      \"title\": \"Diffs et commentaires\",\n      \"content\": \"Le panneau de diffs repensé inclut une arborescence de vos modifications. Commentez directement sur les diffs pour donner des retours à l'agent, et visualisez les commentaires GitHub lorsque votre espace de travail est lié à une PR.\"\n    },\n    \"classicUi\": {\n      \"title\": \"Retourner à l'interface classique\",\n      \"content\": \"Cliquez sur l'icône de sortie à gauche de la barre de navigation pour revenir au tableau kanban classique. Pour désactiver complètement la nouvelle interface, modifiez l'option \\\"Activer la bêta des espaces de travail\\\" sous \\\"Fonctionnalités bêta\\\" dans les paramètres.\"\n    }\n  },\n  \"logs\": {\n    \"searchLogs\": \"Rechercher dans les logs\",\n    \"selectProcessToView\": \"Sélectionnez un processus pour voir les logs\"\n  },\n  \"processes\": {\n    \"noProcesses\": \"Aucun processus\",\n    \"terminal\": \"Terminal\"\n  },\n  \"search\": {\n    \"matchCount\": \"{{current}} sur {{total}}\",\n    \"noMatches\": \"Aucune correspondance\"\n  },\n  \"contextUsage\": {\n    \"label\": \"Utilisation du contexte\",\n    \"emptyTooltip\": \"L'utilisation du contexte apparaît après la prochaine réponse\",\n    \"tooltip\": \"Contexte : {{percentage}}% · {{used}} / {{total}} tokens\",\n    \"ariaLabel\": \"Utilisation du contexte : {{percentage}}%\"\n  },\n  \"shortcuts\": {\n    \"title\": \"Raccourcis Clavier\",\n    \"inWorkspace\": \"(dans l'espace de travail)\",\n    \"sequentialHint\": \"Raccourcis séquentiels : Appuyez sur la première touche, puis sur la seconde en 500ms.\",\n    \"configurableHint\": \"Configurable dans Paramètres → Général → Saisie de Message\",\n    \"groups\": {\n      \"quickActions\": \"Actions Rapides\",\n      \"navigation\": \"Navigation\",\n      \"modifiers\": \"Modificateurs\",\n      \"goTo\": \"Aller à (G ...)\",\n      \"workspace\": \"Espace de travail (W ...)\",\n      \"view\": \"Vue (V ...)\",\n      \"issues\": \"Tickets (I ...)\",\n      \"git\": \"Git (X ...)\",\n      \"yank\": \"Copier (Y ...)\",\n      \"toggle\": \"Basculer (T ...)\",\n      \"run\": \"Exécuter (R ...)\"\n    },\n    \"actions\": {\n      \"showHelp\": \"Afficher cette aide\",\n      \"closeCancel\": \"Fermer/annuler\",\n      \"createNewTask\": \"Créer une nouvelle tâche\",\n      \"deleteSelected\": \"Supprimer la sélection\",\n      \"focusSearch\": \"Aller à la recherche\",\n      \"moveDown\": \"Déplacer vers le bas\",\n      \"moveUp\": \"Déplacer vers le haut\",\n      \"moveLeft\": \"Déplacer vers la gauche\",\n      \"moveRight\": \"Déplacer vers la droite\",\n      \"openCommandBar\": \"Ouvrir la barre de commandes\",\n      \"formatInlineCode\": \"Format inline code\",\n      \"sendMessage\": \"Envoyer le message\",\n      \"settings\": \"Aller aux Paramètres\",\n      \"new-workspace\": \"Aller au Nouvel espace de travail\",\n      \"duplicate-workspace\": \"Dupliquer l'espace de travail\",\n      \"rename-workspace\": \"Renommer l'espace de travail\",\n      \"pin-workspace\": \"Épingler/Désépingler l'espace de travail\",\n      \"archive-workspace\": \"Archiver l'espace de travail\",\n      \"delete-workspace\": \"Supprimer l'espace de travail\",\n      \"toggle-changes-mode\": \"Basculer le panneau Modifications\",\n      \"toggle-logs-mode\": \"Basculer le panneau Logs\",\n      \"toggle-preview-mode\": \"Basculer le panneau Aperçu\",\n      \"toggle-left-sidebar\": \"Basculer la barre latérale gauche\",\n      \"toggle-left-main-panel\": \"Basculer le panneau Chat\",\n      \"create-issue\": \"Créer un ticket\",\n      \"change-issue-status\": \"Changer le statut\",\n      \"change-issue-priority\": \"Changer la priorité\",\n      \"change-assignees\": \"Changer les assignés\",\n      \"make-sub-issue-of\": \"Transformer en sous-ticket de\",\n      \"add-sub-issue\": \"Ajouter un sous-ticket\",\n      \"remove-parent-issue\": \"Retirer le parent\",\n      \"link-workspace\": \"Lier un espace de travail\",\n      \"duplicate-issue\": \"Dupliquer le ticket\",\n      \"delete-issue\": \"Supprimer le ticket\",\n      \"git-create-pr\": \"Créer une Pull Request\",\n      \"git-merge\": \"Fusionner la branche\",\n      \"git-rebase\": \"Rebaser la branche\",\n      \"git-push\": \"Pousser les modifications\",\n      \"copy-path\": \"Copier le chemin\",\n      \"copy-raw-logs\": \"Copier les logs bruts\",\n      \"toggle-dev-server\": \"Basculer le serveur de développement\",\n      \"toggle-wrap-lines\": \"Basculer le retour à la ligne\",\n      \"run-setup-script\": \"Exécuter le script de configuration\",\n      \"run-cleanup-script\": \"Exécuter le script de nettoyage\",\n      \"run-archive-script\": \"Exécuter le script d'archivage\"\n    }\n  },\n  \"typeahead\": {\n    \"tags\": \"Tags\",\n    \"files\": \"Fichiers\",\n    \"commands\": \"Commandes\",\n    \"chooseRepo\": \"Choisir un dépôt\",\n    \"selectedRepo\": \"Dépôt sélectionné : {{repoName}}\",\n    \"missingRepo\": \"Le dépôt sélectionné n'est plus disponible.\",\n    \"noTagsOrFiles\": \"Aucun tag ou fichier trouvé\",\n    \"createTag\": \"Créer un nouveau tag\",\n    \"noCommands\": \"Aucune commande disponible pour cet agent.\"\n  },\n  \"kanban\": {\n    \"noVisibleStatuses\": \"Tous les statuts sont masqués. Utilisez les paramètres d'affichage ou changez d'onglet pour voir les problèmes.\",\n    \"noProjectFound\": \"Aucun projet trouvé\",\n    \"unassigned\": \"Non assigné\",\n    \"noTagsAvailable\": \"Aucun tag disponible\",\n    \"createNewIssue\": \"Créer un nouveau ticket\",\n    \"searchTags\": \"Rechercher des tags...\",\n    \"selectColorFor\": \"Sélectionner la couleur pour\",\n    \"createTag\": \"Créer\",\n    \"noPrCreated\": \"Aucune PR créée\",\n    \"noCommentsYet\": \"Aucun commentaire\",\n    \"createdBy\": \"Créé par\",\n    \"comments\": \"Commentaires\",\n    \"enterCommentPlaceholder\": \"Entrez votre commentaire ici...\",\n    \"attachFile\": \"Joindre un fichier\",\n    \"attachFileHint\": \"Joindre des fichiers (20Mo max par fichier)\",\n    \"dropFilesHere\": \"Déposez les fichiers ici\",\n    \"fileDropHint\": \"Tout type de fichier jusqu'à 20Mo\",\n    \"unknownUser\": \"Utilisateur inconnu\",\n    \"deletedUser\": \"Utilisateur supprimé\",\n    \"replyQuotePrefix\": \"a écrit :\",\n    \"moreActions\": \"Plus d'actions\",\n    \"closePanel\": \"Fermer le panneau\",\n    \"copyLink\": \"Copier le lien\",\n    \"issueTitlePlaceholder\": \"Titre du ticket...\",\n    \"issueDescriptionPlaceholder\": \"Entrez la description de la tâche...\",\n    \"createDraftWorkspaceImmediately\": \"Créer immédiatement un espace de travail brouillon\",\n    \"createDraftWorkspaceDescription\": \"Après la création de l'issue, ouvrir le formulaire de création de workspace pré-rempli avec les détails de l'issue\",\n    \"createIssue\": \"Créer la tâche\",\n    \"newIssue\": \"Nouveau ticket\",\n    \"previewCodeBlock\": \"[Bloc de code]\",\n    \"previewImage\": \"[Image]\",\n    \"previewImageWithName\": \"[Image : {{name}}]\",\n    \"previewFile\": \"[Fichier]\",\n    \"previewFileWithName\": \"[Fichier : {{name}}]\",\n    \"imageAttachmentNameFallback\": \"Pièce jointe\",\n    \"removeImage\": \"Supprimer l'image\",\n    \"maxFilesAtOnce\": \"Maximum {{count}} fichiers à la fois\",\n    \"fileExceedsLimit\": \"Le fichier {{filename}} dépasse la limite de 20Mo\",\n    \"unknownError\": \"Erreur inconnue\",\n    \"failedToUploadFile\": \"Échec de l'envoi de {{filename}} : {{message}}\",\n    \"downloadAttachment\": \"Télécharger la pièce jointe\",\n    \"subIssues\": \"Sous-tâches\",\n    \"noSubIssues\": \"Aucune sous-tâche\",\n    \"markIndependentIssue\": \"Marquer comme problème indépendant\",\n    \"parentIssue\": \"Parent\",\n    \"subIssueIndicator\": \"↳\",\n    \"viewTabs\": {\n      \"active\": \"Actif\",\n      \"all\": \"Tout\"\n    },\n    \"loginRequired\": {\n      \"title\": \"Connexion requise\",\n      \"description\": \"Veuillez vous connecter pour voir ce projet.\",\n      \"action\": \"Se connecter\"\n    },\n    \"team\": \"Team\",\n    \"personal\": \"Personal\",\n    \"searchPlaceholder\": \"Search issues...\",\n    \"filters\": \"Filters\",\n    \"clearFilters\": \"Clear filters\",\n    \"filtersDescription\": \"Adjust filters and sorting for this board view.\",\n    \"filterByTag\": \"Filter by tag\",\n    \"filterByPriority\": \"Filter by priority\",\n    \"tags\": \"Tags\",\n    \"sortBy\": \"Sort\",\n    \"workspaceSidebar\": {\n      \"sortDialogTitle\": \"Sort workspaces\",\n      \"sortDialogDescription\": \"Choose how workspaces are ordered in the sidebar.\",\n      \"sortByLabel\": \"Sort by\",\n      \"sortOrderLabel\": \"Sort order\",\n      \"sortUpdatedAt\": \"Updated at\",\n      \"sortCreatedAt\": \"Created at\",\n      \"filterDialogTitle\": \"Filter workspaces\",\n      \"filterDialogDescription\": \"Narrow down workspaces by project and PR state.\",\n      \"projectFilterLabel\": \"Project\",\n      \"prFilterLabel\": \"PR\",\n      \"prFilterAll\": \"All\",\n      \"prFilterHasPr\": \"Has PR\",\n      \"prFilterNoPr\": \"No PR\",\n      \"noProject\": \"No project\",\n      \"sortButtonTitle\": \"Sort workspaces\",\n      \"filterButtonTitle\": \"Filter workspaces\"\n    },\n    \"sortAscending\": \"Ascending\",\n    \"sortDescending\": \"Descending\",\n    \"assignee\": \"Assignee\",\n    \"self\": \"Me\",\n    \"priority\": \"Priority\",\n    \"subIssuesFilterLabel\": \"Sub-issues\",\n    \"workspacesFilterLabel\": \"Workspaces\",\n    \"selectAssignees\": \"Select assignees...\",\n    \"relationships\": \"Relationships\",\n    \"noRelationships\": \"No relationships\",\n    \"openProjectsGuide\": \"Projects guide\",\n    \"editProjectSettings\": \"Edit project settings\",\n    \"linkWorkspace\": \"Link workspace...\",\n    \"createNewWorkspace\": \"Create new workspace\",\n    \"workspaces\": \"Workspaces\",\n    \"showingWorkspaces\": \"Showing {{count}} of {{total}}\",\n    \"changeColor\": \"Change color\",\n    \"editName\": \"Edit name\",\n    \"deleteStatus\": \"Delete status\",\n    \"cannotDeleteWithIssues\": \"Move issues first\",\n    \"lastVisibleStatus\": \"At least one status must be visible\",\n    \"showStatus\": \"Show status\",\n    \"hideStatus\": \"Hide status\",\n    \"newStatus\": \"New Status\",\n    \"visibleColumns\": \"Visible Columns\",\n    \"dragToRearrange\": \"Drag to re-arrange\",\n    \"addColumn\": \"Add column\",\n    \"projectsGuide\": {\n      \"intro\": {\n        \"title\": \"Welcome\",\n        \"content\": \"Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.\"\n      },\n      \"welcome\": {\n        \"title\": \"Projects\",\n        \"content\": \"The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.\"\n      },\n      \"issues\": {\n        \"title\": \"Issues\",\n        \"content\": \"Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.\"\n      },\n      \"workspaces\": {\n        \"title\": \"Workspaces\",\n        \"content\": \"To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.\"\n      },\n      \"organizations\": {\n        \"title\": \"Invite your team\",\n        \"content\": \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      }\n    }\n  },\n  \"createMode\": {\n    \"headings\": {\n      \"repoStep\": \"Sur quels dépôts voulez-vous travailler ?\",\n      \"chatStep\": \"Sur quoi voulez-vous travailler ?\"\n    },\n    \"repoPicker\": {\n      \"actions\": {\n        \"recent\": \"Récents\",\n        \"browse\": \"Parcourir\",\n        \"create\": \"Créer\"\n      },\n      \"setupHintTitle\": \"Scripts de Configuration\",\n      \"setupHint\": \"Astuce : Configurez les scripts d'installation pour ce dépôt dans Paramètres → Dépôts\",\n      \"setupHintDismiss\": \"Fermer\",\n      \"setupHintLink\": \"Configure in Settings\"\n    }\n  },\n  \"modelSelector\": {\n    \"preset\": \"Préréglage\",\n    \"custom\": \"Personnaliser\",\n    \"permissions\": \"Permissions\",\n    \"permissionAuto\": \"Auto\",\n    \"permissionAsk\": \"Demander\",\n    \"permissionPlan\": \"Plan\",\n    \"agent\": \"Agent\",\n    \"default\": \"Par défaut\",\n    \"model\": \"Modèle\"\n  },\n  \"syncError\": {\n    \"networkErrors\": \"Erreurs réseau\",\n    \"streamsAffected\": \"{{count}} flux affecté\",\n    \"streamsAffected_other\": \"{{count}} flux affectés\",\n    \"status\": \"(statut {{status}})\",\n    \"refreshPage\": \"Actualiser la page\"\n  },\n  \"onboardingSignIn\": {\n    \"subtitle\": \"Sign in to continue\",\n    \"signedInAs\": \"Signed in as {{name}}.\",\n    \"moreOptions\": \"More options\",\n    \"featureHeader\": \"Feature\",\n    \"signedInHeader\": \"Signed in\",\n    \"skipSignInHeader\": \"Don't sign in\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\"\n  },\n  \"migration\": {\n    \"allProjectsMigrated\": \"Tous vos projets ont déjà été migrés vers le cloud.\",\n    \"continueToProjects\": \"Continuer vers les projets\"\n  },\n  \"appBar\": {\n    \"kanban\": {\n      \"tooltip\": \"Kanban\",\n      \"title\": \"Tableaux Kanban\",\n      \"description\": \"Connectez-vous pour organiser vos agents de codage avec des tableaux kanban.\",\n      \"migrateOldProjects\": \"Migrer les anciens projets\"\n    }\n  },\n  \"errors\": {\n    \"generic\": \"An unexpected error occurred\"\n  },\n  \"personal\": \"Personal\",\n  \"askQuestion\": {\n    \"title\": \"L'agent pose une question\",\n    \"selectMultiple\": \"sélection multiple\",\n    \"confirmSelection\": \"Confirmer la sélection\",\n    \"submitting\": \"Envoi des réponses...\",\n    \"answeredCount\": \"Répondu à {{count}} question\",\n    \"answeredCount_other\": \"Répondu à {{count}} questions\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/fr/organization.json",
    "content": "{\n  \"createDialog\": {\n    \"title\": \"Créer une nouvelle organisation\",\n    \"description\": \"Créez une nouvelle organisation pour collaborer avec votre équipe.\",\n    \"nameLabel\": \"Nom de l'organisation\",\n    \"namePlaceholder\": \"ex: Acme Corporation\",\n    \"slugLabel\": \"Identifiant\",\n    \"slugPlaceholder\": \"ex: acme-corporation\",\n    \"slugHelper\": \"Utilisé dans les URLs. Uniquement des lettres minuscules, chiffres et tirets.\",\n    \"creating\": \"Création en cours...\",\n    \"createButton\": \"Créer l'organisation\"\n  },\n  \"inviteDialog\": {\n    \"title\": \"Inviter un membre\",\n    \"description\": \"Envoyez une invitation pour rejoindre votre organisation.\",\n    \"emailLabel\": \"Adresse e-mail\",\n    \"emailPlaceholder\": \"collegue@exemple.com\",\n    \"roleLabel\": \"Rôle\",\n    \"rolePlaceholder\": \"Sélectionner un rôle\",\n    \"roleHelper\": \"Les administrateurs peuvent gérer les membres et les paramètres de l'organisation.\",\n    \"sending\": \"Envoi en cours...\",\n    \"sendButton\": \"Envoyer l'invitation\",\n    \"subscriptionRequired\": \"Abonnement requis pour ajouter plus de membres\",\n    \"upgradePrompt\": \"Mettez à niveau le plan de votre organisation pour inviter des membres supplémentaires.\",\n    \"upgradeButton\": \"Mettre à niveau\"\n  },\n  \"roles\": {\n    \"member\": \"Membre\",\n    \"admin\": \"Administrateur\"\n  },\n  \"memberList\": {\n    \"title\": \"Membres\",\n    \"description\": \"Gérez les membres et leurs rôles dans {{orgName}}\",\n    \"inviteButton\": \"Inviter un membre\",\n    \"loading\": \"Chargement des membres...\",\n    \"none\": \"Aucun membre trouvé\",\n    \"you\": \"Vous\"\n  },\n  \"invitationList\": {\n    \"title\": \"Invitations en attente\",\n    \"description\": \"Voir les invitations en attente pour {{orgName}}\",\n    \"loading\": \"Chargement des invitations...\",\n    \"invited\": \"Invité le {{date}}\",\n    \"pending\": \"En attente\"\n  },\n  \"settings\": {\n    \"title\": \"Paramètres de l'organisation\",\n    \"description\": \"Gérez les membres et les permissions de l'organisation\",\n    \"selectLabel\": \"Sélectionner une organisation\",\n    \"selectPlaceholder\": \"Sélectionner une organisation\",\n    \"selectHelper\": \"Choisissez une organisation pour voir et gérer ses membres\",\n    \"noOrganizations\": \"Aucune organisation disponible\",\n    \"loadingOrganizations\": \"Chargement des organisations...\",\n    \"loadError\": \"Échec du chargement des organisations\",\n    \"dangerZone\": \"Zone de danger\",\n    \"dangerZoneDescription\": \"Actions irréversibles et destructives\",\n    \"deleteOrganization\": \"Supprimer l'organisation\",\n    \"deleteOrganizationDescription\": \"Supprime définitivement cette organisation et toutes ses données\",\n    \"confirmDelete\": \"Êtes-vous sûr de vouloir supprimer {{orgName}} ? Cette action est irréversible.\",\n    \"deleteSuccess\": \"Organisation supprimée avec succès\",\n    \"deleteError\": \"Échec de la suppression de l'organisation\"\n  },\n  \"loginRequired\": {\n    \"title\": \"Connexion requise\",\n    \"description\": \"Vous devez être connecté pour gérer les paramètres de l'organisation.\",\n    \"action\": \"Connexion\"\n  },\n  \"confirmRemoveMember\": \"Êtes-vous sûr de vouloir retirer ce membre de l'organisation ?\",\n  \"billing\": {\n    \"title\": \"Facturation et abonnement\",\n    \"description\": \"Gérez l'abonnement et les paramètres de facturation de votre organisation\",\n    \"manageButton\": \"Gérer la facturation\",\n    \"openInBrowser\": \"S'ouvre dans un nouvel onglet du navigateur\"\n  },\n  \"personalOrg\": {\n    \"cannotInvite\": \"Les organisations personnelles ne peuvent pas avoir de membres supplémentaires.\",\n    \"createOrgPrompt\": \"Créez une organisation pour collaborer avec d'autres.\",\n    \"createOrgButton\": \"Créer une organisation\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/fr/projects.json",
    "content": "{\n  \"createProjectDialog\": {\n    \"title\": \"Create Project\",\n    \"description\": \"Create a new project in this organization.\",\n    \"nameLabel\": \"Project name\",\n    \"namePlaceholder\": \"Enter project name\",\n    \"selectColor\": \"Select project color\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Project\"\n  },\n  \"deleteProjectDialog\": {\n    \"title\": \"Delete Project?\",\n    \"description\": \"This will permanently delete \\\"{{name}}\\\" and all its issues. This action cannot be undone.\",\n    \"error\": \"Failed to delete project. Please try again.\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/fr/settings.json",
    "content": "{\n  \"settings\": {\n    \"common\": {\n      \"unsavedChanges\": \"• Vous avez des modifications non enregistrées\",\n      \"discardChangesConfirm\": \"Discard unsaved changes?\"\n    },\n    \"unsavedChanges\": {\n      \"title\": \"Modifications non enregistrées\",\n      \"message\": \"Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir fermer sans enregistrer ?\",\n      \"discard\": \"Abandonner les modifications\",\n      \"cancel\": \"Continuer l'édition\"\n    },\n    \"layout\": {\n      \"nav\": {\n        \"title\": \"Paramètres\",\n        \"general\": \"Général\",\n        \"generalDesc\": \"Thème, notifications et préférences\",\n        \"projects\": \"Projets\",\n        \"projectsDesc\": \"Dépôts de projet et configuration\",\n        \"repos\": \"Dépôts\",\n        \"reposDesc\": \"Scripts et configuration des dépôts\",\n        \"agents\": \"Agents\",\n        \"agentsDesc\": \"Configurations des agents de codage\",\n        \"mcp\": \"Serveurs MCP\",\n        \"mcpDesc\": \"Serveurs Model Context Protocol\",\n        \"organizations\": \"Paramètres de l'organisation\",\n        \"organizationsDesc\": \"Gérer les membres et les permissions de l'organisation\",\n        \"remote-projects\": \"Projets\",\n        \"remote-projectsDesc\": \"Gérer les projets\",\n        \"relay\": \"Remote Access\",\n        \"relayDesc\": \"Relay tunnel and enrollment\"\n      }\n    },\n    \"general\": {\n      \"loading\": \"Chargement des paramètres...\",\n      \"loadError\": \"Échec du chargement de la configuration.\",\n      \"save\": {\n        \"button\": \"Enregistrer les paramètres\",\n        \"success\": \"✓ Paramètres enregistrés avec succès !\",\n        \"error\": \"Échec de l'enregistrement de la configuration\",\n        \"unsavedChanges\": \"• Vous avez des modifications non enregistrées\",\n        \"discard\": \"Abandonner\"\n      },\n      \"appearance\": {\n        \"title\": \"Apparence\",\n        \"description\": \"Personnalisez l'apparence de l'application.\",\n        \"theme\": {\n          \"label\": \"Thème\",\n          \"placeholder\": \"Sélectionner un thème\",\n          \"helper\": \"Choisissez votre palette de couleurs préférée.\"\n        },\n        \"language\": {\n          \"label\": \"Langue\",\n          \"placeholder\": \"Sélectionner une langue\",\n          \"helper\": \"Choisissez votre langue préférée. Par défaut du navigateur suit la langue de votre système.\"\n        }\n      },\n      \"taskExecution\": {\n        \"title\": \"Agent de codage par défaut\",\n        \"description\": \"Choisissez l'agent de codage par défaut pour les tâches.\",\n        \"executor\": {\n          \"label\": \"Configuration d'agent par défaut\",\n          \"placeholder\": \"Sélectionner un profil\",\n          \"helper\": \"Choisissez la configuration d'agent par défaut à utiliser lors de la création d'une tentative de tâche.\"\n        },\n        \"variant\": \"PAR DÉFAUT\",\n        \"defaultLabel\": \"Par défaut\"\n      },\n      \"editor\": {\n        \"title\": \"Éditeur\",\n        \"description\": \"Configurez votre expérience d'édition de code.\",\n        \"type\": {\n          \"label\": \"Type d'éditeur\",\n          \"placeholder\": \"Sélectionner un éditeur\",\n          \"helper\": \"Choisissez votre interface d'éditeur de code préférée.\"\n        },\n        \"customCommand\": {\n          \"label\": \"Commande d'éditeur personnalisée\",\n          \"placeholder\": \"ex: code, subl, vim\",\n          \"helper\": \"Saisissez la commande pour lancer votre éditeur personnalisé. Elle sera utilisée pour ouvrir les fichiers.\"\n        },\n        \"remoteSsh\": {\n          \"host\": {\n            \"label\": \"Hôte SSH distant (optionnel)\",\n            \"placeholder\": \"ex: nom d'hôte ou adresse IP\",\n            \"helper\": \"Définissez ceci si Vibe Kanban fonctionne sur un serveur distant. Lorsque défini, cliquer sur \\\"Ouvrir dans l'éditeur\\\" générera une URL pour ouvrir votre éditeur via SSH au lieu de lancer une commande locale.\"\n          },\n          \"user\": {\n            \"label\": \"Utilisateur SSH distant (optionnel)\",\n            \"placeholder\": \"ex: nom d'utilisateur\",\n            \"helper\": \"Nom d'utilisateur SSH pour la connexion distante. S'il n'est pas défini, VS Code utilisera votre config SSH ou vous demandera.\"\n          }\n        },\n        \"autoInstallExtension\": {\n          \"label\": \"Auto-install VS Code Extension\",\n          \"helper\": \"Automatically install the Vibe Kanban extension when opening a project in VS Code or Cursor. This runs in the background and has no effect if the extension is already installed.\"\n        },\n        \"availability\": {\n          \"checking\": \"Vérification de la disponibilité...\",\n          \"available\": \"Disponible\",\n          \"notFound\": \"Non trouvé dans le PATH\"\n        }\n      },\n      \"github\": {\n        \"title\": \"Intégration GitHub\",\n        \"cliSetup\": {\n          \"title\": \"Configuration de GitHub CLI\",\n          \"description\": \"L'authentification GitHub CLI est requise pour créer des pull requests et interagir avec les dépôts GitHub.\",\n          \"setupWillTitle\": \"Cette configuration va :\",\n          \"steps\": {\n            \"checkInstalled\": \"Vérifier si GitHub CLI (gh) est installé\",\n            \"installHomebrew\": \"L'installer via Homebrew si nécessaire (macOS)\",\n            \"authenticate\": \"S'authentifier avec GitHub en utilisant OAuth\"\n          },\n          \"setupNote\": \"La configuration s'exécutera dans la fenêtre de chat. Vous devrez compléter l'authentification dans votre navigateur.\",\n          \"runSetup\": \"Lancer la configuration\",\n          \"running\": \"Exécution en cours...\",\n          \"errors\": {\n            \"brewMissing\": \"Homebrew n'est pas installé. Installez-le pour activer la configuration automatique.\",\n            \"notSupported\": \"La configuration automatique n'est pas prise en charge sur cette plateforme. Installez GitHub CLI manuellement.\",\n            \"setupFailed\": \"Échec de l'exécution de la configuration GitHub CLI.\"\n          },\n          \"help\": {\n            \"homebrew\": {\n              \"description\": \"L'installation automatique nécessite Homebrew. Installez Homebrew depuis\",\n              \"brewSh\": \"brew.sh\",\n              \"manualInstall\": \"puis relancez la configuration. Alternativement, installez GitHub CLI manuellement avec :\",\n              \"afterInstall\": \"Après l'installation, authentifiez-vous avec :\"\n            },\n            \"manual\": {\n              \"description\": \"Installez GitHub CLI depuis la\",\n              \"officialDocs\": \"documentation officielle\",\n              \"andAuthenticate\": \"puis authentifiez-vous avec votre compte GitHub.\"\n            }\n          }\n        }\n      },\n      \"git\": {\n        \"title\": \"Git\",\n        \"description\": \"Configurez les préférences de nommage des branches git\",\n        \"branchPrefix\": {\n          \"label\": \"Préfixe de branche\",\n          \"placeholder\": \"vk\",\n          \"helper\": \"Préfixe pour les noms de branches auto-générés. Laissez vide pour aucun préfixe.\",\n          \"preview\": \"Aperçu :\",\n          \"previewWithPrefix\": \"{{prefix}}/1a2b-nom-tache\",\n          \"previewNoPrefix\": \"1a2b-nom-tache\",\n          \"errors\": {\n            \"slash\": \"Le préfixe ne peut pas contenir '/'.\",\n            \"startsWithDot\": \"Le préfixe ne peut pas commencer par '.'.\",\n            \"endsWithDot\": \"Le préfixe ne peut pas se terminer par '.' ou '.lock'.\",\n            \"invalidSequence\": \"Contient une séquence invalide (.., @{).\",\n            \"invalidChars\": \"Contient des caractères invalides.\",\n            \"controlChars\": \"Contient des caractères de contrôle.\"\n          }\n        },\n        \"workspaceDir\": {\n          \"label\": \"Répertoire des espaces de travail\",\n          \"placeholder\": \"~/\",\n          \"helper\": \"Les espaces de travail seront créés dans un sous-répertoire .vibe-kanban-workspaces à l'intérieur de ce chemin. Laissez vide pour utiliser la valeur par défaut du système. Les modifications nécessitent un redémarrage de l'application.\",\n          \"browse\": \"Parcourir\",\n          \"dialogTitle\": \"Sélectionner le répertoire des espaces de travail\",\n          \"dialogDescription\": \"Choisissez un répertoire. Les espaces de travail seront créés dans un sous-répertoire .vibe-kanban-workspaces à l'intérieur.\"\n        }\n      },\n      \"pullRequests\": {\n        \"title\": \"Pull Requests\",\n        \"description\": \"Configurez le comportement de création des PR\",\n        \"autoDescription\": {\n          \"label\": \"Générer automatiquement la description de la PR par défaut\",\n          \"helper\": \"Lorsque activé, l'agent IA mettra automatiquement à jour le titre et la description de la PR après sa création.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"Utiliser un prompt personnalisé\",\n          \"helper\": \"Prompt personnalisé pour l'agent IA lors de la génération des descriptions de PR. Utilisez {pr_number} et {pr_url} comme variables.\"\n        }\n      },\n      \"commits\": {\n        \"title\": \"Commits\",\n        \"description\": \"Configurez le comportement de commit pour les tentatives de tâches\",\n        \"reminder\": {\n          \"label\": \"Rappel de commit\",\n          \"helper\": \"Demander aux agents compatibles de commiter les modifications avant de s'arrêter.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"Utiliser un prompt personnalisé\",\n          \"helper\": \"Prompt personnalisé pour le rappel de commit. Le statut git sera ajouté automatiquement.\"\n        }\n      },\n      \"notifications\": {\n        \"title\": \"Notifications\",\n        \"description\": \"Contrôlez quand et comment vous recevez des notifications.\",\n        \"sound\": {\n          \"label\": \"Notifications sonores\",\n          \"helper\": \"Jouer un son lorsque les tentatives de tâches sont terminées.\",\n          \"fileLabel\": \"Son\",\n          \"filePlaceholder\": \"Sélectionner un son\",\n          \"fileHelper\": \"Choisissez le son à jouer lorsque les tâches sont terminées. Cliquez sur le bouton volume pour prévisualiser.\"\n        },\n        \"push\": {\n          \"label\": \"Notifications push\",\n          \"helper\": \"Afficher les notifications système lorsque les tentatives de tâches sont terminées.\"\n        }\n      },\n      \"messageInput\": {\n        \"title\": \"Saisie des messages\",\n        \"description\": \"Configurez comment les messages sont envoyés dans le chat.\",\n        \"shortcut\": {\n          \"label\": \"Envoyer le message avec\",\n          \"helper\": \"Choisissez le raccourci clavier pour envoyer des messages.\",\n          \"enterLabel\": \"Entrée (Maj+Entrée pour nouvelle ligne)\"\n        }\n      },\n      \"privacy\": {\n        \"title\": \"Confidentialité\",\n        \"description\": \"Aidez à améliorer Vibe-Kanban en partageant des données d'utilisation anonymes.\",\n        \"telemetry\": {\n          \"label\": \"Activer la télémétrie\",\n          \"helper\": \"Active le suivi anonyme des événements d'utilisation pour aider à améliorer l'application. Aucun prompt ou information de projet n'est collecté.\"\n        }\n      },\n      \"taskTemplates\": {\n        \"title\": \"Tags\",\n        \"description\": \"Créez des extraits de texte réutilisables pouvant être insérés dans les descriptions de tâches en utilisant @nom_du_tag.\"\n      },\n      \"tags\": {\n        \"manager\": {\n          \"title\": \"Tags de tâches\",\n          \"addTag\": \"Ajouter un tag\",\n          \"noTags\": \"Aucun tag pour le moment. Créez des extraits de texte réutilisables pour les descriptions de tâches courantes. Utilisez @nom_du_tag dans n'importe quelle tâche.\",\n          \"table\": {\n            \"tagName\": \"Nom du tag\",\n            \"content\": \"Contenu\",\n            \"actions\": \"Actions\"\n          },\n          \"actions\": {\n            \"editTag\": \"Modifier le tag\",\n            \"deleteTag\": \"Supprimer le tag\"\n          },\n          \"deleteConfirm\": \"Êtes-vous sûr de vouloir supprimer le tag \\\"{{tagName}}\\\" ?\"\n        },\n        \"dialog\": {\n          \"createTitle\": \"Créer un tag\",\n          \"editTitle\": \"Modifier le tag\",\n          \"tagName\": {\n            \"label\": \"Nom du tag\",\n            \"required\": \"*\",\n            \"hint\": \"Utilisez ce nom avec @ dans les descriptions de tâches : @{{tagName}}\",\n            \"placeholder\": \"ex: correction_bug, plan_test, docs_api\",\n            \"error\": \"Le nom du tag ne peut pas contenir d'espaces. Utilisez des underscores à la place (ex: mon_tag)\"\n          },\n          \"content\": {\n            \"label\": \"Contenu\",\n            \"required\": \"*\",\n            \"hint\": \"Texte qui sera inséré lorsque vous utilisez @{{tagName}} dans les descriptions de tâches\",\n            \"placeholder\": \"Saisissez le texte qui sera inséré lorsque vous utiliserez ce tag\"\n          },\n          \"errors\": {\n            \"nameRequired\": \"Le nom du tag est requis\",\n            \"saveFailed\": \"Échec de l'enregistrement du tag\"\n          },\n          \"buttons\": {\n            \"cancel\": \"Annuler\",\n            \"create\": \"Créer\",\n            \"update\": \"Mettre à jour\"\n          }\n        }\n      },\n      \"safety\": {\n        \"title\": \"Sécurité et avertissements\",\n        \"description\": \"Réinitialisez les accusés de réception des avertissements de sécurité et de l'intégration.\",\n        \"disclaimer\": {\n          \"title\": \"Accusé de réception de l'avertissement\",\n          \"description\": \"Réinitialiser l'avertissement de sécurité.\",\n          \"button\": \"Réinitialiser\"\n        },\n        \"onboarding\": {\n          \"title\": \"Intégration\",\n          \"description\": \"Réinitialiser le flux d'intégration.\",\n          \"button\": \"Réinitialiser\"\n        }\n      },\n      \"beta\": {\n        \"title\": \"Fonctionnalités bêta\",\n        \"description\": \"Essayez les fonctionnalités expérimentales avant leur sortie.\",\n        \"workspaces\": {\n          \"label\": \"Activer la bêta des espaces de travail\",\n          \"helper\": \"Utiliser la nouvelle interface des espaces de travail lors de la visualisation des tentatives de tâches. Les tâches s'ouvriront d'abord en vue tâche, et les tentatives s'ouvriront dans la nouvelle vue des espaces de travail.\"\n        }\n      }\n    },\n    \"agents\": {\n      \"title\": \"Configurations des agents de codage\",\n      \"description\": \"Personnalisez le comportement des agents de codage avec différentes configurations.\",\n      \"loading\": \"Chargement des configurations d'agents...\",\n      \"selectAgent\": \"Sélectionner un agent\",\n      \"save\": {\n        \"button\": \"Enregistrer les configurations d'agents\",\n        \"success\": \"✓ Configurations d'exécuteurs enregistrées avec succès !\",\n        \"unsavedChanges\": \"• Vous avez des modifications non enregistrées\"\n      },\n      \"availability\": {\n        \"checking\": \"Vérification...\",\n        \"checkingAvailability\": \"Vérification de la disponibilité...\",\n        \"available\": \"Agent disponible\",\n        \"notFoundSimple\": \"Agent non trouvé\",\n        \"loginDetected\": \"Utilisation récente détectée\",\n        \"loginDetectedTooltip\": \"Identifiants d'authentification récents trouvés pour cet agent\",\n        \"installationFound\": \"Utilisation précédente détectée\",\n        \"installationFoundTooltip\": \"Configuration d'agent trouvée. Vous devrez peut-être vous connecter pour l'utiliser.\"\n      },\n      \"editor\": {\n        \"formLabel\": \"Modifier le JSON\",\n        \"agentLabel\": \"Agent\",\n        \"agentPlaceholder\": \"Sélectionner le type d'exécuteur\",\n        \"configLabel\": \"Configuration\",\n        \"configPlaceholder\": \"Sélectionner une configuration\",\n        \"createNew\": \"Créer une nouvelle...\",\n        \"deleteTitle\": \"Impossible de supprimer la dernière configuration\",\n        \"deleteButton\": \"Supprimer {{name}}\",\n        \"deleteText\": \"Supprimer\",\n        \"makeDefault\": \"Définir par défaut\",\n        \"isDefault\": \"Par défaut\",\n        \"jsonLabel\": \"Configuration de l'agent (JSON)\",\n        \"jsonPlaceholder\": \"Chargement des profils...\",\n        \"jsonLoading\": \"Chargement...\",\n        \"pathLabel\": \"Emplacement du fichier de configuration :\"\n      },\n      \"errors\": {\n        \"deleteFailed\": \"Échec de la suppression de la configuration. Veuillez réessayer.\",\n        \"saveFailed\": \"Échec de l'enregistrement des configurations d'agents. Veuillez réessayer.\",\n        \"saveConfigFailed\": \"Échec de l'enregistrement de la configuration. Veuillez réessayer.\",\n        \"schemaNotFound\": \"Schéma non trouvé pour le type d'exécuteur : {{executor}}\"\n      },\n      \"tree\": {\n        \"search\": \"Rechercher des configurations...\",\n        \"expandAll\": \"Tout développer\",\n        \"collapseAll\": \"Tout réduire\",\n        \"noResults\": \"Aucune configuration correspondante\",\n        \"noConfigs\": \"Aucune configuration disponible\",\n        \"selectConfig\": \"Sélectionnez une configuration dans la barre latérale pour afficher et modifier ses paramètres.\"\n      },\n      \"deleteConfigDialog\": {\n        \"title\": \"Supprimer la configuration ?\",\n        \"description\": \"Cela supprimera définitivement \\\"{{configName}}\\\" de l'exécuteur {{executorType}}. Cette action est irréversible.\"\n      }\n    },\n    \"mcp\": {\n      \"title\": \"Configuration des serveurs MCP\",\n      \"description\": \"Configurez les serveurs Model Context Protocol pour étendre les capacités des agents de codage avec des outils et ressources personnalisés.\",\n      \"loading\": \"Chargement de la configuration MCP...\",\n      \"applying\": \"Application de la configuration...\",\n      \"labels\": {\n        \"agent\": \"Agent\",\n        \"agentPlaceholder\": \"Sélectionner un exécuteur\",\n        \"agentHelper\": \"Choisissez pour quel agent configurer les serveurs MCP.\",\n        \"serverConfig\": \"Configuration du serveur (JSON)\",\n        \"popularServers\": \"Serveurs populaires\",\n        \"serverHelper\": \"Cliquez sur une carte pour insérer ce serveur MCP dans le JSON ci-dessus.\",\n        \"saveLocation\": \"Les modifications seront enregistrées dans :\"\n      },\n      \"loadingStates\": {\n        \"jsonEditor\": \"Chargement...\",\n        \"configuration\": \"Chargement de la configuration actuelle des serveurs MCP...\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Échec du chargement de la configuration.\",\n        \"invalidJson\": \"Format JSON invalide\",\n        \"validationError\": \"Erreur de validation\",\n        \"saveFailed\": \"Échec de l'enregistrement des serveurs MCP\",\n        \"applyFailed\": \"Échec de l'application de la configuration des serveurs MCP\",\n        \"addServerFailed\": \"Échec de l'ajout du serveur préconfiguré\",\n        \"mcpError\": \"Erreur de configuration MCP : {{error}}\",\n        \"notSupported\": \"MCP non pris en charge\",\n        \"supportMessage\": \"Pour utiliser les serveurs MCP, veuillez sélectionner un exécuteur différent prenant en charge MCP (Claude, Amp, Gemini, Codex ou Opencode) ci-dessus.\"\n      },\n      \"save\": {\n        \"button\": \"Enregistrer la configuration MCP\",\n        \"success\": \"Paramètres enregistrés !\",\n        \"successMessage\": \"✓ Configuration MCP enregistrée avec succès !\",\n        \"loading\": \"Chargement de la configuration actuelle des serveurs MCP...\",\n        \"unsavedChanges\": \"• Vous avez des modifications non enregistrées\"\n      }\n    },\n    \"projects\": {\n      \"title\": \"Configuration du projet\",\n      \"description\": \"Configurez les scripts et paramètres spécifiques au projet.\",\n      \"loading\": \"Chargement des projets...\",\n      \"loadError\": \"Échec du chargement des projets.\",\n      \"selector\": {\n        \"label\": \"Sélectionner un projet\",\n        \"placeholder\": \"Choisir un projet à configurer\",\n        \"helper\": \"Sélectionnez un projet pour voir et modifier sa configuration.\",\n        \"noProjects\": \"Aucun projet disponible\"\n      },\n      \"general\": {\n        \"title\": \"Paramètres généraux\",\n        \"description\": \"Configurez les informations de base du projet.\",\n        \"name\": {\n          \"label\": \"Nom du projet\",\n          \"placeholder\": \"Saisir le nom du projet\",\n          \"helper\": \"Un nom d'affichage pour ce projet.\"\n        },\n        \"repoPath\": {\n          \"label\": \"Chemin du dépôt Git\",\n          \"placeholder\": \"/chemin/vers/votre/depot/existant\",\n          \"helper\": \"Le chemin absolu vers votre dépôt git sur le disque.\"\n        }\n      },\n      \"save\": {\n        \"button\": \"Enregistrer les paramètres du projet\",\n        \"success\": \"✓ Paramètres du projet enregistrés avec succès !\",\n        \"error\": \"Échec de l'enregistrement des paramètres du projet\",\n        \"unsavedChanges\": \"• Vous avez des modifications non enregistrées\",\n        \"discard\": \"Abandonner\",\n        \"confirmSwitch\": \"Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer de projet ? Vos modifications seront perdues.\",\n        \"saving\": \"Saving...\"\n      },\n      \"repositories\": {\n        \"title\": \"Dépôts\",\n        \"description\": \"Gérer les dépôts git dans ce projet\",\n        \"noRepositories\": \"Aucun dépôt configuré\",\n        \"addRepository\": \"Ajouter un dépôt\"\n      }\n    },\n    \"repos\": {\n      \"title\": \"Configuration du dépôt\",\n      \"description\": \"Configurez les scripts qui s'exécutent lorsque ce dépôt est utilisé dans les espaces de travail.\",\n      \"loading\": \"Chargement des dépôts...\",\n      \"loadError\": \"Échec du chargement des dépôts.\",\n      \"addRepo\": {\n        \"button\": \"Ajouter un dépôt\",\n        \"dialogTitle\": \"Sélectionner un dépôt Git\",\n        \"dialogDescription\": \"Choisissez un dépôt git existant à enregistrer.\",\n        \"error\": \"Échec de l'enregistrement du dépôt\"\n      },\n      \"selector\": {\n        \"label\": \"Sélectionner un dépôt\",\n        \"placeholder\": \"Choisir un dépôt à configurer\",\n        \"helper\": \"Sélectionnez un dépôt pour voir et modifier sa configuration.\",\n        \"noRepos\": \"Aucun dépôt disponible\"\n      },\n      \"general\": {\n        \"title\": \"Paramètres généraux\",\n        \"description\": \"Configurez les informations de base du dépôt.\",\n        \"displayName\": {\n          \"label\": \"Nom d'affichage\",\n          \"placeholder\": \"Saisir le nom d'affichage\",\n          \"helper\": \"Un nom convivial pour ce dépôt.\"\n        },\n        \"path\": {\n          \"label\": \"Chemin du dépôt\"\n        },\n        \"defaultWorkingDir\": {\n          \"label\": \"Répertoire de travail par défaut\",\n          \"placeholder\": \"ex. : packages/frontend\",\n          \"helper\": \"Sous-répertoire relatif à la racine du dépôt où l'agent de codage s'exécute pour les espaces de travail à dépôt unique. Défini lors de la création de l'espace de travail. Laissez vide pour utiliser la racine du dépôt.\"\n        },\n        \"defaultTargetBranch\": {\n          \"label\": \"Branche cible par défaut\",\n          \"placeholder\": \"Sélectionner une branche\",\n          \"helper\": \"La branche de base par défaut pour les nouveaux espaces de travail. Les worktrees partent de cette branche et les PRs la cibleront.\",\n          \"search\": \"Rechercher des branches\",\n          \"noBranches\": \"Aucune branche trouvée\",\n          \"loading\": \"Chargement des branches...\",\n          \"useCurrent\": \"Utiliser la branche actuelle\"\n        }\n      },\n      \"scripts\": {\n        \"title\": \"Scripts et configuration\",\n        \"description\": \"Configurez le serveur de développement, la configuration, le nettoyage et les fichiers à copier pour ce dépôt. Ces scripts s'exécutent chaque fois que le dépôt est utilisé dans un espace de travail.\",\n        \"setup\": {\n          \"label\": \"Script de configuration\",\n          \"helper\": \"Ce script s'exécute depuis le worktree après sa création et avant le démarrage de l'agent de codage. Utilisez-le pour les tâches de configuration comme l'installation des dépendances ou la préparation de l'environnement.\",\n          \"parallelLabel\": \"Exécuter le script de configuration en parallèle avec l'agent de codage\",\n          \"parallelHelper\": \"Lorsque activé, le script de configuration s'exécute simultanément avec l'agent de codage au lieu d'attendre la fin de la configuration.\"\n        },\n        \"cleanup\": {\n          \"label\": \"Script de nettoyage\",\n          \"helper\": \"Ce script s'exécute depuis le worktree après l'exécution de l'agent de codage, uniquement si des modifications ont été effectuées. Utilisez-le pour les tâches d'assurance qualité comme l'exécution de linters, formateurs, tests ou autres étapes de validation.\"\n        },\n        \"archive\": {\n          \"label\": \"Script d'archivage\",\n          \"helper\": \"Ce script s'exécute depuis le worktree lorsque l'espace de travail est archivé. Utilisez-le pour les tâches de nettoyage comme l'arrêt des services, la libération des ressources ou la sauvegarde de l'état.\"\n        },\n        \"copyFiles\": {\n          \"label\": \"Copier les fichiers\",\n          \"helper\": \"Liste de fichiers séparés par des virgules à copier depuis le répertoire du dépôt original vers le worktree. Utile pour les fichiers d'environnement comme .env. Assurez-vous qu'ils sont dans le gitignore !\",\n          \"placeholder\": \"Chemins de fichiers ou patterns glob (ex: .env, config/*.json)\"\n        },\n        \"devServer\": {\n          \"label\": \"Script du serveur de développement\",\n          \"helper\": \"Démarre un serveur de développement pour ce dépôt. Les scripts s'exécutent depuis le répertoire du worktree du dépôt.\"\n        }\n      },\n      \"linkedProjects\": {\n        \"title\": \"Projets liés\",\n        \"description\": \"Projets utilisant ce dépôt dans leur configuration d'espace de travail par défaut.\",\n        \"loading\": \"Vérification des projets…\",\n        \"none\": \"Aucun projet lié\"\n      },\n      \"remove\": {\n        \"title\": \"Retirer le dépôt\",\n        \"description\": \"Dissocier ce dépôt de Vibe Kanban. Les fichiers sur le disque ne seront pas supprimés.\",\n        \"button\": \"Retirer\",\n        \"confirm\": \"Retirer le dépôt\",\n        \"dialogTitle\": \"Retirer « {{name}} » ?\",\n        \"dialogDescription\": \"Cela dissociera le dépôt de Vibe Kanban. Vos fichiers sur le disque ne seront pas affectés. Vous pourrez le rajouter ultérieurement.\",\n        \"success\": \"Dépôt retiré avec succès.\",\n        \"error\": \"Échec du retrait du dépôt.\"\n      },\n      \"save\": {\n        \"button\": \"Enregistrer les paramètres du dépôt\",\n        \"success\": \"Paramètres du dépôt enregistrés avec succès !\",\n        \"error\": \"Échec de l'enregistrement des paramètres du dépôt\",\n        \"unsavedChanges\": \"Vous avez des modifications non enregistrées\",\n        \"discard\": \"Abandonner\",\n        \"confirmSwitch\": \"Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer de dépôt ? Vos modifications seront perdues.\"\n      }\n    },\n    \"relay\": {\n      \"title\": \"Remote Access\",\n      \"description\": \"Allow access to this instance from the web via relay tunnel.\",\n      \"enabled\": {\n        \"label\": \"Enable remote access\",\n        \"helper\": \"When enabled, this instance can be accessed remotely through a relay tunnel.\"\n      },\n      \"enrollmentCode\": {\n        \"label\": \"Pairing Code\",\n        \"helper\": \"Enter this code in the browser to pair with this instance.\",\n        \"loginRequired\": \"Log in to generate a pairing code.\",\n        \"fetchError\": \"Failed to fetch pairing code.\",\n        \"show\": \"Show pairing code\"\n      }\n    },\n    \"remoteProjects\": {\n      \"title\": \"Remote Projects\",\n      \"description\": \"Manage cloud-synced projects across organizations.\",\n      \"loading\": \"Loading remote projects...\",\n      \"loadError\": \"Failed to load organizations\",\n      \"noProjects\": \"No projects yet. Create one to get started.\",\n      \"selectOrg\": \"Select an organization\",\n      \"createSuccess\": \"Project created successfully\",\n      \"deleteSuccess\": \"Project deleted successfully\",\n      \"nameRequired\": \"Project name is required\",\n      \"saveSuccess\": \"Project updated successfully\",\n      \"saveError\": \"Failed to update project\",\n      \"columns\": {\n        \"organizations\": \"Organizations\",\n        \"projects\": \"Projects\"\n      },\n      \"actions\": {\n        \"addProject\": \"Add Project\"\n      },\n      \"form\": {\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\"\n        },\n        \"color\": {\n          \"label\": \"Project Color\"\n        },\n        \"statuses\": {\n          \"label\": \"Project Statuses\",\n          \"description\": \"Manage kanban columns for this project.\"\n        },\n        \"defaultRepos\": {\n          \"label\": \"Default Repositories\",\n          \"description\": \"Repositories that are automatically added when creating workspaces from this project's issues.\",\n          \"empty\": \"No default repositories configured\",\n          \"addButton\": \"Add\",\n          \"addNew\": \"Add new repository...\",\n          \"branchLabel\": \"Branch\",\n          \"noBranches\": \"No branches found for this repository\",\n          \"fetchError\": \"Could not load repositories\",\n          \"saveError\": \"Failed to save default repositories\"\n        }\n      },\n      \"loginRequired\": {\n        \"title\": \"Sign in required\",\n        \"description\": \"Sign in to manage your remote projects.\",\n        \"action\": \"Sign in\"\n      }\n    }\n  },\n  \"integrations\": {\n    \"github\": {\n      \"cliSetup\": {\n        \"title\": \"Configuration de GitHub CLI\",\n        \"description\": \"L'authentification GitHub CLI est requise pour créer des pull requests et interagir avec les dépôts GitHub.\",\n        \"setupWillTitle\": \"Cette configuration va :\",\n        \"steps\": {\n          \"checkInstalled\": \"Vérifier si GitHub CLI (gh) est installé\",\n          \"installHomebrew\": \"L'installer via Homebrew si nécessaire (macOS)\",\n          \"authenticate\": \"S'authentifier avec GitHub en utilisant OAuth\"\n        },\n        \"setupNote\": \"La configuration s'exécutera dans la fenêtre de chat. Vous devrez compléter l'authentification dans votre navigateur.\",\n        \"runSetup\": \"Lancer la configuration\",\n        \"running\": \"Exécution en cours...\",\n        \"errors\": {\n          \"brewMissing\": \"Homebrew n'est pas installé. Installez-le pour activer la configuration automatique.\",\n          \"notSupported\": \"La configuration automatique n'est pas prise en charge sur cette plateforme. Installez GitHub CLI manuellement.\",\n          \"setupFailed\": \"Échec de l'exécution de la configuration GitHub CLI.\"\n        },\n        \"help\": {\n          \"homebrew\": {\n            \"description\": \"L'installation automatique nécessite Homebrew. Installez Homebrew depuis\",\n            \"brewSh\": \"brew.sh\",\n            \"manualInstall\": \"puis relancez la configuration. Alternativement, installez GitHub CLI manuellement avec :\",\n            \"afterInstall\": \"Après l'installation, authentifiez-vous avec :\"\n          },\n          \"manual\": {\n            \"description\": \"Installez GitHub CLI depuis la\",\n            \"officialDocs\": \"documentation officielle\",\n            \"andAuthenticate\": \"puis authentifiez-vous avec votre compte GitHub.\"\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/fr/tasks.json",
    "content": "{\n  \"dropzone\": {\n    \"dropImagesHere\": \"Déposez les images ici\",\n    \"supportedFormats\": \"PNG, JPG, GIF, WebP, SVG supportés\"\n  },\n  \"gitPanel\": {\n    \"advanced\": {\n      \"placeholder\": \"ex: Acme Corp\"\n    }\n  },\n  \"loading\": \"Chargement des tâches...\",\n  \"actions\": {\n    \"addTask\": \"Ajouter une tâche\"\n  },\n  \"rebase\": {\n    \"common\": {\n      \"action\": \"Rebaser\",\n      \"inProgress\": \"Rebasage en cours...\"\n    },\n    \"dialog\": {\n      \"title\": \"Rebaser la tentative de tâche\",\n      \"description\": \"Choisissez une nouvelle branche de base pour rebaser cette tentative de tâche.\",\n      \"upstreamLabel\": \"Branche amont\",\n      \"upstreamPlaceholder\": \"Sélectionner une branche amont\",\n      \"targetLabel\": \"Branche cible\",\n      \"targetPlaceholder\": \"Sélectionner une branche cible\",\n      \"advanced\": \"Avancé\"\n    }\n  },\n  \"branches\": {\n    \"changeTarget\": {\n      \"dialog\": {\n        \"title\": \"Changer la branche cible\",\n        \"description\": \"Choisissez une nouvelle branche cible pour la tentative de tâche.\",\n        \"placeholder\": \"Sélectionner une branche cible\",\n        \"action\": \"Changer de branche\",\n        \"inProgress\": \"Changement en cours...\"\n      }\n    }\n  },\n  \"repos\": {\n    \"selector\": {\n      \"placeholder\": \"Sélectionner un dépôt\",\n      \"empty\": \"Aucun dépôt disponible\"\n    }\n  },\n  \"preview\": {\n    \"noServer\": {\n      \"title\": \"Aucun serveur de développement en cours d'exécution\",\n      \"setupTitle\": \"Vous devez configurer un script de serveur de développement pour utiliser la fonction d'aperçu\",\n      \"setupPrompt\": \"Pour utiliser l'aperçu en direct et le clic-pour-éditer, veuillez ajouter un script de serveur de développement à ce projet.\",\n      \"editDevScript\": \"Modifier le script du serveur de développement\",\n      \"learnMore\": \"En savoir plus sur le test des applications\"\n    },\n    \"logs\": {\n      \"label\": \"Logs\",\n      \"viewFull\": \"Voir tous les logs\"\n    },\n    \"toolbar\": {\n      \"refresh\": \"Actualiser l'aperçu\",\n      \"copyUrl\": \"Copier l'URL\",\n      \"openInTab\": \"Ouvrir dans un nouvel onglet\",\n      \"stopDevServer\": \"Arrêter le serveur de développement\",\n      \"resetUrl\": \"Réinitialiser à l'URL détectée\",\n      \"clearUrlOverride\": \"Effacer le remplacement d'URL\",\n      \"desktopView\": \"Vue bureau\",\n      \"mobileView\": \"Vue mobile (390x844)\",\n      \"responsiveView\": \"Vue responsive (redimensionnable)\",\n      \"startDevServer\": \"Démarrer le serveur de développement\",\n      \"submitUrl\": \"Valider l'URL\",\n      \"toggleDevTools\": \"Basculer DevTools\"\n    },\n    \"loading\": {\n      \"startingServer\": \"Démarrage du serveur de développement...\",\n      \"waitingForServer\": \"En attente du serveur...\",\n      \"loadingPreview\": \"Chargement de l'aperçu...\",\n      \"manualUrlHint\": \"Aucune URL détectée pour l'instant. Vous pouvez saisir une URL manuellement dans la barre d'outils ci-dessus.\"\n    },\n    \"browser\": {\n      \"title\": \"Aperçu du serveur de développement\",\n      \"devServerFallback\": \"Serveur de développement\"\n    }\n  },\n  \"diff\": {\n    \"filesChanged_one\": \"{{count}} fichier modifié\",\n    \"filesChanged_other\": \"{{count}} fichiers modifiés\",\n    \"largeDiff\": {\n      \"title\": \"Fichier volumineux\",\n      \"linesChanged\": \"{{count}} lignes modifiées\",\n      \"loadAnyway\": \"Charger le diff quand même\",\n      \"warning\": \"Les gros diffs peuvent ralentir votre navigateur.\"\n    }\n  },\n  \"processes\": {\n    \"noLogsAvailable\": \"Aucun log disponible\",\n    \"selectAttempt\": \"Sélectionnez une tentative pour voir les processus d'exécution.\",\n    \"errorLoadingUpdates\": \"Échec du chargement des mises à jour en direct pour les processus.\",\n    \"reconnecting\": \"Reconnexion...\",\n    \"loading\": \"Chargement des processus d'exécution...\",\n    \"noProcesses\": \"Aucun processus d'exécution trouvé pour cette tentative.\",\n    \"processId\": \"ID du processus : {{id}}\",\n    \"deleted\": \"Supprimé\",\n    \"deletedTooltip\": \"Supprimé par restauration : la timeline a été restaurée à un point de contrôle et les exécutions ultérieures ont été supprimées\",\n    \"agent\": \"Agent :\",\n    \"exit\": \"Sortie : {{code}}\",\n    \"started\": \"Démarré : {{date}}\",\n    \"completed\": \"Terminé : {{date}}\",\n    \"detailsTitle\": \"Détails du processus\",\n    \"backToList\": \"Retour à la liste\",\n    \"loadingDetails\": \"Chargement des détails du processus...\",\n    \"errorLoadingDetails\": \"Échec du chargement des détails du processus. Veuillez réessayer.\",\n    \"copyLogs\": \"Copier les logs\",\n    \"logsCopied\": \"Copié !\"\n  },\n  \"followUp\": {\n    \"queuedMessage\": \"Message en file d'attente - s'exécutera lorsque l'exécution actuelle sera terminée\"\n  },\n  \"todoPopup\": {\n    \"title\": \"Tâches\",\n    \"progress\": \"{{completed}}/{{total}} terminées\",\n    \"noTasks\": \"Aucune tâche\"\n  },\n  \"conversation\": {\n    \"executors\": \"Exécuteurs\",\n    \"saveAsDefault\": \"Enregistrer par défaut\",\n    \"you\": \"Vous\",\n    \"thinking\": \"Réflexion\",\n    \"todo\": \"À faire\",\n    \"todos\": \"À faire\",\n    \"completed\": \"terminé\",\n    \"incomplete\": \"incomplet\",\n    \"pending\": \"en attente\",\n    \"inProgress\": \"en cours\",\n    \"skipped\": \"ignoré\",\n    \"error\": \"Erreur\",\n    \"retry\": \"Réessayer\",\n    \"showMore\": \"Afficher plus\",\n    \"showLess\": \"Afficher moins\",\n    \"actions\": {\n      \"cancel\": \"Annuler\",\n      \"submitFeedback\": \"Soumettre le feedback\",\n      \"stop\": \"Arrêter\",\n      \"stopping\": \"Arrêt en cours\",\n      \"loading\": \"Chargement\",\n      \"send\": \"Envoyer\",\n      \"sending\": \"Envoi en cours\",\n      \"queue\": \"File d'attente\",\n      \"cancelQueue\": \"Annuler la file\",\n      \"requestChanges\": \"Demander des modifications\",\n      \"approve\": \"Approuver\",\n      \"clearReviewComments\": \"Effacer les commentaires de révision\",\n      \"edit\": \"Modifier le message\",\n      \"scrollToPreviousMessage\": \"Go to previous message\",\n      \"reset\": \"Réinitialiser\",\n      \"resetTooltip\": \"Réinitialiser à ce point\"\n    },\n    \"approval\": {\n      \"conflictWarning\": \"Les fichiers en conflit nécessitent une résolution manuelle\",\n      \"conflicts_one\": \"{{count}} conflit\",\n      \"conflicts_other\": \"{{count}} conflits\"\n    },\n    \"sessions\": {\n      \"newSession\": \"Nouvelle session\",\n      \"latest\": \"Plus récent\",\n      \"previous\": \"Précédent\",\n      \"label\": \"Sessions\",\n      \"noPreviousSessions\": \"Aucune session précédente\",\n      \"rename\": \"Renommer\",\n      \"renameTitle\": \"Renommer la session\",\n      \"renameDescription\": \"Entrez un nouveau nom pour cette session.\",\n      \"renamePlaceholder\": \"Entrez le nom de la session\",\n      \"renaming\": \"Renommage...\"\n    },\n    \"reviewComments\": {\n      \"count_one\": \"{{count}} commentaire de révision sera inclus\",\n      \"count_other\": \"{{count}} commentaires de révision seront inclus\"\n    },\n    \"workspace\": {\n      \"create\": \"Créer\",\n      \"creating\": \"Création en cours...\"\n    },\n    \"fileEntry\": {\n      \"created\": \"Créé\",\n      \"modified\": \"Modifié\",\n      \"deleted\": \"Supprimé\",\n      \"renamed\": \"Renommé\"\n    },\n    \"script\": {\n      \"running\": \"Exécution en cours...\",\n      \"exitCode\": \"Code de sortie : {{code}}\",\n      \"completedSuccessfully\": \"Terminé avec succès\",\n      \"clickToViewLogs\": \"Cliquer pour voir les logs\"\n    },\n    \"scriptPlaceholder\": {\n      \"setupTitle\": \"Script de configuration\",\n      \"setupDescription\": \"Aucun script de configuration. Les scripts de configuration s'exécutent avant le démarrage de l'agent de codage.\",\n      \"cleanupTitle\": \"Script de nettoyage\",\n      \"cleanupDescription\": \"Aucun script de nettoyage. Les scripts de nettoyage s'exécutent après la fin de l'agent de codage.\",\n      \"configure\": \"Configurer\"\n    },\n    \"updatedTodos\": \"À faire mis à jour\",\n    \"viewInChangesPanel\": \"Voir dans le panneau des modifications\",\n    \"unableToRenderDiff\": \"Impossible d'afficher le diff.\"\n  },\n  \"attempt\": {\n    \"actions\": {\n      \"startDevServer\": \"Démarrer le serveur de développement\"\n    }\n  },\n  \"git\": {\n    \"labels\": {\n      \"taskBranch\": \"Branche de la tâche\"\n    },\n    \"branch\": {\n      \"current\": \"actuelle\"\n    },\n    \"forcePushDialog\": {\n      \"title\": \"Push forcé requis\",\n      \"description\": \"La branche distante{{branchLabel}} a divergé de votre branche locale. Un push normal a été rejeté.\",\n      \"warning\": \"Le push forcé écrasera les modifications distantes avec vos modifications locales. Cette action ne peut pas être annulée.\",\n      \"note\": \"Procédez uniquement si vous êtes certain de vouloir remplacer l'historique de la branche distante.\",\n      \"error\": \"Échec du push forcé\"\n    },\n    \"status\": {\n      \"commits_one\": \"commit\",\n      \"commits_other\": \"commits\",\n      \"conflicts\": \"Conflits\",\n      \"upToDate\": \"À jour\",\n      \"ahead\": \"en avance\",\n      \"behind\": \"en retard\"\n    },\n    \"states\": {\n      \"merged\": \"Fusionné !\",\n      \"merging\": \"Fusion en cours...\",\n      \"merge\": \"Fusionner\",\n      \"rebasing\": \"Rebasage en cours...\",\n      \"rebase\": \"Rebaser\",\n      \"pushed\": \"Poussé !\",\n      \"pushing\": \"Push en cours...\",\n      \"push\": \"Push\",\n      \"pushFailed\": \"Échec\",\n      \"forcePush\": \"Push forcé\",\n      \"forcePushing\": \"Push forcé en cours...\",\n      \"creating\": \"Création en cours...\",\n      \"createPr\": \"Créer une PR\"\n    },\n    \"errors\": {\n      \"changeTargetBranch\": \"Échec du changement de branche cible\",\n      \"pushChanges\": \"Échec du push des modifications\",\n      \"mergeChanges\": \"Échec de la fusion des modifications\",\n      \"rebaseBranch\": \"Échec du rebasage de la branche\",\n      \"branchStatusUnavailable\": \"Impossible de récupérer le statut de la branche. Vous pouvez toujours changer la branche cible.\"\n    },\n    \"pr\": {\n      \"open\": \"Ouvrir la PR #{{number}}\",\n      \"number\": \"PR #{{number}}\",\n      \"merged\": \"PR #{{prNumber}} fusionnée\"\n    },\n    \"actions\": {\n      \"title\": \"Actions Git\",\n      \"changeTarget\": \"Changer la cible\",\n      \"prMerged\": \"La PR #{{number}} est déjà fusionnée\",\n      \"loginRequired\": {\n        \"title\": \"Connectez-vous pour gérer les actions git\",\n        \"description\": \"Connectez-vous à Vibe Kanban pour pouvoir pousser des branches, fusionner des modifications ou ouvrir des pull requests pour cette tâche.\",\n        \"action\": \"Se connecter\"\n      }\n    },\n    \"mergeDialog\": {\n      \"title\": \"Fusionner les modifications\",\n      \"description\": \"Cela fusionnera vos modifications dans la branche cible. Êtes-vous sûr de vouloir continuer ?\"\n    },\n    \"createRepo\": {\n      \"dialog\": {\n        \"title\": \"Créer un nouveau dépôt\",\n        \"description\": \"Initialiser un nouveau dépôt git\"\n      },\n      \"form\": {\n        \"nameLabel\": \"Nom\",\n        \"namePlaceholder\": \"mon-projet\",\n        \"locationLabel\": \"Emplacement\",\n        \"locationPlaceholder\": \"Répertoire actuel\"\n      },\n      \"browseDialog\": {\n        \"title\": \"Sélectionner le répertoire parent\",\n        \"description\": \"Choisissez où créer le nouveau dépôt\"\n      },\n      \"errors\": {\n        \"nameRequired\": \"Le nom du dépôt est requis\",\n        \"createFailed\": \"Échec de la création du dépôt\"\n      },\n      \"buttons\": {\n        \"createRepository\": \"Créer le dépôt\"\n      },\n      \"states\": {\n        \"creating\": \"Création en cours...\"\n      }\n    }\n  },\n  \"repoBranchSelector\": {\n    \"label\": \"Branche de base\"\n  },\n  \"viewProcessesDialog\": {\n    \"title\": \"Processus d'exécution\"\n  },\n  \"editBranchName\": {\n    \"dialog\": {\n      \"title\": \"Modifier le nom de la branche\",\n      \"description\": \"Saisissez un nouveau nom pour la branche. Impossible de renommer s'il existe une PR ouverte.\",\n      \"branchNameLabel\": \"Nom de la branche\",\n      \"placeholder\": \"ex: feature/ma-branche\",\n      \"renaming\": \"Renommage en cours...\",\n      \"action\": \"Renommer la branche\"\n    }\n  },\n  \"startReviewDialog\": {\n    \"title\": \"Démarrer la révision\",\n    \"description\": \"Demandez à l'agent de codage de réviser vos modifications et de fournir des commentaires.\",\n    \"additionalInstructions\": \"Instructions supplémentaires (optionnel)\",\n    \"reviewComments\": \"Commentaires de révision ({{count}})\",\n    \"includeGitContext\": \"Inclure le contexte git\",\n    \"includeGitContextDescription\": \"Indique à l'agent comment voir toutes les modifications effectuées sur cette branche\",\n    \"newSession\": \"Nouvelle session\",\n    \"sessionName\": \"Révision\"\n  },\n  \"actionsMenu\": {\n    \"startReview\": \"Démarrer la révision\",\n    \"startingReview\": \"Démarrage de la révision...\"\n  },\n  \"resolveConflicts\": {\n    \"dialog\": {\n      \"title\": \"Résoudre les conflits\",\n      \"description\": \"Des conflits ont été détectés. Choisissez comment vous voulez que l'agent les résolve.\",\n      \"newSession\": \"Démarrer une nouvelle session\",\n      \"resolve\": \"Résoudre les conflits\",\n      \"resolving\": \"Démarrage...\",\n      \"filesWithConflicts_one\": \"{{count}} fichier a des conflits\",\n      \"filesWithConflicts_other\": \"{{count}} fichiers ont des conflits\",\n      \"andMore\": \"...et {{count}} autres\",\n      \"sessionName\": \"Résoudre les conflits\"\n    }\n  },\n  \"rebaseInProgress\": {\n    \"dialog\": {\n      \"title\": \"Rebase en cours\",\n      \"description\": \"Un rebase vers {{targetBranch}} est en cours sans conflits. Choisissez comment procéder.\",\n      \"hint\": \"Vous pouvez continuer le rebase pour le terminer, ou abandonner pour revenir à l'état précédent.\",\n      \"continue\": \"Continuer le rebase\",\n      \"continuing\": \"Continuation...\",\n      \"abort\": \"Abandonner le rebase\",\n      \"aborting\": \"Abandon...\",\n      \"continueError\": \"Échec de la continuation du rebase. Il peut y avoir des conflits non résolus.\",\n      \"abortError\": \"Échec de l'abandon du rebase. Veuillez réessayer.\"\n    }\n  },\n  \"createPrDialog\": {\n    \"title\": \"Créer une Pull Request\",\n    \"description\": \"Créer une pull request pour cette tentative de tâche.\",\n    \"titleLabel\": \"Titre\",\n    \"titlePlaceholder\": \"Saisir le titre de la PR\",\n    \"descriptionLabel\": \"Description (optionnel)\",\n    \"descriptionPlaceholder\": \"Saisir la description de la PR\",\n    \"baseBranchLabel\": \"Branche de base\",\n    \"loadingBranches\": \"Chargement des branches...\",\n    \"selectBaseBranch\": \"Sélectionner la branche de base\",\n    \"draftLabel\": \"Créer comme brouillon\",\n    \"autoGenerateLabel\": \"Générer automatiquement la description de la PR avec l'IA\",\n    \"creating\": \"Création en cours...\",\n    \"createButton\": \"Créer la PR\",\n    \"errors\": {\n      \"failedToCreate\": \"Échec de la création de la PR\",\n      \"gitCliNotLoggedIn\": \"Git n'est pas authentifié. Exécutez \\\"gh auth login\\\" (ou configurez les identifiants Git) et réessayez.\",\n      \"gitCliNotInstalled\": \"Git CLI n'est pas installé. Installez Git pour créer une PR.\",\n      \"targetBranchNotFound\": \"La branche cible '{{branch}}' n'existe pas sur le dépôt distant. Veuillez vous assurer que la branche existe avant de créer une pull request.\"\n    }\n  },\n  \"prComments\": {\n    \"dialog\": {\n      \"title\": \"Sélectionner les commentaires de la PR\",\n      \"noComments\": \"Aucun commentaire trouvé sur cette PR\",\n      \"selectAll\": \"Tout sélectionner\",\n      \"deselectAll\": \"Tout désélectionner\",\n      \"add\": \"Ajouter\",\n      \"selectedCount\": \"{{selected}} sur {{total}} sélectionnés\"\n    },\n    \"card\": {\n      \"review\": \"Révision\",\n      \"tooltip\": \"Cliquer pour voir, double-cliquer pour modifier\"\n    }\n  },\n  \"taskFormDialog\": {\n    \"createTitle\": \"Créer une nouvelle tâche\",\n    \"editTitle\": \"Modifier la tâche\",\n    \"titlePlaceholder\": \"Titre de la tâche...\",\n    \"descriptionPlaceholder\": \"Ajouter plus de détails (optionnel). Tapez @ pour rechercher des fichiers.\",\n    \"statusLabel\": \"Statut\",\n    \"statusOptions\": {\n      \"todo\": \"À faire\",\n      \"inprogress\": \"En cours\",\n      \"inreview\": \"En révision\",\n      \"done\": \"Terminé\",\n      \"cancelled\": \"Annulé\"\n    },\n    \"startLabel\": \"Démarrer\",\n    \"attachFile\": \"Joindre un fichier\",\n    \"dropImagesHere\": \"Déposez les images ici\",\n    \"updating\": \"Mise à jour...\",\n    \"updateTask\": \"Mettre à jour la tâche\",\n    \"starting\": \"Démarrage...\",\n    \"creating\": \"Création...\",\n    \"create\": \"Créer\",\n    \"discardDialog\": {\n      \"title\": \"Abandonner les modifications non enregistrées ?\",\n      \"description\": \"Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir les abandonner ?\",\n      \"continueEditing\": \"Continuer la modification\",\n      \"discardChanges\": \"Abandonner les modifications\"\n    }\n  },\n  \"restoreLogsDialog\": {\n    \"title\": \"Confirmer la nouvelle tentative\",\n    \"titleReset\": \"Confirmer la réinitialisation\",\n    \"historyChange\": {\n      \"title\": \"Changement d'historique\",\n      \"willDelete\": \"Supprimera ce processus\",\n      \"willDeleteProcesses_one\": \"Supprimera {{count}} processus\",\n      \"willDeleteProcesses_other\": \"Supprimera {{count}} processus\",\n      \"andLaterProcesses_one\": \"et {{count}} processus ultérieur\",\n      \"andLaterProcesses_other\": \"et {{count}} processus ultérieurs\",\n      \"fromHistory\": \"de l'historique.\",\n      \"codingAgentRuns_one\": \"{{count}} exécution d'agent de codage\",\n      \"codingAgentRuns_other\": \"{{count}} exécutions d'agent de codage\",\n      \"scriptProcesses_one\": \"{{count}} processus de script\",\n      \"scriptProcesses_other\": \"{{count}} processus de scripts\",\n      \"setupCleanupBreakdown\": \"({{setup}} configuration, {{cleanup}} nettoyage)\",\n      \"permanentWarning\": \"Cela modifie définitivement l'historique et ne peut pas être annulé.\"\n    },\n    \"uncommittedChanges\": {\n      \"title\": \"Modifications non commitées détectées\",\n      \"description_one\": \"Vous avez {{count}} modification non commitée\",\n      \"description_other\": \"Vous avez {{count}} modifications non commitées\",\n      \"andUntracked_one\": \" et {{count}} fichier non suivi\",\n      \"andUntracked_other\": \" et {{count}} fichiers non suivis\",\n      \"acknowledgeLabel\": \"Je comprends que ces modifications peuvent être affectées\"\n    },\n    \"resetWorktree\": {\n      \"title\": \"Réinitialiser le worktree\",\n      \"enabled\": \"Activé\",\n      \"disabled\": \"Désactivé\",\n      \"disabledUncommitted\": \"Désactivé (modifications non commitées détectées)\",\n      \"restoreDescription\": \"Votre worktree sera restauré à ce commit.\",\n      \"discardChanges_one\": \"Abandonner {{count}} modification non commitée.\",\n      \"discardChanges_other\": \"Abandonner {{count}} modifications non commitées.\",\n      \"untrackedPresent_one\": \"{{count}} fichier non suivi présent (non affecté par la réinitialisation).\",\n      \"untrackedPresent_other\": \"{{count}} fichiers non suivis présents (non affectés par la réinitialisation).\",\n      \"forceReset\": \"Forcer la réinitialisation (abandonner les modifications non commitées)\",\n      \"uncommittedWillDiscard\": \"Les modifications non commitées seront abandonnées.\",\n      \"uncommittedPresentHint\": \"Modifications non commitées présentes. Activez la réinitialisation forcée ou commitez/stashez pour continuer.\"\n    },\n    \"buttons\": {\n      \"retry\": \"Réessayer\",\n      \"reset\": \"Réinitialiser\"\n    }\n  },\n  \"scriptFixer\": {\n    \"setupScriptTitle\": \"Corriger le script de configuration\",\n    \"cleanupScriptTitle\": \"Corriger le script de nettoyage\",\n    \"archiveScriptTitle\": \"Corriger le script d'archivage\",\n    \"devServerTitle\": \"Corriger le script du serveur de développement\",\n    \"scriptLabel\": \"Script (modifier)\",\n    \"logsLabel\": \"Logs de la dernière exécution\",\n    \"saveButton\": \"Enregistrer\",\n    \"saveAndTestButton\": \"Enregistrer et tester\",\n    \"noLogs\": \"Aucun log d'exécution disponible\",\n    \"selectRepo\": \"Dépôt\",\n    \"fixScript\": \"Corriger le script\",\n    \"statusRunning\": \"Exécution en cours...\",\n    \"statusSuccess\": \"Terminé avec succès\",\n    \"statusFailed\": \"Échec avec le code de sortie {{exitCode}}\",\n    \"statusKilled\": \"Le processus a été interrompu\"\n  },\n  \"createWorkspaceFromPr\": {\n    \"title\": \"Créer un espace de travail à partir d'une PR\",\n    \"description\": \"Sélectionnez une pull request ouverte pour créer un espace de travail. Une tâche sera créée automatiquement avec le titre de la PR.\",\n    \"repositoryLabel\": \"Dépôt\",\n    \"remoteLabel\": \"Distant\",\n    \"pullRequestLabel\": \"Pull Request\",\n    \"loadingRepositories\": \"Chargement des dépôts...\",\n    \"loadingRemotes\": \"Chargement des distants...\",\n    \"noRepositoriesFound\": \"Aucun dépôt trouvé\",\n    \"loadingPullRequests\": \"Chargement des pull requests...\",\n    \"selectRepositoryFirst\": \"Sélectionnez d'abord un dépôt\",\n    \"noPullRequestsFound\": \"Aucune pull request ouverte trouvée\",\n    \"runSetupScript\": \"Exécuter le script de configuration\",\n    \"creating\": \"Création...\",\n    \"createWorkspace\": \"Créer l'espace de travail\",\n    \"selectRepository\": \"Sélectionner un dépôt\",\n    \"selectRemote\": \"Sélectionner un distant\",\n    \"selectPullRequest\": \"Sélectionner une pull request\",\n    \"searchPrsPlaceholder\": \"Rechercher des PRs par numéro ou titre...\",\n    \"noMatchingPrs\": \"Aucune pull request correspondante\",\n    \"default\": \"par défaut\",\n    \"openPrInBrowser\": \"Ouvrir la PR dans le navigateur\",\n    \"errors\": {\n      \"cliNotInstalled\": \"{{provider}} CLI n'est pas installé\",\n      \"unsupportedProvider\": \"Fournisseur Git non pris en charge\",\n      \"failedToLoadPrs\": \"Échec du chargement des pull requests\",\n      \"prNotFound\": \"Pull request non trouvée\",\n      \"failedToCreateWorkspace\": \"Échec de la création de l'espace de travail\"\n    }\n  },\n  \"shareDialog\": {\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Sign in to access this feature.\",\n      \"action\": \"Sign in\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ja/common.json",
    "content": "{\n  \"editorNames\": {\n    \"custom\": \"カスタム\"\n  },\n  \"folderPicker\": {\n    \"legend\": \"フォルダ名をクリックして移動 • アクションボタンで選択\",\n    \"manualPathLabel\": \"パスを手動入力:\",\n    \"go\": \"移動\",\n    \"searchLabel\": \"現在のディレクトリを検索:\",\n    \"selectCurrent\": \"現在を選択\",\n    \"gitRepo\": \"git リポジトリ\",\n    \"selectPath\": \"パスを選択\"\n  },\n  \"branchSelector\": {\n    \"badges\": {\n      \"current\": \"現在\",\n      \"remote\": \"リモート\"\n    },\n    \"currentDisabled\": \"現在のブランチは選択できません\",\n    \"empty\": \"ブランチが見つかりません\",\n    \"placeholder\": \"ブランチを選択\",\n    \"searchPlaceholder\": \"ブランチを検索...\"\n  },\n  \"buttons\": {\n    \"cancel\": \"キャンセル\",\n    \"close\": \"閉じる\",\n    \"connect\": \"接続\",\n    \"continue\": \"続行\",\n    \"create\": \"作成\",\n    \"delete\": \"削除\",\n    \"disconnect\": \"切断\",\n    \"discard\": \"破棄\",\n    \"edit\": \"編集\",\n    \"manage\": \"管理\",\n    \"replay\": \"再生\",\n    \"reset\": \"リセット\",\n    \"save\": \"保存\",\n    \"send\": \"送信\",\n    \"addItem\": \"項目を追加\",\n    \"reply\": \"返信\",\n    \"retry\": \"Retry\",\n    \"add\": \"追加\"\n  },\n  \"form\": {\n    \"notSpecified\": \"指定なし\",\n    \"selectOption\": \"オプションを選択...\"\n  },\n  \"conversation\": {\n    \"deniedByUser\": \"{{toolName}} がユーザーによって拒否されました\",\n    \"output\": \"出力\",\n    \"plan\": \"計画\",\n    \"tool\": \"ツール\",\n    \"thinking\": \"思考中\",\n    \"toolSummary\": {\n      \"read\": \"{{path}} を読み込み\",\n      \"searched\": \"「{{query}}」を検索\",\n      \"fetched\": \"{{url}} を取得\",\n      \"ranCommand\": \"コマンドを実行\",\n      \"createdTask\": \"タスクを作成: {{description}}\",\n      \"todoOperation\": \"{{operation}} Todo\"\n    },\n    \"subagent\": {\n      \"defaultType\": \"サブエージェント\"\n    },\n    \"loadingEarlierMessages\": \"以前のメッセージを読み込み中\"\n  },\n  \"language\": {\n    \"browserDefault\": \"ブラウザ設定\"\n  },\n  \"states\": {\n    \"error\": \"エラー\",\n    \"loading\": \"読み込み中...\",\n    \"loadingHistory\": \"履歴を読み込み中\",\n    \"reconnecting\": \"再接続中\",\n    \"saving\": \"保存中...\",\n    \"success\": \"成功\"\n  },\n  \"orgSwitcher\": {\n    \"organizations\": \"組織\",\n    \"createOrganization\": \"組織を作成\",\n    \"orgSettings\": \"組織の設定\"\n  },\n  \"ok\": \"OK\",\n  \"error\": \"エラー\",\n  \"signIn\": \"サインイン\",\n  \"signOut\": \"サインアウト\",\n  \"oauth\": {\n    \"title\": \"Vibe Kanbanにサインイン\",\n    \"description\": \"サインインして組織に参加し、チームとタスクを共有します\",\n    \"continueWithGitHub\": \"GitHubで続行\",\n    \"continueWithGoogle\": \"Googleで続行\",\n    \"waitingTitle\": \"認証を完了\",\n    \"waitingDescription\": \"認証用のポップアップウィンドウが開きました\",\n    \"waitingForAuth\": \"認証を待っています...\",\n    \"popupInstructions\": \"ポップアップウィンドウが開かない場合は、ポップアップブロックの設定を確認してください。\",\n    \"back\": \"戻る\",\n    \"successTitle\": \"認証成功！\",\n    \"welcomeBack\": \"おかえりなさい、{{name}}\",\n    \"errorTitle\": \"認証失敗\",\n    \"errorDescription\": \"アカウントの認証中に問題が発生しました\",\n    \"tryAgain\": \"再試行\"\n  },\n  \"toolbar\": {\n    \"sortBy\": \"並べ替え\",\n    \"groupBy\": \"グループ化\"\n  },\n  \"sorting\": {\n    \"ascending\": \"昇順\",\n    \"descending\": \"降順\"\n  },\n  \"grouping\": {\n    \"date\": \"日付\",\n    \"assignee\": \"担当者\",\n    \"label\": \"ラベル\"\n  },\n  \"workspaces\": {\n    \"title\": \"ワークスペース\",\n    \"searchPlaceholder\": \"検索...\",\n    \"active\": \"アクティブ\",\n    \"archived\": \"アーカイブ済み\",\n    \"loading\": \"読み込み中...\",\n    \"notFound\": \"ワークスペースが見つかりません\",\n    \"selectToStart\": \"ワークスペースを選択して開始\",\n    \"draft\": \"下書き\",\n    \"viewArchive\": \"アーカイブを表示\",\n    \"backToActive\": \"アクティブに戻る\",\n    \"noArchived\": \"アーカイブされたワークスペースはありません\",\n    \"noWorkspaces\": \"ワークスペースがありません\",\n    \"newWorkspace\": \"新しいワークスペース\",\n    \"needsAttention\": \"対応が必要\",\n    \"idle\": \"待機中\",\n    \"running\": \"実行中\",\n    \"pin\": \"ピン留め\",\n    \"unpin\": \"ピン留め解除\",\n    \"archive\": \"アーカイブ\",\n    \"more\": \"その他の操作\",\n    \"rename\": {\n      \"title\": \"ワークスペースの名前を変更\",\n      \"description\": \"このワークスペースの新しい名前を入力してください。\",\n      \"nameLabel\": \"名前\",\n      \"placeholder\": \"ワークスペース名を入力\",\n      \"action\": \"名前を変更\",\n      \"renaming\": \"名前を変更中...\"\n    },\n    \"unlinkFromIssue\": \"課題からリンク解除\",\n    \"deleteWorkspace\": \"ワークスペースを削除\",\n    \"unlink\": \"リンク解除\",\n    \"delete\": \"削除\",\n    \"unlinkConfirmMessage\": \"このワークスペースを課題からリンク解除してもよろしいですか？ワークスペースは引き続き存在しますが、この課題との関連付けは解除されます。\",\n    \"deleteConfirmMessage\": \"このワークスペースを削除してもよろしいですか？これにより課題からのリンクが解除され、ローカルワークスペースが削除されます。この操作は元に戻せません。\",\n    \"unlinkError\": \"ワークスペースのリンク解除に失敗しました\",\n    \"deleteError\": \"ワークスペースの削除に失敗しました\",\n    \"filesChanged\": \"{{count}}ファイル\",\n    \"deleteDialog\": {\n      \"title\": \"ワークスペースを削除\",\n      \"description\": \"このワークスペースを削除してもよろしいですか？この操作は元に戻せません。\",\n      \"deleteBranchLabel\": \"ブランチを削除\",\n      \"cannotDeleteOpenPr\": \"PRがオープン中はブランチを削除できません\",\n      \"unlinkFromIssueLabel\": \"イシューからもリンク解除する\"\n    },\n    \"linkError\": \"Failed to link workspace\"\n  },\n  \"fileTree\": {\n    \"searchPlaceholder\": \"ファイルを検索...\",\n    \"noResults\": \"一致するファイルがありません\",\n    \"title\": \"ファイル\",\n    \"showGitHubComments\": \"GitHubコメントを表示\",\n    \"hideGitHubComments\": \"GitHubコメントを非表示\",\n    \"prevGitHubComment\": \"前のコメント付きファイル\",\n    \"nextGitHubComment\": \"次のコメント付きファイル\"\n  },\n  \"sections\": {\n    \"changes\": \"変更\",\n    \"repositories\": \"リポジトリ\",\n    \"addRepositories\": \"リポジトリを追加\",\n    \"project\": \"プロジェクト\",\n    \"processes\": \"プロセス\",\n    \"devServer\": \"開発サーバー\",\n    \"advanced\": \"詳細設定\",\n    \"workingBranch\": \"作業ブランチ\",\n    \"recent\": \"最近\",\n    \"other\": \"その他\",\n    \"devServerPreview\": \"開発サーバープレビュー\",\n    \"terminal\": \"ターミナル\",\n    \"notes\": \"メモ\"\n  },\n  \"notes\": {\n    \"placeholder\": \"このワークスペースについてメモを追加...\",\n    \"selectWorkspace\": \"メモを表示するにはワークスペースを選択してください\"\n  },\n  \"actions\": {\n    \"copyPath\": \"パスをコピー\",\n    \"cancel\": \"キャンセル\",\n    \"saveChanges\": \"変更を保存\",\n    \"copied\": \"コピーしました\",\n    \"collapse\": \"折りたたむ\"\n  },\n  \"comments\": {\n    \"addReviewComment\": \"レビューコメントを追加\",\n    \"addPlaceholder\": \"コメントを追加...\",\n    \"editPlaceholder\": \"コメントを編集...\",\n    \"copyToReview\": \"レビューにコピー\"\n  },\n  \"confirm\": {\n    \"defaultConfirm\": \"確認\",\n    \"defaultCancel\": \"キャンセル\"\n  },\n  \"dialogs\": {\n    \"selectGitRepository\": \"Gitリポジトリを選択\",\n    \"chooseExistingRepo\": \"ファイルシステムから既存のリポジトリを選択してください\"\n  },\n  \"empty\": {\n    \"noChanges\": \"表示する変更がありません\"\n  },\n  \"commandBar\": {\n    \"noResults\": \"結果が見つかりません。\",\n    \"back\": \"戻る\",\n    \"defaultPlaceholder\": \"コマンドを入力または検索...\",\n    \"selectBranch\": \"Select Branch\",\n    \"selectBranchFor\": \"Select Branch for {{repoName}}\"\n  },\n  \"workspacesGuide\": {\n    \"welcome\": {\n      \"title\": \"ようこそ\",\n      \"content\": \"Vibe Kanbanの新しいUIであるWorkspacesへようこそ。早期フィードバックのために一部のユーザーに先行公開しています。ナビバーのフィードバックアイコンからいつでもご意見をお聞かせください。\"\n    },\n    \"commandBar\": {\n      \"title\": \"コマンドバー\",\n      \"content\": \"コマンドバーはナビゲーションの中心となるハブです。CMD+Kで開いて、ワークスペースで利用可能なすべてのアクションを検索・アクセスできます。\"\n    },\n    \"contextBar\": {\n      \"title\": \"コンテキストバー\",\n      \"content\": \"コンテキストバーでペイン間をすばやく切り替えられます。使いやすい場所にドラッグして配置できます。\"\n    },\n    \"sidebar\": {\n      \"title\": \"ワークスペースサイドバー\",\n      \"content\": \"すべてのワークスペースの状態を一目で確認できます。通知により注意が必要なワークスペースがハイライトされます。マージ済みのワークスペースをアーカイブしてサイドバーを整理しましょう。\"\n    },\n    \"multiRepo\": {\n      \"title\": \"マルチリポジトリ対応\",\n      \"content\": \"1つのワークスペースに複数のリポジトリを追加できます。別のリポジトリで作業しながら他のリポジトリのコードを参照したり、複数のリポジトリにまたがる変更を同時に実装できます。\"\n    },\n    \"sessions\": {\n      \"title\": \"複数セッション\",\n      \"content\": \"1つのワークスペース内で複数のエージェント会話セッションを作成できます（異なるエージェントとのセッションも含む）。これにより会話の制限を回避したり、別スレッドでレビューエージェントを起動できます。\"\n    },\n    \"preview\": {\n      \"title\": \"変更のプレビュー\",\n      \"content\": \"コンテキストを切り替えることなく、内蔵ブラウザで作業をプレビューできます。デスクトップ、モバイル、カスタムビューポートサイズでテストできます。\"\n    },\n    \"diffs\": {\n      \"title\": \"差分とコメント\",\n      \"content\": \"再設計された差分パネルには変更のファイルツリーが含まれています。差分に直接コメントしてエージェントにフィードバックを送ったり、ワークスペースがPRにリンクされている場合はGitHubコメントを表示できます。\"\n    },\n    \"classicUi\": {\n      \"title\": \"クラシックUIに戻る\",\n      \"content\": \"ナビバー左側の終了アイコンをクリックすると、クラシックカンバンボードに戻ります。新しいUIを完全に無効にするには、設定の「ベータ機能」にある「Workspacesベータを有効にする」オプションを更新してください。\"\n    }\n  },\n  \"logs\": {\n    \"searchLogs\": \"ログを検索\",\n    \"selectProcessToView\": \"プロセスを選択してログを表示\"\n  },\n  \"processes\": {\n    \"noProcesses\": \"プロセスがありません\",\n    \"terminal\": \"ターミナル\"\n  },\n  \"search\": {\n    \"matchCount\": \"{{current}} / {{total}}\",\n    \"noMatches\": \"一致なし\"\n  },\n  \"contextUsage\": {\n    \"label\": \"コンテキスト使用量\",\n    \"emptyTooltip\": \"コンテキスト使用量は次の返信後に表示されます\",\n    \"tooltip\": \"コンテキスト: {{percentage}}% · {{used}} / {{total}} tokens\",\n    \"ariaLabel\": \"コンテキスト使用量: {{percentage}}%\"\n  },\n  \"shortcuts\": {\n    \"title\": \"キーボードショートカット\",\n    \"inWorkspace\": \"(ワークスペース内)\",\n    \"sequentialHint\": \"シーケンシャルショートカット：最初のキーを押し、500ms以内に2番目のキーを押してください。\",\n    \"configurableHint\": \"設定 → 一般 → メッセージ入力で設定可能\",\n    \"groups\": {\n      \"quickActions\": \"クイックアクション\",\n      \"navigation\": \"ナビゲーション\",\n      \"modifiers\": \"修飾キー\",\n      \"goTo\": \"移動 (G ...)\",\n      \"workspace\": \"ワークスペース (W ...)\",\n      \"view\": \"表示 (V ...)\",\n      \"issues\": \"課題 (I ...)\",\n      \"git\": \"Git (X ...)\",\n      \"yank\": \"コピー (Y ...)\",\n      \"toggle\": \"切り替え (T ...)\",\n      \"run\": \"実行 (R ...)\"\n    },\n    \"actions\": {\n      \"showHelp\": \"このヘルプを表示\",\n      \"closeCancel\": \"閉じる/キャンセル\",\n      \"createNewTask\": \"新しいタスクを作成\",\n      \"deleteSelected\": \"選択を削除\",\n      \"focusSearch\": \"検索にフォーカス\",\n      \"moveDown\": \"下に移動\",\n      \"moveUp\": \"上に移動\",\n      \"moveLeft\": \"左に移動\",\n      \"moveRight\": \"右に移動\",\n      \"openCommandBar\": \"コマンドバーを開く\",\n      \"formatInlineCode\": \"Format inline code\",\n      \"sendMessage\": \"メッセージを送信\",\n      \"settings\": \"設定に移動\",\n      \"new-workspace\": \"新しいワークスペースに移動\",\n      \"duplicate-workspace\": \"ワークスペースを複製\",\n      \"rename-workspace\": \"ワークスペースの名前を変更\",\n      \"pin-workspace\": \"ワークスペースをピン留め/解除\",\n      \"archive-workspace\": \"ワークスペースをアーカイブ\",\n      \"delete-workspace\": \"ワークスペースを削除\",\n      \"toggle-changes-mode\": \"変更パネルの切り替え\",\n      \"toggle-logs-mode\": \"ログパネルの切り替え\",\n      \"toggle-preview-mode\": \"プレビューパネルの切り替え\",\n      \"toggle-left-sidebar\": \"左サイドバーの切り替え\",\n      \"toggle-left-main-panel\": \"チャットパネルの切り替え\",\n      \"create-issue\": \"課題を作成\",\n      \"change-issue-status\": \"ステータスを変更\",\n      \"change-issue-priority\": \"優先度を変更\",\n      \"change-assignees\": \"担当者を変更\",\n      \"make-sub-issue-of\": \"サブ課題にする\",\n      \"add-sub-issue\": \"サブ課題を追加\",\n      \"remove-parent-issue\": \"親課題を解除\",\n      \"link-workspace\": \"ワークスペースをリンク\",\n      \"duplicate-issue\": \"課題を複製\",\n      \"delete-issue\": \"課題を削除\",\n      \"git-create-pr\": \"Pull Requestを作成\",\n      \"git-merge\": \"ブランチをマージ\",\n      \"git-rebase\": \"ブランチをリベース\",\n      \"git-push\": \"変更をプッシュ\",\n      \"copy-path\": \"パスをコピー\",\n      \"copy-raw-logs\": \"生ログをコピー\",\n      \"toggle-dev-server\": \"開発サーバーの切り替え\",\n      \"toggle-wrap-lines\": \"行折り返しの切り替え\",\n      \"run-setup-script\": \"セットアップスクリプトを実行\",\n      \"run-cleanup-script\": \"クリーンアップスクリプトを実行\",\n      \"run-archive-script\": \"アーカイブスクリプトを実行\"\n    }\n  },\n  \"typeahead\": {\n    \"tags\": \"タグ\",\n    \"files\": \"ファイル\",\n    \"commands\": \"コマンド\",\n    \"chooseRepo\": \"リポジトリを選択\",\n    \"selectedRepo\": \"選択中のリポジトリ: {{repoName}}\",\n    \"missingRepo\": \"選択したリポジトリは利用できなくなりました。\",\n    \"noTagsOrFiles\": \"タグまたはファイルが見つかりません\",\n    \"createTag\": \"新しいタグを作成\",\n    \"noCommands\": \"このエージェントで利用可能なコマンドはありません。\"\n  },\n  \"kanban\": {\n    \"noVisibleStatuses\": \"すべてのステータスが非表示です。表示設定を使用するか、別のタブに切り替えて課題を表示してください。\",\n    \"noProjectFound\": \"プロジェクトが見つかりません\",\n    \"unassigned\": \"未割り当て\",\n    \"noTagsAvailable\": \"利用可能なタグがありません\",\n    \"createNewIssue\": \"新しい課題を作成\",\n    \"searchTags\": \"タグを検索...\",\n    \"selectColorFor\": \"色を選択:\",\n    \"createTag\": \"作成\",\n    \"noPrCreated\": \"PRが作成されていません\",\n    \"noCommentsYet\": \"コメントはまだありません\",\n    \"createdBy\": \"作成者\",\n    \"comments\": \"コメント\",\n    \"enterCommentPlaceholder\": \"コメントを入力してください...\",\n    \"attachFile\": \"ファイルを添付\",\n    \"attachFileHint\": \"ファイルを添付（1ファイル最大20MB）\",\n    \"dropFilesHere\": \"ここにファイルをドロップ\",\n    \"fileDropHint\": \"任意のファイル形式、最大20MB\",\n    \"unknownUser\": \"不明なユーザー\",\n    \"deletedUser\": \"削除済みユーザー\",\n    \"replyQuotePrefix\": \"が書きました:\",\n    \"moreActions\": \"その他の操作\",\n    \"closePanel\": \"パネルを閉じる\",\n    \"copyLink\": \"リンクをコピー\",\n    \"issueTitlePlaceholder\": \"課題タイトル...\",\n    \"issueDescriptionPlaceholder\": \"タスクの説明を入力してください...\",\n    \"createDraftWorkspaceImmediately\": \"下書きワークスペースをすぐに作成\",\n    \"createDraftWorkspaceDescription\": \"課題を作成した後、課題の詳細が入力されたワークスペース作成フォームを開きます\",\n    \"createIssue\": \"タスクを作成\",\n    \"newIssue\": \"新しい課題\",\n    \"previewCodeBlock\": \"[コードブロック]\",\n    \"previewImage\": \"[画像]\",\n    \"previewImageWithName\": \"[画像: {{name}}]\",\n    \"previewFile\": \"[ファイル]\",\n    \"previewFileWithName\": \"[ファイル: {{name}}]\",\n    \"imageAttachmentNameFallback\": \"添付ファイル\",\n    \"removeImage\": \"画像を削除\",\n    \"maxFilesAtOnce\": \"一度に最大 {{count}} 個までアップロードできます\",\n    \"fileExceedsLimit\": \"{{filename}} は20MBの上限を超えています\",\n    \"unknownError\": \"不明なエラー\",\n    \"failedToUploadFile\": \"{{filename}} のアップロードに失敗しました: {{message}}\",\n    \"downloadAttachment\": \"添付ファイルをダウンロード\",\n    \"subIssues\": \"サブ課題\",\n    \"noSubIssues\": \"サブ課題なし\",\n    \"markIndependentIssue\": \"独立した課題としてマーク\",\n    \"parentIssue\": \"親\",\n    \"subIssueIndicator\": \"↳\",\n    \"viewTabs\": {\n      \"active\": \"アクティブ\",\n      \"all\": \"すべて\"\n    },\n    \"loginRequired\": {\n      \"title\": \"サインインが必要です\",\n      \"description\": \"このプロジェクトを表示するにはサインインしてください。\",\n      \"action\": \"サインイン\"\n    },\n    \"team\": \"Team\",\n    \"personal\": \"Personal\",\n    \"searchPlaceholder\": \"Search issues...\",\n    \"filters\": \"Filters\",\n    \"clearFilters\": \"Clear filters\",\n    \"filtersDescription\": \"Adjust filters and sorting for this board view.\",\n    \"filterByTag\": \"Filter by tag\",\n    \"filterByPriority\": \"Filter by priority\",\n    \"tags\": \"Tags\",\n    \"sortBy\": \"Sort\",\n    \"workspaceSidebar\": {\n      \"sortDialogTitle\": \"Sort workspaces\",\n      \"sortDialogDescription\": \"Choose how workspaces are ordered in the sidebar.\",\n      \"sortByLabel\": \"Sort by\",\n      \"sortOrderLabel\": \"Sort order\",\n      \"sortUpdatedAt\": \"Updated at\",\n      \"sortCreatedAt\": \"Created at\",\n      \"filterDialogTitle\": \"Filter workspaces\",\n      \"filterDialogDescription\": \"Narrow down workspaces by project and PR state.\",\n      \"projectFilterLabel\": \"Project\",\n      \"prFilterLabel\": \"PR\",\n      \"prFilterAll\": \"All\",\n      \"prFilterHasPr\": \"Has PR\",\n      \"prFilterNoPr\": \"No PR\",\n      \"noProject\": \"No project\",\n      \"sortButtonTitle\": \"Sort workspaces\",\n      \"filterButtonTitle\": \"Filter workspaces\"\n    },\n    \"sortAscending\": \"Ascending\",\n    \"sortDescending\": \"Descending\",\n    \"assignee\": \"Assignee\",\n    \"self\": \"Me\",\n    \"priority\": \"Priority\",\n    \"subIssuesFilterLabel\": \"Sub-issues\",\n    \"workspacesFilterLabel\": \"Workspaces\",\n    \"selectAssignees\": \"Select assignees...\",\n    \"relationships\": \"Relationships\",\n    \"noRelationships\": \"No relationships\",\n    \"openProjectsGuide\": \"Projects guide\",\n    \"editProjectSettings\": \"Edit project settings\",\n    \"linkWorkspace\": \"Link workspace...\",\n    \"createNewWorkspace\": \"Create new workspace\",\n    \"workspaces\": \"Workspaces\",\n    \"showingWorkspaces\": \"Showing {{count}} of {{total}}\",\n    \"changeColor\": \"Change color\",\n    \"editName\": \"Edit name\",\n    \"deleteStatus\": \"Delete status\",\n    \"cannotDeleteWithIssues\": \"Move issues first\",\n    \"lastVisibleStatus\": \"At least one status must be visible\",\n    \"showStatus\": \"Show status\",\n    \"hideStatus\": \"Hide status\",\n    \"newStatus\": \"New Status\",\n    \"visibleColumns\": \"Visible Columns\",\n    \"dragToRearrange\": \"Drag to re-arrange\",\n    \"addColumn\": \"Add column\",\n    \"projectsGuide\": {\n      \"intro\": {\n        \"title\": \"Welcome\",\n        \"content\": \"Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.\"\n      },\n      \"welcome\": {\n        \"title\": \"Projects\",\n        \"content\": \"The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.\"\n      },\n      \"issues\": {\n        \"title\": \"Issues\",\n        \"content\": \"Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.\"\n      },\n      \"workspaces\": {\n        \"title\": \"Workspaces\",\n        \"content\": \"To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.\"\n      },\n      \"organizations\": {\n        \"title\": \"Invite your team\",\n        \"content\": \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      }\n    }\n  },\n  \"createMode\": {\n    \"headings\": {\n      \"repoStep\": \"どのリポジトリで作業しますか？\",\n      \"chatStep\": \"何に取り組みますか？\"\n    },\n    \"repoPicker\": {\n      \"actions\": {\n        \"recent\": \"最近\",\n        \"browse\": \"参照\",\n        \"create\": \"作成\"\n      },\n      \"setupHintTitle\": \"セットアップスクリプト\",\n      \"setupHint\": \"ヒント: このリポジトリのセットアップスクリプトは設定 → リポジトリで構成できます\",\n      \"setupHintDismiss\": \"閉じる\",\n      \"setupHintLink\": \"Configure in Settings\"\n    }\n  },\n  \"modelSelector\": {\n    \"preset\": \"プリセット\",\n    \"custom\": \"カスタマイズ\",\n    \"permissions\": \"権限\",\n    \"permissionAuto\": \"自動\",\n    \"permissionAsk\": \"確認\",\n    \"permissionPlan\": \"計画\",\n    \"agent\": \"エージェント\",\n    \"default\": \"デフォルト\",\n    \"model\": \"モデル\"\n  },\n  \"syncError\": {\n    \"networkErrors\": \"ネットワークエラー\",\n    \"streamsAffected\": \"{{count}}件のストリームが影響を受けています\",\n    \"streamsAffected_other\": \"{{count}}件のストリームが影響を受けています\",\n    \"status\": \"(ステータス {{status}})\",\n    \"refreshPage\": \"ページを更新\"\n  },\n  \"onboardingSignIn\": {\n    \"subtitle\": \"Sign in to continue\",\n    \"signedInAs\": \"Signed in as {{name}}.\",\n    \"moreOptions\": \"More options\",\n    \"featureHeader\": \"Feature\",\n    \"signedInHeader\": \"Signed in\",\n    \"skipSignInHeader\": \"Don't sign in\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\"\n  },\n  \"migration\": {\n    \"allProjectsMigrated\": \"すべてのプロジェクトはすでにクラウドに移行済みです。\",\n    \"continueToProjects\": \"プロジェクトに進む\"\n  },\n  \"appBar\": {\n    \"kanban\": {\n      \"tooltip\": \"カンバン\",\n      \"title\": \"カンバンボード\",\n      \"description\": \"サインインしてカンバンボードでコーディングエージェントを整理しましょう。\",\n      \"migrateOldProjects\": \"古いプロジェクトを移行\"\n    }\n  },\n  \"errors\": {\n    \"generic\": \"An unexpected error occurred\"\n  },\n  \"personal\": \"Personal\",\n  \"askQuestion\": {\n    \"title\": \"エージェントが質問しています\",\n    \"selectMultiple\": \"複数選択\",\n    \"confirmSelection\": \"選択を確定\",\n    \"submitting\": \"回答を送信中...\",\n    \"answeredCount\": \"{{count}}件の質問に回答済み\",\n    \"answeredCount_other\": \"{{count}}件の質問に回答済み\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ja/organization.json",
    "content": "{\n  \"createDialog\": {\n    \"title\": \"Create New Organization\",\n    \"description\": \"Create a new organization to collaborate with your team.\",\n    \"nameLabel\": \"Organization Name\",\n    \"namePlaceholder\": \"e.g., Acme Corporation\",\n    \"slugLabel\": \"Slug\",\n    \"slugPlaceholder\": \"e.g., acme-corporation\",\n    \"slugHelper\": \"Used in URLs. Lowercase letters, numbers, and hyphens only.\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Organization\"\n  },\n  \"inviteDialog\": {\n    \"title\": \"Invite Member\",\n    \"description\": \"Send an invitation to join your organization.\",\n    \"emailLabel\": \"Email Address\",\n    \"emailPlaceholder\": \"colleague@example.com\",\n    \"roleLabel\": \"Role\",\n    \"rolePlaceholder\": \"Select a role\",\n    \"roleHelper\": \"Admins can manage members and organization settings.\",\n    \"sending\": \"Sending...\",\n    \"sendButton\": \"Send Invitation\",\n    \"subscriptionRequired\": \"メンバーを追加するにはサブスクリプションが必要です\",\n    \"upgradePrompt\": \"追加のメンバーを招待するには、組織のプランをアップグレードしてください。\",\n    \"upgradeButton\": \"プランをアップグレード\"\n  },\n  \"roles\": {\n    \"member\": \"Member\",\n    \"admin\": \"Admin\"\n  },\n  \"memberList\": {\n    \"title\": \"Members\",\n    \"description\": \"Manage members and their roles in {{orgName}}\",\n    \"inviteButton\": \"Invite Member\",\n    \"loading\": \"Loading members...\",\n    \"none\": \"No members found\",\n    \"you\": \"You\"\n  },\n  \"invitationList\": {\n    \"title\": \"Pending Invitations\",\n    \"description\": \"View pending invitations for {{orgName}}\",\n    \"loading\": \"Loading invitations...\",\n    \"invited\": \"Invited {{date}}\",\n    \"pending\": \"Pending\"\n  },\n  \"settings\": {\n    \"title\": \"Organization Settings\",\n    \"description\": \"Manage organization members and permissions\",\n    \"selectLabel\": \"Select Organization\",\n    \"selectPlaceholder\": \"Select an organization\",\n    \"selectHelper\": \"Choose an organization to view and manage its members\",\n    \"noOrganizations\": \"No organizations available\",\n    \"loadingOrganizations\": \"Loading organizations...\",\n    \"loadError\": \"Failed to load organizations\",\n    \"dangerZone\": \"Danger Zone\",\n    \"dangerZoneDescription\": \"Irreversible and destructive actions\",\n    \"deleteOrganization\": \"Delete Organization\",\n    \"deleteOrganizationDescription\": \"Permanently delete this organization and all its data\",\n    \"confirmDelete\": \"Are you sure you want to delete {{orgName}}? This action cannot be undone.\",\n    \"deleteSuccess\": \"Organization deleted successfully\",\n    \"deleteError\": \"Failed to delete organization\"\n  },\n  \"loginRequired\": {\n    \"title\": \"ログインが必要です\",\n    \"description\": \"組織設定を管理するにはログインする必要があります。\",\n    \"action\": \"ログイン\"\n  },\n  \"confirmRemoveMember\": \"Are you sure you want to remove this member from the organization?\",\n  \"billing\": {\n    \"title\": \"請求とサブスクリプション\",\n    \"description\": \"組織のサブスクリプションと請求設定を管理\",\n    \"manageButton\": \"請求を管理\",\n    \"openInBrowser\": \"新しいブラウザタブで開きます\"\n  },\n  \"personalOrg\": {\n    \"cannotInvite\": \"個人組織には追加のメンバーを含めることができません。\",\n    \"createOrgPrompt\": \"他の人と協力するには組織を作成してください。\",\n    \"createOrgButton\": \"組織を作成\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ja/projects.json",
    "content": "{\n  \"createProjectDialog\": {\n    \"title\": \"Create Project\",\n    \"description\": \"Create a new project in this organization.\",\n    \"nameLabel\": \"Project name\",\n    \"namePlaceholder\": \"Enter project name\",\n    \"selectColor\": \"Select project color\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Project\"\n  },\n  \"deleteProjectDialog\": {\n    \"title\": \"Delete Project?\",\n    \"description\": \"This will permanently delete \\\"{{name}}\\\" and all its issues. This action cannot be undone.\",\n    \"error\": \"Failed to delete project. Please try again.\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ja/settings.json",
    "content": "{\n  \"settings\": {\n    \"common\": {\n      \"unsavedChanges\": \"• 未保存の変更があります\",\n      \"discardChangesConfirm\": \"Discard unsaved changes?\"\n    },\n    \"unsavedChanges\": {\n      \"title\": \"未保存の変更\",\n      \"message\": \"未保存の変更があります。保存せずに閉じてもよろしいですか？\",\n      \"discard\": \"変更を破棄\",\n      \"cancel\": \"編集を続ける\"\n    },\n    \"layout\": {\n      \"nav\": {\n        \"title\": \"設定\",\n        \"general\": \"一般\",\n        \"generalDesc\": \"テーマ、通知、および設定\",\n        \"projects\": \"プロジェクト\",\n        \"projectsDesc\": \"プロジェクトリポジトリと設定\",\n        \"repos\": \"リポジトリ\",\n        \"reposDesc\": \"リポジトリスクリプトと設定\",\n        \"agents\": \"エージェント\",\n        \"agentsDesc\": \"コーディングエージェントの設定\",\n        \"mcp\": \"MCPサーバー\",\n        \"mcpDesc\": \"モデルコンテキストプロトコルサーバー\",\n        \"organizations\": \"Organization Settings\",\n        \"organizationsDesc\": \"Manage organization members and permissions\",\n        \"remote-projects\": \"プロジェクト\",\n        \"remote-projectsDesc\": \"プロジェクトを管理\",\n        \"relay\": \"Remote Access\",\n        \"relayDesc\": \"Relay tunnel and enrollment\"\n      }\n    },\n    \"general\": {\n      \"loading\": \"設定を読み込み中...\",\n      \"loadError\": \"設定の読み込みに失敗しました。\",\n      \"save\": {\n        \"button\": \"設定を保存\",\n        \"success\": \"✓ 設定が正常に保存されました！\",\n        \"error\": \"設定の保存に失敗しました\",\n        \"unsavedChanges\": \"• 未保存の変更があります\",\n        \"discard\": \"破棄\"\n      },\n      \"appearance\": {\n        \"title\": \"外観\",\n        \"description\": \"アプリケーションの見た目と操作感をカスタマイズします。\",\n        \"theme\": {\n          \"label\": \"テーマ\",\n          \"placeholder\": \"テーマを選択\",\n          \"helper\": \"お好みの色スキームを選択してください。\"\n        },\n        \"language\": {\n          \"label\": \"言語\",\n          \"placeholder\": \"言語を選択\",\n          \"helper\": \"お好みの言語を選択してください。ブラウザ設定では、システム言語に従います。\"\n        }\n      },\n      \"taskExecution\": {\n        \"title\": \"デフォルトコーディングエージェント\",\n        \"description\": \"タスクのデフォルトコーディングエージェントを選択します。\",\n        \"executor\": {\n          \"label\": \"デフォルトエージェント設定\",\n          \"placeholder\": \"プロファイルを選択\",\n          \"helper\": \"タスク試行を作成する際に使用するデフォルトエージェント設定を選択してください。\"\n        },\n        \"variant\": \"デフォルト\",\n        \"defaultLabel\": \"デフォルト\"\n      },\n      \"editor\": {\n        \"title\": \"エディター\",\n        \"description\": \"コード編集体験を設定します。\",\n        \"type\": {\n          \"label\": \"エディタータイプ\",\n          \"placeholder\": \"エディターを選択\",\n          \"helper\": \"お好みのコードエディターインターフェースを選択してください。\"\n        },\n        \"customCommand\": {\n          \"label\": \"カスタムエディターコマンド\",\n          \"placeholder\": \"例: code, subl, vim\",\n          \"helper\": \"カスタムエディターを起動するコマンドを入力してください。ファイルを開くために使用されます。\"\n        },\n        \"remoteSsh\": {\n          \"host\": {\n            \"label\": \"リモートSSHホスト（オプション）\",\n            \"placeholder\": \"例: ホスト名またはIPアドレス\",\n            \"helper\": \"Vibe Kanbanがリモートサーバーで実行されている場合に設定してください。設定すると、「エディターで開く」をクリックしたときに、ローカルコマンドを実行する代わりにSSH経由でエディターを開くURLが生成されます。\"\n          },\n          \"user\": {\n            \"label\": \"リモートSSHユーザー（オプション）\",\n            \"placeholder\": \"例: ユーザー名\",\n            \"helper\": \"リモート接続のSSHユーザー名。設定されていない場合、VS CodeはSSH設定を使用するか、入力を求めます。\"\n          }\n        },\n        \"autoInstallExtension\": {\n          \"label\": \"Auto-install VS Code Extension\",\n          \"helper\": \"Automatically install the Vibe Kanban extension when opening a project in VS Code or Cursor. This runs in the background and has no effect if the extension is already installed.\"\n        },\n        \"availability\": {\n          \"checking\": \"利用可能性を確認中...\",\n          \"available\": \"利用可能\",\n          \"notFound\": \"PATHに見つかりません\"\n        }\n      },\n      \"github\": {\n        \"title\": \"GitHub連携\",\n        \"cliSetup\": {\n          \"title\": \"GitHub CLIセットアップ\",\n          \"description\": \"GitHub CLI認証は、プルリクエストの作成とGitHubリポジトリとのやり取りに必要です。\",\n          \"setupWillTitle\": \"このセットアップでは以下を実行します：\",\n          \"steps\": {\n            \"checkInstalled\": \"GitHub CLI (gh) がインストールされているか確認\",\n            \"installHomebrew\": \"必要に応じてHomebrewでインストール（macOS）\",\n            \"authenticate\": \"OAuthを使用してGitHubで認証\"\n          },\n          \"setupNote\": \"セットアップはチャットウィンドウで実行されます。ブラウザで認証を完了する必要があります。\",\n          \"runSetup\": \"セットアップを実行\",\n          \"running\": \"実行中...\",\n          \"errors\": {\n            \"brewMissing\": \"Homebrewがインストールされていません。自動セットアップを有効にするにはインストールしてください。\",\n            \"notSupported\": \"このプラットフォームでは自動セットアップがサポートされていません。GitHub CLIを手動でインストールしてください。\",\n            \"setupFailed\": \"GitHub CLIセットアップの実行に失敗しました。\"\n          },\n          \"help\": {\n            \"homebrew\": {\n              \"description\": \"自動インストールにはHomebrewが必要です。Homebrewを\",\n              \"brewSh\": \"brew.sh\",\n              \"manualInstall\": \"からインストールして、セットアップを再実行してください。または、次のコマンドでGitHub CLIを手動でインストールできます：\",\n              \"afterInstall\": \"インストール後、次のコマンドで認証します：\"\n            },\n            \"manual\": {\n              \"description\": \"GitHub CLIを\",\n              \"officialDocs\": \"公式ドキュメント\",\n              \"andAuthenticate\": \"からインストールし、GitHubアカウントで認証してください。\"\n            }\n          }\n        }\n      },\n      \"git\": {\n        \"title\": \"Git\",\n        \"description\": \"Gitブランチ名の設定\",\n        \"branchPrefix\": {\n          \"label\": \"ブランチプレフィックス\",\n          \"placeholder\": \"vk\",\n          \"helper\": \"自動生成されるブランチ名のプレフィックス。空欄にするとプレフィックスなしになります。\",\n          \"preview\": \"プレビュー：\",\n          \"previewWithPrefix\": \"{{prefix}}/1a2b-タスク名\",\n          \"previewNoPrefix\": \"1a2b-タスク名\",\n          \"errors\": {\n            \"slash\": \"プレフィックスに'/'を含めることはできません。\",\n            \"startsWithDot\": \"プレフィックスは'.'で始めることができません。\",\n            \"endsWithDot\": \"プレフィックスは'.'または'.lock'で終わることができません。\",\n            \"invalidSequence\": \"無効なシーケンス（..、@{）が含まれています。\",\n            \"invalidChars\": \"無効な文字が含まれています。\",\n            \"controlChars\": \"制御文字が含まれています。\"\n          }\n        },\n        \"workspaceDir\": {\n          \"label\": \"ワークスペースディレクトリ\",\n          \"placeholder\": \"~/\",\n          \"helper\": \"このパス内の .vibe-kanban-workspaces サブディレクトリにワークスペースが作成されます。システムのデフォルトを使用する場合は空白のままにしてください。変更を反映するにはアプリの再起動が必要です。\",\n          \"browse\": \"参照\",\n          \"dialogTitle\": \"ワークスペースディレクトリを選択\",\n          \"dialogDescription\": \"ディレクトリを選択してください。ワークスペースはその中の .vibe-kanban-workspaces サブディレクトリに作成されます。\"\n        }\n      },\n      \"pullRequests\": {\n        \"title\": \"プルリクエスト\",\n        \"description\": \"PR作成の動作を設定\",\n        \"autoDescription\": {\n          \"label\": \"デフォルトでPR説明を自動生成\",\n          \"helper\": \"有効にすると、AIエージェントがPR作成後に自動的にタイトルと説明を更新します。\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"カスタムプロンプトを使用\",\n          \"helper\": \"PR説明生成時のAIエージェント用カスタムプロンプト。{pr_number}と{pr_url}をプレースホルダーとして使用できます。\"\n        }\n      },\n      \"commits\": {\n        \"title\": \"コミット\",\n        \"description\": \"タスク試行のコミット動作を設定\",\n        \"reminder\": {\n          \"label\": \"コミットリマインダー\",\n          \"helper\": \"対応エージェントに停止前の変更コミットを促します。\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"カスタムプロンプトを使用\",\n          \"helper\": \"コミットリマインダー用のカスタムプロンプト。gitステータスは自動的に追加されます。\"\n        }\n      },\n      \"notifications\": {\n        \"title\": \"通知\",\n        \"description\": \"通知を受け取るタイミングと方法を制御します。\",\n        \"sound\": {\n          \"label\": \"音声通知\",\n          \"helper\": \"タスク試行の実行が完了したときに音を再生します。\",\n          \"fileLabel\": \"音声\",\n          \"filePlaceholder\": \"音声を選択\",\n          \"fileHelper\": \"タスク完了時に再生する音声を選択してください。音量ボタンをクリックしてプレビューできます。\"\n        },\n        \"push\": {\n          \"label\": \"プッシュ通知\",\n          \"helper\": \"タスク試行の実行が完了したときにシステム通知を表示します。\"\n        }\n      },\n      \"messageInput\": {\n        \"title\": \"メッセージ入力\",\n        \"description\": \"チャット入力でメッセージを送信する方法を設定します。\",\n        \"shortcut\": {\n          \"label\": \"メッセージ送信キー\",\n          \"helper\": \"メッセージを送信するキーボードショートカットを選択してください。\",\n          \"enterLabel\": \"Enter（Shift+Enterで改行）\"\n        }\n      },\n      \"privacy\": {\n        \"title\": \"プライバシー\",\n        \"description\": \"匿名の使用データを共有してVibe-Kanbanの改善にご協力ください。\",\n        \"telemetry\": {\n          \"label\": \"テレメトリを有効化\",\n          \"helper\": \"アプリケーションの改善に役立つ匿名の使用イベント追跡を有効にします。プロンプトやプロジェクト情報は収集されません。\"\n        }\n      },\n      \"taskTemplates\": {\n        \"title\": \"タグ\",\n        \"description\": \"@tag_nameを使用してタスクの説明に挿入できる再利用可能なテキストスニペットを作成します。\"\n      },\n      \"tags\": {\n        \"manager\": {\n          \"title\": \"タスクタグ\",\n          \"addTag\": \"タグを追加\",\n          \"noTags\": \"タグはまだありません。タスクの説明によく使うテキストスニペットを作成してください。@tag_nameで使用できます。\",\n          \"table\": {\n            \"tagName\": \"タグ名\",\n            \"content\": \"内容\",\n            \"actions\": \"操作\"\n          },\n          \"actions\": {\n            \"editTag\": \"タグを編集\",\n            \"deleteTag\": \"タグを削除\"\n          },\n          \"deleteConfirm\": \"タグ「{{tagName}}」を削除してもよろしいですか？\"\n        },\n        \"dialog\": {\n          \"createTitle\": \"タグを作成\",\n          \"editTitle\": \"タグを編集\",\n          \"tagName\": {\n            \"label\": \"タグ名\",\n            \"required\": \"*\",\n            \"hint\": \"タスクの説明で@を付けて使用します：@{{tagName}}\",\n            \"placeholder\": \"例：bug_fix、test_plan、api_docs\",\n            \"error\": \"タグ名にスペースを含めることはできません。アンダースコアを使用してください（例：my_tag）\"\n          },\n          \"content\": {\n            \"label\": \"内容\",\n            \"required\": \"*\",\n            \"hint\": \"タスクの説明で@{{tagName}}を使用すると挿入されるテキスト\",\n            \"placeholder\": \"このタグを使用するときに挿入されるテキストを入力してください\"\n          },\n          \"errors\": {\n            \"nameRequired\": \"タグ名は必須です\",\n            \"saveFailed\": \"タグの保存に失敗しました\"\n          },\n          \"buttons\": {\n            \"cancel\": \"キャンセル\",\n            \"create\": \"作成\",\n            \"update\": \"更新\"\n          }\n        }\n      },\n      \"safety\": {\n        \"title\": \"安全性と免責事項\",\n        \"description\": \"安全警告とオンボーディングの承認をリセットします。\",\n        \"disclaimer\": {\n          \"title\": \"免責事項の承認\",\n          \"description\": \"安全免責事項をリセットします。\",\n          \"button\": \"リセット\"\n        },\n        \"onboarding\": {\n          \"title\": \"オンボーディング\",\n          \"description\": \"オンボーディングフローをリセットします。\",\n          \"button\": \"リセット\"\n        }\n      },\n      \"beta\": {\n        \"title\": \"ベータ機能\",\n        \"description\": \"リリース前の実験的な機能を試す。\",\n        \"workspaces\": {\n          \"label\": \"ワークスペースベータを有効化\",\n          \"helper\": \"タスク試行を表示する際に新しいワークスペースインターフェースを使用します。タスクは最初にタスクビューで開き、試行は新しいワークスペースビューで開きます。\"\n        }\n      }\n    },\n    \"agents\": {\n      \"title\": \"コーディングエージェント設定\",\n      \"description\": \"コーディングエージェントの動作を異なる設定でカスタマイズします。\",\n      \"loading\": \"エージェント設定を読み込み中...\",\n      \"selectAgent\": \"エージェントを選択\",\n      \"save\": {\n        \"button\": \"エージェント設定を保存\",\n        \"success\": \"✓ 実行設定が正常に保存されました！\",\n        \"unsavedChanges\": \"• 未保存の変更があります\"\n      },\n      \"availability\": {\n        \"checking\": \"確認中...\",\n        \"checkingAvailability\": \"利用可能性を確認中...\",\n        \"available\": \"エージェント利用可能\",\n        \"notFoundSimple\": \"エージェントが見つかりません\",\n        \"loginDetected\": \"最近の使用を検出\",\n        \"loginDetectedTooltip\": \"このエージェントの最近の認証情報が見つかりました\",\n        \"installationFound\": \"以前の使用を検出\",\n        \"installationFoundTooltip\": \"エージェント設定が見つかりました。使用するにはログインが必要な場合があります。\"\n      },\n      \"editor\": {\n        \"formLabel\": \"JSONを編集\",\n        \"agentLabel\": \"エージェント\",\n        \"agentPlaceholder\": \"実行タイプを選択\",\n        \"configLabel\": \"設定\",\n        \"configPlaceholder\": \"設定を選択\",\n        \"createNew\": \"新規作成...\",\n        \"deleteTitle\": \"最後の設定は削除できません\",\n        \"deleteButton\": \"{{name}}を削除\",\n        \"deleteText\": \"削除\",\n        \"makeDefault\": \"デフォルトに設定\",\n        \"isDefault\": \"デフォルト\",\n        \"jsonLabel\": \"エージェント設定（JSON）\",\n        \"jsonPlaceholder\": \"プロファイルを読み込み中...\",\n        \"jsonLoading\": \"読み込み中...\",\n        \"pathLabel\": \"設定ファイルの場所：\"\n      },\n      \"errors\": {\n        \"deleteFailed\": \"設定の削除に失敗しました。もう一度お試しください。\",\n        \"saveFailed\": \"エージェント設定の保存に失敗しました。もう一度お試しください。\",\n        \"saveConfigFailed\": \"設定の保存に失敗しました。もう一度お試しください。\",\n        \"schemaNotFound\": \"実行タイプ {{executor}} のスキーマが見つかりません\"\n      },\n      \"tree\": {\n        \"search\": \"設定を検索...\",\n        \"expandAll\": \"すべて展開\",\n        \"collapseAll\": \"すべて折りたたむ\",\n        \"noResults\": \"一致する設定がありません\",\n        \"noConfigs\": \"利用可能な設定がありません\",\n        \"selectConfig\": \"サイドバーから設定を選択して、その設定を表示・編集できます。\"\n      },\n      \"deleteConfigDialog\": {\n        \"title\": \"設定を削除しますか？\",\n        \"description\": \"「{{configName}}」を{{executorType}}エグゼキューターから完全に削除します。この操作は元に戻せません。\"\n      }\n    },\n    \"mcp\": {\n      \"title\": \"MCPサーバー設定\",\n      \"description\": \"モデルコンテキストプロトコルサーバーを設定して、コーディングエージェントの機能をカスタムツールとリソースで拡張します。\",\n      \"loading\": \"MCP設定を読み込み中...\",\n      \"applying\": \"設定を適用中...\",\n      \"labels\": {\n        \"agent\": \"エージェント\",\n        \"agentPlaceholder\": \"実行器を選択\",\n        \"agentHelper\": \"MCPサーバーを設定するエージェントを選択してください。\",\n        \"serverConfig\": \"サーバー設定（JSON）\",\n        \"popularServers\": \"人気サーバー\",\n        \"serverHelper\": \"カードをクリックして、そのMCPサーバーを上記のJSONに挿入します。\",\n        \"saveLocation\": \"変更は次の場所に保存されます：\"\n      },\n      \"loadingStates\": {\n        \"jsonEditor\": \"読み込み中...\",\n        \"configuration\": \"現在のMCPサーバー設定を読み込み中...\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"設定の読み込みに失敗しました。\",\n        \"invalidJson\": \"無効なJSON形式です\",\n        \"validationError\": \"検証エラー\",\n        \"saveFailed\": \"MCPサーバーの保存に失敗しました\",\n        \"applyFailed\": \"MCPサーバー設定の適用に失敗しました\",\n        \"addServerFailed\": \"事前設定サーバーの追加に失敗しました\",\n        \"mcpError\": \"MCP設定エラー：{{error}}\",\n        \"notSupported\": \"MCPはサポートされていません\",\n        \"supportMessage\": \"MCPサーバーを使用するには、MCP（Claude、Amp、Gemini、Codex、またはOpencode）をサポートする別の実行器を上記で選択してください。\"\n      },\n      \"save\": {\n        \"button\": \"MCP設定を保存\",\n        \"success\": \"設定が保存されました！\",\n        \"successMessage\": \"✓ MCP設定が正常に保存されました！\",\n        \"loading\": \"現在のMCPサーバー設定を読み込み中...\",\n        \"unsavedChanges\": \"• 未保存の変更があります\"\n      }\n    },\n    \"projects\": {\n      \"title\": \"プロジェクト設定\",\n      \"description\": \"プロジェクト固有のスクリプトと設定を構成します。\",\n      \"loading\": \"プロジェクトを読み込み中...\",\n      \"loadError\": \"プロジェクトの読み込みに失敗しました。\",\n      \"selector\": {\n        \"label\": \"プロジェクトを選択\",\n        \"placeholder\": \"設定するプロジェクトを選択\",\n        \"helper\": \"プロジェクトを選択して、その設定を表示および編集します。\",\n        \"noProjects\": \"利用可能なプロジェクトがありません\"\n      },\n      \"general\": {\n        \"title\": \"一般設定\",\n        \"description\": \"プロジェクトの基本情報を設定します。\",\n        \"name\": {\n          \"label\": \"プロジェクト名\",\n          \"placeholder\": \"プロジェクト名を入力\",\n          \"helper\": \"このプロジェクトの表示名。\"\n        },\n        \"repoPath\": {\n          \"label\": \"Gitリポジトリパス\",\n          \"placeholder\": \"/既存の/リポジトリ/へのパス\",\n          \"helper\": \"ディスク上のgitリポジトリへの絶対パス。\"\n        }\n      },\n      \"save\": {\n        \"button\": \"プロジェクト設定を保存\",\n        \"success\": \"✓ プロジェクト設定が正常に保存されました！\",\n        \"error\": \"プロジェクト設定の保存に失敗しました\",\n        \"unsavedChanges\": \"• 未保存の変更があります\",\n        \"discard\": \"破棄\",\n        \"confirmSwitch\": \"未保存の変更があります。本当にプロジェクトを切り替えますか？変更は失われます。\",\n        \"saving\": \"Saving...\"\n      },\n      \"repositories\": {\n        \"title\": \"リポジトリ\",\n        \"description\": \"このプロジェクトのGitリポジトリを管理\",\n        \"noRepositories\": \"リポジトリが設定されていません\",\n        \"addRepository\": \"リポジトリを追加\"\n      }\n    },\n    \"repos\": {\n      \"title\": \"リポジトリ設定\",\n      \"description\": \"このリポジトリがワークスペースで使用されるときに実行されるスクリプトを設定します。\",\n      \"loading\": \"リポジトリを読み込み中...\",\n      \"loadError\": \"リポジトリの読み込みに失敗しました。\",\n      \"addRepo\": {\n        \"button\": \"リポジトリを追加\",\n        \"dialogTitle\": \"Gitリポジトリを選択\",\n        \"dialogDescription\": \"登録する既存のgitリポジトリを選択してください。\",\n        \"error\": \"リポジトリの登録に失敗しました\"\n      },\n      \"selector\": {\n        \"label\": \"リポジトリを選択\",\n        \"placeholder\": \"設定するリポジトリを選択\",\n        \"helper\": \"リポジトリを選択して、その設定を表示および編集します。\",\n        \"noRepos\": \"利用可能なリポジトリがありません\"\n      },\n      \"general\": {\n        \"title\": \"一般設定\",\n        \"description\": \"リポジトリの基本情報を設定します。\",\n        \"displayName\": {\n          \"label\": \"表示名\",\n          \"placeholder\": \"表示名を入力\",\n          \"helper\": \"このリポジトリのわかりやすい名前。\"\n        },\n        \"path\": {\n          \"label\": \"リポジトリパス\"\n        },\n        \"defaultWorkingDir\": {\n          \"label\": \"デフォルト作業ディレクトリ\",\n          \"placeholder\": \"例: packages/frontend\",\n          \"helper\": \"単一リポジトリワークスペースでコーディングエージェントが実行されるサブディレクトリ。ワークスペース作成時に設定されます。リポジトリのルートを使用する場合は空のままにしてください。\"\n        },\n        \"defaultTargetBranch\": {\n          \"label\": \"デフォルトターゲットブランチ\",\n          \"placeholder\": \"ブランチを選択\",\n          \"helper\": \"新しいワークスペースのデフォルトベースブランチ。ワークツリーはこのブランチから分岐し、PRはこのブランチをターゲットにします。\",\n          \"search\": \"ブランチを検索\",\n          \"noBranches\": \"ブランチが見つかりません\",\n          \"loading\": \"ブランチを読み込み中...\",\n          \"useCurrent\": \"現在のブランチを使用\"\n        }\n      },\n      \"scripts\": {\n        \"title\": \"スクリプトと設定\",\n        \"description\": \"このリポジトリのセットアップ、クリーンアップスクリプト、およびコピーするファイルを設定します。これらのスクリプトは、リポジトリがどのワークスペースでも使用されるたびに実行されます。\",\n        \"setup\": {\n          \"label\": \"セットアップスクリプト\",\n          \"helper\": \"このスクリプトはワークツリー内から、作成後かつコーディングエージェントの開始前に実行されます。依存関係のインストールや環境の準備などのセットアップタスクに使用してください。\",\n          \"parallelLabel\": \"セットアップスクリプトをコーディングエージェントと並行して実行\",\n          \"parallelHelper\": \"有効にすると、セットアップスクリプトはセットアップの完了を待たずに、コーディングエージェントと同時に実行されます。\"\n        },\n        \"cleanup\": {\n          \"label\": \"クリーンアップスクリプト\",\n          \"helper\": \"このスクリプトはワークツリー内から、コーディングエージェントの実行後に実行されます（変更が行われた場合のみ）。リンター、フォーマッター、テスト、またはその他の検証ステップの実行など、品質保証タスクに使用してください。\"\n        },\n        \"archive\": {\n          \"label\": \"アーカイブスクリプト\",\n          \"helper\": \"このスクリプトはワークスペースがアーカイブされるときにワークツリー内から実行されます。サービスの停止、リソースの解放、状態の保存などのクリーンアップタスクに使用してください。\"\n        },\n        \"copyFiles\": {\n          \"label\": \"ファイルをコピー\",\n          \"helper\": \"元のリポジトリディレクトリからワークツリーにコピーするファイルのカンマ区切りリスト。.envなどの環境ファイルに役立ちます。gitignoreされていることを確認してください！\",\n          \"placeholder\": \"ファイルパスまたはglobパターン（例：.env、config/*.json）\"\n        },\n        \"devServer\": {\n          \"label\": \"開発サーバースクリプト\",\n          \"helper\": \"このリポジトリの開発サーバーを起動します。スクリプトはリポジトリのワークツリーディレクトリから実行されます。\"\n        }\n      },\n      \"linkedProjects\": {\n        \"title\": \"リンクされたプロジェクト\",\n        \"description\": \"デフォルトのワークスペース設定でこのリポジトリを使用しているプロジェクト。\",\n        \"loading\": \"プロジェクトを確認中…\",\n        \"none\": \"リンクされたプロジェクトはありません\"\n      },\n      \"remove\": {\n        \"title\": \"リポジトリを削除\",\n        \"description\": \"このリポジトリをVibe Kanbanからリンク解除します。ディスク上のファイルは削除されません。\",\n        \"button\": \"削除\",\n        \"confirm\": \"リポジトリを削除\",\n        \"dialogTitle\": \"「{{name}}」を削除しますか？\",\n        \"dialogDescription\": \"リポジトリをVibe Kanbanからリンク解除します。ディスク上のファイルには影響しません。後で再度追加できます。\",\n        \"success\": \"リポジトリが正常に削除されました。\",\n        \"error\": \"リポジトリの削除に失敗しました。\"\n      },\n      \"save\": {\n        \"button\": \"リポジトリ設定を保存\",\n        \"success\": \"リポジトリ設定が正常に保存されました！\",\n        \"error\": \"リポジトリ設定の保存に失敗しました\",\n        \"unsavedChanges\": \"未保存の変更があります\",\n        \"discard\": \"破棄\",\n        \"confirmSwitch\": \"未保存の変更があります。本当にリポジトリを切り替えますか？変更は失われます。\"\n      }\n    },\n    \"relay\": {\n      \"title\": \"Remote Access\",\n      \"description\": \"Allow access to this instance from the web via relay tunnel.\",\n      \"enabled\": {\n        \"label\": \"Enable remote access\",\n        \"helper\": \"When enabled, this instance can be accessed remotely through a relay tunnel.\"\n      },\n      \"enrollmentCode\": {\n        \"label\": \"Pairing Code\",\n        \"helper\": \"Enter this code in the browser to pair with this instance.\",\n        \"loginRequired\": \"Log in to generate a pairing code.\",\n        \"fetchError\": \"Failed to fetch pairing code.\",\n        \"show\": \"Show pairing code\"\n      }\n    },\n    \"remoteProjects\": {\n      \"title\": \"Remote Projects\",\n      \"description\": \"Manage cloud-synced projects across organizations.\",\n      \"loading\": \"Loading remote projects...\",\n      \"loadError\": \"Failed to load organizations\",\n      \"noProjects\": \"No projects yet. Create one to get started.\",\n      \"selectOrg\": \"Select an organization\",\n      \"createSuccess\": \"Project created successfully\",\n      \"deleteSuccess\": \"Project deleted successfully\",\n      \"nameRequired\": \"Project name is required\",\n      \"saveSuccess\": \"Project updated successfully\",\n      \"saveError\": \"Failed to update project\",\n      \"columns\": {\n        \"organizations\": \"Organizations\",\n        \"projects\": \"Projects\"\n      },\n      \"actions\": {\n        \"addProject\": \"Add Project\"\n      },\n      \"form\": {\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\"\n        },\n        \"color\": {\n          \"label\": \"Project Color\"\n        },\n        \"statuses\": {\n          \"label\": \"Project Statuses\",\n          \"description\": \"Manage kanban columns for this project.\"\n        },\n        \"defaultRepos\": {\n          \"label\": \"Default Repositories\",\n          \"description\": \"Repositories that are automatically added when creating workspaces from this project's issues.\",\n          \"empty\": \"No default repositories configured\",\n          \"addButton\": \"Add\",\n          \"addNew\": \"Add new repository...\",\n          \"branchLabel\": \"Branch\",\n          \"noBranches\": \"No branches found for this repository\",\n          \"fetchError\": \"Could not load repositories\",\n          \"saveError\": \"Failed to save default repositories\"\n        }\n      },\n      \"loginRequired\": {\n        \"title\": \"Sign in required\",\n        \"description\": \"Sign in to manage your remote projects.\",\n        \"action\": \"Sign in\"\n      }\n    }\n  },\n  \"integrations\": {\n    \"github\": {\n      \"cliSetup\": {\n        \"title\": \"GitHub CLI セットアップ\",\n        \"description\": \"プルリクエストの作成とGitHubリポジトリとのやり取りには、GitHub CLI認証が必要です。\",\n        \"setupWillTitle\": \"このセットアップは次のことを行います：\",\n        \"steps\": {\n          \"checkInstalled\": \"GitHub CLI (gh) がインストールされているか確認\",\n          \"installHomebrew\": \"必要に応じてHomebrewでインストール（macOS）\",\n          \"authenticate\": \"OAuthを使用してGitHubで認証\"\n        },\n        \"setupNote\": \"セットアップはチャットウィンドウで実行されます。ブラウザで認証を完了する必要があります。\",\n        \"runSetup\": \"セットアップを実行\",\n        \"running\": \"実行中...\",\n        \"errors\": {\n          \"brewMissing\": \"Homebrewがインストールされていません。自動セットアップを有効にするにはインストールしてください。\",\n          \"notSupported\": \"このプラットフォームでは自動セットアップがサポートされていません。GitHub CLIを手動でインストールしてください。\",\n          \"setupFailed\": \"GitHub CLIセットアップの実行に失敗しました。\"\n        },\n        \"help\": {\n          \"homebrew\": {\n            \"description\": \"自動インストールにはHomebrewが必要です。次からHomebrewをインストールしてください\",\n            \"brewSh\": \"brew.sh\",\n            \"manualInstall\": \"その後、セットアップを再実行してください。または、次のコマンドでGitHub CLIを手動でインストールしてください：\",\n            \"afterInstall\": \"インストール後、次のコマンドで認証してください：\"\n          },\n          \"manual\": {\n            \"description\": \"次からGitHub CLIをインストールしてください\",\n            \"officialDocs\": \"公式ドキュメント\",\n            \"andAuthenticate\": \"その後、GitHubアカウントで認証してください。\"\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ja/tasks.json",
    "content": "{\n  \"dropzone\": {\n    \"dropImagesHere\": \"画像をここにドロップ\",\n    \"supportedFormats\": \"PNG、JPG、GIF、WebP、SVG に対応\"\n  },\n  \"gitPanel\": {\n    \"advanced\": {\n      \"placeholder\": \"例: acme Corp\"\n    }\n  },\n  \"actions\": {\n    \"addTask\": \"タスクを追加\"\n  },\n  \"editBranchName\": {\n    \"dialog\": {\n      \"title\": \"ブランチ名を編集\",\n      \"description\": \"ブランチの新しい名前を入力してください。オープンなPRが存在する場合は名前を変更できません。\",\n      \"branchNameLabel\": \"ブランチ名\",\n      \"placeholder\": \"例: feature/my-branch\",\n      \"renaming\": \"名前を変更中...\",\n      \"action\": \"ブランチ名を変更\"\n    }\n  },\n  \"startReviewDialog\": {\n    \"title\": \"レビューを開始\",\n    \"description\": \"コーディングエージェントに変更のレビューとフィードバックを依頼します。\",\n    \"additionalInstructions\": \"追加の指示（オプション）\",\n    \"reviewComments\": \"レビューコメント（{{count}}）\",\n    \"includeGitContext\": \"Gitコンテキストを含める\",\n    \"includeGitContextDescription\": \"このブランチで行われたすべての変更を確認する方法をエージェントに伝えます\",\n    \"newSession\": \"新しいセッション\",\n    \"sessionName\": \"レビュー\"\n  },\n  \"actionsMenu\": {\n    \"startReview\": \"レビューを開始\",\n    \"startingReview\": \"レビューを開始中...\"\n  },\n  \"attempt\": {\n    \"actions\": {\n      \"startDevServer\": \"開発サーバーを開始\"\n    }\n  },\n  \"resolveConflicts\": {\n    \"dialog\": {\n      \"title\": \"競合を解決\",\n      \"description\": \"競合が検出されました。エージェントにどのように解決させるか選択してください。\",\n      \"newSession\": \"新規セッション\",\n      \"resolve\": \"競合を解決\",\n      \"resolving\": \"開始中...\",\n      \"filesWithConflicts_one\": \"{{count}}件のファイルに競合があります\",\n      \"filesWithConflicts_other\": \"{{count}}件のファイルに競合があります\",\n      \"andMore\": \"...他{{count}}件\",\n      \"sessionName\": \"コンフリクトを解決\"\n    }\n  },\n  \"rebaseInProgress\": {\n    \"dialog\": {\n      \"title\": \"リベース進行中\",\n      \"description\": \"{{targetBranch}}へのリベースが競合なしで進行中です。続行方法を選択してください。\",\n      \"hint\": \"リベースを続行して完了するか、中止して以前の状態に戻すことができます。\",\n      \"continue\": \"リベースを続行\",\n      \"continuing\": \"続行中...\",\n      \"abort\": \"リベースを中止\",\n      \"aborting\": \"中止中...\",\n      \"continueError\": \"リベースの続行に失敗しました。未解決の競合がある可能性があります。\",\n      \"abortError\": \"リベースの中止に失敗しました。もう一度お試しください。\"\n    }\n  },\n  \"createPrDialog\": {\n    \"title\": \"プルリクエストを作成\",\n    \"description\": \"このタスク試行のプルリクエストを作成します。\",\n    \"titleLabel\": \"タイトル\",\n    \"titlePlaceholder\": \"PRタイトルを入力\",\n    \"descriptionLabel\": \"説明 (オプション)\",\n    \"descriptionPlaceholder\": \"PR説明を入力\",\n    \"baseBranchLabel\": \"ベースブランチ\",\n    \"loadingBranches\": \"ブランチを読み込み中...\",\n    \"selectBaseBranch\": \"ベースブランチを選択\",\n    \"draftLabel\": \"下書きとして作成\",\n    \"autoGenerateLabel\": \"AIエージェントにより良いPR説明を生成させる\",\n    \"creating\": \"作成中...\",\n    \"createButton\": \"PRを作成\",\n    \"errors\": {\n      \"failedToCreate\": \"PRの作成に失敗しました\",\n      \"gitCliNotLoggedIn\": \"Gitが認証されていません。\\\"gh auth login\\\" を実行するかGitの認証情報を設定してから再試行してください。\",\n      \"gitCliNotInstalled\": \"Git CLIがインストールされていません。PRを作成するにはGitをインストールしてください。\",\n      \"targetBranchNotFound\": \"ターゲットブランチ '{{branch}}' がリモートに存在しません。プルリクエストを作成する前にブランチが存在することを確認してください。\"\n    }\n  },\n  \"branches\": {\n    \"changeTarget\": {\n      \"dialog\": {\n        \"action\": \"ブランチを変更\",\n        \"description\": \"タスク試行の新しいターゲットブランチを選択してください。\",\n        \"inProgress\": \"変更中...\",\n        \"placeholder\": \"ターゲットブランチを選択\",\n        \"title\": \"ターゲットブランチを変更\"\n      }\n    }\n  },\n  \"repos\": {\n    \"selector\": {\n      \"placeholder\": \"リポジトリを選択\",\n      \"empty\": \"利用可能なリポジトリがありません\"\n    }\n  },\n  \"repoBranchSelector\": {\n    \"label\": \"ベースブランチ\"\n  },\n  \"diff\": {\n    \"filesChanged_one\": \"{{count}} file changed\",\n    \"filesChanged_other\": \"{{count}} files changed\",\n    \"largeDiff\": {\n      \"title\": \"大きなファイル\",\n      \"linesChanged\": \"{{count}} 行が変更されました\",\n      \"loadAnyway\": \"差分を読み込む\",\n      \"warning\": \"大きな差分はブラウザを遅くする可能性があります。\"\n    }\n  },\n  \"followUp\": {\n    \"queuedMessage\": \"メッセージがキューに追加されました - 現在の実行が完了すると実行されます\"\n  },\n  \"git\": {\n    \"branch\": {\n      \"current\": \"現在\"\n    },\n    \"forcePushDialog\": {\n      \"title\": \"強制プッシュが必要です\",\n      \"description\": \"リモートブランチ{{branchLabel}}がローカルブランチと乖離しています。通常のプッシュは拒否されました。\",\n      \"warning\": \"強制プッシュはリモートの変更をローカルの変更で上書きします。この操作は元に戻せません。\",\n      \"note\": \"リモートのブランチ履歴を置き換えてもよいと確信できる場合のみ続行してください。\",\n      \"error\": \"強制プッシュに失敗しました\"\n    },\n    \"errors\": {\n      \"changeTargetBranch\": \"ターゲットブランチの変更に失敗しました\",\n      \"mergeChanges\": \"変更のマージに失敗しました\",\n      \"pushChanges\": \"変更のプッシュに失敗しました\",\n      \"rebaseBranch\": \"ブランチのリベースに失敗しました\",\n      \"branchStatusUnavailable\": \"ブランチのステータスを取得できません。ターゲットブランチの変更は引き続き可能です。\"\n    },\n    \"labels\": {\n      \"taskBranch\": \"タスクブランチ\"\n    },\n    \"pr\": {\n      \"number\": \"PR #{{number}}\",\n      \"open\": \"Open PR #{{number}}\",\n      \"merged\": \"マージ済みPR #{{prNumber}}\"\n    },\n    \"actions\": {\n      \"title\": \"Gitアクション\",\n      \"changeTarget\": \"ターゲットを変更\",\n      \"prMerged\": \"PR #{{number}} は既にマージされています\",\n      \"loginRequired\": {\n        \"title\": \"Git アクションを管理するにはサインインしてください\",\n        \"description\": \"このタスクでブランチのプッシュ、変更のマージ、プルリクエストの作成を行うには Vibe Kanban にサインインしてください。\",\n        \"action\": \"サインイン\"\n      }\n    },\n    \"mergeDialog\": {\n      \"title\": \"変更をマージ\",\n      \"description\": \"変更をターゲットブランチにマージします。続行してもよろしいですか？\"\n    },\n    \"createRepo\": {\n      \"dialog\": {\n        \"title\": \"新しいリポジトリを作成\",\n        \"description\": \"新しいGitリポジトリを初期化\"\n      },\n      \"form\": {\n        \"nameLabel\": \"名前\",\n        \"namePlaceholder\": \"my-project\",\n        \"locationLabel\": \"場所\",\n        \"locationPlaceholder\": \"現在のディレクトリ\"\n      },\n      \"browseDialog\": {\n        \"title\": \"親ディレクトリを選択\",\n        \"description\": \"新しいリポジトリを作成する場所を選択\"\n      },\n      \"errors\": {\n        \"nameRequired\": \"リポジトリ名は必須です\",\n        \"createFailed\": \"リポジトリの作成に失敗しました\"\n      },\n      \"buttons\": {\n        \"createRepository\": \"リポジトリを作成\"\n      },\n      \"states\": {\n        \"creating\": \"作成中...\"\n      }\n    },\n    \"states\": {\n      \"createPr\": \"PRを作成\",\n      \"creating\": \"作成中...\",\n      \"merge\": \"マージ\",\n      \"merged\": \"マージ完了！\",\n      \"merging\": \"マージ中...\",\n      \"push\": \"プッシュ\",\n      \"pushed\": \"プッシュ完了！\",\n      \"pushing\": \"プッシュ中...\",\n      \"pushFailed\": \"失敗\",\n      \"forcePush\": \"強制プッシュ\",\n      \"forcePushing\": \"強制プッシュ中...\",\n      \"rebase\": \"リベース\",\n      \"rebasing\": \"リベース中...\"\n    },\n    \"status\": {\n      \"ahead\": \"先行\",\n      \"behind\": \"遅れ\",\n      \"commits_one\": \"コミット\",\n      \"commits_other\": \"コミット\",\n      \"conflicts\": \"競合\",\n      \"upToDate\": \"最新\"\n    }\n  },\n  \"loading\": \"タスクを読み込み中...\",\n  \"preview\": {\n    \"logs\": {\n      \"label\": \"ログ\",\n      \"viewFull\": \"完全なログを表示\"\n    },\n    \"browser\": {\n      \"title\": \"開発サーバープレビュー\",\n      \"devServerFallback\": \"開発サーバー\"\n    },\n    \"noServer\": {\n      \"setupTitle\": \"プレビュー機能を使用するには、開発サーバースクリプトを設定する必要があります\",\n      \"setupPrompt\": \"ライブプレビューとクリックして編集機能を使用するには、このプロジェクトに開発サーバースクリプトを追加してください。\",\n      \"title\": \"開発サーバーが実行されていません\",\n      \"editDevScript\": \"開発サーバースクリプトを編集\",\n      \"learnMore\": \"アプリケーションのテストについてもっと詳しく\"\n    },\n    \"toolbar\": {\n      \"copyUrl\": \"URLをコピー\",\n      \"openInTab\": \"新しいタブで開く\",\n      \"refresh\": \"プレビューを更新\",\n      \"stopDevServer\": \"開発サーバーを停止\",\n      \"resetUrl\": \"検出されたURLにリセット\",\n      \"clearUrlOverride\": \"URL オーバーライドをクリア\",\n      \"desktopView\": \"デスクトップ表示\",\n      \"mobileView\": \"モバイル表示 (390x844)\",\n      \"responsiveView\": \"レスポンシブ表示（リサイズ可能）\",\n      \"startDevServer\": \"開発サーバーを開始\",\n      \"submitUrl\": \"URLを送信\",\n      \"toggleDevTools\": \"DevTools を切り替え\"\n    },\n    \"loading\": {\n      \"startingServer\": \"開発サーバーを起動中...\",\n      \"waitingForServer\": \"サーバーを待機中...\",\n      \"loadingPreview\": \"プレビューを読み込み中...\",\n      \"manualUrlHint\": \"URLがまだ検出されていません。上部のツールバーでURLを手動で入力できます。\"\n    }\n  },\n  \"processes\": {\n    \"noLogsAvailable\": \"ログがありません\",\n    \"agent\": \"Agent:\",\n    \"backToList\": \"Back to list\",\n    \"completed\": \"Completed: {{date}}\",\n    \"deleted\": \"Deleted\",\n    \"deletedTooltip\": \"Deleted by restore: timeline was restored to a checkpoint and later executions were removed\",\n    \"detailsTitle\": \"Process Details\",\n    \"errorLoadingDetails\": \"Failed to load process details. Please try again.\",\n    \"errorLoadingUpdates\": \"Failed to load live updates for processes.\",\n    \"exit\": \"Exit: {{code}}\",\n    \"loading\": \"Loading execution processes...\",\n    \"loadingDetails\": \"Loading process details...\",\n    \"noProcesses\": \"No execution processes found for this attempt.\",\n    \"processId\": \"Process ID: {{id}}\",\n    \"reconnecting\": \"Reconnecting...\",\n    \"selectAttempt\": \"Select an attempt to view execution processes.\",\n    \"started\": \"Started: {{date}}\",\n    \"copyLogs\": \"ログをコピー\",\n    \"logsCopied\": \"コピーしました！\"\n  },\n  \"rebase\": {\n    \"common\": {\n      \"action\": \"リベース\",\n      \"inProgress\": \"リベース中...\"\n    },\n    \"dialog\": {\n      \"advanced\": \"詳細設定\",\n      \"description\": \"このタスク試行をリベースする新しいベースブランチを選択してください。\",\n      \"targetLabel\": \"ターゲットブランチ\",\n      \"targetPlaceholder\": \"ターゲットブランチを選択\",\n      \"title\": \"タスク試行をリベース\",\n      \"upstreamLabel\": \"アップストリームブランチ\",\n      \"upstreamPlaceholder\": \"アップストリームブランチを選択\"\n    }\n  },\n  \"todoPopup\": {\n    \"title\": \"タスク\",\n    \"progress\": \"{{completed}}/{{total}} 完了\",\n    \"noTasks\": \"タスクなし\"\n  },\n  \"viewProcessesDialog\": {\n    \"title\": \"Execution processes\"\n  },\n  \"prComments\": {\n    \"dialog\": {\n      \"title\": \"PRコメントを選択\",\n      \"noComments\": \"このPRにコメントはありません\",\n      \"selectAll\": \"すべて選択\",\n      \"deselectAll\": \"すべて選択解除\",\n      \"add\": \"追加\",\n      \"selectedCount\": \"{{total}}件中{{selected}}件選択\"\n    },\n    \"card\": {\n      \"review\": \"レビュー\",\n      \"tooltip\": \"クリックで表示、ダブルクリックで編集\"\n    }\n  },\n  \"taskFormDialog\": {\n    \"createTitle\": \"新規タスクを作成\",\n    \"editTitle\": \"タスクを編集\",\n    \"titlePlaceholder\": \"タスクのタイトル\",\n    \"descriptionPlaceholder\": \"詳細を追加（オプション）。@でファイルを検索できます。\",\n    \"statusLabel\": \"ステータス\",\n    \"statusOptions\": {\n      \"todo\": \"未着手\",\n      \"inprogress\": \"進行中\",\n      \"inreview\": \"レビュー中\",\n      \"done\": \"完了\",\n      \"cancelled\": \"キャンセル\"\n    },\n    \"startLabel\": \"開始\",\n    \"attachFile\": \"ファイルを添付\",\n    \"dropImagesHere\": \"画像をここにドロップ\",\n    \"updating\": \"更新中...\",\n    \"updateTask\": \"タスクを更新\",\n    \"starting\": \"開始中...\",\n    \"creating\": \"作成中...\",\n    \"create\": \"作成\",\n    \"discardDialog\": {\n      \"title\": \"未保存の変更を破棄しますか？\",\n      \"description\": \"未保存の変更があります。本当に破棄してもよろしいですか？\",\n      \"continueEditing\": \"編集を続ける\",\n      \"discardChanges\": \"変更を破棄\"\n    }\n  },\n  \"restoreLogsDialog\": {\n    \"title\": \"リトライを確認\",\n    \"titleReset\": \"リセットを確認\",\n    \"historyChange\": {\n      \"title\": \"履歴の変更\",\n      \"willDelete\": \"このプロセスを削除します\",\n      \"willDeleteProcesses_one\": \"{{count}}件のプロセスを削除します\",\n      \"willDeleteProcesses_other\": \"{{count}}件のプロセスを削除します\",\n      \"andLaterProcesses_one\": \"および後続の{{count}}件のプロセス\",\n      \"andLaterProcesses_other\": \"および後続の{{count}}件のプロセス\",\n      \"fromHistory\": \"を履歴から削除します。\",\n      \"codingAgentRuns_one\": \"{{count}}件のコーディングエージェント実行\",\n      \"codingAgentRuns_other\": \"{{count}}件のコーディングエージェント実行\",\n      \"scriptProcesses_one\": \"{{count}}件のスクリプトプロセス\",\n      \"scriptProcesses_other\": \"{{count}}件のスクリプトプロセス\",\n      \"setupCleanupBreakdown\": \"(セットアップ{{setup}}件、クリーンアップ{{cleanup}}件)\",\n      \"permanentWarning\": \"この操作は履歴を永久に変更し、元に戻すことはできません。\"\n    },\n    \"uncommittedChanges\": {\n      \"title\": \"未コミットの変更が検出されました\",\n      \"description_one\": \"{{count}}件の未コミットの変更があります\",\n      \"description_other\": \"{{count}}件の未コミットの変更があります\",\n      \"andUntracked_one\": \"および{{count}}件の未追跡ファイル\",\n      \"andUntracked_other\": \"および{{count}}件の未追跡ファイル\",\n      \"acknowledgeLabel\": \"これらの変更が影響を受ける可能性があることを理解しています\"\n    },\n    \"resetWorktree\": {\n      \"title\": \"ワークツリーをリセット\",\n      \"enabled\": \"有効\",\n      \"disabled\": \"無効\",\n      \"disabledUncommitted\": \"無効（未コミットの変更が検出されました）\",\n      \"restoreDescription\": \"ワークツリーはこのコミットに復元されます。\",\n      \"discardChanges_one\": \"{{count}}件の未コミットの変更を破棄します。\",\n      \"discardChanges_other\": \"{{count}}件の未コミットの変更を破棄します。\",\n      \"untrackedPresent_one\": \"{{count}}件の未追跡ファイルがあります（リセットの影響を受けません）。\",\n      \"untrackedPresent_other\": \"{{count}}件の未追跡ファイルがあります（リセットの影響を受けません）。\",\n      \"forceReset\": \"強制リセット（未コミットの変更を破棄）\",\n      \"uncommittedWillDiscard\": \"未コミットの変更は破棄されます。\",\n      \"uncommittedPresentHint\": \"未コミットの変更があります。強制リセットをオンにするか、コミット/スタッシュしてから続行してください。\"\n    },\n    \"buttons\": {\n      \"retry\": \"リトライ\",\n      \"reset\": \"リセット\"\n    }\n  },\n  \"conversation\": {\n    \"you\": \"あなた\",\n    \"thinking\": \"考え中\",\n    \"todo\": \"タスク\",\n    \"todos\": \"タスク\",\n    \"completed\": \"完了\",\n    \"incomplete\": \"未完了\",\n    \"pending\": \"保留中\",\n    \"inProgress\": \"進行中\",\n    \"skipped\": \"スキップ\",\n    \"error\": \"エラー\",\n    \"retry\": \"再試行\",\n    \"showMore\": \"もっと見る\",\n    \"showLess\": \"少なく表示\",\n    \"actions\": {\n      \"cancel\": \"キャンセル\",\n      \"submitFeedback\": \"フィードバックを送信\",\n      \"stop\": \"停止\",\n      \"stopping\": \"停止中\",\n      \"loading\": \"読み込み中\",\n      \"send\": \"送信\",\n      \"sending\": \"送信中\",\n      \"queue\": \"キューに追加\",\n      \"cancelQueue\": \"キューをキャンセル\",\n      \"requestChanges\": \"変更を依頼\",\n      \"approve\": \"承認\",\n      \"clearReviewComments\": \"レビューコメントをクリア\",\n      \"edit\": \"メッセージを編集\",\n      \"scrollToPreviousMessage\": \"Go to previous message\",\n      \"reset\": \"リセット\",\n      \"resetTooltip\": \"この時点にリセット\"\n    },\n    \"approval\": {\n      \"conflictWarning\": \"競合するファイルは手動で解決する必要があります\",\n      \"conflicts_one\": \"{{count}}件の競合ファイルを手動で解決する必要があります\",\n      \"conflicts_other\": \"{{count}}件の競合ファイルを手動で解決する必要があります\"\n    },\n    \"executors\": \"エクゼキューター\",\n    \"saveAsDefault\": \"デフォルトとして保存\",\n    \"script\": {\n      \"clickToViewLogs\": \"クリックしてログを表示\",\n      \"completedSuccessfully\": \"正常に完了しました\",\n      \"exitCode\": \"終了コード: {{code}}\",\n      \"running\": \"実行中...\"\n    },\n    \"scriptPlaceholder\": {\n      \"setupTitle\": \"セットアップスクリプト\",\n      \"setupDescription\": \"セットアップスクリプトが設定されていません。セットアップスクリプトはコーディングエージェントの開始前に実行されます。\",\n      \"cleanupTitle\": \"クリーンアップスクリプト\",\n      \"cleanupDescription\": \"クリーンアップスクリプトが設定されていません。クリーンアップスクリプトはコーディングエージェントの終了後に実行されます。\",\n      \"configure\": \"設定\"\n    },\n    \"unableToRenderDiff\": \"差分を表示できません。\",\n    \"updatedTodos\": \"更新されたTodo\",\n    \"viewInChangesPanel\": \"変更パネルで表示\",\n    \"sessions\": {\n      \"newSession\": \"新規セッション\",\n      \"latest\": \"最新\",\n      \"previous\": \"前へ\",\n      \"label\": \"セッション\",\n      \"noPreviousSessions\": \"以前のセッションはありません\",\n      \"rename\": \"名前を変更\",\n      \"renameTitle\": \"セッション名の変更\",\n      \"renameDescription\": \"このセッションの新しい名前を入力してください。\",\n      \"renamePlaceholder\": \"セッション名を入力\",\n      \"renaming\": \"名前を変更中...\"\n    },\n    \"reviewComments\": {\n      \"count_one\": \"{{count}}件のレビューコメントが含まれます\",\n      \"count_other\": \"{{count}}件のレビューコメントが含まれます\"\n    },\n    \"workspace\": {\n      \"create\": \"作成\",\n      \"creating\": \"作成中...\"\n    },\n    \"fileEntry\": {\n      \"created\": \"作成済み\",\n      \"modified\": \"変更済み\",\n      \"deleted\": \"削除済み\",\n      \"renamed\": \"名前変更済み\"\n    }\n  },\n  \"scriptFixer\": {\n    \"setupScriptTitle\": \"セットアップスクリプトを修正\",\n    \"cleanupScriptTitle\": \"クリーンアップスクリプトを修正\",\n    \"archiveScriptTitle\": \"アーカイブスクリプトを修正\",\n    \"devServerTitle\": \"開発サーバースクリプトを修正\",\n    \"scriptLabel\": \"スクリプト（編集）\",\n    \"logsLabel\": \"最後の実行ログ\",\n    \"saveButton\": \"保存\",\n    \"saveAndTestButton\": \"保存してテスト\",\n    \"noLogs\": \"利用可能な実行ログがありません\",\n    \"selectRepo\": \"リポジトリ\",\n    \"fixScript\": \"スクリプトを修正\",\n    \"statusRunning\": \"実行中...\",\n    \"statusSuccess\": \"正常に完了しました\",\n    \"statusFailed\": \"終了コード {{exitCode}} で失敗しました\",\n    \"statusKilled\": \"プロセスが強制終了されました\"\n  },\n  \"createWorkspaceFromPr\": {\n    \"title\": \"PRからワークスペースを作成\",\n    \"description\": \"オープンなプルリクエストを選択してワークスペースを作成します。PRのタイトルを使用してタスクが自動的に作成されます。\",\n    \"repositoryLabel\": \"リポジトリ\",\n    \"remoteLabel\": \"リモート\",\n    \"pullRequestLabel\": \"プルリクエスト\",\n    \"loadingRepositories\": \"リポジトリを読み込み中...\",\n    \"loadingRemotes\": \"リモートを読み込み中...\",\n    \"noRepositoriesFound\": \"リポジトリが見つかりません\",\n    \"loadingPullRequests\": \"プルリクエストを読み込み中...\",\n    \"selectRepositoryFirst\": \"最初にリポジトリを選択してください\",\n    \"noPullRequestsFound\": \"オープンなプルリクエストが見つかりません\",\n    \"runSetupScript\": \"セットアップスクリプトを実行\",\n    \"creating\": \"作成中...\",\n    \"createWorkspace\": \"ワークスペースを作成\",\n    \"selectRepository\": \"リポジトリを選択\",\n    \"selectRemote\": \"リモートを選択\",\n    \"selectPullRequest\": \"プルリクエストを選択\",\n    \"searchPrsPlaceholder\": \"番号またはタイトルでPRを検索...\",\n    \"noMatchingPrs\": \"一致するプルリクエストがありません\",\n    \"default\": \"デフォルト\",\n    \"openPrInBrowser\": \"ブラウザでPRを開く\",\n    \"errors\": {\n      \"cliNotInstalled\": \"{{provider}} CLIがインストールされていません\",\n      \"unsupportedProvider\": \"Gitプロバイダーがサポートされていません\",\n      \"failedToLoadPrs\": \"プルリクエストの読み込みに失敗しました\",\n      \"prNotFound\": \"プルリクエストが見つかりません\",\n      \"failedToCreateWorkspace\": \"ワークスペースの作成に失敗しました\"\n    }\n  },\n  \"shareDialog\": {\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Sign in to access this feature.\",\n      \"action\": \"Sign in\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ko/common.json",
    "content": "{\n  \"editorNames\": {\n    \"custom\": \"사용자 지정\"\n  },\n  \"folderPicker\": {\n    \"legend\": \"폴더 이름을 클릭하여 탐색 • 작업 버튼을 사용하여 선택\",\n    \"manualPathLabel\": \"경로 직접 입력:\",\n    \"go\": \"이동\",\n    \"searchLabel\": \"현재 디렉토리 검색:\",\n    \"selectCurrent\": \"현재 선택\",\n    \"gitRepo\": \"git 저장소\",\n    \"selectPath\": \"경로 선택\"\n  },\n  \"branchSelector\": {\n    \"badges\": {\n      \"current\": \"현재\",\n      \"remote\": \"원격\"\n    },\n    \"currentDisabled\": \"현재 브랜치는 선택할 수 없습니다\",\n    \"empty\": \"브랜치를 찾을 수 없습니다\",\n    \"placeholder\": \"브랜치 선택\",\n    \"searchPlaceholder\": \"브랜치 검색...\"\n  },\n  \"buttons\": {\n    \"cancel\": \"취소\",\n    \"close\": \"닫기\",\n    \"connect\": \"연결\",\n    \"continue\": \"계속\",\n    \"create\": \"생성\",\n    \"delete\": \"삭제\",\n    \"disconnect\": \"연결 해제\",\n    \"discard\": \"취소\",\n    \"edit\": \"편집\",\n    \"manage\": \"관리\",\n    \"replay\": \"다시 재생\",\n    \"reset\": \"초기화\",\n    \"save\": \"저장\",\n    \"send\": \"보내기\",\n    \"addItem\": \"항목 추가\",\n    \"reply\": \"답장\",\n    \"retry\": \"Retry\",\n    \"add\": \"추가\"\n  },\n  \"form\": {\n    \"notSpecified\": \"지정되지 않음\",\n    \"selectOption\": \"옵션 선택...\"\n  },\n  \"conversation\": {\n    \"deniedByUser\": \"{{toolName}} 사용자에 의해 거부됨\",\n    \"output\": \"출력\",\n    \"plan\": \"계획\",\n    \"tool\": \"도구\",\n    \"thinking\": \"생각 중\",\n    \"toolSummary\": {\n      \"read\": \"{{path}} 읽기\",\n      \"searched\": \"\\\"{{query}}\\\" 검색\",\n      \"fetched\": \"{{url}} 가져오기\",\n      \"ranCommand\": \"명령 실행\",\n      \"createdTask\": \"작업 생성: {{description}}\",\n      \"todoOperation\": \"{{operation}} 할 일\"\n    },\n    \"subagent\": {\n      \"defaultType\": \"서브에이전트\"\n    },\n    \"loadingEarlierMessages\": \"이전 메시지 불러오는 중\"\n  },\n  \"language\": {\n    \"browserDefault\": \"브라우저 기본값\"\n  },\n  \"states\": {\n    \"error\": \"오류\",\n    \"loading\": \"로딩 중...\",\n    \"loadingHistory\": \"기록 로딩 중\",\n    \"reconnecting\": \"재연결 중\",\n    \"saving\": \"저장 중...\",\n    \"success\": \"성공\"\n  },\n  \"orgSwitcher\": {\n    \"organizations\": \"조직\",\n    \"createOrganization\": \"조직 만들기\",\n    \"orgSettings\": \"조직 설정\"\n  },\n  \"ok\": \"확인\",\n  \"error\": \"오류\",\n  \"signIn\": \"로그인\",\n  \"signOut\": \"로그아웃\",\n  \"oauth\": {\n    \"title\": \"Vibe Kanban 로그인\",\n    \"description\": \"로그인하여 조직에 참여하고 팀과 작업을 공유하세요\",\n    \"continueWithGitHub\": \"GitHub로 계속\",\n    \"continueWithGoogle\": \"Google로 계속\",\n    \"waitingTitle\": \"인증 완료\",\n    \"waitingDescription\": \"인증을 위한 팝업 창이 열렸습니다\",\n    \"waitingForAuth\": \"인증 대기 중...\",\n    \"popupInstructions\": \"팝업 창이 열리지 않는 경우 팝업 차단 설정을 확인해주세요.\",\n    \"back\": \"뒤로\",\n    \"successTitle\": \"인증 성공!\",\n    \"welcomeBack\": \"환영합니다, {{name}}\",\n    \"errorTitle\": \"인증 실패\",\n    \"errorDescription\": \"계정 인증 중 문제가 발생했습니다\",\n    \"tryAgain\": \"다시 시도\"\n  },\n  \"toolbar\": {\n    \"sortBy\": \"정렬 기준\",\n    \"groupBy\": \"그룹화 기준\"\n  },\n  \"sorting\": {\n    \"ascending\": \"오름차순\",\n    \"descending\": \"내림차순\"\n  },\n  \"grouping\": {\n    \"date\": \"날짜\",\n    \"assignee\": \"담당자\",\n    \"label\": \"라벨\"\n  },\n  \"workspaces\": {\n    \"title\": \"워크스페이스\",\n    \"searchPlaceholder\": \"검색...\",\n    \"active\": \"활성\",\n    \"archived\": \"보관됨\",\n    \"loading\": \"로딩 중...\",\n    \"notFound\": \"워크스페이스를 찾을 수 없음\",\n    \"selectToStart\": \"워크스페이스를 선택하여 시작\",\n    \"draft\": \"초안\",\n    \"viewArchive\": \"보관함 보기\",\n    \"backToActive\": \"활성으로 돌아가기\",\n    \"noArchived\": \"보관된 워크스페이스 없음\",\n    \"noWorkspaces\": \"워크스페이스 없음\",\n    \"newWorkspace\": \"새 워크스페이스\",\n    \"needsAttention\": \"주의 필요\",\n    \"idle\": \"대기 중\",\n    \"running\": \"실행 중\",\n    \"pin\": \"고정\",\n    \"unpin\": \"고정 해제\",\n    \"archive\": \"보관\",\n    \"more\": \"더 많은 작업\",\n    \"rename\": {\n      \"title\": \"워크스페이스 이름 변경\",\n      \"description\": \"이 워크스페이스의 새 이름을 입력하세요.\",\n      \"nameLabel\": \"이름\",\n      \"placeholder\": \"워크스페이스 이름 입력\",\n      \"action\": \"이름 변경\",\n      \"renaming\": \"이름 변경 중...\"\n    },\n    \"unlinkFromIssue\": \"이슈에서 연결 해제\",\n    \"deleteWorkspace\": \"워크스페이스 삭제\",\n    \"unlink\": \"연결 해제\",\n    \"delete\": \"삭제\",\n    \"unlinkConfirmMessage\": \"이 워크스페이스를 이슈에서 연결 해제하시겠습니까? 워크스페이스는 그대로 유지되지만 이 이슈와의 연결이 끊어집니다.\",\n    \"deleteConfirmMessage\": \"이 워크스페이스를 삭제하시겠습니까? 이슈에서 연결이 해제되고 로컬 워크스페이스가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.\",\n    \"unlinkError\": \"워크스페이스 연결 해제 실패\",\n    \"deleteError\": \"워크스페이스 삭제 실패\",\n    \"filesChanged\": \"{{count}}개 파일\",\n    \"deleteDialog\": {\n      \"title\": \"워크스페이스 삭제\",\n      \"description\": \"이 워크스페이스를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.\",\n      \"deleteBranchLabel\": \"브랜치 삭제\",\n      \"cannotDeleteOpenPr\": \"PR이 열려 있는 동안에는 브랜치를 삭제할 수 없습니다\",\n      \"unlinkFromIssueLabel\": \"이슈에서도 연결 해제\"\n    },\n    \"linkError\": \"Failed to link workspace\"\n  },\n  \"fileTree\": {\n    \"searchPlaceholder\": \"파일 검색...\",\n    \"noResults\": \"일치하는 파일 없음\",\n    \"title\": \"파일\",\n    \"showGitHubComments\": \"GitHub 댓글 표시\",\n    \"hideGitHubComments\": \"GitHub 댓글 숨기기\",\n    \"prevGitHubComment\": \"이전 댓글 파일\",\n    \"nextGitHubComment\": \"다음 댓글 파일\"\n  },\n  \"sections\": {\n    \"changes\": \"변경사항\",\n    \"repositories\": \"저장소\",\n    \"addRepositories\": \"저장소 추가\",\n    \"project\": \"프로젝트\",\n    \"processes\": \"프로세스\",\n    \"devServer\": \"개발 서버\",\n    \"advanced\": \"고급\",\n    \"workingBranch\": \"작업 브랜치\",\n    \"recent\": \"최근\",\n    \"other\": \"기타\",\n    \"devServerPreview\": \"개발 서버 미리보기\",\n    \"terminal\": \"터미널\",\n    \"notes\": \"노트\"\n  },\n  \"notes\": {\n    \"placeholder\": \"이 워크스페이스에 대한 노트 추가...\",\n    \"selectWorkspace\": \"노트를 보려면 워크스페이스를 선택하세요\"\n  },\n  \"actions\": {\n    \"copyPath\": \"워크트리 경로 복사\",\n    \"cancel\": \"취소\",\n    \"saveChanges\": \"변경사항 저장\",\n    \"copied\": \"복사됨\",\n    \"collapse\": \"접기\"\n  },\n  \"comments\": {\n    \"addReviewComment\": \"리뷰 코멘트 추가\",\n    \"addPlaceholder\": \"코멘트 추가...\",\n    \"editPlaceholder\": \"코멘트 편집...\",\n    \"copyToReview\": \"리뷰로 복사\"\n  },\n  \"confirm\": {\n    \"defaultConfirm\": \"확인\",\n    \"defaultCancel\": \"취소\"\n  },\n  \"dialogs\": {\n    \"selectGitRepository\": \"Git 저장소 선택\",\n    \"chooseExistingRepo\": \"파일 시스템에서 기존 저장소를 선택하세요\"\n  },\n  \"empty\": {\n    \"noChanges\": \"표시할 변경사항이 없습니다\"\n  },\n  \"commandBar\": {\n    \"noResults\": \"결과를 찾을 수 없습니다.\",\n    \"back\": \"뒤로\",\n    \"defaultPlaceholder\": \"명령어를 입력하거나 검색...\",\n    \"selectBranch\": \"Select Branch\",\n    \"selectBranchFor\": \"Select Branch for {{repoName}}\"\n  },\n  \"workspacesGuide\": {\n    \"welcome\": {\n      \"title\": \"환영합니다\",\n      \"content\": \"Vibe Kanban의 새롭게 디자인된 UI인 Workspaces에 오신 것을 환영합니다. 초기 피드백을 위해 일부 사용자에게 먼저 공개하고 있습니다. 네비게이션 바의 피드백 아이콘을 통해 언제든지 의견을 공유해 주세요.\"\n    },\n    \"commandBar\": {\n      \"title\": \"명령 바\",\n      \"content\": \"명령 바는 탐색의 중심 허브입니다. CMD+K로 열어 워크스페이스에서 사용 가능한 모든 작업을 검색하고 접근할 수 있습니다.\"\n    },\n    \"contextBar\": {\n      \"title\": \"컨텍스트 바\",\n      \"content\": \"컨텍스트 바를 사용하면 패널 간에 빠르게 전환할 수 있습니다. 가장 편한 위치로 드래그하세요.\"\n    },\n    \"sidebar\": {\n      \"title\": \"워크스페이스 사이드바\",\n      \"content\": \"모든 워크스페이스의 상태를 한눈에 확인할 수 있습니다. 알림은 주의가 필요한 워크스페이스를 강조 표시합니다. 병합된 워크스페이스를 보관하여 사이드바를 깔끔하게 유지하세요.\"\n    },\n    \"multiRepo\": {\n      \"title\": \"멀티 저장소 지원\",\n      \"content\": \"단일 워크스페이스에 여러 저장소를 추가할 수 있습니다. 다른 저장소에서 작업하면서 한 저장소의 코드를 참조하거나 여러 저장소에 걸쳐 변경 사항을 동시에 구현할 수 있습니다.\"\n    },\n    \"sessions\": {\n      \"title\": \"다중 세션\",\n      \"content\": \"단일 워크스페이스 내에서 다른 에이전트와의 세션을 포함하여 여러 에이전트 대화 세션을 만들 수 있습니다. 이를 통해 대화 제한을 우회하거나 별도의 스레드에서 검토 에이전트를 시작할 수 있습니다.\"\n    },\n    \"preview\": {\n      \"title\": \"변경 사항 미리보기\",\n      \"content\": \"컨텍스트 전환 없이 내장 브라우저에서 작업을 미리 볼 수 있습니다. 데스크톱, 모바일 및 사용자 지정 뷰포트 크기에서 테스트할 수 있습니다.\"\n    },\n    \"diffs\": {\n      \"title\": \"변경 사항 및 코멘트\",\n      \"content\": \"새롭게 디자인된 변경 사항 패널에는 변경된 파일 트리가 포함되어 있습니다. 변경 사항에 직접 코멘트하여 에이전트에게 피드백을 제공하고, 워크스페이스가 PR에 연결된 경우 GitHub 코멘트를 볼 수 있습니다.\"\n    },\n    \"classicUi\": {\n      \"title\": \"클래식 UI로 돌아가기\",\n      \"content\": \"네비게이션 바 왼쪽의 나가기 아이콘을 클릭하면 클래식 칸반 보드로 돌아갑니다. 새 UI를 완전히 비활성화하려면 설정의 \\\"베타 기능\\\"에서 \\\"Workspaces 베타 활성화\\\" 옵션을 업데이트하세요.\"\n    }\n  },\n  \"logs\": {\n    \"searchLogs\": \"로그 검색\",\n    \"selectProcessToView\": \"로그를 보려면 프로세스를 선택하세요\"\n  },\n  \"processes\": {\n    \"noProcesses\": \"프로세스 없음\",\n    \"terminal\": \"터미널\"\n  },\n  \"search\": {\n    \"matchCount\": \"{{current}} / {{total}}\",\n    \"noMatches\": \"일치 항목 없음\"\n  },\n  \"contextUsage\": {\n    \"label\": \"컨텍스트 사용량\",\n    \"emptyTooltip\": \"컨텍스트 사용량은 다음 응답 후에 표시됩니다\",\n    \"tooltip\": \"컨텍스트: {{percentage}}% · {{used}} / {{total}} 토큰\",\n    \"ariaLabel\": \"컨텍스트 사용량: {{percentage}}%\"\n  },\n  \"shortcuts\": {\n    \"title\": \"키보드 단축키\",\n    \"inWorkspace\": \"(워크스페이스 내)\",\n    \"sequentialHint\": \"순차 단축키: 첫 번째 키를 누른 후 500ms 이내에 두 번째 키를 누르세요.\",\n    \"configurableHint\": \"설정 → 일반 → 메시지 입력에서 설정 가능\",\n    \"groups\": {\n      \"quickActions\": \"빠른 작업\",\n      \"navigation\": \"탐색\",\n      \"modifiers\": \"수정 키\",\n      \"goTo\": \"이동 (G ...)\",\n      \"workspace\": \"워크스페이스 (W ...)\",\n      \"view\": \"보기 (V ...)\",\n      \"issues\": \"이슈 (I ...)\",\n      \"git\": \"Git (X ...)\",\n      \"yank\": \"복사 (Y ...)\",\n      \"toggle\": \"전환 (T ...)\",\n      \"run\": \"실행 (R ...)\"\n    },\n    \"actions\": {\n      \"showHelp\": \"도움말 표시\",\n      \"closeCancel\": \"닫기/취소\",\n      \"createNewTask\": \"새 작업 만들기\",\n      \"deleteSelected\": \"선택 항목 삭제\",\n      \"focusSearch\": \"검색에 포커스\",\n      \"moveDown\": \"아래로 이동\",\n      \"moveUp\": \"위로 이동\",\n      \"moveLeft\": \"왼쪽으로 이동\",\n      \"moveRight\": \"오른쪽으로 이동\",\n      \"openCommandBar\": \"명령 바 열기\",\n      \"formatInlineCode\": \"Format inline code\",\n      \"sendMessage\": \"메시지 보내기\",\n      \"settings\": \"설정으로 이동\",\n      \"new-workspace\": \"새 워크스페이스로 이동\",\n      \"duplicate-workspace\": \"워크스페이스 복제\",\n      \"rename-workspace\": \"워크스페이스 이름 변경\",\n      \"pin-workspace\": \"워크스페이스 고정/해제\",\n      \"archive-workspace\": \"워크스페이스 보관\",\n      \"delete-workspace\": \"워크스페이스 삭제\",\n      \"toggle-changes-mode\": \"변경사항 패널 전환\",\n      \"toggle-logs-mode\": \"로그 패널 전환\",\n      \"toggle-preview-mode\": \"미리보기 패널 전환\",\n      \"toggle-left-sidebar\": \"왼쪽 사이드바 전환\",\n      \"toggle-left-main-panel\": \"채팅 패널 전환\",\n      \"create-issue\": \"이슈 만들기\",\n      \"change-issue-status\": \"상태 변경\",\n      \"change-issue-priority\": \"우선순위 변경\",\n      \"change-assignees\": \"담당자 변경\",\n      \"make-sub-issue-of\": \"하위 이슈로 설정\",\n      \"add-sub-issue\": \"하위 이슈 추가\",\n      \"remove-parent-issue\": \"상위 이슈 해제\",\n      \"link-workspace\": \"워크스페이스 연결\",\n      \"duplicate-issue\": \"이슈 복제\",\n      \"delete-issue\": \"이슈 삭제\",\n      \"git-create-pr\": \"Pull Request 만들기\",\n      \"git-merge\": \"브랜치 병합\",\n      \"git-rebase\": \"브랜치 리베이스\",\n      \"git-push\": \"변경사항 푸시\",\n      \"copy-path\": \"경로 복사\",\n      \"copy-raw-logs\": \"원본 로그 복사\",\n      \"toggle-dev-server\": \"개발 서버 전환\",\n      \"toggle-wrap-lines\": \"줄 바꿈 전환\",\n      \"run-setup-script\": \"설정 스크립트 실행\",\n      \"run-cleanup-script\": \"정리 스크립트 실행\",\n      \"run-archive-script\": \"보관 스크립트 실행\"\n    }\n  },\n  \"typeahead\": {\n    \"tags\": \"태그\",\n    \"files\": \"파일\",\n    \"commands\": \"명령어\",\n    \"chooseRepo\": \"저장소 선택\",\n    \"selectedRepo\": \"선택된 저장소: {{repoName}}\",\n    \"missingRepo\": \"선택한 저장소를 더 이상 사용할 수 없습니다.\",\n    \"noTagsOrFiles\": \"태그 또는 파일을 찾을 수 없습니다\",\n    \"createTag\": \"새 태그 만들기\",\n    \"noCommands\": \"이 에이전트에 사용 가능한 명령어가 없습니다.\"\n  },\n  \"kanban\": {\n    \"noVisibleStatuses\": \"모든 상태가 숨겨져 있습니다. 표시 설정을 사용하거나 다른 탭으로 전환하여 이슈를 확인하세요.\",\n    \"noProjectFound\": \"프로젝트를 찾을 수 없습니다\",\n    \"unassigned\": \"미할당\",\n    \"noTagsAvailable\": \"사용 가능한 태그 없음\",\n    \"createNewIssue\": \"새 이슈 만들기\",\n    \"searchTags\": \"태그 검색...\",\n    \"selectColorFor\": \"색상 선택:\",\n    \"createTag\": \"만들기\",\n    \"noPrCreated\": \"PR이 생성되지 않음\",\n    \"noCommentsYet\": \"아직 댓글이 없습니다\",\n    \"createdBy\": \"작성자\",\n    \"comments\": \"댓글\",\n    \"enterCommentPlaceholder\": \"댓글을 입력하세요...\",\n    \"attachFile\": \"파일 첨부\",\n    \"attachFileHint\": \"파일 첨부 (파일당 최대 20MB)\",\n    \"dropFilesHere\": \"여기에 파일을 놓으세요\",\n    \"fileDropHint\": \"모든 파일 형식, 최대 20MB\",\n    \"unknownUser\": \"알 수 없는 사용자\",\n    \"deletedUser\": \"삭제된 사용자\",\n    \"replyQuotePrefix\": \"님이 작성:\",\n    \"moreActions\": \"추가 작업\",\n    \"closePanel\": \"패널 닫기\",\n    \"copyLink\": \"링크 복사\",\n    \"issueTitlePlaceholder\": \"이슈 제목...\",\n    \"issueDescriptionPlaceholder\": \"작업 설명을 입력하세요...\",\n    \"createDraftWorkspaceImmediately\": \"초안 워크스페이스를 즉시 생성\",\n    \"createDraftWorkspaceDescription\": \"이슈를 생성한 후, 이슈 세부 정보가 미리 입력된 워크스페이스 생성 양식을 엽니다\",\n    \"createIssue\": \"작업 생성\",\n    \"newIssue\": \"새 이슈\",\n    \"previewCodeBlock\": \"[코드 블록]\",\n    \"previewImage\": \"[이미지]\",\n    \"previewImageWithName\": \"[이미지: {{name}}]\",\n    \"previewFile\": \"[파일]\",\n    \"previewFileWithName\": \"[파일: {{name}}]\",\n    \"imageAttachmentNameFallback\": \"첨부파일\",\n    \"removeImage\": \"이미지 제거\",\n    \"maxFilesAtOnce\": \"한 번에 최대 {{count}}개의 파일만 업로드할 수 있습니다\",\n    \"fileExceedsLimit\": \"파일 {{filename}} 이(가) 20MB 제한을 초과했습니다\",\n    \"unknownError\": \"알 수 없는 오류\",\n    \"failedToUploadFile\": \"{{filename}} 업로드 실패: {{message}}\",\n    \"downloadAttachment\": \"첨부파일 다운로드\",\n    \"subIssues\": \"하위 이슈\",\n    \"noSubIssues\": \"하위 이슈 없음\",\n    \"markIndependentIssue\": \"독립 이슈로 표시\",\n    \"parentIssue\": \"상위\",\n    \"subIssueIndicator\": \"↳\",\n    \"viewTabs\": {\n      \"active\": \"활성\",\n      \"all\": \"전체\"\n    },\n    \"loginRequired\": {\n      \"title\": \"로그인 필요\",\n      \"description\": \"이 프로젝트를 보려면 로그인하세요.\",\n      \"action\": \"로그인\"\n    },\n    \"team\": \"Team\",\n    \"personal\": \"Personal\",\n    \"searchPlaceholder\": \"Search issues...\",\n    \"filters\": \"Filters\",\n    \"clearFilters\": \"Clear filters\",\n    \"filtersDescription\": \"Adjust filters and sorting for this board view.\",\n    \"filterByTag\": \"Filter by tag\",\n    \"filterByPriority\": \"Filter by priority\",\n    \"tags\": \"Tags\",\n    \"sortBy\": \"Sort\",\n    \"workspaceSidebar\": {\n      \"sortDialogTitle\": \"Sort workspaces\",\n      \"sortDialogDescription\": \"Choose how workspaces are ordered in the sidebar.\",\n      \"sortByLabel\": \"Sort by\",\n      \"sortOrderLabel\": \"Sort order\",\n      \"sortUpdatedAt\": \"Updated at\",\n      \"sortCreatedAt\": \"Created at\",\n      \"filterDialogTitle\": \"Filter workspaces\",\n      \"filterDialogDescription\": \"Narrow down workspaces by project and PR state.\",\n      \"projectFilterLabel\": \"Project\",\n      \"prFilterLabel\": \"PR\",\n      \"prFilterAll\": \"All\",\n      \"prFilterHasPr\": \"Has PR\",\n      \"prFilterNoPr\": \"No PR\",\n      \"noProject\": \"No project\",\n      \"sortButtonTitle\": \"Sort workspaces\",\n      \"filterButtonTitle\": \"Filter workspaces\"\n    },\n    \"sortAscending\": \"Ascending\",\n    \"sortDescending\": \"Descending\",\n    \"assignee\": \"Assignee\",\n    \"self\": \"Me\",\n    \"priority\": \"Priority\",\n    \"subIssuesFilterLabel\": \"Sub-issues\",\n    \"workspacesFilterLabel\": \"Workspaces\",\n    \"selectAssignees\": \"Select assignees...\",\n    \"relationships\": \"Relationships\",\n    \"noRelationships\": \"No relationships\",\n    \"openProjectsGuide\": \"Projects guide\",\n    \"editProjectSettings\": \"Edit project settings\",\n    \"linkWorkspace\": \"Link workspace...\",\n    \"createNewWorkspace\": \"Create new workspace\",\n    \"workspaces\": \"Workspaces\",\n    \"showingWorkspaces\": \"Showing {{count}} of {{total}}\",\n    \"changeColor\": \"Change color\",\n    \"editName\": \"Edit name\",\n    \"deleteStatus\": \"Delete status\",\n    \"cannotDeleteWithIssues\": \"Move issues first\",\n    \"lastVisibleStatus\": \"At least one status must be visible\",\n    \"showStatus\": \"Show status\",\n    \"hideStatus\": \"Hide status\",\n    \"newStatus\": \"New Status\",\n    \"visibleColumns\": \"Visible Columns\",\n    \"dragToRearrange\": \"Drag to re-arrange\",\n    \"addColumn\": \"Add column\",\n    \"projectsGuide\": {\n      \"intro\": {\n        \"title\": \"Welcome\",\n        \"content\": \"Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.\"\n      },\n      \"welcome\": {\n        \"title\": \"Projects\",\n        \"content\": \"The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.\"\n      },\n      \"issues\": {\n        \"title\": \"Issues\",\n        \"content\": \"Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.\"\n      },\n      \"workspaces\": {\n        \"title\": \"Workspaces\",\n        \"content\": \"To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.\"\n      },\n      \"organizations\": {\n        \"title\": \"Invite your team\",\n        \"content\": \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      }\n    }\n  },\n  \"createMode\": {\n    \"headings\": {\n      \"repoStep\": \"어떤 리포지토리에서 작업하시겠어요?\",\n      \"chatStep\": \"무엇을 작업하시겠어요?\"\n    },\n    \"repoPicker\": {\n      \"actions\": {\n        \"recent\": \"최근\",\n        \"browse\": \"찾아보기\",\n        \"create\": \"생성\"\n      },\n      \"setupHintTitle\": \"설정 스크립트\",\n      \"setupHint\": \"팁: 이 저장소의 설정 스크립트를 설정 → 저장소에서 구성하세요\",\n      \"setupHintDismiss\": \"닫기\",\n      \"setupHintLink\": \"Configure in Settings\"\n    }\n  },\n  \"modelSelector\": {\n    \"preset\": \"프리셋\",\n    \"custom\": \"사용자 정의\",\n    \"permissions\": \"권한\",\n    \"permissionAuto\": \"자동\",\n    \"permissionAsk\": \"확인\",\n    \"permissionPlan\": \"계획\",\n    \"agent\": \"에이전트\",\n    \"default\": \"기본값\",\n    \"model\": \"모델\"\n  },\n  \"syncError\": {\n    \"networkErrors\": \"네트워크 오류\",\n    \"streamsAffected\": \"{{count}}개 스트림이 영향을 받음\",\n    \"streamsAffected_other\": \"{{count}}개 스트림이 영향을 받음\",\n    \"status\": \"(상태 {{status}})\",\n    \"refreshPage\": \"페이지 새로고침\"\n  },\n  \"onboardingSignIn\": {\n    \"subtitle\": \"Sign in to continue\",\n    \"signedInAs\": \"Signed in as {{name}}.\",\n    \"moreOptions\": \"More options\",\n    \"featureHeader\": \"Feature\",\n    \"signedInHeader\": \"Signed in\",\n    \"skipSignInHeader\": \"Don't sign in\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\"\n  },\n  \"migration\": {\n    \"allProjectsMigrated\": \"모든 프로젝트가 이미 클라우드로 마이그레이션되었습니다.\",\n    \"continueToProjects\": \"프로젝트로 계속\"\n  },\n  \"appBar\": {\n    \"kanban\": {\n      \"tooltip\": \"칸반\",\n      \"title\": \"칸반 보드\",\n      \"description\": \"로그인하여 칸반 보드로 코딩 에이전트를 정리하세요.\",\n      \"migrateOldProjects\": \"이전 프로젝트 마이그레이션\"\n    }\n  },\n  \"errors\": {\n    \"generic\": \"An unexpected error occurred\"\n  },\n  \"personal\": \"Personal\",\n  \"askQuestion\": {\n    \"title\": \"에이전트가 질문하고 있습니다\",\n    \"selectMultiple\": \"복수 선택\",\n    \"confirmSelection\": \"선택 확인\",\n    \"submitting\": \"답변 제출 중...\",\n    \"answeredCount\": \"{{count}}개의 질문에 답변함\",\n    \"answeredCount_other\": \"{{count}}개의 질문에 답변함\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ko/organization.json",
    "content": "{\n  \"createDialog\": {\n    \"title\": \"Create New Organization\",\n    \"description\": \"Create a new organization to collaborate with your team.\",\n    \"nameLabel\": \"Organization Name\",\n    \"namePlaceholder\": \"e.g., Acme Corporation\",\n    \"slugLabel\": \"Slug\",\n    \"slugPlaceholder\": \"e.g., acme-corporation\",\n    \"slugHelper\": \"Used in URLs. Lowercase letters, numbers, and hyphens only.\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Organization\"\n  },\n  \"inviteDialog\": {\n    \"title\": \"Invite Member\",\n    \"description\": \"Send an invitation to join your organization.\",\n    \"emailLabel\": \"Email Address\",\n    \"emailPlaceholder\": \"colleague@example.com\",\n    \"roleLabel\": \"Role\",\n    \"rolePlaceholder\": \"Select a role\",\n    \"roleHelper\": \"Admins can manage members and organization settings.\",\n    \"sending\": \"Sending...\",\n    \"sendButton\": \"Send Invitation\",\n    \"subscriptionRequired\": \"더 많은 멤버를 추가하려면 구독이 필요합니다\",\n    \"upgradePrompt\": \"추가 멤버를 초대하려면 조직의 플랜을 업그레이드하세요.\",\n    \"upgradeButton\": \"플랜 업그레이드\"\n  },\n  \"roles\": {\n    \"member\": \"Member\",\n    \"admin\": \"Admin\"\n  },\n  \"memberList\": {\n    \"title\": \"Members\",\n    \"description\": \"Manage members and their roles in {{orgName}}\",\n    \"inviteButton\": \"Invite Member\",\n    \"loading\": \"Loading members...\",\n    \"none\": \"No members found\",\n    \"you\": \"You\"\n  },\n  \"invitationList\": {\n    \"title\": \"Pending Invitations\",\n    \"description\": \"View pending invitations for {{orgName}}\",\n    \"loading\": \"Loading invitations...\",\n    \"invited\": \"Invited {{date}}\",\n    \"pending\": \"Pending\"\n  },\n  \"settings\": {\n    \"title\": \"Organization Settings\",\n    \"description\": \"Manage organization members and permissions\",\n    \"selectLabel\": \"Select Organization\",\n    \"selectPlaceholder\": \"Select an organization\",\n    \"selectHelper\": \"Choose an organization to view and manage its members\",\n    \"noOrganizations\": \"No organizations available\",\n    \"loadingOrganizations\": \"Loading organizations...\",\n    \"loadError\": \"Failed to load organizations\",\n    \"dangerZone\": \"Danger Zone\",\n    \"dangerZoneDescription\": \"Irreversible and destructive actions\",\n    \"deleteOrganization\": \"Delete Organization\",\n    \"deleteOrganizationDescription\": \"Permanently delete this organization and all its data\",\n    \"confirmDelete\": \"Are you sure you want to delete {{orgName}}? This action cannot be undone.\",\n    \"deleteSuccess\": \"Organization deleted successfully\",\n    \"deleteError\": \"Failed to delete organization\"\n  },\n  \"loginRequired\": {\n    \"title\": \"로그인 필요\",\n    \"description\": \"조직 설정을 관리하려면 로그인해야 합니다.\",\n    \"action\": \"로그인\"\n  },\n  \"confirmRemoveMember\": \"Are you sure you want to remove this member from the organization?\",\n  \"billing\": {\n    \"title\": \"결제 및 구독\",\n    \"description\": \"조직의 구독 및 결제 설정 관리\",\n    \"manageButton\": \"결제 관리\",\n    \"openInBrowser\": \"새 브라우저 탭에서 열림\"\n  },\n  \"personalOrg\": {\n    \"cannotInvite\": \"개인 조직에는 추가 멤버를 포함할 수 없습니다.\",\n    \"createOrgPrompt\": \"다른 사람과 협업하려면 조직을 만드세요.\",\n    \"createOrgButton\": \"조직 만들기\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ko/projects.json",
    "content": "{\n  \"createProjectDialog\": {\n    \"title\": \"Create Project\",\n    \"description\": \"Create a new project in this organization.\",\n    \"nameLabel\": \"Project name\",\n    \"namePlaceholder\": \"Enter project name\",\n    \"selectColor\": \"Select project color\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Project\"\n  },\n  \"deleteProjectDialog\": {\n    \"title\": \"Delete Project?\",\n    \"description\": \"This will permanently delete \\\"{{name}}\\\" and all its issues. This action cannot be undone.\",\n    \"error\": \"Failed to delete project. Please try again.\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ko/settings.json",
    "content": "{\n  \"settings\": {\n    \"common\": {\n      \"unsavedChanges\": \"• 저장되지 않은 변경 사항이 있습니다\",\n      \"discardChangesConfirm\": \"Discard unsaved changes?\"\n    },\n    \"unsavedChanges\": {\n      \"title\": \"저장되지 않은 변경 사항\",\n      \"message\": \"저장되지 않은 변경 사항이 있습니다. 저장하지 않고 닫으시겠습니까?\",\n      \"discard\": \"변경 사항 삭제\",\n      \"cancel\": \"계속 편집\"\n    },\n    \"layout\": {\n      \"nav\": {\n        \"title\": \"설정\",\n        \"general\": \"일반\",\n        \"generalDesc\": \"테마, 알림 및 환경설정\",\n        \"projects\": \"프로젝트\",\n        \"projectsDesc\": \"프로젝트 저장소 및 구성\",\n        \"repos\": \"저장소\",\n        \"reposDesc\": \"저장소 스크립트 및 구성\",\n        \"agents\": \"에이전트\",\n        \"agentsDesc\": \"코딩 에이전트 구성\",\n        \"mcp\": \"MCP 서버\",\n        \"mcpDesc\": \"Model Context Protocol 서버\",\n        \"organizations\": \"Organization Settings\",\n        \"organizationsDesc\": \"Manage organization members and permissions\",\n        \"remote-projects\": \"프로젝트\",\n        \"remote-projectsDesc\": \"프로젝트 관리\",\n        \"relay\": \"Remote Access\",\n        \"relayDesc\": \"Relay tunnel and enrollment\"\n      }\n    },\n    \"general\": {\n      \"loading\": \"설정 로딩 중...\",\n      \"loadError\": \"구성을 불러오지 못했습니다.\",\n      \"save\": {\n        \"button\": \"설정 저장\",\n        \"success\": \"✓ 설정이 성공적으로 저장되었습니다!\",\n        \"error\": \"구성을 저장하지 못했습니다\",\n        \"unsavedChanges\": \"• 저장되지 않은 변경사항이 있습니다\",\n        \"discard\": \"취소\"\n      },\n      \"appearance\": {\n        \"title\": \"외관\",\n        \"description\": \"애플리케이션의 모양과 느낌을 사용자 정의하세요.\",\n        \"theme\": {\n          \"label\": \"테마\",\n          \"placeholder\": \"테마 선택\",\n          \"helper\": \"선호하는 색상 구성표를 선택하세요.\"\n        },\n        \"language\": {\n          \"label\": \"언어\",\n          \"placeholder\": \"언어 선택\",\n          \"helper\": \"선호하는 언어를 선택하세요. 브라우저 기본값은 시스템 언어를 따릅니다.\"\n        }\n      },\n      \"taskExecution\": {\n        \"title\": \"기본 코딩 에이전트\",\n        \"description\": \"작업의 기본 코딩 에이전트를 선택하세요.\",\n        \"executor\": {\n          \"label\": \"기본 에이전트 구성\",\n          \"placeholder\": \"프로필 선택\",\n          \"helper\": \"작업 시도를 생성할 때 사용할 기본 에이전트 구성을 선택하세요.\"\n        },\n        \"variant\": \"DEFAULT\",\n        \"defaultLabel\": \"기본\"\n      },\n      \"editor\": {\n        \"title\": \"에디터\",\n        \"description\": \"코드 편집 환경을 구성하세요.\",\n        \"type\": {\n          \"label\": \"에디터 유형\",\n          \"placeholder\": \"에디터 선택\",\n          \"helper\": \"선호하는 코드 에디터 인터페이스를 선택하세요.\"\n        },\n        \"customCommand\": {\n          \"label\": \"사용자 정의 에디터 명령\",\n          \"placeholder\": \"예: code, subl, vim\",\n          \"helper\": \"사용자 정의 에디터를 실행하는 명령을 입력하세요. 파일을 여는 데 사용됩니다.\"\n        },\n        \"remoteSsh\": {\n          \"host\": {\n            \"label\": \"원격 SSH 호스트 (선택사항)\",\n            \"placeholder\": \"예: 호스트 이름 또는 IP 주소\",\n            \"helper\": \"Vibe Kanban이 원격 서버에서 실행 중인 경우 설정하세요. 설정하면 \\\"에디터에서 열기\\\"를 클릭할 때 로컬 명령을 실행하는 대신 SSH를 통해 에디터를 여는 URL이 생성됩니다.\"\n          },\n          \"user\": {\n            \"label\": \"원격 SSH 사용자 (선택사항)\",\n            \"placeholder\": \"예: 사용자 이름\",\n            \"helper\": \"원격 연결을 위한 SSH 사용자 이름입니다. 설정하지 않으면 VS Code가 SSH 설정을 사용하거나 입력을 요청합니다.\"\n          }\n        },\n        \"autoInstallExtension\": {\n          \"label\": \"Auto-install VS Code Extension\",\n          \"helper\": \"Automatically install the Vibe Kanban extension when opening a project in VS Code or Cursor. This runs in the background and has no effect if the extension is already installed.\"\n        },\n        \"availability\": {\n          \"checking\": \"사용 가능 여부 확인 중...\",\n          \"available\": \"사용 가능\",\n          \"notFound\": \"PATH에서 찾을 수 없음\"\n        }\n      },\n      \"github\": {\n        \"title\": \"GitHub 연동\",\n        \"cliSetup\": {\n          \"title\": \"GitHub CLI 설정\",\n          \"description\": \"GitHub CLI 인증은 풀 리퀘스트를 생성하고 GitHub 저장소와 상호 작용하는 데 필요합니다.\",\n          \"setupWillTitle\": \"이 설정은 다음을 수행합니다:\",\n          \"steps\": {\n            \"checkInstalled\": \"GitHub CLI (gh)가 설치되어 있는지 확인\",\n            \"installHomebrew\": \"필요한 경우 Homebrew를 통해 설치 (macOS)\",\n            \"authenticate\": \"OAuth를 사용하여 GitHub 인증\"\n          },\n          \"setupNote\": \"설정은 채팅 창에서 실행됩니다. 브라우저에서 인증을 완료해야 합니다.\",\n          \"runSetup\": \"설정 실행\",\n          \"running\": \"실행 중...\",\n          \"errors\": {\n            \"brewMissing\": \"Homebrew가 설치되어 있지 않습니다. 자동 설정을 활성화하려면 설치하세요.\",\n            \"notSupported\": \"이 플랫폼에서는 자동 설정이 지원되지 않습니다. GitHub CLI를 수동으로 설치하세요.\",\n            \"setupFailed\": \"GitHub CLI 설정 실행에 실패했습니다.\"\n          },\n          \"help\": {\n            \"homebrew\": {\n              \"description\": \"자동 설치에는 Homebrew가 필요합니다. Homebrew를\",\n              \"brewSh\": \"brew.sh\",\n              \"manualInstall\": \"에서 설치한 다음 설정을 다시 실행하세요. 또는 다음 명령으로 GitHub CLI를 수동으로 설치할 수 있습니다:\",\n              \"afterInstall\": \"설치 후 다음으로 인증하세요:\"\n            },\n            \"manual\": {\n              \"description\": \"GitHub CLI를\",\n              \"officialDocs\": \"공식 문서\",\n              \"andAuthenticate\": \"에서 설치한 다음 GitHub 계정으로 인증하세요.\"\n            }\n          }\n        }\n      },\n      \"git\": {\n        \"title\": \"Git\",\n        \"description\": \"Git 브랜치 이름 지정 기본 설정 구성\",\n        \"branchPrefix\": {\n          \"label\": \"브랜치 접두사\",\n          \"placeholder\": \"vk\",\n          \"helper\": \"자동 생성된 브랜치 이름의 접두사입니다. 접두사가 없으려면 비워두세요.\",\n          \"preview\": \"미리보기:\",\n          \"previewWithPrefix\": \"{{prefix}}/1a2b-task-name\",\n          \"previewNoPrefix\": \"1a2b-task-name\",\n          \"errors\": {\n            \"slash\": \"접두사에는 '/'를 포함할 수 없습니다.\",\n            \"startsWithDot\": \"접두사는 '.'로 시작할 수 없습니다.\",\n            \"endsWithDot\": \"접두사는 '.' 또는 '.lock'으로 끝날 수 없습니다.\",\n            \"invalidSequence\": \"유효하지 않은 시퀀스(.., @{)가 포함되어 있습니다.\",\n            \"invalidChars\": \"유효하지 않은 문자가 포함되어 있습니다.\",\n            \"controlChars\": \"제어 문자가 포함되어 있습니다.\"\n          }\n        },\n        \"workspaceDir\": {\n          \"label\": \"워크스페이스 디렉토리\",\n          \"placeholder\": \"~/\",\n          \"helper\": \"이 경로 내의 .vibe-kanban-workspaces 하위 디렉토리에 워크스페이스가 생성됩니다. 시스템 기본값을 사용하려면 비워두세요. 변경 사항은 앱을 다시 시작해야 적용됩니다.\",\n          \"browse\": \"찾아보기\",\n          \"dialogTitle\": \"워크스페이스 디렉토리 선택\",\n          \"dialogDescription\": \"디렉토리를 선택하세요. 워크스페이스는 해당 디렉토리 내의 .vibe-kanban-workspaces 하위 디렉토리에 생성됩니다.\"\n        }\n      },\n      \"pullRequests\": {\n        \"title\": \"풀 리퀘스트\",\n        \"description\": \"PR 생성 동작 구성\",\n        \"autoDescription\": {\n          \"label\": \"기본적으로 PR 설명 자동 생성\",\n          \"helper\": \"활성화하면 AI 에이전트가 PR 생성 후 자동으로 제목과 설명을 업데이트합니다.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"사용자 정의 프롬프트 사용\",\n          \"helper\": \"PR 설명 생성 시 AI 에이전트용 사용자 정의 프롬프트. {pr_number}와 {pr_url}을 플레이스홀더로 사용하세요.\"\n        }\n      },\n      \"commits\": {\n        \"title\": \"커밋\",\n        \"description\": \"작업 시도의 커밋 동작 구성\",\n        \"reminder\": {\n          \"label\": \"커밋 알림\",\n          \"helper\": \"지원되는 에이전트에게 중지 전 변경사항 커밋을 요청합니다.\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"사용자 정의 프롬프트 사용\",\n          \"helper\": \"커밋 알림용 사용자 정의 프롬프트. git 상태가 자동으로 추가됩니다.\"\n        }\n      },\n      \"notifications\": {\n        \"title\": \"알림\",\n        \"description\": \"알림을 받는 시기와 방법을 제어하세요.\",\n        \"sound\": {\n          \"label\": \"소리 알림\",\n          \"helper\": \"작업 시도가 완료되면 소리를 재생합니다.\",\n          \"fileLabel\": \"소리\",\n          \"filePlaceholder\": \"소리 선택\",\n          \"fileHelper\": \"작업이 완료될 때 재생할 소리를 선택하세요. 볼륨 버튼을 클릭하여 미리 들을 수 있습니다.\"\n        },\n        \"push\": {\n          \"label\": \"푸시 알림\",\n          \"helper\": \"작업 시도가 완료되면 시스템 알림을 표시합니다.\"\n        }\n      },\n      \"messageInput\": {\n        \"title\": \"메시지 입력\",\n        \"description\": \"채팅 입력에서 메시지를 보내는 방법을 구성합니다.\",\n        \"shortcut\": {\n          \"label\": \"메시지 보내기 키\",\n          \"helper\": \"메시지를 보내는 키보드 단축키를 선택하세요.\",\n          \"enterLabel\": \"Enter (Shift+Enter로 새 줄)\"\n        }\n      },\n      \"privacy\": {\n        \"title\": \"개인정보 보호\",\n        \"description\": \"익명 사용 데이터를 공유하여 Vibe-Kanban 개선에 도움을 주세요.\",\n        \"telemetry\": {\n          \"label\": \"원격 분석 활성화\",\n          \"helper\": \"애플리케이션 개선을 위한 익명 사용 이벤트 추적을 활성화합니다. 프롬프트나 프로젝트 정보는 수집되지 않습니다.\"\n        }\n      },\n      \"taskTemplates\": {\n        \"title\": \"태그\",\n        \"description\": \"@tag_name을 사용하여 작업 설명에 삽입할 수 있는 재사용 가능한 텍스트 스니펫을 만드세요.\"\n      },\n      \"tags\": {\n        \"manager\": {\n          \"title\": \"작업 태그\",\n          \"addTag\": \"태그 추가\",\n          \"noTags\": \"아직 태그가 없습니다. 일반적인 작업 설명을 위한 재사용 가능한 텍스트 스니펫을 만드세요. @tag_name을 사용하세요.\",\n          \"table\": {\n            \"tagName\": \"태그 이름\",\n            \"content\": \"내용\",\n            \"actions\": \"작업\"\n          },\n          \"actions\": {\n            \"editTag\": \"태그 편집\",\n            \"deleteTag\": \"태그 삭제\"\n          },\n          \"deleteConfirm\": \"태그 \\\"{{tagName}}\\\"를 삭제하시겠습니까?\"\n        },\n        \"dialog\": {\n          \"createTitle\": \"태그 생성\",\n          \"editTitle\": \"태그 편집\",\n          \"tagName\": {\n            \"label\": \"태그 이름\",\n            \"required\": \"*\",\n            \"hint\": \"작업 설명에서 @와 함께 사용: @{{tagName}}\",\n            \"placeholder\": \"예: bug_fix, test_plan, api_docs\",\n            \"error\": \"태그 이름에는 공백을 포함할 수 없습니다. 밑줄을 사용하세요 (예: my_tag)\"\n          },\n          \"content\": {\n            \"label\": \"내용\",\n            \"required\": \"*\",\n            \"hint\": \"작업 설명에서 @{{tagName}}을 사용할 때 삽입될 텍스트\",\n            \"placeholder\": \"이 태그를 사용할 때 삽입될 텍스트를 입력하세요\"\n          },\n          \"errors\": {\n            \"nameRequired\": \"태그 이름은 필수입니다\",\n            \"saveFailed\": \"태그 저장에 실패했습니다\"\n          },\n          \"buttons\": {\n            \"cancel\": \"취소\",\n            \"create\": \"생성\",\n            \"update\": \"업데이트\"\n          }\n        }\n      },\n      \"safety\": {\n        \"title\": \"안전 및 면책 조항\",\n        \"description\": \"안전 경고 및 온보딩에 대한 확인을 재설정하세요.\",\n        \"disclaimer\": {\n          \"title\": \"면책 조항 확인\",\n          \"description\": \"안전 면책 조항을 재설정합니다.\",\n          \"button\": \"초기화\"\n        },\n        \"onboarding\": {\n          \"title\": \"온보딩\",\n          \"description\": \"온보딩 흐름을 재설정합니다.\",\n          \"button\": \"초기화\"\n        }\n      },\n      \"beta\": {\n        \"title\": \"베타 기능\",\n        \"description\": \"출시 전 실험적인 기능을 체험해보세요.\",\n        \"workspaces\": {\n          \"label\": \"워크스페이스 베타 활성화\",\n          \"helper\": \"작업 시도를 볼 때 새로운 워크스페이스 인터페이스를 사용합니다. 작업은 먼저 작업 보기에서 열리고, 시도는 새로운 워크스페이스 보기에서 열립니다.\"\n        }\n      }\n    },\n    \"agents\": {\n      \"title\": \"코딩 에이전트 구성\",\n      \"description\": \"다양한 구성으로 코딩 에이전트의 동작을 사용자 정의하세요.\",\n      \"loading\": \"에이전트 구성 로딩 중...\",\n      \"selectAgent\": \"에이전트 선택\",\n      \"save\": {\n        \"button\": \"에이전트 구성 저장\",\n        \"success\": \"✓ 실행자 구성이 성공적으로 저장되었습니다!\",\n        \"unsavedChanges\": \"• 저장되지 않은 변경 사항이 있습니다\"\n      },\n      \"availability\": {\n        \"checking\": \"확인 중...\",\n        \"checkingAvailability\": \"가용성 확인 중...\",\n        \"available\": \"에이전트 사용 가능\",\n        \"notFoundSimple\": \"에이전트를 찾을 수 없음\",\n        \"loginDetected\": \"최근 사용 감지됨\",\n        \"loginDetectedTooltip\": \"이 에이전트에 대한 최근 인증 자격 증명이 발견되었습니다\",\n        \"installationFound\": \"이전 사용 감지됨\",\n        \"installationFoundTooltip\": \"에이전트 구성이 발견되었습니다. 사용하려면 로그인해야 할 수 있습니다.\"\n      },\n      \"editor\": {\n        \"formLabel\": \"JSON 편집\",\n        \"agentLabel\": \"에이전트\",\n        \"agentPlaceholder\": \"실행자 유형 선택\",\n        \"configLabel\": \"구성\",\n        \"configPlaceholder\": \"구성 선택\",\n        \"createNew\": \"새로 만들기...\",\n        \"deleteTitle\": \"마지막 구성은 삭제할 수 없습니다\",\n        \"deleteButton\": \"{{name}} 삭제\",\n        \"deleteText\": \"삭제\",\n        \"makeDefault\": \"기본값으로 설정\",\n        \"isDefault\": \"기본값\",\n        \"jsonLabel\": \"에이전트 구성 (JSON)\",\n        \"jsonPlaceholder\": \"프로필 로딩 중...\",\n        \"jsonLoading\": \"로딩 중...\",\n        \"pathLabel\": \"구성 파일 위치:\"\n      },\n      \"errors\": {\n        \"deleteFailed\": \"구성을 삭제하지 못했습니다. 다시 시도해 주세요.\",\n        \"saveFailed\": \"에이전트 구성을 저장하지 못했습니다. 다시 시도해 주세요.\",\n        \"saveConfigFailed\": \"구성을 저장하지 못했습니다. 다시 시도해 주세요.\",\n        \"schemaNotFound\": \"실행자 유형 {{executor}}에 대한 스키마를 찾을 수 없습니다\"\n      },\n      \"tree\": {\n        \"search\": \"구성 검색...\",\n        \"expandAll\": \"모두 펼치기\",\n        \"collapseAll\": \"모두 접기\",\n        \"noResults\": \"일치하는 구성 없음\",\n        \"noConfigs\": \"사용 가능한 구성 없음\",\n        \"selectConfig\": \"사이드바에서 구성을 선택하여 설정을 보고 편집하세요.\"\n      },\n      \"deleteConfigDialog\": {\n        \"title\": \"구성을 삭제하시겠습니까?\",\n        \"description\": \"{{executorType}} 실행자에서 \\\"{{configName}}\\\"을(를) 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.\"\n      }\n    },\n    \"mcp\": {\n      \"title\": \"MCP 서버 구성\",\n      \"description\": \"Model Context Protocol 서버를 구성하여 사용자 정의 도구 및 리소스로 코딩 에이전트 기능을 확장하세요.\",\n      \"loading\": \"MCP 구성 로딩 중...\",\n      \"applying\": \"구성 적용 중...\",\n      \"labels\": {\n        \"agent\": \"에이전트\",\n        \"agentPlaceholder\": \"실행자 선택\",\n        \"agentHelper\": \"MCP 서버를 구성할 에이전트를 선택하세요.\",\n        \"serverConfig\": \"서버 구성 (JSON)\",\n        \"popularServers\": \"인기 서버\",\n        \"serverHelper\": \"카드를 클릭하여 위의 JSON에 해당 MCP 서버를 삽입하세요.\",\n        \"saveLocation\": \"변경 사항이 저장될 위치:\"\n      },\n      \"loadingStates\": {\n        \"jsonEditor\": \"로딩 중...\",\n        \"configuration\": \"현재 MCP 서버 구성 로딩 중...\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"구성을 불러오지 못했습니다.\",\n        \"invalidJson\": \"유효하지 않은 JSON 형식\",\n        \"validationError\": \"검증 오류\",\n        \"saveFailed\": \"MCP 서버를 저장하지 못했습니다\",\n        \"applyFailed\": \"MCP 서버 구성을 적용하지 못했습니다\",\n        \"addServerFailed\": \"미리 구성된 서버를 추가하지 못했습니다\",\n        \"mcpError\": \"MCP 구성 오류: {{error}}\",\n        \"notSupported\": \"MCP가 지원되지 않습니다\",\n        \"supportMessage\": \"MCP 서버를 사용하려면 위에서 MCP를 지원하는 다른 실행자(Claude, Amp, Gemini, Codex 또는 Opencode)를 선택하세요.\"\n      },\n      \"save\": {\n        \"button\": \"MCP 구성 저장\",\n        \"success\": \"설정 저장됨!\",\n        \"successMessage\": \"✓ MCP 구성이 성공적으로 저장되었습니다!\",\n        \"loading\": \"현재 MCP 서버 구성 로딩 중...\",\n        \"unsavedChanges\": \"• 저장되지 않은 변경 사항이 있습니다\"\n      }\n    },\n    \"projects\": {\n      \"title\": \"프로젝트 구성\",\n      \"description\": \"프로젝트별 스크립트 및 설정을 구성하세요.\",\n      \"loading\": \"프로젝트 로딩 중...\",\n      \"loadError\": \"프로젝트를 불러오지 못했습니다.\",\n      \"selector\": {\n        \"label\": \"프로젝트 선택\",\n        \"placeholder\": \"구성할 프로젝트를 선택하세요\",\n        \"helper\": \"프로젝트를 선택하여 구성을 보고 편집하세요.\",\n        \"noProjects\": \"사용 가능한 프로젝트가 없습니다\"\n      },\n      \"general\": {\n        \"title\": \"일반 설정\",\n        \"description\": \"기본 프로젝트 정보를 구성하세요.\",\n        \"name\": {\n          \"label\": \"프로젝트 이름\",\n          \"placeholder\": \"프로젝트 이름 입력\",\n          \"helper\": \"이 프로젝트의 표시 이름입니다.\"\n        },\n        \"repoPath\": {\n          \"label\": \"Git 저장소 경로\",\n          \"placeholder\": \"/기존/저장소/경로\",\n          \"helper\": \"디스크에 있는 git 저장소의 절대 경로입니다.\"\n        }\n      },\n      \"save\": {\n        \"button\": \"프로젝트 설정 저장\",\n        \"success\": \"✓ 프로젝트 설정이 성공적으로 저장되었습니다!\",\n        \"error\": \"프로젝트 설정을 저장하지 못했습니다\",\n        \"unsavedChanges\": \"• 저장되지 않은 변경사항이 있습니다\",\n        \"discard\": \"취소\",\n        \"confirmSwitch\": \"저장되지 않은 변경사항이 있습니다. 정말 프로젝트를 전환하시겠습니까? 변경사항이 손실됩니다.\",\n        \"saving\": \"Saving...\"\n      },\n      \"repositories\": {\n        \"title\": \"저장소\",\n        \"description\": \"이 프로젝트의 Git 저장소 관리\",\n        \"noRepositories\": \"구성된 저장소가 없습니다\",\n        \"addRepository\": \"저장소 추가\"\n      }\n    },\n    \"repos\": {\n      \"title\": \"저장소 구성\",\n      \"description\": \"이 저장소가 워크스페이스에서 사용될 때 실행되는 스크립트를 구성합니다.\",\n      \"loading\": \"저장소 로딩 중...\",\n      \"loadError\": \"저장소를 불러오지 못했습니다.\",\n      \"addRepo\": {\n        \"button\": \"저장소 추가\",\n        \"dialogTitle\": \"Git 저장소 선택\",\n        \"dialogDescription\": \"등록할 기존 git 저장소를 선택하세요.\",\n        \"error\": \"저장소 등록에 실패했습니다\"\n      },\n      \"selector\": {\n        \"label\": \"저장소 선택\",\n        \"placeholder\": \"구성할 저장소를 선택하세요\",\n        \"helper\": \"저장소를 선택하여 구성을 보고 편집하세요.\",\n        \"noRepos\": \"사용 가능한 저장소가 없습니다\"\n      },\n      \"general\": {\n        \"title\": \"일반 설정\",\n        \"description\": \"저장소 기본 정보를 구성합니다.\",\n        \"displayName\": {\n          \"label\": \"표시 이름\",\n          \"placeholder\": \"표시 이름 입력\",\n          \"helper\": \"이 저장소의 친숙한 이름입니다.\"\n        },\n        \"path\": {\n          \"label\": \"저장소 경로\"\n        },\n        \"defaultWorkingDir\": {\n          \"label\": \"기본 작업 디렉터리\",\n          \"placeholder\": \"예: packages/frontend\",\n          \"helper\": \"단일 저장소 워크스페이스에서 코딩 에이전트가 실행되는 하위 디렉터리입니다. 워크스페이스 생성 시 설정됩니다. 저장소 루트를 사용하려면 비워 두세요.\"\n        },\n        \"defaultTargetBranch\": {\n          \"label\": \"기본 대상 브랜치\",\n          \"placeholder\": \"브랜치 선택\",\n          \"helper\": \"새 워크스페이스의 기본 베이스 브랜치입니다. 워크트리는 이 브랜치에서 분기하고 PR은 이 브랜치를 대상으로 합니다.\",\n          \"search\": \"브랜치 검색\",\n          \"noBranches\": \"브랜치를 찾을 수 없습니다\",\n          \"loading\": \"브랜치 로딩 중...\",\n          \"useCurrent\": \"현재 브랜치 사용\"\n        }\n      },\n      \"scripts\": {\n        \"title\": \"스크립트 및 구성\",\n        \"description\": \"이 저장소의 설정, 정리 스크립트 및 복사할 파일을 구성합니다. 이러한 스크립트는 저장소가 모든 워크스페이스에서 사용될 때마다 실행됩니다.\",\n        \"setup\": {\n          \"label\": \"설정 스크립트\",\n          \"helper\": \"이 스크립트는 워크트리 내부에서 생성 후 코딩 에이전트가 시작되기 전에 실행됩니다. 종속성 설치 또는 환경 준비와 같은 설정 작업에 사용하세요.\",\n          \"parallelLabel\": \"설정 스크립트를 코딩 에이전트와 병렬로 실행\",\n          \"parallelHelper\": \"활성화되면 설정 스크립트가 설정 완료를 기다리지 않고 코딩 에이전트와 동시에 실행됩니다.\"\n        },\n        \"cleanup\": {\n          \"label\": \"정리 스크립트\",\n          \"helper\": \"이 스크립트는 워크트리 내부에서 코딩 에이전트 실행 후에 실행됩니다(변경 사항이 있는 경우에만). 린터, 포맷터, 테스트 또는 기타 검증 단계 실행과 같은 품질 보증 작업에 사용하세요.\"\n        },\n        \"archive\": {\n          \"label\": \"아카이브 스크립트\",\n          \"helper\": \"이 스크립트는 워크스페이스가 아카이브될 때 워크트리 내부에서 실행됩니다. 서비스 중지, 리소스 해제 또는 상태 저장과 같은 정리 작업에 사용하세요.\"\n        },\n        \"copyFiles\": {\n          \"label\": \"파일 복사\",\n          \"helper\": \"원래 저장소 디렉토리에서 워크트리로 복사할 파일의 쉼표로 구분된 목록입니다. .env와 같은 환경 파일에 유용합니다. gitignore되었는지 확인하세요!\",\n          \"placeholder\": \"파일 경로 또는 glob 패턴 (예: .env, config/*.json)\"\n        },\n        \"devServer\": {\n          \"label\": \"개발 서버 스크립트\",\n          \"helper\": \"이 저장소의 개발 서버를 시작합니다. 스크립트는 저장소의 워크트리 디렉토리에서 실행됩니다.\"\n        }\n      },\n      \"linkedProjects\": {\n        \"title\": \"연결된 프로젝트\",\n        \"description\": \"기본 워크스페이스 설정에서 이 저장소를 사용하는 프로젝트입니다.\",\n        \"loading\": \"프로젝트 확인 중…\",\n        \"none\": \"연결된 프로젝트가 없습니다\"\n      },\n      \"remove\": {\n        \"title\": \"저장소 제거\",\n        \"description\": \"이 저장소를 Vibe Kanban에서 연결 해제합니다. 디스크의 파일은 삭제되지 않습니다.\",\n        \"button\": \"제거\",\n        \"confirm\": \"저장소 제거\",\n        \"dialogTitle\": \"\\\"{{name}}\\\"을(를) 제거하시겠습니까?\",\n        \"dialogDescription\": \"저장소를 Vibe Kanban에서 연결 해제합니다. 디스크의 파일은 영향을 받지 않습니다. 나중에 다시 추가할 수 있습니다.\",\n        \"success\": \"저장소가 성공적으로 제거되었습니다.\",\n        \"error\": \"저장소 제거에 실패했습니다.\"\n      },\n      \"save\": {\n        \"button\": \"저장소 설정 저장\",\n        \"success\": \"저장소 설정이 성공적으로 저장되었습니다!\",\n        \"error\": \"저장소 설정을 저장하지 못했습니다\",\n        \"unsavedChanges\": \"저장되지 않은 변경사항이 있습니다\",\n        \"discard\": \"취소\",\n        \"confirmSwitch\": \"저장되지 않은 변경사항이 있습니다. 정말 저장소를 전환하시겠습니까? 변경사항이 손실됩니다.\"\n      }\n    },\n    \"relay\": {\n      \"title\": \"Remote Access\",\n      \"description\": \"Allow access to this instance from the web via relay tunnel.\",\n      \"enabled\": {\n        \"label\": \"Enable remote access\",\n        \"helper\": \"When enabled, this instance can be accessed remotely through a relay tunnel.\"\n      },\n      \"enrollmentCode\": {\n        \"label\": \"Pairing Code\",\n        \"helper\": \"Enter this code in the browser to pair with this instance.\",\n        \"loginRequired\": \"Log in to generate a pairing code.\",\n        \"fetchError\": \"Failed to fetch pairing code.\",\n        \"show\": \"Show pairing code\"\n      }\n    },\n    \"remoteProjects\": {\n      \"title\": \"Remote Projects\",\n      \"description\": \"Manage cloud-synced projects across organizations.\",\n      \"loading\": \"Loading remote projects...\",\n      \"loadError\": \"Failed to load organizations\",\n      \"noProjects\": \"No projects yet. Create one to get started.\",\n      \"selectOrg\": \"Select an organization\",\n      \"createSuccess\": \"Project created successfully\",\n      \"deleteSuccess\": \"Project deleted successfully\",\n      \"nameRequired\": \"Project name is required\",\n      \"saveSuccess\": \"Project updated successfully\",\n      \"saveError\": \"Failed to update project\",\n      \"columns\": {\n        \"organizations\": \"Organizations\",\n        \"projects\": \"Projects\"\n      },\n      \"actions\": {\n        \"addProject\": \"Add Project\"\n      },\n      \"form\": {\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\"\n        },\n        \"color\": {\n          \"label\": \"Project Color\"\n        },\n        \"statuses\": {\n          \"label\": \"Project Statuses\",\n          \"description\": \"Manage kanban columns for this project.\"\n        },\n        \"defaultRepos\": {\n          \"label\": \"Default Repositories\",\n          \"description\": \"Repositories that are automatically added when creating workspaces from this project's issues.\",\n          \"empty\": \"No default repositories configured\",\n          \"addButton\": \"Add\",\n          \"addNew\": \"Add new repository...\",\n          \"branchLabel\": \"Branch\",\n          \"noBranches\": \"No branches found for this repository\",\n          \"fetchError\": \"Could not load repositories\",\n          \"saveError\": \"Failed to save default repositories\"\n        }\n      },\n      \"loginRequired\": {\n        \"title\": \"Sign in required\",\n        \"description\": \"Sign in to manage your remote projects.\",\n        \"action\": \"Sign in\"\n      }\n    }\n  },\n  \"integrations\": {\n    \"github\": {\n      \"cliSetup\": {\n        \"title\": \"GitHub CLI 설정\",\n        \"description\": \"풀 리퀘스트를 생성하고 GitHub 리포지토리와 상호작용하려면 GitHub CLI 인증이 필요합니다.\",\n        \"setupWillTitle\": \"이 설정은 다음을 수행합니다:\",\n        \"steps\": {\n          \"checkInstalled\": \"GitHub CLI (gh)가 설치되어 있는지 확인\",\n          \"installHomebrew\": \"필요한 경우 Homebrew를 통해 설치 (macOS)\",\n          \"authenticate\": \"OAuth를 사용하여 GitHub로 인증\"\n        },\n        \"setupNote\": \"설정은 채팅 창에서 실행됩니다. 브라우저에서 인증을 완료해야 합니다.\",\n        \"runSetup\": \"설정 실행\",\n        \"running\": \"실행 중...\",\n        \"errors\": {\n          \"brewMissing\": \"Homebrew가 설치되어 있지 않습니다. 자동 설정을 활성화하려면 설치하세요.\",\n          \"notSupported\": \"이 플랫폼에서는 자동 설정이 지원되지 않습니다. GitHub CLI를 수동으로 설치하세요.\",\n          \"setupFailed\": \"GitHub CLI 설정 실행에 실패했습니다.\"\n        },\n        \"help\": {\n          \"homebrew\": {\n            \"description\": \"자동 설치에는 Homebrew가 필요합니다. 다음에서 Homebrew를 설치하세요\",\n            \"brewSh\": \"brew.sh\",\n            \"manualInstall\": \"그런 다음 설정을 다시 실행하세요. 또는 다음 명령으로 GitHub CLI를 수동으로 설치하세요:\",\n            \"afterInstall\": \"설치 후 다음 명령으로 인증하세요:\"\n          },\n          \"manual\": {\n            \"description\": \"다음에서 GitHub CLI를 설치하세요\",\n            \"officialDocs\": \"공식 문서\",\n            \"andAuthenticate\": \"그런 다음 GitHub 계정으로 인증하세요.\"\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/ko/tasks.json",
    "content": "{\n  \"dropzone\": {\n    \"dropImagesHere\": \"여기에 이미지를 드롭하세요\",\n    \"supportedFormats\": \"PNG, JPG, GIF, WebP, SVG 지원\"\n  },\n  \"gitPanel\": {\n    \"advanced\": {\n      \"placeholder\": \"예: acme Corp\"\n    }\n  },\n  \"actions\": {\n    \"addTask\": \"작업 추가\"\n  },\n  \"editBranchName\": {\n    \"dialog\": {\n      \"title\": \"브랜치 이름 편집\",\n      \"description\": \"브랜치의 새 이름을 입력하세요. 열려있는 PR이 있으면 이름을 변경할 수 없습니다.\",\n      \"branchNameLabel\": \"브랜치 이름\",\n      \"placeholder\": \"예: feature/my-branch\",\n      \"renaming\": \"이름 변경 중...\",\n      \"action\": \"브랜치 이름 변경\"\n    }\n  },\n  \"startReviewDialog\": {\n    \"title\": \"리뷰 시작\",\n    \"description\": \"코딩 에이전트에게 변경 사항을 검토하고 피드백을 제공하도록 요청합니다.\",\n    \"additionalInstructions\": \"추가 지침 (선택 사항)\",\n    \"reviewComments\": \"리뷰 댓글 ({{count}})\",\n    \"includeGitContext\": \"Git 컨텍스트 포함\",\n    \"includeGitContextDescription\": \"이 브랜치에서 수행된 모든 변경 사항을 확인하는 방법을 에이전트에게 알려줍니다\",\n    \"newSession\": \"새 세션\",\n    \"sessionName\": \"리뷰\"\n  },\n  \"actionsMenu\": {\n    \"startReview\": \"리뷰 시작\",\n    \"startingReview\": \"리뷰 시작 중...\"\n  },\n  \"attempt\": {\n    \"actions\": {\n      \"startDevServer\": \"개발 서버 시작\"\n    }\n  },\n  \"resolveConflicts\": {\n    \"dialog\": {\n      \"title\": \"충돌 해결\",\n      \"description\": \"충돌이 감지되었습니다. 에이전트가 어떻게 해결하기를 원하는지 선택하세요.\",\n      \"newSession\": \"새 세션\",\n      \"resolve\": \"충돌 해결\",\n      \"resolving\": \"시작 중...\",\n      \"filesWithConflicts_one\": \"{{count}}개 파일에 충돌이 있습니다\",\n      \"filesWithConflicts_other\": \"{{count}}개 파일에 충돌이 있습니다\",\n      \"andMore\": \"...외 {{count}}개\",\n      \"sessionName\": \"충돌 해결\"\n    }\n  },\n  \"rebaseInProgress\": {\n    \"dialog\": {\n      \"title\": \"리베이스 진행 중\",\n      \"description\": \"{{targetBranch}}으로의 리베이스가 충돌 없이 진행 중입니다. 진행 방법을 선택하세요.\",\n      \"hint\": \"리베이스를 계속하여 완료하거나, 중단하여 이전 상태로 돌아갈 수 있습니다.\",\n      \"continue\": \"리베이스 계속\",\n      \"continuing\": \"계속 중...\",\n      \"abort\": \"리베이스 중단\",\n      \"aborting\": \"중단 중...\",\n      \"continueError\": \"리베이스를 계속할 수 없습니다. 해결되지 않은 충돌이 있을 수 있습니다.\",\n      \"abortError\": \"리베이스 중단에 실패했습니다. 다시 시도해 주세요.\"\n    }\n  },\n  \"createPrDialog\": {\n    \"title\": \"Pull Request 생성\",\n    \"description\": \"이 작업 시도에 대한 Pull Request를 생성합니다.\",\n    \"titleLabel\": \"제목\",\n    \"titlePlaceholder\": \"PR 제목 입력\",\n    \"descriptionLabel\": \"설명 (선택사항)\",\n    \"descriptionPlaceholder\": \"PR 설명 입력\",\n    \"baseBranchLabel\": \"기본 브랜치\",\n    \"loadingBranches\": \"브랜치 로딩 중...\",\n    \"selectBaseBranch\": \"기본 브랜치 선택\",\n    \"draftLabel\": \"초안으로 만들기\",\n    \"autoGenerateLabel\": \"AI 에이전트에게 더 나은 PR 설명 생성 요청\",\n    \"creating\": \"생성 중...\",\n    \"createButton\": \"PR 생성\",\n    \"errors\": {\n      \"failedToCreate\": \"PR 생성에 실패했습니다\",\n      \"gitCliNotLoggedIn\": \"Git이 인증되지 않았습니다. \\\"gh auth login\\\"을 실행하거나 Git 자격 증명을 설정한 후 다시 시도하세요.\",\n      \"gitCliNotInstalled\": \"Git CLI가 설치되어 있지 않습니다. PR을 생성하려면 Git을 설치하세요.\",\n      \"targetBranchNotFound\": \"대상 브랜치 '{{branch}}'이(가) 원격에 존재하지 않습니다. 풀 리퀘스트를 생성하기 전에 브랜치가 존재하는지 확인하세요.\"\n    }\n  },\n  \"branches\": {\n    \"changeTarget\": {\n      \"dialog\": {\n        \"action\": \"브랜치 변경\",\n        \"description\": \"작업 시도의 새 대상 브랜치를 선택하세요.\",\n        \"inProgress\": \"변경 중...\",\n        \"placeholder\": \"대상 브랜치 선택\",\n        \"title\": \"대상 브랜치 변경\"\n      }\n    }\n  },\n  \"repos\": {\n    \"selector\": {\n      \"placeholder\": \"저장소 선택\",\n      \"empty\": \"사용 가능한 저장소가 없습니다\"\n    }\n  },\n  \"repoBranchSelector\": {\n    \"label\": \"기본 브랜치\"\n  },\n  \"diff\": {\n    \"filesChanged_one\": \"{{count}} file changed\",\n    \"filesChanged_other\": \"{{count}} files changed\",\n    \"largeDiff\": {\n      \"title\": \"큰 파일\",\n      \"linesChanged\": \"{{count}} 줄 변경됨\",\n      \"loadAnyway\": \"그래도 diff 로드\",\n      \"warning\": \"큰 diff는 브라우저를 느리게 할 수 있습니다.\"\n    }\n  },\n  \"followUp\": {\n    \"queuedMessage\": \"메시지가 대기열에 추가됨 - 현재 실행이 완료되면 실행됩니다\"\n  },\n  \"git\": {\n    \"labels\": {\n      \"taskBranch\": \"작업 브랜치\"\n    },\n    \"branch\": {\n      \"current\": \"현재\"\n    },\n    \"forcePushDialog\": {\n      \"title\": \"강제 푸시가 필요합니다\",\n      \"description\": \"원격 브랜치{{branchLabel}}가 로컬 브랜치와 분기되었습니다. 일반 푸시가 거부되었습니다.\",\n      \"warning\": \"강제 푸시는 로컬 변경 사항으로 원격 변경을 덮어씁니다. 이 동작은 되돌릴 수 없습니다.\",\n      \"note\": \"원격 브랜치 기록을 대체해도 확실한 경우에만 계속하세요.\",\n      \"error\": \"강제 푸시에 실패했습니다\"\n    },\n    \"status\": {\n      \"commits_one\": \"커밋\",\n      \"commits_other\": \"커밋\",\n      \"conflicts\": \"충돌\",\n      \"upToDate\": \"최신 상태\",\n      \"ahead\": \"앞서감\",\n      \"behind\": \"뒤처짐\"\n    },\n    \"states\": {\n      \"merged\": \"병합됨!\",\n      \"merging\": \"병합 중...\",\n      \"merge\": \"병합\",\n      \"rebasing\": \"리베이스 중...\",\n      \"rebase\": \"리베이스\",\n      \"pushed\": \"푸시됨!\",\n      \"pushing\": \"푸시 중...\",\n      \"push\": \"푸시\",\n      \"pushFailed\": \"실패\",\n      \"forcePush\": \"강제 푸시\",\n      \"forcePushing\": \"강제 푸시 중...\",\n      \"creating\": \"생성 중...\",\n      \"createPr\": \"PR 생성\"\n    },\n    \"errors\": {\n      \"changeTargetBranch\": \"대상 브랜치를 변경하지 못했습니다\",\n      \"pushChanges\": \"변경사항을 푸시하지 못했습니다\",\n      \"mergeChanges\": \"변경사항을 병합하지 못했습니다\",\n      \"rebaseBranch\": \"브랜치를 리베이스하지 못했습니다\",\n      \"branchStatusUnavailable\": \"브랜치 상태를 가져올 수 없습니다. 대상 브랜치는 여전히 변경할 수 있습니다.\"\n    },\n    \"pr\": {\n      \"open\": \"Open PR #{{number}}\",\n      \"number\": \"PR #{{number}}\",\n      \"merged\": \"병합된 PR #{{prNumber}}\"\n    },\n    \"actions\": {\n      \"title\": \"Git 작업\",\n      \"changeTarget\": \"대상 변경\",\n      \"prMerged\": \"PR #{{number}}은(는) 이미 병합되었습니다\",\n      \"loginRequired\": {\n        \"title\": \"Git 작업을 관리하려면 로그인하세요\",\n        \"description\": \"이 작업에서 브랜치를 푸시하고 변경 사항을 병합하거나 풀 리퀘스트를 만들려면 Vibe Kanban에 로그인하세요.\",\n        \"action\": \"로그인\"\n      }\n    },\n    \"mergeDialog\": {\n      \"title\": \"변경사항 병합\",\n      \"description\": \"변경사항을 대상 브랜치에 병합합니다. 계속하시겠습니까?\"\n    },\n    \"createRepo\": {\n      \"dialog\": {\n        \"title\": \"새 저장소 만들기\",\n        \"description\": \"Git으로 추적되는 새 저장소를 만듭니다.\"\n      },\n      \"form\": {\n        \"nameLabel\": \"저장소 이름\",\n        \"namePlaceholder\": \"my-project\",\n        \"locationLabel\": \"위치\",\n        \"locationPlaceholder\": \"저장소 위치 선택\"\n      },\n      \"browseDialog\": {\n        \"title\": \"저장소 위치 선택\",\n        \"description\": \"새 저장소가 생성될 폴더를 선택하세요.\"\n      },\n      \"errors\": {\n        \"nameRequired\": \"저장소 이름을 입력해주세요\",\n        \"createFailed\": \"저장소 생성에 실패했습니다\"\n      },\n      \"buttons\": {\n        \"createRepository\": \"리포지토리 생성\"\n      },\n      \"states\": {\n        \"creating\": \"생성 중...\"\n      }\n    }\n  },\n  \"loading\": \"작업 로딩 중...\",\n  \"preview\": {\n    \"logs\": {\n      \"label\": \"로그\",\n      \"viewFull\": \"전체 로그 보기\"\n    },\n    \"noServer\": {\n      \"setupTitle\": \"미리보기 기능을 사용하려면 개발 서버 스크립트를 설정해야 합니다\",\n      \"setupPrompt\": \"라이브 미리보기 및 클릭하여 편집을 사용하려면 이 프로젝트에 개발 서버 스크립트를 추가하세요.\",\n      \"title\": \"실행 중인 개발 서버 없음\",\n      \"editDevScript\": \"개발 서버 스크립트 편집\",\n      \"learnMore\": \"애플리케이션 테스트에 대해 자세히 알아보기\"\n    },\n    \"toolbar\": {\n      \"copyUrl\": \"URL 복사\",\n      \"openInTab\": \"새 탭에서 열기\",\n      \"refresh\": \"미리보기 새로고침\",\n      \"stopDevServer\": \"개발 서버 중지\",\n      \"resetUrl\": \"감지된 URL로 재설정\",\n      \"clearUrlOverride\": \"URL 오버라이드 지우기\",\n      \"desktopView\": \"데스크탑 보기\",\n      \"mobileView\": \"모바일 보기 (390x844)\",\n      \"responsiveView\": \"반응형 보기 (크기 조절 가능)\",\n      \"startDevServer\": \"개발 서버 시작\",\n      \"submitUrl\": \"URL 제출\",\n      \"toggleDevTools\": \"DevTools 전환\"\n    },\n    \"loading\": {\n      \"startingServer\": \"개발 서버 시작 중...\",\n      \"waitingForServer\": \"서버 대기 중...\",\n      \"loadingPreview\": \"미리보기 로딩 중...\",\n      \"manualUrlHint\": \"아직 URL이 감지되지 않았습니다. 위의 도구 모음에서 URL을 수동으로 입력할 수 있습니다.\"\n    },\n    \"browser\": {\n      \"title\": \"개발 서버 미리보기\",\n      \"devServerFallback\": \"개발 서버\"\n    }\n  },\n  \"processes\": {\n    \"noLogsAvailable\": \"로그가 없습니다\",\n    \"agent\": \"Agent:\",\n    \"backToList\": \"Back to list\",\n    \"completed\": \"Completed: {{date}}\",\n    \"deleted\": \"Deleted\",\n    \"deletedTooltip\": \"Deleted by restore: timeline was restored to a checkpoint and later executions were removed\",\n    \"detailsTitle\": \"Process Details\",\n    \"errorLoadingDetails\": \"Failed to load process details. Please try again.\",\n    \"errorLoadingUpdates\": \"Failed to load live updates for processes.\",\n    \"exit\": \"Exit: {{code}}\",\n    \"loading\": \"Loading execution processes...\",\n    \"loadingDetails\": \"Loading process details...\",\n    \"noProcesses\": \"No execution processes found for this attempt.\",\n    \"processId\": \"Process ID: {{id}}\",\n    \"reconnecting\": \"Reconnecting...\",\n    \"selectAttempt\": \"Select an attempt to view execution processes.\",\n    \"started\": \"Started: {{date}}\",\n    \"copyLogs\": \"로그 복사\",\n    \"logsCopied\": \"복사됨!\"\n  },\n  \"rebase\": {\n    \"common\": {\n      \"action\": \"리베이스\",\n      \"inProgress\": \"리베이스 중...\"\n    },\n    \"dialog\": {\n      \"advanced\": \"고급\",\n      \"description\": \"이 작업 시도를 리베이스할 새 기본 브랜치를 선택하세요.\",\n      \"targetLabel\": \"대상 브랜치\",\n      \"targetPlaceholder\": \"대상 브랜치 선택\",\n      \"title\": \"작업 시도 리베이스\",\n      \"upstreamLabel\": \"업스트림 브랜치\",\n      \"upstreamPlaceholder\": \"업스트림 브랜치 선택\"\n    }\n  },\n  \"todoPopup\": {\n    \"title\": \"작업\",\n    \"progress\": \"{{completed}}/{{total}} 완료\",\n    \"noTasks\": \"작업 없음\"\n  },\n  \"viewProcessesDialog\": {\n    \"title\": \"Execution processes\"\n  },\n  \"prComments\": {\n    \"dialog\": {\n      \"title\": \"PR 댓글 선택\",\n      \"noComments\": \"이 PR에 댓글이 없습니다\",\n      \"selectAll\": \"모두 선택\",\n      \"deselectAll\": \"모두 선택 해제\",\n      \"add\": \"추가\",\n      \"selectedCount\": \"{{total}}개 중 {{selected}}개 선택됨\"\n    },\n    \"card\": {\n      \"review\": \"리뷰\",\n      \"tooltip\": \"클릭하여 보기, 더블 클릭하여 편집\"\n    }\n  },\n  \"taskFormDialog\": {\n    \"createTitle\": \"새 작업 만들기\",\n    \"editTitle\": \"작업 수정\",\n    \"titlePlaceholder\": \"작업 제목\",\n    \"descriptionPlaceholder\": \"세부 정보 추가 (선택 사항). @를 입력하여 파일을 검색합니다.\",\n    \"statusLabel\": \"상태\",\n    \"statusOptions\": {\n      \"todo\": \"할 일\",\n      \"inprogress\": \"진행 중\",\n      \"inreview\": \"검토 중\",\n      \"done\": \"완료\",\n      \"cancelled\": \"취소됨\"\n    },\n    \"startLabel\": \"시작\",\n    \"attachFile\": \"파일 첨부\",\n    \"dropImagesHere\": \"여기에 이미지를 드롭하세요\",\n    \"updating\": \"업데이트 중...\",\n    \"updateTask\": \"작업 업데이트\",\n    \"starting\": \"시작 중...\",\n    \"creating\": \"만드는 중...\",\n    \"create\": \"만들기\",\n    \"discardDialog\": {\n      \"title\": \"저장하지 않은 변경사항을 버리시겠습니까?\",\n      \"description\": \"저장하지 않은 변경사항이 있습니다. 정말 버리시겠습니까?\",\n      \"continueEditing\": \"계속 수정\",\n      \"discardChanges\": \"변경사항 버리기\"\n    }\n  },\n  \"restoreLogsDialog\": {\n    \"title\": \"재시도 확인\",\n    \"titleReset\": \"초기화 확인\",\n    \"historyChange\": {\n      \"title\": \"히스토리 변경\",\n      \"willDelete\": \"이 프로세스를 삭제합니다\",\n      \"willDeleteProcesses_one\": \"{{count}}개 프로세스를 삭제합니다\",\n      \"willDeleteProcesses_other\": \"{{count}}개 프로세스를 삭제합니다\",\n      \"andLaterProcesses_one\": \"및 이후 {{count}}개 프로세스\",\n      \"andLaterProcesses_other\": \"및 이후 {{count}}개 프로세스\",\n      \"fromHistory\": \"를 히스토리에서 삭제합니다.\",\n      \"codingAgentRuns_one\": \"{{count}}개 코딩 에이전트 실행\",\n      \"codingAgentRuns_other\": \"{{count}}개 코딩 에이전트 실행\",\n      \"scriptProcesses_one\": \"{{count}}개 스크립트 프로세스\",\n      \"scriptProcesses_other\": \"{{count}}개 스크립트 프로세스\",\n      \"setupCleanupBreakdown\": \"(설정 {{setup}}개, 정리 {{cleanup}}개)\",\n      \"permanentWarning\": \"이 작업은 히스토리를 영구적으로 변경하며 되돌릴 수 없습니다.\"\n    },\n    \"uncommittedChanges\": {\n      \"title\": \"커밋되지 않은 변경사항 감지됨\",\n      \"description_one\": \"{{count}}개의 커밋되지 않은 변경사항이 있습니다\",\n      \"description_other\": \"{{count}}개의 커밋되지 않은 변경사항이 있습니다\",\n      \"andUntracked_one\": \" 및 {{count}}개의 추적되지 않은 파일\",\n      \"andUntracked_other\": \" 및 {{count}}개의 추적되지 않은 파일\",\n      \"acknowledgeLabel\": \"이 변경사항이 영향을 받을 수 있음을 이해합니다\"\n    },\n    \"resetWorktree\": {\n      \"title\": \"워크트리 초기화\",\n      \"enabled\": \"활성화\",\n      \"disabled\": \"비활성화\",\n      \"disabledUncommitted\": \"비활성화 (커밋되지 않은 변경사항 감지됨)\",\n      \"restoreDescription\": \"워크트리가 이 커밋으로 복원됩니다.\",\n      \"discardChanges_one\": \"{{count}}개의 커밋되지 않은 변경사항을 버립니다.\",\n      \"discardChanges_other\": \"{{count}}개의 커밋되지 않은 변경사항을 버립니다.\",\n      \"untrackedPresent_one\": \"{{count}}개의 추적되지 않은 파일이 있습니다 (초기화의 영향을 받지 않음).\",\n      \"untrackedPresent_other\": \"{{count}}개의 추적되지 않은 파일이 있습니다 (초기화의 영향을 받지 않음).\",\n      \"forceReset\": \"강제 초기화 (커밋되지 않은 변경사항 버리기)\",\n      \"uncommittedWillDiscard\": \"커밋되지 않은 변경사항이 버려집니다.\",\n      \"uncommittedPresentHint\": \"커밋되지 않은 변경사항이 있습니다. 강제 초기화를 켜거나 커밋/스태시한 후 진행하세요.\"\n    },\n    \"buttons\": {\n      \"retry\": \"재시도\",\n      \"reset\": \"초기화\"\n    }\n  },\n  \"conversation\": {\n    \"you\": \"나\",\n    \"thinking\": \"생각 중\",\n    \"todo\": \"할 일\",\n    \"todos\": \"할 일\",\n    \"completed\": \"완료됨\",\n    \"incomplete\": \"미완료\",\n    \"pending\": \"대기 중\",\n    \"inProgress\": \"진행 중\",\n    \"skipped\": \"건너뜀\",\n    \"error\": \"오류\",\n    \"retry\": \"재시도\",\n    \"showMore\": \"더 보기\",\n    \"showLess\": \"간략히\",\n    \"actions\": {\n      \"cancel\": \"취소\",\n      \"submitFeedback\": \"피드백 제출\",\n      \"stop\": \"중지\",\n      \"stopping\": \"중지 중\",\n      \"loading\": \"로딩 중\",\n      \"send\": \"보내기\",\n      \"sending\": \"보내는 중\",\n      \"queue\": \"대기열에 추가\",\n      \"cancelQueue\": \"대기열 취소\",\n      \"requestChanges\": \"변경 요청\",\n      \"approve\": \"승인\",\n      \"clearReviewComments\": \"리뷰 댓글 지우기\",\n      \"edit\": \"메시지 편집\",\n      \"scrollToPreviousMessage\": \"Go to previous message\",\n      \"reset\": \"초기화\",\n      \"resetTooltip\": \"이 시점으로 초기화\"\n    },\n    \"approval\": {\n      \"conflictWarning\": \"충돌하는 파일은 수동으로 해결해야 합니다\",\n      \"conflicts_one\": \"{{count}}개의 충돌 파일을 수동으로 해결해야 합니다\",\n      \"conflicts_other\": \"{{count}}개의 충돌 파일을 수동으로 해결해야 합니다\"\n    },\n    \"executors\": \"실행기\",\n    \"saveAsDefault\": \"기본값으로 저장\",\n    \"script\": {\n      \"clickToViewLogs\": \"로그를 보려면 클릭하세요\",\n      \"completedSuccessfully\": \"성공적으로 완료됨\",\n      \"exitCode\": \"종료 코드: {{code}}\",\n      \"running\": \"실행 중...\"\n    },\n    \"scriptPlaceholder\": {\n      \"setupTitle\": \"설정 스크립트\",\n      \"setupDescription\": \"설정 스크립트가 구성되지 않았습니다. 설정 스크립트는 코딩 에이전트가 시작되기 전에 실행됩니다.\",\n      \"cleanupTitle\": \"정리 스크립트\",\n      \"cleanupDescription\": \"정리 스크립트가 구성되지 않았습니다. 정리 스크립트는 코딩 에이전트가 완료된 후에 실행됩니다.\",\n      \"configure\": \"구성\"\n    },\n    \"unableToRenderDiff\": \"차이를 표시할 수 없습니다.\",\n    \"updatedTodos\": \"업데이트된 할 일\",\n    \"viewInChangesPanel\": \"변경사항 패널에서 보기\",\n    \"sessions\": {\n      \"newSession\": \"새 세션\",\n      \"latest\": \"최신\",\n      \"previous\": \"이전\",\n      \"label\": \"세션\",\n      \"noPreviousSessions\": \"이전 세션이 없습니다\",\n      \"rename\": \"이름 변경\",\n      \"renameTitle\": \"세션 이름 변경\",\n      \"renameDescription\": \"이 세션의 새 이름을 입력하세요.\",\n      \"renamePlaceholder\": \"세션 이름 입력\",\n      \"renaming\": \"이름 변경 중...\"\n    },\n    \"reviewComments\": {\n      \"count_one\": \"{{count}}개의 리뷰 댓글이 포함됩니다\",\n      \"count_other\": \"{{count}}개의 리뷰 댓글이 포함됩니다\"\n    },\n    \"workspace\": {\n      \"create\": \"만들기\",\n      \"creating\": \"만드는 중...\"\n    },\n    \"fileEntry\": {\n      \"created\": \"생성됨\",\n      \"modified\": \"수정됨\",\n      \"deleted\": \"삭제됨\",\n      \"renamed\": \"이름 변경됨\"\n    }\n  },\n  \"scriptFixer\": {\n    \"setupScriptTitle\": \"설정 스크립트 수정\",\n    \"cleanupScriptTitle\": \"정리 스크립트 수정\",\n    \"archiveScriptTitle\": \"아카이브 스크립트 수정\",\n    \"devServerTitle\": \"개발 서버 스크립트 수정\",\n    \"scriptLabel\": \"스크립트 (편집)\",\n    \"logsLabel\": \"마지막 실행 로그\",\n    \"saveButton\": \"저장\",\n    \"saveAndTestButton\": \"저장 및 테스트\",\n    \"noLogs\": \"실행 로그가 없습니다\",\n    \"selectRepo\": \"저장소\",\n    \"fixScript\": \"스크립트 수정\",\n    \"statusRunning\": \"실행 중...\",\n    \"statusSuccess\": \"성공적으로 완료됨\",\n    \"statusFailed\": \"종료 코드 {{exitCode}}(으)로 실패함\",\n    \"statusKilled\": \"프로세스가 종료되었습니다\"\n  },\n  \"createWorkspaceFromPr\": {\n    \"title\": \"PR에서 워크스페이스 만들기\",\n    \"description\": \"열린 풀 리퀘스트를 선택하여 워크스페이스를 만듭니다. PR 제목을 사용하여 태스크가 자동으로 생성됩니다.\",\n    \"repositoryLabel\": \"저장소\",\n    \"remoteLabel\": \"리모트\",\n    \"pullRequestLabel\": \"풀 리퀘스트\",\n    \"loadingRepositories\": \"저장소 로드 중...\",\n    \"loadingRemotes\": \"리모트 로드 중...\",\n    \"noRepositoriesFound\": \"저장소를 찾을 수 없습니다\",\n    \"loadingPullRequests\": \"풀 리퀘스트 로드 중...\",\n    \"selectRepositoryFirst\": \"먼저 저장소를 선택하세요\",\n    \"noPullRequestsFound\": \"열린 풀 리퀘스트가 없습니다\",\n    \"runSetupScript\": \"설정 스크립트 실행\",\n    \"creating\": \"만드는 중...\",\n    \"createWorkspace\": \"워크스페이스 만들기\",\n    \"selectRepository\": \"저장소 선택\",\n    \"selectRemote\": \"리모트 선택\",\n    \"selectPullRequest\": \"풀 리퀘스트 선택\",\n    \"searchPrsPlaceholder\": \"번호 또는 제목으로 PR 검색...\",\n    \"noMatchingPrs\": \"일치하는 풀 리퀘스트가 없습니다\",\n    \"default\": \"기본값\",\n    \"openPrInBrowser\": \"브라우저에서 PR 열기\",\n    \"errors\": {\n      \"cliNotInstalled\": \"{{provider}} CLI가 설치되어 있지 않습니다\",\n      \"unsupportedProvider\": \"Git 공급자가 지원되지 않습니다\",\n      \"failedToLoadPrs\": \"풀 리퀘스트 로드 실패\",\n      \"prNotFound\": \"풀 리퀘스트를 찾을 수 없습니다\",\n      \"failedToCreateWorkspace\": \"워크스페이스 만들기 실패\"\n    }\n  },\n  \"shareDialog\": {\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Sign in to access this feature.\",\n      \"action\": \"Sign in\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hans/common.json",
    "content": "{\n  \"editorNames\": {\n    \"custom\": \"自定义\"\n  },\n  \"buttons\": {\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"send\": \"发送\",\n    \"delete\": \"删除\",\n    \"edit\": \"编辑\",\n    \"create\": \"创建\",\n    \"continue\": \"继续\",\n    \"reset\": \"重置\",\n    \"manage\": \"管理\",\n    \"connect\": \"连接\",\n    \"disconnect\": \"断开连接\",\n    \"close\": \"关闭\",\n    \"replay\": \"重播\",\n    \"discard\": \"放弃\",\n    \"addItem\": \"添加项目\",\n    \"reply\": \"回复\",\n    \"retry\": \"Retry\",\n    \"add\": \"添加\"\n  },\n  \"form\": {\n    \"notSpecified\": \"未指定\",\n    \"selectOption\": \"选择一个选项...\"\n  },\n  \"states\": {\n    \"loading\": \"加载中...\",\n    \"loadingHistory\": \"加载历史记录\",\n    \"saving\": \"保存中...\",\n    \"error\": \"错误\",\n    \"success\": \"成功\",\n    \"reconnecting\": \"重新连接中\"\n  },\n  \"language\": {\n    \"browserDefault\": \"浏览器默认\"\n  },\n  \"conversation\": {\n    \"plan\": \"计划\",\n    \"output\": \"输出\",\n    \"deniedByUser\": \"用户拒绝了 {{toolName}}\",\n    \"tool\": \"工具\",\n    \"thinking\": \"思考中\",\n    \"toolSummary\": {\n      \"read\": \"读取 {{path}}\",\n      \"searched\": \"搜索 \\\"{{query}}\\\"\",\n      \"fetched\": \"获取 {{url}}\",\n      \"ranCommand\": \"执行命令\",\n      \"createdTask\": \"创建任务：{{description}}\",\n      \"todoOperation\": \"{{operation}} 待办事项\"\n    },\n    \"subagent\": {\n      \"defaultType\": \"子代理\"\n    },\n    \"loadingEarlierMessages\": \"正在加载早期消息\"\n  },\n  \"folderPicker\": {\n    \"legend\": \"点击文件夹名称进行导航 • 使用操作按钮进行选择\",\n    \"manualPathLabel\": \"手动输入路径:\",\n    \"go\": \"前往\",\n    \"searchLabel\": \"搜索当前目录:\",\n    \"selectCurrent\": \"选择当前\",\n    \"gitRepo\": \"git 仓库\",\n    \"selectPath\": \"选择路径\"\n  },\n  \"branchSelector\": {\n    \"placeholder\": \"选择分支\",\n    \"searchPlaceholder\": \"搜索分支...\",\n    \"empty\": \"未找到分支\",\n    \"badges\": {\n      \"current\": \"当前\",\n      \"remote\": \"远程\"\n    },\n    \"currentDisabled\": \"无法选择当前分支\"\n  },\n  \"orgSwitcher\": {\n    \"organizations\": \"组织\",\n    \"createOrganization\": \"创建组织\",\n    \"orgSettings\": \"组织设置\"\n  },\n  \"ok\": \"确定\",\n  \"error\": \"错误\",\n  \"signIn\": \"登录\",\n  \"signOut\": \"退出登录\",\n  \"oauth\": {\n    \"title\": \"登录 Vibe Kanban\",\n    \"description\": \"登录以加入组织并与团队共享任务\",\n    \"continueWithGitHub\": \"使用 GitHub 继续\",\n    \"continueWithGoogle\": \"使用 Google 继续\",\n    \"waitingTitle\": \"完成身份验证\",\n    \"waitingDescription\": \"已打开弹出窗口进行身份验证\",\n    \"waitingForAuth\": \"等待身份验证...\",\n    \"popupInstructions\": \"如果弹出窗口未打开，请检查您的弹窗拦截器设置。\",\n    \"back\": \"返回\",\n    \"successTitle\": \"身份验证成功！\",\n    \"welcomeBack\": \"欢迎回来，{{name}}\",\n    \"errorTitle\": \"身份验证失败\",\n    \"errorDescription\": \"验证您的账户时出现问题\",\n    \"tryAgain\": \"重试\"\n  },\n  \"toolbar\": {\n    \"sortBy\": \"排序方式\",\n    \"groupBy\": \"分组方式\"\n  },\n  \"sorting\": {\n    \"ascending\": \"升序\",\n    \"descending\": \"降序\"\n  },\n  \"grouping\": {\n    \"date\": \"日期\",\n    \"assignee\": \"负责人\",\n    \"label\": \"标签\"\n  },\n  \"workspaces\": {\n    \"title\": \"工作区\",\n    \"searchPlaceholder\": \"搜索...\",\n    \"active\": \"活跃\",\n    \"archived\": \"已归档\",\n    \"loading\": \"加载中...\",\n    \"notFound\": \"未找到工作区\",\n    \"selectToStart\": \"选择一个工作区开始\",\n    \"draft\": \"草稿\",\n    \"viewArchive\": \"查看归档\",\n    \"backToActive\": \"返回活跃\",\n    \"noArchived\": \"没有已归档的工作区\",\n    \"noWorkspaces\": \"没有工作区\",\n    \"newWorkspace\": \"新工作区\",\n    \"needsAttention\": \"需要注意\",\n    \"idle\": \"空闲\",\n    \"running\": \"运行中\",\n    \"pin\": \"置顶\",\n    \"unpin\": \"取消置顶\",\n    \"archive\": \"归档\",\n    \"more\": \"更多操作\",\n    \"rename\": {\n      \"title\": \"重命名工作区\",\n      \"description\": \"输入此工作区的新名称。\",\n      \"nameLabel\": \"名称\",\n      \"placeholder\": \"输入工作区名称\",\n      \"action\": \"重命名\",\n      \"renaming\": \"正在重命名...\"\n    },\n    \"unlinkFromIssue\": \"从问题取消关联\",\n    \"deleteWorkspace\": \"删除工作区\",\n    \"unlink\": \"取消关联\",\n    \"delete\": \"删除\",\n    \"unlinkConfirmMessage\": \"确定要将此工作区与问题取消关联吗？工作区仍将存在，但不再与此问题关联。\",\n    \"deleteConfirmMessage\": \"确定要删除此工作区吗？这将取消与问题的关联并删除本地工作区。此操作无法撤销。\",\n    \"unlinkError\": \"取消关联工作区失败\",\n    \"deleteError\": \"删除工作区失败\",\n    \"filesChanged\": \"{{count}} 个文件\",\n    \"deleteDialog\": {\n      \"title\": \"删除工作区\",\n      \"description\": \"确定要删除此工作区吗？此操作无法撤销。\",\n      \"deleteBranchLabel\": \"删除分支\",\n      \"cannotDeleteOpenPr\": \"PR 开启时无法删除分支\",\n      \"unlinkFromIssueLabel\": \"同时取消与议题的关联\"\n    },\n    \"linkError\": \"Failed to link workspace\"\n  },\n  \"fileTree\": {\n    \"searchPlaceholder\": \"搜索文件...\",\n    \"noResults\": \"没有匹配的文件\",\n    \"title\": \"文件\",\n    \"showGitHubComments\": \"显示 GitHub 评论\",\n    \"hideGitHubComments\": \"隐藏 GitHub 评论\",\n    \"prevGitHubComment\": \"上一个有评论的文件\",\n    \"nextGitHubComment\": \"下一个有评论的文件\"\n  },\n  \"sections\": {\n    \"changes\": \"更改\",\n    \"repositories\": \"仓库\",\n    \"addRepositories\": \"添加仓库\",\n    \"project\": \"项目\",\n    \"processes\": \"进程\",\n    \"devServer\": \"开发服务器\",\n    \"advanced\": \"高级\",\n    \"workingBranch\": \"工作分支\",\n    \"recent\": \"最近\",\n    \"other\": \"其他\",\n    \"devServerPreview\": \"开发服务器预览\",\n    \"terminal\": \"终端\",\n    \"notes\": \"笔记\"\n  },\n  \"notes\": {\n    \"placeholder\": \"添加关于此工作区的笔记...\",\n    \"selectWorkspace\": \"选择一个工作区以查看笔记\"\n  },\n  \"actions\": {\n    \"copyPath\": \"复制路径\",\n    \"cancel\": \"取消\",\n    \"saveChanges\": \"保存更改\",\n    \"copied\": \"已复制\",\n    \"collapse\": \"收起\"\n  },\n  \"comments\": {\n    \"addReviewComment\": \"添加审查评论\",\n    \"addPlaceholder\": \"添加评论...\",\n    \"editPlaceholder\": \"编辑评论...\",\n    \"copyToReview\": \"复制到审查\"\n  },\n  \"confirm\": {\n    \"defaultConfirm\": \"确认\",\n    \"defaultCancel\": \"取消\"\n  },\n  \"dialogs\": {\n    \"selectGitRepository\": \"选择 Git 仓库\",\n    \"chooseExistingRepo\": \"从文件系统中选择现有仓库\"\n  },\n  \"empty\": {\n    \"noChanges\": \"没有要显示的更改\"\n  },\n  \"commandBar\": {\n    \"noResults\": \"未找到结果。\",\n    \"back\": \"返回\",\n    \"defaultPlaceholder\": \"输入命令或搜索...\",\n    \"selectBranch\": \"Select Branch\",\n    \"selectBranchFor\": \"Select Branch for {{repoName}}\"\n  },\n  \"workspacesGuide\": {\n    \"welcome\": {\n      \"title\": \"欢迎\",\n      \"content\": \"欢迎使用 Workspaces，这是 Vibe Kanban 的全新设计界面。我们正在向部分用户推出以获取早期反馈。您可以随时通过导航栏中的反馈图标分享您的想法。\"\n    },\n    \"commandBar\": {\n      \"title\": \"命令栏\",\n      \"content\": \"命令栏是您的导航中心。使用 CMD+K 打开它，可以搜索和访问工作区中的所有可用操作。\"\n    },\n    \"contextBar\": {\n      \"title\": \"上下文栏\",\n      \"content\": \"上下文栏让您可以快速切换不同面板。将它拖动到最适合您的位置。\"\n    },\n    \"sidebar\": {\n      \"title\": \"工作区侧边栏\",\n      \"content\": \"一目了然地查看所有工作区的状态。通知会突出显示需要关注的工作区。归档已合并的工作区以保持侧边栏整洁。\"\n    },\n    \"multiRepo\": {\n      \"title\": \"多仓库支持\",\n      \"content\": \"将多个仓库添加到单个工作区。在处理一个仓库时可以引用另一个仓库的代码，或者同时在多个仓库中实现更改。\"\n    },\n    \"sessions\": {\n      \"title\": \"多会话\",\n      \"content\": \"在单个工作区内创建多个代理对话会话，包括与不同代理的会话。这可以帮助您绕过对话限制，或在单独的线程中启动审查代理。\"\n    },\n    \"preview\": {\n      \"title\": \"预览更改\",\n      \"content\": \"无需切换上下文，即可在内置浏览器中预览您的工作。支持桌面、移动设备和自定义视口尺寸测试。\"\n    },\n    \"diffs\": {\n      \"title\": \"差异和评论\",\n      \"content\": \"重新设计的差异面板包含更改的文件树。直接在差异上评论以向代理提供反馈，当您的工作区链接到 PR 时还可以查看 GitHub 评论。\"\n    },\n    \"classicUi\": {\n      \"title\": \"返回经典界面\",\n      \"content\": \"点击导航栏左侧的退出图标可返回经典看板。要完全禁用新界面，请在设置中的「测试版功能」下更新「启用 Workspaces 测试版」选项。\"\n    }\n  },\n  \"logs\": {\n    \"searchLogs\": \"搜索日志\",\n    \"selectProcessToView\": \"选择一个进程以查看日志\"\n  },\n  \"processes\": {\n    \"noProcesses\": \"无进程\",\n    \"terminal\": \"终端\"\n  },\n  \"search\": {\n    \"matchCount\": \"{{current}} / {{total}}\",\n    \"noMatches\": \"无匹配项\"\n  },\n  \"contextUsage\": {\n    \"label\": \"上下文使用量\",\n    \"emptyTooltip\": \"上下文使用量将在下一次回复后显示\",\n    \"tooltip\": \"上下文：{{percentage}}% · {{used}} / {{total}} tokens\",\n    \"ariaLabel\": \"上下文使用量：{{percentage}}%\"\n  },\n  \"shortcuts\": {\n    \"title\": \"键盘快捷键\",\n    \"inWorkspace\": \"(在工作区中)\",\n    \"sequentialHint\": \"组合快捷键：先按第一个键，然后在500毫秒内按第二个键。\",\n    \"configurableHint\": \"可在 设置 → 常规 → 消息输入 中配置\",\n    \"groups\": {\n      \"quickActions\": \"快捷操作\",\n      \"navigation\": \"导航\",\n      \"modifiers\": \"修饰键\",\n      \"goTo\": \"前往 (G ...)\",\n      \"workspace\": \"工作区 (W ...)\",\n      \"view\": \"视图 (V ...)\",\n      \"issues\": \"问题 (I ...)\",\n      \"git\": \"Git (X ...)\",\n      \"yank\": \"复制 (Y ...)\",\n      \"toggle\": \"切换 (T ...)\",\n      \"run\": \"运行 (R ...)\"\n    },\n    \"actions\": {\n      \"showHelp\": \"显示帮助\",\n      \"closeCancel\": \"关闭/取消\",\n      \"createNewTask\": \"创建新任务\",\n      \"deleteSelected\": \"删除所选\",\n      \"focusSearch\": \"聚焦搜索\",\n      \"moveDown\": \"向下移动\",\n      \"moveUp\": \"向上移动\",\n      \"moveLeft\": \"向左移动\",\n      \"moveRight\": \"向右移动\",\n      \"openCommandBar\": \"打开命令栏\",\n      \"formatInlineCode\": \"Format inline code\",\n      \"sendMessage\": \"发送消息\",\n      \"settings\": \"前往设置\",\n      \"new-workspace\": \"前往新工作区\",\n      \"duplicate-workspace\": \"复制工作区\",\n      \"rename-workspace\": \"重命名工作区\",\n      \"pin-workspace\": \"置顶/取消置顶工作区\",\n      \"archive-workspace\": \"归档工作区\",\n      \"delete-workspace\": \"删除工作区\",\n      \"toggle-changes-mode\": \"切换更改面板\",\n      \"toggle-logs-mode\": \"切换日志面板\",\n      \"toggle-preview-mode\": \"切换预览面板\",\n      \"toggle-left-sidebar\": \"切换左侧边栏\",\n      \"toggle-left-main-panel\": \"切换聊天面板\",\n      \"create-issue\": \"创建问题\",\n      \"change-issue-status\": \"更改状态\",\n      \"change-issue-priority\": \"更改优先级\",\n      \"change-assignees\": \"更改负责人\",\n      \"make-sub-issue-of\": \"设为子问题\",\n      \"add-sub-issue\": \"添加子问题\",\n      \"remove-parent-issue\": \"移除父级\",\n      \"link-workspace\": \"关联工作区\",\n      \"duplicate-issue\": \"复制问题\",\n      \"delete-issue\": \"删除问题\",\n      \"git-create-pr\": \"创建 Pull Request\",\n      \"git-merge\": \"合并分支\",\n      \"git-rebase\": \"变基分支\",\n      \"git-push\": \"推送更改\",\n      \"copy-path\": \"复制路径\",\n      \"copy-raw-logs\": \"复制原始日志\",\n      \"toggle-dev-server\": \"切换开发服务器\",\n      \"toggle-wrap-lines\": \"切换自动换行\",\n      \"run-setup-script\": \"运行设置脚本\",\n      \"run-cleanup-script\": \"运行清理脚本\",\n      \"run-archive-script\": \"运行归档脚本\"\n    }\n  },\n  \"typeahead\": {\n    \"tags\": \"标签\",\n    \"files\": \"文件\",\n    \"commands\": \"命令\",\n    \"chooseRepo\": \"选择仓库\",\n    \"selectedRepo\": \"已选择仓库：{{repoName}}\",\n    \"missingRepo\": \"所选仓库已不可用。\",\n    \"noTagsOrFiles\": \"未找到标签或文件\",\n    \"createTag\": \"创建新标签\",\n    \"noCommands\": \"此代理没有可用的命令。\"\n  },\n  \"kanban\": {\n    \"noVisibleStatuses\": \"所有状态已隐藏。请使用显示设置或切换到其他标签页查看问题。\",\n    \"noProjectFound\": \"未找到项目\",\n    \"unassigned\": \"未分配\",\n    \"noTagsAvailable\": \"无可用标签\",\n    \"createNewIssue\": \"创建新问题\",\n    \"searchTags\": \"搜索标签...\",\n    \"selectColorFor\": \"选择颜色：\",\n    \"createTag\": \"创建\",\n    \"noPrCreated\": \"未创建 PR\",\n    \"noCommentsYet\": \"暂无评论\",\n    \"createdBy\": \"创建者\",\n    \"comments\": \"评论\",\n    \"enterCommentPlaceholder\": \"在此输入评论...\",\n    \"attachFile\": \"附加文件\",\n    \"attachFileHint\": \"附加文件（每个文件最大 20MB）\",\n    \"dropFilesHere\": \"将文件拖放到这里\",\n    \"fileDropHint\": \"任意文件类型，最大 20MB\",\n    \"unknownUser\": \"未知用户\",\n    \"deletedUser\": \"已删除用户\",\n    \"replyQuotePrefix\": \"写道：\",\n    \"moreActions\": \"更多操作\",\n    \"closePanel\": \"关闭面板\",\n    \"copyLink\": \"复制链接\",\n    \"issueTitlePlaceholder\": \"问题标题...\",\n    \"issueDescriptionPlaceholder\": \"输入任务描述...\",\n    \"createDraftWorkspaceImmediately\": \"立即创建草稿工作区\",\n    \"createDraftWorkspaceDescription\": \"创建议题后，打开预填了议题详情的工作区创建表单\",\n    \"createIssue\": \"创建任务\",\n    \"newIssue\": \"新问题\",\n    \"previewCodeBlock\": \"[代码块]\",\n    \"previewImage\": \"[图片]\",\n    \"previewImageWithName\": \"[图片: {{name}}]\",\n    \"previewFile\": \"[文件]\",\n    \"previewFileWithName\": \"[文件: {{name}}]\",\n    \"imageAttachmentNameFallback\": \"附件\",\n    \"removeImage\": \"移除图片\",\n    \"maxFilesAtOnce\": \"一次最多上传 {{count}} 个文件\",\n    \"fileExceedsLimit\": \"文件 {{filename}} 超过 20MB 限制\",\n    \"unknownError\": \"未知错误\",\n    \"failedToUploadFile\": \"上传 {{filename}} 失败：{{message}}\",\n    \"downloadAttachment\": \"下载附件\",\n    \"subIssues\": \"子任务\",\n    \"noSubIssues\": \"无子任务\",\n    \"markIndependentIssue\": \"标记为独立问题\",\n    \"parentIssue\": \"父级\",\n    \"subIssueIndicator\": \"↳\",\n    \"viewTabs\": {\n      \"active\": \"活动\",\n      \"all\": \"全部\"\n    },\n    \"loginRequired\": {\n      \"title\": \"需要登录\",\n      \"description\": \"请登录以查看此项目。\",\n      \"action\": \"登录\"\n    },\n    \"team\": \"Team\",\n    \"personal\": \"Personal\",\n    \"searchPlaceholder\": \"Search issues...\",\n    \"filters\": \"Filters\",\n    \"clearFilters\": \"Clear filters\",\n    \"filtersDescription\": \"Adjust filters and sorting for this board view.\",\n    \"filterByTag\": \"Filter by tag\",\n    \"filterByPriority\": \"Filter by priority\",\n    \"tags\": \"Tags\",\n    \"sortBy\": \"Sort\",\n    \"workspaceSidebar\": {\n      \"sortDialogTitle\": \"Sort workspaces\",\n      \"sortDialogDescription\": \"Choose how workspaces are ordered in the sidebar.\",\n      \"sortByLabel\": \"Sort by\",\n      \"sortOrderLabel\": \"Sort order\",\n      \"sortUpdatedAt\": \"Updated at\",\n      \"sortCreatedAt\": \"Created at\",\n      \"filterDialogTitle\": \"Filter workspaces\",\n      \"filterDialogDescription\": \"Narrow down workspaces by project and PR state.\",\n      \"projectFilterLabel\": \"Project\",\n      \"prFilterLabel\": \"PR\",\n      \"prFilterAll\": \"All\",\n      \"prFilterHasPr\": \"Has PR\",\n      \"prFilterNoPr\": \"No PR\",\n      \"noProject\": \"No project\",\n      \"sortButtonTitle\": \"Sort workspaces\",\n      \"filterButtonTitle\": \"Filter workspaces\"\n    },\n    \"sortAscending\": \"Ascending\",\n    \"sortDescending\": \"Descending\",\n    \"assignee\": \"Assignee\",\n    \"self\": \"Me\",\n    \"priority\": \"Priority\",\n    \"subIssuesFilterLabel\": \"Sub-issues\",\n    \"workspacesFilterLabel\": \"Workspaces\",\n    \"selectAssignees\": \"Select assignees...\",\n    \"relationships\": \"Relationships\",\n    \"noRelationships\": \"No relationships\",\n    \"openProjectsGuide\": \"Projects guide\",\n    \"editProjectSettings\": \"Edit project settings\",\n    \"linkWorkspace\": \"Link workspace...\",\n    \"createNewWorkspace\": \"Create new workspace\",\n    \"workspaces\": \"Workspaces\",\n    \"showingWorkspaces\": \"Showing {{count}} of {{total}}\",\n    \"changeColor\": \"Change color\",\n    \"editName\": \"Edit name\",\n    \"deleteStatus\": \"Delete status\",\n    \"cannotDeleteWithIssues\": \"Move issues first\",\n    \"lastVisibleStatus\": \"At least one status must be visible\",\n    \"showStatus\": \"Show status\",\n    \"hideStatus\": \"Hide status\",\n    \"newStatus\": \"New Status\",\n    \"visibleColumns\": \"Visible Columns\",\n    \"dragToRearrange\": \"Drag to re-arrange\",\n    \"addColumn\": \"Add column\",\n    \"projectsGuide\": {\n      \"intro\": {\n        \"title\": \"Welcome\",\n        \"content\": \"Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.\"\n      },\n      \"welcome\": {\n        \"title\": \"Projects\",\n        \"content\": \"The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.\"\n      },\n      \"issues\": {\n        \"title\": \"Issues\",\n        \"content\": \"Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.\"\n      },\n      \"workspaces\": {\n        \"title\": \"Workspaces\",\n        \"content\": \"To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.\"\n      },\n      \"organizations\": {\n        \"title\": \"Invite your team\",\n        \"content\": \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      }\n    }\n  },\n  \"createMode\": {\n    \"headings\": {\n      \"repoStep\": \"你想在哪些仓库上工作？\",\n      \"chatStep\": \"你想做什么工作？\"\n    },\n    \"repoPicker\": {\n      \"actions\": {\n        \"recent\": \"最近\",\n        \"browse\": \"浏览\",\n        \"create\": \"创建\"\n      },\n      \"setupHintTitle\": \"设置脚本\",\n      \"setupHint\": \"提示：在设置 → 仓库中为此仓库配置设置脚本\",\n      \"setupHintDismiss\": \"关闭\",\n      \"setupHintLink\": \"Configure in Settings\"\n    }\n  },\n  \"modelSelector\": {\n    \"preset\": \"预设\",\n    \"custom\": \"自定义\",\n    \"permissions\": \"权限\",\n    \"permissionAuto\": \"自动\",\n    \"permissionAsk\": \"询问\",\n    \"permissionPlan\": \"计划\",\n    \"agent\": \"代理\",\n    \"default\": \"默认\",\n    \"model\": \"模型\"\n  },\n  \"syncError\": {\n    \"networkErrors\": \"网络错误\",\n    \"streamsAffected\": \"{{count}} 个数据流受影响\",\n    \"streamsAffected_other\": \"{{count}} 个数据流受影响\",\n    \"status\": \"(状态 {{status}})\",\n    \"refreshPage\": \"刷新页面\"\n  },\n  \"onboardingSignIn\": {\n    \"subtitle\": \"Sign in to continue\",\n    \"signedInAs\": \"Signed in as {{name}}.\",\n    \"moreOptions\": \"More options\",\n    \"featureHeader\": \"Feature\",\n    \"signedInHeader\": \"Signed in\",\n    \"skipSignInHeader\": \"Don't sign in\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\"\n  },\n  \"migration\": {\n    \"allProjectsMigrated\": \"您的所有项目已迁移到云端。\",\n    \"continueToProjects\": \"继续前往项目\"\n  },\n  \"appBar\": {\n    \"kanban\": {\n      \"tooltip\": \"看板\",\n      \"title\": \"看板\",\n      \"description\": \"登录以使用看板管理您的编码代理。\",\n      \"migrateOldProjects\": \"迁移旧项目\"\n    }\n  },\n  \"errors\": {\n    \"generic\": \"An unexpected error occurred\"\n  },\n  \"personal\": \"Personal\",\n  \"askQuestion\": {\n    \"title\": \"代理正在提问\",\n    \"selectMultiple\": \"多选\",\n    \"confirmSelection\": \"确认选择\",\n    \"submitting\": \"正在提交回答...\",\n    \"answeredCount\": \"已回答{{count}}个问题\",\n    \"answeredCount_other\": \"已回答{{count}}个问题\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hans/organization.json",
    "content": "{\n  \"createDialog\": {\n    \"title\": \"创建新组织\",\n    \"description\": \"创建新组织以与您的团队协作。\",\n    \"nameLabel\": \"组织名称\",\n    \"namePlaceholder\": \"例如：Acme 公司\",\n    \"slugLabel\": \"短标识\",\n    \"slugPlaceholder\": \"例如：acme-corporation\",\n    \"slugHelper\": \"用于 URL。仅限小写字母、数字和连字符。\",\n    \"creating\": \"创建中...\",\n    \"createButton\": \"创建组织\"\n  },\n  \"inviteDialog\": {\n    \"title\": \"邀请成员\",\n    \"description\": \"发送邀请加入您的组织。\",\n    \"emailLabel\": \"电子邮件地址\",\n    \"emailPlaceholder\": \"colleague@example.com\",\n    \"roleLabel\": \"角色\",\n    \"rolePlaceholder\": \"选择角色\",\n    \"roleHelper\": \"管理员可以管理成员和组织设置。\",\n    \"sending\": \"发送中...\",\n    \"sendButton\": \"发送邀请\",\n    \"subscriptionRequired\": \"添加更多成员需要订阅\",\n    \"upgradePrompt\": \"升级您的组织计划以邀请更多成员。\",\n    \"upgradeButton\": \"升级计划\"\n  },\n  \"roles\": {\n    \"member\": \"成员\",\n    \"admin\": \"管理员\"\n  },\n  \"memberList\": {\n    \"title\": \"成员\",\n    \"description\": \"管理 {{orgName}} 中的成员及其角色\",\n    \"inviteButton\": \"邀请成员\",\n    \"loading\": \"加载成员中...\",\n    \"none\": \"未找到成员\",\n    \"you\": \"您\"\n  },\n  \"invitationList\": {\n    \"title\": \"待处理邀请\",\n    \"description\": \"查看 {{orgName}} 的待处理邀请\",\n    \"loading\": \"加载邀请中...\",\n    \"invited\": \"邀请于 {{date}}\",\n    \"pending\": \"待处理\"\n  },\n  \"settings\": {\n    \"title\": \"组织设置\",\n    \"description\": \"管理组织成员和权限\",\n    \"selectLabel\": \"选择组织\",\n    \"selectPlaceholder\": \"选择组织\",\n    \"selectHelper\": \"选择要查看和管理其成员的组织\",\n    \"noOrganizations\": \"没有可用的组织\",\n    \"loadingOrganizations\": \"加载组织中...\",\n    \"loadError\": \"加载组织失败\",\n    \"dangerZone\": \"危险区域\",\n    \"dangerZoneDescription\": \"不可逆和破坏性操作\",\n    \"deleteOrganization\": \"删除组织\",\n    \"deleteOrganizationDescription\": \"永久删除此组织及其所有数据\",\n    \"confirmDelete\": \"您确定要删除 {{orgName}} 吗？此操作无法撤消。\",\n    \"deleteSuccess\": \"组织删除成功\",\n    \"deleteError\": \"删除组织失败\"\n  },\n  \"loginRequired\": {\n    \"title\": \"需要登录\",\n    \"description\": \"您需要登录才能管理组织设置。\",\n    \"action\": \"登录\"\n  },\n  \"confirmRemoveMember\": \"您确定要从组织中移除此成员吗？\",\n  \"billing\": {\n    \"title\": \"账单与订阅\",\n    \"description\": \"管理您组织的订阅和账单设置\",\n    \"manageButton\": \"管理账单\",\n    \"openInBrowser\": \"在新浏览器标签页中打开\"\n  },\n  \"personalOrg\": {\n    \"cannotInvite\": \"个人组织不能添加其他成员。\",\n    \"createOrgPrompt\": \"创建组织以与他人协作。\",\n    \"createOrgButton\": \"创建组织\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hans/projects.json",
    "content": "{\n  \"createProjectDialog\": {\n    \"title\": \"Create Project\",\n    \"description\": \"Create a new project in this organization.\",\n    \"nameLabel\": \"Project name\",\n    \"namePlaceholder\": \"Enter project name\",\n    \"selectColor\": \"Select project color\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Project\"\n  },\n  \"deleteProjectDialog\": {\n    \"title\": \"Delete Project?\",\n    \"description\": \"This will permanently delete \\\"{{name}}\\\" and all its issues. This action cannot be undone.\",\n    \"error\": \"Failed to delete project. Please try again.\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hans/settings.json",
    "content": "{\n  \"settings\": {\n    \"common\": {\n      \"unsavedChanges\": \"• 您有未保存的更改\",\n      \"discardChangesConfirm\": \"Discard unsaved changes?\"\n    },\n    \"unsavedChanges\": {\n      \"title\": \"未保存的更改\",\n      \"message\": \"您有未保存的更改。确定要关闭而不保存吗？\",\n      \"discard\": \"放弃更改\",\n      \"cancel\": \"继续编辑\"\n    },\n    \"layout\": {\n      \"nav\": {\n        \"title\": \"设置\",\n        \"general\": \"常规\",\n        \"generalDesc\": \"主题、通知和偏好设置\",\n        \"projects\": \"项目\",\n        \"projectsDesc\": \"项目仓库和配置\",\n        \"repos\": \"仓库\",\n        \"reposDesc\": \"仓库脚本和配置\",\n        \"agents\": \"代理\",\n        \"agentsDesc\": \"编码代理配置\",\n        \"mcp\": \"MCP 服务器\",\n        \"mcpDesc\": \"模型上下文协议服务器\",\n        \"organizations\": \"组织设置\",\n        \"organizationsDesc\": \"管理组织成员和权限\",\n        \"remote-projects\": \"项目\",\n        \"remote-projectsDesc\": \"管理项目\",\n        \"relay\": \"Remote Access\",\n        \"relayDesc\": \"Relay tunnel and enrollment\"\n      }\n    },\n    \"general\": {\n      \"loading\": \"加载设置中...\",\n      \"loadError\": \"加载配置失败。\",\n      \"save\": {\n        \"button\": \"保存设置\",\n        \"success\": \"✓ 设置保存成功！\",\n        \"error\": \"保存配置失败\",\n        \"unsavedChanges\": \"• 您有未保存的更改\",\n        \"discard\": \"放弃\"\n      },\n      \"appearance\": {\n        \"title\": \"外观\",\n        \"description\": \"自定义应用程序的外观和感觉。\",\n        \"theme\": {\n          \"label\": \"主题\",\n          \"placeholder\": \"选择主题\",\n          \"helper\": \"选择您喜欢的配色方案。\"\n        },\n        \"language\": {\n          \"label\": \"语言\",\n          \"placeholder\": \"选择语言\",\n          \"helper\": \"选择您喜欢的语言。浏览器默认将跟随您的系统语言。\"\n        }\n      },\n      \"taskExecution\": {\n        \"title\": \"默认编码代理\",\n        \"description\": \"选择任务的默认编码代理。\",\n        \"executor\": {\n          \"label\": \"默认代理配置\",\n          \"placeholder\": \"选择配置文件\",\n          \"helper\": \"选择创建任务尝试时使用的默认代理配置。\"\n        },\n        \"variant\": \"默认\",\n        \"defaultLabel\": \"默认\"\n      },\n      \"editor\": {\n        \"title\": \"编辑器\",\n        \"description\": \"配置您的代码编辑体验。\",\n        \"type\": {\n          \"label\": \"编辑器类型\",\n          \"placeholder\": \"选择编辑器\",\n          \"helper\": \"选择您喜欢的代码编辑器界面。\"\n        },\n        \"customCommand\": {\n          \"label\": \"自定义编辑器命令\",\n          \"placeholder\": \"例如：code、subl、vim\",\n          \"helper\": \"输入启动自定义编辑器的命令。这将用于打开文件。\"\n        },\n        \"remoteSsh\": {\n          \"host\": {\n            \"label\": \"远程 SSH 主机（可选）\",\n            \"placeholder\": \"例如：主机名或 IP 地址\",\n            \"helper\": \"如果 Vibe Kanban 在远程服务器上运行，请设置此项。设置后，点击在编辑器中打开将生成 URL 以通过 SSH 打开编辑器，而不是执行本地命令。\"\n          },\n          \"user\": {\n            \"label\": \"远程 SSH 用户（可选）\",\n            \"placeholder\": \"例如：用户名\",\n            \"helper\": \"远程连接的 SSH 用户名。如果未设置，VS Code 将使用您的 SSH 配置或提示您输入。\"\n          }\n        },\n        \"autoInstallExtension\": {\n          \"label\": \"Auto-install VS Code Extension\",\n          \"helper\": \"Automatically install the Vibe Kanban extension when opening a project in VS Code or Cursor. This runs in the background and has no effect if the extension is already installed.\"\n        },\n        \"availability\": {\n          \"checking\": \"检查可用性...\",\n          \"available\": \"可用\",\n          \"notFound\": \"在 PATH 中未找到\"\n        }\n      },\n      \"github\": {\n        \"title\": \"GitHub 集成\",\n        \"cliSetup\": {\n          \"title\": \"GitHub CLI 设置\",\n          \"description\": \"需要 GitHub CLI 身份验证才能创建拉取请求并与 GitHub 仓库交互。\",\n          \"setupWillTitle\": \"此设置将：\",\n          \"steps\": {\n            \"checkInstalled\": \"检查是否安装了 GitHub CLI (gh)\",\n            \"installHomebrew\": \"如果需要，通过 Homebrew 安装它（macOS）\",\n            \"authenticate\": \"使用 OAuth 进行 GitHub 身份验证\"\n          },\n          \"setupNote\": \"设置将在聊天窗口中运行。您需要在浏览器中完成身份验证。\",\n          \"runSetup\": \"运行设置\",\n          \"running\": \"运行中...\",\n          \"errors\": {\n            \"brewMissing\": \"未安装 Homebrew。安装它以启用自动设置。\",\n            \"notSupported\": \"此平台不支持自动设置。请手动安装 GitHub CLI。\",\n            \"setupFailed\": \"运行 GitHub CLI 设置失败。\"\n          },\n          \"help\": {\n            \"homebrew\": {\n              \"description\": \"自动安装需要 Homebrew。从\",\n              \"brewSh\": \"brew.sh\",\n              \"manualInstall\": \"安装 Homebrew，然后重新运行设置。或者，使用以下命令手动安装 GitHub CLI：\",\n              \"afterInstall\": \"安装后，使用以下命令进行身份验证：\"\n            },\n            \"manual\": {\n              \"description\": \"从\",\n              \"officialDocs\": \"官方文档\",\n              \"andAuthenticate\": \"安装 GitHub CLI，然后使用您的 GitHub 账户进行身份验证。\"\n            }\n          }\n        }\n      },\n      \"git\": {\n        \"title\": \"Git\",\n        \"description\": \"配置 git 分支命名偏好\",\n        \"branchPrefix\": {\n          \"label\": \"分支前缀\",\n          \"placeholder\": \"vk\",\n          \"helper\": \"自动生成的分支名称的前缀。留空表示无前缀。\",\n          \"preview\": \"预览：\",\n          \"previewWithPrefix\": \"{{prefix}}/1a2b-task-name\",\n          \"previewNoPrefix\": \"1a2b-task-name\",\n          \"errors\": {\n            \"slash\": \"前缀不能包含 '/'。\",\n            \"startsWithDot\": \"前缀不能以 '.' 开头。\",\n            \"endsWithDot\": \"前缀不能以 '.' 或 '.lock' 结尾。\",\n            \"invalidSequence\": \"包含无效序列（..、@{）。\",\n            \"invalidChars\": \"包含无效字符。\",\n            \"controlChars\": \"包含控制字符。\"\n          }\n        },\n        \"workspaceDir\": {\n          \"label\": \"工作区目录\",\n          \"placeholder\": \"~/\",\n          \"helper\": \"工作区将在此路径内的 .vibe-kanban-workspaces 子目录中创建。留空以使用系统默认值。更改需要重启应用程序。\",\n          \"browse\": \"浏览\",\n          \"dialogTitle\": \"选择工作区目录\",\n          \"dialogDescription\": \"选择一个目录。工作区将在其中的 .vibe-kanban-workspaces 子目录中创建。\"\n        }\n      },\n      \"pullRequests\": {\n        \"title\": \"拉取请求\",\n        \"description\": \"配置PR创建行为\",\n        \"autoDescription\": {\n          \"label\": \"默认自动生成PR描述\",\n          \"helper\": \"启用后，AI代理将在创建PR后自动更新标题和描述。\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"使用自定义提示\",\n          \"helper\": \"生成PR描述时AI代理使用的自定义提示。使用{pr_number}和{pr_url}作为占位符。\"\n        }\n      },\n      \"commits\": {\n        \"title\": \"提交\",\n        \"description\": \"配置任务尝试的提交行为\",\n        \"reminder\": {\n          \"label\": \"提交提醒\",\n          \"helper\": \"提示支持的代理在停止前提交更改。\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"使用自定义提示\",\n          \"helper\": \"提交提醒的自定义提示。git 状态将自动追加。\"\n        }\n      },\n      \"notifications\": {\n        \"title\": \"通知\",\n        \"description\": \"控制何时以及如何接收通知。\",\n        \"sound\": {\n          \"label\": \"声音通知\",\n          \"helper\": \"任务尝试完成运行时播放声音。\",\n          \"fileLabel\": \"声音\",\n          \"filePlaceholder\": \"选择声音\",\n          \"fileHelper\": \"选择任务完成时播放的声音。点击音量按钮预览。\"\n        },\n        \"push\": {\n          \"label\": \"推送通知\",\n          \"helper\": \"任务尝试完成运行时显示系统通知。\"\n        }\n      },\n      \"messageInput\": {\n        \"title\": \"消息输入\",\n        \"description\": \"配置在聊天输入中发送消息的方式。\",\n        \"shortcut\": {\n          \"label\": \"发送消息快捷键\",\n          \"helper\": \"选择发送消息的键盘快捷键。\",\n          \"enterLabel\": \"Enter（Shift+Enter 换行）\"\n        }\n      },\n      \"privacy\": {\n        \"title\": \"隐私\",\n        \"description\": \"通过共享匿名使用数据帮助改进 Vibe-Kanban。\",\n        \"telemetry\": {\n          \"label\": \"启用遥测\",\n          \"helper\": \"启用匿名使用事件跟踪以帮助改进应用程序。不会收集提示或项目信息。\"\n        }\n      },\n      \"taskTemplates\": {\n        \"title\": \"标签\",\n        \"description\": \"创建可使用 @tag_name 插入到任务描述中的可重用文本片段。\"\n      },\n      \"tags\": {\n        \"manager\": {\n          \"title\": \"任务标签\",\n          \"addTag\": \"添加标签\",\n          \"noTags\": \"还没有标签。为常见任务描述创建可重用的文本片段。在任何任务中使用 @tag_name。\",\n          \"table\": {\n            \"tagName\": \"标签名称\",\n            \"content\": \"内容\",\n            \"actions\": \"操作\"\n          },\n          \"actions\": {\n            \"editTag\": \"编辑标签\",\n            \"deleteTag\": \"删除标签\"\n          },\n          \"deleteConfirm\": \"您确定要删除标签 {{tagName}} 吗？\"\n        },\n        \"dialog\": {\n          \"createTitle\": \"创建标签\",\n          \"editTitle\": \"编辑标签\",\n          \"tagName\": {\n            \"label\": \"标签名称\",\n            \"required\": \"*\",\n            \"hint\": \"在任务描述中使用 @ 加此名称：@{{tagName}}\",\n            \"placeholder\": \"例如：bug_fix、test_plan、api_docs\",\n            \"error\": \"标签名称不能包含空格。请使用下划线代替（例如：my_tag）\"\n          },\n          \"content\": {\n            \"label\": \"内容\",\n            \"required\": \"*\",\n            \"hint\": \"当您在任务描述中使用 @{{tagName}} 时将插入的文本\",\n            \"placeholder\": \"输入使用此标签时将插入的文本\"\n          },\n          \"errors\": {\n            \"nameRequired\": \"标签名称是必需的\",\n            \"saveFailed\": \"保存标签失败\"\n          },\n          \"buttons\": {\n            \"cancel\": \"取消\",\n            \"create\": \"创建\",\n            \"update\": \"更新\"\n          }\n        }\n      },\n      \"safety\": {\n        \"title\": \"安全和免责声明\",\n        \"description\": \"重置安全警告和入门流程的确认。\",\n        \"disclaimer\": {\n          \"title\": \"免责声明确认\",\n          \"description\": \"重置安全免责声明。\",\n          \"button\": \"重置\"\n        },\n        \"onboarding\": {\n          \"title\": \"入门流程\",\n          \"description\": \"重置入门流程。\",\n          \"button\": \"重置\"\n        }\n      },\n      \"beta\": {\n        \"title\": \"Beta 功能\",\n        \"description\": \"在正式发布前试用实验性功能。\",\n        \"workspaces\": {\n          \"label\": \"启用工作区 Beta\",\n          \"helper\": \"查看任务尝试时使用新的工作区界面。任务将首先在任务视图中打开，尝试将在新的工作区视图中打开。\"\n        }\n      }\n    },\n    \"agents\": {\n      \"title\": \"编码代理配置\",\n      \"description\": \"使用不同的配置自定义编码代理的行为。\",\n      \"loading\": \"加载代理配置中...\",\n      \"selectAgent\": \"选择代理\",\n      \"save\": {\n        \"button\": \"保存代理配置\",\n        \"success\": \"✓ 执行器配置保存成功！\",\n        \"unsavedChanges\": \"• 您有未保存的更改\"\n      },\n      \"availability\": {\n        \"checking\": \"检查中...\",\n        \"checkingAvailability\": \"正在检查可用性...\",\n        \"available\": \"代理可用\",\n        \"notFoundSimple\": \"未找到代理\",\n        \"loginDetected\": \"检测到最近使用\",\n        \"loginDetectedTooltip\": \"找到此代理的最近身份验证凭据\",\n        \"installationFound\": \"检测到以前使用\",\n        \"installationFoundTooltip\": \"找到代理配置。您可能需要登录才能使用它。\"\n      },\n      \"editor\": {\n        \"formLabel\": \"编辑 JSON\",\n        \"agentLabel\": \"代理\",\n        \"agentPlaceholder\": \"选择执行器类型\",\n        \"configLabel\": \"配置\",\n        \"configPlaceholder\": \"选择配置\",\n        \"createNew\": \"新建...\",\n        \"deleteTitle\": \"无法删除最后一个配置\",\n        \"deleteButton\": \"删除 {{name}}\",\n        \"deleteText\": \"删除\",\n        \"makeDefault\": \"设为默认\",\n        \"isDefault\": \"默认\",\n        \"jsonLabel\": \"代理配置（JSON）\",\n        \"jsonPlaceholder\": \"加载配置文件中...\",\n        \"jsonLoading\": \"加载中...\",\n        \"pathLabel\": \"配置文件位置：\"\n      },\n      \"errors\": {\n        \"deleteFailed\": \"删除配置失败。请重试。\",\n        \"saveFailed\": \"保存代理配置失败。请重试。\",\n        \"saveConfigFailed\": \"保存配置失败。请重试。\",\n        \"schemaNotFound\": \"未找到执行器类型 {{executor}} 的架构\"\n      },\n      \"tree\": {\n        \"search\": \"搜索配置...\",\n        \"expandAll\": \"全部展开\",\n        \"collapseAll\": \"全部折叠\",\n        \"noResults\": \"没有匹配的配置\",\n        \"noConfigs\": \"没有可用的配置\",\n        \"selectConfig\": \"从侧边栏选择一个配置来查看和编辑其设置。\"\n      },\n      \"deleteConfigDialog\": {\n        \"title\": \"删除配置？\",\n        \"description\": \"这将从 {{executorType}} 执行器中永久删除\\\"{{configName}}\\\"。此操作无法撤销。\"\n      }\n    },\n    \"mcp\": {\n      \"title\": \"MCP 服务器配置\",\n      \"description\": \"配置模型上下文协议服务器，使用自定义工具和资源扩展编码代理功能。\",\n      \"loading\": \"加载 MCP 配置中...\",\n      \"applying\": \"应用配置中...\",\n      \"labels\": {\n        \"agent\": \"代理\",\n        \"agentPlaceholder\": \"选择执行器\",\n        \"agentHelper\": \"选择要为其配置 MCP 服务器的代理。\",\n        \"serverConfig\": \"服务器配置（JSON）\",\n        \"popularServers\": \"热门服务器\",\n        \"serverHelper\": \"点击卡片将该 MCP 服务器插入到上面的 JSON 中。\",\n        \"saveLocation\": \"更改将保存到：\"\n      },\n      \"loadingStates\": {\n        \"jsonEditor\": \"加载中...\",\n        \"configuration\": \"加载当前 MCP 服务器配置...\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"加载配置失败。\",\n        \"invalidJson\": \"无效的 JSON 格式\",\n        \"validationError\": \"验证错误\",\n        \"saveFailed\": \"保存 MCP 服务器失败\",\n        \"applyFailed\": \"应用 MCP 服务器配置失败\",\n        \"addServerFailed\": \"添加预配置服务器失败\",\n        \"mcpError\": \"MCP 配置错误：{{error}}\",\n        \"notSupported\": \"不支持 MCP\",\n        \"supportMessage\": \"要使用 MCP 服务器，请在上方选择支持 MCP 的其他执行器（Claude、Amp、Gemini、Codex 或 Opencode）。\"\n      },\n      \"save\": {\n        \"button\": \"保存 MCP 配置\",\n        \"success\": \"设置已保存！\",\n        \"successMessage\": \"✓ MCP 配置保存成功！\",\n        \"loading\": \"加载当前 MCP 服务器配置...\",\n        \"unsavedChanges\": \"• 您有未保存的更改\"\n      }\n    },\n    \"projects\": {\n      \"title\": \"项目配置\",\n      \"description\": \"配置特定于项目的脚本和设置。\",\n      \"loading\": \"加载项目中...\",\n      \"loadError\": \"加载项目失败。\",\n      \"selector\": {\n        \"label\": \"选择项目\",\n        \"placeholder\": \"选择要配置的项目\",\n        \"helper\": \"选择项目以查看和编辑其配置。\",\n        \"noProjects\": \"没有可用的项目\"\n      },\n      \"general\": {\n        \"title\": \"常规设置\",\n        \"description\": \"配置基本项目信息。\",\n        \"name\": {\n          \"label\": \"项目名称\",\n          \"placeholder\": \"输入项目名称\",\n          \"helper\": \"此项目的显示名称。\"\n        },\n        \"repoPath\": {\n          \"label\": \"Git 仓库路径\",\n          \"placeholder\": \"/path/to/your/existing/repo\",\n          \"helper\": \"磁盘上 git 仓库的绝对路径。\"\n        }\n      },\n      \"save\": {\n        \"button\": \"保存项目设置\",\n        \"success\": \"✓ 项目设置保存成功！\",\n        \"error\": \"保存项目设置失败\",\n        \"unsavedChanges\": \"• 您有未保存的更改\",\n        \"discard\": \"放弃\",\n        \"confirmSwitch\": \"您有未保存的更改。您确定要切换项目吗？您的更改将丢失。\",\n        \"saving\": \"Saving...\"\n      },\n      \"repositories\": {\n        \"title\": \"仓库\",\n        \"description\": \"管理此项目中的 Git 仓库\",\n        \"noRepositories\": \"未配置仓库\",\n        \"addRepository\": \"添加仓库\"\n      }\n    },\n    \"repos\": {\n      \"title\": \"仓库配置\",\n      \"description\": \"配置此仓库在工作区中使用时运行的脚本。\",\n      \"loading\": \"加载仓库中...\",\n      \"loadError\": \"加载仓库失败。\",\n      \"addRepo\": {\n        \"button\": \"添加仓库\",\n        \"dialogTitle\": \"选择 Git 仓库\",\n        \"dialogDescription\": \"选择一个现有的 git 仓库进行注册。\",\n        \"error\": \"注册仓库失败\"\n      },\n      \"selector\": {\n        \"label\": \"选择仓库\",\n        \"placeholder\": \"选择要配置的仓库\",\n        \"helper\": \"选择仓库以查看和编辑其配置。\",\n        \"noRepos\": \"没有可用的仓库\"\n      },\n      \"general\": {\n        \"title\": \"常规设置\",\n        \"description\": \"配置仓库基本信息。\",\n        \"displayName\": {\n          \"label\": \"显示名称\",\n          \"placeholder\": \"输入显示名称\",\n          \"helper\": \"此仓库的友好名称。\"\n        },\n        \"path\": {\n          \"label\": \"仓库路径\"\n        },\n        \"defaultWorkingDir\": {\n          \"label\": \"默认工作目录\",\n          \"placeholder\": \"例如：packages/frontend\",\n          \"helper\": \"单仓库工作区中编码代理运行的子目录。在创建工作区时设置。留空则使用仓库根目录。\"\n        },\n        \"defaultTargetBranch\": {\n          \"label\": \"默认目标分支\",\n          \"placeholder\": \"选择分支\",\n          \"helper\": \"新工作区的默认基础分支。工作树从此分支创建，PR 将以此分支为目标。\",\n          \"search\": \"搜索分支\",\n          \"noBranches\": \"未找到分支\",\n          \"loading\": \"正在加载分支...\",\n          \"useCurrent\": \"使用当前分支\"\n        }\n      },\n      \"scripts\": {\n        \"title\": \"脚本和配置\",\n        \"description\": \"配置此仓库的设置脚本、清理脚本和要复制的文件。这些脚本在仓库用于任何工作区时都会运行。\",\n        \"setup\": {\n          \"label\": \"设置脚本\",\n          \"helper\": \"此脚本从工作树内部运行，在创建后、编码代理启动前执行。用于设置任务，如安装依赖项或准备环境。\",\n          \"parallelLabel\": \"与编码代理并行运行设置脚本\",\n          \"parallelHelper\": \"启用后，设置脚本将与编码代理同时运行，而不是等待设置完成后再启动。\"\n        },\n        \"cleanup\": {\n          \"label\": \"清理脚本\",\n          \"helper\": \"此脚本从工作树内部运行，在编码代理执行后执行（仅在进行了更改时）。用于质量保证任务，如运行 linter、格式化程序、测试或其他验证步骤。\"\n        },\n        \"archive\": {\n          \"label\": \"归档脚本\",\n          \"helper\": \"当工作区被归档时，此脚本从工作树内部运行。用于清理任务，如停止服务、释放资源或保存状态。\"\n        },\n        \"copyFiles\": {\n          \"label\": \"复制文件\",\n          \"helper\": \"要从原始仓库目录复制到工作树的文件的逗号分隔列表。对 .env 等环境文件很有用。确保这些文件被 gitignore！\",\n          \"placeholder\": \"文件路径或 glob 模式（例如：.env、config/*.json）\"\n        },\n        \"devServer\": {\n          \"label\": \"开发服务器脚本\",\n          \"helper\": \"为此仓库启动开发服务器。脚本从仓库的工作树目录执行。\"\n        }\n      },\n      \"linkedProjects\": {\n        \"title\": \"关联项目\",\n        \"description\": \"在默认工作区配置中使用此仓库的项目。\",\n        \"loading\": \"正在检查项目…\",\n        \"none\": \"没有关联的项目\"\n      },\n      \"remove\": {\n        \"title\": \"移除仓库\",\n        \"description\": \"从 Vibe Kanban 取消关联此仓库。磁盘上的文件不会被删除。\",\n        \"button\": \"移除\",\n        \"confirm\": \"移除仓库\",\n        \"dialogTitle\": \"移除「{{name}}」？\",\n        \"dialogDescription\": \"这将从 Vibe Kanban 取消关联此仓库。您磁盘上的文件不受影响。您可以稍后重新添加。\",\n        \"success\": \"仓库移除成功。\",\n        \"error\": \"移除仓库失败。\"\n      },\n      \"save\": {\n        \"button\": \"保存仓库设置\",\n        \"success\": \"仓库设置保存成功！\",\n        \"error\": \"保存仓库设置失败\",\n        \"unsavedChanges\": \"您有未保存的更改\",\n        \"discard\": \"放弃\",\n        \"confirmSwitch\": \"您有未保存的更改。您确定要切换仓库吗？您的更改将丢失。\"\n      }\n    },\n    \"relay\": {\n      \"title\": \"Remote Access\",\n      \"description\": \"Allow access to this instance from the web via relay tunnel.\",\n      \"enabled\": {\n        \"label\": \"Enable remote access\",\n        \"helper\": \"When enabled, this instance can be accessed remotely through a relay tunnel.\"\n      },\n      \"enrollmentCode\": {\n        \"label\": \"Pairing Code\",\n        \"helper\": \"Enter this code in the browser to pair with this instance.\",\n        \"loginRequired\": \"Log in to generate a pairing code.\",\n        \"fetchError\": \"Failed to fetch pairing code.\",\n        \"show\": \"Show pairing code\"\n      }\n    },\n    \"remoteProjects\": {\n      \"title\": \"Remote Projects\",\n      \"description\": \"Manage cloud-synced projects across organizations.\",\n      \"loading\": \"Loading remote projects...\",\n      \"loadError\": \"Failed to load organizations\",\n      \"noProjects\": \"No projects yet. Create one to get started.\",\n      \"selectOrg\": \"Select an organization\",\n      \"createSuccess\": \"Project created successfully\",\n      \"deleteSuccess\": \"Project deleted successfully\",\n      \"nameRequired\": \"Project name is required\",\n      \"saveSuccess\": \"Project updated successfully\",\n      \"saveError\": \"Failed to update project\",\n      \"columns\": {\n        \"organizations\": \"Organizations\",\n        \"projects\": \"Projects\"\n      },\n      \"actions\": {\n        \"addProject\": \"Add Project\"\n      },\n      \"form\": {\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\"\n        },\n        \"color\": {\n          \"label\": \"Project Color\"\n        },\n        \"statuses\": {\n          \"label\": \"Project Statuses\",\n          \"description\": \"Manage kanban columns for this project.\"\n        },\n        \"defaultRepos\": {\n          \"label\": \"Default Repositories\",\n          \"description\": \"Repositories that are automatically added when creating workspaces from this project's issues.\",\n          \"empty\": \"No default repositories configured\",\n          \"addButton\": \"Add\",\n          \"addNew\": \"Add new repository...\",\n          \"branchLabel\": \"Branch\",\n          \"noBranches\": \"No branches found for this repository\",\n          \"fetchError\": \"Could not load repositories\",\n          \"saveError\": \"Failed to save default repositories\"\n        }\n      },\n      \"loginRequired\": {\n        \"title\": \"Sign in required\",\n        \"description\": \"Sign in to manage your remote projects.\",\n        \"action\": \"Sign in\"\n      }\n    }\n  },\n  \"integrations\": {\n    \"github\": {\n      \"cliSetup\": {\n        \"title\": \"GitHub CLI 设置\",\n        \"description\": \"需要 GitHub CLI 身份验证才能创建拉取请求并与 GitHub 仓库交互。\",\n        \"setupWillTitle\": \"此设置将：\",\n        \"steps\": {\n          \"checkInstalled\": \"检查是否安装了 GitHub CLI (gh)\",\n          \"installHomebrew\": \"如果需要，通过 Homebrew 安装它（macOS）\",\n          \"authenticate\": \"使用 OAuth 进行 GitHub 身份验证\"\n        },\n        \"setupNote\": \"设置将在聊天窗口中运行。您需要在浏览器中完成身份验证。\",\n        \"runSetup\": \"运行设置\",\n        \"running\": \"运行中...\",\n        \"errors\": {\n          \"brewMissing\": \"未安装 Homebrew。安装它以启用自动设置。\",\n          \"notSupported\": \"此平台不支持自动设置。请手动安装 GitHub CLI。\",\n          \"setupFailed\": \"运行 GitHub CLI 设置失败。\"\n        },\n        \"help\": {\n          \"homebrew\": {\n            \"description\": \"自动安装需要 Homebrew。从\",\n            \"brewSh\": \"brew.sh\",\n            \"manualInstall\": \"安装 Homebrew，然后重新运行设置。或者，使用以下命令手动安装 GitHub CLI：\",\n            \"afterInstall\": \"安装后，使用以下命令进行身份验证：\"\n          },\n          \"manual\": {\n            \"description\": \"从\",\n            \"officialDocs\": \"官方文档\",\n            \"andAuthenticate\": \"安装 GitHub CLI，然后使用您的 GitHub 账户进行身份验证。\"\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hans/tasks.json",
    "content": "{\n  \"dropzone\": {\n    \"dropImagesHere\": \"将图片拖放到这里\",\n    \"supportedFormats\": \"支持 PNG、JPG、GIF、WebP、SVG 格式\"\n  },\n  \"gitPanel\": {\n    \"advanced\": {\n      \"placeholder\": \"例如：Acme Corp\"\n    }\n  },\n  \"loading\": \"加载任务中...\",\n  \"actions\": {\n    \"addTask\": \"添加任务\"\n  },\n  \"rebase\": {\n    \"common\": {\n      \"action\": \"变基\",\n      \"inProgress\": \"变基中...\"\n    },\n    \"dialog\": {\n      \"title\": \"变基任务尝试\",\n      \"description\": \"选择一个新的基础分支以将此任务尝试变基到其上。\",\n      \"upstreamLabel\": \"上游分支\",\n      \"upstreamPlaceholder\": \"选择上游分支\",\n      \"targetLabel\": \"目标分支\",\n      \"targetPlaceholder\": \"选择目标分支\",\n      \"advanced\": \"高级\"\n    }\n  },\n  \"branches\": {\n    \"changeTarget\": {\n      \"dialog\": {\n        \"title\": \"更改目标分支\",\n        \"description\": \"为任务尝试选择新的目标分支。\",\n        \"placeholder\": \"选择目标分支\",\n        \"action\": \"更改分支\",\n        \"inProgress\": \"更改中...\"\n      }\n    }\n  },\n  \"repos\": {\n    \"selector\": {\n      \"placeholder\": \"选择仓库\",\n      \"empty\": \"没有可用的仓库\"\n    }\n  },\n  \"repoBranchSelector\": {\n    \"label\": \"基础分支\"\n  },\n  \"preview\": {\n    \"noServer\": {\n      \"title\": \"没有运行开发服务器\",\n      \"setupTitle\": \"必须设置开发服务器脚本才能使用预览功能\",\n      \"setupPrompt\": \"要使用实时预览和点击编辑，请为此项目添加开发服务器脚本。\",\n      \"editDevScript\": \"编辑开发服务器脚本\",\n      \"learnMore\": \"了解更多关于测试应用程序的信息\"\n    },\n    \"logs\": {\n      \"label\": \"日志\",\n      \"viewFull\": \"查看完整日志\"\n    },\n    \"browser\": {\n      \"title\": \"开发服务器预览\",\n      \"devServerFallback\": \"开发服务器\"\n    },\n    \"toolbar\": {\n      \"refresh\": \"刷新预览\",\n      \"copyUrl\": \"复制 URL\",\n      \"openInTab\": \"在新标签页中打开\",\n      \"stopDevServer\": \"停止开发服务器\",\n      \"resetUrl\": \"重置为检测到的 URL\",\n      \"clearUrlOverride\": \"清除 URL 覆盖\",\n      \"desktopView\": \"桌面视图\",\n      \"mobileView\": \"移动视图 (390x844)\",\n      \"responsiveView\": \"响应式视图（可调整大小）\",\n      \"startDevServer\": \"启动开发服务器\",\n      \"submitUrl\": \"提交 URL\",\n      \"toggleDevTools\": \"切换 DevTools\"\n    },\n    \"loading\": {\n      \"startingServer\": \"正在启动开发服务器...\",\n      \"waitingForServer\": \"正在等待服务器...\",\n      \"loadingPreview\": \"正在加载预览...\",\n      \"manualUrlHint\": \"尚未检测到 URL。您可以在上方工具栏中手动输入 URL。\"\n    }\n  },\n  \"diff\": {\n    \"filesChanged_one\": \"{{count}} 个文件已更改\",\n    \"filesChanged_other\": \"{{count}} 个文件已更改\",\n    \"largeDiff\": {\n      \"title\": \"大文件\",\n      \"linesChanged\": \"{{count}} 行已更改\",\n      \"loadAnyway\": \"仍然加载差异\",\n      \"warning\": \"大型差异可能会降低浏览器速度。\"\n    }\n  },\n  \"processes\": {\n    \"noLogsAvailable\": \"没有可用的日志\",\n    \"selectAttempt\": \"选择尝试以查看执行进程。\",\n    \"errorLoadingUpdates\": \"加载进程的实时更新失败。\",\n    \"reconnecting\": \"重新连接中...\",\n    \"loading\": \"加载执行进程中...\",\n    \"noProcesses\": \"未找到此尝试的执行进程。\",\n    \"processId\": \"进程 ID：{{id}}\",\n    \"deleted\": \"已删除\",\n    \"deletedTooltip\": \"因恢复而删除：时间轴已恢复到检查点，后续执行已被移除\",\n    \"agent\": \"代理：\",\n    \"exit\": \"退出：{{code}}\",\n    \"started\": \"开始：{{date}}\",\n    \"completed\": \"完成：{{date}}\",\n    \"detailsTitle\": \"进程详情\",\n    \"backToList\": \"返回列表\",\n    \"loadingDetails\": \"加载进程详情中...\",\n    \"errorLoadingDetails\": \"加载进程详情失败。请重试。\",\n    \"copyLogs\": \"复制日志\",\n    \"logsCopied\": \"已复制！\"\n  },\n  \"followUp\": {\n    \"queuedMessage\": \"消息已排队 - 将在当前运行完成时执行\"\n  },\n  \"todoPopup\": {\n    \"title\": \"任务\",\n    \"progress\": \"{{completed}}/{{total}} 已完成\",\n    \"noTasks\": \"暂无任务\"\n  },\n  \"attempt\": {\n    \"actions\": {\n      \"startDevServer\": \"启动开发服务器\"\n    }\n  },\n  \"git\": {\n    \"labels\": {\n      \"taskBranch\": \"任务分支\"\n    },\n    \"branch\": {\n      \"current\": \"当前\"\n    },\n    \"forcePushDialog\": {\n      \"title\": \"需要强制推送\",\n      \"description\": \"远程分支{{branchLabel}}已与您的本地分支分离。常规推送被拒绝。\",\n      \"warning\": \"强制推送将用您的本地更改覆盖远程更改。此操作无法撤消。\",\n      \"note\": \"仅当您确定要替换远程分支历史记录时才继续。\",\n      \"error\": \"强制推送失败\"\n    },\n    \"status\": {\n      \"commits_one\": \"提交\",\n      \"commits_other\": \"提交\",\n      \"conflicts\": \"冲突\",\n      \"upToDate\": \"最新\",\n      \"ahead\": \"领先\",\n      \"behind\": \"落后\"\n    },\n    \"states\": {\n      \"merged\": \"已合并！\",\n      \"merging\": \"合并中...\",\n      \"merge\": \"合并\",\n      \"rebasing\": \"变基中...\",\n      \"rebase\": \"变基\",\n      \"pushed\": \"已推送！\",\n      \"pushing\": \"推送中...\",\n      \"push\": \"推送\",\n      \"pushFailed\": \"失败\",\n      \"forcePush\": \"强制推送\",\n      \"forcePushing\": \"强制推送中...\",\n      \"creating\": \"创建中...\",\n      \"createPr\": \"创建 PR\"\n    },\n    \"errors\": {\n      \"changeTargetBranch\": \"更改目标分支失败\",\n      \"pushChanges\": \"推送更改失败\",\n      \"mergeChanges\": \"合并更改失败\",\n      \"rebaseBranch\": \"变基分支失败\",\n      \"branchStatusUnavailable\": \"无法获取分支状态。您仍然可以更改目标分支。\"\n    },\n    \"pr\": {\n      \"open\": \"打开 PR #{{number}}\",\n      \"number\": \"PR #{{number}}\",\n      \"merged\": \"已合并PR #{{prNumber}}\"\n    },\n    \"actions\": {\n      \"title\": \"Git 操作\",\n      \"changeTarget\": \"更改目标\",\n      \"prMerged\": \"PR #{{number}} 已合并\",\n      \"loginRequired\": {\n        \"title\": \"登录以管理 git 操作\",\n        \"description\": \"登录 Vibe Kanban，以便您可以推送分支、合并更改或为此任务打开拉取请求。\",\n        \"action\": \"登录\"\n      }\n    },\n    \"mergeDialog\": {\n      \"title\": \"合并更改\",\n      \"description\": \"这将把您的更改合并到目标分支。您确定要继续吗？\"\n    },\n    \"createRepo\": {\n      \"dialog\": {\n        \"title\": \"创建新仓库\",\n        \"description\": \"初始化新的git仓库\"\n      },\n      \"form\": {\n        \"nameLabel\": \"名称\",\n        \"namePlaceholder\": \"my-project\",\n        \"locationLabel\": \"位置\",\n        \"locationPlaceholder\": \"当前目录\"\n      },\n      \"browseDialog\": {\n        \"title\": \"选择父目录\",\n        \"description\": \"选择创建新仓库的位置\"\n      },\n      \"errors\": {\n        \"nameRequired\": \"仓库名称是必填的\",\n        \"createFailed\": \"创建仓库失败\"\n      },\n      \"buttons\": {\n        \"createRepository\": \"创建仓库\"\n      },\n      \"states\": {\n        \"creating\": \"创建中...\"\n      }\n    }\n  },\n  \"viewProcessesDialog\": {\n    \"title\": \"执行进程\"\n  },\n  \"editBranchName\": {\n    \"dialog\": {\n      \"title\": \"编辑分支名称\",\n      \"description\": \"输入分支的新名称。如果存在未关闭的 PR，则无法重命名。\",\n      \"branchNameLabel\": \"分支名称\",\n      \"placeholder\": \"例如：feature/my-branch\",\n      \"renaming\": \"重命名中...\",\n      \"action\": \"重命名分支\"\n    }\n  },\n  \"startReviewDialog\": {\n    \"title\": \"开始审查\",\n    \"description\": \"请求编码代理审查您的更改并提供反馈。\",\n    \"additionalInstructions\": \"附加说明（可选）\",\n    \"reviewComments\": \"审查评论（{{count}}）\",\n    \"includeGitContext\": \"包含 Git 上下文\",\n    \"includeGitContextDescription\": \"告诉代理如何查看此分支上的所有更改\",\n    \"newSession\": \"新会话\",\n    \"sessionName\": \"审查\"\n  },\n  \"actionsMenu\": {\n    \"startReview\": \"开始审查\",\n    \"startingReview\": \"正在开始审查...\"\n  },\n  \"resolveConflicts\": {\n    \"dialog\": {\n      \"title\": \"解决冲突\",\n      \"description\": \"检测到冲突。选择您希望代理如何解决它们。\",\n      \"newSession\": \"新会话\",\n      \"resolve\": \"解决冲突\",\n      \"resolving\": \"开始中...\",\n      \"filesWithConflicts_one\": \"{{count}} 个文件有冲突\",\n      \"filesWithConflicts_other\": \"{{count}} 个文件有冲突\",\n      \"andMore\": \"...还有 {{count}} 个\",\n      \"sessionName\": \"解决冲突\"\n    }\n  },\n  \"rebaseInProgress\": {\n    \"dialog\": {\n      \"title\": \"变基进行中\",\n      \"description\": \"正在无冲突地变基到 {{targetBranch}}。请选择如何继续。\",\n      \"hint\": \"您可以继续变基以完成操作，或中止以返回之前的状态。\",\n      \"continue\": \"继续变基\",\n      \"continuing\": \"继续中...\",\n      \"abort\": \"中止变基\",\n      \"aborting\": \"中止中...\",\n      \"continueError\": \"继续变基失败。可能存在未解决的冲突。\",\n      \"abortError\": \"中止变基失败。请重试。\"\n    }\n  },\n  \"createPrDialog\": {\n    \"title\": \"创建拉取请求\",\n    \"description\": \"为此任务尝试创建拉取请求。\",\n    \"titleLabel\": \"标题\",\n    \"titlePlaceholder\": \"输入 PR 标题\",\n    \"descriptionLabel\": \"描述（可选）\",\n    \"descriptionPlaceholder\": \"输入 PR 描述\",\n    \"baseBranchLabel\": \"基础分支\",\n    \"loadingBranches\": \"加载分支中...\",\n    \"selectBaseBranch\": \"选择基础分支\",\n    \"draftLabel\": \"创建为草稿\",\n    \"autoGenerateLabel\": \"请求AI代理生成更好的PR描述\",\n    \"creating\": \"创建中...\",\n    \"createButton\": \"创建 PR\",\n    \"errors\": {\n      \"failedToCreate\": \"创建 PR 失败\",\n      \"gitCliNotLoggedIn\": \"Git 未通过身份验证。运行 gh auth login（或配置 Git 凭据）然后重试。\",\n      \"gitCliNotInstalled\": \"未安装 Git CLI。安装 Git 以创建 PR。\",\n      \"targetBranchNotFound\": \"远程上不存在目标分支 {{branch}}。请在创建拉取请求之前确保该分支存在。\"\n    }\n  },\n  \"prComments\": {\n    \"dialog\": {\n      \"title\": \"选择 PR 评论\",\n      \"noComments\": \"此 PR 未找到评论\",\n      \"selectAll\": \"全选\",\n      \"deselectAll\": \"取消全选\",\n      \"add\": \"添加\",\n      \"selectedCount\": \"已选择 {{selected}} / {{total}}\"\n    },\n    \"card\": {\n      \"review\": \"审查\",\n      \"tooltip\": \"点击查看，双击编辑\"\n    }\n  },\n  \"taskFormDialog\": {\n    \"createTitle\": \"创建新任务\",\n    \"editTitle\": \"编辑任务\",\n    \"titlePlaceholder\": \"任务标题\",\n    \"descriptionPlaceholder\": \"添加更多详情（可选）。输入 @ 搜索文件。\",\n    \"statusLabel\": \"状态\",\n    \"statusOptions\": {\n      \"todo\": \"待办\",\n      \"inprogress\": \"进行中\",\n      \"inreview\": \"审查中\",\n      \"done\": \"完成\",\n      \"cancelled\": \"已取消\"\n    },\n    \"startLabel\": \"开始\",\n    \"attachFile\": \"附加文件\",\n    \"dropImagesHere\": \"在此处放置图片\",\n    \"updating\": \"更新中...\",\n    \"updateTask\": \"更新任务\",\n    \"starting\": \"开始中...\",\n    \"creating\": \"创建中...\",\n    \"create\": \"创建\",\n    \"discardDialog\": {\n      \"title\": \"放弃未保存的更改？\",\n      \"description\": \"您有未保存的更改。您确定要放弃它们吗？\",\n      \"continueEditing\": \"继续编辑\",\n      \"discardChanges\": \"放弃更改\"\n    }\n  },\n  \"restoreLogsDialog\": {\n    \"title\": \"确认重试\",\n    \"titleReset\": \"确认重置\",\n    \"historyChange\": {\n      \"title\": \"历史记录变更\",\n      \"willDelete\": \"将删除此进程\",\n      \"willDeleteProcesses_one\": \"将删除 {{count}} 个进程\",\n      \"willDeleteProcesses_other\": \"将删除 {{count}} 个进程\",\n      \"andLaterProcesses_one\": \"及后续 {{count}} 个进程\",\n      \"andLaterProcesses_other\": \"及后续 {{count}} 个进程\",\n      \"fromHistory\": \"从历史记录中删除。\",\n      \"codingAgentRuns_one\": \"{{count}} 个编程代理运行\",\n      \"codingAgentRuns_other\": \"{{count}} 个编程代理运行\",\n      \"scriptProcesses_one\": \"{{count}} 个脚本进程\",\n      \"scriptProcesses_other\": \"{{count}} 个脚本进程\",\n      \"setupCleanupBreakdown\": \"({{setup}} 个设置，{{cleanup}} 个清理)\",\n      \"permanentWarning\": \"此操作将永久更改历史记录，无法撤消。\"\n    },\n    \"uncommittedChanges\": {\n      \"title\": \"检测到未提交的更改\",\n      \"description_one\": \"您有 {{count}} 个未提交的更改\",\n      \"description_other\": \"您有 {{count}} 个未提交的更改\",\n      \"andUntracked_one\": \"和 {{count}} 个未跟踪的文件\",\n      \"andUntracked_other\": \"和 {{count}} 个未跟踪的文件\",\n      \"acknowledgeLabel\": \"我了解这些更改可能会受到影响\"\n    },\n    \"resetWorktree\": {\n      \"title\": \"重置工作树\",\n      \"enabled\": \"已启用\",\n      \"disabled\": \"已禁用\",\n      \"disabledUncommitted\": \"已禁用（检测到未提交的更改）\",\n      \"restoreDescription\": \"您的工作树将被恢复到此提交。\",\n      \"discardChanges_one\": \"丢弃 {{count}} 个未提交的更改。\",\n      \"discardChanges_other\": \"丢弃 {{count}} 个未提交的更改。\",\n      \"untrackedPresent_one\": \"存在 {{count}} 个未跟踪的文件（不受重置影响）。\",\n      \"untrackedPresent_other\": \"存在 {{count}} 个未跟踪的文件（不受重置影响）。\",\n      \"forceReset\": \"强制重置（丢弃未提交的更改）\",\n      \"uncommittedWillDiscard\": \"未提交的更改将被丢弃。\",\n      \"uncommittedPresentHint\": \"存在未提交的更改。请打开强制重置或提交/暂存后继续。\"\n    },\n    \"buttons\": {\n      \"retry\": \"重试\",\n      \"reset\": \"重置\"\n    }\n  },\n  \"conversation\": {\n    \"you\": \"你\",\n    \"thinking\": \"思考中\",\n    \"todo\": \"待办\",\n    \"todos\": \"待办事项\",\n    \"completed\": \"已完成\",\n    \"incomplete\": \"未完成\",\n    \"pending\": \"待处理\",\n    \"inProgress\": \"进行中\",\n    \"skipped\": \"已跳过\",\n    \"error\": \"错误\",\n    \"retry\": \"重试\",\n    \"showMore\": \"显示更多\",\n    \"showLess\": \"收起\",\n    \"actions\": {\n      \"cancel\": \"取消\",\n      \"submitFeedback\": \"提交反馈\",\n      \"stop\": \"停止\",\n      \"stopping\": \"停止中\",\n      \"loading\": \"加载中\",\n      \"send\": \"发送\",\n      \"sending\": \"发送中\",\n      \"queue\": \"加入队列\",\n      \"cancelQueue\": \"取消队列\",\n      \"requestChanges\": \"请求更改\",\n      \"approve\": \"批准\",\n      \"clearReviewComments\": \"清除审查评论\",\n      \"edit\": \"编辑消息\",\n      \"scrollToPreviousMessage\": \"Go to previous message\",\n      \"reset\": \"重置\",\n      \"resetTooltip\": \"重置到此时点\"\n    },\n    \"approval\": {\n      \"conflictWarning\": \"冲突文件需要手动解决\",\n      \"conflicts_one\": \"{{count}}个冲突\",\n      \"conflicts_other\": \"{{count}}个冲突\"\n    },\n    \"sessions\": {\n      \"newSession\": \"新会话\",\n      \"latest\": \"最新\",\n      \"previous\": \"上一个\",\n      \"label\": \"会话\",\n      \"noPreviousSessions\": \"没有以前的会话\",\n      \"rename\": \"重命名\",\n      \"renameTitle\": \"重命名会话\",\n      \"renameDescription\": \"输入此会话的新名称。\",\n      \"renamePlaceholder\": \"输入会话名称\",\n      \"renaming\": \"正在重命名...\"\n    },\n    \"reviewComments\": {\n      \"count_one\": \"{{count}} 条审查评论将被包含\",\n      \"count_other\": \"{{count}} 条审查评论将被包含\"\n    },\n    \"workspace\": {\n      \"create\": \"创建\",\n      \"creating\": \"创建中...\"\n    },\n    \"fileEntry\": {\n      \"created\": \"已创建\",\n      \"modified\": \"已修改\",\n      \"deleted\": \"已删除\",\n      \"renamed\": \"已重命名\"\n    },\n    \"script\": {\n      \"running\": \"运行中...\",\n      \"exitCode\": \"退出代码: {{code}}\",\n      \"completedSuccessfully\": \"成功完成\",\n      \"clickToViewLogs\": \"点击查看日志\"\n    },\n    \"scriptPlaceholder\": {\n      \"setupTitle\": \"设置脚本\",\n      \"setupDescription\": \"未配置设置脚本。设置脚本在编码代理启动前运行。\",\n      \"cleanupTitle\": \"清理脚本\",\n      \"cleanupDescription\": \"未配置清理脚本。清理脚本在编码代理完成后运行。\",\n      \"configure\": \"配置\"\n    },\n    \"executors\": \"执行器\",\n    \"saveAsDefault\": \"设为默认\",\n    \"updatedTodos\": \"更新的待办事项\",\n    \"viewInChangesPanel\": \"在更改面板中查看\",\n    \"unableToRenderDiff\": \"无法显示差异。\"\n  },\n  \"scriptFixer\": {\n    \"setupScriptTitle\": \"修复设置脚本\",\n    \"cleanupScriptTitle\": \"修复清理脚本\",\n    \"archiveScriptTitle\": \"修复归档脚本\",\n    \"devServerTitle\": \"修复开发服务器脚本\",\n    \"scriptLabel\": \"脚本（编辑）\",\n    \"logsLabel\": \"上次执行日志\",\n    \"saveButton\": \"保存\",\n    \"saveAndTestButton\": \"保存并测试\",\n    \"noLogs\": \"没有可用的执行日志\",\n    \"selectRepo\": \"仓库\",\n    \"fixScript\": \"修复脚本\",\n    \"statusRunning\": \"运行中...\",\n    \"statusSuccess\": \"成功完成\",\n    \"statusFailed\": \"失败，退出代码 {{exitCode}}\",\n    \"statusKilled\": \"进程已被终止\"\n  },\n  \"createWorkspaceFromPr\": {\n    \"title\": \"从 PR 创建工作区\",\n    \"description\": \"选择一个打开的拉取请求来创建工作区。将使用 PR 标题自动创建任务。\",\n    \"repositoryLabel\": \"仓库\",\n    \"remoteLabel\": \"远程仓库\",\n    \"pullRequestLabel\": \"拉取请求\",\n    \"loadingRepositories\": \"正在加载仓库...\",\n    \"loadingRemotes\": \"正在加载远程仓库...\",\n    \"noRepositoriesFound\": \"未找到仓库\",\n    \"loadingPullRequests\": \"正在加载拉取请求...\",\n    \"selectRepositoryFirst\": \"请先选择仓库\",\n    \"noPullRequestsFound\": \"未找到打开的拉取请求\",\n    \"runSetupScript\": \"运行设置脚本\",\n    \"creating\": \"创建中...\",\n    \"createWorkspace\": \"创建工作区\",\n    \"selectRepository\": \"选择仓库\",\n    \"selectRemote\": \"选择远程仓库\",\n    \"selectPullRequest\": \"选择拉取请求\",\n    \"searchPrsPlaceholder\": \"按编号或标题搜索 PR...\",\n    \"noMatchingPrs\": \"没有匹配的拉取请求\",\n    \"default\": \"默认\",\n    \"openPrInBrowser\": \"在浏览器中打开 PR\",\n    \"errors\": {\n      \"cliNotInstalled\": \"{{provider}} CLI 未安装\",\n      \"unsupportedProvider\": \"不支持的 Git 提供商\",\n      \"failedToLoadPrs\": \"加载拉取请求失败\",\n      \"prNotFound\": \"未找到拉取请求\",\n      \"failedToCreateWorkspace\": \"创建工作区失败\"\n    }\n  },\n  \"shareDialog\": {\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Sign in to access this feature.\",\n      \"action\": \"Sign in\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hant/common.json",
    "content": "{\n  \"editorNames\": {\n    \"custom\": \"自訂\"\n  },\n  \"buttons\": {\n    \"save\": \"儲存\",\n    \"cancel\": \"取消\",\n    \"send\": \"傳送\",\n    \"delete\": \"刪除\",\n    \"edit\": \"編輯\",\n    \"create\": \"建立\",\n    \"continue\": \"繼續\",\n    \"reset\": \"重設\",\n    \"manage\": \"管理\",\n    \"connect\": \"連線\",\n    \"disconnect\": \"中斷連線\",\n    \"close\": \"關閉\",\n    \"replay\": \"重播\",\n    \"discard\": \"放棄\",\n    \"addItem\": \"新增項目\",\n    \"reply\": \"回覆\",\n    \"retry\": \"Retry\",\n    \"add\": \"新增\"\n  },\n  \"form\": {\n    \"notSpecified\": \"未指定\",\n    \"selectOption\": \"選擇一個選項...\"\n  },\n  \"states\": {\n    \"loading\": \"載入中...\",\n    \"loadingHistory\": \"載入歷史紀錄\",\n    \"saving\": \"儲存中...\",\n    \"error\": \"錯誤\",\n    \"success\": \"成功\",\n    \"reconnecting\": \"重新連線中\"\n  },\n  \"language\": {\n    \"browserDefault\": \"瀏覽器預設\"\n  },\n  \"conversation\": {\n    \"plan\": \"計畫\",\n    \"output\": \"輸出\",\n    \"deniedByUser\": \"使用者拒絕了 {{toolName}}\",\n    \"tool\": \"工具\",\n    \"thinking\": \"思考中\",\n    \"toolSummary\": {\n      \"read\": \"讀取 {{path}}\",\n      \"searched\": \"搜尋 \\\"{{query}}\\\"\",\n      \"fetched\": \"取得 {{url}}\",\n      \"ranCommand\": \"執行命令\",\n      \"createdTask\": \"建立任務：{{description}}\",\n      \"todoOperation\": \"{{operation}} 待辦事項\"\n    },\n    \"subagent\": {\n      \"defaultType\": \"子代理\"\n    },\n    \"loadingEarlierMessages\": \"正在載入先前的訊息\"\n  },\n  \"folderPicker\": {\n    \"legend\": \"點擊資料夾名稱進行導覽 • 使用操作按鈕進行選擇\",\n    \"manualPathLabel\": \"手動輸入路徑:\",\n    \"go\": \"前往\",\n    \"searchLabel\": \"搜尋目前目錄:\",\n    \"selectCurrent\": \"選取目前\",\n    \"gitRepo\": \"Git 儲存庫\",\n    \"selectPath\": \"選取路徑\"\n  },\n  \"branchSelector\": {\n    \"placeholder\": \"選擇分支\",\n    \"searchPlaceholder\": \"搜尋分支...\",\n    \"empty\": \"找不到分支\",\n    \"badges\": {\n      \"current\": \"目前\",\n      \"remote\": \"遠端\"\n    },\n    \"currentDisabled\": \"無法選擇目前分支\"\n  },\n  \"orgSwitcher\": {\n    \"organizations\": \"組織\",\n    \"createOrganization\": \"建立組織\",\n    \"orgSettings\": \"組織設定\"\n  },\n  \"ok\": \"確定\",\n  \"error\": \"錯誤\",\n  \"signIn\": \"登入\",\n  \"signOut\": \"登出\",\n  \"oauth\": {\n    \"title\": \"登入 Vibe Kanban\",\n    \"description\": \"登入以加入組織並與團隊共享任務\",\n    \"continueWithGitHub\": \"使用 GitHub 繼續\",\n    \"continueWithGoogle\": \"使用 Google 繼續\",\n    \"waitingTitle\": \"完成身分驗證\",\n    \"waitingDescription\": \"已開啟彈出視窗進行驗證\",\n    \"waitingForAuth\": \"等待身分驗證...\",\n    \"popupInstructions\": \"如果彈出視窗未開啟，請檢查您的彈出視窗攔截器設定。\",\n    \"back\": \"返回\",\n    \"successTitle\": \"身分驗證成功！\",\n    \"welcomeBack\": \"歡迎回來，{{name}}\",\n    \"errorTitle\": \"身分驗證失敗\",\n    \"errorDescription\": \"驗證您的帳號時發生問題\",\n    \"tryAgain\": \"重試\"\n  },\n  \"toolbar\": {\n    \"sortBy\": \"排序方式\",\n    \"groupBy\": \"分組方式\"\n  },\n  \"sorting\": {\n    \"ascending\": \"升序\",\n    \"descending\": \"降序\"\n  },\n  \"grouping\": {\n    \"date\": \"日期\",\n    \"assignee\": \"負責人\",\n    \"label\": \"標籤\"\n  },\n  \"workspaces\": {\n    \"title\": \"工作區\",\n    \"searchPlaceholder\": \"搜尋...\",\n    \"active\": \"活躍\",\n    \"archived\": \"已封存\",\n    \"loading\": \"載入中...\",\n    \"notFound\": \"找不到工作區\",\n    \"selectToStart\": \"選擇一個工作區開始\",\n    \"draft\": \"草稿\",\n    \"viewArchive\": \"檢視封存\",\n    \"backToActive\": \"返回活躍\",\n    \"noArchived\": \"沒有已封存的工作區\",\n    \"noWorkspaces\": \"沒有工作區\",\n    \"newWorkspace\": \"新工作區\",\n    \"needsAttention\": \"需要關注\",\n    \"idle\": \"閒置\",\n    \"running\": \"執行中\",\n    \"pin\": \"釘選\",\n    \"unpin\": \"取消釘選\",\n    \"archive\": \"封存\",\n    \"more\": \"更多操作\",\n    \"rename\": {\n      \"title\": \"重新命名工作區\",\n      \"description\": \"輸入此工作區的新名稱。\",\n      \"nameLabel\": \"名稱\",\n      \"placeholder\": \"輸入工作區名稱\",\n      \"action\": \"重新命名\",\n      \"renaming\": \"正在重新命名...\"\n    },\n    \"unlinkFromIssue\": \"從問題取消關聯\",\n    \"deleteWorkspace\": \"刪除工作區\",\n    \"unlink\": \"取消關聯\",\n    \"delete\": \"刪除\",\n    \"unlinkConfirmMessage\": \"確定要將此工作區與問題取消關聯嗎？工作區仍將存在，但不再與此問題關聯。\",\n    \"deleteConfirmMessage\": \"確定要刪除此工作區嗎？這將取消與問題的關聯並刪除本地工作區。此操作無法復原。\",\n    \"unlinkError\": \"取消關聯工作區失敗\",\n    \"deleteError\": \"刪除工作區失敗\",\n    \"filesChanged\": \"{{count}} 個檔案\",\n    \"deleteDialog\": {\n      \"title\": \"刪除工作區\",\n      \"description\": \"確定要刪除此工作區嗎？此操作無法復原。\",\n      \"deleteBranchLabel\": \"刪除分支\",\n      \"cannotDeleteOpenPr\": \"PR 開啟時無法刪除分支\",\n      \"unlinkFromIssueLabel\": \"同時取消與議題的關聯\"\n    },\n    \"linkError\": \"Failed to link workspace\"\n  },\n  \"fileTree\": {\n    \"searchPlaceholder\": \"搜尋檔案...\",\n    \"noResults\": \"沒有匹配的檔案\",\n    \"title\": \"檔案\",\n    \"showGitHubComments\": \"顯示 GitHub 評論\",\n    \"hideGitHubComments\": \"隱藏 GitHub 評論\",\n    \"prevGitHubComment\": \"上一個有評論的檔案\",\n    \"nextGitHubComment\": \"下一個有評論的檔案\"\n  },\n  \"sections\": {\n    \"changes\": \"變更\",\n    \"repositories\": \"儲存庫\",\n    \"addRepositories\": \"新增儲存庫\",\n    \"project\": \"專案\",\n    \"processes\": \"程序\",\n    \"devServer\": \"開發伺服器\",\n    \"advanced\": \"進階\",\n    \"workingBranch\": \"工作分支\",\n    \"recent\": \"最近\",\n    \"other\": \"其他\",\n    \"devServerPreview\": \"開發伺服器預覽\",\n    \"terminal\": \"終端機\",\n    \"notes\": \"筆記\"\n  },\n  \"notes\": {\n    \"placeholder\": \"新增關於此工作區的筆記...\",\n    \"selectWorkspace\": \"選擇一個工作區以查看筆記\"\n  },\n  \"actions\": {\n    \"copyPath\": \"複製路徑\",\n    \"cancel\": \"取消\",\n    \"saveChanges\": \"儲存變更\",\n    \"copied\": \"已複製\",\n    \"collapse\": \"收起\"\n  },\n  \"comments\": {\n    \"addReviewComment\": \"新增審查評論\",\n    \"addPlaceholder\": \"新增評論...\",\n    \"editPlaceholder\": \"編輯評論...\",\n    \"copyToReview\": \"複製到審查\"\n  },\n  \"confirm\": {\n    \"defaultConfirm\": \"確認\",\n    \"defaultCancel\": \"取消\"\n  },\n  \"dialogs\": {\n    \"selectGitRepository\": \"選擇 Git 儲存庫\",\n    \"chooseExistingRepo\": \"從檔案系統中選擇現有儲存庫\"\n  },\n  \"empty\": {\n    \"noChanges\": \"沒有要顯示的變更\"\n  },\n  \"commandBar\": {\n    \"noResults\": \"找不到結果。\",\n    \"back\": \"返回\",\n    \"defaultPlaceholder\": \"輸入命令或搜尋...\",\n    \"selectBranch\": \"Select Branch\",\n    \"selectBranchFor\": \"Select Branch for {{repoName}}\"\n  },\n  \"workspacesGuide\": {\n    \"welcome\": {\n      \"title\": \"歡迎\",\n      \"content\": \"歡迎使用 Workspaces，這是 Vibe Kanban 的全新設計介面。我們正在向部分使用者推出以獲取早期回饋。您可以隨時透過導覽列中的回饋圖示分享您的想法。\"\n    },\n    \"commandBar\": {\n      \"title\": \"命令列\",\n      \"content\": \"命令列是您的導覽中心。使用 CMD+K 開啟它，可以搜尋和存取工作區中的所有可用操作。\"\n    },\n    \"contextBar\": {\n      \"title\": \"上下文列\",\n      \"content\": \"上下文列讓您可以快速切換不同面板。將它拖曳到最適合您的位置。\"\n    },\n    \"sidebar\": {\n      \"title\": \"工作區側邊欄\",\n      \"content\": \"一目了然地檢視所有工作區的狀態。通知會醒目提示需要關注的工作區。封存已合併的工作區以保持側邊欄整潔。\"\n    },\n    \"multiRepo\": {\n      \"title\": \"多儲存庫支援\",\n      \"content\": \"將多個儲存庫新增到單一工作區。在處理一個儲存庫時可以參考另一個儲存庫的程式碼，或者同時在多個儲存庫中實作變更。\"\n    },\n    \"sessions\": {\n      \"title\": \"多工作階段\",\n      \"content\": \"在單一工作區內建立多個代理對話工作階段，包括與不同代理的工作階段。這可以幫助您繞過對話限制，或在單獨的執行緒中啟動審查代理。\"\n    },\n    \"preview\": {\n      \"title\": \"預覽變更\",\n      \"content\": \"無需切換上下文，即可在內建瀏覽器中預覽您的工作。支援桌面、行動裝置和自訂視口尺寸測試。\"\n    },\n    \"diffs\": {\n      \"title\": \"差異和評論\",\n      \"content\": \"重新設計的差異面板包含變更的檔案樹。直接在差異上評論以向代理提供回饋，當您的工作區連結到 PR 時還可以檢視 GitHub 評論。\"\n    },\n    \"classicUi\": {\n      \"title\": \"返回經典介面\",\n      \"content\": \"點擊導覽列左側的退出圖示可返回經典看板。要完全停用新介面，請在設定中的「測試版功能」下更新「啟用 Workspaces 測試版」選項。\"\n    }\n  },\n  \"logs\": {\n    \"searchLogs\": \"搜尋日誌\",\n    \"selectProcessToView\": \"選擇一個程序以查看日誌\"\n  },\n  \"processes\": {\n    \"noProcesses\": \"無程序\",\n    \"terminal\": \"終端機\"\n  },\n  \"search\": {\n    \"matchCount\": \"{{current}} / {{total}}\",\n    \"noMatches\": \"無匹配項\"\n  },\n  \"contextUsage\": {\n    \"label\": \"上下文使用量\",\n    \"emptyTooltip\": \"上下文使用量將在下一次回覆後顯示\",\n    \"tooltip\": \"上下文：{{percentage}}% · {{used}} / {{total}} tokens\",\n    \"ariaLabel\": \"上下文使用量：{{percentage}}%\"\n  },\n  \"shortcuts\": {\n    \"title\": \"鍵盤快捷鍵\",\n    \"inWorkspace\": \"(在工作區中)\",\n    \"sequentialHint\": \"組合快捷鍵：先按第一個鍵，然後在500毫秒內按第二個鍵。\",\n    \"configurableHint\": \"可在 設定 → 一般 → 訊息輸入 中設定\",\n    \"groups\": {\n      \"quickActions\": \"快捷操作\",\n      \"navigation\": \"導覽\",\n      \"modifiers\": \"修飾鍵\",\n      \"goTo\": \"前往 (G ...)\",\n      \"workspace\": \"工作區 (W ...)\",\n      \"view\": \"檢視 (V ...)\",\n      \"issues\": \"問題 (I ...)\",\n      \"git\": \"Git (X ...)\",\n      \"yank\": \"複製 (Y ...)\",\n      \"toggle\": \"切換 (T ...)\",\n      \"run\": \"執行 (R ...)\"\n    },\n    \"actions\": {\n      \"showHelp\": \"顯示說明\",\n      \"closeCancel\": \"關閉/取消\",\n      \"createNewTask\": \"建立新任務\",\n      \"deleteSelected\": \"刪除所選\",\n      \"focusSearch\": \"聚焦搜尋\",\n      \"moveDown\": \"向下移動\",\n      \"moveUp\": \"向上移動\",\n      \"moveLeft\": \"向左移動\",\n      \"moveRight\": \"向右移動\",\n      \"openCommandBar\": \"開啟命令列\",\n      \"formatInlineCode\": \"Format inline code\",\n      \"sendMessage\": \"傳送訊息\",\n      \"settings\": \"前往設定\",\n      \"new-workspace\": \"前往新工作區\",\n      \"duplicate-workspace\": \"複製工作區\",\n      \"rename-workspace\": \"重新命名工作區\",\n      \"pin-workspace\": \"釘選/取消釘選工作區\",\n      \"archive-workspace\": \"封存工作區\",\n      \"delete-workspace\": \"刪除工作區\",\n      \"toggle-changes-mode\": \"切換變更面板\",\n      \"toggle-logs-mode\": \"切換日誌面板\",\n      \"toggle-preview-mode\": \"切換預覽面板\",\n      \"toggle-left-sidebar\": \"切換左側邊欄\",\n      \"toggle-left-main-panel\": \"切換聊天面板\",\n      \"create-issue\": \"建立問題\",\n      \"change-issue-status\": \"變更狀態\",\n      \"change-issue-priority\": \"變更優先順序\",\n      \"change-assignees\": \"變更負責人\",\n      \"make-sub-issue-of\": \"設為子問題\",\n      \"add-sub-issue\": \"新增子問題\",\n      \"remove-parent-issue\": \"移除父級\",\n      \"link-workspace\": \"關聯工作區\",\n      \"duplicate-issue\": \"複製問題\",\n      \"delete-issue\": \"刪除問題\",\n      \"git-create-pr\": \"建立 Pull Request\",\n      \"git-merge\": \"合併分支\",\n      \"git-rebase\": \"變基分支\",\n      \"git-push\": \"推送變更\",\n      \"copy-path\": \"複製路徑\",\n      \"copy-raw-logs\": \"複製原始日誌\",\n      \"toggle-dev-server\": \"切換開發伺服器\",\n      \"toggle-wrap-lines\": \"切換自動換行\",\n      \"run-setup-script\": \"執行設定腳本\",\n      \"run-cleanup-script\": \"執行清理腳本\",\n      \"run-archive-script\": \"執行封存腳本\"\n    }\n  },\n  \"typeahead\": {\n    \"tags\": \"標籤\",\n    \"files\": \"檔案\",\n    \"commands\": \"指令\",\n    \"chooseRepo\": \"選擇儲存庫\",\n    \"selectedRepo\": \"已選擇儲存庫：{{repoName}}\",\n    \"missingRepo\": \"所選儲存庫已無法使用。\",\n    \"noTagsOrFiles\": \"找不到標籤或檔案\",\n    \"createTag\": \"建立新標籤\",\n    \"noCommands\": \"此代理沒有可用的指令。\"\n  },\n  \"kanban\": {\n    \"noVisibleStatuses\": \"所有狀態已隱藏。請使用顯示設定或切換到其他標籤頁查看問題。\",\n    \"noProjectFound\": \"找不到專案\",\n    \"unassigned\": \"未指派\",\n    \"noTagsAvailable\": \"無可用標籤\",\n    \"createNewIssue\": \"建立新問題\",\n    \"searchTags\": \"搜尋標籤...\",\n    \"selectColorFor\": \"選擇顏色：\",\n    \"createTag\": \"建立\",\n    \"noPrCreated\": \"未建立 PR\",\n    \"noCommentsYet\": \"尚無評論\",\n    \"createdBy\": \"建立者\",\n    \"comments\": \"評論\",\n    \"enterCommentPlaceholder\": \"在此輸入評論...\",\n    \"attachFile\": \"附加檔案\",\n    \"attachFileHint\": \"附加檔案（每個檔案最大 20MB）\",\n    \"dropFilesHere\": \"將檔案拖放到這裡\",\n    \"fileDropHint\": \"任意檔案類型，最大 20MB\",\n    \"unknownUser\": \"未知使用者\",\n    \"deletedUser\": \"已刪除使用者\",\n    \"replyQuotePrefix\": \"寫道：\",\n    \"moreActions\": \"更多操作\",\n    \"closePanel\": \"關閉面板\",\n    \"copyLink\": \"複製連結\",\n    \"issueTitlePlaceholder\": \"問題標題...\",\n    \"issueDescriptionPlaceholder\": \"輸入任務描述...\",\n    \"createDraftWorkspaceImmediately\": \"立即建立草稿工作區\",\n    \"createDraftWorkspaceDescription\": \"建立議題後，開啟預填了議題詳情的工作區建立表單\",\n    \"createIssue\": \"建立任務\",\n    \"newIssue\": \"新問題\",\n    \"previewCodeBlock\": \"[程式碼區塊]\",\n    \"previewImage\": \"[圖片]\",\n    \"previewImageWithName\": \"[圖片: {{name}}]\",\n    \"previewFile\": \"[檔案]\",\n    \"previewFileWithName\": \"[檔案: {{name}}]\",\n    \"imageAttachmentNameFallback\": \"附件\",\n    \"removeImage\": \"移除圖片\",\n    \"maxFilesAtOnce\": \"一次最多上傳 {{count}} 個檔案\",\n    \"fileExceedsLimit\": \"檔案 {{filename}} 超過 20MB 限制\",\n    \"unknownError\": \"未知錯誤\",\n    \"failedToUploadFile\": \"上傳 {{filename}} 失敗：{{message}}\",\n    \"downloadAttachment\": \"下載附件\",\n    \"subIssues\": \"子任務\",\n    \"noSubIssues\": \"無子任務\",\n    \"markIndependentIssue\": \"標記為獨立問題\",\n    \"parentIssue\": \"父級\",\n    \"subIssueIndicator\": \"↳\",\n    \"viewTabs\": {\n      \"active\": \"活動\",\n      \"all\": \"全部\"\n    },\n    \"loginRequired\": {\n      \"title\": \"需要登入\",\n      \"description\": \"請登入以查看此專案。\",\n      \"action\": \"登入\"\n    },\n    \"team\": \"Team\",\n    \"personal\": \"Personal\",\n    \"searchPlaceholder\": \"Search issues...\",\n    \"filters\": \"Filters\",\n    \"clearFilters\": \"Clear filters\",\n    \"filtersDescription\": \"Adjust filters and sorting for this board view.\",\n    \"filterByTag\": \"Filter by tag\",\n    \"filterByPriority\": \"Filter by priority\",\n    \"tags\": \"Tags\",\n    \"sortBy\": \"Sort\",\n    \"workspaceSidebar\": {\n      \"sortDialogTitle\": \"Sort workspaces\",\n      \"sortDialogDescription\": \"Choose how workspaces are ordered in the sidebar.\",\n      \"sortByLabel\": \"Sort by\",\n      \"sortOrderLabel\": \"Sort order\",\n      \"sortUpdatedAt\": \"Updated at\",\n      \"sortCreatedAt\": \"Created at\",\n      \"filterDialogTitle\": \"Filter workspaces\",\n      \"filterDialogDescription\": \"Narrow down workspaces by project and PR state.\",\n      \"projectFilterLabel\": \"Project\",\n      \"prFilterLabel\": \"PR\",\n      \"prFilterAll\": \"All\",\n      \"prFilterHasPr\": \"Has PR\",\n      \"prFilterNoPr\": \"No PR\",\n      \"noProject\": \"No project\",\n      \"sortButtonTitle\": \"Sort workspaces\",\n      \"filterButtonTitle\": \"Filter workspaces\"\n    },\n    \"sortAscending\": \"Ascending\",\n    \"sortDescending\": \"Descending\",\n    \"assignee\": \"Assignee\",\n    \"self\": \"Me\",\n    \"priority\": \"Priority\",\n    \"subIssuesFilterLabel\": \"Sub-issues\",\n    \"workspacesFilterLabel\": \"Workspaces\",\n    \"selectAssignees\": \"Select assignees...\",\n    \"relationships\": \"Relationships\",\n    \"noRelationships\": \"No relationships\",\n    \"openProjectsGuide\": \"Projects guide\",\n    \"editProjectSettings\": \"Edit project settings\",\n    \"linkWorkspace\": \"Link workspace...\",\n    \"createNewWorkspace\": \"Create new workspace\",\n    \"workspaces\": \"Workspaces\",\n    \"showingWorkspaces\": \"Showing {{count}} of {{total}}\",\n    \"changeColor\": \"Change color\",\n    \"editName\": \"Edit name\",\n    \"deleteStatus\": \"Delete status\",\n    \"cannotDeleteWithIssues\": \"Move issues first\",\n    \"lastVisibleStatus\": \"At least one status must be visible\",\n    \"showStatus\": \"Show status\",\n    \"hideStatus\": \"Hide status\",\n    \"newStatus\": \"New Status\",\n    \"visibleColumns\": \"Visible Columns\",\n    \"dragToRearrange\": \"Drag to re-arrange\",\n    \"addColumn\": \"Add column\",\n    \"projectsGuide\": {\n      \"intro\": {\n        \"title\": \"Welcome\",\n        \"content\": \"Welcome to Vibe Kanban. Use the help sections in this panel to learn how things work, then create your first issue. You can re-open this help dialog or give feedback anytime from the navbar.\"\n      },\n      \"welcome\": {\n        \"title\": \"Projects\",\n        \"content\": \"The project page is where you manage issues. You can view your issues as a kanban board, or as a list, and filter by status, tag, assignee and more.\"\n      },\n      \"issues\": {\n        \"title\": \"Issues\",\n        \"content\": \"Each issue represents a feature or problem to solve. Issues have statuses, priorities, assignees, tags, relationships, comments, sub-issues and more.\"\n      },\n      \"workspaces\": {\n        \"title\": \"Workspaces\",\n        \"content\": \"To start working on an issue, create a workspace. A single issue can have multiple workspaces. Issues describe the work to be done, workspaces are where the work happens.\"\n      },\n      \"organizations\": {\n        \"title\": \"Invite your team\",\n        \"content\": \"We've automatically created a personal organization, and initial project so you can easily get started with Vibe Kanban. You can also create new organizations and invite your team to collaborate.\"\n      }\n    }\n  },\n  \"createMode\": {\n    \"headings\": {\n      \"repoStep\": \"你想在哪些儲存庫上工作？\",\n      \"chatStep\": \"你想做什麼工作？\"\n    },\n    \"repoPicker\": {\n      \"actions\": {\n        \"recent\": \"最近\",\n        \"browse\": \"瀏覽\",\n        \"create\": \"建立\"\n      },\n      \"setupHintTitle\": \"設定腳本\",\n      \"setupHint\": \"提示：在設定 → 儲存庫中為此儲存庫設定安裝腳本\",\n      \"setupHintDismiss\": \"關閉\",\n      \"setupHintLink\": \"Configure in Settings\"\n    }\n  },\n  \"modelSelector\": {\n    \"preset\": \"預設配置\",\n    \"custom\": \"自訂\",\n    \"permissions\": \"權限\",\n    \"permissionAuto\": \"自動\",\n    \"permissionAsk\": \"詢問\",\n    \"permissionPlan\": \"計畫\",\n    \"agent\": \"代理\",\n    \"default\": \"預設\",\n    \"model\": \"模型\"\n  },\n  \"syncError\": {\n    \"networkErrors\": \"網路錯誤\",\n    \"streamsAffected\": \"{{count}} 個資料流受影響\",\n    \"streamsAffected_other\": \"{{count}} 個資料流受影響\",\n    \"status\": \"(狀態 {{status}})\",\n    \"refreshPage\": \"重新整理頁面\"\n  },\n  \"onboardingSignIn\": {\n    \"subtitle\": \"Sign in to continue\",\n    \"signedInAs\": \"Signed in as {{name}}.\",\n    \"moreOptions\": \"More options\",\n    \"featureHeader\": \"Feature\",\n    \"signedInHeader\": \"Signed in\",\n    \"skipSignInHeader\": \"Don't sign in\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\"\n  },\n  \"migration\": {\n    \"allProjectsMigrated\": \"您的所有專案已遷移到雲端。\",\n    \"continueToProjects\": \"繼續前往專案\"\n  },\n  \"appBar\": {\n    \"kanban\": {\n      \"tooltip\": \"看板\",\n      \"title\": \"看板\",\n      \"description\": \"登入以使用看板管理您的編碼代理。\",\n      \"migrateOldProjects\": \"遷移舊專案\"\n    }\n  },\n  \"errors\": {\n    \"generic\": \"An unexpected error occurred\"\n  },\n  \"personal\": \"Personal\",\n  \"askQuestion\": {\n    \"title\": \"代理正在提問\",\n    \"selectMultiple\": \"多選\",\n    \"confirmSelection\": \"確認選擇\",\n    \"submitting\": \"正在提交回答...\",\n    \"answeredCount\": \"已回答{{count}}個問題\",\n    \"answeredCount_other\": \"已回答{{count}}個問題\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hant/organization.json",
    "content": "{\n  \"createDialog\": {\n    \"title\": \"建立新組織\",\n    \"description\": \"建立新組織以與團隊協作。\",\n    \"nameLabel\": \"組織名稱\",\n    \"namePlaceholder\": \"例如：Acme 公司\",\n    \"slugLabel\": \"短標識\",\n    \"slugPlaceholder\": \"例如：acme-corporation\",\n    \"slugHelper\": \"用於 URL。僅限小寫字母、數字與連字號。\",\n    \"creating\": \"建立中...\",\n    \"createButton\": \"建立組織\"\n  },\n  \"inviteDialog\": {\n    \"title\": \"邀請成員\",\n    \"description\": \"發送邀請加入您的組織。\",\n    \"emailLabel\": \"電子郵件地址\",\n    \"emailPlaceholder\": \"colleague@example.com\",\n    \"roleLabel\": \"角色\",\n    \"rolePlaceholder\": \"選擇角色\",\n    \"roleHelper\": \"管理員可以管理成員與組織設定。\",\n    \"sending\": \"發送中...\",\n    \"sendButton\": \"發送邀請\",\n    \"subscriptionRequired\": \"新增更多成員需要訂閱\",\n    \"upgradePrompt\": \"升級您的組織方案以邀請更多成員。\",\n    \"upgradeButton\": \"升級方案\"\n  },\n  \"roles\": {\n    \"member\": \"成員\",\n    \"admin\": \"管理員\"\n  },\n  \"memberList\": {\n    \"title\": \"成員\",\n    \"description\": \"管理 {{orgName}} 中的成員與角色\",\n    \"inviteButton\": \"邀請成員\",\n    \"loading\": \"載入成員中...\",\n    \"none\": \"找不到成員\",\n    \"you\": \"您\"\n  },\n  \"invitationList\": {\n    \"title\": \"待處理邀請\",\n    \"description\": \"查看 {{orgName}} 的待處理邀請\",\n    \"loading\": \"載入邀請中...\",\n    \"invited\": \"邀請於 {{date}}\",\n    \"pending\": \"待處理\"\n  },\n  \"settings\": {\n    \"title\": \"組織設定\",\n    \"description\": \"管理組織成員與權限\",\n    \"selectLabel\": \"選擇組織\",\n    \"selectPlaceholder\": \"選擇組織\",\n    \"selectHelper\": \"選擇要查看與管理成員的組織\",\n    \"noOrganizations\": \"沒有可用的組織\",\n    \"loadingOrganizations\": \"載入組織中...\",\n    \"loadError\": \"載入組織失敗\",\n    \"dangerZone\": \"危險區域\",\n    \"dangerZoneDescription\": \"不可逆且具破壞性的操作\",\n    \"deleteOrganization\": \"刪除組織\",\n    \"deleteOrganizationDescription\": \"永久刪除此組織及其所有資料\",\n    \"confirmDelete\": \"您確定要刪除 {{orgName}} 嗎？此操作無法復原。\",\n    \"deleteSuccess\": \"組織刪除成功\",\n    \"deleteError\": \"刪除組織失敗\"\n  },\n  \"loginRequired\": {\n    \"title\": \"需要登入\",\n    \"description\": \"您需要登入才能管理組織設定。\",\n    \"action\": \"登入\"\n  },\n  \"confirmRemoveMember\": \"您確定要從組織中移除此成員嗎？\",\n  \"billing\": {\n    \"title\": \"帳單與訂閱\",\n    \"description\": \"管理您組織的訂閱與帳單設定\",\n    \"manageButton\": \"管理帳單\",\n    \"openInBrowser\": \"在新瀏覽器分頁中開啟\"\n  },\n  \"personalOrg\": {\n    \"cannotInvite\": \"個人組織不能新增其他成員。\",\n    \"createOrgPrompt\": \"建立組織以與他人協作。\",\n    \"createOrgButton\": \"建立組織\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hant/projects.json",
    "content": "{\n  \"createProjectDialog\": {\n    \"title\": \"Create Project\",\n    \"description\": \"Create a new project in this organization.\",\n    \"nameLabel\": \"Project name\",\n    \"namePlaceholder\": \"Enter project name\",\n    \"selectColor\": \"Select project color\",\n    \"creating\": \"Creating...\",\n    \"createButton\": \"Create Project\"\n  },\n  \"deleteProjectDialog\": {\n    \"title\": \"Delete Project?\",\n    \"description\": \"This will permanently delete \\\"{{name}}\\\" and all its issues. This action cannot be undone.\",\n    \"error\": \"Failed to delete project. Please try again.\"\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hant/settings.json",
    "content": "{\n  \"settings\": {\n    \"common\": {\n      \"unsavedChanges\": \"• 您有未儲存的變更\",\n      \"discardChangesConfirm\": \"Discard unsaved changes?\"\n    },\n    \"unsavedChanges\": {\n      \"title\": \"未儲存的變更\",\n      \"message\": \"您有未儲存的變更。確定要關閉而不儲存嗎？\",\n      \"discard\": \"放棄變更\",\n      \"cancel\": \"繼續編輯\"\n    },\n    \"layout\": {\n      \"nav\": {\n        \"title\": \"設定\",\n        \"general\": \"一般\",\n        \"generalDesc\": \"主題、通知與偏好設定\",\n        \"projects\": \"專案\",\n        \"projectsDesc\": \"專案儲存庫與設定\",\n        \"repos\": \"儲存庫\",\n        \"reposDesc\": \"儲存庫腳本與設定\",\n        \"agents\": \"代理\",\n        \"agentsDesc\": \"編碼代理設定\",\n        \"mcp\": \"MCP 伺服器\",\n        \"mcpDesc\": \"模型上下文協議伺服器\",\n        \"organizations\": \"組織設定\",\n        \"organizationsDesc\": \"管理組織成員與權限\",\n        \"remote-projects\": \"專案\",\n        \"remote-projectsDesc\": \"管理專案\",\n        \"relay\": \"Remote Access\",\n        \"relayDesc\": \"Relay tunnel and enrollment\"\n      }\n    },\n    \"general\": {\n      \"loading\": \"載入設定中...\",\n      \"loadError\": \"載入設定失敗。\",\n      \"save\": {\n        \"button\": \"儲存設定\",\n        \"success\": \"✓ 設定儲存成功！\",\n        \"error\": \"儲存設定失敗\",\n        \"unsavedChanges\": \"• 您有未儲存的變更\",\n        \"discard\": \"放棄\"\n      },\n      \"appearance\": {\n        \"title\": \"外觀\",\n        \"description\": \"自訂應用程式的外觀與風格。\",\n        \"theme\": {\n          \"label\": \"主題\",\n          \"placeholder\": \"選擇主題\",\n          \"helper\": \"選擇您喜歡的配色方案。\"\n        },\n        \"language\": {\n          \"label\": \"語言\",\n          \"placeholder\": \"選擇語言\",\n          \"helper\": \"選擇您偏好的語言。瀏覽器預設將跟隨系統語言。\"\n        }\n      },\n      \"taskExecution\": {\n        \"title\": \"預設編碼代理\",\n        \"description\": \"選擇任務的預設編碼代理。\",\n        \"executor\": {\n          \"label\": \"預設代理設定檔\",\n          \"placeholder\": \"選擇設定檔\",\n          \"helper\": \"選擇建立任務嘗試時要使用的預設代理設定檔。\"\n        },\n        \"variant\": \"預設\",\n        \"defaultLabel\": \"預設\"\n      },\n      \"editor\": {\n        \"title\": \"編輯器\",\n        \"description\": \"設定您的程式碼編輯體驗。\",\n        \"type\": {\n          \"label\": \"編輯器類型\",\n          \"placeholder\": \"選擇編輯器\",\n          \"helper\": \"選擇您偏好的程式碼編輯器介面。\"\n        },\n        \"customCommand\": {\n          \"label\": \"自訂編輯器指令\",\n          \"placeholder\": \"例如：code、subl、vim\",\n          \"helper\": \"輸入啟動自訂編輯器的指令，用於開啟檔案。\"\n        },\n        \"remoteSsh\": {\n          \"host\": {\n            \"label\": \"遠端 SSH 主機（選填）\",\n            \"placeholder\": \"例如：主機名稱或 IP 位址\",\n            \"helper\": \"若 Vibe Kanban 在遠端伺服器上執行，請設定此項。設定後，點擊在編輯器中開啟將產生 URL 以透過 SSH 開啟編輯器，而非執行本機指令。\"\n          },\n          \"user\": {\n            \"label\": \"遠端 SSH 使用者（選填）\",\n            \"placeholder\": \"例如：使用者名稱\",\n            \"helper\": \"遠端連線的 SSH 使用者名稱。若未設定，VS Code 將使用您的 SSH 設定或提示輸入。\"\n          }\n        },\n        \"autoInstallExtension\": {\n          \"label\": \"Auto-install VS Code Extension\",\n          \"helper\": \"Automatically install the Vibe Kanban extension when opening a project in VS Code or Cursor. This runs in the background and has no effect if the extension is already installed.\"\n        },\n        \"availability\": {\n          \"checking\": \"檢查可用性...\",\n          \"available\": \"可用\",\n          \"notFound\": \"在 PATH 中找不到\"\n        }\n      },\n      \"github\": {\n        \"title\": \"GitHub 整合\",\n        \"cliSetup\": {\n          \"title\": \"GitHub CLI 設定\",\n          \"description\": \"需要 GitHub CLI 驗證才能建立 PR 並與 GitHub 儲存庫互動。\",\n          \"setupWillTitle\": \"此設定將：\",\n          \"steps\": {\n            \"checkInstalled\": \"檢查是否已安裝 GitHub CLI (gh)\",\n            \"installHomebrew\": \"如有需要，透過 Homebrew 安裝（macOS）\",\n            \"authenticate\": \"使用 OAuth 進行 GitHub 驗證\"\n          },\n          \"setupNote\": \"設定會在聊天視窗中執行。您需要在瀏覽器中完成驗證。\",\n          \"runSetup\": \"執行設定\",\n          \"running\": \"執行中...\",\n          \"errors\": {\n            \"brewMissing\": \"未安裝 Homebrew。請安裝以啟用自動設定。\",\n            \"notSupported\": \"此平台不支援自動設定。請手動安裝 GitHub CLI。\",\n            \"setupFailed\": \"執行 GitHub CLI 設定失敗。\"\n          },\n          \"help\": {\n            \"homebrew\": {\n              \"description\": \"自動安裝需要 Homebrew。請至\",\n              \"brewSh\": \"brew.sh\",\n              \"manualInstall\": \"安裝 Homebrew，然後重新執行設定。或使用以下指令手動安裝 GitHub CLI：\",\n              \"afterInstall\": \"安裝後，使用以下指令進行驗證：\"\n            },\n            \"manual\": {\n              \"description\": \"從\",\n              \"officialDocs\": \"官方文件\",\n              \"andAuthenticate\": \"安裝 GitHub CLI，並使用您的 GitHub 帳號進行驗證。\"\n            }\n          }\n        }\n      },\n      \"git\": {\n        \"title\": \"Git\",\n        \"description\": \"設定 Git 分支命名偏好\",\n        \"branchPrefix\": {\n          \"label\": \"分支前綴\",\n          \"placeholder\": \"vk\",\n          \"helper\": \"自動產生的分支名稱前綴。留空表示不使用前綴。\",\n          \"preview\": \"預覽：\",\n          \"previewWithPrefix\": \"{{prefix}}/1a2b-task-name\",\n          \"previewNoPrefix\": \"1a2b-task-name\",\n          \"errors\": {\n            \"slash\": \"前綴不能包含 '/'。\",\n            \"startsWithDot\": \"前綴不能以 '.' 開頭。\",\n            \"endsWithDot\": \"前綴不能以 '.' 或 '.lock' 結尾。\",\n            \"invalidSequence\": \"包含無效序列（..、@{）。\",\n            \"invalidChars\": \"包含無效字元。\",\n            \"controlChars\": \"包含控制字元。\"\n          }\n        },\n        \"workspaceDir\": {\n          \"label\": \"工作區目錄\",\n          \"placeholder\": \"~/\",\n          \"helper\": \"工作區將在此路徑內的 .vibe-kanban-workspaces 子目錄中建立。留空以使用系統預設值。變更需要重新啟動應用程式。\",\n          \"browse\": \"瀏覽\",\n          \"dialogTitle\": \"選擇工作區目錄\",\n          \"dialogDescription\": \"選擇一個目錄。工作區將在其中的 .vibe-kanban-workspaces 子目錄中建立。\"\n        }\n      },\n      \"pullRequests\": {\n        \"title\": \"PR\",\n        \"description\": \"設定 PR 建立行為\",\n        \"autoDescription\": {\n          \"label\": \"預設自動產生 PR 描述\",\n          \"helper\": \"啟用後，AI 代理會在建立 PR 後自動更新標題與描述。\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"使用自訂提示\",\n          \"helper\": \"產生 PR 描述時 AI 代理使用的自訂提示。使用 {pr_number} 與 {pr_url} 作為佔位符。\"\n        }\n      },\n      \"commits\": {\n        \"title\": \"提交\",\n        \"description\": \"設定任務嘗試的提交行為\",\n        \"reminder\": {\n          \"label\": \"提交提醒\",\n          \"helper\": \"提示支援的代理在停止前提交變更。\"\n        },\n        \"customPrompt\": {\n          \"useCustom\": \"使用自訂提示\",\n          \"helper\": \"提交提醒的自訂提示。git 狀態將自動追加。\"\n        }\n      },\n      \"notifications\": {\n        \"title\": \"通知\",\n        \"description\": \"控制何時與如何接收通知。\",\n        \"sound\": {\n          \"label\": \"音效通知\",\n          \"helper\": \"任務嘗試完成執行時播放音效。\",\n          \"fileLabel\": \"音效\",\n          \"filePlaceholder\": \"選擇音效\",\n          \"fileHelper\": \"選擇任務完成時播放的音效。點擊音量按鈕可預覽。\"\n        },\n        \"push\": {\n          \"label\": \"推播通知\",\n          \"helper\": \"任務嘗試完成執行時顯示系統通知。\"\n        }\n      },\n      \"messageInput\": {\n        \"title\": \"訊息輸入\",\n        \"description\": \"設定在聊天輸入中傳送訊息的方式。\",\n        \"shortcut\": {\n          \"label\": \"傳送訊息快捷鍵\",\n          \"helper\": \"選擇傳送訊息的鍵盤快捷鍵。\",\n          \"enterLabel\": \"Enter（Shift+Enter 換行）\"\n        }\n      },\n      \"privacy\": {\n        \"title\": \"隱私\",\n        \"description\": \"透過分享匿名使用資料來協助改善 Vibe Kanban。\",\n        \"telemetry\": {\n          \"label\": \"啟用遙測\",\n          \"helper\": \"啟用匿名使用事件追蹤以協助改善應用程式。不會收集提示或專案資訊。\"\n        }\n      },\n      \"taskTemplates\": {\n        \"title\": \"標籤\",\n        \"description\": \"建立可用 @tag_name 插入任務描述的可重用文字片段。\"\n      },\n      \"tags\": {\n        \"manager\": {\n          \"title\": \"任務標籤\",\n          \"addTag\": \"新增標籤\",\n          \"noTags\": \"尚無標籤。為常見任務描述建立可重用文字片段。在任何任務中使用 @tag_name。\",\n          \"table\": {\n            \"tagName\": \"標籤名稱\",\n            \"content\": \"內容\",\n            \"actions\": \"操作\"\n          },\n          \"actions\": {\n            \"editTag\": \"編輯標籤\",\n            \"deleteTag\": \"刪除標籤\"\n          },\n          \"deleteConfirm\": \"您確定要刪除標籤 {{tagName}} 嗎？\"\n        },\n        \"dialog\": {\n          \"createTitle\": \"建立標籤\",\n          \"editTitle\": \"編輯標籤\",\n          \"tagName\": {\n            \"label\": \"標籤名稱\",\n            \"required\": \"*\",\n            \"hint\": \"在任務描述中使用 @ 加上此名稱：@{{tagName}}\",\n            \"placeholder\": \"例如：bug_fix、test_plan、api_docs\",\n            \"error\": \"標籤名稱不能包含空白。請使用底線（例如：my_tag）\"\n          },\n          \"content\": {\n            \"label\": \"內容\",\n            \"required\": \"*\",\n            \"hint\": \"在任務描述中使用 @{{tagName}} 時會插入的文字\",\n            \"placeholder\": \"輸入使用此標籤時要插入的文字\"\n          },\n          \"errors\": {\n            \"nameRequired\": \"標籤名稱為必填\",\n            \"saveFailed\": \"儲存標籤失敗\"\n          },\n          \"buttons\": {\n            \"cancel\": \"取消\",\n            \"create\": \"建立\",\n            \"update\": \"更新\"\n          }\n        }\n      },\n      \"safety\": {\n        \"title\": \"安全與免責聲明\",\n        \"description\": \"重設安全警告與入門流程確認。\",\n        \"disclaimer\": {\n          \"title\": \"免責聲明確認\",\n          \"description\": \"重設安全免責聲明。\",\n          \"button\": \"重設\"\n        },\n        \"onboarding\": {\n          \"title\": \"入門流程\",\n          \"description\": \"重設入門流程。\",\n          \"button\": \"重設\"\n        }\n      },\n      \"beta\": {\n        \"title\": \"Beta 功能\",\n        \"description\": \"在正式發布前試用實驗性功能。\",\n        \"workspaces\": {\n          \"label\": \"啟用工作區 Beta\",\n          \"helper\": \"查看任務嘗試時使用新的工作區介面。任務將首先在任務檢視中開啟，嘗試將在新的工作區檢視中開啟。\"\n        }\n      }\n    },\n    \"agents\": {\n      \"title\": \"編碼代理設定\",\n      \"description\": \"使用不同設定自訂編碼代理的行為。\",\n      \"loading\": \"載入代理設定中...\",\n      \"selectAgent\": \"選擇代理\",\n      \"save\": {\n        \"button\": \"儲存代理設定\",\n        \"success\": \"✓ 執行器設定儲存成功！\",\n        \"unsavedChanges\": \"• 您有未儲存的變更\"\n      },\n      \"availability\": {\n        \"checking\": \"檢查中...\",\n        \"checkingAvailability\": \"正在檢查可用性...\",\n        \"available\": \"代理可用\",\n        \"notFoundSimple\": \"找不到代理\",\n        \"loginDetected\": \"偵測到最近使用\",\n        \"loginDetectedTooltip\": \"找到此代理的最近驗證憑證\",\n        \"installationFound\": \"偵測到曾使用\",\n        \"installationFoundTooltip\": \"找到代理設定。您可能需要登入才能使用。\"\n      },\n      \"editor\": {\n        \"formLabel\": \"編輯 JSON\",\n        \"agentLabel\": \"代理\",\n        \"agentPlaceholder\": \"選擇執行器類型\",\n        \"configLabel\": \"設定\",\n        \"configPlaceholder\": \"選擇設定\",\n        \"createNew\": \"新建...\",\n        \"deleteTitle\": \"無法刪除最後一個設定\",\n        \"deleteButton\": \"刪除 {{name}}\",\n        \"deleteText\": \"刪除\",\n        \"makeDefault\": \"設為預設\",\n        \"isDefault\": \"預設\",\n        \"jsonLabel\": \"代理設定（JSON）\",\n        \"jsonPlaceholder\": \"載入設定檔中...\",\n        \"jsonLoading\": \"載入中...\",\n        \"pathLabel\": \"設定檔位置：\"\n      },\n      \"errors\": {\n        \"deleteFailed\": \"刪除設定失敗。請重試。\",\n        \"saveFailed\": \"儲存代理設定失敗。請重試。\",\n        \"saveConfigFailed\": \"儲存設定失敗。請重試。\",\n        \"schemaNotFound\": \"找不到執行器類型 {{executor}} 的結構描述\"\n      },\n      \"tree\": {\n        \"search\": \"搜尋設定...\",\n        \"expandAll\": \"全部展開\",\n        \"collapseAll\": \"全部收合\",\n        \"noResults\": \"沒有符合的設定\",\n        \"noConfigs\": \"沒有可用的設定\",\n        \"selectConfig\": \"從側邊欄選擇一個設定來查看和編輯其設定。\"\n      },\n      \"deleteConfigDialog\": {\n        \"title\": \"刪除設定？\",\n        \"description\": \"這將從 {{executorType}} 執行器中永久刪除「{{configName}}」。此操作無法復原。\"\n      }\n    },\n    \"mcp\": {\n      \"title\": \"MCP 伺服器設定\",\n      \"description\": \"設定模型上下文協議伺服器，使用自訂工具與資源擴充編碼代理功能。\",\n      \"loading\": \"載入 MCP 設定中...\",\n      \"applying\": \"套用設定中...\",\n      \"labels\": {\n        \"agent\": \"代理\",\n        \"agentPlaceholder\": \"選擇執行器\",\n        \"agentHelper\": \"選擇要設定 MCP 伺服器的代理。\",\n        \"serverConfig\": \"伺服器設定（JSON）\",\n        \"popularServers\": \"熱門伺服器\",\n        \"serverHelper\": \"點擊卡片將該 MCP 伺服器插入到上方 JSON 中。\",\n        \"saveLocation\": \"變更將儲存到：\"\n      },\n      \"loadingStates\": {\n        \"jsonEditor\": \"載入中...\",\n        \"configuration\": \"載入目前 MCP 伺服器設定...\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"載入設定失敗。\",\n        \"invalidJson\": \"JSON 格式無效\",\n        \"validationError\": \"驗證錯誤\",\n        \"saveFailed\": \"儲存 MCP 伺服器失敗\",\n        \"applyFailed\": \"套用 MCP 伺服器設定失敗\",\n        \"addServerFailed\": \"新增預設伺服器失敗\",\n        \"mcpError\": \"MCP 設定錯誤：{{error}}\",\n        \"notSupported\": \"不支援 MCP\",\n        \"supportMessage\": \"要使用 MCP 伺服器，請在上方選擇支援 MCP 的其他執行器（Claude、Amp、Gemini、Codex 或 Opencode）。\"\n      },\n      \"save\": {\n        \"button\": \"儲存 MCP 設定\",\n        \"success\": \"設定已儲存！\",\n        \"successMessage\": \"✓ MCP 設定儲存成功！\",\n        \"loading\": \"載入目前 MCP 伺服器設定...\",\n        \"unsavedChanges\": \"• 您有未儲存的變更\"\n      }\n    },\n    \"projects\": {\n      \"title\": \"專案設定\",\n      \"description\": \"設定專案專屬的腳本與選項。\",\n      \"loading\": \"載入專案中...\",\n      \"loadError\": \"載入專案失敗。\",\n      \"selector\": {\n        \"label\": \"選擇專案\",\n        \"placeholder\": \"選擇要設定的專案\",\n        \"helper\": \"選擇專案以查看與編輯其設定。\",\n        \"noProjects\": \"沒有可用的專案\"\n      },\n      \"general\": {\n        \"title\": \"一般設定\",\n        \"description\": \"設定基本專案資訊。\",\n        \"name\": {\n          \"label\": \"專案名稱\",\n          \"placeholder\": \"輸入專案名稱\",\n          \"helper\": \"此專案的顯示名稱。\"\n        },\n        \"repoPath\": {\n          \"label\": \"Git 儲存庫路徑\",\n          \"placeholder\": \"/path/to/your/existing/repo\",\n          \"helper\": \"磁碟上的 Git 儲存庫絕對路徑。\"\n        }\n      },\n      \"save\": {\n        \"button\": \"儲存專案設定\",\n        \"success\": \"✓ 專案設定儲存成功！\",\n        \"error\": \"儲存專案設定失敗\",\n        \"unsavedChanges\": \"• 您有未儲存的變更\",\n        \"discard\": \"放棄\",\n        \"confirmSwitch\": \"您有未儲存的變更。確定要切換專案嗎？您的變更將會遺失。\",\n        \"saving\": \"Saving...\"\n      },\n      \"repositories\": {\n        \"title\": \"儲存庫\",\n        \"description\": \"管理此專案中的 Git 儲存庫\",\n        \"noRepositories\": \"未設定儲存庫\",\n        \"addRepository\": \"新增儲存庫\"\n      }\n    },\n    \"repos\": {\n      \"title\": \"儲存庫設定\",\n      \"description\": \"設定此儲存庫在工作區中使用時執行的腳本。\",\n      \"loading\": \"載入儲存庫中...\",\n      \"loadError\": \"載入儲存庫失敗。\",\n      \"addRepo\": {\n        \"button\": \"新增儲存庫\",\n        \"dialogTitle\": \"選擇 Git 儲存庫\",\n        \"dialogDescription\": \"選擇一個現有的 git 儲存庫進行註冊。\",\n        \"error\": \"註冊儲存庫失敗\"\n      },\n      \"selector\": {\n        \"label\": \"選擇儲存庫\",\n        \"placeholder\": \"選擇要設定的儲存庫\",\n        \"helper\": \"選擇儲存庫以查看與編輯其設定。\",\n        \"noRepos\": \"沒有可用的儲存庫\"\n      },\n      \"general\": {\n        \"title\": \"一般設定\",\n        \"description\": \"設定基本儲存庫資訊。\",\n        \"displayName\": {\n          \"label\": \"顯示名稱\",\n          \"placeholder\": \"輸入顯示名稱\",\n          \"helper\": \"此儲存庫的易讀名稱。\"\n        },\n        \"path\": {\n          \"label\": \"儲存庫路徑\"\n        },\n        \"defaultWorkingDir\": {\n          \"label\": \"預設工作目錄\",\n          \"placeholder\": \"例如：packages/frontend\",\n          \"helper\": \"單儲存庫工作區中程式碼代理執行的子目錄。在建立工作區時設定。留空則使用儲存庫根目錄。\"\n        },\n        \"defaultTargetBranch\": {\n          \"label\": \"預設目標分支\",\n          \"placeholder\": \"選擇分支\",\n          \"helper\": \"新工作區的預設基礎分支。工作樹從此分支建立，PR 將以此分支為目標。\",\n          \"search\": \"搜尋分支\",\n          \"noBranches\": \"找不到分支\",\n          \"loading\": \"正在載入分支...\",\n          \"useCurrent\": \"使用目前分支\"\n        }\n      },\n      \"scripts\": {\n        \"title\": \"腳本與設定\",\n        \"description\": \"設定此儲存庫的設定腳本、清理腳本與要複製的檔案。這些腳本在儲存庫用於任何工作區時執行。\",\n        \"setup\": {\n          \"label\": \"設定腳本\",\n          \"helper\": \"此腳本在工作樹內執行，於建立後、編碼代理啟動前執行。用於安裝相依套件或準備環境等設定工作。\",\n          \"parallelLabel\": \"與編碼代理平行執行設定腳本\",\n          \"parallelHelper\": \"啟用後，設定腳本將與編碼代理同時執行，而不是等待設定完成後再啟動。\"\n        },\n        \"cleanup\": {\n          \"label\": \"清理腳本\",\n          \"helper\": \"此腳本在工作樹內執行，於編碼代理執行後（僅在有變更時）執行。用於品質保證工作，如執行 linter、格式化工具、測試或其他驗證步驟。\"\n        },\n        \"archive\": {\n          \"label\": \"歸檔腳本\",\n          \"helper\": \"當工作區被歸檔時，此腳本在工作樹內執行。用於清理工作，如停止服務、釋放資源或儲存狀態。\"\n        },\n        \"copyFiles\": {\n          \"label\": \"複製檔案\",\n          \"helper\": \"要從原始儲存庫目錄複製到工作樹的檔案清單（以逗號分隔）。適合用於 .env 等環境檔案。請確保這些檔案已加入 gitignore！\",\n          \"placeholder\": \"檔案路徑或 glob 模式（例如：.env、config/*.json）\"\n        },\n        \"devServer\": {\n          \"label\": \"開發伺服器腳本\",\n          \"helper\": \"啟動此儲存庫的開發伺服器。腳本會從儲存庫的工作樹目錄執行。\"\n        }\n      },\n      \"linkedProjects\": {\n        \"title\": \"關聯專案\",\n        \"description\": \"在預設工作區設定中使用此儲存庫的專案。\",\n        \"loading\": \"正在檢查專案…\",\n        \"none\": \"沒有關聯的專案\"\n      },\n      \"remove\": {\n        \"title\": \"移除儲存庫\",\n        \"description\": \"從 Vibe Kanban 取消關聯此儲存庫。磁碟上的檔案不會被刪除。\",\n        \"button\": \"移除\",\n        \"confirm\": \"移除儲存庫\",\n        \"dialogTitle\": \"移除「{{name}}」？\",\n        \"dialogDescription\": \"這將從 Vibe Kanban 取消關聯此儲存庫。您磁碟上的檔案不受影響。您可以稍後重新加入。\",\n        \"success\": \"儲存庫移除成功。\",\n        \"error\": \"移除儲存庫失敗。\"\n      },\n      \"save\": {\n        \"button\": \"儲存儲存庫設定\",\n        \"success\": \"儲存庫設定儲存成功！\",\n        \"error\": \"儲存儲存庫設定失敗\",\n        \"unsavedChanges\": \"您有未儲存的變更\",\n        \"discard\": \"放棄\",\n        \"confirmSwitch\": \"您有未儲存的變更。確定要切換儲存庫嗎？您的變更將會遺失。\"\n      }\n    },\n    \"relay\": {\n      \"title\": \"Remote Access\",\n      \"description\": \"Allow access to this instance from the web via relay tunnel.\",\n      \"enabled\": {\n        \"label\": \"Enable remote access\",\n        \"helper\": \"When enabled, this instance can be accessed remotely through a relay tunnel.\"\n      },\n      \"enrollmentCode\": {\n        \"label\": \"Pairing Code\",\n        \"helper\": \"Enter this code in the browser to pair with this instance.\",\n        \"loginRequired\": \"Log in to generate a pairing code.\",\n        \"fetchError\": \"Failed to fetch pairing code.\",\n        \"show\": \"Show pairing code\"\n      }\n    },\n    \"remoteProjects\": {\n      \"title\": \"Remote Projects\",\n      \"description\": \"Manage cloud-synced projects across organizations.\",\n      \"loading\": \"Loading remote projects...\",\n      \"loadError\": \"Failed to load organizations\",\n      \"noProjects\": \"No projects yet. Create one to get started.\",\n      \"selectOrg\": \"Select an organization\",\n      \"createSuccess\": \"Project created successfully\",\n      \"deleteSuccess\": \"Project deleted successfully\",\n      \"nameRequired\": \"Project name is required\",\n      \"saveSuccess\": \"Project updated successfully\",\n      \"saveError\": \"Failed to update project\",\n      \"columns\": {\n        \"organizations\": \"Organizations\",\n        \"projects\": \"Projects\"\n      },\n      \"actions\": {\n        \"addProject\": \"Add Project\"\n      },\n      \"form\": {\n        \"name\": {\n          \"label\": \"Project Name\",\n          \"placeholder\": \"Enter project name\"\n        },\n        \"color\": {\n          \"label\": \"Project Color\"\n        },\n        \"statuses\": {\n          \"label\": \"Project Statuses\",\n          \"description\": \"Manage kanban columns for this project.\"\n        },\n        \"defaultRepos\": {\n          \"label\": \"Default Repositories\",\n          \"description\": \"Repositories that are automatically added when creating workspaces from this project's issues.\",\n          \"empty\": \"No default repositories configured\",\n          \"addButton\": \"Add\",\n          \"addNew\": \"Add new repository...\",\n          \"branchLabel\": \"Branch\",\n          \"noBranches\": \"No branches found for this repository\",\n          \"fetchError\": \"Could not load repositories\",\n          \"saveError\": \"Failed to save default repositories\"\n        }\n      },\n      \"loginRequired\": {\n        \"title\": \"Sign in required\",\n        \"description\": \"Sign in to manage your remote projects.\",\n        \"action\": \"Sign in\"\n      }\n    }\n  },\n  \"integrations\": {\n    \"github\": {\n      \"cliSetup\": {\n        \"title\": \"GitHub CLI 設定\",\n        \"description\": \"需要 GitHub CLI 驗證才能建立 PR 並與 GitHub 儲存庫互動。\",\n        \"setupWillTitle\": \"此設定將：\",\n        \"steps\": {\n          \"checkInstalled\": \"檢查是否已安裝 GitHub CLI (gh)\",\n          \"installHomebrew\": \"如有需要，透過 Homebrew 安裝（macOS）\",\n          \"authenticate\": \"使用 OAuth 進行 GitHub 驗證\"\n        },\n        \"setupNote\": \"設定會在聊天視窗中執行。您需要在瀏覽器中完成驗證。\",\n        \"runSetup\": \"執行設定\",\n        \"running\": \"執行中...\",\n        \"errors\": {\n          \"brewMissing\": \"未安裝 Homebrew。請安裝以啟用自動設定。\",\n          \"notSupported\": \"此平台不支援自動設定。請手動安裝 GitHub CLI。\",\n          \"setupFailed\": \"執行 GitHub CLI 設定失敗。\"\n        },\n        \"help\": {\n          \"homebrew\": {\n            \"description\": \"自動安裝需要 Homebrew。請至\",\n            \"brewSh\": \"brew.sh\",\n            \"manualInstall\": \"安裝 Homebrew，然後重新執行設定。或使用以下指令手動安裝 GitHub CLI：\",\n            \"afterInstall\": \"安裝後，使用以下指令進行驗證：\"\n          },\n          \"manual\": {\n            \"description\": \"從\",\n            \"officialDocs\": \"官方文件\",\n            \"andAuthenticate\": \"安裝 GitHub CLI，並使用您的 GitHub 帳號進行驗證。\"\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/i18n/locales/zh-Hant/tasks.json",
    "content": "{\n  \"dropzone\": {\n    \"dropImagesHere\": \"將圖片拖放到這裡\",\n    \"supportedFormats\": \"支援 PNG、JPG、GIF、WebP、SVG 格式\"\n  },\n  \"gitPanel\": {\n    \"advanced\": {\n      \"placeholder\": \"例如：acme Corp\"\n    }\n  },\n  \"loading\": \"載入任務中...\",\n  \"actions\": {\n    \"addTask\": \"新增任務\"\n  },\n  \"rebase\": {\n    \"common\": {\n      \"action\": \"重基底\",\n      \"inProgress\": \"重基底中...\"\n    },\n    \"dialog\": {\n      \"title\": \"重基底任務嘗試\",\n      \"description\": \"選擇新的基底分支以將此任務嘗試重基底到該分支上。\",\n      \"upstreamLabel\": \"上游分支\",\n      \"upstreamPlaceholder\": \"選擇上游分支\",\n      \"targetLabel\": \"目標分支\",\n      \"targetPlaceholder\": \"選擇目標分支\",\n      \"advanced\": \"進階\"\n    }\n  },\n  \"branches\": {\n    \"changeTarget\": {\n      \"dialog\": {\n        \"title\": \"變更目標分支\",\n        \"description\": \"為任務嘗試選擇新的目標分支。\",\n        \"placeholder\": \"選擇目標分支\",\n        \"action\": \"變更分支\",\n        \"inProgress\": \"變更中...\"\n      }\n    }\n  },\n  \"repos\": {\n    \"selector\": {\n      \"placeholder\": \"選擇儲存庫\",\n      \"empty\": \"沒有可用的儲存庫\"\n    }\n  },\n  \"repoBranchSelector\": {\n    \"label\": \"基底分支\"\n  },\n  \"preview\": {\n    \"noServer\": {\n      \"title\": \"沒有執行中的開發伺服器\",\n      \"setupTitle\": \"必須設定開發伺服器腳本才能使用預覽功能\",\n      \"setupPrompt\": \"要使用即時預覽與點擊編輯，請為此專案新增開發伺服器腳本。\",\n      \"editDevScript\": \"編輯開發伺服器腳本\",\n      \"learnMore\": \"了解更多關於測試應用程式的資訊\"\n    },\n    \"logs\": {\n      \"label\": \"日誌\",\n      \"viewFull\": \"查看完整日誌\"\n    },\n    \"browser\": {\n      \"title\": \"開發伺服器預覽\",\n      \"devServerFallback\": \"開發伺服器\"\n    },\n    \"toolbar\": {\n      \"refresh\": \"重新整理預覽\",\n      \"copyUrl\": \"複製 URL\",\n      \"openInTab\": \"在新分頁開啟\",\n      \"stopDevServer\": \"停止開發伺服器\",\n      \"resetUrl\": \"重設為偵測到的 URL\",\n      \"clearUrlOverride\": \"清除 URL 覆寫\",\n      \"desktopView\": \"桌面檢視\",\n      \"mobileView\": \"行動裝置檢視 (390x844)\",\n      \"responsiveView\": \"響應式檢視（可調整大小）\",\n      \"startDevServer\": \"啟動開發伺服器\",\n      \"submitUrl\": \"提交 URL\",\n      \"toggleDevTools\": \"切換 DevTools\"\n    },\n    \"loading\": {\n      \"startingServer\": \"正在啟動開發伺服器...\",\n      \"waitingForServer\": \"正在等待伺服器...\",\n      \"loadingPreview\": \"正在載入預覽...\",\n      \"manualUrlHint\": \"尚未偵測到 URL。您可以在上方工具列中手動輸入 URL。\"\n    }\n  },\n  \"diff\": {\n    \"filesChanged_one\": \"{{count}} 個檔案已變更\",\n    \"filesChanged_other\": \"{{count}} 個檔案已變更\",\n    \"largeDiff\": {\n      \"title\": \"大型檔案\",\n      \"linesChanged\": \"{{count}} 行已變更\",\n      \"loadAnyway\": \"仍然載入差異\",\n      \"warning\": \"大型差異可能會降低瀏覽器速度。\"\n    }\n  },\n  \"processes\": {\n    \"noLogsAvailable\": \"沒有可用的日誌\",\n    \"selectAttempt\": \"選擇嘗試以查看執行程序。\",\n    \"errorLoadingUpdates\": \"載入程序的即時更新失敗。\",\n    \"reconnecting\": \"重新連線中...\",\n    \"loading\": \"載入執行程序中...\",\n    \"noProcesses\": \"找不到此嘗試的執行程序。\",\n    \"processId\": \"程序 ID：{{id}}\",\n    \"deleted\": \"已刪除\",\n    \"deletedTooltip\": \"因復原而刪除：時間軸已回復到檢查點，後續執行已移除\",\n    \"agent\": \"代理：\",\n    \"exit\": \"退出：{{code}}\",\n    \"started\": \"開始：{{date}}\",\n    \"completed\": \"完成：{{date}}\",\n    \"detailsTitle\": \"程序詳情\",\n    \"backToList\": \"返回列表\",\n    \"loadingDetails\": \"載入程序詳情中...\",\n    \"errorLoadingDetails\": \"載入程序詳情失敗。請重試。\",\n    \"copyLogs\": \"複製日誌\",\n    \"logsCopied\": \"已複製！\"\n  },\n  \"followUp\": {\n    \"queuedMessage\": \"訊息已加入佇列 - 會在目前執行完成後處理\"\n  },\n  \"todoPopup\": {\n    \"title\": \"任務\",\n    \"progress\": \"{{completed}}/{{total}} 已完成\",\n    \"noTasks\": \"暫無任務\"\n  },\n  \"attempt\": {\n    \"actions\": {\n      \"startDevServer\": \"啟動開發伺服器\"\n    }\n  },\n  \"git\": {\n    \"labels\": {\n      \"taskBranch\": \"任務分支\"\n    },\n    \"branch\": {\n      \"current\": \"目前\"\n    },\n    \"forcePushDialog\": {\n      \"title\": \"需要強制推送\",\n      \"description\": \"遠端分支{{branchLabel}}已與本機分支分岔。一般推送被拒絕。\",\n      \"warning\": \"強制推送將以本機變更覆寫遠端變更。此操作無法復原。\",\n      \"note\": \"僅在您確定要取代遠端分支歷史時才繼續。\",\n      \"error\": \"強制推送失敗\"\n    },\n    \"status\": {\n      \"commits_one\": \"提交\",\n      \"commits_other\": \"提交\",\n      \"conflicts\": \"衝突\",\n      \"upToDate\": \"最新\",\n      \"ahead\": \"領先\",\n      \"behind\": \"落後\"\n    },\n    \"states\": {\n      \"merged\": \"已合併！\",\n      \"merging\": \"合併中...\",\n      \"merge\": \"合併\",\n      \"rebasing\": \"重基底中...\",\n      \"rebase\": \"重基底\",\n      \"pushed\": \"已推送！\",\n      \"pushing\": \"推送中...\",\n      \"push\": \"推送\",\n      \"pushFailed\": \"失敗\",\n      \"forcePush\": \"強制推送\",\n      \"forcePushing\": \"強制推送中...\",\n      \"creating\": \"建立中...\",\n      \"createPr\": \"建立 PR\"\n    },\n    \"errors\": {\n      \"changeTargetBranch\": \"變更目標分支失敗\",\n      \"pushChanges\": \"推送變更失敗\",\n      \"mergeChanges\": \"合併變更失敗\",\n      \"rebaseBranch\": \"重基底分支失敗\",\n      \"branchStatusUnavailable\": \"無法取得分支狀態。您仍然可以變更目標分支。\"\n    },\n    \"pr\": {\n      \"open\": \"開啟 PR #{{number}}\",\n      \"number\": \"PR #{{number}}\",\n      \"merged\": \"已合併PR #{{prNumber}}\"\n    },\n    \"actions\": {\n      \"title\": \"Git 操作\",\n      \"changeTarget\": \"變更目標\",\n      \"prMerged\": \"PR #{{number}} 已合併\",\n      \"loginRequired\": {\n        \"title\": \"登入以管理 Git 操作\",\n        \"description\": \"登入 Vibe Kanban，才能推送分支、合併變更或為此任務建立 PR。\",\n        \"action\": \"登入\"\n      }\n    },\n    \"mergeDialog\": {\n      \"title\": \"合併變更\",\n      \"description\": \"這將把您的變更合併到目標分支。確定要繼續嗎？\"\n    },\n    \"createRepo\": {\n      \"dialog\": {\n        \"title\": \"建立新儲存庫\",\n        \"description\": \"初始化新的 Git 儲存庫\"\n      },\n      \"form\": {\n        \"nameLabel\": \"名稱\",\n        \"namePlaceholder\": \"my-project\",\n        \"locationLabel\": \"位置\",\n        \"locationPlaceholder\": \"目前目錄\"\n      },\n      \"browseDialog\": {\n        \"title\": \"選擇父目錄\",\n        \"description\": \"選擇新儲存庫的建立位置\"\n      },\n      \"errors\": {\n        \"nameRequired\": \"儲存庫名稱為必填\",\n        \"createFailed\": \"建立儲存庫失敗\"\n      },\n      \"buttons\": {\n        \"createRepository\": \"建立儲存庫\"\n      },\n      \"states\": {\n        \"creating\": \"建立中...\"\n      }\n    }\n  },\n  \"viewProcessesDialog\": {\n    \"title\": \"執行程序\"\n  },\n  \"editBranchName\": {\n    \"dialog\": {\n      \"title\": \"編輯分支名稱\",\n      \"description\": \"輸入分支的新名稱。如果有未關閉的 PR，則無法重新命名。\",\n      \"branchNameLabel\": \"分支名稱\",\n      \"placeholder\": \"例如：feature/my-branch\",\n      \"renaming\": \"重新命名中...\",\n      \"action\": \"重新命名分支\"\n    }\n  },\n  \"startReviewDialog\": {\n    \"title\": \"開始審查\",\n    \"description\": \"請求編碼代理審查您的變更並提供反饋。\",\n    \"additionalInstructions\": \"附加說明（可選）\",\n    \"reviewComments\": \"審查評論（{{count}}）\",\n    \"includeGitContext\": \"包含 Git 上下文\",\n    \"includeGitContextDescription\": \"告訴代理如何查看此分支上的所有變更\",\n    \"newSession\": \"新工作階段\",\n    \"sessionName\": \"審查\"\n  },\n  \"actionsMenu\": {\n    \"startReview\": \"開始審查\",\n    \"startingReview\": \"正在開始審查...\"\n  },\n  \"resolveConflicts\": {\n    \"dialog\": {\n      \"title\": \"解決衝突\",\n      \"description\": \"偵測到衝突。選擇您希望代理如何解決它們。\",\n      \"newSession\": \"新工作階段\",\n      \"resolve\": \"解決衝突\",\n      \"resolving\": \"開始中...\",\n      \"filesWithConflicts_one\": \"{{count}} 個檔案有衝突\",\n      \"filesWithConflicts_other\": \"{{count}} 個檔案有衝突\",\n      \"andMore\": \"...還有 {{count}} 個\",\n      \"sessionName\": \"解決衝突\"\n    }\n  },\n  \"rebaseInProgress\": {\n    \"dialog\": {\n      \"title\": \"變基進行中\",\n      \"description\": \"正在無衝突地變基到 {{targetBranch}}。請選擇如何繼續。\",\n      \"hint\": \"您可以繼續變基以完成操作，或中止以返回之前的狀態。\",\n      \"continue\": \"繼續變基\",\n      \"continuing\": \"繼續中...\",\n      \"abort\": \"中止變基\",\n      \"aborting\": \"中止中...\",\n      \"continueError\": \"繼續變基失敗。可能存在未解決的衝突。\",\n      \"abortError\": \"中止變基失敗。請重試。\"\n    }\n  },\n  \"createPrDialog\": {\n    \"title\": \"建立 PR\",\n    \"description\": \"為此任務嘗試建立 PR。\",\n    \"titleLabel\": \"標題\",\n    \"titlePlaceholder\": \"輸入 PR 標題\",\n    \"descriptionLabel\": \"描述（選填）\",\n    \"descriptionPlaceholder\": \"輸入 PR 描述\",\n    \"baseBranchLabel\": \"基底分支\",\n    \"loadingBranches\": \"載入分支中...\",\n    \"selectBaseBranch\": \"選擇基底分支\",\n    \"draftLabel\": \"建立為草稿\",\n    \"autoGenerateLabel\": \"請求 AI 代理產生更好的 PR 描述\",\n    \"creating\": \"建立中...\",\n    \"createButton\": \"建立 PR\",\n    \"errors\": {\n      \"failedToCreate\": \"建立 PR 失敗\",\n      \"gitCliNotLoggedIn\": \"Git 尚未驗證。請執行 gh auth login（或設定 Git 憑證）後重試。\",\n      \"gitCliNotInstalled\": \"未安裝 Git CLI。請安裝 Git 以建立 PR。\",\n      \"targetBranchNotFound\": \"遠端不存在目標分支 {{branch}}。建立 PR 前請確認該分支存在。\"\n    }\n  },\n  \"prComments\": {\n    \"dialog\": {\n      \"title\": \"選擇 PR 評論\",\n      \"noComments\": \"此 PR 沒有評論\",\n      \"selectAll\": \"全選\",\n      \"deselectAll\": \"取消全選\",\n      \"add\": \"新增\",\n      \"selectedCount\": \"已選擇 {{selected}} / {{total}}\"\n    },\n    \"card\": {\n      \"review\": \"審查\",\n      \"tooltip\": \"點擊查看，雙擊編輯\"\n    }\n  },\n  \"taskFormDialog\": {\n    \"createTitle\": \"建立新任務\",\n    \"editTitle\": \"編輯任務\",\n    \"titlePlaceholder\": \"任務標題\",\n    \"descriptionPlaceholder\": \"新增更多細節（選填）。輸入 @ 搜尋檔案。\",\n    \"statusLabel\": \"狀態\",\n    \"statusOptions\": {\n      \"todo\": \"待辦\",\n      \"inprogress\": \"進行中\",\n      \"inreview\": \"審查中\",\n      \"done\": \"完成\",\n      \"cancelled\": \"已取消\"\n    },\n    \"startLabel\": \"開始\",\n    \"attachFile\": \"附加檔案\",\n    \"dropImagesHere\": \"將圖片放在這裡\",\n    \"updating\": \"更新中...\",\n    \"updateTask\": \"更新任務\",\n    \"starting\": \"開始中...\",\n    \"creating\": \"建立中...\",\n    \"create\": \"建立\",\n    \"discardDialog\": {\n      \"title\": \"放棄未儲存的變更？\",\n      \"description\": \"您有未儲存的變更。確定要放棄嗎？\",\n      \"continueEditing\": \"繼續編輯\",\n      \"discardChanges\": \"放棄變更\"\n    }\n  },\n  \"restoreLogsDialog\": {\n    \"title\": \"確認重試\",\n    \"titleReset\": \"確認重設\",\n    \"historyChange\": {\n      \"title\": \"歷史紀錄變更\",\n      \"willDelete\": \"將刪除此程序\",\n      \"willDeleteProcesses_one\": \"將刪除 {{count}} 個程序\",\n      \"willDeleteProcesses_other\": \"將刪除 {{count}} 個程序\",\n      \"andLaterProcesses_one\": \"及後續 {{count}} 個程序\",\n      \"andLaterProcesses_other\": \"及後續 {{count}} 個程序\",\n      \"fromHistory\": \"從歷史紀錄中刪除。\",\n      \"codingAgentRuns_one\": \"{{count}} 次編碼代理執行\",\n      \"codingAgentRuns_other\": \"{{count}} 次編碼代理執行\",\n      \"scriptProcesses_one\": \"{{count}} 個腳本程序\",\n      \"scriptProcesses_other\": \"{{count}} 個腳本程序\",\n      \"setupCleanupBreakdown\": \"({{setup}} 個設定，{{cleanup}} 個清理)\",\n      \"permanentWarning\": \"此操作會永久變更歷史紀錄，無法復原。\"\n    },\n    \"uncommittedChanges\": {\n      \"title\": \"偵測到未提交的變更\",\n      \"description_one\": \"您有 {{count}} 個未提交的變更\",\n      \"description_other\": \"您有 {{count}} 個未提交的變更\",\n      \"andUntracked_one\": \"與 {{count}} 個未追蹤的檔案\",\n      \"andUntracked_other\": \"與 {{count}} 個未追蹤的檔案\",\n      \"acknowledgeLabel\": \"我了解這些變更可能會受到影響\"\n    },\n    \"resetWorktree\": {\n      \"title\": \"重設工作樹\",\n      \"enabled\": \"已啟用\",\n      \"disabled\": \"已停用\",\n      \"disabledUncommitted\": \"已停用（偵測到未提交的變更）\",\n      \"restoreDescription\": \"您的工作樹將還原到此提交。\",\n      \"discardChanges_one\": \"捨棄 {{count}} 個未提交的變更。\",\n      \"discardChanges_other\": \"捨棄 {{count}} 個未提交的變更。\",\n      \"untrackedPresent_one\": \"存在 {{count}} 個未追蹤的檔案（不受重設影響）。\",\n      \"untrackedPresent_other\": \"存在 {{count}} 個未追蹤的檔案（不受重設影響）。\",\n      \"forceReset\": \"強制重設（捨棄未提交的變更）\",\n      \"uncommittedWillDiscard\": \"未提交的變更將被捨棄。\",\n      \"uncommittedPresentHint\": \"存在未提交的變更。請啟用強制重設或先提交/暫存後再繼續。\"\n    },\n    \"buttons\": {\n      \"retry\": \"重試\",\n      \"reset\": \"重設\"\n    }\n  },\n  \"conversation\": {\n    \"you\": \"你\",\n    \"thinking\": \"思考中\",\n    \"todo\": \"待辦\",\n    \"todos\": \"待辦事項\",\n    \"completed\": \"已完成\",\n    \"incomplete\": \"未完成\",\n    \"pending\": \"待處理\",\n    \"inProgress\": \"進行中\",\n    \"skipped\": \"已跳過\",\n    \"error\": \"錯誤\",\n    \"retry\": \"重試\",\n    \"showMore\": \"顯示更多\",\n    \"showLess\": \"收起\",\n    \"actions\": {\n      \"cancel\": \"取消\",\n      \"submitFeedback\": \"提交回饋\",\n      \"stop\": \"停止\",\n      \"stopping\": \"停止中\",\n      \"loading\": \"載入中\",\n      \"send\": \"傳送\",\n      \"sending\": \"傳送中\",\n      \"queue\": \"加入佇列\",\n      \"cancelQueue\": \"取消佇列\",\n      \"requestChanges\": \"請求變更\",\n      \"approve\": \"核准\",\n      \"clearReviewComments\": \"清除審查評論\",\n      \"edit\": \"編輯訊息\",\n      \"scrollToPreviousMessage\": \"Go to previous message\",\n      \"reset\": \"重設\",\n      \"resetTooltip\": \"重設到此時點\"\n    },\n    \"approval\": {\n      \"conflictWarning\": \"衝突檔案需要手動解決\",\n      \"conflicts_one\": \"{{count}}個衝突檔案需要手動解決\",\n      \"conflicts_other\": \"{{count}}個衝突檔案需要手動解決\"\n    },\n    \"executors\": \"執行器\",\n    \"saveAsDefault\": \"設為預設\",\n    \"script\": {\n      \"clickToViewLogs\": \"點擊查看日誌\",\n      \"completedSuccessfully\": \"成功完成\",\n      \"exitCode\": \"結束代碼: {{code}}\",\n      \"running\": \"執行中...\"\n    },\n    \"scriptPlaceholder\": {\n      \"setupTitle\": \"設定腳本\",\n      \"setupDescription\": \"未配置設定腳本。設定腳本在程式碼代理啟動前執行。\",\n      \"cleanupTitle\": \"清理腳本\",\n      \"cleanupDescription\": \"未配置清理腳本。清理腳本在程式碼代理完成後執行。\",\n      \"configure\": \"設定\"\n    },\n    \"unableToRenderDiff\": \"無法顯示差異。\",\n    \"updatedTodos\": \"更新的待辦事項\",\n    \"viewInChangesPanel\": \"在變更面板中檢視\",\n    \"sessions\": {\n      \"newSession\": \"新工作階段\",\n      \"latest\": \"最新\",\n      \"previous\": \"上一個\",\n      \"label\": \"工作階段\",\n      \"noPreviousSessions\": \"沒有先前的工作階段\",\n      \"rename\": \"重新命名\",\n      \"renameTitle\": \"重新命名工作階段\",\n      \"renameDescription\": \"輸入此工作階段的新名稱。\",\n      \"renamePlaceholder\": \"輸入工作階段名稱\",\n      \"renaming\": \"正在重新命名...\"\n    },\n    \"reviewComments\": {\n      \"count_one\": \"{{count}} 則審查評論將被包含\",\n      \"count_other\": \"{{count}} 則審查評論將被包含\"\n    },\n    \"workspace\": {\n      \"create\": \"建立\",\n      \"creating\": \"建立中...\"\n    },\n    \"fileEntry\": {\n      \"created\": \"已建立\",\n      \"modified\": \"已修改\",\n      \"deleted\": \"已刪除\",\n      \"renamed\": \"已重新命名\"\n    }\n  },\n  \"scriptFixer\": {\n    \"setupScriptTitle\": \"修復設定腳本\",\n    \"cleanupScriptTitle\": \"修復清理腳本\",\n    \"archiveScriptTitle\": \"修復封存腳本\",\n    \"devServerTitle\": \"修復開發伺服器腳本\",\n    \"scriptLabel\": \"腳本（編輯）\",\n    \"logsLabel\": \"上次執行日誌\",\n    \"saveButton\": \"儲存\",\n    \"saveAndTestButton\": \"儲存並測試\",\n    \"noLogs\": \"沒有可用的執行日誌\",\n    \"selectRepo\": \"儲存庫\",\n    \"fixScript\": \"修復腳本\",\n    \"statusRunning\": \"執行中...\",\n    \"statusSuccess\": \"成功完成\",\n    \"statusFailed\": \"失敗，結束代碼 {{exitCode}}\",\n    \"statusKilled\": \"程序已被終止\"\n  },\n  \"createWorkspaceFromPr\": {\n    \"title\": \"從 PR 建立工作區\",\n    \"description\": \"選擇一個開啟的拉取請求來建立工作區。將使用 PR 標題自動建立任務。\",\n    \"repositoryLabel\": \"儲存庫\",\n    \"remoteLabel\": \"遠端儲存庫\",\n    \"pullRequestLabel\": \"拉取請求\",\n    \"loadingRepositories\": \"正在載入儲存庫...\",\n    \"loadingRemotes\": \"正在載入遠端儲存庫...\",\n    \"noRepositoriesFound\": \"找不到儲存庫\",\n    \"loadingPullRequests\": \"正在載入拉取請求...\",\n    \"selectRepositoryFirst\": \"請先選擇儲存庫\",\n    \"noPullRequestsFound\": \"找不到開啟的拉取請求\",\n    \"runSetupScript\": \"執行設定腳本\",\n    \"creating\": \"建立中...\",\n    \"createWorkspace\": \"建立工作區\",\n    \"selectRepository\": \"選擇儲存庫\",\n    \"selectRemote\": \"選擇遠端儲存庫\",\n    \"selectPullRequest\": \"選擇拉取請求\",\n    \"searchPrsPlaceholder\": \"按編號或標題搜尋 PR...\",\n    \"noMatchingPrs\": \"沒有符合的拉取請求\",\n    \"default\": \"預設\",\n    \"openPrInBrowser\": \"在瀏覽器中開啟 PR\",\n    \"errors\": {\n      \"cliNotInstalled\": \"{{provider}} CLI 未安裝\",\n      \"unsupportedProvider\": \"不支援的 Git 提供者\",\n      \"failedToLoadPrs\": \"載入拉取請求失敗\",\n      \"prNotFound\": \"找不到拉取請求\",\n      \"failedToCreateWorkspace\": \"建立工作區失敗\"\n    }\n  },\n  \"shareDialog\": {\n    \"loginRequired\": {\n      \"title\": \"Sign in required\",\n      \"description\": \"Sign in to access this feature.\",\n      \"action\": \"Sign in\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/integrations/remote/IssueProvider.tsx",
    "content": "import { useMemo, useCallback, type ReactNode } from 'react';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport {\n  ISSUE_COMMENTS_SHAPE,\n  ISSUE_REACTIONS_SHAPE,\n  ISSUE_COMMENT_MUTATION,\n  ISSUE_COMMENT_REACTION_MUTATION,\n  type IssueComment,\n  type IssueCommentReaction,\n} from 'shared/remote-types';\nimport {\n  IssueContext,\n  type IssueContextValue,\n} from '@/shared/hooks/useIssueContext';\n\ninterface IssueProviderProps {\n  issueId: string;\n  children: ReactNode;\n}\n\nexport function IssueProvider({ issueId, children }: IssueProviderProps) {\n  const params = useMemo(() => ({ issue_id: issueId }), [issueId]);\n  const enabled = Boolean(issueId);\n\n  // Shape subscriptions\n  const commentsResult = useShape(ISSUE_COMMENTS_SHAPE, params, {\n    enabled,\n    mutation: ISSUE_COMMENT_MUTATION,\n  });\n  const reactionsResult = useShape(ISSUE_REACTIONS_SHAPE, params, {\n    enabled,\n    mutation: ISSUE_COMMENT_REACTION_MUTATION,\n  });\n\n  // Combined loading state\n  const isLoading = commentsResult.isLoading || reactionsResult.isLoading;\n\n  // First error found\n  const error = commentsResult.error || reactionsResult.error || null;\n\n  // Combined retry\n  const retry = useCallback(() => {\n    commentsResult.retry();\n    reactionsResult.retry();\n  }, [commentsResult, reactionsResult]);\n\n  // Computed Maps for O(1) lookup\n  const commentsById = useMemo(() => {\n    const map = new Map<string, IssueComment>();\n    for (const comment of commentsResult.data) {\n      map.set(comment.id, comment);\n    }\n    return map;\n  }, [commentsResult.data]);\n\n  const reactionsByComment = useMemo(() => {\n    const map = new Map<string, IssueCommentReaction[]>();\n    for (const reaction of reactionsResult.data) {\n      const existing = map.get(reaction.comment_id) ?? [];\n      existing.push(reaction);\n      map.set(reaction.comment_id, existing);\n    }\n    return map;\n  }, [reactionsResult.data]);\n\n  // Lookup helpers\n  const getComment = useCallback(\n    (commentId: string) => commentsById.get(commentId),\n    [commentsById]\n  );\n\n  const getReactionsForComment = useCallback(\n    (commentId: string) => reactionsByComment.get(commentId) ?? [],\n    [reactionsByComment]\n  );\n\n  const getReactionCountForComment = useCallback(\n    (commentId: string) => (reactionsByComment.get(commentId) ?? []).length,\n    [reactionsByComment]\n  );\n\n  const hasUserReactedToComment = useCallback(\n    (commentId: string, userId: string, emoji: string) => {\n      const reactions = reactionsByComment.get(commentId) ?? [];\n      return reactions.some((r) => r.user_id === userId && r.emoji === emoji);\n    },\n    [reactionsByComment]\n  );\n\n  const value = useMemo<IssueContextValue>(\n    () => ({\n      issueId,\n\n      // Data\n      comments: commentsResult.data,\n      reactions: reactionsResult.data,\n\n      // Loading/error\n      isLoading,\n      error,\n      retry,\n\n      // Comment mutations\n      insertComment: commentsResult.insert,\n      updateComment: commentsResult.update,\n      removeComment: commentsResult.remove,\n\n      // Reaction mutations\n      insertReaction: reactionsResult.insert,\n      removeReaction: reactionsResult.remove,\n\n      // Lookup helpers\n      getComment,\n      getReactionsForComment,\n      getReactionCountForComment,\n      hasUserReactedToComment,\n\n      // Computed aggregations\n      commentsById,\n      reactionsByComment,\n    }),\n    [\n      issueId,\n      commentsResult,\n      reactionsResult,\n      isLoading,\n      error,\n      retry,\n      getComment,\n      getReactionsForComment,\n      getReactionCountForComment,\n      hasUserReactedToComment,\n      commentsById,\n      reactionsByComment,\n    ]\n  );\n\n  return (\n    <IssueContext.Provider value={value}>{children}</IssueContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/integrations/vscode/ContextMenu.tsx",
    "content": "import React, { useEffect, useRef, useState } from 'react';\nimport { readClipboardViaBridge } from '@/integrations/vscode/bridge';\nimport { writeClipboardViaBridge } from '@/shared/lib/clipboard';\n\ntype Point = { x: number; y: number };\n\nfunction inIframe(): boolean {\n  try {\n    return window.self !== window.top;\n  } catch {\n    return true;\n  }\n}\n\nfunction isEditable(\n  target: EventTarget | null\n): target is\n  | HTMLInputElement\n  | HTMLTextAreaElement\n  | (HTMLElement & { isContentEditable: boolean }) {\n  const el = target as HTMLElement | null;\n  if (!el) return false;\n  const tag = el.tagName?.toLowerCase();\n  if (tag === 'input' || tag === 'textarea') return true;\n  return !!el.isContentEditable;\n}\n\nasync function readClipboardText(): Promise<string> {\n  return await readClipboardViaBridge();\n}\nasync function writeClipboardText(text: string): Promise<boolean> {\n  return await writeClipboardViaBridge(text);\n}\n\nfunction getSelectedText(): string {\n  const sel = window.getSelection();\n  return sel ? sel.toString() : '';\n}\n\nfunction cutFromInput(el: HTMLInputElement | HTMLTextAreaElement) {\n  const start = el.selectionStart ?? 0;\n  const end = el.selectionEnd ?? 0;\n  if (end > start) {\n    const selected = el.value.slice(start, end);\n    void writeClipboardText(selected);\n    const before = el.value.slice(0, start);\n    const after = el.value.slice(end);\n    el.value = before + after;\n    el.setSelectionRange(start, start);\n    el.dispatchEvent(new Event('input', { bubbles: true }));\n  }\n}\n\nfunction pasteIntoInput(\n  el: HTMLInputElement | HTMLTextAreaElement,\n  text: string\n) {\n  const start = el.selectionStart ?? 0;\n  const end = el.selectionEnd ?? 0;\n  const before = el.value.slice(0, start);\n  const after = el.value.slice(end);\n  el.value = before + text + after;\n  const caret = start + text.length;\n  el.setSelectionRange(caret, caret);\n  el.dispatchEvent(new Event('input', { bubbles: true }));\n}\n\nexport const WebviewContextMenu: React.FC = () => {\n  const [visible, setVisible] = useState(false);\n  const [pos, setPos] = useState<Point>({ x: 0, y: 0 });\n  const [adjustedPos, setAdjustedPos] = useState<Point | null>(null);\n  const [canCut, setCanCut] = useState<boolean>(false);\n  const [canPaste, setCanPaste] = useState<boolean>(false);\n  const targetRef = useRef<EventTarget | null>(null);\n  const menuRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    if (!inIframe()) return;\n    const onContext = (e: MouseEvent) => {\n      e.preventDefault();\n      targetRef.current = e.target;\n      setPos({ x: e.clientX, y: e.clientY });\n      // Decide whether Cut should be shown: only for editable targets with a selection\n      const tgt = e.target as HTMLElement | null;\n      let cut = false;\n      let paste = false;\n      if (tgt && (tgt as HTMLInputElement).selectionStart !== undefined) {\n        const el = tgt as HTMLInputElement | HTMLTextAreaElement;\n        const start = el.selectionStart ?? 0;\n        const end = el.selectionEnd ?? 0;\n        cut = end > start && !el.readOnly && !el.disabled;\n        paste = !el.readOnly && !el.disabled;\n      } else if (isEditable(tgt)) {\n        const sel = window.getSelection();\n        cut = !!sel && sel.toString().length > 0;\n        paste = true;\n      } else {\n        cut = false;\n        paste = false;\n      }\n      setCanCut(cut);\n      setCanPaste(paste);\n      setVisible(true);\n    };\n    const onClick = () => setVisible(false);\n    document.addEventListener('contextmenu', onContext);\n    document.addEventListener('click', onClick);\n    window.addEventListener('blur', onClick);\n    return () => {\n      document.removeEventListener('contextmenu', onContext);\n      document.removeEventListener('click', onClick);\n      window.removeEventListener('blur', onClick);\n    };\n  }, []);\n\n  // When menu becomes visible, adjust position to stay within viewport\n  useEffect(() => {\n    if (!visible) {\n      setAdjustedPos(null);\n      return;\n    }\n    const el = menuRef.current;\n    if (!el) return;\n    // Use a microtask to ensure layout is ready\n    const id = requestAnimationFrame(() => {\n      const menuW = el.offsetWidth;\n      const menuH = el.offsetHeight;\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const margin = 4;\n      let x = pos.x;\n      let y = pos.y;\n      if (x + menuW + margin > vw) x = Math.max(margin, vw - menuW - margin);\n      if (y + menuH + margin > vh) y = Math.max(margin, vh - menuH - margin);\n      setAdjustedPos({ x, y });\n    });\n    return () => cancelAnimationFrame(id);\n  }, [visible, pos]);\n\n  const close = () => setVisible(false);\n\n  const onCopy = async () => {\n    const tgt = targetRef.current as HTMLElement | null;\n    let copied = false;\n    if (tgt && (tgt as HTMLInputElement).selectionStart !== undefined) {\n      const el = tgt as HTMLInputElement | HTMLTextAreaElement;\n      const start = el.selectionStart ?? 0;\n      const end = el.selectionEnd ?? 0;\n      if (end > start) {\n        const selected = el.value.slice(start, end);\n        copied = await writeClipboardText(selected);\n      }\n    }\n    if (!copied) {\n      const sel = getSelectedText();\n      if (sel) copied = await writeClipboardText(sel);\n    }\n    if (!copied) {\n      try {\n        document.execCommand('copy');\n      } catch {\n        /* empty */\n      }\n    }\n    close();\n  };\n\n  const onCut = async () => {\n    const tgt = targetRef.current as HTMLElement | null;\n    if (\n      tgt &&\n      (tgt as HTMLInputElement).selectionStart !== undefined &&\n      !(tgt as HTMLInputElement).readOnly &&\n      !(tgt as HTMLInputElement).disabled\n    ) {\n      cutFromInput(tgt as HTMLInputElement | HTMLTextAreaElement);\n    } else if (isEditable(tgt)) {\n      // contentEditable: emulate cut by copying selection, then deleting via execCommand\n      const sel = getSelectedText();\n      if (sel) {\n        await writeClipboardText(sel);\n        try {\n          document.execCommand('delete');\n        } catch {\n          /* empty */\n        }\n      }\n    } else {\n      // Read-only content: treat Cut as Copy for usability\n      const sel = getSelectedText();\n      if (sel) await writeClipboardText(sel);\n    }\n    close();\n  };\n\n  const onPaste = async () => {\n    const text = await readClipboardText();\n    const tgt = targetRef.current as HTMLElement | null;\n    if (tgt && (tgt as HTMLInputElement).selectionStart !== undefined) {\n      (tgt as HTMLElement).focus();\n      pasteIntoInput(tgt as HTMLInputElement | HTMLTextAreaElement, text);\n    } else if (isEditable(tgt)) {\n      (tgt as HTMLElement).focus();\n      document.execCommand('insertText', false, text);\n    }\n    close();\n  };\n\n  const onUndo = () => {\n    try {\n      document.execCommand('undo');\n    } catch {\n      /* empty */\n    }\n    close();\n  };\n  const onRedo = () => {\n    try {\n      document.execCommand('redo');\n    } catch {\n      /* empty */\n    }\n    close();\n  };\n  const onSelectAll = () => {\n    try {\n      document.execCommand('selectAll');\n    } catch {\n      /* empty */\n    }\n    close();\n  };\n\n  if (!visible) return null;\n\n  return (\n    <div\n      ref={menuRef}\n      style={{\n        position: 'fixed',\n        left: (adjustedPos ?? pos).x,\n        top: (adjustedPos ?? pos).y,\n        zIndex: 99999,\n      }}\n      className=\"min-w-[160px] rounded-md border border-gray-300 bg-white text-gray-900 shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100\"\n      onContextMenu={(e) => e.preventDefault()}\n    >\n      <MenuItem label=\"Copy\" onClick={onCopy} />\n      {canCut && <MenuItem label=\"Cut\" onClick={onCut} />}\n      {canPaste && <MenuItem label=\"Paste\" onClick={onPaste} />}\n      <Divider />\n      <MenuItem label=\"Undo\" onClick={onUndo} />\n      <MenuItem label=\"Redo\" onClick={onRedo} />\n      <Divider />\n      <MenuItem label=\"Select All\" onClick={onSelectAll} />\n    </div>\n  );\n};\n\nconst MenuItem: React.FC<{ label: string; onClick: () => void }> = ({\n  label,\n  onClick,\n}) => (\n  <button\n    className=\"block w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700\"\n    onClick={onClick}\n    type=\"button\"\n  >\n    {label}\n  </button>\n);\n\nconst Divider: React.FC = () => (\n  <div className=\"my-1 h-px bg-gray-200 dark:bg-gray-700\" />\n);\n"
  },
  {
    "path": "packages/web-core/src/integrations/vscode/bridge.ts",
    "content": "// VS Code Webview iframe keyboard bridge\n//\n// Purpose\n// - Make typing, paste/cut/undo/redo inside the iframe feel like a regular browser\n//   input/textarea/contentEditable.\n// - Still allow VS Code to handle global/editor shortcuts by forwarding non-text\n//   editing keys to the parent webview.\n// - Bridge clipboard reads/writes when navigator.clipboard is restricted.\n\n/** Returns true when running inside an iframe (vs top-level window). */\nexport function inIframe(): boolean {\n  try {\n    return window.self !== window.top;\n  } catch {\n    return true;\n  }\n}\n\n/** Minimal serializable keyboard event shape used across the bridge. */\ntype KeyPayload = {\n  key: string;\n  code: string;\n  altKey: boolean;\n  ctrlKey: boolean;\n  shiftKey: boolean;\n  metaKey: boolean;\n  repeat: boolean;\n  isComposing: boolean;\n  location: number;\n};\n\n/** Convert a KeyboardEvent to a serializable payload for postMessage. */\nfunction serializeKeyEvent(e: KeyboardEvent): KeyPayload {\n  return {\n    key: e.key,\n    code: e.code,\n    altKey: e.altKey,\n    ctrlKey: e.ctrlKey,\n    shiftKey: e.shiftKey,\n    metaKey: e.metaKey,\n    repeat: e.repeat,\n    isComposing: e.isComposing,\n    location: e.location ?? 0,\n  };\n}\n\n/** Platform check used for shortcut detection. */\nconst isMac = () => navigator.platform.toUpperCase().includes('MAC');\n\n/** True for Cmd/Ctrl+C (no Shift/Alt). */\nconst isCopy = (e: KeyboardEvent) =>\n  (isMac() ? e.metaKey : e.ctrlKey) &&\n  !e.shiftKey &&\n  !e.altKey &&\n  e.key.toLowerCase() === 'c';\n/** True for Cmd/Ctrl+X (no Shift/Alt). */\nconst isCut = (e: KeyboardEvent) =>\n  (isMac() ? e.metaKey : e.ctrlKey) &&\n  !e.shiftKey &&\n  !e.altKey &&\n  e.key.toLowerCase() === 'x';\n/** True for Cmd/Ctrl+V (no Shift/Alt). */\nconst isPaste = (e: KeyboardEvent) =>\n  (isMac() ? e.metaKey : e.ctrlKey) &&\n  !e.shiftKey &&\n  !e.altKey &&\n  e.key.toLowerCase() === 'v';\n/** True for Cmd/Ctrl+Z. */\nconst isUndo = (e: KeyboardEvent) =>\n  (isMac() ? e.metaKey : e.ctrlKey) &&\n  !e.shiftKey &&\n  !e.altKey &&\n  e.key.toLowerCase() === 'z';\n/** True for redo (Cmd+Shift+Z on macOS, Ctrl+Y elsewhere). */\nconst isRedo = (e: KeyboardEvent) =>\n  (isMac() ? e.metaKey : e.ctrlKey) &&\n  !e.altKey &&\n  ((isMac() && e.shiftKey && e.key.toLowerCase() === 'z') ||\n    (!isMac() && !e.shiftKey && e.key.toLowerCase() === 'y'));\n\n/**\n * Returns the currently focused editable element (input/textarea/contentEditable)\n * or null when focus is not within an editable.\n */\nfunction activeEditable():\n  | HTMLInputElement\n  | HTMLTextAreaElement\n  | (HTMLElement & { isContentEditable: boolean })\n  | null {\n  const el = document.activeElement as HTMLElement | null;\n  if (!el) return null;\n  const tag = el.tagName?.toLowerCase();\n  if (tag === 'input' || tag === 'textarea')\n    return el as HTMLInputElement | HTMLTextAreaElement;\n  if (el.isContentEditable)\n    return el as HTMLElement & { isContentEditable: boolean };\n  return null;\n}\n\n/** Attempt to write to the OS clipboard. Returns true on success. */\nasync function writeClipboardText(text: string): Promise<boolean> {\n  try {\n    await navigator.clipboard.writeText(text);\n    return true;\n  } catch {\n    try {\n      return document.execCommand('copy');\n    } catch {\n      return false;\n    }\n  }\n}\n\n/** Attempt to read from the OS clipboard. Returns empty string on failure. */\nasync function readClipboardText(): Promise<string> {\n  try {\n    return await navigator.clipboard.readText();\n  } catch {\n    return '';\n  }\n}\n\n/** Best-effort selection extractor for inputs, textareas, and contentEditable. */\nfunction getSelectedText(): string {\n  const el = activeEditable() as\n    | HTMLInputElement\n    | HTMLTextAreaElement\n    | (HTMLElement & { isContentEditable: boolean })\n    | null;\n  if (el && (el as HTMLInputElement).selectionStart !== undefined) {\n    const input = el as HTMLInputElement | HTMLTextAreaElement;\n    const start = input.selectionStart ?? 0;\n    const end = input.selectionEnd ?? 0;\n    return start < end ? input.value.slice(start, end) : '';\n  }\n  const sel = window.getSelection();\n  return sel ? sel.toString() : '';\n}\n\n/** Perform a browser-like cut on an input/textarea and emit input/change events. */\nfunction cutFromInput(el: HTMLInputElement | HTMLTextAreaElement) {\n  const start = el.selectionStart ?? 0;\n  const end = el.selectionEnd ?? 0;\n  if (end > start) {\n    const selected = el.value.slice(start, end);\n    void writeClipboardText(selected);\n    if (typeof el.setRangeText === 'function') {\n      el.setRangeText('', start, end, 'end');\n    } else {\n      const before = el.value.slice(0, start);\n      const after = el.value.slice(end);\n      el.value = before + after;\n      el.setSelectionRange(start, start);\n    }\n    const ie: Event =\n      typeof InputEvent === 'function'\n        ? new InputEvent('input', {\n            bubbles: true,\n            composed: true,\n            inputType: 'deleteByCut',\n          })\n        : new Event('input', { bubbles: true });\n    el.dispatchEvent(ie);\n    el.dispatchEvent(new Event('change', { bubbles: true }));\n  }\n}\n\n/** Paste text at the current caret position in an input/textarea and emit events. */\nfunction pasteIntoInput(\n  el: HTMLInputElement | HTMLTextAreaElement,\n  text: string\n) {\n  const start = el.selectionStart ?? el.value.length;\n  const end = el.selectionEnd ?? el.value.length;\n  if (typeof el.setRangeText === 'function') {\n    el.setRangeText(text, start, end, 'end');\n  } else {\n    const before = el.value.slice(0, start);\n    const after = el.value.slice(end);\n    el.value = before + text + after;\n    const caret = start + text.length;\n    el.setSelectionRange(caret, caret);\n  }\n  el.focus();\n  const ie: Event =\n    typeof InputEvent === 'function'\n      ? new InputEvent('input', {\n          bubbles: true,\n          composed: true,\n          inputType: 'insertFromPaste',\n          data: text,\n        })\n      : new Event('input', { bubbles: true });\n  el.dispatchEvent(ie);\n  el.dispatchEvent(new Event('change', { bubbles: true }));\n}\n\n/**\n * Insert text at the caret for the currently active editable.\n * Uses native mechanisms (setRangeText/execCommand) and emits input events so\n * controlled frameworks (like React) update state predictably.\n */\nfunction insertTextAtCaretGeneric(text: string) {\n  const el =\n    (activeEditable() as\n      | HTMLInputElement\n      | HTMLTextAreaElement\n      | (HTMLElement & { isContentEditable: boolean })\n      | null) ||\n    (document.querySelector(\n      'textarea, input:not([type=checkbox]):not([type=radio])'\n    ) as HTMLTextAreaElement | HTMLInputElement | null);\n  if (!el) return;\n  if ((el as HTMLInputElement).selectionStart !== undefined) {\n    pasteIntoInput(el as HTMLInputElement | HTMLTextAreaElement, text);\n  } else {\n    try {\n      document.execCommand('insertText', false, text);\n      el.dispatchEvent(new Event('input', { bubbles: true }));\n    } catch {\n      (el as HTMLElement).innerText += text;\n    }\n  }\n}\n\n// Lightweight retry for cases where add-to arrives before an editable exists\n/** CSS selector for a reasonable first editable fallback. */\nconst EDITABLE_SELECTOR =\n  'textarea, input:not([type=checkbox]):not([type=radio])';\n/** Interval (ms) between retries while we wait for an editable to appear. */\nconst RETRY_INTERVAL_MS = 100;\n/** Maximum number of retry attempts before giving up. */\nconst MAX_RETRY_ATTEMPTS = 15;\nlet insertRetryTimer: number | null = null;\nconst insertQueue: string[] = [];\nfunction enqueueInsert(text: string) {\n  insertQueue.push(text);\n  if (insertRetryTimer != null) return;\n  let attempts = 0;\n  const run = () => {\n    attempts++;\n    const el =\n      activeEditable() ||\n      (document.querySelector(EDITABLE_SELECTOR) as\n        | HTMLTextAreaElement\n        | HTMLInputElement\n        | null);\n    if (el) {\n      // drain queue\n      while (insertQueue.length > 0) {\n        insertTextAtCaretGeneric(insertQueue.shift() as string);\n      }\n      if (insertRetryTimer != null) {\n        window.clearInterval(insertRetryTimer);\n        insertRetryTimer = null;\n      }\n      return;\n    }\n    if (attempts >= MAX_RETRY_ATTEMPTS && insertRetryTimer != null) {\n      window.clearInterval(insertRetryTimer);\n      insertRetryTimer = null;\n    }\n  };\n  insertRetryTimer = window.setInterval(run, RETRY_INTERVAL_MS);\n}\n\n/** Request map to resolve clipboard paste requests from the extension. */\nconst pasteResolvers: Record<string, (text: string) => void> = {};\n\nimport { parentClipboardWrite } from '@/shared/lib/clipboard';\n\n/** Ask the extension to read text from the OS clipboard (fallback path). */\nexport function parentClipboardRead(): Promise<string> {\n  return new Promise((resolve) => {\n    const requestId = Math.random().toString(36).slice(2);\n    pasteResolvers[requestId] = (text: string) => resolve(text);\n    try {\n      window.parent.postMessage(\n        { type: 'vscode-iframe-clipboard-paste-request', requestId },\n        '*'\n      );\n    } catch {\n      resolve('');\n    }\n  });\n}\n\n/** Ask the extension to open a file in VS Code at an optional line. */\nexport function openFileInVSCode(\n  filePath: string,\n  options?: { lineNumber?: number; openAsDiff?: boolean }\n) {\n  if (!inIframe()) return;\n  try {\n    window.parent.postMessage(\n      {\n        type: 'VIBE_OPEN_FILE',\n        filePath,\n        lineNumber: options?.lineNumber,\n        openAsDiff: options?.openAsDiff ?? true,\n      },\n      '*'\n    );\n  } catch (_err) {\n    void 0;\n  }\n}\n\n/** Message union used for iframe <-> extension communications. */\ntype IframeMessage = {\n  type: string;\n  event?: KeyPayload;\n  text?: string;\n  requestId?: string;\n};\n\n// Handle messages from the parent webview (clipboard, add-to input)\nwindow.addEventListener('message', (e: MessageEvent) => {\n  const data: unknown = e?.data;\n  if (!data || typeof data !== 'object') return;\n  const msg = data as IframeMessage;\n  if (msg.type === 'vscode-iframe-clipboard-paste-result' && msg.requestId) {\n    const fn = pasteResolvers[msg.requestId];\n    if (fn) {\n      fn(msg.text || '');\n      delete pasteResolvers[msg.requestId];\n    }\n  }\n  if (msg.type === 'VIBE_ADD_TO_INPUT' && typeof msg.text === 'string') {\n    const el =\n      activeEditable() ||\n      (document.querySelector(EDITABLE_SELECTOR) as\n        | HTMLTextAreaElement\n        | HTMLInputElement\n        | null);\n    if (el) insertTextAtCaretGeneric(msg.text);\n    else enqueueInsert(msg.text);\n  }\n});\n\n/** Install keyboard + clipboard handlers when running inside an iframe. */\nexport function installVSCodeIframeKeyboardBridge() {\n  if (!inIframe()) return;\n\n  const forward = (type: string, e: KeyboardEvent) => {\n    try {\n      window.parent.postMessage({ type, event: serializeKeyEvent(e) }, '*');\n    } catch (_err) {\n      void 0;\n    }\n  };\n\n  const onKeyDown = async (e: KeyboardEvent) => {\n    // Handle clipboard combos locally so OS shortcuts work inside the iframe\n    if (isCopy(e)) {\n      const text = getSelectedText();\n      if (text) {\n        e.preventDefault();\n        e.stopPropagation();\n        const ok = await writeClipboardText(text);\n        if (!ok) parentClipboardWrite(text);\n        return;\n      }\n    } else if (isCut(e)) {\n      const el = activeEditable() as\n        | HTMLInputElement\n        | HTMLTextAreaElement\n        | null;\n      if (el) {\n        e.preventDefault();\n        e.stopPropagation();\n        cutFromInput(el);\n        return;\n      }\n    } else if (isUndo(e)) {\n      e.preventDefault();\n      e.stopPropagation();\n      try {\n        document.execCommand('undo');\n      } catch {\n        /* empty */\n      }\n      return;\n    } else if (isRedo(e)) {\n      e.preventDefault();\n      e.stopPropagation();\n      try {\n        document.execCommand('redo');\n      } catch {\n        /* empty */\n      }\n      return;\n    } else if (isPaste(e)) {\n      const el = activeEditable() as\n        | HTMLInputElement\n        | HTMLTextAreaElement\n        | (HTMLElement & { isContentEditable: boolean })\n        | null;\n      if (el) {\n        e.preventDefault();\n        e.stopPropagation();\n        let text = await readClipboardText();\n        if (!text) text = await parentClipboardRead();\n        insertTextAtCaretGeneric(text);\n        return;\n      }\n    }\n    // Forward everything else so VS Code can handle global shortcuts\n    forward('vscode-iframe-keydown', e);\n  };\n\n  const onKeyUp = (e: KeyboardEvent) => forward('vscode-iframe-keyup', e);\n  const onKeyPress = (e: KeyboardEvent) => forward('vscode-iframe-keypress', e);\n\n  // Capture phase to run before app handlers\n  window.addEventListener('keydown', onKeyDown, true);\n  window.addEventListener('keyup', onKeyUp, true);\n  window.addEventListener('keypress', onKeyPress, true);\n  document.addEventListener('keydown', onKeyDown, true);\n  document.addEventListener('keyup', onKeyUp, true);\n  document.addEventListener('keypress', onKeyPress, true);\n}\n\n/** Paste helper that prefers navigator.clipboard and falls back to the bridge. */\nexport async function readClipboardViaBridge(): Promise<string> {\n  try {\n    return await navigator.clipboard.readText();\n  } catch {\n    return await parentClipboardRead();\n  }\n}\n\n// Auto-install on import to make it robust\ninstallVSCodeIframeKeyboardBridge();\n"
  },
  {
    "path": "packages/web-core/src/mock/normalized_entries.json",
    "content": "[\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"entry_type\": {\n        \"type\": \"user_message\"\n      },\n      \"content\": \"Remove the logo\\n\\nfrontend/src/components/layout/Navbar.tsx\",\n      \"timestamp\": null\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:user\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"error_message\",\n        \"error_type\": {\n          \"type\": \"other\"\n        }\n      },\n      \"content\": \"npm warn Unknown env config \\\"_jsr-registry\\\". This will stop working in the next major version of npm.\\nnpm warn Unknown env config \\\"verify-deps-before-run\\\". This will stop working in the next major version of npm.\\n\",\n      \"metadata\": null\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:0\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"system_message\"\n      },\n      \"content\": \"System initialized with model: claude-sonnet-4-5-20250929\",\n      \"metadata\": null\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:1\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"assistant_message\"\n      },\n      \"content\": \"I'll help you remove the logo from the Navbar component. Let me first read the file to see the current implementation.\",\n      \"metadata\": {\n        \"type\": \"text\",\n        \"text\": \"I'll help you remove the logo from the Navbar component. Let me first read the file to see the current implementation.\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:2\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"tool_use\",\n        \"tool_name\": \"Read\",\n        \"action_type\": {\n          \"action\": \"file_read\",\n          \"path\": \"frontend/src/components/layout/Navbar.tsx\"\n        },\n        \"status\": {\n          \"status\": \"created\"\n        }\n      },\n      \"content\": \"frontend/src/components/layout/Navbar.tsx\",\n      \"metadata\": {\n        \"type\": \"tool_use\",\n        \"id\": \"toolu_01C4eRJgKfiHg5YNz74DnqkZ\",\n        \"name\": \"Read\",\n        \"input\": {\n          \"file_path\": \"/private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\"\n        },\n        \"tool_call_id\": \"toolu_01C4eRJgKfiHg5YNz74DnqkZ\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:3\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"assistant_message\"\n      },\n      \"content\": \"Now I'll remove the logo from the Navbar. I can see the logo is rendered on lines 141-143. I'll also need to remove the import of the Logo component from line 23.\",\n      \"metadata\": {\n        \"type\": \"text\",\n        \"text\": \"Now I'll remove the logo from the Navbar. I can see the logo is rendered on lines 141-143. I'll also need to remove the import of the Logo component from line 23.\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:4\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"tool_use\",\n        \"tool_name\": \"Edit\",\n        \"action_type\": {\n          \"action\": \"file_edit\",\n          \"path\": \"frontend/src/components/layout/Navbar.tsx\",\n          \"changes\": [\n            {\n              \"action\": \"edit\",\n              \"unified_diff\": \"--- a//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n+++ b//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n@@ -1,2 +1 @@\\n-import { Logo } from '@/components/Logo';\\n import { SearchBar } from '@/components/SearchBar';\\n\",\n              \"has_line_numbers\": false\n            }\n          ]\n        },\n        \"status\": {\n          \"status\": \"created\"\n        }\n      },\n      \"content\": \"frontend/src/components/layout/Navbar.tsx\",\n      \"metadata\": {\n        \"type\": \"tool_use\",\n        \"id\": \"toolu_01ECjA5SgaonrPsSnd4bkfxB\",\n        \"name\": \"Edit\",\n        \"input\": {\n          \"file_path\": \"/private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\",\n          \"old_string\": \"import { Logo } from '@/components/Logo';\\nimport { SearchBar } from '@/components/SearchBar';\",\n          \"new_string\": \"import { SearchBar } from '@/components/SearchBar';\"\n        },\n        \"tool_call_id\": \"toolu_01ECjA5SgaonrPsSnd4bkfxB\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:5\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"tool_use\",\n        \"tool_name\": \"Edit\",\n        \"action_type\": {\n          \"action\": \"file_edit\",\n          \"path\": \"frontend/src/components/layout/Navbar.tsx\",\n          \"changes\": [\n            {\n              \"action\": \"edit\",\n              \"unified_diff\": \"--- a//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n+++ b//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n@@ -1,6 +1,3 @@\\n           <div className=\\\"flex-1 flex items-center\\\">\\n-            <Link to=\\\"/projects\\\">\\n-              <Logo />\\n-            </Link>\\n             <a\\n               href=\\\"https://discord.gg/AC4nwVtJM3\\\"\\n\",\n              \"has_line_numbers\": false\n            }\n          ]\n        },\n        \"status\": {\n          \"status\": \"created\"\n        }\n      },\n      \"content\": \"frontend/src/components/layout/Navbar.tsx\",\n      \"metadata\": {\n        \"type\": \"tool_use\",\n        \"id\": \"toolu_01QNp5prW7wYa6SmfQQaTkrR\",\n        \"name\": \"Edit\",\n        \"input\": {\n          \"file_path\": \"/private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\",\n          \"old_string\": \"          <div className=\\\"flex-1 flex items-center\\\">\\n            <Link to=\\\"/projects\\\">\\n              <Logo />\\n            </Link>\\n            <a\\n              href=\\\"https://discord.gg/AC4nwVtJM3\\\"\",\n          \"new_string\": \"          <div className=\\\"flex-1 flex items-center\\\">\\n            <a\\n              href=\\\"https://discord.gg/AC4nwVtJM3\\\"\"\n        },\n        \"tool_call_id\": \"toolu_01QNp5prW7wYa6SmfQQaTkrR\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:6\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"assistant_message\"\n      },\n      \"content\": \"Perfect! I've successfully removed the logo from the Navbar component. The changes I made:\\n\\n1. **Removed the Logo import** (frontend/src/components/layout/Navbar.tsx:23)\\n2. **Removed the Logo component and its Link wrapper** (frontend/src/components/layout/Navbar.tsx:140-143)\\n\\nThe logo has been removed while keeping all other navigation functionality intact, including the Discord link, search bar, and menu buttons.\",\n      \"metadata\": {\n        \"type\": \"text\",\n        \"text\": \"Perfect! I've successfully removed the logo from the Navbar component. The changes I made:\\n\\n1. **Removed the Logo import** (frontend/src/components/layout/Navbar.tsx:23)\\n2. **Removed the Logo component and its Link wrapper** (frontend/src/components/layout/Navbar.tsx:140-143)\\n\\nThe logo has been removed while keeping all other navigation functionality intact, including the Discord link, search bar, and menu buttons.\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:7\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"entry_type\": {\n        \"type\": \"next_action\",\n        \"failed\": false,\n        \"execution_processes\": 1,\n        \"needs_setup\": false,\n        \"setup_help_text\": null\n      },\n      \"content\": \"\",\n      \"timestamp\": null\n    },\n    \"patchKey\": \"next_action\",\n    \"executionProcessId\": \"\"\n  }\n]\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/IssueCommentsSectionContainer.tsx",
    "content": "import {\n  useMemo,\n  useCallback,\n  useState,\n  useRef,\n  useEffect,\n  type Ref,\n} from 'react';\nimport { useDropzone } from 'react-dropzone';\nimport { useTranslation } from 'react-i18next';\nimport { IssueProvider } from '@/integrations/remote/IssueProvider';\nimport { useIssueContext } from '@/shared/hooks/useIssueContext';\nimport { useScratch } from '@/shared/hooks/useScratch';\nimport { useDebouncedCallback } from '@/shared/hooks/useDebouncedCallback';\nimport { useOrgContext } from '@/shared/hooks/useOrgContext';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useCurrentUser } from '@/shared/hooks/auth/useCurrentUser';\nimport { useAzureAttachments } from '@/shared/hooks/useAzureAttachments';\nimport {\n  commitCommentAttachments,\n  deleteAttachment,\n} from '@/shared/lib/remoteApi';\nimport {\n  extractAttachmentIds,\n  removeAttachmentMarkdownBySource,\n  replaceAttachmentSource,\n} from '@/shared/lib/attachmentUtils';\nimport {\n  IssueCommentsSection,\n  type IssueCommentsEditorProps,\n  type IssueCommentData,\n  type ReactionGroup,\n} from '@vibe/ui/components/IssueCommentsSection';\nimport WYSIWYGEditor, {\n  type WYSIWYGEditorRef,\n} from '@/shared/components/WYSIWYGEditor';\nimport { MemberRole } from 'shared/remote-types';\nimport { ScratchType } from 'shared/types';\n\ninterface IssueCommentsSectionContainerProps {\n  issueId: string;\n}\n\n/**\n * Container that wraps IssueCommentsSection with IssueProvider.\n * Manages comment data transformation, mutations, and UI state.\n */\nexport function IssueCommentsSectionContainer({\n  issueId,\n}: IssueCommentsSectionContainerProps) {\n  return (\n    <IssueProvider issueId={issueId}>\n      <IssueCommentsSectionContent />\n    </IssueProvider>\n  );\n}\n\nfunction IssueCommentsSectionContent() {\n  const { t } = useTranslation('common');\n  const { membersWithProfilesById } = useOrgContext();\n  const { projectId } = useProjectContext();\n  const issueContext = useIssueContext();\n  const { data: currentUser } = useCurrentUser();\n  const currentUserId = currentUser?.user_id ?? '';\n\n  // Check if current user is admin\n  const currentUserMember = currentUserId\n    ? membersWithProfilesById.get(currentUserId)\n    : undefined;\n  const isCurrentUserAdmin = currentUserMember?.role === MemberRole.ADMIN;\n\n  // Ref to comment editor for programmatic focus\n  const commentEditorRef = useRef<WYSIWYGEditorRef>(null);\n\n  // UI state for comment input\n  const [commentInput, setCommentInput] = useState('');\n  const commentDraftId = `issue-comment:${issueContext.issueId}`;\n  const {\n    scratch: commentDraftScratch,\n    updateScratch: updateCommentDraft,\n    deleteScratch: deleteCommentDraft,\n    isLoading: isCommentDraftLoading,\n  } = useScratch(ScratchType.DRAFT_TASK, commentDraftId);\n  const commentDraft =\n    commentDraftScratch?.payload?.type === 'DRAFT_TASK'\n      ? commentDraftScratch.payload.data\n      : undefined;\n  const hydratedCommentDraftIdRef = useRef<string | null>(null);\n  const skipNextPersistRef = useRef(false);\n\n  const persistCommentDraft = useCallback(\n    async (value: string) => {\n      try {\n        if (!value.trim()) {\n          await deleteCommentDraft();\n          return;\n        }\n\n        await updateCommentDraft({\n          payload: {\n            type: 'DRAFT_TASK',\n            data: value,\n          },\n        });\n      } catch (e) {\n        console.error('[IssueCommentsSection] Failed to persist draft:', e);\n      }\n    },\n    [updateCommentDraft, deleteCommentDraft]\n  );\n\n  const {\n    debounced: debouncedPersistCommentDraft,\n    cancel: cancelDebouncedPersistCommentDraft,\n  } = useDebouncedCallback(persistCommentDraft, 500);\n\n  useEffect(() => {\n    cancelDebouncedPersistCommentDraft();\n    hydratedCommentDraftIdRef.current = null;\n    skipNextPersistRef.current = false;\n    setCommentInput('');\n  }, [commentDraftId, cancelDebouncedPersistCommentDraft]);\n\n  useEffect(() => {\n    if (isCommentDraftLoading) return;\n    if (hydratedCommentDraftIdRef.current === commentDraftId) return;\n\n    const nextCommentInput = commentDraft ?? '';\n    const shouldSkipNextPersist = nextCommentInput !== commentInput;\n\n    hydratedCommentDraftIdRef.current = commentDraftId;\n    skipNextPersistRef.current = shouldSkipNextPersist;\n    setCommentInput(nextCommentInput);\n  }, [isCommentDraftLoading, commentDraft, commentDraftId, commentInput]);\n\n  const handleCommentMarkdownInsert = useCallback((markdown: string) => {\n    setCommentInput((prev) =>\n      prev.trim() ? `${prev}\\n\\n${markdown}` : markdown\n    );\n  }, []);\n\n  const handleCommentSourceReplace = useCallback(\n    (previousSrc: string, nextSrc: string) => {\n      let didReplace = false;\n      setCommentInput((prev) => {\n        const { content, replaced } = replaceAttachmentSource(\n          prev,\n          previousSrc,\n          nextSrc\n        );\n        didReplace = replaced;\n        return content;\n      });\n      return didReplace;\n    },\n    []\n  );\n\n  const handleCommentSourceRemove = useCallback((src: string) => {\n    let didRemove = false;\n    setCommentInput((prev) => {\n      const { content, removed } = removeAttachmentMarkdownBySource(prev, src);\n      didRemove = removed;\n      return content;\n    });\n    return didRemove;\n  }, []);\n\n  const {\n    uploadFiles,\n    getAttachmentIds,\n    clearAttachments,\n    isUploading,\n    hasPendingAttachments,\n    uploadError,\n    clearUploadError,\n    localAttachments,\n  } = useAzureAttachments({\n    projectId,\n    onMarkdownInsert: handleCommentMarkdownInsert,\n    onAttachmentSourceReplace: handleCommentSourceReplace,\n    onAttachmentSourceRemove: handleCommentSourceRemove,\n  });\n\n  useEffect(() => {\n    if (hydratedCommentDraftIdRef.current !== commentDraftId) return;\n    if (hasPendingAttachments) return;\n    if (skipNextPersistRef.current) {\n      skipNextPersistRef.current = false;\n      return;\n    }\n\n    debouncedPersistCommentDraft(commentInput);\n  }, [\n    commentInput,\n    commentDraftId,\n    debouncedPersistCommentDraft,\n    hasPendingAttachments,\n  ]);\n\n  const { getRootProps, getInputProps, isDragActive, open } = useDropzone({\n    onDrop: (acceptedFiles) => {\n      if (acceptedFiles.length > 0) uploadFiles(acceptedFiles);\n    },\n    multiple: true,\n    noClick: true,\n    noKeyboard: true,\n  });\n\n  const onPasteFiles = useCallback(\n    (files: File[]) => {\n      if (files.length > 0) uploadFiles(files);\n    },\n    [uploadFiles]\n  );\n\n  // UI state for editing\n  const [editingCommentId, setEditingCommentId] = useState<string | null>(null);\n  const [editingValue, setEditingValue] = useState('');\n\n  // Transform IssueComment to IssueCommentData\n  const commentsData = useMemo<IssueCommentData[]>(() => {\n    return issueContext.comments\n      .map((comment) => {\n        const author = comment.author_id\n          ? membersWithProfilesById.get(comment.author_id)\n          : undefined;\n        const isAuthor =\n          comment.author_id !== null && comment.author_id === currentUserId;\n        const canModify = isAuthor || isCurrentUserAdmin;\n        return {\n          id: comment.id,\n          authorId: comment.author_id,\n          authorName: comment.author_id\n            ? author\n              ? `${author.first_name ?? ''} ${author.last_name ?? ''}`.trim() ||\n                author.email ||\n                t('kanban.unknownUser')\n              : t('kanban.unknownUser')\n            : t('kanban.deletedUser'),\n          message: comment.message,\n          createdAt: comment.created_at,\n          author: author ?? null,\n          canModify,\n        };\n      })\n      .sort(\n        (a, b) =>\n          new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()\n      );\n  }, [\n    issueContext.comments,\n    membersWithProfilesById,\n    currentUserId,\n    isCurrentUserAdmin,\n    t,\n  ]);\n\n  // Group reactions by comment, then by emoji\n  const reactionsByCommentId = useMemo(() => {\n    const result = new Map<string, ReactionGroup[]>();\n\n    for (const comment of commentsData) {\n      const commentReactions = issueContext.getReactionsForComment(comment.id);\n      const emojiMap = new Map<\n        string,\n        {\n          count: number;\n          hasReacted: boolean;\n          reactionId: string | undefined;\n          userIds: string[];\n        }\n      >();\n\n      for (const reaction of commentReactions) {\n        const existing = emojiMap.get(reaction.emoji);\n        const isCurrentUser = reaction.user_id === currentUserId;\n\n        if (existing) {\n          existing.count++;\n          existing.userIds.push(reaction.user_id);\n          if (isCurrentUser) {\n            existing.hasReacted = true;\n            existing.reactionId = reaction.id;\n          }\n        } else {\n          emojiMap.set(reaction.emoji, {\n            count: 1,\n            hasReacted: isCurrentUser,\n            reactionId: isCurrentUser ? reaction.id : undefined,\n            userIds: [reaction.user_id],\n          });\n        }\n      }\n\n      const groups: ReactionGroup[] = Array.from(emojiMap.entries()).map(\n        ([emoji, data]) => ({\n          emoji,\n          count: data.count,\n          hasReacted: data.hasReacted,\n          reactionId: data.reactionId,\n          userNames: data.userIds.map((userId) => {\n            const member = membersWithProfilesById.get(userId);\n            return member\n              ? `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() ||\n                  member.email ||\n                  t('kanban.unknownUser')\n              : t('kanban.unknownUser');\n          }),\n        })\n      );\n\n      result.set(comment.id, groups);\n    }\n\n    return result;\n  }, [commentsData, issueContext, currentUserId, membersWithProfilesById, t]);\n\n  const handleSubmitComment = useCallback(async () => {\n    if (!commentInput.trim()) return;\n    const message = commentInput.trim();\n    const { persisted } = issueContext.insertComment({\n      issue_id: issueContext.issueId,\n      message,\n      parent_id: null,\n    });\n    cancelDebouncedPersistCommentDraft();\n    setCommentInput('');\n    try {\n      await deleteCommentDraft();\n    } catch (e) {\n      console.error('[IssueCommentsSection] Failed to clear draft:', e);\n    }\n\n    const allUploadedIds = getAttachmentIds();\n    if (allUploadedIds.length > 0) {\n      const referencedIds = extractAttachmentIds(message);\n      const idsToCommit = allUploadedIds.filter((id) => referencedIds.has(id));\n      const idsToDelete = allUploadedIds.filter((id) => !referencedIds.has(id));\n\n      if (idsToCommit.length > 0) {\n        try {\n          const confirmedComment = await persisted;\n          await commitCommentAttachments(confirmedComment.id, {\n            attachment_ids: idsToCommit,\n          });\n        } catch (err) {\n          console.error('Failed to commit comment attachments:', err);\n        }\n      }\n      for (const id of idsToDelete) {\n        deleteAttachment(id).catch((err) =>\n          console.error('Failed to delete abandoned attachment:', err)\n        );\n      }\n    }\n    clearAttachments();\n  }, [\n    commentInput,\n    issueContext,\n    getAttachmentIds,\n    clearAttachments,\n    cancelDebouncedPersistCommentDraft,\n    deleteCommentDraft,\n  ]);\n\n  const handleStartEdit = useCallback(\n    (commentId: string) => {\n      const comment = commentsData.find((c) => c.id === commentId);\n      if (comment) {\n        setEditingCommentId(commentId);\n        setEditingValue(comment.message);\n      }\n    },\n    [commentsData]\n  );\n\n  const handleSaveEdit = useCallback(() => {\n    if (!editingCommentId || !editingValue.trim()) return;\n    issueContext.updateComment(editingCommentId, {\n      message: editingValue.trim(),\n    });\n    setEditingCommentId(null);\n    setEditingValue('');\n  }, [editingCommentId, editingValue, issueContext]);\n\n  const handleCancelEdit = useCallback(() => {\n    setEditingCommentId(null);\n    setEditingValue('');\n  }, []);\n\n  const handleDeleteComment = useCallback(\n    (id: string) => {\n      issueContext.removeComment(id);\n    },\n    [issueContext]\n  );\n\n  const handleToggleReaction = useCallback(\n    (commentId: string, emoji: string) => {\n      // Check if user already has this reaction\n      const reactions = issueContext.getReactionsForComment(commentId);\n      const existingReaction = reactions.find(\n        (r) => r.user_id === currentUserId && r.emoji === emoji\n      );\n\n      if (existingReaction) {\n        // Remove the reaction\n        issueContext.removeReaction(existingReaction.id);\n      } else {\n        // Add the reaction\n        issueContext.insertReaction({\n          comment_id: commentId,\n          emoji,\n        });\n      }\n    },\n    [issueContext, currentUserId]\n  );\n\n  const handleReply = useCallback(\n    (authorName: string, message: string) => {\n      // Get first line of the message for the quote\n      const firstLine = message.split('\\n')[0].trim();\n      const truncatedLine =\n        firstLine.length > 100 ? `${firstLine.slice(0, 100)}...` : firstLine;\n      const quote = `> ${authorName} ${t('kanban.replyQuotePrefix')}\\n> ${truncatedLine}`;\n      setCommentInput(quote);\n      // Focus editor after setting value (setTimeout ensures value is set first)\n      setTimeout(() => {\n        commentEditorRef.current?.focus();\n      }, 0);\n    },\n    [t]\n  );\n\n  const renderEditor = useCallback(\n    ({\n      value,\n      onChange,\n      placeholder,\n      className,\n      disabled,\n      autoFocus,\n      onCmdEnter,\n      onPasteFiles,\n      localAttachments,\n      editorRef,\n    }: IssueCommentsEditorProps) => (\n      <WYSIWYGEditor\n        ref={editorRef as Ref<WYSIWYGEditorRef>}\n        value={value}\n        onChange={onChange}\n        placeholder={placeholder}\n        className={className}\n        disabled={disabled}\n        autoFocus={autoFocus}\n        onCmdEnter={onCmdEnter}\n        onPasteFiles={onPasteFiles}\n        localAttachments={localAttachments}\n      />\n    ),\n    []\n  );\n\n  return (\n    <IssueCommentsSection\n      comments={commentsData}\n      commentInput={commentInput}\n      onCommentInputChange={setCommentInput}\n      onSubmitComment={handleSubmitComment}\n      editingCommentId={editingCommentId}\n      editingValue={editingValue}\n      onEditingValueChange={setEditingValue}\n      onStartEdit={handleStartEdit}\n      onSaveEdit={handleSaveEdit}\n      onCancelEdit={handleCancelEdit}\n      onDeleteComment={handleDeleteComment}\n      reactionsByCommentId={reactionsByCommentId}\n      onToggleReaction={handleToggleReaction}\n      onReply={handleReply}\n      isLoading={issueContext.isLoading}\n      commentEditorRef={commentEditorRef}\n      onPasteFiles={onPasteFiles}\n      localAttachments={localAttachments}\n      dropzoneProps={{ getRootProps, getInputProps, isDragActive }}\n      onBrowseAttachment={open}\n      isUploading={isUploading}\n      attachmentError={uploadError}\n      onDismissAttachmentError={clearUploadError}\n      renderEditor={renderEditor}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/IssueRelationshipsSectionContainer.tsx",
    "content": "import { useMemo, useCallback } from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport {\n  PlusIcon,\n  ArrowBendUpRightIcon,\n  ProhibitIcon,\n  ArrowsLeftRightIcon,\n  CopyIcon,\n} from '@phosphor-icons/react';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { resolveRelationshipsForIssue } from '@/shared/lib/resolveRelationships';\nimport { IssueRelationshipsSection } from '@vibe/ui/components/IssueRelationshipsSection';\nimport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n} from '@vibe/ui/components/Dropdown';\n\ninterface IssueRelationshipsSectionContainerProps {\n  issueId: string;\n}\n\nexport function IssueRelationshipsSectionContainer({\n  issueId,\n}: IssueRelationshipsSectionContainerProps) {\n  const { projectId } = useParams({ strict: false });\n  const appNavigation = useAppNavigation();\n  const { openRelationshipSelection } = useActions();\n\n  const {\n    getRelationshipsForIssue,\n    removeIssueRelationship,\n    issuesById,\n    isLoading,\n  } = useProjectContext();\n\n  const relationships = useMemo(\n    () =>\n      resolveRelationshipsForIssue(\n        issueId,\n        getRelationshipsForIssue(issueId),\n        issuesById\n      ),\n    [issueId, getRelationshipsForIssue, issuesById]\n  );\n\n  const handleRelationshipClick = useCallback(\n    (relatedIssueId: string) => {\n      if (!projectId) {\n        return;\n      }\n\n      appNavigation.goToProjectIssue(projectId, relatedIssueId);\n    },\n    [projectId, appNavigation]\n  );\n\n  const handleRemoveRelationship = useCallback(\n    (relationshipId: string) => {\n      removeIssueRelationship(relationshipId);\n    },\n    [removeIssueRelationship]\n  );\n\n  const handleSelectType = useCallback(\n    (\n      relationshipType: 'blocking' | 'related' | 'has_duplicate',\n      direction: 'forward' | 'reverse'\n    ) => {\n      if (projectId) {\n        openRelationshipSelection(\n          projectId,\n          issueId,\n          relationshipType,\n          direction\n        );\n      }\n    },\n    [projectId, issueId, openRelationshipSelection]\n  );\n\n  const headerExtra = (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <span\n          role=\"button\"\n          tabIndex={0}\n          className=\"text-low hover:text-normal\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n        </span>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem\n          icon={ArrowBendUpRightIcon}\n          onSelect={() => handleSelectType('blocking', 'forward')}\n        >\n          Blocks...\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          icon={ProhibitIcon}\n          onSelect={() => handleSelectType('blocking', 'reverse')}\n        >\n          Blocked by...\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          icon={ArrowsLeftRightIcon}\n          onSelect={() => handleSelectType('related', 'forward')}\n        >\n          Related to...\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          icon={CopyIcon}\n          onSelect={() => handleSelectType('has_duplicate', 'forward')}\n        >\n          Duplicate of...\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n\n  return (\n    <IssueRelationshipsSection\n      relationships={relationships}\n      onRelationshipClick={handleRelationshipClick}\n      onRemoveRelationship={handleRemoveRelationship}\n      isLoading={isLoading}\n      headerExtra={headerExtra}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/IssueSubIssuesSectionContainer.tsx",
    "content": "import { useMemo, useCallback, useState } from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport { DragDropContext, type DropResult } from '@hello-pangea/dnd';\nimport { PlusIcon, LinkIcon } from '@phosphor-icons/react';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useOrgContext } from '@/shared/hooks/useOrgContext';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { Actions } from '@/shared/actions';\nimport { bulkUpdateIssues } from '@/shared/lib/remoteApi';\nimport { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog';\nimport {\n  IssueSubIssuesSection,\n  type SubIssueData,\n} from '@vibe/ui/components/IssueSubIssuesSection';\nimport type { SectionAction } from '@vibe/ui/components/CollapsibleSectionHeader';\n\ninterface IssueSubIssuesSectionContainerProps {\n  issueId: string;\n}\n\n/**\n * Container component for the sub-issues section.\n * Fetches sub-issues from ProjectContext and transforms them for display.\n * Supports drag-and-drop reordering of sub-issues.\n */\nexport function IssueSubIssuesSectionContainer({\n  issueId,\n}: IssueSubIssuesSectionContainerProps) {\n  const { projectId } = useParams({ strict: false });\n  const appNavigation = useAppNavigation();\n  const {\n    executeAction,\n    openSubIssueSelection,\n    openPrioritySelection,\n    openAssigneeSelection,\n  } = useActions();\n\n  const {\n    issues,\n    statuses,\n    updateIssue,\n    removeIssue,\n    getAssigneesForIssue,\n    isLoading: projectLoading,\n  } = useProjectContext();\n\n  const { membersWithProfilesById, isLoading: orgLoading } = useOrgContext();\n\n  // Create lookup maps for efficient access\n  const statusesById = useMemo(() => {\n    return new Map(statuses.map((s) => [s.id, s]));\n  }, [statuses]);\n\n  // Filter, sort, and transform sub-issues\n  const subIssues: SubIssueData[] = useMemo(() => {\n    return issues\n      .filter((issue) => issue.parent_issue_id === issueId)\n      .sort((a, b) => {\n        // Sort by parent_issue_sort_order (nulls last), then by created_at\n        const aOrder = a.parent_issue_sort_order;\n        const bOrder = b.parent_issue_sort_order;\n        if (aOrder === null && bOrder === null) {\n          return (\n            new Date(a.created_at).getTime() - new Date(b.created_at).getTime()\n          );\n        }\n        if (aOrder === null) return 1;\n        if (bOrder === null) return -1;\n        return aOrder - bOrder;\n      })\n      .map((issue) => {\n        const status = statusesById.get(issue.status_id);\n        const assigneeRecords = getAssigneesForIssue(issue.id);\n        const assignees = assigneeRecords\n          .map((a) => membersWithProfilesById.get(a.user_id))\n          .filter((u): u is NonNullable<typeof u> => u !== undefined);\n\n        return {\n          id: issue.id,\n          simpleId: issue.simple_id,\n          title: issue.title,\n          priority: issue.priority,\n          statusColor: status?.color ?? '#888888',\n          assignees,\n          createdAt: issue.created_at,\n          parentIssueSortOrder: issue.parent_issue_sort_order ?? null,\n        };\n      });\n  }, [\n    issues,\n    issueId,\n    statusesById,\n    membersWithProfilesById,\n    getAssigneesForIssue,\n  ]);\n\n  // Handle clicking on a sub-issue to navigate to it\n  const handleSubIssueClick = useCallback(\n    (subIssueId: string) => {\n      if (!projectId) {\n        return;\n      }\n\n      appNavigation.goToProjectIssue(projectId, subIssueId);\n    },\n    [projectId, appNavigation]\n  );\n\n  // Track reordering state for loading overlay\n  const [isReordering, setIsReordering] = useState(false);\n\n  // Handle drag and drop reordering\n  const handleDragEnd = useCallback(\n    (result: DropResult) => {\n      if (!result.destination) return;\n      if (result.source.index === result.destination.index) return;\n\n      // Reorder locally\n      const reordered = [...subIssues];\n      const [moved] = reordered.splice(result.source.index, 1);\n      reordered.splice(result.destination.index, 0, moved);\n\n      // Build updates: all items get sequential integers 0, 1, 2, ...\n      const updates = reordered.map((item, index) => ({\n        id: item.id,\n        changes: { parent_issue_sort_order: index },\n      }));\n\n      // Show loading overlay while saving\n      setIsReordering(true);\n      bulkUpdateIssues(updates)\n        .catch((err) => {\n          console.error('Failed to update sort order:', err);\n        })\n        .finally(() => {\n          // Small delay before hiding loader to prevent flicker\n          setTimeout(() => setIsReordering(false), 500);\n        });\n    },\n    [subIssues]\n  );\n\n  const isLoading = projectLoading || orgLoading;\n\n  // Handle clicking '+' to create new sub-issue immediately\n  const handleCreateNewSubIssue = useCallback(() => {\n    if (projectId) {\n      void executeAction(Actions.CreateSubIssue, undefined, projectId, [\n        issueId,\n      ]);\n    }\n  }, [executeAction, projectId, issueId]);\n\n  // Handle clicking link icon to select an existing issue as sub-issue\n  const handleLinkSubIssue = useCallback(() => {\n    if (projectId) {\n      openSubIssueSelection(projectId, issueId);\n    }\n  }, [projectId, issueId, openSubIssueSelection]);\n\n  // Inline editing callbacks for sub-issue rows\n  const handleSubIssuePriorityClick = useCallback(\n    (subIssueId: string) => {\n      if (projectId) {\n        openPrioritySelection(projectId, [subIssueId]);\n      }\n    },\n    [projectId, openPrioritySelection]\n  );\n\n  const handleSubIssueAssigneeClick = useCallback(\n    (subIssueId: string) => {\n      if (projectId) {\n        openAssigneeSelection(projectId, [subIssueId]);\n      }\n    },\n    [projectId, openAssigneeSelection]\n  );\n\n  const handleSubIssueMarkIndependent = useCallback(\n    (subIssueId: string) => {\n      updateIssue(subIssueId, {\n        parent_issue_id: null,\n        parent_issue_sort_order: null,\n      });\n    },\n    [updateIssue]\n  );\n\n  const handleSubIssueDelete = useCallback(\n    async (subIssueId: string) => {\n      const subIssue = issues.find((issue) => issue.id === subIssueId);\n      const result = await ConfirmDialog.show({\n        title: 'Delete Sub-issue',\n        message: subIssue\n          ? `Are you sure you want to delete \"${subIssue.title}\"? This action cannot be undone.`\n          : 'Are you sure you want to delete this sub-issue? This action cannot be undone.',\n        confirmText: 'Delete',\n        cancelText: 'Cancel',\n        variant: 'destructive',\n      });\n\n      if (result === 'confirmed') {\n        removeIssue(subIssueId);\n      }\n    },\n    [issues, removeIssue]\n  );\n\n  // Actions for the section header\n  const actions: SectionAction[] = useMemo(\n    () => [\n      {\n        icon: PlusIcon,\n        onClick: handleCreateNewSubIssue,\n      },\n      {\n        icon: LinkIcon,\n        onClick: handleLinkSubIssue,\n      },\n    ],\n    [handleCreateNewSubIssue, handleLinkSubIssue]\n  );\n\n  return (\n    <DragDropContext onDragEnd={handleDragEnd}>\n      <IssueSubIssuesSection\n        parentIssueId={issueId}\n        subIssues={subIssues}\n        onSubIssueClick={handleSubIssueClick}\n        onSubIssueMarkIndependent={handleSubIssueMarkIndependent}\n        onSubIssueDelete={handleSubIssueDelete}\n        onSubIssuePriorityClick={handleSubIssuePriorityClick}\n        onSubIssueAssigneeClick={handleSubIssueAssigneeClick}\n        isLoading={isLoading}\n        isReordering={isReordering}\n        actions={actions}\n      />\n    </DragDropContext>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/IssueWorkspacesSectionContainer.tsx",
    "content": "import { useMemo, useCallback } from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport { useTranslation } from 'react-i18next';\nimport { LinkIcon, PlusIcon } from '@phosphor-icons/react';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useOrgContext } from '@/shared/hooks/useOrgContext';\nimport { useUserContext } from '@/shared/hooks/useUserContext';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useProjectWorkspaceCreateDraft } from '@/shared/hooks/useProjectWorkspaceCreateDraft';\nimport { workspacesApi } from '@/shared/lib/api';\nimport { getWorkspaceDefaults } from '@/shared/lib/workspaceDefaults';\nimport {\n  buildLinkedIssueCreateState,\n  buildLocalWorkspaceIdSet,\n  buildWorkspaceCreateInitialState,\n  buildWorkspaceCreatePrompt,\n} from '@/shared/lib/workspaceCreateState';\nimport { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog';\nimport { DeleteWorkspaceDialog } from '@vibe/ui/components/DeleteWorkspaceDialog';\nimport type { WorkspaceWithStats } from '@vibe/ui/components/IssueWorkspaceCard';\nimport { IssueWorkspacesSection } from '@vibe/ui/components/IssueWorkspacesSection';\nimport type { SectionAction } from '@vibe/ui/components/CollapsibleSectionHeader';\n\ninterface IssueWorkspacesSectionContainerProps {\n  issueId: string;\n}\n\n/**\n * Container component for the workspaces section.\n * Fetches workspace data from ProjectContext and transforms it for display.\n */\nexport function IssueWorkspacesSectionContainer({\n  issueId,\n}: IssueWorkspacesSectionContainerProps) {\n  const { t } = useTranslation('common');\n  const { projectId } = useParams({ strict: false });\n  const appNavigation = useAppNavigation();\n  const { openWorkspaceCreateFromState } = useProjectWorkspaceCreateDraft();\n  const { userId } = useAuth();\n  const { workspaces } = useUserContext();\n\n  const {\n    pullRequests,\n    getIssue,\n    getWorkspacesForIssue,\n    issues,\n    isLoading: projectLoading,\n  } = useProjectContext();\n  const { activeWorkspaces, archivedWorkspaces } = useWorkspaceContext();\n  const { membersWithProfilesById, isLoading: orgLoading } = useOrgContext();\n\n  const localWorkspacesById = useMemo(() => {\n    const map = new Map<string, (typeof activeWorkspaces)[number]>();\n\n    for (const workspace of activeWorkspaces) {\n      map.set(workspace.id, workspace);\n    }\n\n    for (const workspace of archivedWorkspaces) {\n      map.set(workspace.id, workspace);\n    }\n\n    return map;\n  }, [activeWorkspaces, archivedWorkspaces]);\n\n  // Get workspaces for the issue, with PR info\n  const workspacesWithStats: WorkspaceWithStats[] = useMemo(() => {\n    const rawWorkspaces = getWorkspacesForIssue(issueId);\n\n    return rawWorkspaces.map((workspace) => {\n      const localWorkspace = workspace.local_workspace_id\n        ? localWorkspacesById.get(workspace.local_workspace_id)\n        : undefined;\n\n      // Find all linked PRs for this workspace\n      const linkedPrs = pullRequests\n        .filter((pr) => pr.workspace_id === workspace.id)\n        .map((pr) => ({\n          number: pr.number,\n          url: pr.url,\n          status: pr.status as 'open' | 'merged' | 'closed',\n        }));\n\n      // Get owner\n      const owner =\n        membersWithProfilesById.get(workspace.owner_user_id) ?? null;\n\n      return {\n        id: workspace.id,\n        localWorkspaceId: workspace.local_workspace_id,\n        name: workspace.name,\n        archived: workspace.archived,\n        filesChanged: workspace.files_changed ?? 0,\n        linesAdded: workspace.lines_added ?? 0,\n        linesRemoved: workspace.lines_removed ?? 0,\n        prs: linkedPrs,\n        owner,\n        updatedAt: workspace.updated_at,\n        isOwnedByCurrentUser: workspace.owner_user_id === userId,\n        isRunning: localWorkspace?.isRunning,\n        hasPendingApproval: localWorkspace?.hasPendingApproval,\n        hasRunningDevServer: localWorkspace?.hasRunningDevServer,\n        hasUnseenActivity: localWorkspace?.hasUnseenActivity,\n        latestProcessCompletedAt: localWorkspace?.latestProcessCompletedAt,\n        latestProcessStatus: localWorkspace?.latestProcessStatus,\n      };\n    });\n  }, [\n    issueId,\n    getWorkspacesForIssue,\n    pullRequests,\n    membersWithProfilesById,\n    userId,\n    localWorkspacesById,\n  ]);\n\n  const isLoading = projectLoading || orgLoading;\n  const shouldAnimateCreateButton = useMemo(() => {\n    if (issues.length !== 1) {\n      return false;\n    }\n\n    return issues.every(\n      (issue) => getWorkspacesForIssue(issue.id).length === 0\n    );\n  }, [issues, getWorkspacesForIssue]);\n\n  // Handle clicking '+' to create and link a new workspace directly\n  const handleAddWorkspace = useCallback(async () => {\n    if (!projectId) {\n      return;\n    }\n\n    const issue = getIssue(issueId);\n    const initialPrompt = buildWorkspaceCreatePrompt(\n      issue?.title ?? null,\n      issue?.description ?? null\n    );\n    const localWorkspaceIds = buildLocalWorkspaceIdSet(\n      activeWorkspaces,\n      archivedWorkspaces\n    );\n\n    const defaults = await getWorkspaceDefaults(\n      workspaces,\n      localWorkspaceIds,\n      projectId\n    );\n    const createState = buildWorkspaceCreateInitialState({\n      prompt: initialPrompt,\n      defaults,\n      linkedIssue: buildLinkedIssueCreateState(issue, projectId),\n    });\n\n    const draftId = await openWorkspaceCreateFromState(createState, {\n      issueId,\n    });\n    if (!draftId) {\n      await ConfirmDialog.show({\n        title: t('common:error'),\n        message: t(\n          'workspaces.createDraftError',\n          'Failed to prepare workspace draft. Please try again.'\n        ),\n        confirmText: t('common:ok'),\n        showCancelButton: false,\n      });\n    }\n  }, [\n    projectId,\n    openWorkspaceCreateFromState,\n    getIssue,\n    issueId,\n    activeWorkspaces,\n    archivedWorkspaces,\n    workspaces,\n    t,\n  ]);\n\n  // Handle clicking link action to link an existing workspace\n  const handleLinkWorkspace = useCallback(async () => {\n    if (!projectId) {\n      return;\n    }\n\n    const { WorkspaceSelectionDialog } = await import(\n      '@/shared/dialogs/command-bar/WorkspaceSelectionDialog'\n    );\n    await WorkspaceSelectionDialog.show({ projectId, issueId });\n  }, [projectId, issueId]);\n\n  // Handle clicking a workspace card to open it\n  const handleWorkspaceClick = useCallback(\n    (localWorkspaceId: string | null) => {\n      if (projectId && localWorkspaceId) {\n        appNavigation.goToProjectIssueWorkspace(\n          projectId,\n          issueId,\n          localWorkspaceId\n        );\n      }\n    },\n    [projectId, issueId, appNavigation]\n  );\n\n  // Handle unlinking a workspace from the issue\n  const handleUnlinkWorkspace = useCallback(\n    async (localWorkspaceId: string) => {\n      const result = await ConfirmDialog.show({\n        title: t('workspaces.unlinkFromIssue'),\n        message: t('workspaces.unlinkConfirmMessage'),\n        confirmText: t('workspaces.unlink'),\n        variant: 'destructive',\n      });\n\n      if (result === 'confirmed') {\n        try {\n          await workspacesApi.unlinkFromIssue(localWorkspaceId);\n        } catch (error) {\n          ConfirmDialog.show({\n            title: t('common:error'),\n            message:\n              error instanceof Error\n                ? error.message\n                : t('workspaces.unlinkError'),\n            confirmText: t('common:ok'),\n            showCancelButton: false,\n          });\n        }\n      }\n    },\n    [t]\n  );\n\n  // Handle deleting a workspace (unlinks first, then deletes local)\n  const handleDeleteWorkspace = useCallback(\n    async (localWorkspaceId: string) => {\n      const localWorkspace = localWorkspacesById.get(localWorkspaceId);\n      if (!localWorkspace) {\n        ConfirmDialog.show({\n          title: t('common:error'),\n          message: t('workspaces.deleteError'),\n          confirmText: t('common:ok'),\n          showCancelButton: false,\n        });\n        return;\n      }\n\n      const result = await DeleteWorkspaceDialog.show({\n        branchName: localWorkspace.branch,\n        hasOpenPR:\n          workspacesWithStats\n            .find(\n              (workspace) => workspace.localWorkspaceId === localWorkspaceId\n            )\n            ?.prs.some((pr) => pr.status === 'open') ?? false,\n        isLinkedToIssue: true,\n        linkedIssueSimpleId: getIssue(issueId)?.simple_id,\n      });\n\n      if (result.action !== 'confirmed') {\n        return;\n      }\n\n      try {\n        // Delete local workspace first\n        await workspacesApi.delete(localWorkspaceId, result.deleteBranches);\n        // Unlink from remote after successful deletion\n        if (result.unlinkFromIssue) {\n          await workspacesApi.unlinkFromIssue(localWorkspaceId);\n        }\n      } catch (error) {\n        ConfirmDialog.show({\n          title: t('common:error'),\n          message:\n            error instanceof Error\n              ? error.message\n              : t('workspaces.deleteError'),\n          confirmText: t('common:ok'),\n          showCancelButton: false,\n        });\n      }\n    },\n    [localWorkspacesById, workspacesWithStats, t, issueId, getIssue]\n  );\n\n  // Actions for the section header\n  const actions: SectionAction[] = useMemo(\n    () => [\n      {\n        icon: PlusIcon,\n        onClick: handleAddWorkspace,\n      },\n      {\n        icon: LinkIcon,\n        onClick: handleLinkWorkspace,\n      },\n    ],\n    [handleAddWorkspace, handleLinkWorkspace]\n  );\n\n  return (\n    <IssueWorkspacesSection\n      workspaces={workspacesWithStats}\n      isLoading={isLoading}\n      actions={actions}\n      onWorkspaceClick={handleWorkspaceClick}\n      onCreateWorkspace={handleAddWorkspace}\n      onUnlinkWorkspace={handleUnlinkWorkspace}\n      onDeleteWorkspace={handleDeleteWorkspace}\n      shouldAnimateCreateButton={shouldAnimateCreateButton}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/KanbanIssuePanelContainer.tsx",
    "content": "import {\n  useState,\n  useCallback,\n  useEffect,\n  useReducer,\n  useRef,\n  useMemo,\n} from 'react';\nimport { useDropzone } from 'react-dropzone';\nimport { useTranslation } from 'react-i18next';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\nimport type { IssuePriority } from 'shared/remote-types';\nimport { useDebouncedCallback } from '@/shared/hooks/useDebouncedCallback';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useOrgContext } from '@/shared/hooks/useOrgContext';\nimport { useProjectWorkspaceCreateDraft } from '@/shared/hooks/useProjectWorkspaceCreateDraft';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { SearchableTagDropdownContainer } from '@/shared/components/SearchableTagDropdownContainer';\nimport { IssueCommentsSectionContainer } from './IssueCommentsSectionContainer';\nimport { IssueSubIssuesSectionContainer } from './IssueSubIssuesSectionContainer';\nimport { IssueRelationshipsSectionContainer } from './IssueRelationshipsSectionContainer';\nimport { IssueWorkspacesSectionContainer } from './IssueWorkspacesSectionContainer';\nimport {\n  KanbanIssuePanel,\n  type IssueFormData,\n} from '@vibe/ui/components/KanbanIssuePanel';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useUserContext } from '@/shared/hooks/useUserContext';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { CommandBarDialog } from '@/shared/dialogs/command-bar/CommandBarDialog';\nimport { getWorkspaceDefaults } from '@/shared/lib/workspaceDefaults';\nimport {\n  buildLinkedIssueCreateState,\n  buildWorkspaceCreateInitialState,\n  buildWorkspaceCreatePrompt,\n} from '@/shared/lib/workspaceCreateState';\nimport {\n  createBlankCreateFormData,\n  createInitialKanbanIssuePanelFormState,\n  kanbanIssuePanelFormReducer,\n  selectDisplayData,\n  selectIsCreateDraftDirty,\n} from './kanban-issue-panel-state';\nimport { useUiPreferencesStore } from '@/shared/stores/useUiPreferencesStore';\nimport { useAzureAttachments } from '@/shared/hooks/useAzureAttachments';\nimport {\n  commitIssueAttachments,\n  deleteAttachment,\n} from '@/shared/lib/remoteApi';\nimport {\n  extractAttachmentIds,\n  removeAttachmentMarkdownBySource,\n  replaceAttachmentSource,\n} from '@/shared/lib/attachmentUtils';\nimport { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useCurrentKanbanRouteState } from '@/shared/hooks/useCurrentKanbanRouteState';\nimport {\n  buildKanbanIssueComposerKey,\n  closeKanbanIssueComposer,\n  patchKanbanIssueComposer,\n  resetKanbanIssueComposer,\n  useKanbanIssueComposer,\n  useKanbanIssueComposerStore,\n} from '@/shared/stores/useKanbanIssueComposerStore';\n\ninterface KanbanIssuePanelContainerProps {\n  issueResolution: 'resolving' | 'ready' | 'missing' | null;\n  onExpectIssueOpen: (issueId: string) => void;\n}\n\n/**\n * KanbanIssuePanelContainer manages the issue detail/create panel.\n * Uses ProjectContext and OrgContext for data and mutations.\n * Must be rendered within both OrgProvider and ProjectProvider.\n */\nexport function KanbanIssuePanelContainer({\n  issueResolution,\n  onExpectIssueOpen,\n}: KanbanIssuePanelContainerProps) {\n  const { t } = useTranslation('common');\n  const appNavigation = useAppNavigation();\n  const routeState = useCurrentKanbanRouteState();\n\n  const { openWorkspaceCreateFromState } = useProjectWorkspaceCreateDraft();\n  const { workspaces } = useUserContext();\n  const { activeWorkspaces, archivedWorkspaces } = useWorkspaceContext();\n\n  // Build set of local workspace IDs that exist on this machine\n  const localWorkspaceIds = useMemo(\n    () =>\n      new Set([\n        ...activeWorkspaces.map((w) => w.id),\n        ...archivedWorkspaces.map((w) => w.id),\n      ]),\n    [activeWorkspaces, archivedWorkspaces]\n  );\n\n  // Get data from contexts\n  const {\n    projectId,\n    issues,\n    statuses,\n    tags,\n    issueAssignees,\n    issueTags,\n    insertIssue,\n    updateIssue,\n    insertIssueAssignee,\n    insertIssueTag,\n    removeIssueTag,\n    insertTag,\n    getTagsForIssue,\n    getPullRequestsForIssue,\n    isLoading: projectLoading,\n  } = useProjectContext();\n  const selectedKanbanIssueId = routeState.issueId;\n  const issueComposerKey = useMemo(\n    () => buildKanbanIssueComposerKey(routeState.hostId, projectId),\n    [routeState.hostId, projectId]\n  );\n  const issueComposer = useKanbanIssueComposer(issueComposerKey);\n  const kanbanCreateMode = issueComposer !== null;\n  const createComposerInitial = issueComposer?.initial ?? null;\n  const kanbanCreateDefaultStatusId = createComposerInitial?.statusId ?? null;\n  const kanbanCreateDefaultPriority = createComposerInitial?.priority ?? null;\n  const kanbanCreateDefaultAssigneeIds =\n    createComposerInitial?.assigneeIds ?? null;\n  const kanbanCreateDefaultParentIssueId =\n    createComposerInitial?.parentIssueId ?? null;\n  const createDraftWorkspaceByDefault = useUiPreferencesStore(\n    (state) => state.createDraftWorkspaceByDefault\n  );\n  const setCreateDraftWorkspaceByDefault = useUiPreferencesStore(\n    (state) => state.setCreateDraftWorkspaceByDefault\n  );\n  const openIssue = useCallback(\n    (issueId: string) => {\n      if (kanbanCreateMode && issueComposerKey) {\n        closeKanbanIssueComposer(issueComposerKey);\n      }\n      appNavigation.goToProjectIssue(projectId, issueId);\n    },\n    [kanbanCreateMode, issueComposerKey, appNavigation, projectId]\n  );\n  const closeKanbanIssuePanel = useCallback(() => {\n    if (kanbanCreateMode && issueComposerKey) {\n      closeKanbanIssueComposer(issueComposerKey);\n    }\n    appNavigation.goToProject(projectId);\n  }, [kanbanCreateMode, issueComposerKey, appNavigation, projectId]);\n  const updateIssueComposerDraft = useCallback(\n    (patch: {\n      statusId?: string;\n      priority?: IssuePriority | null;\n      assigneeIds?: string[];\n      parentIssueId?: string;\n      title?: string;\n      description?: string | null;\n      tagIds?: string[];\n      createDraftWorkspace?: boolean;\n    }) => {\n      if (!kanbanCreateMode || !issueComposerKey) {\n        return;\n      }\n\n      patchKanbanIssueComposer(issueComposerKey, patch);\n    },\n    [kanbanCreateMode, issueComposerKey]\n  );\n  const resetIssueComposerDraft = useCallback(() => {\n    if (!issueComposerKey) {\n      return;\n    }\n\n    resetKanbanIssueComposer(issueComposerKey);\n  }, [issueComposerKey]);\n\n  const { isLoading: orgLoading, membersWithProfilesById } = useOrgContext();\n\n  // Get action methods from actions context\n  const { openStatusSelection, openPrioritySelection, openAssigneeSelection } =\n    useActions();\n\n  // Find selected issue if in edit mode\n  const selectedIssue = useMemo(() => {\n    if (kanbanCreateMode || !selectedKanbanIssueId) return null;\n    return issues.find((i) => i.id === selectedKanbanIssueId) ?? null;\n  }, [issues, selectedKanbanIssueId, kanbanCreateMode]);\n\n  const creatorUserId = selectedIssue?.creator_user_id ?? null;\n  const issueCreator = useMemo(() => {\n    if (!creatorUserId) return null;\n    return membersWithProfilesById.get(creatorUserId) ?? null;\n  }, [membersWithProfilesById, creatorUserId]);\n\n  // Find parent issue if current issue has one\n  const parentIssue = useMemo(() => {\n    if (!selectedIssue?.parent_issue_id) return null;\n    const parent = issues.find((i) => i.id === selectedIssue.parent_issue_id);\n    if (!parent) return null;\n    return { id: parent.id, simpleId: parent.simple_id };\n  }, [issues, selectedIssue]);\n\n  // Handler for clicking on parent issue - navigate to that issue\n  const handleParentIssueClick = useCallback(() => {\n    if (parentIssue) {\n      openIssue(parentIssue.id);\n    }\n  }, [parentIssue, openIssue]);\n\n  const handleRemoveParentIssue = useCallback(() => {\n    if (!selectedKanbanIssueId || !selectedIssue?.parent_issue_id) return;\n    updateIssue(selectedKanbanIssueId, {\n      parent_issue_id: null,\n      parent_issue_sort_order: null,\n    });\n  }, [selectedKanbanIssueId, selectedIssue?.parent_issue_id, updateIssue]);\n\n  // Get all current assignees from issue_assignees\n  const currentAssigneeIds = useMemo(() => {\n    if (!selectedKanbanIssueId) return [];\n    return issueAssignees\n      .filter((a) => a.issue_id === selectedKanbanIssueId)\n      .map((a) => a.user_id);\n  }, [issueAssignees, selectedKanbanIssueId]);\n\n  // Get current tag IDs from issue_tags junction table\n  const currentTagIds = useMemo(() => {\n    if (!selectedKanbanIssueId) return [];\n    const tagLinks = getTagsForIssue(selectedKanbanIssueId);\n    return tagLinks.map((it) => it.tag_id);\n  }, [getTagsForIssue, selectedKanbanIssueId]);\n\n  // Get linked PRs for the issue\n  const linkedPrs = useMemo(() => {\n    if (!selectedKanbanIssueId) return [];\n    return getPullRequestsForIssue(selectedKanbanIssueId).map((pr) => ({\n      id: pr.id,\n      number: pr.number,\n      url: pr.url,\n      status: pr.status,\n    }));\n  }, [getPullRequestsForIssue, selectedKanbanIssueId]);\n\n  // Determine mode from composer state (create) or issue route (edit).\n  const mode = kanbanCreateMode ? 'create' : 'edit';\n\n  // Sort statuses by sort_order\n  const sortedStatuses = useMemo(\n    () => [...statuses].sort((a, b) => a.sort_order - b.sort_order),\n    [statuses]\n  );\n\n  // Default status: use kanbanCreateDefaultStatusId if set, otherwise first by sort order\n  const defaultStatusId =\n    kanbanCreateDefaultStatusId ?? sortedStatuses[0]?.id ?? '';\n\n  // Default create form values for the current create-default state + project context\n  const createModeDefaults = useMemo<IssueFormData>(\n    () => ({\n      title: '',\n      description: null,\n      statusId: defaultStatusId,\n      priority: kanbanCreateDefaultPriority ?? null,\n      assigneeIds: [...(kanbanCreateDefaultAssigneeIds ?? [])],\n      tagIds: [],\n      createDraftWorkspace: createDraftWorkspaceByDefault,\n    }),\n    [\n      defaultStatusId,\n      kanbanCreateDefaultPriority,\n      kanbanCreateDefaultAssigneeIds,\n      createDraftWorkspaceByDefault,\n    ]\n  );\n\n  // Track previous issue ID to detect actual issue switches (not just data updates)\n  const prevIssueIdRef = useRef<string | null>(null);\n  const prevHasPendingAttachmentsRef = useRef(false);\n  const hasPendingAttachmentsRef = useRef(false);\n  const titleInputRef = useRef<HTMLTextAreaElement>(null);\n\n  const [formState, dispatchFormState] = useReducer(\n    kanbanIssuePanelFormReducer,\n    undefined,\n    createInitialKanbanIssuePanelFormState\n  );\n  const createFormData = formState.createFormData;\n\n  useEffect(() => {\n    if (mode !== 'create') return;\n\n    const titleInput = titleInputRef.current;\n    if (!titleInput || document.activeElement === titleInput) return;\n\n    const frameId = requestAnimationFrame(() => {\n      const node = titleInputRef.current;\n      if (!node || document.activeElement === node) return;\n\n      node.focus();\n      const caretIndex = node.value.length;\n      node.setSelectionRange(caretIndex, caretIndex);\n    });\n\n    return () => cancelAnimationFrame(frameId);\n  }, [mode, selectedKanbanIssueId, createFormData?.title]);\n\n  // Display ID: use real simple_id in edit mode, placeholder for create mode\n  const displayId = useMemo(() => {\n    if (mode === 'edit' && selectedIssue) {\n      return selectedIssue.simple_id;\n    }\n    return t('kanban.newIssue');\n  }, [mode, selectedIssue, t]);\n\n  // Compute display values based on mode\n  // - Create mode: createFormData is the single source of truth.\n  // - Edit mode: text fields come from explicit local edit state, dropdown fields from server.\n  const displayData = useMemo((): IssueFormData => {\n    return selectDisplayData({\n      state: formState,\n      mode,\n      createModeDefaults,\n      selectedIssue,\n      currentAssigneeIds,\n      currentTagIds,\n    });\n  }, [\n    formState,\n    mode,\n    createModeDefaults,\n    selectedIssue,\n    currentAssigneeIds,\n    currentTagIds,\n  ]);\n  const latestDescriptionRef = useRef<string | null>(\n    displayData.description ?? null\n  );\n  latestDescriptionRef.current = displayData.description ?? null;\n\n  const isCreateDraftDirty = useMemo(() => {\n    return selectIsCreateDraftDirty({\n      state: formState,\n      mode,\n      createModeDefaults,\n    });\n  }, [formState, mode, createModeDefaults]);\n\n  // Resolve assignee IDs to full profiles for avatar display\n  const displayAssigneeUsers = useMemo(() => {\n    return displayData.assigneeIds\n      .map((id) => membersWithProfilesById.get(id))\n      .filter((m): m is OrganizationMemberWithProfile => m != null);\n  }, [displayData.assigneeIds, membersWithProfilesById]);\n\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  // Save status for description (shown in WYSIWYG toolbar)\n  const [descriptionSaveStatus, setDescriptionSaveStatus] = useState<\n    'idle' | 'saved'\n  >('idle');\n\n  // Debounced save for title changes\n  const { debounced: debouncedSaveTitle, cancel: cancelDebouncedTitle } =\n    useDebouncedCallback((title: string) => {\n      if (selectedKanbanIssueId && !kanbanCreateMode) {\n        updateIssue(selectedKanbanIssueId, { title });\n      }\n    }, 500);\n\n  // Debounced save for description changes\n  const {\n    debounced: debouncedSaveDescription,\n    cancel: cancelDebouncedDescription,\n  } = useDebouncedCallback((description: string | null) => {\n    if (selectedKanbanIssueId && !kanbanCreateMode) {\n      updateIssue(selectedKanbanIssueId, { description });\n      setDescriptionSaveStatus('saved');\n      setTimeout(() => setDescriptionSaveStatus('idle'), 1500);\n    }\n  }, 500);\n\n  // Reset save status only when switching to a different issue or mode\n  useEffect(() => {\n    setDescriptionSaveStatus('idle');\n  }, [selectedKanbanIssueId, kanbanCreateMode]);\n\n  const createFormFallback = useMemo(\n    () =>\n      createBlankCreateFormData(defaultStatusId, createDraftWorkspaceByDefault),\n    [defaultStatusId, createDraftWorkspaceByDefault]\n  );\n\n  // --- Image attachment upload integration ---\n\n  // Callback to insert markdown into the description field\n  const handleDescriptionInsert = useCallback(\n    (markdown: string, options?: { persist?: boolean }) => {\n      const currentDesc = latestDescriptionRef.current ?? '';\n      const separator = currentDesc.length > 0 ? '\\n' : '';\n      const newDesc = currentDesc + separator + markdown;\n      latestDescriptionRef.current = newDesc;\n\n      if (kanbanCreateMode || !selectedKanbanIssueId) {\n        // Create mode: update form data\n        dispatchFormState({\n          type: 'patchCreateFormData',\n          patch: { description: newDesc },\n          fallback: createFormFallback,\n        });\n      } else {\n        // Edit mode: update local state + debounced save\n        dispatchFormState({\n          type: 'setEditDescription',\n          description: newDesc,\n        });\n        if (options?.persist !== false && !hasPendingAttachmentsRef.current) {\n          debouncedSaveDescription(newDesc);\n        }\n      }\n    },\n    [\n      kanbanCreateMode,\n      selectedKanbanIssueId,\n      createFormFallback,\n      debouncedSaveDescription,\n    ]\n  );\n\n  const handleDescriptionSourceReplace = useCallback(\n    (previousSrc: string, nextSrc: string, options?: { persist?: boolean }) => {\n      const currentDesc = latestDescriptionRef.current ?? '';\n      const { content: nextDesc, replaced } = replaceAttachmentSource(\n        currentDesc,\n        previousSrc,\n        nextSrc\n      );\n\n      if (!replaced) {\n        return false;\n      }\n      latestDescriptionRef.current = nextDesc;\n\n      if (kanbanCreateMode || !selectedKanbanIssueId) {\n        dispatchFormState({\n          type: 'patchCreateFormData',\n          patch: { description: nextDesc },\n          fallback: createFormFallback,\n        });\n      } else {\n        dispatchFormState({\n          type: 'setEditDescription',\n          description: nextDesc,\n        });\n        if (options?.persist !== false && !hasPendingAttachmentsRef.current) {\n          debouncedSaveDescription(nextDesc);\n        }\n      }\n\n      return true;\n    },\n    [\n      kanbanCreateMode,\n      selectedKanbanIssueId,\n      createFormFallback,\n      debouncedSaveDescription,\n    ]\n  );\n\n  const handleDescriptionSourceRemove = useCallback(\n    (src: string, options?: { persist?: boolean }) => {\n      const currentDesc = latestDescriptionRef.current ?? '';\n      const { content: nextDesc, removed } = removeAttachmentMarkdownBySource(\n        currentDesc,\n        src\n      );\n\n      if (!removed) {\n        return false;\n      }\n      latestDescriptionRef.current = nextDesc || null;\n\n      if (kanbanCreateMode || !selectedKanbanIssueId) {\n        dispatchFormState({\n          type: 'patchCreateFormData',\n          patch: { description: nextDesc || null },\n          fallback: createFormFallback,\n        });\n      } else {\n        dispatchFormState({\n          type: 'setEditDescription',\n          description: nextDesc || null,\n        });\n        if (options?.persist !== false && !hasPendingAttachmentsRef.current) {\n          debouncedSaveDescription(nextDesc || null);\n        }\n      }\n\n      return true;\n    },\n    [\n      kanbanCreateMode,\n      selectedKanbanIssueId,\n      createFormFallback,\n      debouncedSaveDescription,\n    ]\n  );\n\n  // Azure attachment upload hook\n  const {\n    uploadFiles,\n    getAttachmentIds,\n    clearAttachments,\n    isUploading,\n    hasPendingAttachments,\n    uploadError,\n    clearUploadError,\n    localAttachments,\n  } = useAzureAttachments({\n    projectId,\n    issueId: kanbanCreateMode\n      ? undefined\n      : (selectedKanbanIssueId ?? undefined),\n    onMarkdownInsert: handleDescriptionInsert,\n    onAttachmentSourceReplace: handleDescriptionSourceReplace,\n    onAttachmentSourceRemove: handleDescriptionSourceRemove,\n    onError: (msg) => console.error('[attachment]', msg),\n  });\n  hasPendingAttachmentsRef.current = hasPendingAttachments;\n\n  // Dropzone for drag-drop image upload on description area\n  const {\n    getRootProps,\n    getInputProps,\n    isDragActive,\n    open: openFilePicker,\n  } = useDropzone({\n    onDrop: (acceptedFiles) => {\n      if (acceptedFiles.length > 0) uploadFiles(acceptedFiles);\n    },\n    multiple: true,\n    noClick: true,\n    noKeyboard: true,\n  });\n\n  // Paste handler for images\n  const onPasteFiles = useCallback(\n    (files: File[]) => {\n      if (files.length > 0) uploadFiles(files);\n    },\n    [uploadFiles]\n  );\n\n  // Reset local state when switching issues or modes.\n  useEffect(() => {\n    const currentIssueId = selectedKanbanIssueId;\n    const isNewIssue = currentIssueId !== prevIssueIdRef.current;\n    const shouldSeedCreateForm = mode === 'create' && createFormData === null;\n\n    if (!isNewIssue && !shouldSeedCreateForm) {\n      // Same issue - no reset needed\n      // (dropdown fields derive from server state, text fields preserve local edits)\n      return;\n    }\n\n    // Track the new issue ID\n    prevIssueIdRef.current = currentIssueId;\n\n    // Cancel any pending debounced saves when switching issues\n    cancelDebouncedTitle();\n    cancelDebouncedDescription();\n\n    let nextCreateFormData: IssueFormData | null = null;\n    let restoredFromScratch = false;\n\n    if (mode === 'create') {\n      // Check if the composer store has a saved draft (e.g., restored from\n      // localStorage on remote-web). Use it to seed the form instead of defaults.\n      const composerDraft =\n        useKanbanIssueComposerStore.getState().byKey[issueComposerKey]?.draft;\n      const hasSavedDraft =\n        composerDraft != null &&\n        (composerDraft.title !== '' || composerDraft.description != null);\n\n      if (hasSavedDraft) {\n        nextCreateFormData = {\n          title: composerDraft.title,\n          description: composerDraft.description ?? null,\n          statusId: composerDraft.statusId ?? createModeDefaults.statusId,\n          priority:\n            composerDraft.priority === undefined\n              ? createModeDefaults.priority\n              : composerDraft.priority,\n          assigneeIds:\n            composerDraft.assigneeIds ?? createModeDefaults.assigneeIds,\n          tagIds: composerDraft.tagIds ?? createModeDefaults.tagIds,\n          createDraftWorkspace:\n            composerDraft.createDraftWorkspace ??\n            createModeDefaults.createDraftWorkspace,\n        };\n        restoredFromScratch = true;\n      } else {\n        nextCreateFormData = createModeDefaults;\n      }\n    }\n\n    dispatchFormState({\n      type: 'resetForIssueChange',\n      mode,\n      createFormData: nextCreateFormData,\n      hasRestoredFromScratch: restoredFromScratch,\n    });\n  }, [\n    mode,\n    createFormData,\n    selectedKanbanIssueId,\n    cancelDebouncedTitle,\n    cancelDebouncedDescription,\n    createModeDefaults,\n    issueComposerKey,\n  ]);\n\n  useEffect(() => {\n    const wasPending = prevHasPendingAttachmentsRef.current;\n    prevHasPendingAttachmentsRef.current = hasPendingAttachments;\n\n    if (kanbanCreateMode || !selectedKanbanIssueId) {\n      return;\n    }\n\n    if (!wasPending || hasPendingAttachments) {\n      return;\n    }\n\n    const currentDescription = displayData.description ?? null;\n    const persistedDescription = selectedIssue?.description ?? null;\n\n    if (currentDescription === persistedDescription) {\n      return;\n    }\n\n    debouncedSaveDescription(currentDescription);\n  }, [\n    kanbanCreateMode,\n    selectedKanbanIssueId,\n    hasPendingAttachments,\n    displayData.description,\n    selectedIssue?.description,\n    debouncedSaveDescription,\n  ]);\n\n  // Form change handler - persists changes immediately in edit mode\n  const handlePropertyChange = useCallback(\n    async <K extends keyof IssueFormData>(\n      field: K,\n      value: IssueFormData[K]\n    ) => {\n      // Create mode: update in-panel form state and composer draft.\n      if (kanbanCreateMode) {\n        // For statusId, open the status selection dialog with callback\n        if (field === 'statusId') {\n          const { ProjectSelectionDialog } = await import(\n            '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n          );\n          const result = await ProjectSelectionDialog.show({\n            projectId,\n            selection: { type: 'status', issueIds: [], isCreateMode: true },\n          });\n          if (result && typeof result === 'object' && 'statusId' in result) {\n            const statusId = result.statusId as string;\n            updateIssueComposerDraft({ statusId });\n            dispatchFormState({\n              type: 'patchCreateFormData',\n              patch: { statusId },\n              fallback: createFormFallback,\n            });\n          }\n          return;\n        }\n\n        // For priority, open the priority selection dialog with callback\n        if (field === 'priority') {\n          const { ProjectSelectionDialog } = await import(\n            '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n          );\n          const result = await ProjectSelectionDialog.show({\n            projectId,\n            selection: { type: 'priority', issueIds: [], isCreateMode: true },\n          });\n          if (result && typeof result === 'object' && 'priority' in result) {\n            const priority = (result as { priority: IssuePriority | null })\n              .priority;\n            updateIssueComposerDraft({ priority });\n            dispatchFormState({\n              type: 'patchCreateFormData',\n              patch: { priority },\n              fallback: createFormFallback,\n            });\n          }\n          return;\n        }\n\n        // For assigneeIds, open the assignee selection dialog with callback\n        if (field === 'assigneeIds') {\n          const { AssigneeSelectionDialog } = await import(\n            '@/shared/dialogs/kanban/AssigneeSelectionDialog'\n          );\n          await AssigneeSelectionDialog.show({\n            projectId,\n            issueIds: [],\n            isCreateMode: true,\n            createModeAssigneeIds: createFormData?.assigneeIds ?? [],\n            onCreateModeAssigneesChange: (assigneeIds: string[]) => {\n              updateIssueComposerDraft({ assigneeIds });\n              dispatchFormState({\n                type: 'setCreateAssigneeIds',\n                assigneeIds,\n              });\n            },\n          });\n          return;\n        }\n\n        // For other fields, just update the form data\n        dispatchFormState({\n          type: 'patchCreateFormData',\n          patch: { [field]: value } as Partial<IssueFormData>,\n          fallback: createFormFallback,\n        });\n        updateIssueComposerDraft({ [field]: value } as Partial<IssueFormData>);\n        if (field === 'createDraftWorkspace') {\n          setCreateDraftWorkspaceByDefault(value as boolean);\n        }\n        return;\n      }\n\n      if (!selectedKanbanIssueId) {\n        return;\n      }\n\n      // Edit mode: handle text fields vs dropdown fields differently\n      if (field === 'title') {\n        // Text field: update local state, then debounced save\n        dispatchFormState({\n          type: 'setEditTitle',\n          title: value as string,\n        });\n        debouncedSaveTitle(value as string);\n      } else if (field === 'description') {\n        // Text field: update local state, then debounced save\n        dispatchFormState({\n          type: 'setEditDescription',\n          description: value as string | null,\n        });\n        if (!hasPendingAttachments) {\n          debouncedSaveDescription(value as string | null);\n        }\n      } else if (field === 'statusId') {\n        // Status changes go through the command bar status selection\n        openStatusSelection(projectId, [selectedKanbanIssueId]);\n      } else if (field === 'priority') {\n        // Priority changes go through the command bar priority selection\n        openPrioritySelection(projectId, [selectedKanbanIssueId]);\n      } else if (field === 'assigneeIds') {\n        // Assignee changes go through the assignee selection dialog\n        openAssigneeSelection(projectId, [selectedKanbanIssueId], false);\n      } else if (field === 'tagIds') {\n        // Handle tag changes via junction table\n        const newTagIds = value as string[];\n        const currentIssueTags = issueTags.filter(\n          (it) => it.issue_id === selectedKanbanIssueId\n        );\n        const currentTagIdSet = new Set(\n          currentIssueTags.map((it) => it.tag_id)\n        );\n        const newTagIdSet = new Set(newTagIds);\n\n        // Remove tags that are no longer selected\n        for (const issueTag of currentIssueTags) {\n          if (!newTagIdSet.has(issueTag.tag_id)) {\n            removeIssueTag(issueTag.id);\n          }\n        }\n\n        // Add newly selected tags\n        for (const tagId of newTagIds) {\n          if (!currentTagIdSet.has(tagId)) {\n            insertIssueTag({\n              issue_id: selectedKanbanIssueId,\n              tag_id: tagId,\n            });\n          }\n        }\n      }\n    },\n    [\n      kanbanCreateMode,\n      selectedKanbanIssueId,\n      projectId,\n      createFormFallback,\n      createFormData,\n      hasPendingAttachments,\n      debouncedSaveTitle,\n      debouncedSaveDescription,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      updateIssueComposerDraft,\n      setCreateDraftWorkspaceByDefault,\n      issueTags,\n      insertIssueTag,\n      removeIssueTag,\n    ]\n  );\n\n  // Submit handler\n  const handleSubmit = useCallback(async () => {\n    if (!displayData.title.trim() || hasPendingAttachments) return;\n\n    setIsSubmitting(true);\n    try {\n      if (mode === 'create') {\n        // Create new issue at the top of the column\n        const statusIssues = issues.filter(\n          (i) => i.status_id === displayData.statusId\n        );\n        const minSortOrder =\n          statusIssues.length > 0\n            ? Math.min(...statusIssues.map((i) => i.sort_order))\n            : 0;\n\n        const { persisted } = insertIssue({\n          project_id: projectId,\n          status_id: displayData.statusId,\n          title: displayData.title,\n          description: displayData.description,\n          priority: displayData.priority,\n          sort_order: minSortOrder - 1,\n          start_date: null,\n          target_date: null,\n          completed_at: null,\n          parent_issue_id: kanbanCreateDefaultParentIssueId,\n          parent_issue_sort_order: null,\n          extension_metadata: null,\n        });\n\n        // Wait for the issue to be confirmed by the backend and get the synced entity\n        const syncedIssue = await persisted;\n\n        // Commit only attachments still referenced in the description\n        const allUploadedIds = getAttachmentIds();\n        if (allUploadedIds.length > 0) {\n          const referencedIds = extractAttachmentIds(\n            displayData.description ?? ''\n          );\n          const idsToCommit = allUploadedIds.filter((id) =>\n            referencedIds.has(id)\n          );\n          const idsToDelete = allUploadedIds.filter(\n            (id) => !referencedIds.has(id)\n          );\n\n          if (idsToCommit.length > 0) {\n            await commitIssueAttachments(syncedIssue.id, {\n              attachment_ids: idsToCommit,\n            });\n          }\n          for (const id of idsToDelete) {\n            deleteAttachment(id).catch((err) =>\n              console.error('Failed to delete abandoned attachment:', err)\n            );\n          }\n          clearAttachments();\n        }\n\n        // Create assignee records for all selected assignees\n        displayData.assigneeIds.forEach((userId) => {\n          insertIssueAssignee({\n            issue_id: syncedIssue.id,\n            user_id: userId,\n          });\n        });\n\n        // Create tag records if tags were selected\n        for (const tagId of displayData.tagIds) {\n          insertIssueTag({\n            issue_id: syncedIssue.id,\n            tag_id: tagId,\n          });\n        }\n\n        if (issueComposerKey) {\n          closeKanbanIssueComposer(issueComposerKey);\n        }\n\n        if (displayData.createDraftWorkspace) {\n          const initialPrompt = buildWorkspaceCreatePrompt(\n            displayData.title,\n            displayData.description\n          );\n\n          // Get defaults from most recent workspace\n          const defaults = await getWorkspaceDefaults(\n            workspaces,\n            localWorkspaceIds,\n            projectId\n          );\n\n          const createState = buildWorkspaceCreateInitialState({\n            prompt: initialPrompt,\n            defaults,\n            linkedIssue: buildLinkedIssueCreateState(syncedIssue, projectId),\n          });\n          const draftId = await openWorkspaceCreateFromState(createState, {\n            issueId: syncedIssue.id,\n          });\n          if (!draftId) {\n            await ConfirmDialog.show({\n              title: t('common:error'),\n              message: t(\n                'workspaces.createDraftError',\n                'Failed to prepare workspace draft. Please try again.'\n              ),\n              confirmText: t('common:ok'),\n              showCancelButton: false,\n            });\n            onExpectIssueOpen?.(syncedIssue.id);\n            openIssue(syncedIssue.id);\n          }\n          return; // Don't open issue panel since we're navigating away\n        }\n\n        // Open the newly created issue\n        onExpectIssueOpen?.(syncedIssue.id);\n        openIssue(syncedIssue.id);\n      } else {\n        // Update existing issue - would use update mutation\n        // For now, just close the panel\n        closeKanbanIssuePanel();\n      }\n    } catch (error) {\n      console.error('Failed to save issue:', error);\n    } finally {\n      setIsSubmitting(false);\n    }\n  }, [\n    mode,\n    displayData,\n    projectId,\n    issues,\n    insertIssue,\n    insertIssueAssignee,\n    insertIssueTag,\n    openIssue,\n    kanbanCreateDefaultParentIssueId,\n    openWorkspaceCreateFromState,\n    workspaces,\n    localWorkspaceIds,\n    closeKanbanIssuePanel,\n    issueComposerKey,\n    getAttachmentIds,\n    clearAttachments,\n    hasPendingAttachments,\n    onExpectIssueOpen,\n    t,\n  ]);\n\n  const handleCmdEnterSubmit = useCallback(() => {\n    if (mode !== 'create') return;\n    void handleSubmit();\n  }, [mode, handleSubmit]);\n\n  const handleDeleteDraft = useCallback(() => {\n    dispatchFormState({\n      type: 'setCreateFormData',\n      createFormData: createModeDefaults,\n    });\n    resetIssueComposerDraft();\n  }, [createModeDefaults, resetIssueComposerDraft]);\n\n  // Tag create callback - returns the new tag ID so it can be auto-selected\n  const handleCreateTag = useCallback(\n    (data: { name: string; color: string }): string => {\n      const { data: newTag } = insertTag({\n        project_id: projectId,\n        name: data.name,\n        color: data.color,\n      });\n      return newTag.id;\n    },\n    [insertTag, projectId]\n  );\n\n  // Copy link callback - copies issue URL to clipboard\n  const handleCopyLink = useCallback(() => {\n    if (!selectedKanbanIssueId || !projectId) return;\n    const url = `${window.location.origin}/projects/${projectId}/issues/${selectedKanbanIssueId}`;\n    navigator.clipboard.writeText(url);\n  }, [projectId, selectedKanbanIssueId]);\n\n  // More actions callback - opens command bar with issue actions\n  const handleMoreActions = useCallback(async () => {\n    if (!selectedKanbanIssueId || !projectId) return;\n    await CommandBarDialog.show({\n      page: 'issueActions',\n      projectId,\n      issueIds: [selectedKanbanIssueId],\n    });\n  }, [selectedKanbanIssueId, projectId]);\n\n  // Loading state\n  const isLoading = projectLoading || orgLoading;\n  const isResolvingExpectedIssue =\n    mode === 'edit' &&\n    selectedKanbanIssueId !== null &&\n    issueResolution === 'resolving';\n  const hasMissingIssueDataInEditMode =\n    mode === 'edit' && selectedKanbanIssueId !== null && selectedIssue === null;\n\n  if (isLoading || isResolvingExpectedIssue || hasMissingIssueDataInEditMode) {\n    return (\n      <div className=\"flex items-center justify-center h-full bg-secondary\">\n        <p className=\"text-low\">{t('states.loading')}</p>\n      </div>\n    );\n  }\n\n  return (\n    <KanbanIssuePanel\n      mode={mode}\n      displayId={displayId}\n      formData={displayData}\n      assigneeUsers={displayAssigneeUsers}\n      onFormChange={handlePropertyChange}\n      statuses={sortedStatuses}\n      tags={tags}\n      issueId={selectedKanbanIssueId}\n      creatorUser={issueCreator}\n      parentIssue={parentIssue}\n      onParentIssueClick={handleParentIssueClick}\n      onRemoveParentIssue={handleRemoveParentIssue}\n      linkedPrs={linkedPrs}\n      onClose={closeKanbanIssuePanel}\n      onSubmit={handleSubmit}\n      onCmdEnterSubmit={handleCmdEnterSubmit}\n      onCreateTag={handleCreateTag}\n      renderAddTagControl={({\n        tags,\n        selectedTagIds,\n        onTagToggle,\n        onCreateTag,\n        disabled,\n        trigger,\n      }) => (\n        <SearchableTagDropdownContainer\n          tags={tags}\n          selectedTagIds={selectedTagIds}\n          onTagToggle={onTagToggle}\n          onCreateTag={onCreateTag}\n          disabled={disabled}\n          contentClassName=\"\"\n          trigger={trigger}\n        />\n      )}\n      isSubmitting={isSubmitting}\n      descriptionSaveStatus={\n        mode === 'edit' ? descriptionSaveStatus : undefined\n      }\n      titleInputRef={titleInputRef}\n      onDeleteDraft={\n        mode === 'create' && isCreateDraftDirty ? handleDeleteDraft : undefined\n      }\n      onCopyLink={mode === 'edit' ? handleCopyLink : undefined}\n      onMoreActions={mode === 'edit' ? handleMoreActions : undefined}\n      onPasteFiles={onPasteFiles}\n      localAttachments={localAttachments}\n      dropzoneProps={{ getRootProps, getInputProps, isDragActive }}\n      onBrowseAttachment={openFilePicker}\n      isUploading={isUploading}\n      attachmentError={uploadError}\n      onDismissAttachmentError={clearUploadError}\n      renderDescriptionEditor={(props) => (\n        <WYSIWYGEditor {...props} localAttachments={localAttachments} />\n      )}\n      renderWorkspacesSection={(issueId) => (\n        <IssueWorkspacesSectionContainer issueId={issueId} />\n      )}\n      renderRelationshipsSection={(issueId) => (\n        <IssueRelationshipsSectionContainer issueId={issueId} />\n      )}\n      renderSubIssuesSection={(issueId) => (\n        <IssueSubIssuesSectionContainer issueId={issueId} />\n      )}\n      renderCommentsSection={(issueId) => (\n        <IssueCommentsSectionContainer issueId={issueId} />\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/LocalProjectKanban.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { ProjectsGuideDialog } from '@vibe/ui/components/ProjectsGuideDialog';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { ProjectKanban } from '@/pages/kanban/ProjectKanban';\n\nconst PROJECTS_GUIDE_ID = 'projects-guide';\n\nexport function LocalProjectKanban() {\n  const { config, updateAndSaveConfig, loading } = useUserSystem();\n  const { isLoaded, isSignedIn } = useAuth();\n  const hasAutoShownProjectsGuide = useRef(false);\n\n  useEffect(() => {\n    if (hasAutoShownProjectsGuide.current) return;\n    if (!isLoaded || !isSignedIn || loading || !config) return;\n\n    const seenFeatures = config.showcases?.seen_features ?? [];\n    if (seenFeatures.includes(PROJECTS_GUIDE_ID)) return;\n\n    hasAutoShownProjectsGuide.current = true;\n\n    void updateAndSaveConfig({\n      showcases: { seen_features: [...seenFeatures, PROJECTS_GUIDE_ID] },\n    });\n    ProjectsGuideDialog.show().finally(() => ProjectsGuideDialog.hide());\n  }, [config, isLoaded, isSignedIn, loading, updateAndSaveConfig]);\n\n  return <ProjectKanban />;\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/ProjectKanban.tsx",
    "content": "import { useEffect, useMemo, useRef, type ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Group, Layout, Panel, Separator } from 'react-resizable-panels';\nimport { OrgProvider } from '@/shared/providers/remote/OrgProvider';\nimport { useOrgContext } from '@/shared/hooks/useOrgContext';\nimport { ProjectProvider } from '@/shared/providers/remote/ProjectProvider';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { usePageTitle } from '@/shared/hooks/usePageTitle';\nimport { KanbanContainer } from '@/features/kanban/ui/KanbanContainer';\nimport { useIsMobile } from '@/shared/hooks/useIsMobile';\nimport { ProjectRightSidebarContainer } from './ProjectRightSidebarContainer';\nimport { LoginRequiredPrompt } from '@/shared/dialogs/shared/LoginRequiredPrompt';\nimport {\n  PERSIST_KEYS,\n  usePaneSize,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { useOrganizationProjects } from '@/shared/hooks/useOrganizationProjects';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useCurrentKanbanRouteState } from '@/shared/hooks/useCurrentKanbanRouteState';\nimport {\n  buildKanbanIssueComposerKey,\n  closeKanbanIssueComposer,\n} from '@/shared/stores/useKanbanIssueComposerStore';\n/**\n * Component that registers project mutations with ActionsContext.\n * Must be rendered inside both ActionsProvider and ProjectProvider.\n */\nfunction ProjectMutationsRegistration({ children }: { children: ReactNode }) {\n  const { registerProjectMutations } = useActions();\n  const { removeIssue, insertIssue, getIssue, getAssigneesForIssue, issues } =\n    useProjectContext();\n\n  // Use ref to always access latest issues (avoid stale closure)\n  const issuesRef = useRef(issues);\n  useEffect(() => {\n    issuesRef.current = issues;\n  }, [issues]);\n\n  useEffect(() => {\n    registerProjectMutations({\n      removeIssue: (id) => {\n        removeIssue(id);\n      },\n      duplicateIssue: (issueId) => {\n        const issue = getIssue(issueId);\n        if (!issue) return;\n\n        // Use ref to get current issues (not stale closure)\n        const currentIssues = issuesRef.current;\n        const statusIssues = currentIssues.filter(\n          (i) => i.status_id === issue.status_id\n        );\n        const minSortOrder =\n          statusIssues.length > 0\n            ? Math.min(...statusIssues.map((i) => i.sort_order))\n            : 0;\n\n        insertIssue({\n          project_id: issue.project_id,\n          status_id: issue.status_id,\n          title: `${issue.title} (Copy)`,\n          description: issue.description,\n          priority: issue.priority,\n          sort_order: minSortOrder - 1,\n          start_date: issue.start_date,\n          target_date: issue.target_date,\n          completed_at: null,\n          parent_issue_id: issue.parent_issue_id,\n          parent_issue_sort_order: issue.parent_issue_sort_order,\n          extension_metadata: issue.extension_metadata,\n        });\n      },\n      getIssue,\n      getAssigneesForIssue,\n    });\n\n    return () => {\n      registerProjectMutations(null);\n    };\n  }, [\n    registerProjectMutations,\n    removeIssue,\n    insertIssue,\n    getIssue,\n    getAssigneesForIssue,\n  ]);\n\n  return <>{children}</>;\n}\n\nfunction ProjectKanbanLayout({ projectName }: { projectName: string }) {\n  const { issueId, isPanelOpen } = useCurrentKanbanRouteState();\n  const isMobile = useIsMobile();\n  const { getIssue } = useProjectContext();\n  const issue = issueId ? getIssue(issueId) : undefined;\n  usePageTitle(issue?.title, projectName);\n  const [kanbanLeftPanelSize, setKanbanLeftPanelSize] = usePaneSize(\n    PERSIST_KEYS.kanbanLeftPanel,\n    75\n  );\n\n  const isRightPanelOpen = isPanelOpen;\n\n  if (isMobile) {\n    return isRightPanelOpen ? (\n      <div className=\"h-full w-full overflow-hidden bg-secondary\">\n        <ProjectRightSidebarContainer />\n      </div>\n    ) : (\n      <div className=\"h-full w-full overflow-hidden bg-primary\">\n        <KanbanContainer />\n      </div>\n    );\n  }\n\n  const kanbanDefaultLayout: Layout =\n    typeof kanbanLeftPanelSize === 'number'\n      ? {\n          'kanban-left': kanbanLeftPanelSize,\n          'kanban-right': 100 - kanbanLeftPanelSize,\n        }\n      : { 'kanban-left': 75, 'kanban-right': 25 };\n\n  const onKanbanLayoutChange = (layout: Layout) => {\n    if (isRightPanelOpen) {\n      setKanbanLeftPanelSize(layout['kanban-left']);\n    }\n  };\n\n  return (\n    <Group\n      orientation=\"horizontal\"\n      className=\"flex-1 min-w-0 h-full\"\n      defaultLayout={kanbanDefaultLayout}\n      onLayoutChange={onKanbanLayoutChange}\n    >\n      <Panel\n        id=\"kanban-left\"\n        minSize=\"20%\"\n        className=\"min-w-0 h-full overflow-hidden bg-primary\"\n      >\n        <KanbanContainer />\n      </Panel>\n\n      {isRightPanelOpen && (\n        <Separator\n          id=\"kanban-separator\"\n          className=\"w-1 bg-panel outline-none hover:bg-brand/50 transition-colors cursor-col-resize\"\n        />\n      )}\n\n      {isRightPanelOpen && (\n        <Panel\n          id=\"kanban-right\"\n          minSize=\"400px\"\n          maxSize=\"800px\"\n          className=\"min-w-0 h-full overflow-hidden bg-secondary\"\n        >\n          <ProjectRightSidebarContainer />\n        </Panel>\n      )}\n    </Group>\n  );\n}\n\n/**\n * Inner component that renders the Kanban board once we have the org context\n */\nfunction ProjectKanbanInner({ projectId }: { projectId: string }) {\n  const { t } = useTranslation('common');\n  const { projects, isLoading } = useOrgContext();\n\n  const project = projects.find((p) => p.id === projectId);\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center h-full w-full\">\n        <p className=\"text-low\">{t('states.loading')}</p>\n      </div>\n    );\n  }\n\n  if (!project) {\n    return (\n      <div className=\"flex items-center justify-center h-full w-full\">\n        <p className=\"text-low\">{t('kanban.noProjectFound')}</p>\n      </div>\n    );\n  }\n\n  return (\n    <ProjectProvider projectId={projectId}>\n      <ProjectMutationsRegistration>\n        <ProjectKanbanLayout projectName={project.name} />\n      </ProjectMutationsRegistration>\n    </ProjectProvider>\n  );\n}\n\n/**\n * Hook to find a project by ID, using orgId from Zustand store\n */\nfunction useFindProjectById(projectId: string | undefined) {\n  const { isLoaded: authLoaded } = useAuth();\n  const { data: orgsData, isLoading: orgsLoading } = useUserOrganizations();\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  const organizations = orgsData?.organizations ?? [];\n\n  // Use stored org ID, or fall back to first org\n  const orgIdToUse = selectedOrgId ?? organizations[0]?.id ?? null;\n\n  const { data: projects = [], isLoading: projectsLoading } =\n    useOrganizationProjects(orgIdToUse);\n\n  const project = useMemo(() => {\n    if (!projectId) return undefined;\n    return projects.find((p) => p.id === projectId);\n  }, [projectId, projects]);\n\n  return {\n    project,\n    organizationId: project?.organization_id ?? selectedOrgId,\n    // Include auth loading state - we can't determine project access until auth loads\n    isLoading: !authLoaded || orgsLoading || projectsLoading,\n  };\n}\n\n/**\n * ProjectKanban page - displays the Kanban board for a specific project\n *\n * URL patterns:\n * - /projects/:projectId - Kanban board with no issue selected\n * - /projects/:projectId/issues/:issueId - Kanban with issue panel open\n * - /projects/:projectId/issues/:issueId/workspaces/:workspaceId - Kanban with workspace session panel open\n * - /projects/:projectId/issues/:issueId/workspaces/create/:draftId - Kanban with workspace create panel\n *\n * Note: issue creation is composer-store state on top of /projects/:projectId.\n *\n * Note: This component is rendered inside SharedAppLayout which provides\n * NavbarContainer, AppBar, and SyncErrorProvider.\n */\nexport function ProjectKanban() {\n  const { projectId, hostId, hasInvalidWorkspaceCreateDraftId } =\n    useCurrentKanbanRouteState();\n  const appNavigation = useAppNavigation();\n  const { t } = useTranslation('common');\n  const { isSignedIn, isLoaded: authLoaded } = useAuth();\n  const issueComposerKey = useMemo(() => {\n    if (!projectId) {\n      return null;\n    }\n    return buildKanbanIssueComposerKey(hostId, projectId);\n  }, [hostId, projectId]);\n  const previousIssueComposerKeyRef = useRef<string | null>(null);\n\n  useEffect(() => {\n    const previousKey = previousIssueComposerKeyRef.current;\n    if (previousKey && previousKey !== issueComposerKey) {\n      closeKanbanIssueComposer(previousKey);\n    }\n\n    previousIssueComposerKeyRef.current = issueComposerKey;\n  }, [issueComposerKey]);\n\n  // Redirect invalid workspace-create draft URLs back to the closed project view.\n  useEffect(() => {\n    if (!projectId) return;\n\n    if (hasInvalidWorkspaceCreateDraftId) {\n      appNavigation.goToProject(projectId, {\n        replace: true,\n      });\n    }\n  }, [projectId, hasInvalidWorkspaceCreateDraftId, appNavigation]);\n\n  // Find the project and get its organization\n  const { organizationId, isLoading } = useFindProjectById(\n    projectId ?? undefined\n  );\n\n  // Show loading while auth state is being determined\n  if (!authLoaded || isLoading) {\n    return (\n      <div className=\"flex items-center justify-center h-full w-full\">\n        <p className=\"text-low\">{t('states.loading')}</p>\n      </div>\n    );\n  }\n\n  // If not signed in, prompt user to log in\n  if (!isSignedIn) {\n    return (\n      <div className=\"flex items-center justify-center h-full w-full p-base\">\n        <LoginRequiredPrompt\n          className=\"max-w-md\"\n          title={t('kanban.loginRequired.title')}\n          description={t('kanban.loginRequired.description')}\n          actionLabel={t('kanban.loginRequired.action')}\n        />\n      </div>\n    );\n  }\n\n  if (!projectId || !organizationId) {\n    return (\n      <div className=\"flex items-center justify-center h-full w-full\">\n        <p className=\"text-low\">{t('kanban.noProjectFound')}</p>\n      </div>\n    );\n  }\n\n  return (\n    <OrgProvider organizationId={organizationId}>\n      <ProjectKanbanInner projectId={projectId} />\n    </OrgProvider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/ProjectRightSidebarContainer.tsx",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { ArrowDownIcon, ArrowsOutIcon, XIcon } from '@phosphor-icons/react';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useUserContext } from '@/shared/hooks/useUserContext';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { ExecutionProcessesProvider } from '@/shared/providers/ExecutionProcessesProvider';\nimport { ApprovalFeedbackProvider } from '@/features/workspace-chat/model/contexts/ApprovalFeedbackContext';\nimport { EntriesProvider } from '@/features/workspace-chat/model/contexts/EntriesContext';\nimport { MessageEditProvider } from '@/features/workspace-chat/model/contexts/MessageEditContext';\nimport { CreateModeProvider } from '@/features/create-mode/model/CreateModeProvider';\nimport { useWorkspaceSessions } from '@/shared/hooks/useWorkspaceSessions';\nimport { useWorkspaceRecord } from '@/shared/hooks/useWorkspaceRecord';\nimport { SessionChatBoxContainer } from '@/features/workspace-chat/ui/SessionChatBoxContainer';\nimport { CreateChatBoxContainer } from '@/shared/components/CreateChatBoxContainer';\nimport { KanbanIssuePanelContainer } from './KanbanIssuePanelContainer';\nimport {\n  ConversationList,\n  type ConversationListHandle,\n} from '@/features/workspace-chat/ui/ConversationListContainer';\nimport { RetryUiProvider } from '@/features/workspace-chat/model/contexts/RetryUiContext';\nimport { createWorkspaceWithSession } from '@/shared/types/attempt';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useCurrentKanbanRouteState } from '@/shared/hooks/useCurrentKanbanRouteState';\nimport {\n  buildKanbanIssueComposerKey,\n  closeKanbanIssueComposer,\n  useKanbanIssueComposer,\n} from '@/shared/stores/useKanbanIssueComposerStore';\n\ninterface WorkspaceSessionPanelProps {\n  workspaceId: string;\n  onClose: () => void;\n}\n\ninterface WorkspaceCreatePanelProps {\n  linkedIssueId: string | null;\n  linkedIssueSimpleId: string | null;\n  onOpenIssue: (issueId: string) => void;\n  onClose: () => void;\n  children: ReactNode;\n}\n\ntype IssuePanelResolution = 'resolving' | 'ready' | 'missing';\n\ntype RightPanelState =\n  | { kind: 'closed' }\n  | { kind: 'create-issue' }\n  | { kind: 'issue'; issueId: string; resolution: IssuePanelResolution }\n  | { kind: 'issue-workspace'; workspaceId: string }\n  | { kind: 'workspace-create'; draftId: string; issueId: string | null };\n\nfunction resolveIssuePanelResolution({\n  issueId,\n  hasIssue,\n  isProjectLoading,\n  expectedIssueId,\n}: {\n  issueId: string;\n  hasIssue: boolean;\n  isProjectLoading: boolean;\n  expectedIssueId: string | null;\n}): IssuePanelResolution {\n  if (isProjectLoading) {\n    return 'resolving';\n  }\n\n  if (hasIssue) {\n    return 'ready';\n  }\n\n  if (expectedIssueId === issueId) {\n    return 'resolving';\n  }\n\n  return 'missing';\n}\n\nfunction WorkspaceCreatePanel({\n  linkedIssueId,\n  linkedIssueSimpleId,\n  onOpenIssue,\n  onClose,\n  children,\n}: WorkspaceCreatePanelProps) {\n  const { t } = useTranslation('tasks');\n  const breadcrumbButtonClass =\n    'min-w-0 text-sm text-normal truncate rounded-sm px-1 py-0.5 hover:bg-panel hover:text-high transition-colors';\n\n  const handleOpenIssue = useCallback(() => {\n    if (linkedIssueId) {\n      onOpenIssue(linkedIssueId);\n      return;\n    }\n    onClose();\n  }, [linkedIssueId, onOpenIssue, onClose]);\n\n  return (\n    <div className=\"relative flex h-full flex-1 flex-col bg-primary\">\n      <div className=\"flex items-center justify-between px-base py-half border-b shrink-0\">\n        <div className=\"flex items-center gap-half min-w-0 font-ibm-plex-mono\">\n          <button\n            type=\"button\"\n            onClick={handleOpenIssue}\n            className={`${breadcrumbButtonClass} shrink-0`}\n            aria-label=\"Open linked issue\"\n          >\n            {linkedIssueSimpleId ?? 'Issue'}\n          </button>\n          <span className=\"text-low text-sm shrink-0\">/</span>\n          <span className={breadcrumbButtonClass}>\n            {t('createWorkspaceFromPr.createWorkspace')}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-half\">\n          <button\n            type=\"button\"\n            onClick={onClose}\n            className=\"p-half rounded-sm text-low hover:text-normal hover:bg-panel transition-colors\"\n            aria-label=\"Close create workspace view\"\n          >\n            <XIcon className=\"size-icon-sm\" weight=\"bold\" />\n          </button>\n        </div>\n      </div>\n      <div className=\"flex-1 min-h-0\">{children}</div>\n    </div>\n  );\n}\n\nfunction WorkspaceSessionPanel({\n  workspaceId,\n  onClose,\n}: WorkspaceSessionPanelProps) {\n  const appNavigation = useAppNavigation();\n  const { projectId, getIssue } = useProjectContext();\n  const routeState = useCurrentKanbanRouteState();\n  const { workspaces: remoteWorkspaces } = useUserContext();\n  const { activeWorkspaces, archivedWorkspaces } = useWorkspaceContext();\n  const conversationListRef = useRef<ConversationListHandle>(null);\n  const [isAtBottom, setIsAtBottom] = useState(true);\n  const { data: workspace, isLoading: isWorkspaceLoading } = useWorkspaceRecord(\n    workspaceId,\n    { enabled: !!workspaceId }\n  );\n  const {\n    sessions,\n    selectedSession,\n    selectedSessionId,\n    selectSession,\n    isLoading: isSessionsLoading,\n    isNewSessionMode,\n    startNewSession,\n  } = useWorkspaceSessions(workspaceId, { enabled: !!workspaceId });\n\n  const workspaceSummary = useMemo(\n    () =>\n      [...activeWorkspaces, ...archivedWorkspaces].find(\n        (workspace) => workspace.id === workspaceId\n      ),\n    [activeWorkspaces, archivedWorkspaces, workspaceId]\n  );\n\n  const linkedWorkspace = useMemo(\n    () =>\n      remoteWorkspaces.find(\n        (ws) =>\n          ws.local_workspace_id === workspaceId && ws.project_id === projectId\n      ) ?? null,\n    [remoteWorkspaces, workspaceId, projectId]\n  );\n\n  const linkedIssueId = linkedWorkspace?.issue_id ?? null;\n  const breadcrumbIssueId = routeState.issueId ?? linkedIssueId;\n\n  const issueSimpleId = useMemo(() => {\n    if (!breadcrumbIssueId) return null;\n    return getIssue(breadcrumbIssueId)?.simple_id ?? null;\n  }, [breadcrumbIssueId, getIssue]);\n\n  const workspaceBranch = workspace?.branch ?? workspaceSummary?.branch ?? null;\n\n  const handleOpenIssuePanel = useCallback(() => {\n    if (projectId && breadcrumbIssueId) {\n      appNavigation.goToProjectIssue(projectId, breadcrumbIssueId);\n      return;\n    }\n    onClose();\n  }, [projectId, breadcrumbIssueId, appNavigation, onClose]);\n\n  const handleOpenWorkspaceView = useCallback(() => {\n    appNavigation.goToWorkspace(workspaceId);\n  }, [appNavigation, workspaceId]);\n\n  const breadcrumbButtonClass =\n    'min-w-0 text-sm text-normal truncate rounded-sm px-1 py-0.5 hover:bg-panel hover:text-high transition-colors';\n\n  const workspaceWithSession = useMemo(() => {\n    if (!workspace) return undefined;\n    return createWorkspaceWithSession(workspace, selectedSession);\n  }, [workspace, selectedSession]);\n\n  const handleScrollToPreviousMessage = useCallback(() => {\n    conversationListRef.current?.scrollToPreviousUserMessage();\n  }, []);\n\n  const handleScrollToBottom = useCallback(\n    (behavior: 'auto' | 'smooth' = 'smooth') => {\n      conversationListRef.current?.scrollToBottom(behavior);\n    },\n    []\n  );\n\n  const handleAtBottomChange = useCallback((atBottom: boolean) => {\n    setIsAtBottom(atBottom);\n  }, []);\n\n  return (\n    <ExecutionProcessesProvider\n      key={`${workspaceId}-${selectedSessionId ?? 'new'}`}\n      sessionId={selectedSessionId}\n    >\n      <ApprovalFeedbackProvider>\n        <EntriesProvider key={`${workspaceId}-${selectedSessionId ?? 'new'}`}>\n          <MessageEditProvider>\n            <div className=\"relative flex h-full flex-1 flex-col bg-primary\">\n              <div className=\"flex items-center justify-between px-base py-half border-b shrink-0\">\n                <div className=\"flex items-center gap-half min-w-0 font-ibm-plex-mono\">\n                  <button\n                    type=\"button\"\n                    onClick={handleOpenIssuePanel}\n                    className={`${breadcrumbButtonClass} shrink-0`}\n                    aria-label=\"Open linked issue\"\n                  >\n                    {issueSimpleId ?? 'Issue'}\n                  </button>\n                  <span className=\"text-low text-sm shrink-0\">/</span>\n                  <button\n                    type=\"button\"\n                    onClick={handleOpenWorkspaceView}\n                    className={breadcrumbButtonClass}\n                    aria-label=\"Open workspace\"\n                  >\n                    {workspaceBranch ?? 'Workspace'}\n                  </button>\n                </div>\n\n                <div className=\"flex items-center gap-half\">\n                  <button\n                    type=\"button\"\n                    onClick={handleOpenWorkspaceView}\n                    className=\"p-half rounded-sm text-low hover:text-normal hover:bg-panel transition-colors\"\n                    aria-label=\"Open in workspace view\"\n                  >\n                    <ArrowsOutIcon className=\"size-icon-sm\" weight=\"bold\" />\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={onClose}\n                    className=\"p-half rounded-sm text-low hover:text-normal hover:bg-panel transition-colors\"\n                    aria-label=\"Close conversation view\"\n                  >\n                    <XIcon className=\"size-icon-sm\" weight=\"bold\" />\n                  </button>\n                </div>\n              </div>\n\n              {workspaceWithSession ? (\n                <div className=\"flex flex-1 min-h-0 overflow-hidden justify-center\">\n                  <div className=\"w-chat max-w-full h-full\">\n                    <RetryUiProvider workspaceId={workspaceWithSession.id}>\n                      <ConversationList\n                        key={`${workspaceId}-${selectedSessionId ?? 'new'}`}\n                        ref={conversationListRef}\n                        attempt={workspaceWithSession}\n                        onAtBottomChange={handleAtBottomChange}\n                        sessionScopeId={selectedSessionId}\n                      />\n                    </RetryUiProvider>\n                  </div>\n                </div>\n              ) : (\n                <div className=\"flex-1\" />\n              )}\n\n              {workspaceWithSession && !isAtBottom && (\n                <div className=\"flex justify-center pointer-events-none\">\n                  <div className=\"w-chat max-w-full relative\">\n                    <button\n                      type=\"button\"\n                      onClick={() => handleScrollToBottom('auto')}\n                      className=\"absolute bottom-2 right-4 z-10 pointer-events-auto flex items-center justify-center size-8 rounded-full bg-secondary/80 backdrop-blur-sm border border-secondary text-low hover:text-normal hover:bg-secondary shadow-md transition-all\"\n                      aria-label=\"Scroll to bottom\"\n                      title=\"Scroll to bottom\"\n                    >\n                      <ArrowDownIcon className=\"size-icon-base\" weight=\"bold\" />\n                    </button>\n                  </div>\n                </div>\n              )}\n\n              <div className=\"flex justify-center @container pl-px\">\n                <SessionChatBoxContainer\n                  {...(isSessionsLoading || isWorkspaceLoading\n                    ? {\n                        mode: 'placeholder' as const,\n                      }\n                    : isNewSessionMode\n                      ? {\n                          mode: 'new-session' as const,\n                          workspaceId,\n                          onSelectSession: selectSession,\n                        }\n                      : selectedSession\n                        ? {\n                            mode: 'existing-session' as const,\n                            session: selectedSession,\n                            onSelectSession: selectSession,\n                            onStartNewSession: startNewSession,\n                          }\n                        : {\n                            mode: 'placeholder' as const,\n                          })}\n                  sessions={sessions}\n                  filesChanged={workspaceSummary?.filesChanged ?? 0}\n                  linesAdded={workspaceSummary?.linesAdded ?? 0}\n                  linesRemoved={workspaceSummary?.linesRemoved ?? 0}\n                  disableViewCode\n                  showOpenWorkspaceButton\n                  onScrollToPreviousMessage={handleScrollToPreviousMessage}\n                  onScrollToBottom={handleScrollToBottom}\n                />\n              </div>\n            </div>\n          </MessageEditProvider>\n        </EntriesProvider>\n      </ApprovalFeedbackProvider>\n    </ExecutionProcessesProvider>\n  );\n}\n\nexport function ProjectRightSidebarContainer() {\n  const appNavigation = useAppNavigation();\n  const {\n    projectId,\n    getIssue,\n    isLoading: isProjectLoading,\n    issuesById,\n  } = useProjectContext();\n  const routeState = useCurrentKanbanRouteState();\n  const { issueId, workspaceId, draftId, isWorkspaceCreateMode, hostId } =\n    routeState;\n  const issueComposerKey = useMemo(() => {\n    if (!projectId) {\n      return null;\n    }\n\n    return buildKanbanIssueComposerKey(hostId, projectId);\n  }, [hostId, projectId]);\n  const issueComposer = useKanbanIssueComposer(issueComposerKey);\n  const isCreateMode = issueComposer !== null;\n  const openIssue = useCallback(\n    (targetIssueId: string) => {\n      if (!projectId) {\n        return;\n      }\n\n      if (isCreateMode && issueComposerKey) {\n        closeKanbanIssueComposer(issueComposerKey);\n      }\n\n      appNavigation.goToProjectIssue(projectId, targetIssueId);\n    },\n    [projectId, isCreateMode, issueComposerKey, appNavigation]\n  );\n  const openIssueWorkspace = useCallback(\n    (targetIssueId: string, targetWorkspaceId: string) => {\n      if (!projectId) {\n        return;\n      }\n\n      appNavigation.goToProjectIssueWorkspace(\n        projectId,\n        targetIssueId,\n        targetWorkspaceId\n      );\n    },\n    [projectId, appNavigation]\n  );\n  const closePanel = useCallback(() => {\n    if (!projectId) {\n      return;\n    }\n\n    if (isCreateMode && issueComposerKey) {\n      closeKanbanIssueComposer(issueComposerKey);\n    }\n\n    appNavigation.goToProject(projectId);\n  }, [projectId, isCreateMode, issueComposerKey, appNavigation]);\n  const [expectedIssueId, setExpectedIssueId] = useState<string | null>(null);\n\n  const markExpectedIssue = useCallback((nextIssueId: string) => {\n    setExpectedIssueId(nextIssueId);\n  }, []);\n\n  // Keep transient create expectations scoped to the current issue route only.\n  useEffect(() => {\n    if (!expectedIssueId) {\n      return;\n    }\n\n    if (!issueId || issueId !== expectedIssueId) {\n      setExpectedIssueId(null);\n      return;\n    }\n\n    if (issuesById.has(expectedIssueId)) {\n      setExpectedIssueId(null);\n    }\n  }, [expectedIssueId, issueId, issuesById]);\n\n  const issuePanelResolution = useMemo<IssuePanelResolution | null>(() => {\n    if (!issueId || isCreateMode || workspaceId || isWorkspaceCreateMode) {\n      return null;\n    }\n\n    return resolveIssuePanelResolution({\n      issueId,\n      hasIssue: issuesById.has(issueId),\n      isProjectLoading,\n      expectedIssueId,\n    });\n  }, [\n    issueId,\n    isCreateMode,\n    workspaceId,\n    isWorkspaceCreateMode,\n    issuesById,\n    isProjectLoading,\n    expectedIssueId,\n  ]);\n\n  const rightPanelState = useMemo<RightPanelState>(() => {\n    if (isCreateMode) {\n      return { kind: 'create-issue' };\n    }\n\n    if (isWorkspaceCreateMode) {\n      if (draftId) {\n        return {\n          kind: 'workspace-create',\n          draftId,\n          issueId,\n        };\n      }\n      return { kind: 'closed' };\n    }\n\n    if (workspaceId) {\n      return { kind: 'issue-workspace', workspaceId };\n    }\n\n    if (issueId) {\n      return {\n        kind: 'issue',\n        issueId,\n        resolution: issuePanelResolution ?? 'resolving',\n      };\n    }\n\n    return { kind: 'closed' };\n  }, [\n    isWorkspaceCreateMode,\n    draftId,\n    issueId,\n    workspaceId,\n    isCreateMode,\n    issuePanelResolution,\n  ]);\n\n  const handleOpenIssueFromCreate = useCallback(\n    (targetIssueId: string) => {\n      openIssue(targetIssueId);\n    },\n    [openIssue]\n  );\n\n  const handleWorkspaceCreated = useCallback(\n    (createdWorkspaceId: string) => {\n      if (issueId) {\n        openIssueWorkspace(issueId, createdWorkspaceId);\n        return;\n      }\n\n      appNavigation.goToWorkspace(createdWorkspaceId);\n    },\n    [issueId, openIssueWorkspace, appNavigation]\n  );\n\n  useEffect(() => {\n    if (rightPanelState.kind !== 'issue') {\n      return;\n    }\n\n    if (rightPanelState.resolution !== 'missing') {\n      return;\n    }\n\n    closePanel();\n  }, [rightPanelState, closePanel]);\n\n  if (rightPanelState.kind === 'workspace-create') {\n    const linkedIssueId = rightPanelState.issueId;\n    const linkedIssueSimpleId = linkedIssueId\n      ? (getIssue(linkedIssueId)?.simple_id ?? null)\n      : null;\n\n    return (\n      <WorkspaceCreatePanel\n        linkedIssueId={linkedIssueId}\n        linkedIssueSimpleId={linkedIssueSimpleId}\n        onOpenIssue={handleOpenIssueFromCreate}\n        onClose={closePanel}\n      >\n        <CreateModeProvider\n          key={rightPanelState.draftId}\n          draftId={rightPanelState.draftId}\n        >\n          <CreateChatBoxContainer onWorkspaceCreated={handleWorkspaceCreated} />\n        </CreateModeProvider>\n      </WorkspaceCreatePanel>\n    );\n  }\n\n  if (rightPanelState.kind === 'issue-workspace') {\n    return (\n      <WorkspaceSessionPanel\n        workspaceId={rightPanelState.workspaceId}\n        onClose={closePanel}\n      />\n    );\n  }\n\n  if (rightPanelState.kind === 'closed') {\n    return null;\n  }\n\n  return (\n    <KanbanIssuePanelContainer\n      issueResolution={\n        rightPanelState.kind === 'issue' ? rightPanelState.resolution : null\n      }\n      onExpectIssueOpen={markExpectedIssue}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/kanban/kanban-issue-panel-state.ts",
    "content": "import type { IssuePriority } from 'shared/remote-types';\nimport type {\n  IssueFormData,\n  IssuePanelMode,\n} from '@vibe/ui/components/KanbanIssuePanel';\n\ninterface EditTextState {\n  title: string;\n  hasLocalTitleEdit: boolean;\n  description: string | null;\n  hasLocalDescriptionEdit: boolean;\n}\n\nexport interface KanbanIssuePanelFormState {\n  createFormData: IssueFormData | null;\n  editTextState: EditTextState;\n  isDraftAutosavePaused: boolean;\n  hasRestoredFromScratch: boolean;\n}\n\ninterface SelectedIssueSnapshot {\n  title: string;\n  description: string | null;\n  status_id: string;\n  priority: IssuePriority | null;\n}\n\ntype KanbanIssuePanelFormAction =\n  | {\n      type: 'resetForIssueChange';\n      mode: IssuePanelMode;\n      createFormData: IssueFormData | null;\n      hasRestoredFromScratch: boolean;\n    }\n  | { type: 'setCreateFormData'; createFormData: IssueFormData | null }\n  | {\n      type: 'patchCreateFormData';\n      patch: Partial<IssueFormData>;\n      fallback: IssueFormData;\n    }\n  | { type: 'setCreateAssigneeIds'; assigneeIds: string[] }\n  | { type: 'setDraftAutosavePaused'; isPaused: boolean }\n  | {\n      type: 'setHasRestoredFromScratch';\n      hasRestoredFromScratch: boolean;\n    }\n  | { type: 'setEditTitle'; title: string }\n  | { type: 'setEditDescription'; description: string | null };\n\nconst EMPTY_EDIT_TEXT_STATE: EditTextState = {\n  title: '',\n  hasLocalTitleEdit: false,\n  description: null,\n  hasLocalDescriptionEdit: false,\n};\n\nexport function createBlankCreateFormData(\n  defaultStatusId: string,\n  createDraftWorkspaceByDefault = false\n): IssueFormData {\n  return {\n    title: '',\n    description: null,\n    statusId: defaultStatusId,\n    priority: null,\n    assigneeIds: [],\n    tagIds: [],\n    createDraftWorkspace: createDraftWorkspaceByDefault,\n  };\n}\n\nexport function createInitialKanbanIssuePanelFormState(): KanbanIssuePanelFormState {\n  return {\n    createFormData: null,\n    editTextState: EMPTY_EDIT_TEXT_STATE,\n    isDraftAutosavePaused: false,\n    hasRestoredFromScratch: false,\n  };\n}\n\nexport function kanbanIssuePanelFormReducer(\n  state: KanbanIssuePanelFormState,\n  action: KanbanIssuePanelFormAction\n): KanbanIssuePanelFormState {\n  switch (action.type) {\n    case 'resetForIssueChange':\n      return {\n        createFormData: action.mode === 'create' ? action.createFormData : null,\n        editTextState: EMPTY_EDIT_TEXT_STATE,\n        isDraftAutosavePaused: false,\n        hasRestoredFromScratch:\n          action.mode === 'create' ? action.hasRestoredFromScratch : false,\n      };\n    case 'setCreateFormData':\n      return {\n        ...state,\n        createFormData: action.createFormData,\n      };\n    case 'patchCreateFormData':\n      return {\n        ...state,\n        createFormData: {\n          ...(state.createFormData ?? action.fallback),\n          ...action.patch,\n        },\n      };\n    case 'setCreateAssigneeIds':\n      return {\n        ...state,\n        createFormData: state.createFormData\n          ? {\n              ...state.createFormData,\n              assigneeIds: action.assigneeIds,\n            }\n          : state.createFormData,\n      };\n    case 'setDraftAutosavePaused':\n      return {\n        ...state,\n        isDraftAutosavePaused: action.isPaused,\n      };\n    case 'setHasRestoredFromScratch':\n      return {\n        ...state,\n        hasRestoredFromScratch: action.hasRestoredFromScratch,\n      };\n    case 'setEditTitle':\n      return {\n        ...state,\n        editTextState: {\n          ...state.editTextState,\n          title: action.title,\n          hasLocalTitleEdit: true,\n        },\n      };\n    case 'setEditDescription':\n      return {\n        ...state,\n        editTextState: {\n          ...state.editTextState,\n          description: action.description,\n          hasLocalDescriptionEdit: true,\n        },\n      };\n    default:\n      return state;\n  }\n}\n\nfunction areStringSetsEqual(a: string[], b: string[]): boolean {\n  if (a.length !== b.length) return false;\n  const aSet = new Set(a);\n  for (const item of b) {\n    if (!aSet.has(item)) return false;\n  }\n  return true;\n}\n\ninterface DisplayDataSelectorInput {\n  state: KanbanIssuePanelFormState;\n  mode: IssuePanelMode;\n  createModeDefaults: IssueFormData;\n  selectedIssue: SelectedIssueSnapshot | null;\n  currentAssigneeIds: string[];\n  currentTagIds: string[];\n}\n\nexport function selectDisplayData({\n  state,\n  mode,\n  createModeDefaults,\n  selectedIssue,\n  currentAssigneeIds,\n  currentTagIds,\n}: DisplayDataSelectorInput): IssueFormData {\n  if (mode === 'create') {\n    return state.createFormData ?? createModeDefaults;\n  }\n\n  return {\n    title: state.editTextState.hasLocalTitleEdit\n      ? state.editTextState.title\n      : (selectedIssue?.title ?? ''),\n    description: state.editTextState.hasLocalDescriptionEdit\n      ? state.editTextState.description\n      : (selectedIssue?.description ?? null),\n    statusId: selectedIssue?.status_id ?? '',\n    priority: selectedIssue?.priority ?? null,\n    assigneeIds: currentAssigneeIds,\n    tagIds: currentTagIds,\n    createDraftWorkspace: false,\n  };\n}\n\ninterface CreateDraftDirtySelectorInput {\n  state: KanbanIssuePanelFormState;\n  mode: IssuePanelMode;\n  createModeDefaults: IssueFormData;\n}\n\nexport function selectIsCreateDraftDirty({\n  state,\n  mode,\n  createModeDefaults,\n}: CreateDraftDirtySelectorInput): boolean {\n  if (mode !== 'create' || !state.createFormData) return false;\n\n  return (\n    state.createFormData.title !== createModeDefaults.title ||\n    (state.createFormData.description ?? null) !==\n      createModeDefaults.description ||\n    state.createFormData.statusId !== createModeDefaults.statusId ||\n    state.createFormData.priority !== createModeDefaults.priority ||\n    !areStringSetsEqual(\n      state.createFormData.assigneeIds,\n      createModeDefaults.assigneeIds\n    ) ||\n    !areStringSetsEqual(\n      state.createFormData.tagIds,\n      createModeDefaults.tagIds\n    ) ||\n    state.createFormData.createDraftWorkspace !==\n      createModeDefaults.createDraftWorkspace\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/migrate/MigratePage.tsx",
    "content": "import { useCallback, useEffect, useRef } from 'react';\nimport { usePostHog } from 'posthog-js/react';\nimport { ThemeMode } from 'shared/types';\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport { MigrateLayout } from '@/features/migration/ui/MigrateLayout';\n\nconst REMOTE_ONBOARDING_EVENTS = {\n  STAGE_VIEWED: 'remote_onboarding_ui_stage_viewed',\n} as const;\n\nfunction resolveTheme(theme: ThemeMode): 'light' | 'dark' {\n  if (theme === ThemeMode.SYSTEM) {\n    return window.matchMedia('(prefers-color-scheme: dark)').matches\n      ? 'dark'\n      : 'light';\n  }\n  return theme === ThemeMode.DARK ? 'dark' : 'light';\n}\n\nexport function MigratePage() {\n  const { theme } = useTheme();\n  const posthog = usePostHog();\n  const hasTrackedStageViewRef = useRef(false);\n\n  const trackRemoteOnboardingEvent = useCallback(\n    (eventName: string, properties: Record<string, unknown> = {}) => {\n      posthog?.capture(eventName, {\n        ...properties,\n        flow: 'remote_onboarding_ui',\n        source: 'frontend',\n      });\n    },\n    [posthog]\n  );\n\n  useEffect(() => {\n    if (hasTrackedStageViewRef.current) {\n      return;\n    }\n\n    trackRemoteOnboardingEvent(REMOTE_ONBOARDING_EVENTS.STAGE_VIEWED, {\n      stage: 'migrate',\n    });\n    hasTrackedStageViewRef.current = true;\n  }, [trackRemoteOnboardingEvent]);\n\n  const logoSrc =\n    resolveTheme(theme) === 'dark'\n      ? '/vibe-kanban-logo-dark.svg'\n      : '/vibe-kanban-logo.svg';\n\n  return (\n    <div className=\"h-full overflow-auto bg-primary\">\n      <div className=\"mx-auto flex min-h-full w-full max-w-3xl flex-col justify-center px-base py-double\">\n        <div className=\"rounded-sm border border-border bg-secondary p-double space-y-double\">\n          <header className=\"space-y-double text-center\">\n            <div className=\"flex justify-center\">\n              <img\n                src={logoSrc}\n                alt=\"Vibe Kanban\"\n                className=\"h-8 w-auto logo\"\n              />\n            </div>\n            <p className=\"text-sm text-low\">\n              Migrate your local projects to cloud projects.\n            </p>\n          </header>\n          <MigrateLayout />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/root/RootRedirectPage.tsx",
    "content": "import { useEffect } from 'react';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { getFirstProjectDestination } from '@/shared/lib/firstProjectDestination';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { useUiPreferencesStore } from '@/shared/stores/useUiPreferencesStore';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\n\nexport function RootRedirectPage() {\n  const { config, loading, loginStatus } = useUserSystem();\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n  const appNavigation = useAppNavigation();\n\n  useEffect(() => {\n    if (loading || !config) {\n      return;\n    }\n\n    let isActive = true;\n    void (async () => {\n      if (!config.remote_onboarding_acknowledged) {\n        appNavigation.goToOnboarding({ replace: true });\n        return;\n      }\n\n      if (loginStatus?.status !== 'loggedin') {\n        appNavigation.goToWorkspacesCreate({ replace: true });\n        return;\n      }\n\n      // Read saved selections imperatively to avoid re-triggering this effect\n      // when the scratch store initializes from the server\n      const { selectedOrgId, selectedProjectId } =\n        useUiPreferencesStore.getState();\n\n      const destination = await getFirstProjectDestination(\n        setSelectedOrgId,\n        selectedOrgId,\n        selectedProjectId\n      );\n      if (!isActive) {\n        return;\n      }\n\n      if (destination?.kind === 'project') {\n        appNavigation.goToProject(destination.projectId, { replace: true });\n        return;\n      }\n\n      appNavigation.goToWorkspacesCreate({ replace: true });\n    })();\n\n    return () => {\n      isActive = false;\n    };\n  }, [appNavigation, config, loading, loginStatus?.status, setSelectedOrgId]);\n\n  return (\n    <div className=\"h-screen bg-primary flex items-center justify-center\">\n      <p className=\"text-low\">Loading...</p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/AppBarNotificationBellContainer.tsx",
    "content": "import { useNavigate } from '@tanstack/react-router';\nimport { BellIcon } from '@phosphor-icons/react';\nimport { cn } from '@vibe/ui/lib/cn';\nimport { Tooltip } from '@vibe/ui/components/Tooltip';\nimport { useNotifications } from '@/shared/hooks/useNotifications';\n\nexport function AppBarNotificationBellContainer() {\n  const navigate = useNavigate();\n  const { unseenCount, enabled } = useNotifications();\n\n  if (!enabled) return null;\n\n  return (\n    <Tooltip content=\"Notifications\" side=\"right\">\n      <button\n        type=\"button\"\n        onClick={() => navigate({ to: '/notifications' })}\n        className={cn(\n          'relative flex items-center justify-center w-10 h-10 rounded-lg',\n          'text-sm font-medium transition-colors cursor-pointer',\n          'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand',\n          'bg-panel text-normal hover:opacity-80'\n        )}\n        aria-label=\"Notifications\"\n      >\n        <BellIcon className=\"w-5 h-5\" weight=\"bold\" />\n        {unseenCount > 0 && (\n          <span className=\"absolute -top-2 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-brand-secondary text-[10px] font-medium text-white\">\n            {unseenCount > 99 ? '99+' : unseenCount}\n          </span>\n        )}\n      </button>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/ChangesPanelContainer.tsx",
    "content": "import { memo, useRef, useEffect, useCallback, useState, useMemo } from 'react';\nimport {\n  ChangesPanel,\n  type ChangesPanelHandle,\n  type RenderDiffItemProps,\n} from '@vibe/ui/components/ChangesPanel';\nimport { sortDiffs } from '@/shared/lib/fileTreeUtils';\nimport { useChangesView } from '@/shared/hooks/useChangesView';\nimport { useWorkspaceDiffContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useScrollSyncStateMachine } from '@/shared/hooks/useScrollSyncStateMachine';\nimport { usePersistedExpanded } from '@/shared/stores/useUiPreferencesStore';\nimport { PierreDiffCard } from './PierreDiffCard';\nimport type { Diff, DiffChangeKind } from 'shared/types';\n\n// Auto-collapse defaults based on change type (matches DiffsPanel behavior)\nconst COLLAPSE_BY_CHANGE_TYPE: Record<DiffChangeKind, boolean> = {\n  added: false, // Expand added files\n  deleted: true, // Collapse deleted files\n  modified: false, // Expand modified files\n  renamed: true, // Collapse renamed files\n  copied: true, // Collapse copied files\n  permissionChange: true, // Collapse permission changes\n};\n\n// Collapse large diffs (over 200 lines)\nconst COLLAPSE_MAX_LINES = 200;\n\nfunction shouldAutoCollapse(diff: Diff): boolean {\n  const totalLines = (diff.additions ?? 0) + (diff.deletions ?? 0);\n\n  // For renamed files, only collapse if there are no content changes\n  // OR if the diff is large\n  if (diff.change === 'renamed') {\n    return totalLines === 0 || totalLines > COLLAPSE_MAX_LINES;\n  }\n\n  // Collapse based on change type for other types\n  if (COLLAPSE_BY_CHANGE_TYPE[diff.change]) {\n    return true;\n  }\n\n  // Collapse large diffs\n  if (totalLines > COLLAPSE_MAX_LINES) {\n    return true;\n  }\n\n  return false;\n}\n\ninterface ChangesPanelContainerProps {\n  className: string;\n  /** Attempt ID for opening files in IDE */\n  workspaceId: string;\n}\n\nconst PersistedDiffItem = memo(function PersistedDiffItem({\n  diff,\n  initialExpanded,\n  workspaceId,\n}: {\n  diff: Diff;\n  initialExpanded: boolean;\n  workspaceId: string;\n}) {\n  const path = diff.newPath || diff.oldPath || '';\n  const [expanded, toggle] = usePersistedExpanded(\n    `diff:${path}`,\n    initialExpanded\n  );\n\n  return (\n    <PierreDiffCard\n      diff={diff}\n      expanded={expanded}\n      onToggle={toggle}\n      workspaceId={workspaceId}\n      className=\"\"\n    />\n  );\n});\n\nexport function ChangesPanelContainer({\n  className,\n  workspaceId,\n}: ChangesPanelContainerProps) {\n  const { diffs } = useWorkspaceDiffContext();\n  const {\n    selectedFilePath,\n    selectedLineNumber,\n    setFileInView,\n    registerScrollToFile,\n  } = useChangesView();\n  const diffRefs = useRef<Map<string, HTMLDivElement>>(new Map());\n  const changesPanelRef = useRef<ChangesPanelHandle>(null);\n  const scrollContainerRef = useRef<HTMLElement | null>(null);\n  const visibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({\n    startIndex: 0,\n    endIndex: 0,\n  });\n  const [processedPaths] = useState(() => new Set<string>());\n\n  const diffItems = useMemo(() => {\n    return sortDiffs(diffs).map((diff) => {\n      const path = diff.newPath || diff.oldPath || '';\n\n      let initialExpanded = true;\n      if (!processedPaths.has(path)) {\n        processedPaths.add(path);\n        initialExpanded = !shouldAutoCollapse(diff);\n      }\n\n      return { diff, initialExpanded };\n    });\n  }, [diffs, processedPaths]);\n\n  const pathToIndex = useMemo(() => {\n    const map = new Map<string, number>();\n    diffItems.forEach(({ diff }, index) => {\n      const path = diff.newPath || diff.oldPath || '';\n      map.set(path, index);\n    });\n    return map;\n  }, [diffItems]);\n\n  const indexToPath = useCallback(\n    (index: number): string | null => {\n      const item = diffItems[index];\n      if (!item) return null;\n      return item.diff.newPath || item.diff.oldPath || null;\n    },\n    [diffItems]\n  );\n\n  const getTopFilePath = useCallback(\n    (range: { startIndex: number; endIndex: number }): string | null => {\n      const container = scrollContainerRef.current;\n      if (!container) {\n        return indexToPath(range.startIndex);\n      }\n\n      const containerTop = container.getBoundingClientRect().top;\n\n      let bestPath: string | null = null;\n      let bestTop = -Infinity;\n\n      for (let i = range.startIndex; i <= range.endIndex; i++) {\n        const path = indexToPath(i);\n        if (!path) continue;\n\n        const el = diffRefs.current.get(path);\n        if (!el) continue;\n\n        const rect = el.getBoundingClientRect();\n        const relativeTop = rect.top - containerTop;\n        const relativeBottom = rect.bottom - containerTop;\n\n        const spansContainerTop = relativeTop <= 0 && relativeBottom > 0;\n\n        if (spansContainerTop && relativeTop > bestTop) {\n          bestTop = relativeTop;\n          bestPath = path;\n        }\n      }\n\n      return bestPath ?? indexToPath(range.startIndex);\n    },\n    [indexToPath]\n  );\n\n  const {\n    state: syncState,\n    fileInView: stateMachineFileInView,\n    scrollToFile: stateMachineScrollToFile,\n    onRangeChanged,\n    onScrollComplete,\n  } = useScrollSyncStateMachine({\n    pathToIndex,\n    indexToPath,\n    getTopFilePath,\n  });\n\n  // Keep a ref to syncState for the scroll listener (avoids stale closure)\n  const syncStateRef = useRef(syncState);\n  syncStateRef.current = syncState;\n\n  useEffect(() => {\n    if (stateMachineFileInView !== null) {\n      setFileInView(stateMachineFileInView);\n    }\n  }, [stateMachineFileInView, setFileInView]);\n\n  useEffect(() => {\n    const container = scrollContainerRef.current;\n    if (!container) return;\n\n    const handleScroll = () => {\n      const currentState = syncStateRef.current;\n      if (\n        currentState === 'programmatic-scroll' ||\n        currentState === 'sync-cooldown'\n      ) {\n        return;\n      }\n\n      const range = visibleRangeRef.current;\n      const topPath = getTopFilePath(range);\n      if (topPath !== null) {\n        setFileInView(topPath);\n      }\n    };\n\n    container.addEventListener('scroll', handleScroll, { passive: true });\n    return () => {\n      container.removeEventListener('scroll', handleScroll);\n    };\n  }, [getTopFilePath, setFileInView]);\n\n  const handleRangeChanged = useCallback(\n    (range: { startIndex: number; endIndex: number }) => {\n      visibleRangeRef.current = range;\n      onRangeChanged(range);\n    },\n    [onRangeChanged]\n  );\n\n  const handleScrollToFile = useCallback(\n    (path: string, lineNumber?: number) => {\n      const index = stateMachineScrollToFile(path, lineNumber);\n      if (index === null) return;\n\n      changesPanelRef.current?.scrollToIndex(index, { align: 'start' });\n\n      requestAnimationFrame(() => {\n        setTimeout(() => {\n          if (lineNumber) {\n            const fileEl = diffRefs.current.get(path);\n            if (fileEl) {\n              const selector = `[data-line=\"${lineNumber}\"]`;\n              const commentEl = fileEl.querySelector(selector);\n              commentEl?.scrollIntoView({\n                behavior: 'instant',\n                block: 'center',\n              });\n            }\n          }\n          onScrollComplete();\n        }, 100);\n      });\n    },\n    [stateMachineScrollToFile, onScrollComplete]\n  );\n\n  useEffect(() => {\n    registerScrollToFile(handleScrollToFile);\n    return () => registerScrollToFile(null);\n  }, [registerScrollToFile, handleScrollToFile]);\n\n  useEffect(() => {\n    if (!selectedFilePath) return;\n\n    const index = pathToIndex.get(selectedFilePath);\n    if (index === undefined) return;\n\n    const timeoutId = setTimeout(() => {\n      changesPanelRef.current?.scrollToIndex(index, { align: 'start' });\n\n      if (selectedLineNumber) {\n        setTimeout(() => {\n          const fileEl = diffRefs.current.get(selectedFilePath);\n          if (fileEl) {\n            const selector = `[data-line=\"${selectedLineNumber}\"]`;\n            const commentEl = fileEl.querySelector(selector);\n            commentEl?.scrollIntoView({ behavior: 'instant', block: 'center' });\n          }\n        }, 100);\n      }\n    }, 0);\n\n    return () => clearTimeout(timeoutId);\n  }, [selectedFilePath, selectedLineNumber, pathToIndex]);\n\n  const handleDiffRef = useCallback(\n    (path: string, el: HTMLDivElement | null) => {\n      if (el) {\n        diffRefs.current.set(path, el);\n      } else {\n        diffRefs.current.delete(path);\n      }\n    },\n    []\n  );\n\n  const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {\n    scrollContainerRef.current = el instanceof HTMLElement ? el : null;\n  }, []);\n\n  const renderDiffItem = useCallback(\n    ({ diff, initialExpanded, workspaceId }: RenderDiffItemProps<Diff>) => (\n      <PersistedDiffItem\n        diff={diff}\n        initialExpanded={initialExpanded ?? true}\n        workspaceId={workspaceId}\n      />\n    ),\n    []\n  );\n\n  return (\n    <ChangesPanel\n      ref={changesPanelRef}\n      className={className}\n      diffItems={diffItems}\n      onDiffRef={handleDiffRef}\n      onScrollerRef={handleScrollerRef}\n      onRangeChanged={handleRangeChanged}\n      renderDiffItem={renderDiffItem}\n      workspaceId={workspaceId}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/CommentWidgetLine.tsx",
    "content": "import { useState, useCallback, memo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { CommentCard } from '@vibe/ui/components/CommentCard';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { useReview, type ReviewDraft } from '@/shared/hooks/useReview';\n\ninterface CommentWidgetLineProps {\n  draft: ReviewDraft;\n  widgetKey: string;\n  onSave: () => void;\n  onCancel: () => void;\n}\n\nexport const CommentWidgetLine = memo(function CommentWidgetLine({\n  draft,\n  widgetKey,\n  onSave,\n  onCancel,\n}: CommentWidgetLineProps) {\n  const { t } = useTranslation('common');\n  const { setDraft, addComment } = useReview();\n  const [value, setValue] = useState(draft.text);\n\n  const handleCancel = useCallback(() => {\n    setDraft(widgetKey, null);\n    onCancel();\n  }, [setDraft, widgetKey, onCancel]);\n\n  const handleSave = useCallback(() => {\n    if (value.trim()) {\n      addComment({\n        filePath: draft.filePath,\n        side: draft.side,\n        lineNumber: draft.lineNumber,\n        text: value.trim(),\n        codeLine: draft.codeLine,\n      });\n    }\n    setDraft(widgetKey, null);\n    onSave();\n  }, [value, draft, setDraft, widgetKey, onSave, addComment]);\n\n  return (\n    <CommentCard\n      variant=\"input\"\n      actions={\n        <>\n          <PrimaryButton\n            variant=\"default\"\n            onClick={handleSave}\n            disabled={!value.trim()}\n          >\n            {t('comments.addReviewComment')}\n          </PrimaryButton>\n          <PrimaryButton variant=\"secondary\" onClick={handleCancel}>\n            {t('actions.cancel')}\n          </PrimaryButton>\n        </>\n      }\n    >\n      <WYSIWYGEditor\n        value={value}\n        onChange={setValue}\n        placeholder={t('comments.addPlaceholder')}\n        className=\"w-full text-normal min-h-[60px]\"\n        onCmdEnter={handleSave}\n        autoFocus\n      />\n    </CommentCard>\n  );\n});\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/ContextBarContainer.tsx",
    "content": "import { useMemo, useCallback, type RefObject } from 'react';\nimport { CopyIcon } from '@phosphor-icons/react';\nimport {\n  ContextBar,\n  type ContextBarRenderItem,\n} from '@vibe/ui/components/ContextBar';\nimport { Tooltip } from '@vibe/ui/components/Tooltip';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { IdeIcon } from '@/shared/components/IdeIcon';\nimport { useContextBarPosition } from '@/shared/hooks/useContextBarPosition';\nimport { ContextBarActionGroups } from '@/shared/actions';\nimport {\n  type ActionDefinition,\n  type ActionVisibilityContext,\n  type ContextBarItem,\n  type SpecialIconType,\n  ActionTargetType,\n  isSpecialIcon,\n  isActionVisible,\n  isActionEnabled,\n  getActionIcon,\n  getActionTooltip,\n} from '@/shared/types/actions';\nimport type { EditorType } from 'shared/types';\nimport { useActionVisibilityContext } from '@/shared/hooks/useActionVisibilityContext';\nimport { CopyButton } from '@/shared/components/CopyButton';\nimport { isRealMobileDevice } from '@/shared/hooks/useIsMobile';\n\n/**\n * Check if a ContextBarItem is a divider\n */\nfunction isDivider(item: ContextBarItem): item is { readonly type: 'divider' } {\n  return 'type' in item && item.type === 'divider';\n}\n\n/**\n * Filter context bar items by visibility, keeping dividers but removing them\n * if they would appear at the start, end, or consecutively.\n */\nfunction filterContextBarItems(\n  items: readonly ContextBarItem[],\n  ctx: ActionVisibilityContext\n): ContextBarItem[] {\n  // Filter actions by visibility, keep dividers\n  const filtered = items.filter((item) => {\n    if (isDivider(item)) return true;\n    return isActionVisible(item, ctx);\n  });\n\n  // Remove leading/trailing dividers and consecutive dividers\n  const result: ContextBarItem[] = [];\n  for (const item of filtered) {\n    if (isDivider(item)) {\n      // Only add divider if we have items before it and last item wasn't a divider\n      if (result.length > 0 && !isDivider(result[result.length - 1])) {\n        result.push(item);\n      }\n    } else {\n      result.push(item);\n    }\n  }\n\n  // Remove trailing divider\n  if (result.length > 0 && isDivider(result[result.length - 1])) {\n    result.pop();\n  }\n\n  return result;\n}\n\n/**\n * Get the icon class name based on action state and type.\n */\nfunction getIconClassName(\n  action: ActionDefinition,\n  actionContext: ActionVisibilityContext,\n  isDisabled: boolean\n): string | undefined {\n  // Handle dev server running state (for ToggleDevServer action)\n  if (action.id === 'toggle-dev-server') {\n    const { devServerState } = actionContext;\n    if (devServerState === 'starting' || devServerState === 'stopping') {\n      return 'animate-spin';\n    }\n    if (devServerState === 'running') {\n      return 'text-error hover:text-error group-hover:text-error';\n    }\n  }\n\n  if (isDisabled) {\n    return 'opacity-40';\n  }\n\n  return undefined;\n}\n\nfunction buildSpecialItem(\n  iconType: SpecialIconType,\n  key: string,\n  tooltip: string,\n  shortcut: string | undefined,\n  enabled: boolean,\n  editorType: EditorType | null,\n  onExecuteAction: () => void\n): ContextBarRenderItem {\n  if (iconType === 'ide-icon') {\n    if (isRealMobileDevice()) {\n      return { type: 'action', key, label: tooltip, customContent: null };\n    }\n    return {\n      type: 'action',\n      key,\n      label: tooltip,\n      customContent: (\n        <Tooltip content={tooltip} shortcut={shortcut} side=\"left\">\n          <button\n            type=\"button\"\n            className=\"flex items-center justify-center transition-colors drop-shadow-[2px_2px_4px_rgba(121,121,121,0.25)]\"\n            aria-label={tooltip}\n            onClick={onExecuteAction}\n            disabled={!enabled}\n          >\n            <IdeIcon\n              editorType={editorType}\n              className=\"size-icon-xs opacity-50 group-hover:opacity-80 transition-opacity\"\n            />\n          </button>\n        </Tooltip>\n      ),\n    };\n  }\n\n  return {\n    type: 'action',\n    key,\n    label: tooltip,\n    customContent: (\n      <CopyButton\n        onCopy={onExecuteAction}\n        disabled={!enabled}\n        iconSize=\"size-icon-base\"\n        icon={CopyIcon}\n      />\n    ),\n  };\n}\n\nexport interface ContextBarContainerProps {\n  containerRef: RefObject<HTMLElement | null>;\n}\n\nexport function ContextBarContainer({\n  containerRef,\n}: ContextBarContainerProps) {\n  const { executorContext } = useActions();\n  const { config } = useUserSystem();\n  const editorType =\n    (config?.editor?.editor_type as EditorType | undefined) ?? null;\n\n  // Get visibility context (now includes dev server state)\n  const actionCtx = useActionVisibilityContext();\n\n  // Action handler - use executor context directly from provider\n  const handleExecuteAction = useCallback(\n    async (action: ActionDefinition) => {\n      if (action.requiresTarget === ActionTargetType.NONE) {\n        await action.execute(executorContext);\n      }\n    },\n    [executorContext]\n  );\n\n  const { style, isDragging, dragHandlers } =\n    useContextBarPosition(containerRef);\n\n  const toRenderItems = useCallback(\n    (items: ContextBarItem[], prefix: string): ContextBarRenderItem[] => {\n      return items.flatMap((item, index) => {\n        if (isDivider(item)) {\n          return [{ type: 'divider', key: `${prefix}-divider-${index}` }];\n        }\n\n        const action = item;\n        const enabled = isActionEnabled(action, actionCtx);\n        const tooltip = getActionTooltip(action, actionCtx);\n        const shortcut = action.shortcut;\n        const iconClassName = getIconClassName(action, actionCtx, !enabled);\n        const key = `${prefix}-${action.id}-${index}`;\n        const execute = () => {\n          void handleExecuteAction(action);\n        };\n\n        const iconType = action.icon;\n        if (isSpecialIcon(iconType)) {\n          return [\n            buildSpecialItem(\n              iconType,\n              key,\n              tooltip,\n              shortcut,\n              enabled,\n              editorType,\n              execute\n            ),\n          ];\n        }\n\n        const icon = getActionIcon(action, actionCtx);\n        if (isSpecialIcon(icon)) {\n          return [];\n        }\n\n        return [\n          {\n            type: 'action',\n            key,\n            label: tooltip,\n            tooltip,\n            shortcut,\n            icon,\n            iconClassName,\n            disabled: !enabled,\n            onClick: execute,\n          },\n        ];\n      });\n    },\n    [actionCtx, editorType, handleExecuteAction]\n  );\n\n  // Filter visible actions and map to render items\n  const primaryItems = useMemo(() => {\n    const filtered = filterContextBarItems(\n      ContextBarActionGroups.primary,\n      actionCtx\n    );\n    return toRenderItems(filtered, 'primary');\n  }, [actionCtx, toRenderItems]);\n\n  const secondaryItems = useMemo(() => {\n    const filtered = filterContextBarItems(\n      ContextBarActionGroups.secondary,\n      actionCtx\n    );\n    return toRenderItems(filtered, 'secondary');\n  }, [actionCtx, toRenderItems]);\n\n  if (isRealMobileDevice()) return null;\n\n  return (\n    <ContextBar\n      style={style}\n      isDragging={isDragging}\n      onDragHandleMouseDown={dragHandlers.onMouseDown}\n      primaryItems={primaryItems}\n      secondaryItems={secondaryItems}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/ElectricTestPage.tsx",
    "content": "import { useState } from 'react';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { useCurrentUser } from '@/shared/hooks/auth/useCurrentUser';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport type { SyncError } from '@/shared/lib/electric/types';\nimport {\n  PROJECTS_SHAPE,\n  PROJECT_TAGS_SHAPE,\n  ISSUE_COMMENTS_SHAPE,\n  PROJECT_MUTATION,\n  TAG_MUTATION,\n  ISSUE_COMMENT_MUTATION,\n  NOTIFICATIONS_SHAPE,\n  PROJECT_ISSUES_SHAPE,\n  PROJECT_WORKSPACES_SHAPE,\n  PROJECT_PROJECT_STATUSES_SHAPE,\n  PROJECT_ISSUE_ASSIGNEES_SHAPE,\n  PROJECT_ISSUE_FOLLOWERS_SHAPE,\n  PROJECT_ISSUE_TAGS_SHAPE,\n  PROJECT_ISSUE_RELATIONSHIPS_SHAPE,\n  ISSUE_REACTIONS_SHAPE,\n  type Project,\n  type Issue,\n} from 'shared/remote-types';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ntype OrgCollectionType = 'projects' | 'notifications';\ntype ProjectCollectionType =\n  | 'issues'\n  | 'workspaces'\n  | 'statuses'\n  | 'tags'\n  | 'assignees'\n  | 'followers'\n  | 'issueTags'\n  | 'dependencies';\ntype IssueCollectionType = 'comments' | 'reactions';\n\n// ============================================================================\n// Helper Components\n// ============================================================================\n\nfunction CollectionTabs<T extends string>({\n  options,\n  value,\n  onChange,\n}: {\n  options: { value: T; label: string }[];\n  value: T;\n  onChange: (value: T) => void;\n}) {\n  return (\n    <div className=\"flex flex-wrap gap-base mb-base\">\n      {options.map((opt) => (\n        <button\n          key={opt.value}\n          onClick={() => onChange(opt.value)}\n          className={`px-base py-half text-sm rounded-sm ${\n            value === opt.value\n              ? 'bg-brand text-on-brand'\n              : 'bg-secondary text-normal hover:bg-panel'\n          }`}\n        >\n          {opt.label}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nfunction LoadingState({ message }: { message: string }) {\n  return (\n    <div className=\"p-base bg-secondary border rounded-sm text-low\">\n      {message}\n    </div>\n  );\n}\n\nfunction ErrorState({\n  syncError,\n  title,\n  onRetry,\n}: {\n  syncError: SyncError | null;\n  title: string;\n  onRetry?: () => void;\n}) {\n  if (!syncError) return null;\n  return (\n    <div className=\"p-base bg-error/10 border border-error rounded-sm text-error\">\n      <p className=\"font-medium\">\n        {title}\n        {syncError.status ? ` (${syncError.status})` : ''}:\n      </p>\n      <pre className=\"mt-base text-sm overflow-auto\">{syncError.message}</pre>\n      {onRetry && (\n        <button\n          onClick={onRetry}\n          className=\"mt-base px-base py-half bg-error text-on-brand rounded-sm\"\n        >\n          Retry\n        </button>\n      )}\n    </div>\n  );\n}\n\nfunction DataTable<T extends Record<string, unknown>>({\n  data,\n  columns,\n  onRowClick,\n  selectedId,\n  getRowId,\n}: {\n  data: T[];\n  columns: {\n    key: string;\n    label: string;\n    render?: (item: T) => React.ReactNode;\n  }[];\n  onRowClick?: (item: T) => void;\n  selectedId?: string;\n  getRowId: (item: T) => string;\n}) {\n  if (data.length === 0) {\n    return (\n      <div className=\"p-base bg-secondary border rounded-sm text-low\">\n        No data found.\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"overflow-x-auto\">\n      <table className=\"min-w-full border rounded-sm text-sm\">\n        <thead className=\"bg-secondary\">\n          <tr>\n            {columns.map((col) => (\n              <th\n                key={col.key}\n                className=\"px-base py-half text-left font-medium text-normal border-b\"\n              >\n                {col.label}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {data.map((item) => {\n            const rowId = getRowId(item);\n            const isSelected = selectedId === rowId;\n            return (\n              <tr\n                key={rowId}\n                onClick={() => onRowClick?.(item)}\n                className={`${onRowClick ? 'cursor-pointer' : ''} ${\n                  isSelected ? 'bg-brand/10' : 'hover:bg-secondary'\n                }`}\n              >\n                {columns.map((col) => (\n                  <td key={col.key} className=\"px-base py-half border-b\">\n                    {col.render\n                      ? col.render(item)\n                      : String(item[col.key] ?? '')}\n                  </td>\n                ))}\n              </tr>\n            );\n          })}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\nfunction MutationPanel({\n  onCreate,\n  onUpdate,\n  onDelete,\n  selectedId,\n  disabled,\n  children,\n}: {\n  onCreate?: () => void;\n  onUpdate?: () => void;\n  onDelete?: () => void;\n  selectedId?: string | null;\n  disabled?: boolean;\n  children?: React.ReactNode;\n}) {\n  return (\n    <div className=\"mt-base p-base bg-secondary rounded-sm space-y-base\">\n      <h4 className=\"text-sm font-medium text-normal\">\n        Mutations (Optimistic)\n      </h4>\n      {children}\n      <div className=\"flex gap-base flex-wrap\">\n        {onCreate && (\n          <button\n            onClick={onCreate}\n            disabled={disabled}\n            className=\"px-base py-half text-sm bg-success text-white rounded-sm hover:bg-success/80 disabled:bg-panel disabled:text-low disabled:cursor-not-allowed\"\n          >\n            Create\n          </button>\n        )}\n        {onUpdate && (\n          <button\n            onClick={onUpdate}\n            disabled={disabled || !selectedId}\n            className=\"px-base py-half text-sm bg-brand text-on-brand rounded-sm hover:bg-brand-hover disabled:bg-panel disabled:text-low disabled:cursor-not-allowed\"\n          >\n            Update Selected\n          </button>\n        )}\n        {onDelete && (\n          <button\n            onClick={onDelete}\n            disabled={disabled || !selectedId}\n            className=\"px-base py-half text-sm bg-error text-white rounded-sm hover:bg-error/80 disabled:bg-panel disabled:text-low disabled:cursor-not-allowed\"\n          >\n            Delete Selected\n          </button>\n        )}\n      </div>\n      {selectedId && (\n        <p className=\"text-xs text-low\">Selected: {truncateId(selectedId)}</p>\n      )}\n    </div>\n  );\n}\n\n// ============================================================================\n// Collection List Components (using generic hook)\n// ============================================================================\n\nfunction ProjectsList({\n  organizationId,\n  onSelectProject,\n  selectedProjectId,\n}: {\n  organizationId: string;\n  onSelectProject: (project: Project | null) => void;\n  selectedProjectId: string | null;\n}) {\n  const { data, isLoading, error, retry, insert, update, remove } = useShape(\n    PROJECTS_SHAPE,\n    { organization_id: organizationId },\n    { mutation: PROJECT_MUTATION }\n  );\n\n  const [newProjectName, setNewProjectName] = useState('');\n  const [newProjectColor, setNewProjectColor] = useState('#3b82f6');\n\n  const handleCreate = () => {\n    if (!newProjectName.trim()) return;\n    insert({\n      organization_id: organizationId,\n      name: newProjectName.trim(),\n      color: newProjectColor,\n    });\n    setNewProjectName('');\n  };\n\n  const handleUpdate = () => {\n    if (!selectedProjectId || !newProjectName.trim()) return;\n    update(selectedProjectId, {\n      name: newProjectName.trim(),\n      color: newProjectColor,\n    });\n  };\n\n  const handleDelete = () => {\n    if (!selectedProjectId) return;\n    remove(selectedProjectId);\n    onSelectProject(null);\n  };\n\n  const handleRowClick = (project: Project) => {\n    onSelectProject(project);\n    setNewProjectName(project.name);\n    setNewProjectColor(project.color);\n  };\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading projects...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(p) => p.id}\n        selectedId={selectedProjectId ?? undefined}\n        onRowClick={handleRowClick}\n        columns={[\n          {\n            key: 'name',\n            label: 'Name',\n            render: (p) => (\n              <div className=\"flex items-center gap-2\">\n                <span\n                  className=\"w-3 h-3 rounded-full\"\n                  style={{ backgroundColor: p.color }}\n                />\n                <span className=\"font-medium\">{p.name}</span>\n              </div>\n            ),\n          },\n          { key: 'id', label: 'ID', render: (p) => truncateId(p.id) },\n          {\n            key: 'updated_at',\n            label: 'Updated',\n            render: (p) => formatDate(p.updated_at),\n          },\n        ]}\n      />\n\n      <MutationPanel\n        onCreate={handleCreate}\n        onUpdate={handleUpdate}\n        onDelete={handleDelete}\n        selectedId={selectedProjectId}\n        disabled={isLoading}\n      >\n        <div className=\"flex gap-base items-end flex-wrap\">\n          <div>\n            <label className=\"block text-xs text-low mb-half\">Name</label>\n            <input\n              type=\"text\"\n              value={newProjectName}\n              onChange={(e) => setNewProjectName(e.target.value)}\n              placeholder=\"Project name\"\n              className=\"px-base py-half text-sm border rounded-sm bg-primary text-normal focus:outline-none focus:ring-1 focus:ring-brand\"\n            />\n          </div>\n          <div>\n            <label className=\"block text-xs text-low mb-half\">Color</label>\n            <input\n              type=\"color\"\n              value={newProjectColor}\n              onChange={(e) => setNewProjectColor(e.target.value)}\n              className=\"w-10 h-8 border rounded-sm cursor-pointer\"\n            />\n          </div>\n        </div>\n      </MutationPanel>\n    </div>\n  );\n}\n\nfunction NotificationsList({ userId }: { userId: string }) {\n  const { data, isLoading, error, retry } = useShape(NOTIFICATIONS_SHAPE, {\n    user_id: userId,\n  });\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading notifications...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(n) => n.id}\n        columns={[\n          { key: 'notification_type', label: 'Type' },\n          {\n            key: 'seen',\n            label: 'Seen',\n            render: (n) => (n.seen ? 'Yes' : 'No'),\n          },\n          { key: 'id', label: 'ID', render: (n) => truncateId(n.id) },\n          {\n            key: 'created_at',\n            label: 'Created',\n            render: (n) => formatDate(n.created_at),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction IssuesList({\n  projectId,\n  onSelectIssue,\n  selectedIssueId,\n}: {\n  projectId: string;\n  onSelectIssue: (issue: Issue) => void;\n  selectedIssueId: string | null;\n}) {\n  const { data, isLoading, error, retry } = useShape(PROJECT_ISSUES_SHAPE, {\n    project_id: projectId,\n  });\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading issues...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(i) => i.id}\n        selectedId={selectedIssueId ?? undefined}\n        onRowClick={onSelectIssue}\n        columns={[\n          { key: 'title', label: 'Title' },\n          { key: 'priority', label: 'Priority' },\n          { key: 'id', label: 'ID', render: (i) => truncateId(i.id) },\n          {\n            key: 'updated_at',\n            label: 'Updated',\n            render: (i) => formatDate(i.updated_at),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction WorkspacesList({ projectId }: { projectId: string }) {\n  const { data, isLoading, error, retry } = useShape(PROJECT_WORKSPACES_SHAPE, {\n    project_id: projectId,\n  });\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading workspaces...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(w) => w.id}\n        columns={[\n          { key: 'id', label: 'ID', render: (w) => truncateId(w.id) },\n          {\n            key: 'archived',\n            label: 'Archived',\n            render: (w) => (w.archived ? 'Yes' : 'No'),\n          },\n          { key: 'files_changed', label: 'Files Changed' },\n          {\n            key: 'created_at',\n            label: 'Created',\n            render: (w) => formatDate(w.created_at),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction StatusesList({ projectId }: { projectId: string }) {\n  const { data, isLoading, error, retry } = useShape(\n    PROJECT_PROJECT_STATUSES_SHAPE,\n    { project_id: projectId }\n  );\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading statuses...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(s) => s.id}\n        columns={[\n          {\n            key: 'name',\n            label: 'Name',\n            render: (s) => (\n              <div className=\"flex items-center gap-2\">\n                <span\n                  className=\"w-3 h-3 rounded-full\"\n                  style={{ backgroundColor: s.color }}\n                />\n                <span>{s.name}</span>\n              </div>\n            ),\n          },\n          { key: 'sort_order', label: 'Order' },\n          { key: 'id', label: 'ID', render: (s) => truncateId(s.id) },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction TagsList({ projectId }: { projectId: string }) {\n  const { data, isLoading, error, retry, insert, update, remove } = useShape(\n    PROJECT_TAGS_SHAPE,\n    { project_id: projectId },\n    { mutation: TAG_MUTATION }\n  );\n\n  const [selectedTagId, setSelectedTagId] = useState<string | null>(null);\n  const [newTagName, setNewTagName] = useState('');\n  const [newTagColor, setNewTagColor] = useState('#3b82f6');\n\n  const handleCreate = () => {\n    if (!newTagName.trim()) return;\n    insert({\n      project_id: projectId,\n      name: newTagName.trim(),\n      color: newTagColor,\n    });\n    setNewTagName('');\n  };\n\n  const handleUpdate = () => {\n    if (!selectedTagId || !newTagName.trim()) return;\n    update(selectedTagId, { name: newTagName.trim() });\n  };\n\n  const handleDelete = () => {\n    if (!selectedTagId) return;\n    remove(selectedTagId);\n    setSelectedTagId(null);\n  };\n\n  const handleRowClick = (tag: { id: string; name: string; color: string }) => {\n    setSelectedTagId(tag.id);\n    setNewTagName(tag.name);\n    setNewTagColor(tag.color);\n  };\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading tags...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(t) => t.id}\n        selectedId={selectedTagId ?? undefined}\n        onRowClick={handleRowClick}\n        columns={[\n          {\n            key: 'name',\n            label: 'Name',\n            render: (t) => (\n              <div className=\"flex items-center gap-2\">\n                <span\n                  className=\"w-3 h-3 rounded-full\"\n                  style={{ backgroundColor: t.color }}\n                />\n                <span>{t.name}</span>\n              </div>\n            ),\n          },\n          { key: 'id', label: 'ID', render: (t) => truncateId(t.id) },\n        ]}\n      />\n\n      <MutationPanel\n        onCreate={handleCreate}\n        onUpdate={handleUpdate}\n        onDelete={handleDelete}\n        selectedId={selectedTagId}\n        disabled={isLoading}\n      >\n        <div className=\"flex gap-base items-end flex-wrap\">\n          <div>\n            <label className=\"block text-xs text-low mb-half\">Name</label>\n            <input\n              type=\"text\"\n              value={newTagName}\n              onChange={(e) => setNewTagName(e.target.value)}\n              placeholder=\"Tag name\"\n              className=\"px-base py-half text-sm border rounded-sm bg-primary text-normal focus:outline-none focus:ring-1 focus:ring-brand\"\n            />\n          </div>\n          <div>\n            <label className=\"block text-xs text-low mb-half\">Color</label>\n            <input\n              type=\"color\"\n              value={newTagColor}\n              onChange={(e) => setNewTagColor(e.target.value)}\n              className=\"w-10 h-8 border rounded-sm cursor-pointer\"\n            />\n          </div>\n        </div>\n      </MutationPanel>\n    </div>\n  );\n}\n\nfunction AssigneesList({ projectId }: { projectId: string }) {\n  const { data, isLoading, error, retry } = useShape(\n    PROJECT_ISSUE_ASSIGNEES_SHAPE,\n    { project_id: projectId }\n  );\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading assignees...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(a) => `${a.issue_id}-${a.user_id}`}\n        columns={[\n          {\n            key: 'issue_id',\n            label: 'Issue ID',\n            render: (a) => truncateId(a.issue_id),\n          },\n          {\n            key: 'user_id',\n            label: 'User ID',\n            render: (a) => truncateId(a.user_id),\n          },\n          {\n            key: 'assigned_at',\n            label: 'Assigned',\n            render: (a) => formatDate(a.assigned_at),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction FollowersList({ projectId }: { projectId: string }) {\n  const { data, isLoading, error, retry } = useShape(\n    PROJECT_ISSUE_FOLLOWERS_SHAPE,\n    { project_id: projectId }\n  );\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading followers...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(f) => `${f.issue_id}-${f.user_id}`}\n        columns={[\n          {\n            key: 'issue_id',\n            label: 'Issue ID',\n            render: (f) => truncateId(f.issue_id),\n          },\n          {\n            key: 'user_id',\n            label: 'User ID',\n            render: (f) => truncateId(f.user_id),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction IssueTagsList({ projectId }: { projectId: string }) {\n  const { data, isLoading, error, retry } = useShape(PROJECT_ISSUE_TAGS_SHAPE, {\n    project_id: projectId,\n  });\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading issue tags...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(t) => `${t.issue_id}-${t.tag_id}`}\n        columns={[\n          {\n            key: 'issue_id',\n            label: 'Issue ID',\n            render: (t) => truncateId(t.issue_id),\n          },\n          {\n            key: 'tag_id',\n            label: 'Tag ID',\n            render: (t) => truncateId(t.tag_id),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction DependenciesList({ projectId }: { projectId: string }) {\n  const { data, isLoading, error, retry } = useShape(\n    PROJECT_ISSUE_RELATIONSHIPS_SHAPE,\n    { project_id: projectId }\n  );\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading dependencies...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(d) => `${d.issue_id}-${d.related_issue_id}`}\n        columns={[\n          {\n            key: 'issue_id',\n            label: 'Issue',\n            render: (d) => truncateId(d.issue_id),\n          },\n          {\n            key: 'related_issue_id',\n            label: 'Related Issue',\n            render: (d) => truncateId(d.related_issue_id),\n          },\n          {\n            key: 'created_at',\n            label: 'Created',\n            render: (d) => formatDate(d.created_at),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n\nfunction CommentsList({ issueId }: { issueId: string }) {\n  const { data, isLoading, error, retry, insert, update, remove } = useShape(\n    ISSUE_COMMENTS_SHAPE,\n    { issue_id: issueId },\n    { mutation: ISSUE_COMMENT_MUTATION }\n  );\n\n  const [selectedCommentId, setSelectedCommentId] = useState<string | null>(\n    null\n  );\n  const [newMessage, setNewMessage] = useState('');\n\n  const handleCreate = () => {\n    if (!newMessage.trim()) return;\n    insert({ issue_id: issueId, message: newMessage.trim(), parent_id: null });\n    setNewMessage('');\n  };\n\n  const handleUpdate = () => {\n    if (!selectedCommentId || !newMessage.trim()) return;\n    update(selectedCommentId, { message: newMessage.trim() });\n  };\n\n  const handleDelete = () => {\n    if (!selectedCommentId) return;\n    remove(selectedCommentId);\n    setSelectedCommentId(null);\n    setNewMessage('');\n  };\n\n  const handleRowClick = (comment: { id: string; message: string }) => {\n    setSelectedCommentId(comment.id);\n    setNewMessage(comment.message);\n  };\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading comments...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(c) => c.id}\n        selectedId={selectedCommentId ?? undefined}\n        onRowClick={handleRowClick}\n        columns={[\n          {\n            key: 'message',\n            label: 'Message',\n            render: (c) =>\n              c.message.length > 50\n                ? c.message.slice(0, 50) + '...'\n                : c.message,\n          },\n          {\n            key: 'author_id',\n            label: 'Author',\n            render: (c) => (c.author_id ? truncateId(c.author_id) : '-'),\n          },\n          { key: 'id', label: 'ID', render: (c) => truncateId(c.id) },\n          {\n            key: 'created_at',\n            label: 'Created',\n            render: (c) => formatDate(c.created_at),\n          },\n        ]}\n      />\n\n      <MutationPanel\n        onCreate={handleCreate}\n        onUpdate={handleUpdate}\n        onDelete={handleDelete}\n        selectedId={selectedCommentId}\n        disabled={isLoading}\n      >\n        <div>\n          <label className=\"block text-xs text-low mb-half\">Message</label>\n          <textarea\n            value={newMessage}\n            onChange={(e) => setNewMessage(e.target.value)}\n            placeholder=\"Enter comment message...\"\n            rows={2}\n            className=\"w-full px-base py-half text-sm border rounded-sm bg-primary text-normal focus:outline-none focus:ring-1 focus:ring-brand resize-none\"\n          />\n        </div>\n      </MutationPanel>\n    </div>\n  );\n}\n\nfunction ReactionsList({ issueId }: { issueId: string }) {\n  const { data, isLoading, error, retry } = useShape(ISSUE_REACTIONS_SHAPE, {\n    issue_id: issueId,\n  });\n\n  if (error)\n    return <ErrorState syncError={error} title=\"Sync Error\" onRetry={retry} />;\n  if (isLoading) return <LoadingState message=\"Loading reactions...\" />;\n\n  return (\n    <div>\n      <p className=\"text-sm text-low mb-base\">{data.length} synced</p>\n      <DataTable\n        data={data}\n        getRowId={(r) => r.id}\n        columns={[\n          { key: 'emoji', label: 'Emoji' },\n          {\n            key: 'comment_id',\n            label: 'Comment',\n            render: (r) => truncateId(r.comment_id),\n          },\n          {\n            key: 'user_id',\n            label: 'User',\n            render: (r) => truncateId(r.user_id),\n          },\n          { key: 'id', label: 'ID', render: (r) => truncateId(r.id) },\n        ]}\n      />\n    </div>\n  );\n}\n\n// ============================================================================\n// Utility functions\n// ============================================================================\n\nfunction truncateId(id: string): string {\n  return id.length > 8 ? id.slice(0, 8) + '...' : id;\n}\n\nfunction formatDate(dateStr: string): string {\n  return new Date(dateStr).toLocaleString();\n}\n\n// ============================================================================\n// Main Component\n// ============================================================================\n\nexport function ElectricTestPage() {\n  const { isSignedIn, isLoaded } = useAuth();\n  const { data: orgsData } = useUserOrganizations();\n  const { data: currentUser } = useCurrentUser();\n\n  const [selectedOrgId, setSelectedOrgId] = useState<string>('');\n  const [selectedProjectId, setSelectedProjectId] = useState<string | null>(\n    null\n  );\n  const [selectedProject, setSelectedProject] = useState<Project | null>(null);\n  const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);\n  const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);\n  const [isConnected, setIsConnected] = useState(false);\n\n  const [activeOrgCollection, setActiveOrgCollection] =\n    useState<OrgCollectionType>('projects');\n  const [activeProjectCollection, setActiveProjectCollection] =\n    useState<ProjectCollectionType>('issues');\n  const [activeIssueCollection, setActiveIssueCollection] =\n    useState<IssueCollectionType>('comments');\n\n  const organizations = orgsData?.organizations ?? [];\n  const userId = currentUser?.user_id;\n\n  const handleDisconnect = () => {\n    setIsConnected(false);\n    setSelectedProjectId(null);\n    setSelectedProject(null);\n    setSelectedIssueId(null);\n    setSelectedIssue(null);\n  };\n\n  const handleSelectProject = (project: Project | null) => {\n    setSelectedProjectId(project?.id ?? null);\n    setSelectedProject(project);\n    setSelectedIssueId(null);\n    setSelectedIssue(null);\n  };\n\n  const handleSelectIssue = (issue: Issue) => {\n    setSelectedIssueId(issue.id);\n    setSelectedIssue(issue);\n  };\n\n  if (!isLoaded) {\n    return (\n      <div className=\"p-double\">\n        <p className=\"text-low\">Loading...</p>\n      </div>\n    );\n  }\n\n  if (!isSignedIn) {\n    return (\n      <div className=\"p-double\">\n        <h2 className=\"text-xl font-medium text-high mb-base\">\n          Electric SDK Test\n        </h2>\n        <p className=\"text-low\">Please sign in to test Electric sync.</p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"min-h-screen overflow-auto p-double space-y-double max-w-6xl bg-background\">\n      <h2 className=\"text-xl font-medium text-high\">Electric SDK Test</h2>\n\n      {/* Configuration */}\n      <div className=\"bg-primary border rounded-sm p-base space-y-base\">\n        <h3 className=\"text-lg font-medium text-normal\">Configuration</h3>\n\n        <div className=\"grid grid-cols-2 gap-base\">\n          <div>\n            <label className=\"block text-sm font-medium text-normal mb-half\">\n              Organization\n            </label>\n            <select\n              value={selectedOrgId}\n              onChange={(e) => {\n                setSelectedOrgId(e.target.value);\n                setSelectedProjectId(null);\n                setSelectedProject(null);\n                setSelectedIssueId(null);\n                setSelectedIssue(null);\n              }}\n              disabled={isConnected}\n              className=\"w-full px-base py-half border rounded-sm bg-primary text-normal focus:outline-none focus:ring-2 focus:ring-brand focus:border-brand disabled:bg-secondary disabled:text-low\"\n            >\n              <option value=\"\">Select an organization...</option>\n              {organizations.map((org) => (\n                <option key={org.id} value={org.id}>\n                  {org.name}\n                </option>\n              ))}\n            </select>\n          </div>\n\n          <div className=\"flex items-end gap-base\">\n            {!isConnected ? (\n              <button\n                onClick={() => setIsConnected(true)}\n                disabled={!selectedOrgId}\n                className=\"px-base py-half bg-brand text-on-brand rounded-sm hover:bg-brand-hover focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 disabled:bg-panel disabled:text-low disabled:cursor-not-allowed\"\n              >\n                Connect\n              </button>\n            ) : (\n              <button\n                onClick={handleDisconnect}\n                className=\"px-base py-half bg-error text-on-brand rounded-sm focus:outline-none focus:ring-2 focus:ring-error focus:ring-offset-2\"\n              >\n                Disconnect\n              </button>\n            )}\n            <span\n              className={`text-sm ${isConnected ? 'text-success' : 'text-low'}`}\n            >\n              {isConnected ? 'Connected' : 'Not connected'}\n            </span>\n          </div>\n        </div>\n\n        {selectedOrgId && (\n          <div className=\"text-xs text-low font-ibm-plex-mono\">\n            Organization ID: {selectedOrgId}\n            {userId && <span className=\"ml-base\">User ID: {userId}</span>}\n          </div>\n        )}\n      </div>\n\n      {/* Organization-scoped collections */}\n      {isConnected && selectedOrgId && (\n        <div className=\"bg-primary border rounded-sm p-base\">\n          <h3 className=\"text-lg font-medium text-normal mb-base\">\n            Organization Collections\n          </h3>\n\n          <CollectionTabs\n            value={activeOrgCollection}\n            onChange={setActiveOrgCollection}\n            options={[\n              { value: 'projects', label: 'Projects' },\n              { value: 'notifications', label: 'Notifications' },\n            ]}\n          />\n\n          {activeOrgCollection === 'projects' && (\n            <ProjectsList\n              organizationId={selectedOrgId}\n              onSelectProject={handleSelectProject}\n              selectedProjectId={selectedProjectId}\n            />\n          )}\n          {activeOrgCollection === 'notifications' && userId && (\n            <NotificationsList userId={userId} />\n          )}\n          {activeOrgCollection === 'notifications' && !userId && (\n            <LoadingState message=\"Loading user info...\" />\n          )}\n\n          {selectedProject && (\n            <p className=\"mt-base text-sm text-brand\">\n              Selected project: <strong>{selectedProject.name}</strong> (click a\n              row to select)\n            </p>\n          )}\n        </div>\n      )}\n\n      {/* Project-scoped collections */}\n      {isConnected && selectedProjectId && (\n        <div className=\"bg-primary border rounded-sm p-base\">\n          <h3 className=\"text-lg font-medium text-normal mb-base\">\n            Project Collections\n            <span className=\"text-sm font-normal text-low ml-base\">\n              ({selectedProject?.name})\n            </span>\n          </h3>\n\n          <CollectionTabs\n            value={activeProjectCollection}\n            onChange={setActiveProjectCollection}\n            options={[\n              { value: 'issues', label: 'Issues' },\n              { value: 'workspaces', label: 'Workspaces' },\n              { value: 'statuses', label: 'Statuses' },\n              { value: 'tags', label: 'Tags' },\n              { value: 'assignees', label: 'Assignees' },\n              { value: 'followers', label: 'Followers' },\n              { value: 'issueTags', label: 'Issue Tags' },\n              { value: 'dependencies', label: 'Dependencies' },\n            ]}\n          />\n\n          {activeProjectCollection === 'issues' && (\n            <IssuesList\n              projectId={selectedProjectId}\n              onSelectIssue={handleSelectIssue}\n              selectedIssueId={selectedIssueId}\n            />\n          )}\n          {activeProjectCollection === 'workspaces' && (\n            <WorkspacesList projectId={selectedProjectId} />\n          )}\n          {activeProjectCollection === 'statuses' && (\n            <StatusesList projectId={selectedProjectId} />\n          )}\n          {activeProjectCollection === 'tags' && (\n            <TagsList projectId={selectedProjectId} />\n          )}\n          {activeProjectCollection === 'assignees' && (\n            <AssigneesList projectId={selectedProjectId} />\n          )}\n          {activeProjectCollection === 'followers' && (\n            <FollowersList projectId={selectedProjectId} />\n          )}\n          {activeProjectCollection === 'issueTags' && (\n            <IssueTagsList projectId={selectedProjectId} />\n          )}\n          {activeProjectCollection === 'dependencies' && (\n            <DependenciesList projectId={selectedProjectId} />\n          )}\n\n          {selectedIssue && (\n            <p className=\"mt-base text-sm text-brand\">\n              Selected issue: <strong>{selectedIssue.title}</strong>\n            </p>\n          )}\n        </div>\n      )}\n\n      {/* Issue-scoped collections */}\n      {isConnected && selectedIssueId && (\n        <div className=\"bg-primary border rounded-sm p-base\">\n          <h3 className=\"text-lg font-medium text-normal mb-base\">\n            Issue Collections\n            <span className=\"text-sm font-normal text-low ml-base\">\n              ({selectedIssue?.title})\n            </span>\n          </h3>\n\n          <CollectionTabs\n            value={activeIssueCollection}\n            onChange={setActiveIssueCollection}\n            options={[\n              { value: 'comments', label: 'Comments' },\n              { value: 'reactions', label: 'Reactions' },\n            ]}\n          />\n\n          {activeIssueCollection === 'comments' && (\n            <CommentsList issueId={selectedIssueId} />\n          )}\n          {activeIssueCollection === 'reactions' && (\n            <ReactionsList issueId={selectedIssueId} />\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/FileTreeContainer.tsx",
    "content": "import { useState, useMemo, useCallback, useEffect, useRef } from 'react';\nimport { FileTree } from '@vibe/ui/components/FileTree';\nimport {\n  buildFileTree,\n  filterFileTree,\n  getExpandedPathsForSearch,\n  getAllFolderPaths,\n  sortDiffs,\n} from '@/shared/lib/fileTreeUtils';\nimport { usePersistedCollapsedPaths } from '@/shared/stores/useUiPreferencesStore';\nimport { useWorkspaceDiffContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useChangesView } from '@/shared/hooks/useChangesView';\nimport { getFileIcon } from '@/shared/lib/fileTypeIcon';\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport { getActualTheme } from '@/shared/lib/theme';\nimport type { Diff } from 'shared/types';\n\ninterface FileTreeContainerProps {\n  workspaceId: string;\n  diffs: Diff[];\n  onSelectFile: (path: string, diff: Diff) => void;\n  className: string;\n}\n\nexport function FileTreeContainer({\n  workspaceId,\n  diffs,\n  onSelectFile,\n  className,\n}: FileTreeContainerProps) {\n  const { theme } = useTheme();\n  const actualTheme = getActualTheme(theme);\n\n  const { fileInView } = useChangesView();\n  const [searchQuery, setSearchQuery] = useState('');\n  const [collapsedPaths, setCollapsedPaths] =\n    usePersistedCollapsedPaths(workspaceId);\n  const [selectedPath, setSelectedPath] = useState<string | null>(null);\n  const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());\n\n  const {\n    showGitHubComments,\n    setShowGitHubComments,\n    getGitHubCommentCountForFile,\n    getFilesWithGitHubComments,\n    getFirstCommentLineForFile,\n    isGitHubCommentsLoading,\n  } = useWorkspaceDiffContext();\n\n  const { selectFile, scrollToFile } = useChangesView();\n\n  // Sync selectedPath with fileInView from context and scroll into view\n  useEffect(() => {\n    if (fileInView !== undefined) {\n      setSelectedPath(fileInView);\n      // Scroll the selected node into view if needed\n      if (fileInView) {\n        const el = nodeRefs.current.get(fileInView);\n        if (el) {\n          el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n        }\n      }\n    }\n  }, [fileInView]);\n\n  const handleNodeRef = useCallback(\n    (path: string, el: HTMLDivElement | null) => {\n      if (el) {\n        nodeRefs.current.set(path, el);\n      } else {\n        nodeRefs.current.delete(path);\n      }\n    },\n    []\n  );\n\n  // Build tree from diffs\n  const fullTree = useMemo(() => buildFileTree(diffs), [diffs]);\n\n  // Get all folder paths for expand all functionality\n  const allFolderPaths = useMemo(() => getAllFolderPaths(fullTree), [fullTree]);\n\n  // All folders are expanded when none are in the collapsed set\n  const isAllExpanded = collapsedPaths.size === 0;\n\n  // Filter tree based on search\n  const filteredTree = useMemo(\n    () => filterFileTree(fullTree, searchQuery),\n    [fullTree, searchQuery]\n  );\n\n  // Auto-expand folders when searching (remove from collapsed set)\n  const collapsedPathsRef = useRef(collapsedPaths);\n  collapsedPathsRef.current = collapsedPaths;\n\n  useEffect(() => {\n    if (searchQuery) {\n      const pathsToExpand = getExpandedPathsForSearch(fullTree, searchQuery);\n      const next = new Set(collapsedPathsRef.current);\n      pathsToExpand.forEach((p) => next.delete(p));\n      setCollapsedPaths(next);\n    }\n  }, [searchQuery, fullTree, setCollapsedPaths]);\n\n  const handleToggleExpand = useCallback(\n    (path: string) => {\n      const next = new Set(collapsedPaths);\n      if (next.has(path)) {\n        next.delete(path); // was collapsed, now expand\n      } else {\n        next.add(path); // was expanded, now collapse\n      }\n      setCollapsedPaths(next);\n    },\n    [collapsedPaths, setCollapsedPaths]\n  );\n\n  const handleToggleExpandAll = useCallback(() => {\n    if (isAllExpanded) {\n      setCollapsedPaths(new Set(allFolderPaths)); // collapse all\n    } else {\n      setCollapsedPaths(new Set()); // expand all\n    }\n  }, [isAllExpanded, allFolderPaths, setCollapsedPaths]);\n\n  const handleSelectFile = useCallback(\n    (path: string) => {\n      setSelectedPath(path);\n      const diff = diffs.find((d) => d.newPath === path || d.oldPath === path);\n      if (diff) {\n        scrollToFile(path);\n        onSelectFile(path, diff);\n      }\n    },\n    [diffs, onSelectFile, scrollToFile]\n  );\n\n  // Get list of diff paths that have GitHub comments, sorted to match visual order\n  const filesWithComments = useMemo(() => {\n    const ghFiles = getFilesWithGitHubComments();\n    // Sort diffs first to match visual order, then filter to those with comments\n    return sortDiffs(diffs)\n      .map((d) => d.newPath || d.oldPath || '')\n      .filter((diffPath) =>\n        ghFiles.some(\n          (ghPath) => diffPath === ghPath || diffPath.endsWith('/' + ghPath)\n        )\n      );\n  }, [getFilesWithGitHubComments, diffs]);\n\n  // Navigate between files with GitHub comments\n  const handleNavigateComments = useCallback(\n    (direction: 'prev' | 'next') => {\n      if (filesWithComments.length === 0) return;\n\n      const currentIndex = selectedPath\n        ? filesWithComments.indexOf(selectedPath)\n        : -1;\n      let nextIndex: number;\n\n      if (direction === 'next') {\n        nextIndex =\n          currentIndex < filesWithComments.length - 1 ? currentIndex + 1 : 0;\n      } else {\n        nextIndex =\n          currentIndex > 0 ? currentIndex - 1 : filesWithComments.length - 1;\n      }\n\n      const targetPath = filesWithComments[nextIndex];\n      const lineNumber = getFirstCommentLineForFile(targetPath);\n\n      // Update local state\n      setSelectedPath(targetPath);\n\n      // Select file with line number to scroll to the comment\n      selectFile(targetPath, lineNumber ?? undefined);\n    },\n    [filesWithComments, selectedPath, getFirstCommentLineForFile, selectFile]\n  );\n\n  const renderFileIcon = useCallback(\n    (fileName: string) => {\n      const FileIcon = getFileIcon(fileName, actualTheme);\n      return FileIcon ? <FileIcon size={14} /> : null;\n    },\n    [actualTheme]\n  );\n\n  return (\n    <FileTree\n      nodes={filteredTree}\n      collapsedPaths={collapsedPaths}\n      onToggleExpand={handleToggleExpand}\n      selectedPath={selectedPath}\n      onSelectFile={handleSelectFile}\n      onNodeRef={handleNodeRef}\n      searchQuery={searchQuery}\n      onSearchChange={setSearchQuery}\n      renderFileIcon={renderFileIcon}\n      isAllExpanded={isAllExpanded}\n      onToggleExpandAll={handleToggleExpandAll}\n      className={className}\n      showGitHubComments={showGitHubComments}\n      onToggleGitHubComments={setShowGitHubComments}\n      getGitHubCommentCountForFile={getGitHubCommentCountForFile}\n      isGitHubCommentsLoading={isGitHubCommentsLoading}\n      onNavigateComments={handleNavigateComments}\n      hasFilesWithComments={filesWithComments.length > 0}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/GitHubCommentRenderer.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  GithubLogoIcon,\n  ArrowSquareOutIcon,\n  ChatsCircleIcon,\n} from '@phosphor-icons/react';\nimport { CommentCard } from '@vibe/ui/components/CommentCard';\nimport { formatRelativeTime } from '@/shared/lib/date';\nimport type { NormalizedGitHubComment } from '@/shared/hooks/useWorkspaceContext';\n\ninterface GitHubCommentRendererProps {\n  comment: NormalizedGitHubComment;\n  onCopyToUserComment: () => void;\n}\n\nexport const GitHubCommentRenderer = memo(function GitHubCommentRenderer({\n  comment,\n  onCopyToUserComment,\n}: GitHubCommentRendererProps) {\n  const { t } = useTranslation('common');\n\n  const header = (\n    <div className=\"flex items-center gap-half text-sm\">\n      <GithubLogoIcon className=\"size-icon-sm text-low\" weight=\"fill\" />\n      <span className=\"font-medium text-normal\">@{comment.author}</span>\n      <span className=\"text-low\">{formatRelativeTime(comment.createdAt)}</span>\n      <div className=\"flex items-center gap-half ml-auto\">\n        <button\n          className=\"text-low hover:text-normal\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onCopyToUserComment();\n          }}\n          title={t('comments.copyToReview')}\n        >\n          <ChatsCircleIcon className=\"size-icon-xs\" />\n        </button>\n        {comment.url && (\n          <a\n            href={comment.url}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-low hover:text-normal\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            <ArrowSquareOutIcon className=\"size-icon-xs\" />\n          </a>\n        )}\n      </div>\n    </div>\n  );\n\n  return (\n    <CommentCard variant=\"github\" header={header}>\n      <div className=\"text-sm text-normal whitespace-pre-wrap break-words\">\n        {comment.body}\n      </div>\n    </CommentCard>\n  );\n});\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/GitPanelContainer.tsx",
    "content": "import { useState, useCallback, useMemo, useEffect, useRef } from 'react';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { usePush } from '@/shared/hooks/usePush';\nimport { useRenameBranch } from '@/shared/hooks/useRenameBranch';\nimport { useBranchStatus } from '@/shared/hooks/useBranchStatus';\nimport { useUiPreferencesStore } from '@/shared/stores/useUiPreferencesStore';\nimport { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog';\nimport { ForcePushDialog } from '@/shared/dialogs/command-bar/ForcePushDialog';\nimport { CommandBarDialog } from '@/shared/dialogs/command-bar/CommandBarDialog';\nimport { GitPanel, type RepoInfo } from '@vibe/ui/components/GitPanel';\nimport { Actions } from '@/shared/actions';\nimport type { RepoAction } from '@vibe/ui/components/RepoCard';\nimport type { Workspace, RepoWithTargetBranch, Merge } from 'shared/types';\n\nexport interface GitPanelContainerProps {\n  selectedWorkspace: Workspace | undefined;\n  repos: RepoWithTargetBranch[];\n}\n\ntype PushState = 'idle' | 'pending' | 'success' | 'error';\n\nexport function GitPanelContainer({\n  selectedWorkspace,\n  repos,\n}: GitPanelContainerProps) {\n  const { executeAction } = useActions();\n  const { activeWorkspaces, archivedWorkspaces } = useWorkspaceContext();\n  const repoActions = useUiPreferencesStore((s) => s.repoActions);\n  const setRepoAction = useUiPreferencesStore((s) => s.setRepoAction);\n\n  // Hooks for branch management (moved from WorkspacesLayout)\n  const renameBranch = useRenameBranch(selectedWorkspace?.id);\n  const { data: branchStatus } = useBranchStatus(selectedWorkspace?.id);\n\n  // Get PR info from workspace summary (available immediately, no git calls needed)\n  const summaryPr = useMemo(() => {\n    if (!selectedWorkspace?.id) return undefined;\n    const ws =\n      activeWorkspaces.find((w) => w.id === selectedWorkspace.id) ??\n      archivedWorkspaces.find((w) => w.id === selectedWorkspace.id);\n    if (!ws?.prStatus || !ws.prNumber) return undefined;\n    return {\n      prNumber: ws.prNumber,\n      prUrl: ws.prUrl,\n      prStatus: ws.prStatus,\n    };\n  }, [selectedWorkspace?.id, activeWorkspaces, archivedWorkspaces]);\n\n  const handleBranchNameChange = useCallback(\n    (newName: string) => {\n      renameBranch.mutate(newName);\n    },\n    [renameBranch]\n  );\n\n  // Transform repos to RepoInfo format (moved from WorkspacesLayout)\n  // Uses workspace summary PR data as a fast fallback before branchStatus loads\n  const repoInfos: RepoInfo[] = useMemo(\n    () =>\n      repos.map((repo) => {\n        const repoStatus = branchStatus?.find((s) => s.repo_id === repo.id);\n\n        let prNumber: number | undefined;\n        let prUrl: string | undefined;\n        let prStatus: 'open' | 'merged' | 'closed' | 'unknown' | undefined;\n\n        if (repoStatus?.merges) {\n          const openPR = repoStatus.merges.find(\n            (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open'\n          );\n          const mergedPR = repoStatus.merges.find(\n            (m: Merge) => m.type === 'pr' && m.pr_info.status === 'merged'\n          );\n\n          const relevantPR = openPR || mergedPR;\n          if (relevantPR && relevantPR.type === 'pr') {\n            prNumber = Number(relevantPR.pr_info.number);\n            prUrl = relevantPR.pr_info.url;\n            prStatus = relevantPR.pr_info.status;\n          }\n        } else if (summaryPr) {\n          // Use workspace summary PR data as a fast fallback while branchStatus loads.\n          // The summary is fetched from the DB (no git calls) and is already cached.\n          prNumber = summaryPr.prNumber;\n          prUrl = summaryPr.prUrl;\n          prStatus = summaryPr.prStatus;\n        }\n\n        return {\n          id: repo.id,\n          name: repo.display_name || repo.name,\n          targetBranch: repo.target_branch || 'main',\n          commitsAhead: repoStatus?.commits_ahead ?? 0,\n          commitsBehind: repoStatus?.commits_behind ?? 0,\n          remoteCommitsAhead: repoStatus?.remote_commits_ahead ?? 0,\n          prNumber,\n          prUrl,\n          prStatus,\n          isTargetRemote: repoStatus?.is_target_remote ?? false,\n        };\n      }),\n    [repos, branchStatus, summaryPr]\n  );\n\n  // Track push state per repo: idle, pending, success, or error\n  const [pushStates, setPushStates] = useState<Record<string, PushState>>({});\n  const pushStatesRef = useRef<Record<string, PushState>>({});\n  pushStatesRef.current = pushStates;\n  const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const currentPushRepoRef = useRef<string | null>(null);\n\n  // Reset push-related state when the selected workspace changes to avoid\n  // leaking push state across workspaces with repos that share the same ID.\n  useEffect(() => {\n    setPushStates({});\n    pushStatesRef.current = {};\n    currentPushRepoRef.current = null;\n\n    if (successTimeoutRef.current) {\n      clearTimeout(successTimeoutRef.current);\n      successTimeoutRef.current = null;\n    }\n  }, [selectedWorkspace?.id]);\n  // Use push hook for direct API access with proper error handling\n  const pushMutation = usePush(\n    selectedWorkspace?.id,\n    // onSuccess\n    () => {\n      const repoId = currentPushRepoRef.current;\n      if (!repoId) return;\n      setPushStates((prev) => ({ ...prev, [repoId]: 'success' }));\n      // Clear success state after 2 seconds\n      successTimeoutRef.current = setTimeout(() => {\n        setPushStates((prev) => ({ ...prev, [repoId]: 'idle' }));\n      }, 2000);\n    },\n    // onError\n    async (err, errorData) => {\n      const repoId = currentPushRepoRef.current;\n      if (!repoId) return;\n\n      // Handle force push required - show confirmation dialog\n      if (errorData?.type === 'force_push_required' && selectedWorkspace?.id) {\n        setPushStates((prev) => ({ ...prev, [repoId]: 'idle' }));\n        await ForcePushDialog.show({\n          workspaceId: selectedWorkspace.id,\n          repoId,\n        });\n        return;\n      }\n\n      // Show error state and dialog for other errors\n      setPushStates((prev) => ({ ...prev, [repoId]: 'error' }));\n      const message =\n        err instanceof Error ? err.message : 'Failed to push changes';\n      ConfirmDialog.show({\n        title: 'Error',\n        message,\n        confirmText: 'OK',\n        showCancelButton: false,\n        variant: 'destructive',\n      });\n      // Clear error state after 3 seconds\n      successTimeoutRef.current = setTimeout(() => {\n        setPushStates((prev) => ({ ...prev, [repoId]: 'idle' }));\n      }, 3000);\n    }\n  );\n\n  // Clean up timeout on unmount\n  useEffect(() => {\n    return () => {\n      if (successTimeoutRef.current) {\n        clearTimeout(successTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  // Compute repoInfos with push button state\n  const repoInfosWithPushButton = useMemo(\n    () =>\n      repoInfos.map((repo) => {\n        const state = pushStates[repo.id] ?? 'idle';\n        const hasUnpushedCommits =\n          repo.prStatus === 'open' && (repo.remoteCommitsAhead ?? 0) > 0;\n        // Show push button if there are unpushed commits OR if we're in a push flow\n        // (pending/success/error states keep the button visible for feedback)\n        const isInPushFlow = state !== 'idle';\n        return {\n          ...repo,\n          showPushButton: hasUnpushedCommits && !isInPushFlow,\n          isPushPending: state === 'pending',\n          isPushSuccess: state === 'success',\n          isPushError: state === 'error',\n        };\n      }),\n    [repoInfos, pushStates]\n  );\n\n  // Handle opening command bar for repo actions\n  const handleMoreClick = useCallback(\n    (repoId: string) => {\n      CommandBarDialog.show({\n        page: 'repoActions',\n        workspaceId: selectedWorkspace?.id,\n        repoId,\n      });\n    },\n    [selectedWorkspace?.id]\n  );\n\n  // Handle GitPanel actions using the action system\n  const handleActionsClick = useCallback(\n    async (repoId: string, action: RepoAction) => {\n      if (!selectedWorkspace?.id) return;\n\n      // Map RepoAction to Action definitions\n      const actionMap = {\n        'pull-request': Actions.GitCreatePR,\n        'link-pr': Actions.GitLinkPR,\n        merge: Actions.GitMerge,\n        rebase: Actions.GitRebase,\n        'change-target': Actions.GitChangeTarget,\n        push: Actions.GitPush,\n      };\n\n      const actionDef = actionMap[action];\n      if (!actionDef) return;\n\n      // Execute git action with workspaceId and repoId\n      await executeAction(actionDef, selectedWorkspace.id, repoId);\n    },\n    [selectedWorkspace, executeAction]\n  );\n\n  // Handle push button click - use mutation for proper state tracking\n  const handlePushClick = useCallback(\n    (repoId: string) => {\n      // Use ref to check current state to avoid stale closure\n      if (pushStatesRef.current[repoId] === 'pending') return;\n\n      // Clear any existing timeout\n      if (successTimeoutRef.current) {\n        clearTimeout(successTimeoutRef.current);\n        successTimeoutRef.current = null;\n      }\n\n      // Track which repo we're pushing\n      currentPushRepoRef.current = repoId;\n      setPushStates((prev) => ({ ...prev, [repoId]: 'pending' }));\n      pushMutation.mutate({ repo_id: repoId });\n    },\n    [pushMutation]\n  );\n\n  return (\n    <GitPanel\n      repos={repoInfosWithPushButton}\n      repoSelectedActions={repoActions}\n      workingBranchName={selectedWorkspace?.branch ?? ''}\n      onWorkingBranchNameChange={handleBranchNameChange}\n      onActionsClick={handleActionsClick}\n      onRepoActionChange={setRepoAction}\n      onPushClick={handlePushClick}\n      onMoreClick={handleMoreClick}\n      onAddRepo={() => console.log('Add repo clicked')}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/LogsContentContainer.tsx",
    "content": "import { useEffect, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '@/shared/lib/utils';\nimport {\n  type LogEntry,\n  VirtualizedProcessLogs,\n} from '@/shared/components/VirtualizedProcessLogs';\nimport { useLogStream } from '@/shared/hooks/useLogStream';\nimport { useLogsPanel } from '@/shared/hooks/useLogsPanel';\nimport { TerminalPanelContainer } from '@/shared/components/TerminalPanelContainer';\nimport { ArrowsInSimpleIcon } from '@phosphor-icons/react';\n\nexport type LogsPanelContent =\n  | { type: 'process'; processId: string }\n  | {\n      type: 'tool';\n      toolName: string;\n      content: string;\n      command: string | undefined;\n    }\n  | { type: 'terminal' };\n\ninterface LogsContentContainerProps {\n  className: string;\n}\n\nexport function LogsContentContainer({ className }: LogsContentContainerProps) {\n  const {\n    logsPanelContent: content,\n    logSearchQuery: searchQuery,\n    logCurrentMatchIdx: currentMatchIndex,\n    setLogMatchIndices: onMatchIndicesChange,\n    collapseTerminal,\n  } = useLogsPanel();\n  const { t } = useTranslation('common');\n  // Get logs for process content (only when type is 'process')\n  const processId = content?.type === 'process' ? content.processId : '';\n  const { logs, error } = useLogStream(processId);\n\n  // Get the current logs based on content type\n  const currentLogs = useMemo(() => {\n    if (content?.type === 'tool') {\n      return content.content\n        .split('\\n')\n        .map((line) => ({ type: 'STDOUT' as const, content: line }));\n    }\n    return logs;\n  }, [content, logs]);\n\n  // Compute which log indices match the search query (reversed for bottom-to-top)\n  const matchIndices = useMemo(() => {\n    if (!searchQuery.trim()) return [];\n    const query = searchQuery.toLowerCase();\n    const matches = currentLogs\n      .map((log, idx) => (log.content.toLowerCase().includes(query) ? idx : -1))\n      .filter((idx) => idx !== -1);\n    // Reverse so newest matches (bottom) come first\n    return matches.reverse();\n  }, [currentLogs, searchQuery]);\n\n  // Report match indices to parent\n  useEffect(() => {\n    onMatchIndicesChange?.(matchIndices);\n  }, [matchIndices, onMatchIndicesChange]);\n\n  // Empty state\n  if (!content) {\n    return (\n      <div className=\"w-full h-full bg-secondary flex items-center justify-center text-low\">\n        <p className=\"text-sm\">{t('logs.selectProcessToView')}</p>\n      </div>\n    );\n  }\n\n  // Tool content - render static content using VirtualizedProcessLogs\n  if (content.type === 'tool') {\n    const toolLogs: LogEntry[] = content.content\n      .split('\\n')\n      .map((line) => ({ type: 'STDOUT' as const, content: line }));\n\n    return (\n      <div className={cn('h-full bg-secondary flex flex-col', className)}>\n        <div className=\"px-4 py-2 border-b border-border text-sm font-medium text-normal shrink-0\">\n          {content.toolName}\n        </div>\n        {content.command && (\n          <div className=\"px-4 py-2 font-mono text-xs text-low border-b border-border bg-tertiary shrink-0\">\n            $ {content.command}\n          </div>\n        )}\n        <div className=\"flex-1 min-h-0\">\n          <VirtualizedProcessLogs\n            logs={toolLogs}\n            error={null}\n            searchQuery={searchQuery}\n            matchIndices={matchIndices}\n            currentMatchIndex={currentMatchIndex}\n          />\n        </div>\n      </div>\n    );\n  }\n\n  // Terminal content - render terminal with collapse button\n  if (content.type === 'terminal') {\n    return (\n      <div className={cn('h-full bg-secondary flex flex-col', className)}>\n        <div className=\"px-4 py-1 flex items-center justify-between shrink-0 h-8\">\n          <span className=\"text-sm font-medium text-normal\">\n            {t('processes.terminal')}\n          </span>\n          <button\n            type=\"button\"\n            onClick={collapseTerminal}\n            className=\"text-low hover:text-normal transition-colors\"\n            title={t('actions.collapse')}\n          >\n            <ArrowsInSimpleIcon className=\"size-icon-sm\" weight=\"bold\" />\n          </button>\n        </div>\n        <div className=\"flex-1 flex min-h-0 border-t border-border\">\n          <div className=\"flex-1 min-h-0 w-full\">\n            <TerminalPanelContainer />\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  // Process logs - render with VirtualizedProcessLogs\n  return (\n    <div className={cn('h-full bg-secondary', className)}>\n      <VirtualizedProcessLogs\n        key={processId}\n        logs={logs}\n        error={error}\n        searchQuery={searchQuery}\n        matchIndices={matchIndices}\n        currentMatchIndex={currentMatchIndex}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/NotificationsPage.tsx",
    "content": "import { useCallback } from 'react';\nimport { useRouter } from '@tanstack/react-router';\nimport { BellIcon, CheckIcon, ChecksIcon } from '@phosphor-icons/react';\nimport { UserAvatar } from '@vibe/ui/components/UserAvatar';\nimport { useNotifications } from '@/shared/hooks/useNotifications';\nimport { useNotificationMembers } from '@/shared/hooks/useNotificationMembers';\nimport type { GroupedNotification } from '@/shared/lib/notifications';\nimport {\n  getGroupedNotificationSegments,\n  type MessageSegment,\n} from '@/shared/lib/notificationMessage';\nimport { formatRelativeTime } from '@/shared/lib/date';\nimport { cn } from '@/shared/lib/utils';\n\nfunction NotificationMessage({\n  segments,\n  membersByUserId,\n}: {\n  segments: MessageSegment[];\n  membersByUserId: ReturnType<typeof useNotificationMembers>['membersByUserId'];\n}) {\n  return (\n    <>\n      {segments.map((seg, i) => {\n        if (seg.type === 'text') return <span key={i}>{seg.value}</span>;\n        if (seg.type === 'emphasis') {\n          return (\n            <span key={i} className=\"font-medium text-high\">\n              {seg.value}\n            </span>\n          );\n        }\n        if (seg.type === 'issue') {\n          return (\n            <span\n              key={i}\n              className=\"font-ibm-plex-mono text-high text-[0.95em]\"\n            >\n              {seg.value}\n            </span>\n          );\n        }\n        const member = membersByUserId.get(seg.userId);\n        if (member) {\n          return (\n            <UserAvatar\n              key={i}\n              user={member}\n              className=\"inline-flex h-5 w-5 align-text-bottom text-[10px]\"\n            />\n          );\n        }\n        return <span key={i}>Someone</span>;\n      })}\n    </>\n  );\n}\n\nexport function NotificationsPage() {\n  const router = useRouter();\n  const { data, updateMany, enabled, unseenCount, groupedNotifications } =\n    useNotifications();\n  const { membersByUserId } = useNotificationMembers(data);\n\n  const markGroupSeen = useCallback(\n    (group: GroupedNotification) => {\n      if (group.unseenNotificationIds.length === 0) {\n        return;\n      }\n\n      updateMany(\n        group.unseenNotificationIds.map((notificationId) => ({\n          id: notificationId,\n          changes: { seen: true },\n        }))\n      );\n    },\n    [updateMany]\n  );\n\n  const handleClick = useCallback(\n    (group: GroupedNotification) => {\n      markGroupSeen(group);\n      const path = group.deeplinkPath;\n      if (path) {\n        router.navigate({ to: path as '/' });\n      }\n    },\n    [markGroupSeen, router]\n  );\n\n  const handleMarkAllSeen = useCallback(() => {\n    const unseen = data.filter((n) => !n.seen);\n    if (unseen.length === 0) return;\n    updateMany(unseen.map((n) => ({ id: n.id, changes: { seen: true } })));\n  }, [data, updateMany]);\n\n  if (!enabled) {\n    return (\n      <div className=\"flex items-center justify-center h-full text-low\">\n        Sign in to view notifications\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col h-full overflow-hidden\">\n      <div className=\"flex items-center justify-between px-double py-base border-b border-border\">\n        <h1 className=\"text-xl font-medium text-high\">Notifications</h1>\n        {unseenCount > 0 && (\n          <button\n            type=\"button\"\n            onClick={handleMarkAllSeen}\n            className=\"flex items-center gap-1 px-base py-half text-sm text-low hover:text-normal transition-colors cursor-pointer\"\n          >\n            <ChecksIcon size={16} />\n            Mark all as read\n          </button>\n        )}\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        {groupedNotifications.length === 0 ? (\n          <div className=\"flex flex-col items-center justify-center h-full gap-2 text-low\">\n            <BellIcon size={32} weight=\"light\" />\n            <p className=\"text-base\">No notifications yet</p>\n          </div>\n        ) : (\n          <div className=\"divide-y divide-border\">\n            {groupedNotifications.map((group) => (\n              <div\n                key={group.id}\n                role=\"button\"\n                tabIndex={0}\n                onClick={() => handleClick(group)}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter' || e.key === ' ') {\n                    e.preventDefault();\n                    handleClick(group);\n                  }\n                }}\n                className={cn(\n                  'w-full flex items-center gap-base px-double py-base text-left transition-colors cursor-pointer outline-none',\n                  'hover:bg-secondary',\n                  'focus-visible:bg-secondary',\n                  'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-brand',\n                  !group.seen && 'bg-brand/5'\n                )}\n              >\n                <span\n                  className={cn(\n                    'shrink-0 w-2 h-2 rounded-full',\n                    !group.seen && 'bg-brand'\n                  )}\n                />\n                <div className=\"flex-1 min-w-0\">\n                  <p\n                    className={cn(\n                      'text-base truncate',\n                      group.seen ? 'text-normal' : 'text-high'\n                    )}\n                  >\n                    <NotificationMessage\n                      segments={getGroupedNotificationSegments(group)}\n                      membersByUserId={membersByUserId}\n                    />\n                  </p>\n                  <p className=\"text-sm text-low mt-0.5\">\n                    {formatRelativeTime(group.latest.created_at)}\n                  </p>\n                </div>\n                {!group.seen && (\n                  <button\n                    type=\"button\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      markGroupSeen(group);\n                    }}\n                    onKeyDown={(e) => e.stopPropagation()}\n                    className={cn(\n                      'shrink-0 inline-flex items-center gap-half rounded-sm px-half py-half text-sm text-low transition-colors cursor-pointer',\n                      'hover:bg-secondary hover:text-normal',\n                      'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand'\n                    )}\n                    aria-label=\"Mark notification as read\"\n                    title=\"Mark as read\"\n                  >\n                    <CheckIcon size={14} weight=\"bold\" />\n                    <span className=\"hidden sm:inline\">Mark as read</span>\n                  </button>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/PierreDiffCard.tsx",
    "content": "import { useMemo, useCallback, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  CaretDownIcon,\n  ChatCircleIcon,\n  CopyIcon,\n  GithubLogoIcon,\n  PlusIcon,\n} from '@phosphor-icons/react';\nimport { FileDiff } from '@pierre/diffs/react';\nimport type {\n  DiffLineAnnotation,\n  AnnotationSide,\n  ChangeContent,\n} from '@pierre/diffs';\nimport { cn } from '@/shared/lib/utils';\nimport { DiffSide } from '@/shared/types/diff';\nimport {\n  transformDiffToFileDiffMetadata,\n  transformCommentsToAnnotations,\n  type CommentAnnotation,\n} from '@/shared/lib/diffDataAdapter';\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport { getActualTheme } from '@/shared/lib/theme';\nimport {\n  useDiffViewMode,\n  useWrapTextDiff,\n  useIgnoreWhitespaceDiff,\n} from '@/shared/stores/useDiffViewStore';\nimport { useReview, type ReviewDraft } from '@/shared/hooks/useReview';\nimport { useWorkspaceDiffContext } from '@/shared/hooks/useWorkspaceContext';\nimport { isRealMobileDevice } from '@/shared/hooks/useIsMobile';\nimport { getFileIcon } from '@/shared/lib/fileTypeIcon';\nimport { OpenInIdeButton } from '@/shared/components/OpenInIdeButton';\nimport { CopyButton } from '@/shared/components/CopyButton';\nimport { useOpenInEditor } from '@/shared/hooks/useOpenInEditor';\nimport { writeClipboardViaBridge } from '@/shared/lib/clipboard';\nimport { ReviewCommentRenderer } from './ReviewCommentRenderer';\nimport { GitHubCommentRenderer } from './GitHubCommentRenderer';\nimport { CommentWidgetLine } from './CommentWidgetLine';\nimport { DisplayTruncatedPath } from '@/shared/lib/TruncatePath';\nimport { stripLineEnding, splitLines } from '@/shared/lib/string';\nimport type { Diff } from 'shared/types';\n\n/**\n * Extracts a specific line from file content.\n * @param content - The full file content as a string\n * @param lineNumber - The 1-indexed line number to extract\n * @returns The line content, or undefined if the line doesn't exist\n */\nfunction getLineContent(\n  content: string | null,\n  lineNumber: number\n): string | undefined {\n  if (!content) return undefined;\n  const lines = splitLines(content);\n  const index = lineNumber - 1; // Convert 1-indexed to 0-indexed\n  if (index < 0 || index >= lines.length) return undefined;\n  return stripLineEnding(lines[index]);\n}\n\n/**\n * Gets the appropriate line content based on whether the comment is on\n * the old (deletions) or new (additions) side of the diff.\n * @param diff - The Diff object containing oldContent and newContent\n * @param lineNumber - The 1-indexed line number\n * @param side - The side of the diff (Old for deletions, New for additions)\n * @returns The line content, or undefined if not available\n */\nfunction getCodeLineForComment(\n  diff: Diff,\n  lineNumber: number,\n  side: DiffSide\n): string | undefined {\n  const content = side === DiffSide.Old ? diff.oldContent : diff.newContent;\n  return getLineContent(content, lineNumber);\n}\n\n/**\n * CSS overrides for @pierre/diffs to match our app's theme.\n * Injected via unsafeCSS which applies at @layer unsafe (highest priority).\n */\nconst PIERRE_DIFFS_THEME_CSS = `\n  [data-separator=\"line-info\"][data-separator-first] {\n    margin-top: 4px;\n  }\n  [data-separator=\"line-info\"][data-separator-last] {\n    margin-bottom: 4px;\n  }\n\n  /* Add space for hover comment button between line numbers and code */\n  [data-indicators='classic'] [data-column-content] {\n    position: relative !important;\n    padding-inline-start: 34px !important;\n  }\n\n  /* Move +/- indicator right to make room for hover button */\n  [data-indicators='classic'] [data-line-type='change-addition'] [data-column-content]::before,\n  [data-indicators='classic'] [data-line-type='change-deletion'] [data-column-content]::before {\n    left: 22px !important;\n  }\n\n  /* Position hover utility dynamically based on line number column width */\n  [data-hover-slot] {\n    right: auto !important;\n    left: calc(var(--diffs-column-number-width, 3ch) - 25px) !important;\n    width: 22px !important;\n  }\n\n  /* Keep annotations full-row without inheriting long-line scroll width */\n  [data-annotation-content] {\n    grid-column: 1 / -1 !important;\n    left: 0 !important;\n    width: var(--diffs-column-width, 100%) !important;\n    max-width: 100% !important;\n  }\n  \n  [data-line-annotation] {\n    grid-column: 1 / -1 !important;\n  }\n\n  /* Show scrollbar only on hover */\n  [data-code] {\n    padding-bottom: 0 !important;\n  }\n  [data-code]::-webkit-scrollbar {\n    height: 8px !important;\n    background: transparent !important;\n  }\n  [data-code]::-webkit-scrollbar-track {\n    background: transparent !important;\n  }\n  [data-code]::-webkit-scrollbar-thumb {\n    background-color: transparent !important;\n    border-radius: 4px !important;\n  }\n  [data-code]:hover::-webkit-scrollbar-thumb {\n    background-color: hsl(var(--text-low) / 0.3) !important;\n  }\n\n  /* Light theme overrides */\n  [data-diffs][data-theme-type='light'] {\n    --diffs-gap-style: none !important;\n    \n    /* Background colors - use standard CSS variables */\n    --diffs-light-bg: hsl(var(--bg-primary)) !important;\n    --diffs-bg-context-override: hsl(var(--bg-primary)) !important;\n    --diffs-bg-separator-override: hsl(var(--bg-primary)) !important;\n    \n    /* Addition colors - soft green matching old design */\n    --diffs-light-addition-color: hsl(160, 77%, 35%) !important;\n    --diffs-bg-addition-override: hsl(160, 77%, 88%) !important;\n    --diffs-bg-addition-number-override: hsl(160, 77%, 85%) !important;\n    --diffs-bg-addition-hover-override: hsl(160, 77%, 82%) !important;\n    \n    /* Deletion colors - soft red matching old design */\n    --diffs-light-deletion-color: hsl(10, 100%, 40%) !important;\n    --diffs-bg-deletion-override: hsl(10, 100%, 90%) !important;\n    --diffs-bg-deletion-number-override: hsl(10, 100%, 87%) !important;\n    --diffs-bg-deletion-hover-override: hsl(10, 100%, 84%) !important;\n    \n    /* Line numbers */\n    --diffs-fg-number-override: hsl(var(--text-low)) !important;\n  }\n\n  /* Dark theme overrides */\n  [data-diffs][data-theme-type='dark'] {\n    --diffs-gap-style: none !important;\n    \n    /* Background colors - use standard CSS variables */\n    --diffs-dark-bg: hsl(var(--bg-panel)) !important;\n    --diffs-bg-context-override: hsl(var(--bg-panel)) !important;\n    --diffs-bg-separator-override: hsl(var(--bg-panel)) !important;\n    --diffs-bg-hover-override: hsl(0, 0%, 22%) !important;\n    \n    /* Addition colors - dark green */\n    --diffs-dark-addition-color: hsl(130, 50%, 50%) !important;\n    --diffs-bg-addition-override: hsl(130, 30%, 20%) !important;\n    --diffs-bg-addition-number-override: hsl(130, 30%, 18%) !important;\n    --diffs-bg-addition-hover-override: hsl(130, 30%, 25%) !important;\n    \n    /* Deletion colors - dark red */\n    --diffs-dark-deletion-color: hsl(12, 50%, 55%) !important;\n    --diffs-bg-deletion-override: hsl(12, 30%, 18%) !important;\n    --diffs-bg-deletion-number-override: hsl(12, 30%, 16%) !important;\n    --diffs-bg-deletion-hover-override: hsl(12, 30%, 23%) !important;\n    \n    /* Line numbers */\n    --diffs-fg-number-override: hsl(var(--text-low)) !important;\n  }\n`;\n\ninterface PierreDiffCardProps {\n  diff: Diff;\n  expanded: boolean;\n  onToggle: () => void;\n  workspaceId: string;\n  className: string;\n}\n\ntype ExtendedCommentAnnotation =\n  | CommentAnnotation\n  | { type: 'draft'; draft: ReviewDraft; widgetKey: string };\n\nfunction mapSideToAnnotationSide(side: DiffSide): AnnotationSide {\n  return side === DiffSide.Old ? 'deletions' : 'additions';\n}\n\nfunction mapAnnotationSideToSplitSide(side: AnnotationSide): DiffSide {\n  return side === 'deletions' ? DiffSide.Old : DiffSide.New;\n}\n\nexport function PierreDiffCard({\n  diff,\n  expanded,\n  onToggle,\n  workspaceId,\n  className = '',\n}: PierreDiffCardProps) {\n  const { t } = useTranslation('tasks');\n  const { theme } = useTheme();\n  const actualTheme = getActualTheme(theme);\n  const globalMode = useDiffViewMode();\n  const wrapText = useWrapTextDiff();\n  const ignoreWhitespace = useIgnoreWhitespaceDiff();\n\n  const { comments, drafts, setDraft, addComment } = useReview();\n  const { showGitHubComments, getGitHubCommentsForFile } =\n    useWorkspaceDiffContext();\n\n  // File path logic\n  const filePath = diff.newPath || diff.oldPath || 'unknown';\n  const oldPath = diff.oldPath;\n  const changeKind = diff.change;\n\n  const openInEditor = useOpenInEditor(workspaceId);\n  const handleOpenInIde = useCallback(() => {\n    openInEditor({ filePath });\n  }, [openInEditor, filePath]);\n  const handleCopyFilePath = useCallback(() => {\n    void writeClipboardViaBridge(filePath);\n  }, [filePath]);\n\n  // Transform diff to pierre/diffs metadata\n  const fileDiffMetadata = useMemo(\n    () => transformDiffToFileDiffMetadata(diff, { ignoreWhitespace }),\n    [diff, ignoreWhitespace]\n  );\n\n  const additions = useMemo(() => {\n    return fileDiffMetadata.hunks.reduce((acc, hunk) => {\n      return (\n        acc +\n        hunk.hunkContent.reduce((count, content) => {\n          if (content.type === 'change') {\n            return count + (content as ChangeContent).additions.length;\n          }\n          return count;\n        }, 0)\n      );\n    }, 0);\n  }, [fileDiffMetadata]);\n\n  const deletions = useMemo(() => {\n    return fileDiffMetadata.hunks.reduce((acc, hunk) => {\n      return (\n        acc +\n        hunk.hunkContent.reduce((count, content) => {\n          if (content.type === 'change') {\n            return count + (content as ChangeContent).deletions.length;\n          }\n          return count;\n        }, 0)\n      );\n    }, 0);\n  }, [fileDiffMetadata]);\n\n  const hasStats = additions > 0 || deletions > 0;\n\n  const FileIcon = getFileIcon(filePath, actualTheme);\n\n  // Change Label\n  const getChangeLabel = (kind?: string): string | null => {\n    switch (kind) {\n      case 'added':\n        return 'Added';\n      case 'deleted':\n        return 'Deleted';\n      case 'renamed':\n        return 'Renamed';\n      case 'copied':\n        return 'Copied';\n      case 'permissionChange':\n        return 'Perm';\n      default:\n        return null;\n    }\n  };\n  const changeLabel = getChangeLabel(changeKind);\n\n  const commentsForFile = useMemo(\n    () => comments.filter((c) => c.filePath === filePath),\n    [comments, filePath]\n  );\n\n  const githubCommentsForFile = useMemo(() => {\n    if (!showGitHubComments) return [];\n    return getGitHubCommentsForFile(filePath);\n  }, [showGitHubComments, getGitHubCommentsForFile, filePath]);\n\n  const totalCommentCount =\n    commentsForFile.length + githubCommentsForFile.length;\n\n  const annotations = useMemo(() => {\n    // 1. Get standard comments\n    const baseAnnotations = transformCommentsToAnnotations(\n      commentsForFile,\n      githubCommentsForFile,\n      filePath\n    ) as DiffLineAnnotation<ExtendedCommentAnnotation>[];\n\n    // 2. Add drafts\n    const draftAnnotations: DiffLineAnnotation<ExtendedCommentAnnotation>[] =\n      [];\n    Object.entries(drafts).forEach(([key, draft]) => {\n      if (!draft || draft.filePath !== filePath) return;\n\n      draftAnnotations.push({\n        side: mapSideToAnnotationSide(draft.side),\n        lineNumber: draft.lineNumber,\n        metadata: {\n          type: 'draft',\n          draft,\n          widgetKey: key,\n        },\n      });\n    });\n\n    return [...baseAnnotations, ...draftAnnotations];\n  }, [commentsForFile, githubCommentsForFile, filePath, drafts]);\n\n  const renderAnnotation = useCallback(\n    (annotation: DiffLineAnnotation<ExtendedCommentAnnotation>) => {\n      const { metadata } = annotation;\n\n      if (metadata.type === 'draft') {\n        return (\n          <CommentWidgetLine\n            draft={metadata.draft}\n            widgetKey={metadata.widgetKey}\n            onSave={() => {}}\n            onCancel={() => {}}\n          />\n        );\n      }\n\n      if (metadata.type === 'github') {\n        const githubComment = metadata.comment;\n        const handleCopyToUserComment = () => {\n          const codeLine = getCodeLineForComment(\n            diff,\n            githubComment.lineNumber,\n            githubComment.side\n          );\n          addComment({\n            filePath,\n            lineNumber: githubComment.lineNumber,\n            side: githubComment.side,\n            text: githubComment.body,\n            ...(codeLine !== undefined ? { codeLine } : {}),\n          });\n        };\n        return (\n          <GitHubCommentRenderer\n            comment={githubComment}\n            onCopyToUserComment={handleCopyToUserComment}\n          />\n        );\n      }\n\n      return <ReviewCommentRenderer comment={metadata.comment} />;\n    },\n    [filePath, addComment, diff]\n  );\n\n  // Handle line click to add comment\n  const handleLineClick = useCallback(\n    (props: { lineNumber: number; annotationSide: AnnotationSide }) => {\n      const { lineNumber, annotationSide } = props;\n      const splitSide = mapAnnotationSideToSplitSide(annotationSide);\n      const widgetKey = `${filePath}-${splitSide}-${lineNumber}`;\n\n      // Don't create a new draft if one already exists\n      if (drafts[widgetKey]) return;\n\n      const codeLine = getCodeLineForComment(diff, lineNumber, splitSide);\n\n      setDraft(widgetKey, {\n        filePath,\n        side: splitSide,\n        lineNumber,\n        text: '',\n        ...(codeLine !== undefined ? { codeLine } : {}),\n      });\n    },\n    [filePath, drafts, setDraft, diff]\n  );\n\n  const renderHoverUtility = useCallback(\n    (\n      getHoveredLine: () =>\n        | { lineNumber: number; side: AnnotationSide }\n        | undefined\n    ) => {\n      return (\n        <button\n          className=\"flex items-center justify-center size-icon-base rounded text-brand bg-brand/20 transition-transform hover:scale-110\"\n          onClick={() => {\n            const line = getHoveredLine();\n            if (!line) return;\n\n            const { side, lineNumber } = line;\n            const splitSide = mapAnnotationSideToSplitSide(side);\n            const widgetKey = `${filePath}-${splitSide}-${lineNumber}`;\n\n            if (drafts[widgetKey]) return;\n\n            const codeLine = getCodeLineForComment(diff, lineNumber, splitSide);\n\n            setDraft(widgetKey, {\n              filePath,\n              side: splitSide,\n              lineNumber,\n              text: '',\n              ...(codeLine !== undefined ? { codeLine } : {}),\n            });\n          }}\n          title={t('common:comments.addReviewComment')}\n        >\n          <PlusIcon className=\"size-3.5\" weight=\"bold\" />\n        </button>\n      );\n    },\n    [filePath, drafts, setDraft, t, diff]\n  );\n\n  const fileDiffOptions = useMemo(\n    () => ({\n      diffStyle:\n        globalMode === 'split' ? ('split' as const) : ('unified' as const),\n      diffIndicators: 'classic' as const,\n      themeType: actualTheme,\n      overflow: wrapText ? ('wrap' as const) : ('scroll' as const),\n      hunkSeparators: 'line-info' as const,\n      disableFileHeader: true,\n      enableHoverUtility: true,\n      onLineClick: handleLineClick,\n      theme: { dark: 'github-dark', light: 'github-light' } as const,\n      unsafeCSS: PIERRE_DIFFS_THEME_CSS,\n    }),\n    [globalMode, actualTheme, wrapText, handleLineClick]\n  );\n\n  // Large diff placeholder logic\n  const LARGE_DIFF_THRESHOLD = 2000;\n  const [forceExpanded, setForceExpanded] = useState(false);\n  const totalLines = additions + deletions;\n  const isLargeDiff = totalLines > LARGE_DIFF_THRESHOLD;\n  const shouldShowPlaceholder = expanded && isLargeDiff && !forceExpanded;\n\n  return (\n    <div className={cn('pb-base rounded-sm', className)}>\n      <div\n        className={cn(\n          'w-full flex items-center bg-primary px-base gap-base sticky top-0 z-10 border-b border-transparent',\n          'cursor-pointer min-h-10',\n          expanded && 'rounded-t-sm'\n        )}\n        onClick={onToggle}\n      >\n        <span className=\"relative shrink-0\">\n          <FileIcon className=\"size-icon-base\" />\n        </span>\n        {changeLabel && (\n          <span\n            className={cn(\n              'text-sm shrink-0 bg-primary rounded-sm px-half',\n              changeKind === 'deleted' && 'text-error border border-error/20',\n              changeKind === 'added' && 'text-success border border-success/20'\n            )}\n          >\n            {changeLabel}\n          </span>\n        )}\n        <div className=\"flex items-center gap-half flex-1 min-w-0\">\n          <div\n            className={cn(\n              'text-sm min-w-0 flex-1',\n              changeKind === 'deleted' && 'text-error line-through'\n            )}\n          >\n            <DisplayTruncatedPath path={filePath} />\n          </div>\n          <span onClick={(e) => e.stopPropagation()} className=\"shrink-0\">\n            <CopyButton\n              onCopy={handleCopyFilePath}\n              disabled={false}\n              iconSize=\"size-icon-xs\"\n              icon={CopyIcon}\n            />\n          </span>\n        </div>\n        {(changeKind === 'renamed' || changeKind === 'copied') && oldPath && (\n          <span className=\"text-low text-sm shrink-0\">\n            ← {oldPath.split('/').pop()}\n          </span>\n        )}\n        {hasStats && (\n          <span className=\"text-sm shrink-0\">\n            {additions > 0 && (\n              <span className=\"text-success\">+{additions}</span>\n            )}\n            {additions > 0 && deletions > 0 && ' '}\n            {deletions > 0 && <span className=\"text-error\">-{deletions}</span>}\n          </span>\n        )}\n        {totalCommentCount > 0 && (\n          <span className=\"inline-flex items-center gap-half px-base py-0.5 text-xs rounded shrink-0\">\n            {commentsForFile.length > 0 && (\n              <span className=\"inline-flex items-center gap-0.5 text-accent\">\n                <ChatCircleIcon className=\"size-icon-xs\" weight=\"fill\" />\n                {commentsForFile.length}\n              </span>\n            )}\n            {githubCommentsForFile.length > 0 && (\n              <span className=\"inline-flex items-center gap-0.5 text-low\">\n                <GithubLogoIcon className=\"size-icon-xs\" weight=\"fill\" />\n                {githubCommentsForFile.length}\n              </span>\n            )}\n          </span>\n        )}\n        <div className=\"flex items-center gap-half shrink-0\">\n          {!isRealMobileDevice() && (\n            <span onClick={(e) => e.stopPropagation()}>\n              <OpenInIdeButton\n                onClick={handleOpenInIde}\n                className=\"size-icon-xs p-0\"\n              />\n            </span>\n          )}\n          <CaretDownIcon\n            className={cn(\n              'size-icon-xs text-low transition-transform',\n              !expanded && '-rotate-90'\n            )}\n          />\n        </div>\n      </div>\n\n      {expanded && (\n        <div className=\"bg-primary rounded-b-sm overflow-hidden\">\n          {shouldShowPlaceholder ? (\n            <div className=\"p-base bg-warning/5 border-t border-warning/20\">\n              <div className=\"flex items-center justify-between gap-base\">\n                <div className=\"text-sm text-low\">\n                  <span className=\"font-medium text-warning\">\n                    {t('diff.largeDiff.title')}\n                  </span>\n                  <span className=\"ml-base\">\n                    {t('diff.largeDiff.linesChanged', { count: totalLines })}\n                    <span className=\"text-success ml-base\">\n                      +{additions.toLocaleString()}\n                    </span>\n                    <span className=\"text-error ml-half\">\n                      -{deletions.toLocaleString()}\n                    </span>\n                  </span>\n                </div>\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setForceExpanded(true);\n                  }}\n                  className=\"text-sm text-brand hover:text-brand-hover transition-colors\"\n                >\n                  {t('diff.largeDiff.loadAnyway')}\n                </button>\n              </div>\n              <p className=\"text-xs text-low mt-half\">\n                {t('diff.largeDiff.warning')}\n              </p>\n            </div>\n          ) : (\n            <FileDiff\n              fileDiff={fileDiffMetadata}\n              options={fileDiffOptions}\n              lineAnnotations={annotations}\n              renderAnnotation={renderAnnotation}\n              renderHoverUtility={renderHoverUtility}\n            />\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/PreviewBrowserContainer.tsx",
    "content": "import {\n  useCallback,\n  useState,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useMemo,\n} from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { configApi } from '@/shared/lib/api';\nimport {\n  PreviewBrowser,\n  MOBILE_WIDTH,\n  MOBILE_HEIGHT,\n  PHONE_FRAME_PADDING,\n} from '@vibe/ui/components/PreviewBrowser';\nimport { usePreviewDevServer } from '@/features/workspace/model/hooks/usePreviewDevServer';\nimport { usePreviewUrl } from '@/shared/hooks/usePreviewUrl';\nimport { useIsMobile } from '@/shared/hooks/useIsMobile';\nimport {\n  usePreviewSettings,\n  type ScreenSize,\n} from '@/shared/hooks/usePreviewSettings';\nimport { useLogStream } from '@/shared/hooks/useLogStream';\nimport { useUiPreferencesStore } from '@/shared/stores/useUiPreferencesStore';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { ScriptFixerDialog } from '@/shared/dialogs/scripts/ScriptFixerDialog';\nimport { usePreviewNavigation } from '@/shared/hooks/usePreviewNavigation';\nimport { PreviewDevToolsBridge } from '@/shared/lib/previewDevToolsBridge';\nimport { useInspectModeStore } from '@/features/workspace-chat/model/store/useInspectModeStore';\nimport type { PreviewDevToolsMessage } from '@/shared/types/previewDevTools';\n\nconst MIN_RESPONSIVE_WIDTH = 320;\nconst MIN_RESPONSIVE_HEIGHT = 480;\n\nfunction parsePreviewUrl(rawUrl: string, baseUrl?: string): URL | null {\n  const trimmed = rawUrl.trim();\n  if (!trimmed) return null;\n\n  try {\n    const parsed = new URL(trimmed);\n    if (\n      (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&\n      parsed.hostname\n    ) {\n      return parsed;\n    }\n  } catch {\n    // Keep going.\n  }\n\n  if (\n    (trimmed.startsWith('/') ||\n      trimmed.startsWith('?') ||\n      trimmed.startsWith('#')) &&\n    baseUrl\n  ) {\n    try {\n      return new URL(trimmed, baseUrl);\n    } catch {\n      return null;\n    }\n  }\n\n  if (!trimmed.includes('://')) {\n    try {\n      return new URL(`http://${trimmed}`);\n    } catch {\n      return null;\n    }\n  }\n\n  return null;\n}\n\nfunction normalizePreviewUrl(rawUrl: string, baseUrl?: string): string | null {\n  return parsePreviewUrl(rawUrl, baseUrl)?.toString() ?? null;\n}\n\n/**\n * Transform a proxy URL back to the dev server URL.\n * Proxy format: http://{devPort}.localhost:{proxyPort}{path}?_refresh=...\n * Dev format:   http://localhost:{devPort}{path}\n */\nfunction transformProxyUrlToDevUrl(proxyUrl: string): string | null {\n  try {\n    const url = new URL(proxyUrl);\n\n    const hostnameParts = url.hostname.split('.');\n    if (\n      hostnameParts.length < 2 ||\n      !hostnameParts.slice(1).join('.').startsWith('localhost')\n    ) {\n      return null;\n    }\n\n    const devPort = hostnameParts[0];\n    if (!/^\\d+$/.test(devPort)) {\n      return null;\n    }\n\n    url.searchParams.delete('_refresh');\n\n    const devUrl = new URL(`http://localhost${url.pathname}`);\n\n    const search = url.searchParams.toString();\n    if (search) {\n      devUrl.search = search;\n    }\n\n    if (url.hash) {\n      devUrl.hash = url.hash;\n    }\n\n    const portNum = parseInt(devPort, 10);\n    if (portNum !== 80) {\n      devUrl.port = devPort;\n    }\n\n    return devUrl.toString();\n  } catch {\n    return null;\n  }\n}\n\ninterface PreviewBrowserContainerProps {\n  workspaceId: string;\n  className: string;\n}\n\nexport function PreviewBrowserContainer({\n  workspaceId,\n  className,\n}: PreviewBrowserContainerProps) {\n  // ─── Data Sources ───────────────────────────────────────────────────────────\n  // Workspace context, preview proxy config, dev server state, log streams,\n  // URL auto-detection, and preview settings (override URL, screen size).\n\n  const previewRefreshKey = useUiPreferencesStore((s) => s.previewRefreshKey);\n  const isMobile = useIsMobile();\n  const [mobileUrlExpanded, setMobileUrlExpanded] = useState(false);\n  const triggerPreviewRefresh = useUiPreferencesStore(\n    (s) => s.triggerPreviewRefresh\n  );\n  const { repos, workspaceId: activeWorkspaceId } = useWorkspaceContext();\n\n  // Get preview proxy port for security isolation\n  const { data: systemInfo } = useQuery({\n    queryKey: ['user-system'],\n    queryFn: configApi.getConfig,\n    staleTime: 5 * 60 * 1000,\n  });\n  const previewProxyPort = systemInfo?.preview_proxy_port;\n\n  const {\n    start,\n    stop,\n    isStarting,\n    isStopping,\n    runningDevServers,\n    devServerProcesses,\n  } = usePreviewDevServer(activeWorkspaceId ?? workspaceId);\n\n  const primaryDevServer = useMemo(() => {\n    if (runningDevServers.length === 0) return undefined;\n\n    return runningDevServers.reduce((latest, current) =>\n      new Date(current.started_at).getTime() >\n      new Date(latest.started_at).getTime()\n        ? current\n        : latest\n    );\n  }, [runningDevServers]);\n  const { logs } = useLogStream(primaryDevServer?.id ?? '');\n  const urlInfo = usePreviewUrl(logs, previewProxyPort ?? undefined);\n\n  // Detect failed dev server process (failed status or completed with non-zero exit code)\n  const failedDevServerProcess = devServerProcesses.find(\n    (p) =>\n      p.status === 'failed' ||\n      (p.status === 'completed' && p.exit_code !== null && p.exit_code !== 0n)\n  );\n  const hasFailedDevServer = Boolean(failedDevServerProcess);\n\n  // Preview settings (URL override and screen size)\n  const {\n    overrideUrl,\n    hasOverride,\n    setOverrideUrl,\n    clearOverride,\n    screenSize,\n    responsiveDimensions,\n    setScreenSize,\n    setResponsiveDimensions,\n  } = usePreviewSettings(activeWorkspaceId ?? workspaceId);\n\n  // ─── URL Bar State ──────────────────────────────────────────────────────────\n  // effectiveUrl:       The override URL (if set) or the auto-detected dev server URL.\n  // urlInputValue:      Local state for the URL bar text. Decoupled from effectiveUrl\n  //                     so that external URL changes don't disrupt the user while typing.\n  // prevEffectiveUrlRef: Tracks the previous effectiveUrl so the sync effect can detect\n  //                     when it changes (new URL detected or override toggled).\n  // Use override URL if set, otherwise fall back to auto-detected\n  const effectiveUrl = hasOverride ? overrideUrl : urlInfo?.url;\n  const urlInputRef = useRef<HTMLInputElement>(null);\n  const [urlInputValue, setUrlInputValue] = useState(effectiveUrl ?? '');\n  const prevEffectiveUrlRef = useRef(effectiveUrl);\n\n  // ─── Iframe Display Timing ──────────────────────────────────────────────────\n  // Controls when the iframe becomes visible after URL detection.\n  // Iframe display timing state\n  const [showIframe, setShowIframe] = useState(false);\n  const [allowManualUrl, setAllowManualUrl] = useState(false);\n  const [immediateLoad, setImmediateLoad] = useState(false);\n\n  // Inspect mode state\n  const isInspectMode = useInspectModeStore((s) => s.isInspectMode);\n  const toggleInspectMode = useInspectModeStore((s) => s.toggleInspectMode);\n  const setPendingComponentMarkdown = useInspectModeStore(\n    (s) => s.setPendingComponentMarkdown\n  );\n\n  // ─── Navigation Bridge ────────────────────────────────────────────────────\n  // The Rust proxy injects devtools_script.js into every iframe response.\n  // That script reports navigation events (URL changes, page ready) via postMessage.\n  // PreviewDevToolsBridge wraps the postMessage protocol for type-safe communication.\n  //\n  // navigationDevUrl transforms proxy URLs back to dev URLs:\n  //   proxy:  http://4000.localhost:{proxyPort}/path\n  //   dev:    http://localhost:4000/path\n  //\n  // currentPreviewUrl = best-known current URL (navigation > effectiveUrl).\n  // Eruda DevTools state\n  const [isErudaVisible, setIsErudaVisible] = useState(false);\n  const iframeRef = useRef<HTMLIFrameElement>(null);\n  const {\n    navigation,\n    isReady,\n    handleMessage: handleNavigationMessage,\n    reset: resetNavigation,\n  } = usePreviewNavigation();\n  const bridgeRef = useRef<PreviewDevToolsBridge | null>(null);\n  const navigationDevUrl = useMemo(() => {\n    if (!navigation?.url || !previewProxyPort) {\n      return null;\n    }\n    return transformProxyUrlToDevUrl(navigation.url);\n  }, [navigation?.url, previewProxyPort]);\n  const currentPreviewUrl = navigationDevUrl ?? effectiveUrl ?? null;\n\n  const handleBridgeMessage = useCallback(\n    (message: PreviewDevToolsMessage) => {\n      handleNavigationMessage(message);\n    },\n    [handleNavigationMessage]\n  );\n\n  // ─── URL Sync Effect ──────────────────────────────────────────────────────\n  // Keeps urlInputValue in sync with navigation/effectiveUrl. Priority:\n  //   1. Skip if input is focused (user is typing)\n  //   2. Prefer navigationDevUrl (iframe reported this URL via postMessage)\n  //   3. Use effectiveUrl if it changed (new URL detected or override set)\n  //   4. Fallback: set to effectiveUrl (catch-all for initial render, etc.)\n  //\n  // NOTE: After resetNavigation() in handleUrlSubmit, there's a brief flash\n  // where the URL bar shows the old URL before the iframe reports the new URL.\n  // This is a known cosmetic limitation.\n  // Sync URL bar from effectiveUrl changes OR iframe navigation\n  useEffect(() => {\n    if (document.activeElement === urlInputRef.current) {\n      return;\n    }\n\n    if (navigationDevUrl) {\n      setUrlInputValue(navigationDevUrl);\n      return;\n    }\n\n    if (prevEffectiveUrlRef.current !== effectiveUrl) {\n      prevEffectiveUrlRef.current = effectiveUrl;\n      setUrlInputValue(effectiveUrl ?? '');\n      return;\n    }\n\n    setUrlInputValue(effectiveUrl ?? '');\n  }, [effectiveUrl, navigation?.url, navigationDevUrl]);\n\n  useEffect(() => {\n    bridgeRef.current = new PreviewDevToolsBridge(\n      handleBridgeMessage,\n      iframeRef\n    );\n    bridgeRef.current.start();\n\n    return () => {\n      bridgeRef.current?.stop();\n    };\n  }, [handleBridgeMessage]);\n\n  // Send inspect mode toggle to iframe\n  useEffect(() => {\n    const iframe = iframeRef.current;\n    if (!iframe?.contentWindow) return;\n\n    iframe.contentWindow.postMessage(\n      {\n        source: 'click-to-component',\n        type: 'toggle-inspect',\n        payload: { active: isInspectMode },\n      },\n      '*'\n    );\n  }, [isInspectMode]);\n\n  useEffect(() => {\n    const handleMessage = (event: MessageEvent) => {\n      if (event.source !== iframeRef.current?.contentWindow) return;\n      if (!event.data || event.data.source !== 'click-to-component') return;\n      if (event.data.type !== 'component-detected') return;\n\n      const { data } = event;\n\n      if (data.version === 2 && data.payload) {\n        const fenced = `\\`\\`\\`vk-component\\n${JSON.stringify(data.payload)}\\n\\`\\`\\``;\n        setPendingComponentMarkdown(fenced);\n      } else if (data.payload?.markdown) {\n        setPendingComponentMarkdown(data.payload.markdown);\n      }\n    };\n\n    window.addEventListener('message', handleMessage);\n    return () => window.removeEventListener('message', handleMessage);\n  }, [setPendingComponentMarkdown]);\n\n  // 10-second timeout to enable manual URL entry when no URL detected\n  useEffect(() => {\n    if (!runningDevServers.length) {\n      setAllowManualUrl(false);\n      return;\n    }\n    if (urlInfo?.url) return; // Already have URL\n    const timer = setTimeout(() => setAllowManualUrl(true), 10000);\n    return () => clearTimeout(timer);\n  }, [runningDevServers.length, urlInfo?.url]);\n\n  // Reset immediateLoad when server stops\n  useEffect(() => {\n    if (!runningDevServers.length) {\n      setImmediateLoad(false);\n    }\n  }, [runningDevServers.length]);\n\n  // 2-second delay before showing iframe after URL detection\n  // When there's an override URL from scratch, wait for server to detect a URL first\n  // unless user has triggered an immediate load (refresh/submit)\n  useEffect(() => {\n    if (!effectiveUrl) {\n      setShowIframe(false);\n      return;\n    }\n\n    // If user has triggered immediate load (refresh/submit), show immediately after delay\n    // OR if no override (normal flow), show after delay once effectiveUrl is set\n    // OR if we have both override and auto-detected URL (server is ready), show after delay\n    // OR after timeout fallback when URL auto-detection never resolves.\n    const shouldShow =\n      immediateLoad || !hasOverride || Boolean(urlInfo?.url) || allowManualUrl;\n\n    if (!shouldShow) {\n      setShowIframe(false);\n      return;\n    }\n\n    setShowIframe(false);\n    const timer = setTimeout(() => setShowIframe(true), 2000);\n    return () => clearTimeout(timer);\n  }, [\n    effectiveUrl,\n    previewRefreshKey,\n    immediateLoad,\n    hasOverride,\n    urlInfo?.url,\n    allowManualUrl,\n  ]);\n\n  // Responsive resize state - use refs for values that shouldn't trigger re-renders\n  const [localDimensions, setLocalDimensions] = useState(responsiveDimensions);\n  const [isResizing, setIsResizing] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const isResizingRef = useRef(false);\n  const resizeDirectionRef = useRef<'right' | 'bottom' | 'corner' | null>(null);\n  const localDimensionsRef = useRef(localDimensions);\n  const startPosRef = useRef<{ x: number; y: number } | null>(null);\n  const startDimensionsRef = useRef<{ width: number; height: number } | null>(\n    null\n  );\n\n  // Store callback in ref to avoid effect re-runs when callback identity changes\n  const setResponsiveDimensionsRef = useRef(setResponsiveDimensions);\n  useEffect(() => {\n    setResponsiveDimensionsRef.current = setResponsiveDimensions;\n  }, [setResponsiveDimensions]);\n\n  // Keep ref in sync with state for use in event handlers\n  useEffect(() => {\n    localDimensionsRef.current = localDimensions;\n  }, [localDimensions]);\n\n  // Sync local dimensions with prop when not resizing\n  useEffect(() => {\n    if (!isResizingRef.current) {\n      setLocalDimensions(responsiveDimensions);\n    }\n  }, [responsiveDimensions]);\n\n  // Calculate scale for mobile preview to fit container\n  const [mobileScale, setMobileScale] = useState(1);\n\n  useLayoutEffect(() => {\n    if (screenSize !== 'mobile' || !containerRef.current) {\n      setMobileScale(1);\n      return;\n    }\n\n    const updateScale = () => {\n      const container = containerRef.current;\n      if (!container) return;\n\n      // Get available space (subtract padding from p-double which is typically 32px total)\n      const availableWidth = container.clientWidth - 32;\n      const availableHeight = container.clientHeight - 32;\n\n      // Total phone frame dimensions including padding\n      const totalFrameWidth = MOBILE_WIDTH + PHONE_FRAME_PADDING;\n      const totalFrameHeight = MOBILE_HEIGHT + PHONE_FRAME_PADDING;\n\n      // Calculate scale needed to fit\n      const scaleX = availableWidth / totalFrameWidth;\n      const scaleY = availableHeight / totalFrameHeight;\n      const scale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down\n\n      setMobileScale(scale);\n    };\n\n    updateScale();\n\n    // Observe container size changes\n    const resizeObserver = new ResizeObserver(updateScale);\n    resizeObserver.observe(containerRef.current);\n\n    return () => resizeObserver.disconnect();\n  }, [screenSize]);\n\n  // Handle resize events - register listeners once on mount\n  useEffect(() => {\n    const handleMove = (clientX: number, clientY: number) => {\n      if (\n        !isResizingRef.current ||\n        !startPosRef.current ||\n        !startDimensionsRef.current\n      )\n        return;\n\n      const direction = resizeDirectionRef.current;\n      const deltaX = clientX - startPosRef.current.x;\n      const deltaY = clientY - startPosRef.current.y;\n\n      setLocalDimensions(() => {\n        let newWidth = startDimensionsRef.current!.width;\n        let newHeight = startDimensionsRef.current!.height;\n\n        if (direction === 'right' || direction === 'corner') {\n          // Double delta to compensate for centered element (grows on both sides)\n          newWidth = Math.max(\n            MIN_RESPONSIVE_WIDTH,\n            startDimensionsRef.current!.width + deltaX * 2\n          );\n        }\n\n        if (direction === 'bottom' || direction === 'corner') {\n          // Double delta to compensate for centered element (grows on both sides)\n          newHeight = Math.max(\n            MIN_RESPONSIVE_HEIGHT,\n            startDimensionsRef.current!.height + deltaY * 2\n          );\n        }\n\n        return { width: newWidth, height: newHeight };\n      });\n    };\n\n    const handleMouseMove = (e: MouseEvent) => {\n      handleMove(e.clientX, e.clientY);\n    };\n\n    const handleTouchMove = (e: TouchEvent) => {\n      const touch = e.touches[0];\n      handleMove(touch.clientX, touch.clientY);\n    };\n\n    const handleEnd = () => {\n      if (isResizingRef.current) {\n        isResizingRef.current = false;\n        resizeDirectionRef.current = null;\n        startPosRef.current = null;\n        startDimensionsRef.current = null;\n        setIsResizing(false);\n        setResponsiveDimensionsRef.current(localDimensionsRef.current);\n      }\n    };\n\n    document.addEventListener('mousemove', handleMouseMove);\n    document.addEventListener('mouseup', handleEnd);\n    document.addEventListener('touchmove', handleTouchMove);\n    document.addEventListener('touchend', handleEnd);\n\n    return () => {\n      document.removeEventListener('mousemove', handleMouseMove);\n      document.removeEventListener('mouseup', handleEnd);\n      document.removeEventListener('touchmove', handleTouchMove);\n      document.removeEventListener('touchend', handleEnd);\n    };\n  }, []); // Empty deps - mount only, uses refs for all external values\n\n  const handleResizeStart = useCallback(\n    (direction: 'right' | 'bottom' | 'corner') =>\n      (e: React.MouseEvent | React.TouchEvent) => {\n        e.preventDefault();\n        isResizingRef.current = true;\n        resizeDirectionRef.current = direction;\n        setIsResizing(true);\n\n        // Capture starting position and dimensions for delta-based resizing\n        const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;\n        const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;\n        startPosRef.current = { x: clientX, y: clientY };\n        startDimensionsRef.current = { ...localDimensionsRef.current };\n      },\n    []\n  );\n\n  // ─── URL Bar Handlers ─────────────────────────────────────────────────────\n  // handleUrlSubmit flow:\n  //   1. Empty input → clear override, blur\n  //   2. Invalid URL → reject (stay focused so user can fix)\n  //   3. Same URL as current → noop, blur\n  //   4. New URL → resetNavigation() to force sync effect to fire when iframe\n  //      reports new URL\n  //   5. Same port as current → bridge goto (postMessage to iframe, SPA navigation)\n  //   6. Different port → set override URL (full iframe src change)\n  //\n  // WHY resetNavigation is needed: after blur + navigateTo, no React state changes\n  // occur, so the sync effect wouldn't fire without it. resetNavigation nullifies\n  // navigation.url → sync effect dependency changes → effect will fire when iframe\n  // reports new URL.\n  const handleUrlInputChange = useCallback((value: string) => {\n    setUrlInputValue(value);\n  }, []);\n\n  const handleUrlSubmit = useCallback(() => {\n    const trimmed = urlInputValue.trim();\n    if (!trimmed) {\n      clearOverride();\n      urlInputRef.current?.blur();\n      return;\n    }\n\n    const baseUrl = currentPreviewUrl ?? urlInfo?.url ?? undefined;\n    const normalizedInput = normalizePreviewUrl(trimmed, baseUrl);\n    if (!normalizedInput) {\n      return;\n    }\n\n    urlInputRef.current?.blur();\n    const normalizedCurrentUrl = currentPreviewUrl\n      ? normalizePreviewUrl(currentPreviewUrl, urlInfo?.url ?? undefined)\n      : null;\n    if (normalizedCurrentUrl && normalizedInput === normalizedCurrentUrl) {\n      if (hasOverride) {\n        clearOverride();\n      }\n      return;\n    }\n\n    resetNavigation();\n\n    if (showIframe && iframeRef.current?.contentWindow && previewProxyPort) {\n      try {\n        const parsed = new URL(normalizedInput);\n        const devPort =\n          parsed.port || (parsed.protocol === 'https:' ? '443' : '80');\n\n        const currentUrl = currentPreviewUrl\n          ? parsePreviewUrl(currentPreviewUrl, urlInfo?.url ?? undefined)\n          : null;\n        const currentPort =\n          currentUrl?.port ||\n          (currentUrl?.protocol === 'https:' ? '443' : '80');\n\n        if (currentPort != null && devPort === currentPort) {\n          const proxyPath = parsed.pathname + parsed.search + parsed.hash;\n          const proxyUrl = `http://${devPort}.localhost:${previewProxyPort}${proxyPath}`;\n          bridgeRef.current?.navigateTo(proxyUrl);\n          return;\n        }\n      } catch {\n        // fall through to iframe src change\n      }\n    }\n\n    setOverrideUrl(normalizedInput);\n    setImmediateLoad(true);\n  }, [\n    urlInputValue,\n    urlInfo?.url,\n    currentPreviewUrl,\n    hasOverride,\n    showIframe,\n    previewProxyPort,\n    clearOverride,\n    resetNavigation,\n    setOverrideUrl,\n  ]);\n\n  // handleUrlEscape: reverts URL bar to the current page URL and blurs,\n  // discarding whatever the user typed.\n  const handleUrlEscape = useCallback(() => {\n    setUrlInputValue(navigationDevUrl ?? effectiveUrl ?? '');\n    urlInputRef.current?.blur();\n  }, [navigationDevUrl, effectiveUrl]);\n\n  const handleStart = useCallback(() => {\n    start();\n  }, [start]);\n\n  const handleStop = useCallback(() => {\n    stop();\n  }, [stop]);\n\n  const handleRefresh = useCallback(() => {\n    const canUseBridgeRefresh = Boolean(\n      showIframe &&\n        isReady &&\n        iframeRef.current?.contentWindow &&\n        bridgeRef.current\n    );\n\n    if (canUseBridgeRefresh) {\n      bridgeRef.current?.refresh();\n      return;\n    }\n    setImmediateLoad(true);\n    triggerPreviewRefresh();\n  }, [triggerPreviewRefresh, showIframe, isReady]);\n\n  const handleClearOverride = useCallback(async () => {\n    await clearOverride();\n    setUrlInputValue('');\n  }, [clearOverride]);\n\n  const handleNavigateBack = useCallback(() => {\n    bridgeRef.current?.navigateBack();\n  }, []);\n\n  const handleNavigateForward = useCallback(() => {\n    bridgeRef.current?.navigateForward();\n  }, []);\n\n  const sendErudaCommand = useCallback((visible: boolean) => {\n    const iframe = iframeRef.current;\n    if (!iframe?.contentWindow) return;\n\n    iframe.contentWindow.postMessage(\n      {\n        source: 'vibe-kanban',\n        command: visible ? 'show-eruda' : 'hide-eruda',\n      },\n      '*'\n    );\n  }, []);\n\n  const handleToggleEruda = useCallback(() => {\n    const newState = !isErudaVisible;\n    setIsErudaVisible(newState);\n    sendErudaCommand(newState);\n  }, [isErudaVisible, sendErudaCommand]);\n\n  // Re-send the current Eruda state when the iframe devtools bridge becomes ready.\n  useEffect(() => {\n    if (!isReady) return;\n    sendErudaCommand(isErudaVisible);\n  }, [isReady, isErudaVisible, sendErudaCommand]);\n\n  const handleIframeLoad = useCallback(() => {\n    // Initial postMessage can race with injected script startup on fresh loads.\n    window.setTimeout(() => {\n      sendErudaCommand(isErudaVisible);\n    }, 150);\n  }, [isErudaVisible, sendErudaCommand]);\n\n  const handleCopyUrl = useCallback(async () => {\n    if (!currentPreviewUrl) return;\n\n    const normalizedUrl = normalizePreviewUrl(\n      currentPreviewUrl,\n      urlInfo?.url ?? undefined\n    );\n    if (normalizedUrl) {\n      await navigator.clipboard.writeText(normalizedUrl);\n    }\n  }, [currentPreviewUrl, urlInfo?.url]);\n\n  const handleOpenInNewTab = useCallback(() => {\n    if (!currentPreviewUrl) return;\n\n    const normalizedUrl = normalizePreviewUrl(\n      currentPreviewUrl,\n      urlInfo?.url ?? undefined\n    );\n    if (normalizedUrl) {\n      window.open(normalizedUrl, '_blank');\n    }\n  }, [currentPreviewUrl, urlInfo?.url]);\n\n  const handleScreenSizeChange = useCallback(\n    (size: ScreenSize) => {\n      setScreenSize(size);\n    },\n    [setScreenSize]\n  );\n\n  // ─── Iframe URL Construction ────────────────────────────────────────────────\n  // Builds the subdomain-based proxy URL loaded by the iframe.\n  //   Dev server at localhost:4000 → iframe loads http://4000.localhost:{proxyPort}/path\n  //   The proxy extracts the target port from the subdomain and forwards to the dev server.\n  //   _refresh query param forces iframe reload on refresh button click.\n  // Construct proxy URL for iframe to enable security isolation via separate origin\n  // Uses subdomain-based routing: http://{devPort}.localhost:{proxyPort}{path}\n  const iframeUrl = useMemo(() => {\n    if (!effectiveUrl || !previewProxyPort) return undefined;\n\n    const parsed = parsePreviewUrl(effectiveUrl, urlInfo?.url ?? undefined);\n    if (!parsed) return undefined;\n\n    try {\n      const devServerPort =\n        parsed.port || (parsed.protocol === 'https:' ? '443' : '80');\n\n      // Don't proxy to Vibe Kanban's own ports (would create infinite loop)\n      const vibeKanbanPort = window.location.port || '80';\n      if (devServerPort === vibeKanbanPort) {\n        console.warn(\n          `[Preview] Ignoring dev server URL with same port as Vibe Kanban (${devServerPort}). ` +\n            'This usually means the dev server failed to start or reported the wrong port.'\n        );\n        return undefined;\n      }\n\n      // Also check if it's the preview proxy port itself\n      if (devServerPort === String(previewProxyPort)) {\n        console.warn(\n          `[Preview] Ignoring dev server URL with same port as preview proxy (${devServerPort}).`\n        );\n        return undefined;\n      }\n\n      // Warn if not on localhost (subdomain routing requires localhost)\n      if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {\n        console.warn(\n          '[Preview] Preview proxy subdomain routing may not work on non-localhost hostname'\n        );\n      }\n\n      const path = parsed.pathname + parsed.search;\n\n      // Subdomain-based routing: the proxy extracts the port from the Host header\n      const proxyUrl = new URL(\n        `http://${devServerPort}.localhost:${previewProxyPort}${path}`\n      );\n      proxyUrl.searchParams.set('_refresh', String(previewRefreshKey));\n\n      return proxyUrl.toString();\n    } catch {\n      return undefined;\n    }\n  }, [effectiveUrl, previewProxyPort, previewRefreshKey, urlInfo?.url]);\n\n  // ─── Navigation Reset on URL Change ────────────────────────────────────────\n  // Resets navigation state when the iframe URL changes (e.g., new dev server\n  // detected, user switched override). Prevents stale navigation data from the\n  // previous page.\n\n  const prevIframeUrlRef = useRef(iframeUrl);\n  useEffect(() => {\n    if (prevIframeUrlRef.current !== iframeUrl) {\n      prevIframeUrlRef.current = iframeUrl;\n      resetNavigation();\n    }\n  }, [iframeUrl, resetNavigation]);\n\n  // NOTE: handleEditDevScript and handleFixDevScript have identical bodies.\n  // This duplication is intentional — they may diverge in the future to support\n  // different dialog configurations (e.g., edit vs. auto-fix modes).\n  const handleEditDevScript = useCallback(() => {\n    const targetWorkspaceId = activeWorkspaceId ?? workspaceId;\n    if (!targetWorkspaceId || repos.length === 0) return;\n\n    const sessionId = devServerProcesses[0]?.session_id;\n\n    ScriptFixerDialog.show({\n      scriptType: 'dev_server',\n      repos,\n      workspaceId: targetWorkspaceId,\n      sessionId,\n      initialRepoId: repos.length === 1 ? repos[0].id : undefined,\n    });\n  }, [activeWorkspaceId, workspaceId, repos, devServerProcesses]);\n\n  const handleFixDevScript = useCallback(() => {\n    const targetWorkspaceId = activeWorkspaceId ?? workspaceId;\n    if (!targetWorkspaceId || repos.length === 0) return;\n\n    // Get session ID from the latest dev server process\n    const sessionId = devServerProcesses[0]?.session_id;\n\n    ScriptFixerDialog.show({\n      scriptType: 'dev_server',\n      repos,\n      workspaceId: targetWorkspaceId,\n      sessionId,\n      initialRepoId: repos.length === 1 ? repos[0].id : undefined,\n    });\n  }, [activeWorkspaceId, workspaceId, repos, devServerProcesses]);\n\n  return (\n    <PreviewBrowser\n      url={iframeUrl}\n      autoDetectedUrl={urlInfo?.url}\n      urlInputValue={urlInputValue}\n      urlInputRef={urlInputRef}\n      isUsingOverride={hasOverride}\n      onUrlInputChange={handleUrlInputChange}\n      onUrlSubmit={handleUrlSubmit}\n      onUrlEscape={handleUrlEscape}\n      onClearOverride={handleClearOverride}\n      onCopyUrl={handleCopyUrl}\n      onOpenInNewTab={handleOpenInNewTab}\n      onRefresh={handleRefresh}\n      onStart={handleStart}\n      onStop={handleStop}\n      isStarting={isStarting}\n      isStopping={isStopping}\n      isServerRunning={runningDevServers.length > 0}\n      showIframe={showIframe}\n      allowManualUrl={allowManualUrl}\n      screenSize={screenSize}\n      localDimensions={localDimensions}\n      onScreenSizeChange={handleScreenSizeChange}\n      onResizeStart={handleResizeStart}\n      isResizing={isResizing}\n      containerRef={containerRef}\n      repos={repos}\n      handleEditDevScript={handleEditDevScript}\n      handleFixDevScript={\n        workspaceId && repos.length > 0 ? handleFixDevScript : undefined\n      }\n      hasFailedDevServer={hasFailedDevServer}\n      mobileScale={mobileScale}\n      className={className}\n      iframeRef={iframeRef}\n      navigation={navigation}\n      onNavigateBack={handleNavigateBack}\n      onNavigateForward={handleNavigateForward}\n      isInspectMode={isInspectMode}\n      onToggleInspectMode={toggleInspectMode}\n      isErudaVisible={isErudaVisible}\n      onToggleEruda={handleToggleEruda}\n      onIframeLoad={handleIframeLoad}\n      isMobile={isMobile}\n      mobileUrlExpanded={mobileUrlExpanded}\n      onMobileUrlExpandedChange={setMobileUrlExpanded}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/PreviewControlsContainer.tsx",
    "content": "import { useCallback, useMemo, useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { PreviewControls } from '@vibe/ui/components/PreviewControls';\nimport { usePreviewDevServer } from '@/features/workspace/model/hooks/usePreviewDevServer';\nimport { useLogStream } from '@/shared/hooks/useLogStream';\nimport {\n  useUiPreferencesStore,\n  RIGHT_MAIN_PANEL_MODES,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useLogsPanel } from '@/shared/hooks/useLogsPanel';\nimport { VirtualizedProcessLogs } from '@/shared/components/VirtualizedProcessLogs';\nimport { getDevServerWorkingDir } from '@/shared/lib/devServerUtils';\n\ninterface PreviewControlsContainerProps {\n  workspaceId: string;\n  className: string;\n}\n\nexport function PreviewControlsContainer({\n  workspaceId,\n  className,\n}: PreviewControlsContainerProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n  const { repos } = useWorkspaceContext();\n  const { viewProcessInPanel } = useLogsPanel();\n  const setRightMainPanelMode = useUiPreferencesStore(\n    (s) => s.setRightMainPanelMode\n  );\n\n  const { isStarting, runningDevServers, devServerProcesses } =\n    usePreviewDevServer(workspaceId);\n\n  const [activeProcessId, setActiveProcessId] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (devServerProcesses.length > 0 && !activeProcessId) {\n      setActiveProcessId(devServerProcesses[0].id);\n    }\n  }, [devServerProcesses, activeProcessId]);\n\n  const activeProcess =\n    devServerProcesses.find((p) => p.id === activeProcessId) ??\n    devServerProcesses[0];\n\n  const processTabs = useMemo(\n    () =>\n      devServerProcesses.map((process) => ({\n        id: process.id,\n        label:\n          getDevServerWorkingDir(process) ??\n          t('preview.browser.devServerFallback'),\n      })),\n    [devServerProcesses, t]\n  );\n\n  const { logs, error: logsError } = useLogStream(activeProcess?.id ?? '');\n\n  const handleViewFullLogs = useCallback(() => {\n    const targetId = activeProcess?.id;\n    if (targetId) {\n      viewProcessInPanel(targetId);\n    } else {\n      setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS);\n    }\n  }, [activeProcess?.id, viewProcessInPanel, setRightMainPanelMode]);\n\n  const handleTabChange = useCallback((processId: string) => {\n    setActiveProcessId(processId);\n  }, []);\n\n  const hasDevScript = repos.some(\n    (repo) => repo.dev_server_script && repo.dev_server_script.trim() !== ''\n  );\n\n  // Don't render if no repos have dev server scripts configured\n  if (!hasDevScript) {\n    return null;\n  }\n\n  return (\n    <PreviewControls\n      processTabs={processTabs}\n      activeProcessId={activeProcess?.id ?? null}\n      logsContent={\n        <VirtualizedProcessLogs\n          logs={logs}\n          error={logsError}\n          searchQuery=\"\"\n          matchIndices={[]}\n          currentMatchIndex={-1}\n        />\n      }\n      onViewFullLogs={handleViewFullLogs}\n      onTabChange={handleTabChange}\n      isLoading={isStarting || runningDevServers.length > 0}\n      className={className}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/ProcessListContainer.tsx",
    "content": "import { useEffect, useMemo, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useExecutionProcessesContext } from '@/shared/hooks/useExecutionProcessesContext';\nimport { useLogsPanel } from '@/shared/hooks/useLogsPanel';\nimport { ProcessListItem } from '@vibe/ui/components/ProcessListItem';\nimport { InputField } from '@vibe/ui/components/InputField';\nimport {\n  CaretUpIcon,\n  CaretDownIcon,\n  TerminalIcon,\n} from '@phosphor-icons/react';\nimport { cn } from '@/shared/lib/utils';\n\nexport function ProcessListContainer() {\n  const {\n    logsPanelContent,\n    logSearchQuery: searchQuery,\n    logMatchIndices,\n    logCurrentMatchIdx: currentMatchIdx,\n    setLogSearchQuery: onSearchQueryChange,\n    handleLogPrevMatch: onPrevMatch,\n    handleLogNextMatch: onNextMatch,\n    viewProcessInPanel: onSelectProcess,\n    expandTerminal,\n    isTerminalExpanded,\n  } = useLogsPanel();\n\n  const selectedProcessId =\n    logsPanelContent?.type === 'process' ? logsPanelContent.processId : null;\n  const disableAutoSelect =\n    logsPanelContent?.type === 'tool' || logsPanelContent?.type === 'terminal';\n  const matchCount = logMatchIndices.length;\n  const { t } = useTranslation('common');\n  const { executionProcessesVisible } = useExecutionProcessesContext();\n\n  // Sort processes by created_at descending (newest first)\n  const sortedProcesses = useMemo(() => {\n    return [...executionProcessesVisible].sort((a, b) => {\n      return (\n        new Date(b.created_at).getTime() - new Date(a.created_at).getTime()\n      );\n    });\n  }, [executionProcessesVisible]);\n\n  // Auto-select latest process if none selected (unless disabled)\n  useEffect(() => {\n    if (\n      !disableAutoSelect &&\n      !selectedProcessId &&\n      sortedProcesses.length > 0\n    ) {\n      onSelectProcess(sortedProcesses[0].id);\n    }\n  }, [disableAutoSelect, selectedProcessId, sortedProcesses, onSelectProcess]);\n\n  const handleSelectProcess = useCallback(\n    (processId: string) => {\n      onSelectProcess(processId);\n    },\n    [onSelectProcess]\n  );\n\n  const handleSearchKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (e.key === 'Enter') {\n        if (e.shiftKey) {\n          onPrevMatch?.();\n        } else {\n          onNextMatch?.();\n        }\n      } else if (e.key === 'Escape') {\n        onSearchQueryChange?.('');\n      }\n    },\n    [onPrevMatch, onNextMatch, onSearchQueryChange]\n  );\n\n  const showSearch = onSearchQueryChange !== undefined;\n\n  const searchBar = showSearch && (\n    <div\n      className=\"flex items-center gap-2 shrink-0 mx-base mb-base\"\n      onKeyDown={handleSearchKeyDown}\n    >\n      <InputField\n        value={searchQuery}\n        onChange={onSearchQueryChange}\n        placeholder={t('logs.searchLogs')}\n        variant=\"search\"\n        className=\"flex-1\"\n      />\n      {searchQuery && (\n        <>\n          <span className=\"text-xs text-low whitespace-nowrap\">\n            {matchCount > 0\n              ? t('search.matchCount', {\n                  current: currentMatchIdx + 1,\n                  total: matchCount,\n                })\n              : t('search.noMatches')}\n          </span>\n          <div className=\"flex items-center gap-1\">\n            <button\n              onClick={onPrevMatch}\n              disabled={matchCount === 0}\n              className=\"p-1 text-low hover:text-normal disabled:opacity-50 disabled:cursor-not-allowed\"\n              title=\"Previous match (Shift+Enter)\"\n            >\n              <CaretUpIcon className=\"size-icon-sm\" weight=\"bold\" />\n            </button>\n            <button\n              onClick={onNextMatch}\n              disabled={matchCount === 0}\n              className=\"p-1 text-low hover:text-normal disabled:opacity-50 disabled:cursor-not-allowed\"\n              title=\"Next match (Enter)\"\n            >\n              <CaretDownIcon className=\"size-icon-sm\" weight=\"bold\" />\n            </button>\n          </div>\n        </>\n      )}\n    </div>\n  );\n\n  const terminalItem = (\n    <button\n      type=\"button\"\n      onClick={expandTerminal}\n      className={cn(\n        'w-full h-[26px] flex items-center gap-half px-half rounded-sm text-left transition-colors'\n      )}\n    >\n      <TerminalIcon\n        className=\"size-icon-sm flex-shrink-0 text-low\"\n        weight=\"regular\"\n      />\n      <span\n        className={cn(\n          'text-sm truncate flex-1',\n          isTerminalExpanded ? 'text-high' : 'text-normal'\n        )}\n      >\n        {t('processes.terminal')}\n      </span>\n    </button>\n  );\n\n  return (\n    <div className=\"flex flex-col flex-1 w-full bg-secondary\">\n      <div className=\"flex-1 overflow-y-auto pt-half px-base\">\n        {terminalItem}\n        {sortedProcesses.map((process) => (\n          <ProcessListItem\n            key={process.id}\n            runReason={process.run_reason}\n            status={process.status}\n            startedAt={process.started_at}\n            selected={process.id === selectedProcessId}\n            onClick={() => handleSelectProcess(process.id)}\n          />\n        ))}\n      </div>\n      {sortedProcesses.length === 0 && !isTerminalExpanded && (\n        <div className=\"flex-1 flex items-center justify-center text-low\">\n          <p className=\"text-sm\">{t('processes.noProcesses')}</p>\n        </div>\n      )}\n      <div>{searchBar}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/ReviewCommentRenderer.tsx",
    "content": "import { useState, memo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { CommentCard } from '@vibe/ui/components/CommentCard';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { useReview, type ReviewComment } from '@/shared/hooks/useReview';\n\ninterface ReviewCommentRendererProps {\n  comment: ReviewComment;\n}\n\nexport const ReviewCommentRenderer = memo(function ReviewCommentRenderer({\n  comment,\n}: ReviewCommentRendererProps) {\n  const { t } = useTranslation('common');\n  const { deleteComment, updateComment } = useReview();\n  const [isEditing, setIsEditing] = useState(false);\n  const [editText, setEditText] = useState(comment.text);\n\n  const handleDelete = () => {\n    deleteComment(comment.id);\n  };\n\n  const handleEdit = () => {\n    setEditText(comment.text);\n    setIsEditing(true);\n  };\n\n  const handleSave = () => {\n    if (editText.trim()) {\n      updateComment(comment.id, editText.trim());\n    }\n    setIsEditing(false);\n  };\n\n  const handleCancel = () => {\n    setEditText(comment.text);\n    setIsEditing(false);\n  };\n\n  if (isEditing) {\n    return (\n      <CommentCard\n        variant=\"user\"\n        actions={\n          <>\n            <PrimaryButton\n              variant=\"default\"\n              onClick={handleSave}\n              disabled={!editText.trim()}\n            >\n              {t('actions.saveChanges')}\n            </PrimaryButton>\n            <PrimaryButton variant=\"tertiary\" onClick={handleCancel}>\n              {t('actions.cancel')}\n            </PrimaryButton>\n          </>\n        }\n      >\n        <WYSIWYGEditor\n          value={editText}\n          onChange={setEditText}\n          placeholder={t('comments.editPlaceholder')}\n          className=\"w-full text-sm text-normal min-h-[60px]\"\n          onCmdEnter={handleSave}\n          autoFocus\n        />\n      </CommentCard>\n    );\n  }\n\n  return (\n    <CommentCard variant=\"user\">\n      <WYSIWYGEditor\n        value={comment.text}\n        disabled={true}\n        className=\"text-sm\"\n        onEdit={handleEdit}\n        onDelete={handleDelete}\n      />\n    </CommentCard>\n  );\n});\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/RightSidebar.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { FileTreeContainer } from './FileTreeContainer';\nimport { ProcessListContainer } from './ProcessListContainer';\nimport { PreviewControlsContainer } from './PreviewControlsContainer';\nimport { GitPanelContainer } from './GitPanelContainer';\nimport { TerminalPanelContainer } from '@/shared/components/TerminalPanelContainer';\nimport { WorkspaceNotesContainer } from './WorkspaceNotesContainer';\nimport { useChangesView } from '@/shared/hooks/useChangesView';\nimport { useWorkspaceDiffContext } from '@/shared/hooks/useWorkspaceContext';\nimport { ArrowsOutSimpleIcon } from '@phosphor-icons/react';\nimport { useLogsPanel } from '@/shared/hooks/useLogsPanel';\nimport type { RepoWithTargetBranch, Workspace } from 'shared/types';\nimport {\n  PERSIST_KEYS,\n  PersistKey,\n  RIGHT_MAIN_PANEL_MODES,\n  type RightMainPanelMode,\n  useExpandedAll,\n  usePersistedExpanded,\n  useUiPreferencesStore,\n} from '@/shared/stores/useUiPreferencesStore';\nimport {\n  CollapsibleSectionHeader,\n  type SectionAction,\n} from '@vibe/ui/components/CollapsibleSectionHeader';\n\ntype SectionDef = {\n  title: string;\n  persistKey: PersistKey;\n  visible: boolean;\n  expanded: boolean;\n  content: React.ReactNode;\n  actions: SectionAction[];\n};\n\nexport interface RightSidebarProps {\n  rightMainPanelMode: RightMainPanelMode | null;\n  selectedWorkspace: Workspace | undefined;\n  repos: RepoWithTargetBranch[];\n}\n\nexport function RightSidebar({\n  rightMainPanelMode,\n  selectedWorkspace,\n  repos,\n}: RightSidebarProps) {\n  const { t } = useTranslation(['tasks', 'common']);\n  const { selectFile } = useChangesView();\n  const { diffs } = useWorkspaceDiffContext();\n  const { setExpanded } = useExpandedAll();\n  const isTerminalVisible = useUiPreferencesStore((s) => s.isTerminalVisible);\n  const { expandTerminal, isTerminalExpanded } = useLogsPanel();\n\n  const [changesExpanded] = usePersistedExpanded(\n    PERSIST_KEYS.changesSection,\n    true\n  );\n  const [processesExpanded] = usePersistedExpanded(\n    PERSIST_KEYS.processesSection,\n    true\n  );\n  const [devServerExpanded] = usePersistedExpanded(\n    PERSIST_KEYS.devServerSection,\n    true\n  );\n  const [gitExpanded] = usePersistedExpanded(\n    PERSIST_KEYS.gitPanelRepositories,\n    true\n  );\n  const [terminalExpanded] = usePersistedExpanded(\n    PERSIST_KEYS.terminalSection,\n    false\n  );\n  const [notesExpanded] = usePersistedExpanded(\n    PERSIST_KEYS.notesSection,\n    false\n  );\n\n  const hasUpperContent =\n    rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES ||\n    rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS ||\n    rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW;\n\n  const getUpperExpanded = () => {\n    if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES)\n      return changesExpanded;\n    if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS)\n      return processesExpanded;\n    if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW)\n      return devServerExpanded;\n    return false;\n  };\n\n  const upperExpanded = getUpperExpanded();\n\n  const sections: SectionDef[] = buildWorkspaceSections();\n\n  function buildWorkspaceSections(): SectionDef[] {\n    const result: SectionDef[] = [\n      {\n        title: 'Git',\n        persistKey: PERSIST_KEYS.gitPanelRepositories,\n        visible: true,\n        expanded: gitExpanded,\n        content: (\n          <GitPanelContainer\n            selectedWorkspace={selectedWorkspace}\n            repos={repos}\n          />\n        ),\n        actions: [],\n      },\n      {\n        title: 'Terminal',\n        persistKey: PERSIST_KEYS.terminalSection,\n        visible: isTerminalVisible && !isTerminalExpanded,\n        expanded: terminalExpanded,\n        content: <TerminalPanelContainer />,\n        actions: [{ icon: ArrowsOutSimpleIcon, onClick: expandTerminal }],\n      },\n      {\n        title: t('common:sections.notes'),\n        persistKey: PERSIST_KEYS.notesSection,\n        visible: true,\n        expanded: notesExpanded,\n        content: <WorkspaceNotesContainer />,\n        actions: [],\n      },\n    ];\n\n    switch (rightMainPanelMode) {\n      case RIGHT_MAIN_PANEL_MODES.CHANGES:\n        if (selectedWorkspace) {\n          result.unshift({\n            title: 'Changes',\n            persistKey: PERSIST_KEYS.changesSection,\n            visible: hasUpperContent,\n            expanded: upperExpanded,\n            content: (\n              <FileTreeContainer\n                key={selectedWorkspace.id}\n                workspaceId={selectedWorkspace.id}\n                diffs={diffs}\n                onSelectFile={(path) => {\n                  selectFile(path);\n                  setExpanded(`diff:${path}`, true);\n                }}\n                className=\"\"\n              />\n            ),\n            actions: [],\n          });\n        }\n        break;\n      case RIGHT_MAIN_PANEL_MODES.LOGS:\n        result.unshift({\n          title: 'Logs',\n          persistKey: PERSIST_KEYS.rightPanelprocesses,\n          visible: hasUpperContent,\n          expanded: upperExpanded,\n          content: <ProcessListContainer />,\n          actions: [],\n        });\n        break;\n      case RIGHT_MAIN_PANEL_MODES.PREVIEW:\n        if (selectedWorkspace) {\n          result.unshift({\n            title: 'Preview',\n            persistKey: PERSIST_KEYS.rightPanelPreview,\n            visible: hasUpperContent,\n            expanded: upperExpanded,\n            content: (\n              <PreviewControlsContainer\n                workspaceId={selectedWorkspace.id}\n                className=\"\"\n              />\n            ),\n            actions: [],\n          });\n        }\n        break;\n      case null:\n        break;\n    }\n\n    return result;\n  }\n\n  return (\n    <div className=\"h-full border-l bg-secondary overflow-y-auto\">\n      <div className=\"divide-y border-b\">\n        {sections\n          .filter((section) => section.visible)\n          .map((section) => (\n            <div\n              key={section.persistKey}\n              className=\"max-h-[max(50vh,400px)] flex flex-col overflow-hidden\"\n            >\n              <CollapsibleSectionHeader\n                title={section.title}\n                persistKey={section.persistKey}\n                defaultExpanded={section.expanded}\n                actions={section.actions}\n              >\n                <div className=\"flex flex-1 border-t min-h-[200px] w-full overflow-auto\">\n                  {section.content}\n                </div>\n              </CollapsibleSectionHeader>\n            </div>\n          ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/VSCodeWorkspacePage.tsx",
    "content": "// VS Code webview integration - install keyboard/clipboard bridge\nimport '@/integrations/vscode/bridge';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport type { Session } from 'shared/types';\nimport { useTranslation } from 'react-i18next';\nimport { AppWithStyleOverride } from '@/shared/lib/StyleOverride';\nimport { useStyleOverrideThemeSetter } from '@/shared/lib/StyleOverride';\nimport { WebviewContextMenu } from '@/integrations/vscode/ContextMenu';\nimport { ArrowDownIcon } from '@phosphor-icons/react';\nimport {\n  useWorkspaceContext,\n  useWorkspaceDiffContext,\n} from '@/shared/hooks/useWorkspaceContext';\nimport { usePageTitle } from '@/shared/hooks/usePageTitle';\nimport { SessionChatBoxContainer } from '@/features/workspace-chat/ui/SessionChatBoxContainer';\nimport {\n  ConversationList,\n  type ConversationListHandle,\n} from '@/features/workspace-chat/ui/ConversationListContainer';\nimport { EntriesProvider } from '@/features/workspace-chat/model/contexts/EntriesContext';\nimport { MessageEditProvider } from '@/features/workspace-chat/model/contexts/MessageEditContext';\nimport { RetryUiProvider } from '@/features/workspace-chat/model/contexts/RetryUiContext';\nimport { ApprovalFeedbackProvider } from '@/features/workspace-chat/model/contexts/ApprovalFeedbackContext';\nimport { createWorkspaceWithSession } from '@/shared/types/attempt';\n\nfunction VSCodeChatBox({\n  session,\n  workspaceId,\n  isNewSessionMode,\n  sessions,\n  onSelectSession,\n  onStartNewSession,\n  onScrollToPreviousMessage,\n  onScrollToBottom,\n}: {\n  session: Session | undefined;\n  workspaceId: string | undefined;\n  isNewSessionMode: boolean;\n  sessions: Session[];\n  onSelectSession: (sessionId: string) => void;\n  onStartNewSession: () => void;\n  onScrollToPreviousMessage: () => void;\n  onScrollToBottom: (behavior?: 'auto' | 'smooth') => void;\n}) {\n  const { diffStats } = useWorkspaceDiffContext();\n\n  return (\n    <SessionChatBoxContainer\n      {...(isNewSessionMode && workspaceId\n        ? {\n            mode: 'new-session' as const,\n            workspaceId,\n            onSelectSession,\n          }\n        : session\n          ? {\n              mode: 'existing-session' as const,\n              session,\n              onSelectSession,\n              onStartNewSession,\n            }\n          : {\n              mode: 'placeholder' as const,\n            })}\n      sessions={sessions}\n      filesChanged={diffStats.files_changed}\n      linesAdded={diffStats.lines_added}\n      linesRemoved={diffStats.lines_removed}\n      disableViewCode\n      showOpenWorkspaceButton={false}\n      onScrollToPreviousMessage={onScrollToPreviousMessage}\n      onScrollToBottom={onScrollToBottom}\n    />\n  );\n}\n\nexport function VSCodeWorkspacePage() {\n  const { t } = useTranslation('common');\n  const setTheme = useStyleOverrideThemeSetter();\n  const mainContainerRef = useRef<HTMLElement>(null);\n  const conversationListRef = useRef<ConversationListHandle>(null);\n  const [isAtBottom, setIsAtBottom] = useState(true);\n  const isAtBottomRef = useRef(isAtBottom);\n\n  const {\n    workspace,\n    sessions,\n    selectedSession,\n    selectedSessionId,\n    selectSession,\n    isLoading,\n    isNewSessionMode,\n    startNewSession,\n    repos,\n  } = useWorkspaceContext();\n\n  usePageTitle(workspace?.name);\n\n  const workspaceWithSession = workspace\n    ? createWorkspaceWithSession(workspace, selectedSession)\n    : undefined;\n\n  const handleScrollToPreviousMessage = () => {\n    conversationListRef.current?.scrollToPreviousUserMessage();\n  };\n\n  const handleScrollToBottom = useCallback(\n    (behavior: 'auto' | 'smooth' = 'smooth') => {\n      conversationListRef.current?.scrollToBottom(behavior);\n    },\n    []\n  );\n\n  const handleAtBottomChange = useCallback((atBottom: boolean) => {\n    isAtBottomRef.current = atBottom;\n    setIsAtBottom(atBottom);\n  }, []);\n\n  useEffect(() => {\n    isAtBottomRef.current = isAtBottom;\n  }, [isAtBottom]);\n\n  useEffect(() => {\n    const container = mainContainerRef.current;\n    if (!container || typeof ResizeObserver === 'undefined') return;\n\n    const chatBoxContainer = container.querySelector<HTMLElement>(\n      '[data-chatbox-container=\"true\"]'\n    );\n    if (!chatBoxContainer) return;\n\n    let previousHeight = chatBoxContainer.getBoundingClientRect().height;\n\n    const observer = new ResizeObserver((entries) => {\n      const nextHeight =\n        entries[0]?.contentRect.height ??\n        chatBoxContainer.getBoundingClientRect().height;\n\n      if (Math.abs(nextHeight - previousHeight) < 0.5) return;\n      const heightDelta = nextHeight - previousHeight;\n      previousHeight = nextHeight;\n\n      if (!isAtBottomRef.current) return;\n\n      requestAnimationFrame(() => {\n        if (!isAtBottomRef.current) return;\n        conversationListRef.current?.adjustScrollBy(heightDelta);\n      });\n    });\n\n    observer.observe(chatBoxContainer);\n\n    return () => {\n      observer.disconnect();\n    };\n  }, [workspaceWithSession?.id, selectedSession?.id]);\n\n  return (\n    <AppWithStyleOverride setTheme={setTheme}>\n      <div className=\"h-screen flex flex-col bg-primary\">\n        <WebviewContextMenu />\n\n        <main\n          ref={mainContainerRef}\n          className=\"relative flex flex-1 flex-col h-full min-h-0\"\n        >\n          <ApprovalFeedbackProvider>\n            <EntriesProvider\n              key={\n                workspaceWithSession\n                  ? `${workspaceWithSession.id}-${selectedSessionId ?? 'new'}`\n                  : 'empty'\n              }\n            >\n              <MessageEditProvider>\n                {isLoading ? (\n                  <div className=\"flex-1 flex items-center justify-center\">\n                    <p className=\"text-low\">{t('workspaces.loading')}</p>\n                  </div>\n                ) : !workspaceWithSession ? (\n                  <div className=\"flex-1 flex items-center justify-center\">\n                    <p className=\"text-low\">{t('workspaces.notFound')}</p>\n                  </div>\n                ) : (\n                  <div className=\"flex-1 min-h-0 overflow-hidden flex justify-center\">\n                    <div className=\"w-chat max-w-full h-full\">\n                      <RetryUiProvider workspaceId={workspaceWithSession.id}>\n                        <ConversationList\n                          key={`${workspaceWithSession.id}-${selectedSessionId ?? 'new'}`}\n                          ref={conversationListRef}\n                          attempt={workspaceWithSession}\n                          repos={repos}\n                          onAtBottomChange={handleAtBottomChange}\n                          sessionScopeId={selectedSessionId}\n                        />\n                      </RetryUiProvider>\n                    </div>\n                  </div>\n                )}\n\n                {workspaceWithSession && !isAtBottom && (\n                  <div className=\"flex justify-center pointer-events-none\">\n                    <div className=\"w-chat max-w-full relative\">\n                      <button\n                        type=\"button\"\n                        onClick={() => handleScrollToBottom('auto')}\n                        className=\"absolute bottom-2 right-4 z-10 pointer-events-auto flex items-center justify-center size-8 rounded-full bg-secondary/80 backdrop-blur-sm border border-secondary text-low hover:text-normal hover:bg-secondary shadow-md transition-all\"\n                        aria-label=\"Scroll to bottom\"\n                        title=\"Scroll to bottom\"\n                      >\n                        <ArrowDownIcon\n                          className=\"size-icon-base\"\n                          weight=\"bold\"\n                        />\n                      </button>\n                    </div>\n                  </div>\n                )}\n                <div\n                  className=\"flex justify-center @container pl-px\"\n                  data-chatbox-container=\"true\"\n                >\n                  <VSCodeChatBox\n                    session={selectedSession}\n                    workspaceId={workspaceWithSession?.id}\n                    isNewSessionMode={isNewSessionMode}\n                    sessions={sessions}\n                    onSelectSession={selectSession}\n                    onStartNewSession={startNewSession}\n                    onScrollToPreviousMessage={handleScrollToPreviousMessage}\n                    onScrollToBottom={handleScrollToBottom}\n                  />\n                </div>\n              </MessageEditProvider>\n            </EntriesProvider>\n          </ApprovalFeedbackProvider>\n        </main>\n      </div>\n    </AppWithStyleOverride>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/WorkspaceNotesContainer.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useWorkspaceNotes } from '@/features/workspace/model/hooks/useWorkspaceNotes';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { SpinnerIcon } from '@phosphor-icons/react';\n\nexport function WorkspaceNotesContainer() {\n  const { t } = useTranslation('common');\n  const { workspace } = useWorkspaceContext();\n  const workspaceId = workspace?.id;\n\n  const { content, isLoading, setContent } = useWorkspaceNotes(workspaceId);\n\n  if (!workspaceId) {\n    return (\n      <div className=\"p-base text-low text-sm flex-1\">\n        {t('notes.selectWorkspace')}\n      </div>\n    );\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center flex-1 p-base\">\n        <SpinnerIcon className=\"animate-spin h-5 w-5 text-low\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"p-base flex flex-col flex-1 min-h-0 overflow-y-auto\">\n      <WYSIWYGEditor\n        placeholder={t('notes.placeholder')}\n        value={content}\n        onChange={setContent}\n        autoFocus={false}\n        className=\"min-h-[300px]\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/Workspaces.tsx",
    "content": "import { WorkspacesLayout } from './WorkspacesLayout';\n\nexport function Workspaces() {\n  return <WorkspacesLayout />;\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/WorkspacesLanding.tsx",
    "content": "import { useEffect } from 'react';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\n\nexport function WorkspacesLanding() {\n  const appNavigation = useAppNavigation();\n\n  useEffect(() => {\n    appNavigation.goToWorkspacesCreate({\n      replace: true,\n    });\n  }, [appNavigation]);\n\n  return null;\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/WorkspacesLayout.tsx",
    "content": "import {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Group, Layout, Panel, Separator } from 'react-resizable-panels';\nimport type { CreateModeInitialState } from '@/shared/types/createMode';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { usePageTitle } from '@/shared/hooks/usePageTitle';\nimport { useIsMobile } from '@/shared/hooks/useIsMobile';\nimport { useMobileActiveTab } from '@/shared/stores/useUiPreferencesStore';\nimport { cn } from '@/shared/lib/utils';\nimport { CreateModeProvider } from '@/features/create-mode/model/CreateModeProvider';\nimport {\n  consumeCreateModeSeedState,\n  getCreateModeSeedVersion,\n  subscribeCreateModeSeedState,\n} from '@/features/create-mode/model/createModeSeedStore';\nimport { ReviewProvider } from '@/shared/hooks/ReviewProvider';\nimport { ChangesViewProvider } from '@/shared/hooks/ChangesViewProvider';\nimport { WorkspacesSidebarContainer } from './WorkspacesSidebarContainer';\nimport { LogsContentContainer } from './LogsContentContainer';\nimport {\n  WorkspacesMainContainer,\n  type WorkspacesMainContainerHandle,\n} from './WorkspacesMainContainer';\nimport { RightSidebar } from './RightSidebar';\nimport { ChangesPanelContainer } from './ChangesPanelContainer';\nimport { CreateChatBoxContainer } from '@/shared/components/CreateChatBoxContainer';\nimport { PreviewBrowserContainer } from './PreviewBrowserContainer';\nimport { WorkspacesGuideDialog } from '@/shared/dialogs/shared/WorkspacesGuideDialog';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\n\nimport {\n  PERSIST_KEYS,\n  usePaneSize,\n  useWorkspacePanelState,\n  RIGHT_MAIN_PANEL_MODES,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\n\nconst WORKSPACES_GUIDE_ID = 'workspaces-guide';\n\nexport function WorkspacesLayout() {\n  const appNavigation = useAppNavigation();\n  const {\n    workspaceId,\n    workspace: selectedWorkspace,\n    isLoading,\n    isCreateMode,\n    selectedSession,\n    selectedSessionId,\n    sessions,\n    selectSession,\n    repos,\n    isNewSessionMode,\n    startNewSession,\n  } = useWorkspaceContext();\n\n  const { t } = useTranslation('common');\n  usePageTitle(\n    isCreateMode ? t('workspaces.newWorkspace') : selectedWorkspace?.name\n  );\n\n  const seedVersion = useSyncExternalStore(\n    subscribeCreateModeSeedState,\n    getCreateModeSeedVersion,\n    getCreateModeSeedVersion\n  );\n  const consumedSeedVersionRef = useRef(0);\n  const [createModeSeed, setCreateModeSeed] = useState<{\n    version: number;\n    state: CreateModeInitialState | null;\n  }>({\n    version: 0,\n    state: null,\n  });\n\n  useEffect(() => {\n    if (!isCreateMode) {\n      consumedSeedVersionRef.current = 0;\n      setCreateModeSeed((current) =>\n        current.version === 0 && current.state === null\n          ? current\n          : { version: 0, state: null }\n      );\n      return;\n    }\n\n    if (seedVersion === 0 || seedVersion === consumedSeedVersionRef.current) {\n      return;\n    }\n\n    consumedSeedVersionRef.current = seedVersion;\n    setCreateModeSeed({\n      version: seedVersion,\n      state: consumeCreateModeSeedState(),\n    });\n  }, [isCreateMode, seedVersion]);\n\n  const createModeProviderKey =\n    createModeSeed.version > 0\n      ? `create-mode-seed-${createModeSeed.version}`\n      : 'create-mode-seed-default';\n\n  const isMobile = useIsMobile();\n  const [mobileTab] = useMobileActiveTab();\n  const mainContainerRef = useRef<WorkspacesMainContainerHandle>(null);\n\n  const handleScrollToBottom = useCallback(\n    (behavior: 'auto' | 'smooth' = 'smooth') => {\n      mainContainerRef.current?.scrollToBottom(behavior);\n    },\n    []\n  );\n\n  const handleWorkspaceCreated = useCallback(\n    (workspaceId: string) => {\n      appNavigation.goToWorkspace(workspaceId);\n    },\n    [appNavigation]\n  );\n\n  // Use workspace-specific panel state (pass undefined when in create mode)\n  const {\n    isLeftSidebarVisible,\n    isLeftMainPanelVisible,\n    isRightSidebarVisible,\n    rightMainPanelMode,\n    setLeftSidebarVisible,\n    setLeftMainPanelVisible,\n  } = useWorkspacePanelState(isCreateMode ? undefined : workspaceId);\n\n  const {\n    config,\n    updateAndSaveConfig,\n    loading: configLoading,\n  } = useUserSystem();\n  const hasAutoShownWorkspacesGuide = useRef(false);\n\n  // Auto-show Workspaces Guide on first visit\n  useEffect(() => {\n    if (hasAutoShownWorkspacesGuide.current) return;\n    if (configLoading || !config) return;\n\n    const seenFeatures = config.showcases?.seen_features ?? [];\n    if (seenFeatures.includes(WORKSPACES_GUIDE_ID)) return;\n\n    hasAutoShownWorkspacesGuide.current = true;\n\n    void updateAndSaveConfig({\n      showcases: { seen_features: [...seenFeatures, WORKSPACES_GUIDE_ID] },\n    });\n    WorkspacesGuideDialog.show().finally(() => WorkspacesGuideDialog.hide());\n  }, [configLoading, config, updateAndSaveConfig]);\n\n  // Ensure left panels visible when right main panel hidden\n  useEffect(() => {\n    if (rightMainPanelMode === null) {\n      setLeftSidebarVisible(true);\n      if (!isLeftMainPanelVisible) setLeftMainPanelVisible(true);\n    }\n  }, [\n    isLeftMainPanelVisible,\n    rightMainPanelMode,\n    setLeftSidebarVisible,\n    setLeftMainPanelVisible,\n  ]);\n\n  const [rightMainPanelSize, setRightMainPanelSize] = usePaneSize(\n    PERSIST_KEYS.rightMainPanel,\n    50\n  );\n\n  const defaultLayout: Layout =\n    typeof rightMainPanelSize === 'number'\n      ? {\n          'left-main': 100 - rightMainPanelSize,\n          'right-main': rightMainPanelSize,\n        }\n      : { 'left-main': 50, 'right-main': 50 };\n\n  const onLayoutChange = (layout: Layout) => {\n    if (isLeftMainPanelVisible && rightMainPanelMode !== null)\n      setRightMainPanelSize(layout['right-main']);\n  };\n\n  // ── Mobile layout ──────────────────────────────────────────────────\n  // Uses `hidden` CSS class (NOT conditional rendering) to preserve\n  // WebSocket connections and scroll positions across tab switches.\n  if (isMobile) {\n    const mobileContent = (\n      <ReviewProvider workspaceId={selectedWorkspace?.id}>\n        <ChangesViewProvider>\n          <div className=\"flex flex-col h-full min-h-0\">\n            {/* Workspaces tab */}\n            <div\n              className={cn(\n                'flex-1 min-h-0 overflow-hidden',\n                mobileTab !== 'workspaces' && 'hidden'\n              )}\n            >\n              <WorkspacesSidebarContainer\n                onScrollToBottom={handleScrollToBottom}\n              />\n            </div>\n\n            {/* Chat tab */}\n            <div\n              className={cn(\n                'flex-1 min-h-0 overflow-hidden',\n                mobileTab !== 'chat' && 'hidden'\n              )}\n            >\n              {isCreateMode ? (\n                <CreateChatBoxContainer\n                  onWorkspaceCreated={handleWorkspaceCreated}\n                />\n              ) : (\n                <WorkspacesMainContainer\n                  ref={mainContainerRef}\n                  selectedWorkspace={selectedWorkspace ?? null}\n                  selectedSession={selectedSession}\n                  selectedSessionId={selectedSessionId}\n                  sessions={sessions}\n                  repos={repos}\n                  onSelectSession={selectSession}\n                  isLoading={isLoading}\n                  isNewSessionMode={isNewSessionMode}\n                  onStartNewSession={startNewSession}\n                />\n              )}\n            </div>\n\n            {/* Changes tab */}\n            <div\n              className={cn(\n                'flex-1 min-h-0 overflow-hidden',\n                mobileTab !== 'changes' && 'hidden'\n              )}\n            >\n              {selectedWorkspace?.id && (\n                <ChangesPanelContainer\n                  className=\"\"\n                  workspaceId={selectedWorkspace.id}\n                />\n              )}\n            </div>\n\n            {/* Logs tab */}\n            <div\n              className={cn(\n                'flex-1 min-h-0 overflow-hidden',\n                mobileTab !== 'logs' && 'hidden'\n              )}\n            >\n              <LogsContentContainer className=\"\" />\n            </div>\n\n            {/* Preview tab */}\n            <div\n              className={cn(\n                'flex-1 min-h-0 overflow-hidden',\n                mobileTab !== 'preview' && 'hidden'\n              )}\n            >\n              {selectedWorkspace?.id && (\n                <PreviewBrowserContainer\n                  workspaceId={selectedWorkspace.id}\n                  className=\"\"\n                />\n              )}\n            </div>\n\n            {/* Git tab */}\n            <div\n              className={cn(\n                'flex-1 min-h-0 overflow-hidden',\n                mobileTab !== 'git' && 'hidden'\n              )}\n            >\n              {selectedWorkspace && !isCreateMode && (\n                <RightSidebar\n                  rightMainPanelMode={rightMainPanelMode}\n                  selectedWorkspace={selectedWorkspace}\n                  repos={repos}\n                />\n              )}\n            </div>\n          </div>\n        </ChangesViewProvider>\n      </ReviewProvider>\n    );\n\n    return (\n      <div className=\"flex flex-1 min-h-0 h-full\">\n        <div className=\"flex-1 min-w-0 h-full\">\n          {isCreateMode ? (\n            <CreateModeProvider\n              key={createModeProviderKey}\n              initialState={createModeSeed.state}\n            >\n              {mobileContent}\n            </CreateModeProvider>\n          ) : (\n            mobileContent\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  const mainContent = (\n    <ReviewProvider workspaceId={selectedWorkspace?.id}>\n      <ChangesViewProvider>\n        <div className=\"flex h-full\">\n          <Group\n            orientation=\"horizontal\"\n            className=\"flex-1 min-w-0 h-full\"\n            defaultLayout={defaultLayout}\n            onLayoutChange={onLayoutChange}\n          >\n            {isLeftMainPanelVisible && (\n              <Panel\n                id=\"left-main\"\n                minSize=\"20%\"\n                className=\"min-w-0 h-full overflow-hidden\"\n              >\n                {isCreateMode ? (\n                  <CreateChatBoxContainer\n                    onWorkspaceCreated={handleWorkspaceCreated}\n                  />\n                ) : (\n                  <WorkspacesMainContainer\n                    ref={mainContainerRef}\n                    selectedWorkspace={selectedWorkspace ?? null}\n                    selectedSession={selectedSession}\n                    selectedSessionId={selectedSessionId}\n                    sessions={sessions}\n                    repos={repos}\n                    onSelectSession={selectSession}\n                    isLoading={isLoading}\n                    isNewSessionMode={isNewSessionMode}\n                    onStartNewSession={startNewSession}\n                  />\n                )}\n              </Panel>\n            )}\n\n            {isLeftMainPanelVisible && rightMainPanelMode !== null && (\n              <Separator\n                id=\"main-separator\"\n                className=\"w-1 bg-transparent hover:bg-brand/50 transition-colors cursor-col-resize\"\n              />\n            )}\n\n            {rightMainPanelMode !== null && (\n              <Panel\n                id=\"right-main\"\n                minSize=\"20%\"\n                className=\"min-w-0 h-full overflow-hidden\"\n              >\n                {rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES &&\n                  selectedWorkspace?.id && (\n                    <ChangesPanelContainer\n                      className=\"\"\n                      workspaceId={selectedWorkspace.id}\n                    />\n                  )}\n                {rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS && (\n                  <LogsContentContainer className=\"\" />\n                )}\n                {rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW &&\n                  selectedWorkspace?.id && (\n                    <PreviewBrowserContainer\n                      workspaceId={selectedWorkspace.id}\n                      className=\"\"\n                    />\n                  )}\n              </Panel>\n            )}\n          </Group>\n\n          {isRightSidebarVisible && !isCreateMode && (\n            <div className=\"w-[300px] shrink-0 h-full overflow-hidden\">\n              <RightSidebar\n                rightMainPanelMode={rightMainPanelMode}\n                selectedWorkspace={selectedWorkspace}\n                repos={repos}\n              />\n            </div>\n          )}\n        </div>\n      </ChangesViewProvider>\n    </ReviewProvider>\n  );\n\n  return (\n    <div className=\"flex flex-1 min-h-0 h-full\">\n      {isLeftSidebarVisible && (\n        <div className=\"w-[300px] shrink-0 h-full overflow-hidden\">\n          <WorkspacesSidebarContainer onScrollToBottom={handleScrollToBottom} />\n        </div>\n      )}\n\n      <div className=\"flex-1 min-w-0 h-full\">\n        {isCreateMode ? (\n          <CreateModeProvider\n            key={createModeProviderKey}\n            initialState={createModeSeed.state}\n          >\n            {mainContent}\n          </CreateModeProvider>\n        ) : (\n          mainContent\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/WorkspacesMainContainer.tsx",
    "content": "import {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from 'react';\nimport type { Workspace, Session, RepoWithTargetBranch } from 'shared/types';\nimport { createWorkspaceWithSession } from '@/shared/types/attempt';\nimport { WorkspacesMain } from '@vibe/ui/components/WorkspacesMain';\nimport {\n  ConversationList,\n  type ConversationListHandle,\n} from '@/features/workspace-chat/ui/ConversationListContainer';\nimport { SessionChatBoxContainer } from '@/features/workspace-chat/ui/SessionChatBoxContainer';\nimport { ContextBarContainer } from './ContextBarContainer';\nimport { EntriesProvider } from '@/features/workspace-chat/model/contexts/EntriesContext';\nimport { MessageEditProvider } from '@/features/workspace-chat/model/contexts/MessageEditContext';\nimport { RetryUiProvider } from '@/features/workspace-chat/model/contexts/RetryUiContext';\nimport { ApprovalFeedbackProvider } from '@/features/workspace-chat/model/contexts/ApprovalFeedbackContext';\nimport { useWorkspaceDiffContext } from '@/shared/hooks/useWorkspaceContext';\n\n/**\n * Isolated component that reads diffStats from WorkspaceContext.\n * By pushing the context subscription down to this leaf, the parent\n * WorkspacesMainContainer (and its ConversationList child) no longer\n * rerenders when diffs/comments/repos stream in.\n */\nfunction ChatBoxWithDiffStats({\n  session,\n  workspaceId,\n  isNewSessionMode,\n  sessions,\n  onSelectSession,\n  onStartNewSession,\n  onScrollToPreviousMessage,\n  onScrollToBottom,\n}: {\n  session: Session | undefined;\n  workspaceId: string | undefined;\n  isNewSessionMode: boolean;\n  sessions: Session[];\n  onSelectSession: (sessionId: string) => void;\n  onStartNewSession: () => void;\n  onScrollToPreviousMessage: () => void;\n  onScrollToBottom: (behavior?: 'auto' | 'smooth') => void;\n}) {\n  const { diffStats } = useWorkspaceDiffContext();\n\n  return (\n    <SessionChatBoxContainer\n      {...(isNewSessionMode && workspaceId\n        ? {\n            mode: 'new-session' as const,\n            workspaceId,\n            onSelectSession,\n          }\n        : session\n          ? {\n              mode: 'existing-session' as const,\n              session,\n              onSelectSession,\n              onStartNewSession,\n            }\n          : {\n              mode: 'placeholder' as const,\n            })}\n      sessions={sessions}\n      filesChanged={diffStats.files_changed}\n      linesAdded={diffStats.lines_added}\n      linesRemoved={diffStats.lines_removed}\n      disableViewCode={false}\n      showOpenWorkspaceButton={false}\n      onScrollToPreviousMessage={onScrollToPreviousMessage}\n      onScrollToBottom={onScrollToBottom}\n    />\n  );\n}\n\nexport interface WorkspacesMainContainerHandle {\n  scrollToBottom: (behavior?: 'auto' | 'smooth') => void;\n}\n\ninterface WorkspacesMainContainerProps {\n  selectedWorkspace: Workspace | null;\n  selectedSession: Session | undefined;\n  selectedSessionId: string | undefined;\n  sessions: Session[];\n  repos: RepoWithTargetBranch[];\n  onSelectSession: (sessionId: string) => void;\n  isLoading: boolean;\n  isNewSessionMode: boolean;\n  onStartNewSession: () => void;\n}\n\nexport const WorkspacesMainContainer = forwardRef<\n  WorkspacesMainContainerHandle,\n  WorkspacesMainContainerProps\n>(function WorkspacesMainContainer(\n  {\n    selectedWorkspace,\n    selectedSession,\n    selectedSessionId,\n    sessions,\n    repos,\n    onSelectSession,\n    isLoading,\n    isNewSessionMode,\n    onStartNewSession,\n  },\n  ref\n) {\n  const containerRef = useRef<HTMLElement>(null);\n  const conversationListRef = useRef<ConversationListHandle>(null);\n\n  const workspaceWithSession = useMemo(() => {\n    if (!selectedWorkspace) return undefined;\n    return createWorkspaceWithSession(selectedWorkspace, selectedSession);\n  }, [selectedWorkspace, selectedSession]);\n\n  const handleScrollToPreviousMessage = useCallback(() => {\n    conversationListRef.current?.scrollToPreviousUserMessage();\n  }, []);\n\n  const [isAtBottom, setIsAtBottom] = useState(true);\n  const isAtBottomRef = useRef(isAtBottom);\n  const handleAtBottomChange = useCallback((atBottom: boolean) => {\n    isAtBottomRef.current = atBottom;\n    setIsAtBottom(atBottom);\n  }, []);\n\n  const handleScrollToBottom = useCallback(\n    (behavior: 'auto' | 'smooth' = 'smooth') => {\n      conversationListRef.current?.scrollToBottom(behavior);\n    },\n    []\n  );\n\n  const { session } = workspaceWithSession ?? {};\n\n  useEffect(() => {\n    isAtBottomRef.current = isAtBottom;\n  }, [isAtBottom]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container || typeof ResizeObserver === 'undefined') return;\n\n    const chatBoxContainer = container.querySelector<HTMLElement>(\n      '[data-chatbox-container=\"true\"]'\n    );\n    if (!chatBoxContainer) return;\n\n    let previousHeight = chatBoxContainer.getBoundingClientRect().height;\n\n    const observer = new ResizeObserver((entries) => {\n      const nextHeight =\n        entries[0]?.contentRect.height ??\n        chatBoxContainer.getBoundingClientRect().height;\n\n      if (Math.abs(nextHeight - previousHeight) < 0.5) return;\n      const heightDelta = nextHeight - previousHeight;\n      previousHeight = nextHeight;\n\n      if (!isAtBottomRef.current) return;\n\n      requestAnimationFrame(() => {\n        if (!isAtBottomRef.current) return;\n        conversationListRef.current?.adjustScrollBy(heightDelta);\n      });\n    });\n\n    observer.observe(chatBoxContainer);\n\n    return () => {\n      observer.disconnect();\n    };\n  }, [workspaceWithSession?.id, session?.id]);\n\n  const entriesProviderKey = workspaceWithSession\n    ? `${workspaceWithSession.id}-${selectedSessionId ?? 'new'}`\n    : 'empty';\n\n  const conversationContent = workspaceWithSession ? (\n    <div className=\"flex-1 min-h-0 overflow-hidden flex justify-center\">\n      <div className=\"w-chat max-w-full h-full\">\n        <RetryUiProvider workspaceId={workspaceWithSession.id}>\n          <ConversationList\n            key={entriesProviderKey}\n            ref={conversationListRef}\n            attempt={workspaceWithSession}\n            repos={repos}\n            onAtBottomChange={handleAtBottomChange}\n            sessionScopeId={selectedSessionId}\n          />\n        </RetryUiProvider>\n      </div>\n    </div>\n  ) : null;\n\n  const chatBoxContent = (\n    <ChatBoxWithDiffStats\n      session={session}\n      workspaceId={workspaceWithSession?.id}\n      isNewSessionMode={isNewSessionMode}\n      sessions={sessions}\n      onSelectSession={onSelectSession}\n      onStartNewSession={onStartNewSession}\n      onScrollToPreviousMessage={handleScrollToPreviousMessage}\n      onScrollToBottom={handleScrollToBottom}\n    />\n  );\n\n  const contextBarContent = workspaceWithSession ? (\n    <ContextBarContainer containerRef={containerRef} />\n  ) : null;\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      scrollToBottom: (behavior = 'smooth') => {\n        conversationListRef.current?.scrollToBottom(behavior);\n      },\n    }),\n    []\n  );\n\n  return (\n    <ApprovalFeedbackProvider>\n      <EntriesProvider key={entriesProviderKey}>\n        <MessageEditProvider>\n          <WorkspacesMain\n            workspaceWithSession={\n              workspaceWithSession ? { id: workspaceWithSession.id } : undefined\n            }\n            isLoading={isLoading}\n            containerRef={containerRef}\n            conversationContent={conversationContent}\n            chatBoxContent={chatBoxContent}\n            contextBarContent={contextBarContent}\n            isAtBottom={isAtBottom}\n            onAtBottomChange={handleAtBottomChange}\n            onScrollToBottom={handleScrollToBottom}\n          />\n        </MessageEditProvider>\n      </EntriesProvider>\n    </ApprovalFeedbackProvider>\n  );\n});\n"
  },
  {
    "path": "packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx",
    "content": "import { useState, useMemo, useCallback, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useUserContext } from '@/shared/hooks/useUserContext';\nimport { useScratch } from '@/shared/hooks/useScratch';\nimport { useAllOrganizationProjects } from '@/shared/hooks/useAllOrganizationProjects';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { ScratchType, type DraftWorkspaceData } from 'shared/types';\nimport type { Project } from 'shared/remote-types';\nimport { splitMessageToTitleDescription } from '@/shared/lib/string';\nimport { cn } from '@/shared/lib/utils';\nimport { useIsMobile } from '@/shared/hooks/useIsMobile';\nimport {\n  PERSIST_KEYS,\n  usePersistedExpanded,\n  useUiPreferencesStore,\n  type WorkspacePrFilter,\n  type WorkspaceSortBy,\n  type WorkspaceSortOrder,\n} from '@/shared/stores/useUiPreferencesStore';\nimport type { Workspace } from '@/shared/hooks/useWorkspaces';\nimport { CommandBarDialog } from '@/shared/dialogs/command-bar/CommandBarDialog';\nimport {\n  WorkspacesSidebar,\n  type WorkspacesSidebarPersistKeys,\n} from '@vibe/ui/components/WorkspacesSidebar';\nimport {\n  MultiSelectDropdown,\n  type MultiSelectDropdownOption,\n} from '@vibe/ui/components/MultiSelectDropdown';\nimport { PropertyDropdown } from '@vibe/ui/components/PropertyDropdown';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { IconButton } from '@vibe/ui/components/IconButton';\nimport {\n  ButtonGroup,\n  ButtonGroupItem,\n} from '@vibe/ui/components/IconButtonGroup';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/Dialog';\nimport {\n  FunnelIcon,\n  FolderIcon,\n  GitPullRequestIcon,\n  SortAscendingIcon,\n  SortDescendingIcon,\n  XIcon,\n} from '@phosphor-icons/react';\n\nexport type WorkspaceLayoutMode = 'flat' | 'accordion';\n\n// Fixed UUID for the universal workspace draft (same as in useCreateModeState.ts)\nconst DRAFT_WORKSPACE_ID = '00000000-0000-0000-0000-000000000001';\n\nconst PAGE_SIZE = 50;\nconst NO_PROJECT_ID = '__no_project__';\nconst DEFAULT_WORKSPACE_SORT = {\n  sortBy: 'updated_at' as WorkspaceSortBy,\n  sortOrder: 'desc' as WorkspaceSortOrder,\n};\n\nconst PR_FILTER_OPTIONS: WorkspacePrFilter[] = ['all', 'has_pr', 'no_pr'];\n\nconst SORT_BY_OPTIONS: WorkspaceSortBy[] = ['updated_at', 'created_at'];\n\ninterface WorkspacesSidebarContainerProps {\n  onScrollToBottom?: (behavior?: 'auto' | 'smooth') => void;\n}\n\ninterface WorkspacesSortDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  sortBy: WorkspaceSortBy;\n  sortOrder: WorkspaceSortOrder;\n  onSortByChange: (sortBy: WorkspaceSortBy) => void;\n  onSortOrderChange: (sortOrder: WorkspaceSortOrder) => void;\n}\n\nfunction WorkspacesSortDialog({\n  open,\n  onOpenChange,\n  sortBy,\n  sortOrder,\n  onSortByChange,\n  onSortOrderChange,\n}: WorkspacesSortDialogProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-md p-0\">\n        <div className=\"border-b border-border px-double pb-base pt-double\">\n          <DialogHeader className=\"space-y-half\">\n            <DialogTitle>\n              {t('kanban.workspaceSidebar.sortDialogTitle')}\n            </DialogTitle>\n            <DialogDescription>\n              {t('kanban.workspaceSidebar.sortDialogDescription')}\n            </DialogDescription>\n          </DialogHeader>\n        </div>\n\n        <div className=\"px-double py-double\">\n          <div className=\"flex flex-col gap-base\">\n            <div className=\"flex items-center justify-between gap-base\">\n              <span className=\"text-sm text-low\">\n                {t('kanban.workspaceSidebar.sortByLabel')}\n              </span>\n              <PropertyDropdown\n                value={sortBy}\n                options={SORT_BY_OPTIONS.map((option) => ({\n                  value: option,\n                  label:\n                    option === 'updated_at'\n                      ? t('kanban.workspaceSidebar.sortUpdatedAt')\n                      : t('kanban.workspaceSidebar.sortCreatedAt'),\n                }))}\n                onChange={onSortByChange}\n              />\n            </div>\n            <div className=\"flex items-center justify-between gap-base\">\n              <span className=\"text-sm text-low\">\n                {t('kanban.workspaceSidebar.sortOrderLabel')}\n              </span>\n              <ButtonGroup>\n                <ButtonGroupItem\n                  active={sortOrder === 'desc'}\n                  onClick={() => onSortOrderChange('desc')}\n                >\n                  {t('kanban.sortDescending')}\n                </ButtonGroupItem>\n                <ButtonGroupItem\n                  active={sortOrder === 'asc'}\n                  onClick={() => onSortOrderChange('asc')}\n                >\n                  {t('kanban.sortAscending')}\n                </ButtonGroupItem>\n              </ButtonGroup>\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\ninterface WorkspacesFilterDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  projectOptions: MultiSelectDropdownOption<string>[];\n  projectIds: string[];\n  prFilter: WorkspacePrFilter;\n  hasActiveFilters: boolean;\n  onProjectFilterChange: (projectIds: string[]) => void;\n  onPrFilterChange: (prFilter: WorkspacePrFilter) => void;\n  onClearFilters: () => void;\n}\n\nfunction WorkspacesFilterDialog({\n  open,\n  onOpenChange,\n  projectOptions,\n  projectIds,\n  prFilter,\n  hasActiveFilters,\n  onProjectFilterChange,\n  onPrFilterChange,\n  onClearFilters,\n}: WorkspacesFilterDialogProps) {\n  const { t } = useTranslation('common');\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-md p-0\">\n        <div className=\"border-b border-border px-double pb-base pt-double\">\n          <DialogHeader className=\"space-y-half\">\n            <DialogTitle>\n              {t('kanban.workspaceSidebar.filterDialogTitle')}\n            </DialogTitle>\n            <DialogDescription>\n              {t('kanban.workspaceSidebar.filterDialogDescription')}\n            </DialogDescription>\n          </DialogHeader>\n        </div>\n\n        <div className=\"px-double py-double\">\n          <div className=\"flex flex-col items-start gap-base\">\n            <MultiSelectDropdown\n              values={projectIds}\n              options={projectOptions}\n              onChange={onProjectFilterChange}\n              icon={FolderIcon}\n              label={t('kanban.workspaceSidebar.projectFilterLabel')}\n            />\n            <PropertyDropdown\n              value={prFilter}\n              options={PR_FILTER_OPTIONS.map((option) => ({\n                value: option,\n                label:\n                  option === 'all'\n                    ? t('kanban.workspaceSidebar.prFilterAll')\n                    : option === 'has_pr'\n                      ? t('kanban.workspaceSidebar.prFilterHasPr')\n                      : t('kanban.workspaceSidebar.prFilterNoPr'),\n              }))}\n              onChange={onPrFilterChange}\n              icon={GitPullRequestIcon}\n              label={t('kanban.workspaceSidebar.prFilterLabel')}\n            />\n            {hasActiveFilters && (\n              <div className=\"self-end\">\n                <PrimaryButton\n                  variant=\"tertiary\"\n                  value={t('kanban.clearFilters')}\n                  actionIcon={XIcon}\n                  onClick={onClearFilters}\n                />\n              </div>\n            )}\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction toTimestamp(value: string | undefined): number | null {\n  if (!value) {\n    return null;\n  }\n\n  const timestamp = new Date(value).getTime();\n  return Number.isNaN(timestamp) ? null : timestamp;\n}\n\nfunction getWorkspaceSortTimestamp(\n  workspace: Workspace,\n  sortBy: WorkspaceSortBy\n): number | null {\n  if (sortBy === 'updated_at') {\n    return toTimestamp(workspace.latestProcessCompletedAt);\n  }\n\n  return toTimestamp(workspace.createdAt);\n}\n\nexport function WorkspacesSidebarContainer({\n  onScrollToBottom = () => {},\n}: WorkspacesSidebarContainerProps) {\n  const {\n    workspaceId: selectedWorkspaceId,\n    activeWorkspaces,\n    archivedWorkspaces,\n    isCreateMode,\n    selectWorkspace,\n    navigateToCreate,\n  } = useWorkspaceContext();\n\n  const isMobile = useIsMobile();\n  const setMobileActiveTab = useUiPreferencesStore((s) => s.setMobileActiveTab);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [showArchive, setShowArchive] = usePersistedExpanded(\n    PERSIST_KEYS.workspacesSidebarArchived,\n    false\n  );\n  const [isAccordionLayout, setAccordionLayout] = usePersistedExpanded(\n    PERSIST_KEYS.workspacesSidebarAccordionLayout,\n    true\n  );\n  const [isSortDialogOpen, setIsSortDialogOpen] = useState(false);\n  const [isFilterDialogOpen, setIsFilterDialogOpen] = useState(false);\n  const { t } = useTranslation('common');\n  const sortDialogTitle = t('kanban.workspaceSidebar.sortButtonTitle');\n  const filterDialogTitle = t('kanban.workspaceSidebar.filterButtonTitle');\n\n  const layoutMode: WorkspaceLayoutMode = isAccordionLayout\n    ? 'accordion'\n    : 'flat';\n  const toggleLayoutMode = () => setAccordionLayout(!isAccordionLayout);\n\n  // Workspace sidebar filters + sort\n  const workspaceFilters = useUiPreferencesStore((s) => s.workspaceFilters);\n  const setWorkspaceProjectFilter = useUiPreferencesStore(\n    (s) => s.setWorkspaceProjectFilter\n  );\n  const setWorkspacePrFilter = useUiPreferencesStore(\n    (s) => s.setWorkspacePrFilter\n  );\n  const clearWorkspaceFilters = useUiPreferencesStore(\n    (s) => s.clearWorkspaceFilters\n  );\n  const workspaceSort = useUiPreferencesStore((s) => s.workspaceSort);\n  const setWorkspaceSortBy = useUiPreferencesStore((s) => s.setWorkspaceSortBy);\n  const setWorkspaceSortOrder = useUiPreferencesStore(\n    (s) => s.setWorkspaceSortOrder\n  );\n\n  // Remote data for project filter (all orgs)\n  const { workspaces: remoteWorkspaces } = useUserContext();\n  const { data: allRemoteProjects } = useAllOrganizationProjects();\n  const { data: orgsData } = useUserOrganizations();\n  const organizations = useMemo(\n    () => orgsData?.organizations ?? [],\n    [orgsData?.organizations]\n  );\n\n  // Map local workspace ID → remote project ID\n  const remoteProjectByLocalId = useMemo(() => {\n    const map = new Map<string, string>();\n    for (const rw of remoteWorkspaces) {\n      if (rw.local_workspace_id) {\n        map.set(rw.local_workspace_id, rw.project_id);\n      }\n    }\n    return map;\n  }, [remoteWorkspaces]);\n\n  // Build org name lookup\n  const orgNameById = useMemo(() => {\n    const map = new Map<string, string>();\n    for (const org of organizations) {\n      map.set(org.id, org.name);\n    }\n    return map;\n  }, [organizations]);\n\n  // Group projects by org, only including projects with linked workspaces\n  const projectGroups = useMemo(() => {\n    const linkedProjectIds = new Set(remoteProjectByLocalId.values());\n    const relevant = allRemoteProjects.filter((p) =>\n      linkedProjectIds.has(p.id)\n    );\n\n    const groupMap = new Map<string, Project[]>();\n    for (const project of relevant) {\n      const arr = groupMap.get(project.organization_id) ?? [];\n      arr.push(project);\n      groupMap.set(project.organization_id, arr);\n    }\n\n    return Array.from(groupMap.entries())\n      .map(([orgId, projects]) => ({\n        orgId,\n        orgName: orgNameById.get(orgId) ?? 'Unknown',\n        projects: projects.sort((a, b) => a.name.localeCompare(b.name)),\n      }))\n      .sort((a, b) => a.orgName.localeCompare(b.orgName));\n  }, [allRemoteProjects, remoteProjectByLocalId, orgNameById]);\n\n  // Build flat project options for MultiSelectDropdown\n  const projectOptions = useMemo<MultiSelectDropdownOption<string>[]>(\n    () => [\n      {\n        value: NO_PROJECT_ID,\n        label: t('kanban.workspaceSidebar.noProject'),\n      },\n      ...projectGroups.flatMap((g) =>\n        g.projects.map((p) => ({\n          value: p.id,\n          label: p.name,\n          renderOption: () => (\n            <div className=\"flex items-center gap-base\">\n              <span\n                className=\"h-2 w-2 shrink-0 rounded-full\"\n                style={{ backgroundColor: `hsl(${p.color})` }}\n              />\n              {p.name}\n            </div>\n          ),\n        }))\n      ),\n    ],\n    [projectGroups, t]\n  );\n\n  const hasActiveFilters =\n    workspaceFilters.projectIds.length > 0 ||\n    workspaceFilters.prFilter !== 'all';\n  const hasNonDefaultSort =\n    workspaceSort.sortBy !== DEFAULT_WORKSPACE_SORT.sortBy ||\n    workspaceSort.sortOrder !== DEFAULT_WORKSPACE_SORT.sortOrder;\n\n  // Pagination state for infinite scroll\n  const [displayLimit, setDisplayLimit] = useState(PAGE_SIZE);\n\n  // Reset display limit when search, filter, or sort state changes\n  useEffect(() => {\n    setDisplayLimit(PAGE_SIZE);\n  }, [searchQuery, showArchive, workspaceFilters, workspaceSort]);\n\n  const searchLower = searchQuery.toLowerCase();\n  const isSearching = searchQuery.length > 0;\n\n  // Apply sidebar filters (project + PR), then search\n  const filteredActiveWorkspaces = useMemo(() => {\n    let result = activeWorkspaces;\n\n    // Project filter\n    if (workspaceFilters.projectIds.length > 0) {\n      const includeNoProject =\n        workspaceFilters.projectIds.includes(NO_PROJECT_ID);\n      const realProjectIds = workspaceFilters.projectIds.filter(\n        (id) => id !== NO_PROJECT_ID\n      );\n      result = result.filter((ws) => {\n        const projectId = remoteProjectByLocalId.get(ws.id);\n        if (!projectId) return includeNoProject;\n        return realProjectIds.includes(projectId);\n      });\n    }\n\n    // PR filter\n    if (workspaceFilters.prFilter === 'has_pr') {\n      result = result.filter((ws) => !!ws.prStatus);\n    } else if (workspaceFilters.prFilter === 'no_pr') {\n      result = result.filter((ws) => !ws.prStatus);\n    }\n\n    // Search filter\n    if (searchLower) {\n      result = result.filter(\n        (ws) =>\n          ws.name.toLowerCase().includes(searchLower) ||\n          ws.branch.toLowerCase().includes(searchLower)\n      );\n    }\n\n    return result;\n  }, [activeWorkspaces, workspaceFilters, remoteProjectByLocalId, searchLower]);\n\n  const filteredArchivedWorkspaces = useMemo(() => {\n    let result = archivedWorkspaces;\n\n    if (workspaceFilters.projectIds.length > 0) {\n      const includeNoProject =\n        workspaceFilters.projectIds.includes(NO_PROJECT_ID);\n      const realProjectIds = workspaceFilters.projectIds.filter(\n        (id) => id !== NO_PROJECT_ID\n      );\n      result = result.filter((ws) => {\n        const projectId = remoteProjectByLocalId.get(ws.id);\n        if (!projectId) return includeNoProject;\n        return realProjectIds.includes(projectId);\n      });\n    }\n\n    if (workspaceFilters.prFilter === 'has_pr') {\n      result = result.filter((ws) => !!ws.prStatus);\n    } else if (workspaceFilters.prFilter === 'no_pr') {\n      result = result.filter((ws) => !ws.prStatus);\n    }\n\n    if (searchLower) {\n      result = result.filter(\n        (ws) =>\n          ws.name.toLowerCase().includes(searchLower) ||\n          ws.branch.toLowerCase().includes(searchLower)\n      );\n    }\n\n    return result;\n  }, [\n    archivedWorkspaces,\n    workspaceFilters,\n    remoteProjectByLocalId,\n    searchLower,\n  ]);\n\n  const sortWorkspaces = useCallback(\n    (workspaces: Workspace[]) =>\n      [...workspaces].sort((a, b) => {\n        // Always keep pinned workspaces at the top.\n        if (a.isPinned !== b.isPinned) {\n          return a.isPinned ? -1 : 1;\n        }\n\n        const aTimestamp = getWorkspaceSortTimestamp(a, workspaceSort.sortBy);\n        const bTimestamp = getWorkspaceSortTimestamp(b, workspaceSort.sortBy);\n\n        // Workspaces without the selected timestamp are always sorted first.\n        if (aTimestamp === null && bTimestamp === null) {\n          return a.name.localeCompare(b.name);\n        }\n        if (aTimestamp === null) {\n          return -1;\n        }\n        if (bTimestamp === null) {\n          return 1;\n        }\n\n        if (aTimestamp === bTimestamp) {\n          return a.name.localeCompare(b.name);\n        }\n\n        return workspaceSort.sortOrder === 'asc'\n          ? aTimestamp - bTimestamp\n          : bTimestamp - aTimestamp;\n      }),\n    [workspaceSort.sortBy, workspaceSort.sortOrder]\n  );\n\n  const sortedActiveWorkspaces = useMemo(\n    () => sortWorkspaces(filteredActiveWorkspaces),\n    [filteredActiveWorkspaces, sortWorkspaces]\n  );\n\n  const sortedArchivedWorkspaces = useMemo(\n    () => sortWorkspaces(filteredArchivedWorkspaces),\n    [filteredArchivedWorkspaces, sortWorkspaces]\n  );\n\n  // Apply pagination (only when not searching)\n  const paginatedActiveWorkspaces = useMemo(\n    () =>\n      isSearching\n        ? sortedActiveWorkspaces\n        : sortedActiveWorkspaces.slice(0, displayLimit),\n    [sortedActiveWorkspaces, displayLimit, isSearching]\n  );\n\n  const paginatedArchivedWorkspaces = useMemo(\n    () =>\n      isSearching\n        ? sortedArchivedWorkspaces\n        : sortedArchivedWorkspaces.slice(0, displayLimit),\n    [sortedArchivedWorkspaces, displayLimit, isSearching]\n  );\n\n  // Check if there are more workspaces to load\n  const hasMoreWorkspaces = showArchive\n    ? sortedArchivedWorkspaces.length > displayLimit\n    : sortedActiveWorkspaces.length > displayLimit;\n\n  // Handle scroll to load more\n  const handleLoadMore = useCallback(() => {\n    if (!isSearching && hasMoreWorkspaces) {\n      setDisplayLimit((prev) => prev + PAGE_SIZE);\n    }\n  }, [isSearching, hasMoreWorkspaces]);\n\n  // Read persisted draft for sidebar placeholder\n  const { scratch: draftScratch } = useScratch(\n    ScratchType.DRAFT_WORKSPACE,\n    DRAFT_WORKSPACE_ID\n  );\n\n  // Extract draft title from persisted scratch\n  const persistedDraftTitle = useMemo(() => {\n    const scratchData: DraftWorkspaceData | undefined =\n      draftScratch?.payload?.type === 'DRAFT_WORKSPACE'\n        ? draftScratch.payload.data\n        : undefined;\n\n    if (!scratchData?.message?.trim()) return undefined;\n    const { title } = splitMessageToTitleDescription(\n      scratchData.message.trim()\n    );\n    return title || 'New Workspace';\n  }, [draftScratch]);\n\n  // Handle workspace selection - scroll to bottom if re-selecting same workspace\n  const handleSelectWorkspace = useCallback(\n    (id: string) => {\n      if (id === selectedWorkspaceId) {\n        onScrollToBottom();\n      } else {\n        selectWorkspace(id);\n      }\n      if (isMobile) {\n        setMobileActiveTab('chat');\n      }\n    },\n    [\n      selectedWorkspaceId,\n      selectWorkspace,\n      onScrollToBottom,\n      isMobile,\n      setMobileActiveTab,\n    ]\n  );\n\n  const handleAddWorkspace = useCallback(() => {\n    navigateToCreate();\n    if (isMobile) {\n      setMobileActiveTab('chat');\n    }\n  }, [navigateToCreate, isMobile, setMobileActiveTab]);\n\n  const handleOpenWorkspaceActions = useCallback((workspaceId: string) => {\n    CommandBarDialog.show({\n      page: 'workspaceActions',\n      workspaceId,\n    });\n  }, []);\n\n  const sidebarPersistKeys: WorkspacesSidebarPersistKeys = {\n    raisedHand: PERSIST_KEYS.workspacesSidebarRaisedHand,\n    notRunning: PERSIST_KEYS.workspacesSidebarNotRunning,\n    running: PERSIST_KEYS.workspacesSidebarRunning,\n  };\n\n  const searchControls = (\n    <>\n      <div className=\"shrink-0\">\n        <div className=\"flex items-stretch\">\n          <IconButton\n            icon={\n              workspaceSort.sortOrder === 'asc'\n                ? SortAscendingIcon\n                : SortDescendingIcon\n            }\n            onClick={() => setIsSortDialogOpen(true)}\n            aria-label={sortDialogTitle}\n            title={sortDialogTitle}\n            className={cn(\n              '!h-cta !px-half !py-0',\n              hasNonDefaultSort && 'text-brand hover:text-brand'\n            )}\n            iconClassName=\"size-icon-lg\"\n          />\n          <IconButton\n            icon={FunnelIcon}\n            onClick={() => setIsFilterDialogOpen(true)}\n            aria-label={filterDialogTitle}\n            title={filterDialogTitle}\n            className=\"!h-cta !px-half !py-0\"\n            iconClassName={cn('size-icon-lg', hasActiveFilters && 'text-brand')}\n          />\n        </div>\n      </div>\n\n      <WorkspacesSortDialog\n        open={isSortDialogOpen}\n        onOpenChange={setIsSortDialogOpen}\n        sortBy={workspaceSort.sortBy}\n        sortOrder={workspaceSort.sortOrder}\n        onSortByChange={setWorkspaceSortBy}\n        onSortOrderChange={setWorkspaceSortOrder}\n      />\n\n      <WorkspacesFilterDialog\n        open={isFilterDialogOpen}\n        onOpenChange={setIsFilterDialogOpen}\n        projectOptions={projectOptions}\n        projectIds={workspaceFilters.projectIds}\n        prFilter={workspaceFilters.prFilter}\n        hasActiveFilters={hasActiveFilters}\n        onProjectFilterChange={setWorkspaceProjectFilter}\n        onPrFilterChange={setWorkspacePrFilter}\n        onClearFilters={clearWorkspaceFilters}\n      />\n    </>\n  );\n\n  return (\n    <WorkspacesSidebar\n      workspaces={paginatedActiveWorkspaces}\n      totalWorkspacesCount={activeWorkspaces.length}\n      archivedWorkspaces={paginatedArchivedWorkspaces}\n      selectedWorkspaceId={selectedWorkspaceId ?? null}\n      onSelectWorkspace={handleSelectWorkspace}\n      searchQuery={searchQuery}\n      onSearchChange={setSearchQuery}\n      onAddWorkspace={handleAddWorkspace}\n      isCreateMode={isCreateMode}\n      draftTitle={persistedDraftTitle}\n      onSelectCreate={navigateToCreate}\n      showArchive={showArchive}\n      onShowArchiveChange={setShowArchive}\n      layoutMode={layoutMode}\n      onToggleLayoutMode={toggleLayoutMode}\n      onLoadMore={handleLoadMore}\n      hasMoreWorkspaces={hasMoreWorkspaces && !isSearching}\n      searchControls={searchControls}\n      onOpenWorkspaceActions={handleOpenWorkspaceActions}\n      persistKeys={sidebarPersistKeys}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/project-routes/ProjectFallbackPage.tsx",
    "content": "import React from 'react';\nimport { useParams } from '@tanstack/react-router';\n\nexport function ProjectFallbackPage() {\n  const { projectId } = useParams({ strict: false });\n  const resolvedProjectId = projectId ?? 'unknown';\n\n  return React.createElement(\n    'div',\n    { className: 'mx-auto min-h-screen w-full max-w-5xl px-double py-double' },\n    React.createElement(\n      'h1',\n      { className: 'text-2xl font-semibold text-high' },\n      'Project'\n    ),\n    React.createElement(\n      'p',\n      { className: 'mt-base text-normal' },\n      `Project ID: ${resolvedProjectId}`\n    )\n  );\n}\n\nexport default ProjectFallbackPage;\n"
  },
  {
    "path": "packages/web-core/src/project-routes/project-search.ts",
    "content": "import { zodValidator } from '@tanstack/zod-adapter';\nimport { z } from 'zod';\n\nexport const projectSearchSchema = z.object({});\n\nexport type ProjectSearch = z.infer<typeof projectSearchSchema>;\n\nexport const projectSearchValidator = zodValidator(projectSearchSchema);\n"
  },
  {
    "path": "packages/web-core/src/shared/actions/index.ts",
    "content": "import { forwardRef, createElement } from 'react';\nimport type { Icon, IconProps } from '@phosphor-icons/react';\nimport type { ExecutorConfig, Merge, Workspace } from 'shared/types';\nimport type { QueryClient } from '@tanstack/react-query';\nimport {\n  CopyIcon,\n  XIcon,\n  PushPinIcon,\n  ArchiveIcon,\n  TrashIcon,\n  PlusIcon,\n  GearIcon,\n  ColumnsIcon,\n  RowsIcon,\n  TextAlignLeftIcon,\n  EyeSlashIcon,\n  SidebarSimpleIcon,\n  ChatsTeardropIcon,\n  GitDiffIcon,\n  TerminalIcon,\n  SignInIcon,\n  SignOutIcon,\n  CaretDoubleUpIcon,\n  CaretDoubleDownIcon,\n  PlayIcon,\n  PauseIcon,\n  SpinnerIcon,\n  GitPullRequestIcon,\n  GitMergeIcon,\n  GitForkIcon,\n  ArrowsClockwiseIcon,\n  CrosshairIcon,\n  DesktopIcon,\n  PencilSimpleIcon,\n  ArrowUpIcon,\n  HighlighterIcon,\n  ListIcon,\n  MegaphoneIcon,\n  QuestionIcon,\n  ArrowsLeftRightIcon,\n  ArrowFatLineUpIcon,\n  UsersIcon,\n  TreeStructureIcon,\n  LinkIcon,\n  ArrowBendUpRightIcon,\n  ProhibitIcon,\n} from '@phosphor-icons/react';\nimport { useDiffViewStore } from '@/shared/stores/useDiffViewStore';\nimport {\n  useUiPreferencesStore,\n  RIGHT_MAIN_PANEL_MODES,\n} from '@/shared/stores/useUiPreferencesStore';\n\nimport { workspacesApi, repoApi } from '@/shared/lib/api';\nimport { bulkUpdateIssues } from '@/shared/lib/remoteApi';\nimport { workspaceRecordKeys } from '@/shared/hooks/useWorkspaceRecord';\nimport { workspaceRepoKeys } from '@/shared/hooks/useWorkspaceRepo';\nimport { repoBranchKeys } from '@/shared/hooks/useRepoBranches';\nimport { workspaceSummaryKeys } from '@/shared/hooks/workspaceSummaryKeys';\nimport { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog';\nimport { ChangeTargetDialog } from '@vibe/ui/components/ChangeTargetDialog';\nimport { DeleteWorkspaceDialog } from '@vibe/ui/components/DeleteWorkspaceDialog';\nimport { RebaseDialog } from '@/shared/dialogs/command-bar/RebaseDialog';\nimport { ResolveConflictsDialog } from '@/shared/dialogs/tasks/ResolveConflictsDialog';\nimport { RenameWorkspaceDialog } from '@vibe/ui/components/RenameWorkspaceDialog';\nimport { ProjectsGuideDialog } from '@vibe/ui/components/ProjectsGuideDialog';\nimport { CreatePRDialog } from '@/shared/dialogs/command-bar/CreatePRDialog';\nimport { getIdeName } from '@/shared/lib/ideName';\nimport { EditorSelectionDialog } from '@/shared/dialogs/command-bar/EditorSelectionDialog';\nimport { StartReviewDialog } from '@/shared/dialogs/command-bar/StartReviewDialog';\nimport posthog from 'posthog-js';\nimport { WorkspacesGuideDialog } from '@/shared/dialogs/shared/WorkspacesGuideDialog';\nimport { SettingsDialog } from '@/shared/dialogs/settings/SettingsDialog';\nimport { CreateWorkspaceFromPrDialog } from '@/shared/dialogs/command-bar/CreateWorkspaceFromPrDialog';\nimport { buildWorkspaceCreateInitialState } from '@/shared/lib/workspaceCreateState';\nimport { setCreateModeSeedState } from '@/features/create-mode/model/createModeSeedStore';\n\n// Mirrored sidebar icon for right sidebar toggle\nconst RightSidebarIcon: Icon = forwardRef<SVGSVGElement, IconProps>(\n  (props, ref) =>\n    createElement(SidebarSimpleIcon, {\n      ref,\n      ...props,\n      style: { transform: 'scaleX(-1)', ...props.style },\n    })\n);\nRightSidebarIcon.displayName = 'RightSidebarIcon';\n\nimport type {\n  ActionExecutorContext,\n  ActionDefinition,\n  GlobalActionDefinition,\n  WorkspaceActionDefinition,\n  IssueActionDefinition,\n  NavbarItem,\n} from '@/shared/types/actions';\nimport { ActionTargetType, NavbarDivider } from '@/shared/types/actions';\n\nasync function resolveLinkedIssue(\n  workspaceId: string,\n  remoteWorkspaces: {\n    local_workspace_id: string | null;\n    issue_id: string | null;\n    project_id: string;\n  }[]\n): Promise<{ issueId: string; remoteProjectId: string } | undefined> {\n  const remoteWs = remoteWorkspaces.find(\n    (w) => w.local_workspace_id === workspaceId\n  );\n  if (remoteWs?.issue_id) {\n    return { issueId: remoteWs.issue_id, remoteProjectId: remoteWs.project_id };\n  }\n  return undefined;\n}\n\nasync function getWorkspace(\n  queryClient: QueryClient,\n  workspaceId: string\n): Promise<Workspace> {\n  const cached = queryClient.getQueryData<Workspace>(\n    workspaceRecordKeys.byId(workspaceId)\n  );\n  if (cached) {\n    return cached;\n  }\n  // Fetch from API if not in cache\n  return workspacesApi.get(workspaceId);\n}\n\n// Helper to invalidate workspace-related queries\nfunction invalidateWorkspaceQueries(\n  queryClient: QueryClient,\n  workspaceId: string\n) {\n  queryClient.invalidateQueries({\n    queryKey: workspaceRecordKeys.byId(workspaceId),\n  });\n  queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n}\n\n// Helper to find the next workspace to navigate to when removing current workspace\nfunction getNextWorkspaceId(\n  activeWorkspaces: { id: string; isRunning?: boolean }[],\n  removingWorkspaceId: string\n): string | null {\n  const currentIndex = activeWorkspaces.findIndex(\n    (ws) => ws.id === removingWorkspaceId\n  );\n  if (currentIndex >= 0 && activeWorkspaces.length > 1) {\n    const nextWorkspace =\n      activeWorkspaces[currentIndex + 1] || activeWorkspaces[currentIndex - 1];\n    return nextWorkspace?.id ?? null;\n  }\n  return null;\n}\n\n// Helper to navigate to create-issue form for a sub-issue, carrying over parent assignees\nfunction navigateToCreateSubIssue(\n  ctx: ActionExecutorContext,\n  parentIssueId: string\n) {\n  const assigneeIds = ctx.projectMutations\n    ?.getAssigneesForIssue(parentIssueId)\n    .map((a) => a.user_id);\n  ctx.navigateToCreateIssue({\n    statusId: ctx.defaultCreateStatusId,\n    parentIssueId,\n    assigneeIds: assigneeIds?.length ? assigneeIds : undefined,\n  });\n}\n\n// All application actions\nexport const Actions = {\n  // === Workspace Actions ===\n  DuplicateWorkspace: {\n    id: 'duplicate-workspace',\n    label: 'Duplicate',\n    icon: CopyIcon,\n    shortcut: 'W D',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    execute: async (ctx, workspaceId) => {\n      try {\n        const [firstMessage, repos, workspaceWithSession] = await Promise.all([\n          workspacesApi.getFirstUserMessage(workspaceId),\n          workspacesApi.getRepos(workspaceId),\n          workspacesApi.getWithSession(workspaceId),\n        ]);\n\n        const linkedIssue = await resolveLinkedIssue(\n          workspaceId,\n          ctx.remoteWorkspaces\n        );\n\n        const executorConfig = workspaceWithSession.session?.executor\n          ? {\n              executor: workspaceWithSession.session\n                .executor as ExecutorConfig['executor'],\n            }\n          : null;\n\n        const createState = buildWorkspaceCreateInitialState({\n          prompt: firstMessage,\n          defaults: {\n            preferredRepos: repos.map((r) => ({\n              repo_id: r.id,\n              target_branch: r.target_branch,\n            })),\n          },\n          linkedIssue,\n          executorConfig,\n        });\n        setCreateModeSeedState(createState);\n        ctx.appNavigation.goToWorkspacesCreate();\n      } catch {\n        ctx.appNavigation.goToWorkspacesCreate();\n      }\n    },\n  },\n\n  RenameWorkspace: {\n    id: 'rename-workspace',\n    label: 'Rename',\n    icon: PencilSimpleIcon,\n    shortcut: 'W R',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    execute: async (ctx, workspaceId) => {\n      const workspace = await getWorkspace(ctx.queryClient, workspaceId);\n      await RenameWorkspaceDialog.show({\n        currentName: workspace.name || workspace.branch,\n        onRename: async (newName) => {\n          await workspacesApi.update(workspaceId, { name: newName });\n          invalidateWorkspaceQueries(ctx.queryClient, workspaceId);\n        },\n      });\n    },\n  },\n\n  PinWorkspace: {\n    id: 'pin-workspace',\n    label: (workspace?: Workspace) => (workspace?.pinned ? 'Unpin' : 'Pin'),\n    icon: PushPinIcon,\n    shortcut: 'W P',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    execute: async (ctx, workspaceId) => {\n      const workspace = await getWorkspace(ctx.queryClient, workspaceId);\n      await workspacesApi.update(workspaceId, {\n        pinned: !workspace.pinned,\n      });\n      invalidateWorkspaceQueries(ctx.queryClient, workspaceId);\n    },\n  },\n\n  ArchiveWorkspace: {\n    id: 'archive-workspace',\n    label: (workspace?: Workspace) =>\n      workspace?.archived ? 'Unarchive' : 'Archive',\n    icon: ArchiveIcon,\n    shortcut: 'W A',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.layoutMode === 'workspaces',\n    isActive: (ctx) => ctx.workspaceArchived,\n    execute: async (ctx, workspaceId) => {\n      const workspace = await getWorkspace(ctx.queryClient, workspaceId);\n      const wasArchived = workspace.archived;\n\n      // Calculate next workspace before archiving\n      const nextWorkspaceId = !wasArchived\n        ? getNextWorkspaceId(ctx.activeWorkspaces, workspaceId)\n        : null;\n\n      // Perform the archive/unarchive\n      await workspacesApi.update(workspaceId, { archived: !wasArchived });\n      invalidateWorkspaceQueries(ctx.queryClient, workspaceId);\n\n      // Select next workspace after successful archive\n      if (!wasArchived && nextWorkspaceId) {\n        ctx.selectWorkspace(nextWorkspaceId);\n      }\n    },\n  },\n\n  DeleteWorkspace: {\n    id: 'delete-workspace',\n    label: 'Delete',\n    icon: TrashIcon,\n    shortcut: 'W X',\n    variant: 'destructive',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    execute: async (ctx, workspaceId) => {\n      const workspace = await getWorkspace(ctx.queryClient, workspaceId);\n\n      // Check if workspace is linked to a remote issue\n      const remoteWs = ctx.remoteWorkspaces.find(\n        (w) => w.local_workspace_id === workspaceId\n      );\n      const linkedIssueSimpleId = remoteWs?.issue_id\n        ? ctx.projectMutations?.getIssue(remoteWs.issue_id)?.simple_id\n        : undefined;\n      const branchStatus = await workspacesApi.getBranchStatus(workspaceId);\n      const hasOpenPR = branchStatus.some((repoStatus) =>\n        repoStatus.merges?.some(\n          (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open'\n        )\n      );\n\n      const result = await DeleteWorkspaceDialog.show({\n        branchName: workspace.branch,\n        hasOpenPR,\n        isLinkedToIssue: Boolean(remoteWs?.issue_id),\n        linkedIssueSimpleId,\n      });\n      if (result.action === 'confirmed') {\n        // Calculate next workspace before deleting (only if deleting current)\n        const isCurrentWorkspace = ctx.currentWorkspaceId === workspaceId;\n        const nextWorkspaceId = isCurrentWorkspace\n          ? getNextWorkspaceId(ctx.activeWorkspaces, workspaceId)\n          : null;\n\n        await workspacesApi.delete(workspaceId, result.deleteBranches);\n\n        // Unlink from remote issue after successful deletion\n        if (result.unlinkFromIssue) {\n          await workspacesApi.unlinkFromIssue(workspaceId);\n        }\n        ctx.queryClient.invalidateQueries({\n          queryKey: workspaceSummaryKeys.all,\n        });\n\n        // Navigate away if we deleted the current workspace\n        if (isCurrentWorkspace) {\n          if (nextWorkspaceId) {\n            ctx.selectWorkspace(nextWorkspaceId);\n          } else {\n            ctx.appNavigation.goToWorkspacesCreate();\n          }\n        }\n      }\n    },\n  },\n\n  StartReview: {\n    id: 'start-review',\n    label: 'Start Review',\n    icon: HighlighterIcon,\n    requiresTarget: ActionTargetType.WORKSPACE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    getTooltip: () => 'Review changes with agent',\n    execute: async (_ctx, workspaceId) => {\n      await StartReviewDialog.show({\n        workspaceId,\n      });\n    },\n  },\n\n  SpinOffWorkspace: {\n    id: 'spin-off-workspace',\n    label: 'Spin off workspace',\n    icon: GitForkIcon,\n    requiresTarget: ActionTargetType.WORKSPACE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    execute: async (ctx, workspaceId) => {\n      try {\n        const [workspace, repos] = await Promise.all([\n          getWorkspace(ctx.queryClient, workspaceId),\n          workspacesApi.getRepos(workspaceId),\n        ]);\n        const linkedIssue = await resolveLinkedIssue(\n          workspaceId,\n          ctx.remoteWorkspaces\n        );\n\n        const createState = buildWorkspaceCreateInitialState({\n          prompt: null,\n          defaults: {\n            preferredRepos: repos.map((r) => ({\n              repo_id: r.id,\n              target_branch: workspace.branch,\n            })),\n          },\n          linkedIssue,\n        });\n        setCreateModeSeedState(createState);\n        ctx.appNavigation.goToWorkspacesCreate();\n      } catch {\n        ctx.appNavigation.goToWorkspacesCreate();\n      }\n    },\n  },\n\n  // === Global/Navigation Actions ===\n  NewWorkspace: {\n    id: 'new-workspace',\n    label: 'New Workspace',\n    icon: PlusIcon,\n    shortcut: 'G N',\n    requiresTarget: ActionTargetType.NONE,\n    execute: (ctx) => {\n      ctx.appNavigation.goToWorkspacesCreate();\n    },\n  },\n\n  CreateWorkspaceFromPR: {\n    id: 'create-workspace-from-pr',\n    label: 'Create Workspace from PR',\n    icon: GitPullRequestIcon,\n    keywords: ['pull request'],\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'workspaces',\n    execute: async () => {\n      await CreateWorkspaceFromPrDialog.show({});\n    },\n  } satisfies GlobalActionDefinition,\n\n  Settings: {\n    id: 'settings',\n    label: 'Settings',\n    icon: GearIcon,\n    shortcut: 'G S',\n    requiresTarget: ActionTargetType.NONE,\n    execute: async () => {\n      await SettingsDialog.show();\n    },\n  },\n\n  ProjectSettings: {\n    id: 'project-settings',\n    label: 'Project Settings',\n    icon: GearIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'kanban',\n    execute: async (ctx) => {\n      await SettingsDialog.show({\n        initialSection: 'remote-projects',\n        initialState: {\n          organizationId: ctx.kanbanOrgId,\n          projectId: ctx.kanbanProjectId,\n        },\n      });\n    },\n  } satisfies GlobalActionDefinition,\n\n  SignIn: {\n    id: 'sign-in',\n    label: 'Sign In',\n    icon: SignInIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => !ctx.isSignedIn,\n    execute: async () => {\n      const { OAuthDialog } = await import(\n        '@/shared/dialogs/global/OAuthDialog'\n      );\n      await OAuthDialog.show({});\n    },\n  } satisfies GlobalActionDefinition,\n\n  SignOut: {\n    id: 'sign-out',\n    label: 'Sign Out',\n    icon: SignOutIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.isSignedIn,\n    execute: async (ctx) => {\n      const { oauthApi } = await import('@/shared/lib/api');\n      const { useOrganizationStore } = await import(\n        '@/shared/stores/useOrganizationStore'\n      );\n      const { organizationKeys } = await import(\n        '@/shared/hooks/organizationKeys'\n      );\n\n      await oauthApi.logout();\n      useOrganizationStore.getState().clearSelectedOrgId();\n      ctx.queryClient.removeQueries({ queryKey: organizationKeys.all });\n      // Invalidate user-system query to update loginStatus/useAuth state\n      await ctx.queryClient.invalidateQueries({ queryKey: ['user-system'] });\n      ctx.appNavigation.goToWorkspaces();\n    },\n  } satisfies GlobalActionDefinition,\n\n  Feedback: {\n    id: 'feedback',\n    label: 'Give Feedback',\n    icon: MegaphoneIcon,\n    requiresTarget: ActionTargetType.NONE,\n    execute: () => {\n      posthog.displaySurvey('019bb6e8-3d36-0000-1806-7330cd3c727e');\n    },\n  },\n\n  WorkspacesGuide: {\n    id: 'workspaces-guide',\n    label: 'Workspaces Guide',\n    icon: QuestionIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'workspaces',\n    execute: async () => {\n      await WorkspacesGuideDialog.show();\n    },\n  },\n\n  ProjectsGuide: {\n    id: 'projects-guide',\n    label: 'Projects Guide',\n    icon: QuestionIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'kanban',\n    execute: async () => {\n      await ProjectsGuideDialog.show();\n    },\n  } satisfies GlobalActionDefinition,\n\n  OpenCommandBar: {\n    id: 'open-command-bar',\n    label: 'Open Command Bar',\n    icon: ListIcon,\n    shortcut: '{mod} K',\n    requiresTarget: ActionTargetType.NONE,\n    execute: async () => {\n      // Dynamic import to avoid circular dependency (pages.ts imports Actions)\n      const { CommandBarDialog } = await import(\n        '@/shared/dialogs/command-bar/CommandBarDialog'\n      );\n      CommandBarDialog.show();\n    },\n  },\n\n  // === Diff View Actions ===\n  ToggleDiffViewMode: {\n    id: 'toggle-diff-view-mode',\n    label: () =>\n      useDiffViewStore.getState().mode === 'unified'\n        ? 'Switch to Side-by-Side View'\n        : 'Switch to Inline View',\n    icon: ColumnsIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES &&\n      ctx.layoutMode === 'workspaces',\n    isActive: (ctx) => ctx.diffViewMode === 'split',\n    getIcon: (ctx) => (ctx.diffViewMode === 'split' ? ColumnsIcon : RowsIcon),\n    getTooltip: (ctx) =>\n      ctx.diffViewMode === 'split' ? 'Inline view' : 'Side-by-side view',\n    execute: () => {\n      useDiffViewStore.getState().toggle();\n    },\n  },\n\n  ToggleIgnoreWhitespace: {\n    id: 'toggle-ignore-whitespace',\n    label: () =>\n      useDiffViewStore.getState().ignoreWhitespace\n        ? 'Show Whitespace Changes'\n        : 'Ignore Whitespace Changes',\n    icon: EyeSlashIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES &&\n      ctx.layoutMode === 'workspaces',\n    execute: () => {\n      const store = useDiffViewStore.getState();\n      store.setIgnoreWhitespace(!store.ignoreWhitespace);\n    },\n  },\n\n  ToggleWrapLines: {\n    id: 'toggle-wrap-lines',\n    label: () =>\n      useDiffViewStore.getState().wrapText\n        ? 'Disable Line Wrapping'\n        : 'Enable Line Wrapping',\n    icon: TextAlignLeftIcon,\n    shortcut: 'T W',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES &&\n      ctx.layoutMode === 'workspaces',\n    execute: () => {\n      const store = useDiffViewStore.getState();\n      store.setWrapText(!store.wrapText);\n    },\n  },\n\n  // === Layout Panel Actions ===\n  ToggleLeftSidebar: {\n    id: 'toggle-left-sidebar',\n    label: () =>\n      useUiPreferencesStore.getState().isLeftSidebarVisible\n        ? 'Hide Left Sidebar'\n        : 'Show Left Sidebar',\n    icon: SidebarSimpleIcon,\n    shortcut: 'V S',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'workspaces',\n    isActive: (ctx) => ctx.isLeftSidebarVisible,\n    execute: () => {\n      useUiPreferencesStore.getState().toggleLeftSidebar();\n    },\n  },\n\n  ToggleLeftMainPanel: {\n    id: 'toggle-left-main-panel',\n    label: 'Toggle Chat Panel',\n    icon: ChatsTeardropIcon,\n    shortcut: 'V H',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'workspaces',\n    isActive: (ctx) => ctx.isLeftMainPanelVisible,\n    isEnabled: (ctx) =>\n      !(ctx.isLeftMainPanelVisible && ctx.rightMainPanelMode === null),\n    getLabel: (ctx) =>\n      ctx.isLeftMainPanelVisible ? 'Hide Chat Panel' : 'Show Chat Panel',\n    execute: (ctx) => {\n      useUiPreferencesStore\n        .getState()\n        .toggleLeftMainPanel(ctx.currentWorkspaceId ?? undefined);\n    },\n  },\n\n  ToggleRightSidebar: {\n    id: 'toggle-right-sidebar',\n    label: () =>\n      useUiPreferencesStore.getState().isRightSidebarVisible\n        ? 'Hide Right Sidebar'\n        : 'Show Right Sidebar',\n    icon: RightSidebarIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'workspaces',\n    isActive: (ctx) => ctx.isRightSidebarVisible,\n    execute: () => {\n      useUiPreferencesStore.getState().toggleRightSidebar();\n    },\n  },\n\n  ToggleChangesMode: {\n    id: 'toggle-changes-mode',\n    label: 'Toggle Changes Panel',\n    icon: GitDiffIcon,\n    shortcut: 'V C',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => !ctx.isCreateMode && ctx.layoutMode === 'workspaces',\n    isActive: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES,\n    isEnabled: (ctx) => !ctx.isCreateMode,\n    getLabel: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES\n        ? 'Hide Changes Panel'\n        : 'Show Changes Panel',\n    execute: (ctx) => {\n      useUiPreferencesStore\n        .getState()\n        .toggleRightMainPanelMode(\n          RIGHT_MAIN_PANEL_MODES.CHANGES,\n          ctx.currentWorkspaceId ?? undefined\n        );\n    },\n  },\n\n  ToggleLogsMode: {\n    id: 'toggle-logs-mode',\n    label: 'Toggle Logs Panel',\n    icon: TerminalIcon,\n    shortcut: 'V L',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => !ctx.isCreateMode && ctx.layoutMode === 'workspaces',\n    isActive: (ctx) => ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS,\n    isEnabled: (ctx) => !ctx.isCreateMode,\n    getLabel: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS\n        ? 'Hide Logs Panel'\n        : 'Show Logs Panel',\n    execute: (ctx) => {\n      useUiPreferencesStore\n        .getState()\n        .toggleRightMainPanelMode(\n          RIGHT_MAIN_PANEL_MODES.LOGS,\n          ctx.currentWorkspaceId ?? undefined\n        );\n    },\n  },\n\n  TogglePreviewMode: {\n    id: 'toggle-preview-mode',\n    label: 'Toggle Preview Panel',\n    icon: DesktopIcon,\n    shortcut: 'V P',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => !ctx.isCreateMode && ctx.layoutMode === 'workspaces',\n    isActive: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW,\n    isEnabled: (ctx) => !ctx.isCreateMode,\n    getLabel: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW\n        ? 'Hide Preview Panel'\n        : 'Show Preview Panel',\n    execute: (ctx) => {\n      useUiPreferencesStore\n        .getState()\n        .toggleRightMainPanelMode(\n          RIGHT_MAIN_PANEL_MODES.PREVIEW,\n          ctx.currentWorkspaceId ?? undefined\n        );\n    },\n  },\n\n  // === Diff Actions for Navbar ===\n  ToggleAllDiffs: {\n    id: 'toggle-all-diffs',\n    label: () => {\n      const { diffPaths } = useDiffViewStore.getState();\n      const { expanded } = useUiPreferencesStore.getState();\n      const keys = diffPaths.map((p) => `diff:${p}`);\n      const isAllExpanded =\n        keys.length > 0 && keys.every((k) => expanded[k] !== false);\n      return isAllExpanded ? 'Collapse All Diffs' : 'Expand All Diffs';\n    },\n    icon: CaretDoubleUpIcon,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES &&\n      ctx.layoutMode === 'workspaces',\n    getIcon: (ctx) =>\n      ctx.isAllDiffsExpanded ? CaretDoubleUpIcon : CaretDoubleDownIcon,\n    getTooltip: (ctx) =>\n      ctx.isAllDiffsExpanded ? 'Collapse all diffs' : 'Expand all diffs',\n    execute: () => {\n      const { diffPaths } = useDiffViewStore.getState();\n      const { expanded, setExpandedAll } = useUiPreferencesStore.getState();\n      const keys = diffPaths.map((p) => `diff:${p}`);\n      const isAllExpanded =\n        keys.length > 0 && keys.every((k) => expanded[k] !== false);\n      setExpandedAll(keys, !isAllExpanded);\n    },\n  },\n\n  // === ContextBar Actions ===\n  OpenInIDE: {\n    id: 'open-in-ide',\n    label: 'Open in IDE',\n    icon: 'ide-icon' as const,\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    getTooltip: (ctx) => `Open in ${getIdeName(ctx.editorType)}`,\n    execute: async (ctx) => {\n      if (!ctx.currentWorkspaceId) return;\n      try {\n        const response = await workspacesApi.openEditor(\n          ctx.currentWorkspaceId,\n          {\n            editor_type: null,\n            file_path: null,\n          }\n        );\n        if (response.url) {\n          window.open(response.url, '_blank');\n        }\n      } catch {\n        // Show editor selection dialog on failure\n        EditorSelectionDialog.show({\n          selectedAttemptId: ctx.currentWorkspaceId,\n        });\n      }\n    },\n  },\n\n  CopyWorkspacePath: {\n    id: 'copy-workspace-path',\n    label: 'Copy Workspace Path',\n    icon: 'copy-icon' as const,\n    shortcut: 'Y P',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    execute: async (ctx) => {\n      if (!ctx.containerRef) return;\n      await navigator.clipboard.writeText(ctx.containerRef);\n    },\n  },\n\n  CopyRawLogs: {\n    id: 'copy-raw-logs',\n    label: 'Copy Raw Logs',\n    icon: CopyIcon,\n    shortcut: 'Y L',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS &&\n      ctx.logsPanelContent?.type !== 'terminal',\n    execute: async (ctx) => {\n      if (!ctx.currentLogs || ctx.currentLogs.length === 0) return;\n      const rawText = ctx.currentLogs.map((log) => log.content).join('\\n');\n      await navigator.clipboard.writeText(rawText);\n    },\n  },\n\n  ToggleDevServer: {\n    id: 'toggle-dev-server',\n    label: 'Dev Server',\n    icon: PlayIcon,\n    shortcut: 'T D',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    isEnabled: (ctx) =>\n      ctx.devServerState !== 'starting' && ctx.devServerState !== 'stopping',\n    getIcon: (ctx) => {\n      if (\n        ctx.devServerState === 'starting' ||\n        ctx.devServerState === 'stopping'\n      ) {\n        return SpinnerIcon;\n      }\n      if (ctx.devServerState === 'running') {\n        return PauseIcon;\n      }\n      return PlayIcon;\n    },\n    getTooltip: (ctx) => {\n      switch (ctx.devServerState) {\n        case 'starting':\n          return 'Starting dev server...';\n        case 'stopping':\n          return 'Stopping dev server...';\n        case 'running':\n          return 'Stop dev server';\n        default:\n          return 'Start dev server';\n      }\n    },\n    getLabel: (ctx) =>\n      ctx.devServerState === 'running' ? 'Stop Dev Server' : 'Start Dev Server',\n    execute: (ctx) => {\n      if (ctx.runningDevServers.length > 0) {\n        ctx.stopDevServer();\n      } else {\n        ctx.startDevServer();\n        // Auto-open preview mode when starting dev server\n        useUiPreferencesStore\n          .getState()\n          .setRightMainPanelMode(\n            RIGHT_MAIN_PANEL_MODES.PREVIEW,\n            ctx.currentWorkspaceId ?? undefined\n          );\n      }\n    },\n  },\n\n  // === Git Actions ===\n  GitCreatePR: {\n    id: 'git-create-pr',\n    label: 'Create Pull Request',\n    icon: GitPullRequestIcon,\n    shortcut: 'X P',\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    execute: async (ctx, workspaceId, repoId) => {\n      const workspace = await getWorkspace(ctx.queryClient, workspaceId);\n\n      const repos = await workspacesApi.getRepos(workspaceId);\n      const repo = repos.find((r) => r.id === repoId);\n\n      // Resolve vibe-kanban identifier from remote workspace + issue\n      let issueIdentifier: string | undefined;\n      const remoteWs = ctx.remoteWorkspaces.find(\n        (w) => w.local_workspace_id === workspaceId\n      );\n      if (remoteWs?.issue_id && ctx.projectMutations?.getIssue) {\n        const issue = ctx.projectMutations.getIssue(remoteWs.issue_id);\n        issueIdentifier = issue?.simple_id || remoteWs.issue_id;\n      }\n\n      const result = await CreatePRDialog.show({\n        attempt: workspace,\n        repoId,\n        targetBranch: repo?.target_branch,\n        issueIdentifier,\n      });\n\n      if (!result.success && result.error) {\n        throw new Error(result.error);\n      }\n    },\n  },\n\n  GitLinkPR: {\n    id: 'git-link-pr',\n    label: 'Link Pull Request',\n    icon: LinkIcon,\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos && !ctx.hasOpenPR,\n    execute: async (ctx, workspaceId, repoId) => {\n      const result = await workspacesApi.attachPr(workspaceId, {\n        repo_id: repoId,\n      });\n\n      if (result.success && result.data.pr_attached && result.data.pr_number) {\n        invalidateWorkspaceQueries(ctx.queryClient, workspaceId);\n        ctx.queryClient.invalidateQueries({\n          queryKey: ['branch-status'],\n        });\n\n        await ConfirmDialog.show({\n          title: 'Pull Request Linked',\n          message: `Linked PR #${result.data.pr_number}${result.data.pr_url ? ` — ${result.data.pr_url}` : ''}`,\n          confirmText: 'OK',\n          showCancelButton: false,\n          variant: 'success',\n        });\n      } else if (result.success && !result.data.pr_attached) {\n        await ConfirmDialog.show({\n          title: 'No Pull Request Found',\n          message:\n            'No open pull request was found matching this branch. Make sure a PR exists for this branch on the remote.',\n          confirmText: 'OK',\n          showCancelButton: false,\n          variant: 'info',\n        });\n      } else if (!result.success) {\n        throw new Error(result.message || 'Failed to attach PR');\n      }\n    },\n  },\n\n  GitMerge: {\n    id: 'git-merge',\n    label: 'Merge',\n    icon: GitMergeIcon,\n    shortcut: 'X M',\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    execute: async (ctx, workspaceId, repoId) => {\n      // Check for existing conflicts first\n      const branchStatus = await workspacesApi.getBranchStatus(workspaceId);\n      const repoStatus = branchStatus?.find((s) => s.repo_id === repoId);\n\n      // Check if repo has an open PR - cannot merge directly\n      const hasOpenPR = repoStatus?.merges?.some(\n        (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open'\n      );\n      if (hasOpenPR) {\n        await ConfirmDialog.show({\n          title: 'Cannot Merge',\n          message:\n            'This repository has an open pull request. Please close or merge the PR before merging directly.',\n          confirmText: 'OK',\n          showCancelButton: false,\n        });\n        return;\n      }\n\n      const hasConflicts =\n        repoStatus?.is_rebase_in_progress ||\n        (repoStatus?.conflicted_files?.length ?? 0) > 0;\n\n      if (hasConflicts && repoStatus) {\n        // Skip showing the dialog if a process is already running\n        // (e.g. an AI session is already resolving these conflicts)\n        const isRunning = ctx.activeWorkspaces.find(\n          (w) => w.id === workspaceId\n        )?.isRunning;\n        if (isRunning) return;\n\n        // Show resolve conflicts dialog\n        const workspace = await getWorkspace(ctx.queryClient, workspaceId);\n        const result = await ResolveConflictsDialog.show({\n          workspaceId,\n          conflictOp: repoStatus.conflict_op ?? 'merge',\n          sourceBranch: workspace.branch,\n          targetBranch: repoStatus.target_branch_name,\n          conflictedFiles: repoStatus.conflicted_files ?? [],\n          repoName: repoStatus.repo_name,\n        });\n\n        if (result.action === 'resolved') {\n          invalidateWorkspaceQueries(ctx.queryClient, workspaceId);\n        }\n        return;\n      }\n\n      // Check if branch is behind - need to rebase first\n      const commitsBehind = repoStatus?.commits_behind ?? 0;\n      if (commitsBehind > 0) {\n        // Prompt user to rebase first\n        const confirmRebase = await ConfirmDialog.show({\n          title: 'Rebase Required',\n          message: `Your branch is ${commitsBehind} commit${commitsBehind === 1 ? '' : 's'} behind the target branch. Would you like to rebase first?`,\n          confirmText: 'Rebase',\n          cancelText: 'Cancel',\n        });\n\n        if (confirmRebase === 'confirmed') {\n          // Open rebase dialog - it loads branches/status internally\n          await RebaseDialog.show({\n            workspaceId: workspaceId,\n            repoId,\n          });\n        }\n        return;\n      }\n\n      const confirmResult = await ConfirmDialog.show({\n        title: 'Merge Branch',\n        message:\n          'Are you sure you want to merge this branch into the target branch?',\n        confirmText: 'Merge',\n        cancelText: 'Cancel',\n      });\n\n      if (confirmResult === 'confirmed') {\n        await workspacesApi.merge(workspaceId, { repo_id: repoId });\n        invalidateWorkspaceQueries(ctx.queryClient, workspaceId);\n      }\n    },\n  },\n\n  GitRebase: {\n    id: 'git-rebase',\n    label: 'Rebase',\n    icon: ArrowsClockwiseIcon,\n    shortcut: 'X R',\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    execute: async (_ctx, workspaceId, repoId) => {\n      // Open rebase dialog - it loads branches/status internally and handles conflicts\n      await RebaseDialog.show({\n        workspaceId: workspaceId,\n        repoId,\n      });\n    },\n  },\n\n  GitChangeTarget: {\n    id: 'git-change-target',\n    label: 'Change Target Branch',\n    icon: CrosshairIcon,\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    execute: async (ctx, workspaceId, repoId) => {\n      const branches = await repoApi.getBranches(repoId);\n      await ChangeTargetDialog.show({\n        branches: branches.map((branch) => ({\n          name: branch.name,\n          isCurrent: branch.is_current,\n        })),\n        onChangeTargetBranch: async (newTargetBranch) => {\n          await workspacesApi.change_target_branch(workspaceId, {\n            new_target_branch: newTargetBranch,\n            repo_id: repoId,\n          });\n\n          ctx.queryClient.invalidateQueries({\n            queryKey: ['branchStatus', workspaceId],\n          });\n          ctx.queryClient.invalidateQueries({\n            queryKey: workspaceRecordKeys.byId(workspaceId),\n          });\n          ctx.queryClient.invalidateQueries({\n            queryKey: workspaceRepoKeys.byWorkspace(workspaceId),\n          });\n          ctx.queryClient.invalidateQueries({\n            queryKey: repoBranchKeys.byRepo(repoId),\n          });\n        },\n      });\n    },\n  },\n\n  GitPush: {\n    id: 'git-push',\n    label: 'Push',\n    icon: ArrowUpIcon,\n    shortcut: 'X U',\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) =>\n      ctx.hasWorkspace &&\n      ctx.hasGitRepos &&\n      ctx.hasOpenPR &&\n      ctx.hasUnpushedCommits,\n    execute: async (ctx, workspaceId, repoId) => {\n      const result = await workspacesApi.push(workspaceId, { repo_id: repoId });\n      if (!result.success) {\n        if (result.error?.type === 'force_push_required') {\n          throw new Error(\n            'Force push required. The remote branch has diverged.'\n          );\n        }\n        throw new Error('Failed to push changes');\n      }\n      invalidateWorkspaceQueries(ctx.queryClient, workspaceId);\n    },\n  },\n\n  // === Repo-specific Actions (for command bar when selecting a repo) ===\n  RepoCopyPath: {\n    id: 'repo-copy-path',\n    label: 'Copy Repo Path',\n    icon: CopyIcon,\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    execute: async (_ctx, _workspaceId, repoId) => {\n      try {\n        const repo = await repoApi.getById(repoId);\n        if (repo?.path) {\n          await navigator.clipboard.writeText(repo.path);\n        }\n      } catch (err) {\n        console.error('Failed to copy repo path:', err);\n        throw new Error('Failed to copy repository path');\n      }\n    },\n  },\n\n  RepoOpenInIDE: {\n    id: 'repo-open-in-ide',\n    label: 'Open Repo in IDE',\n    icon: DesktopIcon,\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    execute: async (_ctx, _workspaceId, repoId) => {\n      try {\n        const response = await repoApi.openEditor(repoId, {\n          editor_type: null,\n          file_path: null,\n        });\n        if (response.url) {\n          window.open(response.url, '_blank');\n        }\n      } catch (err) {\n        console.error('Failed to open repo in editor:', err);\n        throw new Error('Failed to open repository in IDE');\n      }\n    },\n  },\n\n  RepoSettings: {\n    id: 'repo-settings',\n    label: 'Repository Settings',\n    icon: GearIcon,\n    requiresTarget: ActionTargetType.GIT,\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    execute: async (_ctx, _workspaceId, repoId) => {\n      await SettingsDialog.show({\n        initialSection: 'repos',\n        initialState: {\n          repoId,\n        },\n      });\n    },\n  },\n\n  // === Script Actions ===\n  RunSetupScript: {\n    id: 'run-setup-script',\n    label: 'Run Setup Script',\n    icon: TerminalIcon,\n    shortcut: 'R S',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    isEnabled: (ctx) => !ctx.isAttemptRunning,\n    execute: async (_ctx, workspaceId) => {\n      const result = await workspacesApi.runSetupScript(workspaceId);\n      if (!result.success) {\n        if (result.error?.type === 'no_script_configured') {\n          throw new Error('No setup script configured for this project');\n        }\n        if (result.error?.type === 'process_already_running') {\n          throw new Error('Cannot run script while another process is running');\n        }\n        throw new Error('Failed to run setup script');\n      }\n    },\n  },\n\n  RunCleanupScript: {\n    id: 'run-cleanup-script',\n    label: 'Run Cleanup Script',\n    icon: TerminalIcon,\n    shortcut: 'R C',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    isEnabled: (ctx) => !ctx.isAttemptRunning,\n    execute: async (_ctx, workspaceId) => {\n      const result = await workspacesApi.runCleanupScript(workspaceId);\n      if (!result.success) {\n        if (result.error?.type === 'no_script_configured') {\n          throw new Error('No cleanup script configured for this project');\n        }\n        if (result.error?.type === 'process_already_running') {\n          throw new Error('Cannot run script while another process is running');\n        }\n        throw new Error('Failed to run cleanup script');\n      }\n    },\n  },\n\n  RunArchiveScript: {\n    id: 'run-archive-script',\n    label: 'Run Archive Script',\n    icon: TerminalIcon,\n    shortcut: 'R A',\n    requiresTarget: ActionTargetType.WORKSPACE,\n    isVisible: (ctx) => ctx.hasWorkspace,\n    isEnabled: (ctx) => !ctx.isAttemptRunning,\n    execute: async (_ctx, workspaceId) => {\n      const result = await workspacesApi.runArchiveScript(workspaceId);\n      if (!result.success) {\n        if (result.error?.type === 'no_script_configured') {\n          throw new Error('No archive script configured for this project');\n        }\n        if (result.error?.type === 'process_already_running') {\n          throw new Error('Cannot run script while another process is running');\n        }\n        throw new Error('Failed to run archive script');\n      }\n    },\n  } satisfies WorkspaceActionDefinition,\n\n  // === Issue Actions ===\n  CreateIssue: {\n    id: 'create-issue',\n    label: 'Create Issue',\n    icon: PlusIcon,\n    shortcut: 'I C',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'kanban' && !ctx.isCreatingIssue,\n    execute: (ctx) => {\n      ctx.navigateToCreateIssue({ statusId: ctx.defaultCreateStatusId });\n    },\n  } satisfies GlobalActionDefinition,\n\n  ChangeIssueStatus: {\n    id: 'change-issue-status',\n    label: 'Change Status',\n    icon: ArrowsLeftRightIcon,\n    shortcut: 'I S',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      await ctx.openStatusSelection(projectId, issueIds);\n    },\n  } satisfies IssueActionDefinition,\n\n  ChangeNewIssueStatus: {\n    id: 'change-new-issue-status',\n    label: 'Change Status',\n    icon: ArrowsLeftRightIcon,\n    shortcut: 'I S',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'kanban' && ctx.isCreatingIssue,\n    execute: async (ctx) => {\n      if (!ctx.kanbanProjectId) return;\n      const { ProjectSelectionDialog } = await import(\n        '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n      );\n      await ProjectSelectionDialog.show({\n        projectId: ctx.kanbanProjectId,\n        selection: { type: 'status', issueIds: [], isCreateMode: true },\n      });\n    },\n  } satisfies GlobalActionDefinition,\n\n  ChangePriority: {\n    id: 'change-issue-priority',\n    label: 'Change Priority',\n    icon: ArrowFatLineUpIcon,\n    shortcut: 'I P',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      await ctx.openPrioritySelection(projectId, issueIds);\n    },\n  } satisfies IssueActionDefinition,\n\n  ChangeNewIssuePriority: {\n    id: 'change-new-issue-priority',\n    label: 'Change Priority',\n    icon: ArrowFatLineUpIcon,\n    shortcut: 'I P',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'kanban' && ctx.isCreatingIssue,\n    execute: async (ctx) => {\n      if (!ctx.kanbanProjectId) return;\n      const { ProjectSelectionDialog } = await import(\n        '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n      );\n      await ProjectSelectionDialog.show({\n        projectId: ctx.kanbanProjectId,\n        selection: { type: 'priority', issueIds: [], isCreateMode: true },\n      });\n    },\n  } satisfies GlobalActionDefinition,\n\n  ChangeAssignees: {\n    id: 'change-assignees',\n    label: 'Change Assignees',\n    icon: UsersIcon,\n    shortcut: 'I A',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      await ctx.openAssigneeSelection(projectId, issueIds, false);\n    },\n  } satisfies IssueActionDefinition,\n\n  ChangeNewIssueAssignees: {\n    id: 'change-new-issue-assignees',\n    label: 'Change Assignees',\n    icon: UsersIcon,\n    shortcut: 'I A',\n    requiresTarget: ActionTargetType.NONE,\n    isVisible: (ctx) => ctx.layoutMode === 'kanban' && ctx.isCreatingIssue,\n    execute: async (ctx) => {\n      // Opens assignee selection for the issue being created\n      // ProjectId will be resolved from route params inside the dialog\n      await ctx.openAssigneeSelection('', [], true);\n    },\n  } satisfies GlobalActionDefinition,\n\n  MakeSubIssueOf: {\n    id: 'make-sub-issue-of',\n    label: 'Make Sub-issue of',\n    icon: TreeStructureIcon,\n    shortcut: 'I M',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      if (issueIds.length === 1) {\n        await ctx.openSubIssueSelection(projectId, issueIds[0], 'setParent');\n      }\n    },\n  } satisfies IssueActionDefinition,\n\n  AddSubIssue: {\n    id: 'add-sub-issue',\n    label: 'Add Sub-issue',\n    icon: PlusIcon,\n    shortcut: 'I B',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      if (issueIds.length !== 1) return;\n      const parentIssueId = issueIds[0];\n      const result = await ctx.openSubIssueSelection(\n        projectId,\n        parentIssueId,\n        'addChild'\n      );\n      if (result?.type === 'createNew') {\n        navigateToCreateSubIssue(ctx, parentIssueId);\n      }\n    },\n  } satisfies IssueActionDefinition,\n\n  CreateSubIssue: {\n    id: 'create-sub-issue',\n    label: 'Create Sub-issue',\n    icon: PlusIcon,\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, _projectId, issueIds) => {\n      if (issueIds.length !== 1) return;\n      navigateToCreateSubIssue(ctx, issueIds[0]);\n    },\n  } satisfies IssueActionDefinition,\n\n  RemoveParentIssue: {\n    id: 'remove-parent-issue',\n    label: 'Remove Parent',\n    icon: XIcon,\n    shortcut: 'I U',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' &&\n      ctx.hasSelectedKanbanIssue &&\n      ctx.hasSelectedKanbanIssueParent,\n    execute: async (_ctx, _projectId, issueIds) => {\n      await bulkUpdateIssues(\n        issueIds.map((issueId) => ({\n          id: issueId,\n          changes: {\n            parent_issue_id: null,\n            parent_issue_sort_order: null,\n          },\n        }))\n      );\n    },\n  } satisfies IssueActionDefinition,\n\n  LinkWorkspace: {\n    id: 'link-workspace',\n    label: 'Link Workspace',\n    icon: LinkIcon,\n    shortcut: 'I W',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      if (issueIds.length === 1) {\n        await ctx.openWorkspaceSelection(projectId, issueIds[0]);\n      }\n    },\n  } satisfies IssueActionDefinition,\n\n  DeleteIssue: {\n    id: 'delete-issue',\n    label: 'Delete Issue',\n    icon: TrashIcon,\n    shortcut: 'I X',\n    variant: 'destructive',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, _projectId, issueIds) => {\n      const count = issueIds.length;\n      const result = await ConfirmDialog.show({\n        title: count === 1 ? 'Delete Issue' : `Delete ${count} Issues`,\n        message:\n          count === 1\n            ? 'Are you sure you want to delete this issue? This action cannot be undone.'\n            : `Are you sure you want to delete these ${count} issues? This action cannot be undone.`,\n        confirmText: 'Delete',\n        cancelText: 'Cancel',\n        variant: 'destructive',\n      });\n      if (result === 'confirmed' && ctx.projectMutations?.removeIssue) {\n        for (const issueId of issueIds) {\n          ctx.projectMutations.removeIssue(issueId);\n        }\n      }\n    },\n  } satisfies IssueActionDefinition,\n\n  DuplicateIssue: {\n    id: 'duplicate-issue',\n    label: 'Duplicate Issue',\n    icon: CopyIcon,\n    shortcut: 'I D',\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, _projectId, issueIds) => {\n      if (issueIds.length !== 1) {\n        throw new Error('Can only duplicate one issue at a time');\n      }\n      ctx.projectMutations?.duplicateIssue(issueIds[0]);\n    },\n  } satisfies IssueActionDefinition,\n\n  MarkBlocking: {\n    id: 'mark-blocking',\n    label: 'Mark Blocking',\n    icon: ArrowBendUpRightIcon,\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      if (issueIds.length === 1) {\n        await ctx.openRelationshipSelection(\n          projectId,\n          issueIds[0],\n          'blocking',\n          'forward'\n        );\n      }\n    },\n  } satisfies IssueActionDefinition,\n\n  MarkBlockedBy: {\n    id: 'mark-blocked-by',\n    label: 'Mark Blocked By',\n    icon: ProhibitIcon,\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      if (issueIds.length === 1) {\n        await ctx.openRelationshipSelection(\n          projectId,\n          issueIds[0],\n          'blocking',\n          'reverse'\n        );\n      }\n    },\n  } satisfies IssueActionDefinition,\n\n  MarkRelated: {\n    id: 'mark-related',\n    label: 'Mark Related',\n    icon: ArrowsLeftRightIcon,\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      if (issueIds.length === 1) {\n        await ctx.openRelationshipSelection(\n          projectId,\n          issueIds[0],\n          'related',\n          'forward'\n        );\n      }\n    },\n  } satisfies IssueActionDefinition,\n\n  MarkDuplicateOf: {\n    id: 'mark-duplicate-of',\n    label: 'Mark Duplicate Of',\n    icon: CopyIcon,\n    requiresTarget: ActionTargetType.ISSUE,\n    isVisible: (ctx) =>\n      ctx.layoutMode === 'kanban' && ctx.hasSelectedKanbanIssue,\n    execute: async (ctx, projectId, issueIds) => {\n      if (issueIds.length === 1) {\n        await ctx.openRelationshipSelection(\n          projectId,\n          issueIds[0],\n          'has_duplicate',\n          'forward'\n        );\n      }\n    },\n  } satisfies IssueActionDefinition,\n} as const satisfies Record<string, ActionDefinition>;\n\n// Navbar action groups define which actions appear in each section\nexport const NavbarActionGroups = {\n  left: [Actions.ArchiveWorkspace] as NavbarItem[],\n  right: [\n    Actions.ToggleDiffViewMode,\n    Actions.ToggleAllDiffs,\n    NavbarDivider,\n    Actions.ToggleLeftSidebar,\n    Actions.ToggleLeftMainPanel,\n    Actions.ToggleChangesMode,\n    Actions.ToggleLogsMode,\n    Actions.TogglePreviewMode,\n    Actions.ToggleRightSidebar,\n    NavbarDivider,\n    Actions.OpenCommandBar,\n    Actions.Feedback,\n    Actions.WorkspacesGuide,\n    Actions.ProjectsGuide,\n    Actions.Settings,\n  ] as NavbarItem[],\n};\n\n// ContextBar action groups define which actions appear in each section\nexport const ContextBarActionGroups = {\n  primary: [Actions.OpenInIDE, Actions.CopyWorkspacePath] as ActionDefinition[],\n  secondary: [\n    Actions.ToggleDevServer,\n    Actions.TogglePreviewMode,\n    Actions.ToggleChangesMode,\n  ] as ActionDefinition[],\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/command-bar/actions/pages.ts",
    "content": "import { Actions } from '@/shared/actions';\nimport type { ActionDefinition } from '@/shared/types/actions';\nimport { RIGHT_MAIN_PANEL_MODES } from '@/shared/stores/useUiPreferencesStore';\nimport type { StaticPageId, CommandBarPage } from '@/shared/types/commandBar';\n\nexport const Pages: Record<StaticPageId, CommandBarPage> = {\n  // Root page - shown when opening via CMD+K\n  root: {\n    id: 'root',\n    items: [\n      {\n        type: 'group',\n        label: 'Actions',\n        items: [\n          { type: 'action', action: Actions.NewWorkspace },\n          { type: 'action', action: Actions.CreateWorkspaceFromPR },\n          { type: 'action', action: Actions.OpenInIDE },\n          { type: 'action', action: Actions.CopyWorkspacePath },\n          { type: 'action', action: Actions.CopyRawLogs },\n          { type: 'action', action: Actions.ToggleDevServer },\n\n          { type: 'childPages', id: 'workspaceActions' },\n          { type: 'childPages', id: 'repoActions' },\n          { type: 'childPages', id: 'issueActions' },\n        ],\n      },\n      {\n        type: 'group',\n        label: 'View',\n        items: [\n          { type: 'childPages', id: 'viewOptions' },\n          { type: 'childPages', id: 'diffOptions' },\n        ],\n      },\n      {\n        type: 'group',\n        label: 'General',\n        items: [\n          { type: 'action', action: Actions.SignIn },\n          { type: 'action', action: Actions.SignOut },\n          { type: 'action', action: Actions.Feedback },\n          { type: 'action', action: Actions.WorkspacesGuide },\n          { type: 'action', action: Actions.ProjectsGuide },\n          { type: 'action', action: Actions.ProjectSettings },\n          { type: 'action', action: Actions.Settings },\n        ],\n      },\n    ],\n  },\n\n  // Workspace actions page - shown when clicking three-dots on a workspace\n  workspaceActions: {\n    id: 'workspace-actions',\n    title: 'Workspace Actions',\n    parent: 'root',\n    isVisible: (ctx) => ctx.hasWorkspace,\n    items: [\n      {\n        type: 'group',\n        label: 'Workspace',\n        items: [\n          { type: 'action', action: Actions.StartReview },\n          { type: 'action', action: Actions.RenameWorkspace },\n          { type: 'action', action: Actions.DuplicateWorkspace },\n          { type: 'action', action: Actions.SpinOffWorkspace },\n          { type: 'action', action: Actions.PinWorkspace },\n          { type: 'action', action: Actions.ArchiveWorkspace },\n          { type: 'action', action: Actions.DeleteWorkspace },\n        ],\n      },\n      {\n        type: 'group',\n        label: 'Scripts',\n        items: [\n          { type: 'action', action: Actions.RunSetupScript },\n          { type: 'action', action: Actions.RunCleanupScript },\n          { type: 'action', action: Actions.RunArchiveScript },\n        ],\n      },\n    ],\n  },\n\n  // Diff options page - shown when changes panel is visible\n  diffOptions: {\n    id: 'diff-options',\n    title: 'Diff Options',\n    parent: 'root',\n    isVisible: (ctx) =>\n      ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES,\n    items: [\n      {\n        type: 'group',\n        label: 'Display',\n        items: [\n          { type: 'action', action: Actions.ToggleDiffViewMode },\n          { type: 'action', action: Actions.ToggleWrapLines },\n          { type: 'action', action: Actions.ToggleIgnoreWhitespace },\n          { type: 'action', action: Actions.ToggleAllDiffs },\n        ],\n      },\n    ],\n  },\n\n  // View options page - layout panel controls\n  viewOptions: {\n    id: 'view-options',\n    title: 'View Options',\n    parent: 'root',\n    isVisible: (ctx) => ctx.layoutMode === 'workspaces',\n    items: [\n      {\n        type: 'group',\n        label: 'Panels',\n        items: [\n          { type: 'action', action: Actions.ToggleLeftSidebar },\n          { type: 'action', action: Actions.ToggleLeftMainPanel },\n          { type: 'action', action: Actions.ToggleRightSidebar },\n          { type: 'action', action: Actions.ToggleChangesMode },\n          { type: 'action', action: Actions.ToggleLogsMode },\n          { type: 'action', action: Actions.TogglePreviewMode },\n        ],\n      },\n    ],\n  },\n\n  // Repository actions page - shown when clicking \"...\" on a repo card or via CMD+K\n  repoActions: {\n    id: 'repo-actions',\n    title: 'Repository Actions',\n    parent: 'root',\n    isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,\n    items: [\n      {\n        type: 'group',\n        label: 'Actions',\n        items: [\n          { type: 'action', action: Actions.RepoCopyPath },\n          { type: 'action', action: Actions.RepoOpenInIDE },\n          { type: 'action', action: Actions.RepoSettings },\n          { type: 'action', action: Actions.GitCreatePR },\n          { type: 'action', action: Actions.GitLinkPR },\n          { type: 'action', action: Actions.GitMerge },\n          { type: 'action', action: Actions.GitPush },\n          { type: 'action', action: Actions.GitRebase },\n          { type: 'action', action: Actions.GitChangeTarget },\n        ],\n      },\n    ],\n  },\n\n  // Issue actions page - shown in kanban mode\n  issueActions: {\n    id: 'issue-actions',\n    title: 'Issue Actions',\n    parent: 'root',\n    isVisible: (ctx) => ctx.layoutMode === 'kanban',\n    items: [\n      {\n        type: 'group',\n        label: 'Actions',\n        items: [\n          { type: 'action', action: Actions.CreateIssue },\n          { type: 'action', action: Actions.ChangeIssueStatus },\n          { type: 'action', action: Actions.ChangeNewIssueStatus },\n          { type: 'action', action: Actions.ChangePriority },\n          { type: 'action', action: Actions.ChangeNewIssuePriority },\n          { type: 'action', action: Actions.ChangeAssignees },\n          { type: 'action', action: Actions.ChangeNewIssueAssignees },\n          { type: 'action', action: Actions.MakeSubIssueOf },\n          { type: 'action', action: Actions.AddSubIssue },\n          { type: 'action', action: Actions.RemoveParentIssue },\n          { type: 'action', action: Actions.LinkWorkspace },\n          { type: 'action', action: Actions.MarkBlocking },\n          { type: 'action', action: Actions.MarkBlockedBy },\n          { type: 'action', action: Actions.MarkRelated },\n          { type: 'action', action: Actions.MarkDuplicateOf },\n          { type: 'action', action: Actions.DuplicateIssue },\n          { type: 'action', action: Actions.DeleteIssue },\n        ],\n      },\n    ],\n  },\n};\n\n// Get all actions from a specific page\nexport function getPageActions(pageId: StaticPageId): ActionDefinition[] {\n  const page = Pages[pageId];\n  const actions: ActionDefinition[] = [];\n\n  for (const group of page.items) {\n    for (const item of group.items) {\n      if (item.type === 'action') {\n        actions.push(item.action);\n      }\n    }\n  }\n\n  return actions;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/command-bar/actions/useActionVisibility.ts",
    "content": "import type { ActionVisibilityContext } from '@/shared/types/actions';\nimport type { CommandBarPage } from '@/shared/types/commandBar';\n\n/**\n * Helper to check if a page is visible given the current context.\n * If the page has no isVisible condition, it's always visible.\n */\nexport function isPageVisible(\n  page: CommandBarPage,\n  ctx: ActionVisibilityContext\n): boolean {\n  return page.isVisible ? page.isVisible(ctx) : true;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/AgentIcon.tsx",
    "content": "import { BaseCodingAgent } from 'shared/types';\nimport { useTheme, getResolvedTheme } from '@/shared/hooks/useTheme';\n\ntype AgentIconProps = {\n  agent: BaseCodingAgent | null | undefined;\n  className?: string;\n};\n\nexport function getAgentName(\n  agent: BaseCodingAgent | null | undefined\n): string {\n  if (!agent) return 'Agent';\n  switch (agent) {\n    case BaseCodingAgent.CLAUDE_CODE:\n      return 'Claude Code';\n    case BaseCodingAgent.AMP:\n      return 'AMP';\n    case BaseCodingAgent.GEMINI:\n      return 'Gemini';\n    case BaseCodingAgent.CODEX:\n      return 'Codex';\n    case BaseCodingAgent.OPENCODE:\n      return 'OpenCode';\n    case BaseCodingAgent.CURSOR_AGENT:\n      return 'Cursor';\n    case BaseCodingAgent.QWEN_CODE:\n      return 'Qwen';\n    case BaseCodingAgent.COPILOT:\n      return 'Copilot';\n    case BaseCodingAgent.DROID:\n      return 'Droid';\n  }\n}\n\nexport function AgentIcon({ agent, className = 'h-4 w-4' }: AgentIconProps) {\n  const { theme } = useTheme();\n  const resolvedTheme = getResolvedTheme(theme);\n  const isDark = resolvedTheme === 'dark';\n  const suffix = isDark ? '-dark' : '-light';\n\n  if (!agent) {\n    return null;\n  }\n\n  const agentName = getAgentName(agent);\n  let iconPath = '';\n\n  switch (agent) {\n    case BaseCodingAgent.CLAUDE_CODE:\n      iconPath = `/agents/claude${suffix}.svg`;\n      break;\n    case BaseCodingAgent.AMP:\n      iconPath = `/agents/amp${suffix}.svg`;\n      break;\n    case BaseCodingAgent.GEMINI:\n      iconPath = `/agents/gemini${suffix}.svg`;\n      break;\n    case BaseCodingAgent.CODEX:\n      iconPath = `/agents/codex${suffix}.svg`;\n      break;\n    case BaseCodingAgent.OPENCODE:\n      iconPath = `/agents/opencode${suffix}.svg`;\n      break;\n    case BaseCodingAgent.CURSOR_AGENT:\n      iconPath = `/agents/cursor${suffix}.svg`;\n      break;\n    case BaseCodingAgent.QWEN_CODE:\n      iconPath = `/agents/qwen${suffix}.svg`;\n      break;\n    case BaseCodingAgent.COPILOT:\n      iconPath = `/agents/copilot${suffix}.svg`;\n      break;\n    case BaseCodingAgent.DROID:\n      iconPath = `/agents/droid${suffix}.svg`;\n      break;\n    default:\n      return null;\n  }\n\n  return <img src={iconPath} alt={agentName} className={className} />;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/CopyButton.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { CheckIcon, type Icon } from '@phosphor-icons/react';\nimport { cn } from '@/shared/lib/utils';\nimport { Tooltip } from '@vibe/ui/components/Tooltip';\n\ninterface CopyButtonProps {\n  onCopy: () => void;\n  disabled: boolean;\n  iconSize: string;\n  /** Icon to show before copying */\n  icon: Icon;\n}\n\n/**\n * Copy button with self-contained feedback state.\n * Shows a checkmark for 2 seconds after copying.\n */\nexport function CopyButton({\n  onCopy,\n  disabled,\n  iconSize,\n  icon: DefaultIcon,\n}: CopyButtonProps) {\n  const { t } = useTranslation('common');\n  const [copied, setCopied] = useState(false);\n\n  useEffect(() => {\n    if (!copied) return;\n    const timer = setTimeout(() => setCopied(false), 2000);\n    return () => clearTimeout(timer);\n  }, [copied]);\n\n  const handleClick = () => {\n    onCopy();\n    setCopied(true);\n  };\n\n  const IconComponent = copied ? CheckIcon : DefaultIcon;\n  const tooltip = copied ? t('actions.copied') : t('actions.copyPath');\n  const iconClassName = copied\n    ? 'text-success hover:text-success group-hover:text-success'\n    : undefined;\n\n  const button = (\n    <button\n      className={cn(\n        'flex items-center justify-center transition-colors',\n        'drop-shadow-[2px_2px_4px_rgba(121,121,121,0.25)]',\n        'text-low group-hover:text-normal'\n      )}\n      aria-label={tooltip}\n      onClick={handleClick}\n      disabled={disabled}\n    >\n      <IconComponent className={cn(iconSize, iconClassName)} weight=\"bold\" />\n    </button>\n  );\n\n  return (\n    <Tooltip content={tooltip} side=\"top\">\n      {button}\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/CreateChatBoxContainer.tsx",
    "content": "import { useMemo, useCallback, useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useDropzone } from 'react-dropzone';\nimport { useCreateMode } from '@/features/create-mode/model/useCreateMode';\nimport { AgentIcon } from '@/shared/components/AgentIcon';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { useCreateWorkspace } from '@/shared/hooks/useCreateWorkspace';\nimport { useCreateAttachments } from '@/shared/hooks/useCreateAttachments';\nimport { useExecutorConfig } from '@/shared/hooks/useExecutorConfig';\nimport { saveProjectRepoDefaults } from '@/shared/hooks/useProjectRepoDefaults';\nimport { getSortedExecutorVariantKeys } from '@/shared/lib/executor';\nimport {\n  toPrettyCase,\n  splitMessageToTitleDescription,\n} from '@/shared/lib/string';\nimport type { BaseCodingAgent, Repo } from 'shared/types';\nimport { CreateChatBox } from '@vibe/ui/components/CreateChatBox';\nimport { SettingsDialog } from '@/shared/dialogs/settings/SettingsDialog';\nimport { CreateModeRepoPickerBar } from './CreateModeRepoPickerBar';\nimport { ModelSelectorContainer } from '@/shared/components/ModelSelectorContainer';\n\nfunction getRepoDisplayName(repo: Repo) {\n  return repo.display_name || repo.name;\n}\n\nconst BRANCH_LABEL_MAX_CHARS = 15;\n\nfunction truncateBranchLabel(branch: string) {\n  return branch.length > BRANCH_LABEL_MAX_CHARS\n    ? `${branch.slice(0, BRANCH_LABEL_MAX_CHARS)}...`\n    : branch;\n}\n\ninterface CreateChatBoxContainerProps {\n  onWorkspaceCreated: (workspaceId: string) => void;\n}\n\nexport function CreateChatBoxContainer({\n  onWorkspaceCreated,\n}: CreateChatBoxContainerProps) {\n  const { t } = useTranslation('common');\n  const { profiles, config } = useUserSystem();\n  const {\n    repos,\n    targetBranches,\n    message,\n    setMessage,\n    clearDraft,\n    hasInitialValue,\n    hasResolvedInitialRepoDefaults,\n    linkedIssue,\n    clearLinkedIssue,\n    preferredExecutorConfig,\n    executorConfig: draftConfig,\n    setExecutorConfig: setDraftConfig,\n    attachments: draftAttachments,\n    setAttachments: setDraftAttachments,\n  } = useCreateMode();\n\n  const { createWorkspace } = useCreateWorkspace();\n  const hasSelectedRepos = repos.length > 0;\n  const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);\n  const [hasInitializedStep, setHasInitializedStep] = useState(false);\n  const [isSelectingRepos, setIsSelectingRepos] = useState(true);\n\n  useEffect(() => {\n    if (!hasInitialValue || hasInitializedStep) return;\n    if (!hasSelectedRepos && !hasResolvedInitialRepoDefaults) return;\n\n    setIsSelectingRepos(!hasSelectedRepos);\n    setHasInitializedStep(true);\n  }, [\n    hasInitialValue,\n    hasInitializedStep,\n    hasSelectedRepos,\n    hasResolvedInitialRepoDefaults,\n  ]);\n\n  const showRepoPickerStep = !hasSelectedRepos || isSelectingRepos;\n  const showChatStep = hasSelectedRepos && !isSelectingRepos;\n\n  // Attachment handling - insert markdown and track attachment IDs\n  const handleInsertMarkdown = useCallback(\n    (markdown: string) => {\n      const newMessage = message.trim()\n        ? `${message}\\n\\n${markdown}`\n        : markdown;\n      setMessage(newMessage);\n    },\n    [message, setMessage]\n  );\n\n  const { uploadFiles, getAttachmentIds, clearAttachments, localAttachments } =\n    useCreateAttachments(\n      handleInsertMarkdown,\n      draftAttachments,\n      setDraftAttachments\n    );\n\n  const onDrop = useCallback(\n    (acceptedFiles: File[]) => {\n      if (acceptedFiles.length > 0) {\n        uploadFiles(acceptedFiles);\n      }\n    },\n    [uploadFiles]\n  );\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    onDrop,\n    disabled: createWorkspace.isPending || !hasSelectedRepos,\n    noClick: true,\n    noKeyboard: true,\n  });\n\n  const scratchConfig = useMemo(() => {\n    if (!hasInitialValue) return undefined; // still loading\n    return draftConfig ?? null;\n  }, [hasInitialValue, draftConfig]);\n\n  const {\n    executorConfig,\n    effectiveExecutor,\n    selectedVariant,\n    executorOptions,\n    variantOptions,\n    presetOptions,\n    setOverrides: setExecutorOverrides,\n  } = useExecutorConfig({\n    profiles,\n    lastUsedConfig: preferredExecutorConfig,\n    scratchConfig,\n    configExecutorProfile: config?.executor_profile,\n    onPersist: (cfg) => setDraftConfig(cfg),\n  });\n\n  const repoId = repos.length === 1 ? repos[0]?.id : undefined;\n  const repoSummaryLabel = useMemo(() => {\n    if (repos.length === 1) {\n      const repo = repos[0];\n      if (!repo) return '0 repositories selected';\n      const selectedBranch = targetBranches[repo.id];\n      const branch = selectedBranch\n        ? truncateBranchLabel(selectedBranch)\n        : 'Select branch';\n      return `${getRepoDisplayName(repo)} · ${branch}`;\n    }\n\n    return `${repos.length} repositories selected`;\n  }, [repos, targetBranches]);\n\n  const repoSummaryTitle = useMemo(\n    () =>\n      repos\n        .map((repo) => {\n          const branch = targetBranches[repo.id] ?? 'Select branch';\n          return `${getRepoDisplayName(repo)} (${branch})`;\n        })\n        .join('\\n'),\n    [repos, targetBranches]\n  );\n\n  const hasSelectedBranchesForAllRepos = repos.every(\n    (repo) => !!targetBranches[repo.id]\n  );\n\n  // Determine if we can submit\n  const canSubmit =\n    hasSelectedRepos &&\n    hasSelectedBranchesForAllRepos &&\n    message.trim().length > 0 &&\n    effectiveExecutor !== null;\n\n  const handlePresetSelect = (presetId: string | null) => {\n    if (!effectiveExecutor) return;\n    setDraftConfig({\n      ...draftConfig,\n      executor: effectiveExecutor,\n      variant: presetId,\n    });\n  };\n\n  const handleCustomise = () => {\n    SettingsDialog.show({ initialSection: 'agents' });\n  };\n\n  // Handle executor change - use saved variant if switching to default executor\n  const handleExecutorChange = useCallback(\n    (executor: BaseCodingAgent) => {\n      const executorProfile = profiles?.[executor];\n      if (!executorProfile) {\n        setDraftConfig({ executor, variant: null });\n        return;\n      }\n\n      const variants = getSortedExecutorVariantKeys(executorProfile);\n      let targetVariant: string | null = null;\n\n      // If switching to user's default executor, use their saved variant\n      if (\n        config?.executor_profile?.executor === executor &&\n        config?.executor_profile?.variant\n      ) {\n        const savedVariant = config.executor_profile.variant;\n        if (variants.includes(savedVariant)) {\n          targetVariant = savedVariant;\n        }\n      }\n\n      // Fallback to DEFAULT or first available\n      if (!targetVariant) {\n        targetVariant = variants.includes('DEFAULT')\n          ? 'DEFAULT'\n          : (variants[0] ?? null);\n      }\n\n      setDraftConfig({ executor, variant: targetVariant });\n    },\n    [profiles, setDraftConfig, config?.executor_profile]\n  );\n\n  // Handle submit\n  const handleSubmit = useCallback(async () => {\n    setHasAttemptedSubmit(true);\n    if (!canSubmit || !executorConfig) return;\n\n    const { title } = splitMessageToTitleDescription(message);\n    const data = {\n      executor_config: executorConfig,\n      name: title,\n      prompt: message,\n      repos: repos.map((r) => ({\n        repo_id: r.id,\n        target_branch: targetBranches[r.id]!,\n      })),\n      linked_issue: linkedIssue\n        ? {\n            remote_project_id: linkedIssue.remoteProjectId,\n            issue_id: linkedIssue.issueId,\n          }\n        : null,\n      attachment_ids: getAttachmentIds(),\n    };\n    const linkToIssue = linkedIssue\n      ? {\n          remoteProjectId: linkedIssue.remoteProjectId,\n          issueId: linkedIssue.issueId,\n        }\n      : undefined;\n\n    const result = await createWorkspace.mutateAsync({\n      data,\n      linkToIssue,\n    });\n\n    if (result.workspace) {\n      onWorkspaceCreated(result.workspace.id);\n    }\n\n    if (linkedIssue?.remoteProjectId) {\n      saveProjectRepoDefaults(linkedIssue.remoteProjectId, data.repos).catch(\n        (err) => console.warn('Failed to save project repo defaults:', err)\n      );\n    }\n\n    clearAttachments();\n    await clearDraft();\n  }, [\n    canSubmit,\n    executorConfig,\n    message,\n    repos,\n    targetBranches,\n    createWorkspace,\n    onWorkspaceCreated,\n    getAttachmentIds,\n    clearAttachments,\n    clearDraft,\n    linkedIssue,\n  ]);\n\n  // Determine error to display\n  const displayError =\n    hasAttemptedSubmit && repos.length === 0\n      ? 'Add at least one repository to create a workspace'\n      : hasAttemptedSubmit && !hasSelectedBranchesForAllRepos\n        ? 'Select a branch for every repository before creating a workspace'\n        : createWorkspace.error\n          ? createWorkspace.error instanceof Error\n            ? createWorkspace.error.message\n            : 'Failed to create workspace'\n          : null;\n\n  // Wait for initial value to be applied before rendering\n  // This ensures the editor mounts with content ready, so autoFocus works correctly\n  if (!hasInitialValue) {\n    return null;\n  }\n\n  return (\n    <div className=\"relative flex flex-1 flex-col bg-primary h-full\">\n      <div className=\"flex flex-1 items-center justify-center px-base\">\n        <div className=\"flex w-chat max-w-full flex-col gap-base\">\n          {showRepoPickerStep && (\n            <>\n              <h2 className=\"mb-double text-center text-4xl font-medium tracking-tight text-high\">\n                {t('createMode.headings.repoStep')}\n              </h2>\n              <CreateModeRepoPickerBar\n                onContinueToPrompt={() => setIsSelectingRepos(false)}\n              />\n            </>\n          )}\n\n          {showChatStep && (\n            <>\n              <h2 className=\"mb-double text-center text-4xl font-medium tracking-tight text-high\">\n                {t('createMode.headings.chatStep')}\n              </h2>\n\n              <div className=\"flex justify-center @container\">\n                <CreateChatBox\n                  editor={{\n                    value: message,\n                    onChange: setMessage,\n                  }}\n                  renderEditor={({\n                    value,\n                    onChange,\n                    onCmdEnter,\n                    disabled,\n                    repoIds,\n                    repoId,\n                    executor,\n                    onPasteFiles,\n                    localAttachments,\n                  }) => (\n                    <WYSIWYGEditor\n                      placeholder=\"Describe the task...\"\n                      value={value}\n                      onChange={onChange}\n                      onCmdEnter={onCmdEnter}\n                      disabled={disabled}\n                      className=\"min-h-double max-h-[50vh] overflow-y-auto\"\n                      repoIds={repoIds}\n                      repoId={repoId}\n                      executor={executor}\n                      autoFocus\n                      onPasteFiles={onPasteFiles}\n                      localAttachments={localAttachments}\n                      sendShortcut={config?.send_message_shortcut}\n                    />\n                  )}\n                  agentIcon={\n                    <AgentIcon\n                      agent={effectiveExecutor}\n                      className=\"size-icon-xl\"\n                    />\n                  }\n                  onSend={handleSubmit}\n                  isSending={createWorkspace.isPending}\n                  disabled={!hasSelectedRepos}\n                  executor={{\n                    selected: effectiveExecutor,\n                    options: executorOptions,\n                    onChange: handleExecutorChange,\n                  }}\n                  formatExecutorLabel={toPrettyCase}\n                  error={displayError}\n                  repoIds={repos.map((r) => r.id)}\n                  repoId={repoId}\n                  modelSelector={\n                    effectiveExecutor ? (\n                      <ModelSelectorContainer\n                        agent={effectiveExecutor}\n                        workspaceId={undefined}\n                        onAdvancedSettings={handleCustomise}\n                        presets={variantOptions}\n                        selectedPreset={selectedVariant}\n                        onPresetSelect={handlePresetSelect}\n                        onOverrideChange={setExecutorOverrides}\n                        executorConfig={executorConfig}\n                        presetOptions={presetOptions}\n                      />\n                    ) : undefined\n                  }\n                  onPasteFiles={uploadFiles}\n                  localAttachments={localAttachments}\n                  dropzone={{ getRootProps, getInputProps, isDragActive }}\n                  onEditRepos={() => setIsSelectingRepos(true)}\n                  repoSummaryLabel={repoSummaryLabel}\n                  repoSummaryTitle={repoSummaryTitle}\n                  linkedIssue={\n                    linkedIssue?.simpleId\n                      ? {\n                          simpleId: linkedIssue.simpleId,\n                          title: linkedIssue.title ?? '',\n                          onRemove: clearLinkedIssue,\n                        }\n                      : null\n                  }\n                />\n              </div>\n            </>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/CreateModeRepoPickerBar.tsx",
    "content": "import { useCallback, useMemo, useState } from 'react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport {\n  ClockCounterClockwiseIcon,\n  GitBranchIcon,\n  MagnifyingGlassIcon,\n  PlusIcon,\n  SpinnerIcon,\n  XIcon,\n} from '@phosphor-icons/react';\nimport { useTranslation } from 'react-i18next';\nimport type { Repo } from 'shared/types';\nimport type { BranchItem, RepoItem } from '@/shared/types/selectionItems';\nimport { repoApi } from '@/shared/lib/api';\nimport { cn } from '@/shared/lib/utils';\nimport { useCreateMode } from '@/features/create-mode/model/useCreateMode';\nimport { FolderPickerDialog } from '@/shared/dialogs/shared/FolderPickerDialog';\nimport { SettingsDialog } from '@/shared/dialogs/settings/SettingsDialog';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { CreateRepoDialog } from '@vibe/ui/components/CreateRepoDialog';\nimport {\n  SelectionDialog,\n  type SelectionPage,\n} from '@/shared/dialogs/command-bar/SelectionDialog';\nimport {\n  buildRepoSelectionPages,\n  type RepoSelectionResult,\n} from '@/shared/dialogs/command-bar/selections/repoSelection';\nimport {\n  buildBranchSelectionPages,\n  type BranchSelectionResult,\n} from '@/shared/dialogs/command-bar/selections/branchSelection';\n\nfunction toRepoItem(repo: Repo): RepoItem {\n  return {\n    id: repo.id,\n    display_name: repo.display_name || repo.name,\n  };\n}\n\nfunction toBranchItem(branch: {\n  name: string;\n  is_current: boolean;\n}): BranchItem {\n  return {\n    name: branch.name,\n    isCurrent: branch.is_current,\n  };\n}\n\nfunction getRepoDisplayName(repo: Repo): string {\n  return repo.display_name || repo.name;\n}\n\ntype PendingAction = 'choose' | 'browse' | 'create' | 'branch' | null;\n\nconst inlineControlButtonClassName =\n  'inline-flex items-center gap-half rounded-sm px-half py-half text-sm text-normal ' +\n  'hover:text-high disabled:cursor-not-allowed disabled:opacity-50';\n\nconst recentInlineControlButtonClassName =\n  'inline-flex items-center gap-half rounded-sm px-half py-half text-sm ' +\n  'disabled:cursor-not-allowed disabled:opacity-50';\n\nconst repoRowButtonClassName =\n  'inline-flex items-center gap-half text-sm text-low hover:text-high ' +\n  'disabled:cursor-not-allowed disabled:opacity-50';\n\ninterface CreateModeRepoPickerBarProps {\n  onContinueToPrompt: () => void;\n}\n\nexport function CreateModeRepoPickerBar({\n  onContinueToPrompt,\n}: CreateModeRepoPickerBarProps) {\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const { repos, targetBranches, addRepo, removeRepo, setTargetBranch } =\n    useCreateMode();\n  const [pendingAction, setPendingAction] = useState<PendingAction>(null);\n  const [branchRepoId, setBranchRepoId] = useState<string | null>(null);\n  const [pickerError, setPickerError] = useState<string | null>(null);\n  const [setupHintDismissed, setSetupHintDismissed] = useState(false);\n  const isBusy = pendingAction !== null;\n\n  const hasUnconfiguredRepo = useMemo(\n    () => repos.some((repo) => !repo.setup_script),\n    [repos]\n  );\n  const showSetupHint = hasUnconfiguredRepo && !setupHintDismissed;\n\n  const selectedRepoIds = useMemo(\n    () => new Set(repos.map((repo) => repo.id)),\n    [repos]\n  );\n\n  const pickBranchForRepo = useCallback(async (repo: Repo) => {\n    const branches = await repoApi.getBranches(repo.id);\n    const branchItems = branches.map(toBranchItem);\n    const branchResult = (await SelectionDialog.show({\n      initialPageId: 'selectBranch',\n      pages: buildBranchSelectionPages(\n        branchItems,\n        getRepoDisplayName(repo)\n      ) as Record<string, SelectionPage>,\n    })) as BranchSelectionResult | undefined;\n\n    return branchResult?.branch ?? null;\n  }, []);\n\n  const runPickerAction = useCallback(\n    async (\n      action: Exclude<PendingAction, null>,\n      run: () => Promise<void>,\n      fallbackError: string\n    ) => {\n      setPickerError(null);\n      setPendingAction(action);\n\n      try {\n        await run();\n      } catch (error) {\n        setPickerError(error instanceof Error ? error.message : fallbackError);\n      } finally {\n        setPendingAction(null);\n        if (action === 'branch') {\n          setBranchRepoId(null);\n        }\n      }\n    },\n    []\n  );\n\n  const addRepoWithBranchSelection = useCallback(\n    async (repo: Repo) => {\n      if (selectedRepoIds.has(repo.id)) {\n        setPickerError('Repository is already selected');\n        return false;\n      }\n\n      const selectedBranch = await pickBranchForRepo(repo);\n      if (!selectedBranch) return false;\n\n      addRepo(repo);\n      setTargetBranch(repo.id, selectedBranch);\n      return true;\n    },\n    [addRepo, pickBranchForRepo, selectedRepoIds, setTargetBranch]\n  );\n\n  const handleChooseRepo = useCallback(async () => {\n    await runPickerAction(\n      'choose',\n      async () => {\n        const allRepos = await repoApi.listRecent();\n        const availableRepos = allRepos.filter(\n          (repo) => !selectedRepoIds.has(repo.id)\n        );\n\n        if (availableRepos.length === 0) {\n          setPickerError(\n            'No recently used repositories found, please browse repositories instead'\n          );\n          return;\n        }\n\n        const repoResult = (await SelectionDialog.show({\n          initialPageId: 'selectRepo',\n          pages: buildRepoSelectionPages(\n            availableRepos.map(toRepoItem)\n          ) as Record<string, SelectionPage>,\n        })) as RepoSelectionResult | undefined;\n\n        if (!repoResult?.repoId) return;\n\n        const selectedRepo = availableRepos.find(\n          (repo) => repo.id === repoResult.repoId\n        );\n        if (!selectedRepo) return;\n\n        await addRepoWithBranchSelection(selectedRepo);\n      },\n      'Failed to load repositories or branches'\n    );\n  }, [addRepoWithBranchSelection, runPickerAction, selectedRepoIds]);\n\n  const handleBrowseRepo = useCallback(async () => {\n    await runPickerAction(\n      'browse',\n      async () => {\n        const selectedPath = await FolderPickerDialog.show({\n          title: t('dialogs.selectGitRepository'),\n          description: t('dialogs.chooseExistingRepo'),\n        });\n        if (!selectedPath) return;\n\n        const repo = await repoApi.register({ path: selectedPath });\n        queryClient.invalidateQueries({ queryKey: ['repos'] });\n        await addRepoWithBranchSelection(repo);\n      },\n      'Failed to register repository'\n    );\n  }, [addRepoWithBranchSelection, runPickerAction, t]);\n\n  const handleCreateRepo = useCallback(async () => {\n    await runPickerAction(\n      'create',\n      async () => {\n        await CreateRepoDialog.show({\n          onBrowseForPath: async (currentPath) =>\n            FolderPickerDialog.show({\n              title: t('git.createRepo.browseDialog.title'),\n              description: t('git.createRepo.browseDialog.description'),\n              value: currentPath,\n            }),\n          onCreateRepo: async ({ parentPath, folderName }) => {\n            const repo = await repoApi.init({\n              parent_path: parentPath,\n              folder_name: folderName,\n            });\n            queryClient.invalidateQueries({ queryKey: ['repos'] });\n            await addRepoWithBranchSelection(repo);\n          },\n        });\n      },\n      'Failed to create repository'\n    );\n  }, [addRepoWithBranchSelection, runPickerAction, t]);\n\n  const handleChangeBranch = useCallback(\n    async (repo: Repo) => {\n      setBranchRepoId(repo.id);\n      await runPickerAction(\n        'branch',\n        async () => {\n          const selectedBranch = await pickBranchForRepo(repo);\n          if (!selectedBranch) return;\n          setTargetBranch(repo.id, selectedBranch);\n        },\n        'Failed to load branches'\n      );\n    },\n    [pickBranchForRepo, runPickerAction, setTargetBranch]\n  );\n\n  return (\n    <div className=\"w-chat max-w-full\">\n      <div className=\"px-plusfifty py-base\">\n        {repos.length > 0 && (\n          <div>\n            <div className=\"rounded-sm border border-border/60\">\n              {repos.map((repo, index) => {\n                const branch = targetBranches[repo.id] ?? 'Select branch';\n                const repoDisplayName = getRepoDisplayName(repo);\n                const isChangingBranch =\n                  pendingAction === 'branch' && branchRepoId === repo.id;\n\n                return (\n                  <div\n                    key={repo.id}\n                    className={cn(\n                      'flex min-w-0 items-center gap-half px-base py-half',\n                      index > 0 && 'border-t border-border/60'\n                    )}\n                  >\n                    <span className=\"min-w-0 flex-1 truncate text-sm text-normal\">\n                      {repoDisplayName}\n                    </span>\n                    <span className=\"h-3 w-px shrink-0 bg-border/70\" />\n                    <button\n                      type=\"button\"\n                      onClick={() => handleChangeBranch(repo)}\n                      disabled={isBusy}\n                      className={repoRowButtonClassName}\n                      title=\"Change branch\"\n                    >\n                      {isChangingBranch ? (\n                        <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n                      ) : (\n                        <GitBranchIcon className=\"size-icon-xs\" weight=\"bold\" />\n                      )}\n                      <span className=\"max-w-[200px] truncate\">{branch}</span>\n                    </button>\n                    <span className=\"h-3 w-px shrink-0 bg-border/70\" />\n                    <button\n                      type=\"button\"\n                      onClick={() => removeRepo(repo.id)}\n                      disabled={isBusy}\n                      aria-label={`Remove ${repoDisplayName}`}\n                      title={`Remove ${repoDisplayName}`}\n                      className={cn(repoRowButtonClassName, 'hover:text-error')}\n                    >\n                      <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n                    </button>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n        )}\n\n        <div className=\"mt-base flex flex-wrap items-center gap-half\">\n          <button\n            type=\"button\"\n            onClick={handleChooseRepo}\n            disabled={isBusy}\n            className={cn(\n              recentInlineControlButtonClassName,\n              repos.length > 0\n                ? 'text-normal hover:text-high'\n                : 'text-brand hover:text-brand-hover'\n            )}\n          >\n            {pendingAction === 'choose' ? (\n              <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n            ) : (\n              <ClockCounterClockwiseIcon\n                className=\"size-icon-xs\"\n                weight=\"bold\"\n              />\n            )}\n            <span>{t('createMode.repoPicker.actions.recent')}</span>\n          </button>\n          <button\n            type=\"button\"\n            onClick={handleBrowseRepo}\n            disabled={isBusy}\n            className={inlineControlButtonClassName}\n          >\n            {pendingAction === 'browse' ? (\n              <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n            ) : (\n              <MagnifyingGlassIcon className=\"size-icon-xs\" weight=\"bold\" />\n            )}\n            <span>{t('createMode.repoPicker.actions.browse')}</span>\n          </button>\n          <button\n            type=\"button\"\n            onClick={handleCreateRepo}\n            disabled={isBusy}\n            className={inlineControlButtonClassName}\n          >\n            {pendingAction === 'create' ? (\n              <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n            ) : (\n              <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n            )}\n            <span>{t('createMode.repoPicker.actions.create')}</span>\n          </button>\n\n          <div className=\"ml-auto\">\n            <PrimaryButton\n              variant=\"default\"\n              value=\"Continue\"\n              onClick={onContinueToPrompt}\n              disabled={isBusy || repos.length === 0}\n            />\n          </div>\n        </div>\n      </div>\n      {showSetupHint && (\n        <div className=\"mx-plusfifty mt-half flex items-start gap-half rounded-sm border border-brand/20 bg-brand/5 px-base py-base\">\n          <div className=\"flex-1\">\n            <p className=\"text-sm font-medium text-normal\">\n              {t('createMode.repoPicker.setupHintTitle')}\n            </p>\n            <p className=\"mt-quarter text-sm text-low\">\n              {t('createMode.repoPicker.setupHint')}\n            </p>\n            <button\n              type=\"button\"\n              className=\"mt-quarter cursor-pointer text-sm font-medium text-brand underline hover:text-brand/80\"\n              onClick={() => {\n                const unconfiguredRepo = repos.find(\n                  (repo) => !repo.setup_script\n                );\n                SettingsDialog.show({\n                  initialSection: 'repos',\n                  initialState: { repoId: unconfiguredRepo?.id },\n                });\n              }}\n            >\n              {t('createMode.repoPicker.setupHintLink')}\n            </button>\n          </div>\n          <button\n            type=\"button\"\n            onClick={() => setSetupHintDismissed(true)}\n            className=\"shrink-0 text-low hover:text-normal\"\n            aria-label={t('createMode.repoPicker.setupHintDismiss')}\n          >\n            <XIcon className=\"size-icon-2xs\" weight=\"bold\" />\n          </button>\n        </div>\n      )}\n      {pickerError && (\n        <div className=\"mt-half rounded-sm border border-error/30 bg-error/10 px-base py-half\">\n          <p className=\"text-xs text-error\">{pickerError}</p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/IdeIcon.tsx",
    "content": "import { Code2 } from 'lucide-react';\nimport { EditorType } from 'shared/types';\nimport { useTheme, getResolvedTheme } from '@/shared/hooks/useTheme';\nimport { getIdeName } from '@/shared/lib/ideName';\n\ntype IdeIconProps = {\n  editorType?: EditorType | null;\n  className?: string;\n};\n\nexport function IdeIcon({ editorType, className = 'h-4 w-4' }: IdeIconProps) {\n  const { theme } = useTheme();\n  const resolvedTheme = getResolvedTheme(theme);\n  const isDark = resolvedTheme === 'dark';\n\n  const ideName = getIdeName(editorType);\n  let ideIconPath = '';\n\n  if (!editorType || editorType === EditorType.CUSTOM) {\n    // Generic fallback for other IDEs or no IDE configured\n    return <Code2 className={className} />;\n  }\n\n  switch (editorType) {\n    case EditorType.VS_CODE:\n      ideIconPath = isDark ? '/ide/vscode-dark.svg' : '/ide/vscode-light.svg';\n      break;\n    case EditorType.VS_CODE_INSIDERS:\n      ideIconPath = '/ide/vscode-insiders.svg';\n      break;\n    case EditorType.CURSOR:\n      ideIconPath = isDark ? '/ide/cursor-dark.svg' : '/ide/cursor-light.svg';\n      break;\n    case EditorType.WINDSURF:\n      ideIconPath = isDark\n        ? '/ide/windsurf-dark.svg'\n        : '/ide/windsurf-light.svg';\n      break;\n    case EditorType.INTELLI_J:\n      ideIconPath = '/ide/intellij.svg';\n      break;\n    case EditorType.ZED:\n      ideIconPath = isDark ? '/ide/zed-dark.svg' : '/ide/zed-light.svg';\n      break;\n    case EditorType.XCODE:\n      ideIconPath = '/ide/xcode.svg';\n      break;\n    case EditorType.GOOGLE_ANTIGRAVITY:\n      ideIconPath = isDark\n        ? '/ide/antigravity-dark.svg'\n        : '/ide/antigravity-light.svg';\n      break;\n  }\n\n  return <img src={ideIconPath} alt={ideName} className={className} />;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/ModelSelectorContainer.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  CheckIcon,\n  FastForwardIcon,\n  GearIcon,\n  HandIcon,\n  ListBulletsIcon,\n  SlidersHorizontalIcon,\n  type Icon,\n} from '@phosphor-icons/react';\nimport type { BaseCodingAgent, ExecutorConfig, ModelInfo } from 'shared/types';\nimport { PermissionPolicy } from 'shared/types';\nimport { toPrettyCase } from '@/shared/lib/string';\nimport {\n  getModelKey,\n  getRecentModelEntries,\n  getRecentReasoningByModel,\n  touchRecentModel,\n  updateRecentModelEntries,\n  setRecentReasoning,\n} from '@/shared/lib/recentModels';\nimport {\n  getReasoningLabel,\n  getSelectedModel,\n  escapeAttributeValue,\n  parseModelId,\n  appendPresetModel,\n  resolveDefaultModelId,\n  isModelAvailable,\n  resolveDefaultReasoningId,\n} from '@/shared/lib/modelSelector';\nimport { profilesApi } from '@/shared/lib/api';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { getResolvedTheme, useTheme } from '@/shared/hooks/useTheme';\nimport { useModelSelectorConfig } from '@/shared/hooks/useExecutorDiscovery';\nimport { ModelSelectorPopover } from '@vibe/ui/components/ModelSelectorPopover';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTriggerButton,\n} from '@vibe/ui/components/Dropdown';\n\ninterface ModelSelectorContainerProps {\n  agent: BaseCodingAgent | null;\n  workspaceId: string | undefined;\n  sessionId?: string;\n  onAdvancedSettings: () => void;\n  presets: string[];\n  selectedPreset: string | null;\n  onPresetSelect: (presetId: string | null) => void;\n  onOverrideChange: (partial: Partial<ExecutorConfig>) => void;\n  executorConfig: ExecutorConfig | null;\n  presetOptions: ExecutorConfig | null | undefined;\n}\n\nexport function ModelSelectorContainer({\n  agent,\n  workspaceId,\n  sessionId,\n  onAdvancedSettings,\n  presets,\n  selectedPreset,\n  onPresetSelect,\n  onOverrideChange,\n  executorConfig,\n  presetOptions,\n}: ModelSelectorContainerProps) {\n  const { t } = useTranslation('common');\n  const { theme } = useTheme();\n  const resolvedTheme = getResolvedTheme(theme);\n  const [isOpen, setIsOpen] = useState(false);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [expandedProviderId, setExpandedProviderId] = useState('');\n  const { profiles, setProfiles, reloadSystem } = useUserSystem();\n  const defaultLabel = t('modelSelector.default');\n  const loadingLabel = t('states.loading');\n\n  const permissionMetaByPolicy: Record<\n    PermissionPolicy,\n    { label: string; icon: Icon }\n  > = {\n    [PermissionPolicy.AUTO]: {\n      label: t('modelSelector.permissionAuto'),\n      icon: FastForwardIcon,\n    },\n    [PermissionPolicy.SUPERVISED]: {\n      label: t('modelSelector.permissionAsk'),\n      icon: HandIcon,\n    },\n    [PermissionPolicy.PLAN]: {\n      label: t('modelSelector.permissionPlan'),\n      icon: ListBulletsIcon,\n    },\n  };\n\n  const resolvedPreset =\n    selectedPreset ??\n    (presets.includes('DEFAULT') ? 'DEFAULT' : (presets[0] ?? null));\n\n  const {\n    config: streamConfig,\n    loadingModels,\n    error: streamError,\n  } = useModelSelectorConfig(agent, {\n    workspaceId: sessionId ? workspaceId : undefined,\n    sessionId,\n  });\n\n  useEffect(() => {\n    if (streamError) {\n      console.error('Failed to fetch model config', streamError);\n    }\n  }, [streamError]);\n\n  const baseConfig = streamConfig;\n  const config = appendPresetModel(baseConfig, presetOptions?.model_id);\n\n  const availableProviderIds = useMemo(\n    () => config?.providers.map((item) => item.id) ?? [],\n    [config?.providers]\n  );\n  const hasProviders = availableProviderIds.length > 0;\n  const providerIdMap = useMemo(\n    () => new Map(availableProviderIds.map((id) => [id.toLowerCase(), id])),\n    [availableProviderIds]\n  );\n  const resolveProviderId = (value?: string | null) =>\n    value ? (providerIdMap.get(value.toLowerCase()) ?? null) : null;\n\n  const { providerId: configProviderId, modelId: configModelId } = useMemo(\n    () => parseModelId(executorConfig?.model_id, hasProviders),\n    [executorConfig?.model_id, hasProviders]\n  );\n\n  const fallbackProviderId = availableProviderIds[0] ?? null;\n  const resolvedConfigProviderId = resolveProviderId(configProviderId);\n\n  const { providerId: presetProviderId } = useMemo(\n    () => parseModelId(presetOptions?.model_id, hasProviders),\n    [presetOptions?.model_id, hasProviders]\n  );\n  const resolvedPresetProviderId = resolveProviderId(presetProviderId);\n\n  const hasDefaultModel = Boolean(config?.default_model);\n  const selectedProviderId =\n    resolvedConfigProviderId ??\n    resolvedPresetProviderId ??\n    (hasDefaultModel ? fallbackProviderId : null);\n\n  const defaultModelId = config\n    ? resolveDefaultModelId(\n        config.models,\n        selectedProviderId,\n        config.default_model,\n        hasProviders\n      )\n    : null;\n\n  const { modelId: presetModelId } = useMemo(\n    () => parseModelId(presetOptions?.model_id, hasProviders),\n    [presetOptions?.model_id, hasProviders]\n  );\n\n  const presetModelMatchesProvider =\n    !selectedProviderId ||\n    !resolvedPresetProviderId ||\n    resolvedPresetProviderId === selectedProviderId;\n  const resolvedPresetModelId = presetModelMatchesProvider\n    ? presetModelId\n    : null;\n\n  const selectedModelId = (() => {\n    const candidate = configModelId ?? resolvedPresetModelId ?? defaultModelId;\n    if (!candidate || !config || !selectedProviderId) return candidate;\n    const hasMatch = isModelAvailable(config, selectedProviderId, candidate);\n    return hasMatch\n      ? candidate\n      : resolveDefaultModelId(\n          config.models,\n          selectedProviderId,\n          config.default_model,\n          hasProviders\n        );\n  })();\n\n  const selectedModel = config\n    ? getSelectedModel(config.models, selectedProviderId, selectedModelId)\n    : null;\n\n  const recentReasoningByModel = getRecentReasoningByModel(profiles, agent);\n\n  const presetReasoningId =\n    resolvedPresetModelId && selectedModelId === resolvedPresetModelId\n      ? (presetOptions?.reasoning_id ?? null)\n      : null;\n\n  const recentReasoningId = useMemo(() => {\n    if (!selectedModel || !recentReasoningByModel) return null;\n    const key = selectedModel.provider_id\n      ? `${selectedModel.provider_id}/${selectedModel.id}`\n      : selectedModel.id;\n    const keyLower = key.toLowerCase();\n    for (const [k, v] of Object.entries(recentReasoningByModel)) {\n      if (k.toLowerCase() === keyLower) {\n        if (selectedModel.reasoning_options.some((o) => o.id === v)) return v;\n      }\n    }\n    return null;\n  }, [selectedModel, recentReasoningByModel]);\n\n  const selectedReasoningId =\n    executorConfig?.reasoning_id ??\n    presetReasoningId ??\n    recentReasoningId ??\n    resolveDefaultReasoningId(selectedModel?.reasoning_options ?? []);\n\n  const defaultAgentId =\n    config?.agents.find((entry) => entry.is_default)?.id ?? null;\n\n  const selectedAgentId =\n    executorConfig?.agent_id !== undefined\n      ? executorConfig.agent_id\n      : (presetOptions?.agent_id ?? defaultAgentId);\n\n  const supportsPermissions = (config?.permissions.length ?? 0) > 0;\n\n  const basePermissionPolicy = supportsPermissions\n    ? (presetOptions?.permission_policy ?? config?.permissions[0] ?? null)\n    : null;\n  const permissionPolicy = supportsPermissions\n    ? (executorConfig?.permission_policy ?? basePermissionPolicy)\n    : null;\n\n  // LRU persistence (on popover close)\n\n  const recentModelEntries = getRecentModelEntries(profiles, agent);\n  const pendingModelRef = useRef<ModelInfo | null>(null);\n  const pendingReasoningRef = useRef<string | null>(null);\n\n  const persistPendingSelections = useCallback(() => {\n    if (!profiles || !agent) return;\n    if (!pendingModelRef.current && !pendingReasoningRef.current) return;\n\n    let nextProfiles = profiles;\n\n    const model = pendingModelRef.current;\n    if (model) {\n      pendingModelRef.current = null;\n      const current = getRecentModelEntries(nextProfiles, agent);\n      const nextEntries = touchRecentModel(current, model);\n      nextProfiles = updateRecentModelEntries(nextProfiles, agent, nextEntries);\n    }\n\n    const reasoningModel =\n      model ??\n      (selectedModelId && config\n        ? getSelectedModel(config.models, selectedProviderId, selectedModelId)\n        : null);\n    if (pendingReasoningRef.current && reasoningModel) {\n      nextProfiles = setRecentReasoning(\n        nextProfiles,\n        agent,\n        reasoningModel,\n        pendingReasoningRef.current\n      );\n      pendingReasoningRef.current = null;\n    }\n\n    if (nextProfiles !== profiles) {\n      setProfiles(nextProfiles);\n      void profilesApi\n        .save(JSON.stringify({ executors: nextProfiles }, null, 2))\n        .catch((error) => {\n          console.error('Failed to save recent models', error);\n          void reloadSystem();\n        });\n    }\n  }, [\n    agent,\n    config,\n    profiles,\n    reloadSystem,\n    selectedModelId,\n    selectedProviderId,\n    setProfiles,\n  ]);\n\n  const handleModelSelect = (modelId: string | null, providerId?: string) => {\n    const modelOverride = (() => {\n      if (!modelId) return null;\n      if (providerId) return `${providerId}/${modelId}`;\n      return modelId;\n    })();\n    onOverrideChange({ model_id: modelOverride });\n\n    pendingModelRef.current =\n      modelId && config\n        ? (() => {\n            const selectedId = modelId.toLowerCase();\n            if (!providerId) {\n              return (\n                config.models.find((m) => m.id.toLowerCase() === selectedId) ??\n                null\n              );\n            }\n            const provider = providerId.toLowerCase();\n            return (\n              config.models.find(\n                (m) =>\n                  m.id.toLowerCase() === selectedId &&\n                  m.provider_id?.toLowerCase() === provider\n              ) ?? null\n            );\n          })()\n        : null;\n    pendingReasoningRef.current = null;\n  };\n\n  const handleReasoningSelect = (reasoningId: string | null) => {\n    onOverrideChange({ reasoning_id: reasoningId });\n    pendingReasoningRef.current = reasoningId;\n  };\n\n  const handleAgentSelect = (id: string | null) => {\n    onOverrideChange({ agent_id: id });\n  };\n\n  const handlePermissionPolicyChange = (policy: PermissionPolicy) => {\n    if (!supportsPermissions) return;\n    onOverrideChange({ permission_policy: policy });\n  };\n\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    setSearchQuery('');\n  }, [selectedProviderId]);\n\n  useEffect(() => {\n    if (!isOpen) {\n      setSearchQuery('');\n      return;\n    }\n    requestAnimationFrame(() => {\n      const node = scrollRef.current;\n      if (!node) return;\n      if (selectedModelId && config) {\n        const selected = getSelectedModel(\n          config.models,\n          selectedProviderId,\n          selectedModelId\n        );\n        if (selected) {\n          const key = getModelKey(selected);\n          const selector = `[data-model-key=\"${escapeAttributeValue(key)}\"]`;\n          const target = node.querySelector(selector);\n          if (target instanceof HTMLElement) {\n            target.scrollIntoView({ block: 'nearest' });\n            return;\n          }\n        }\n      }\n      if (!selectedModelId) {\n        node.scrollTop = node.scrollHeight;\n      }\n    });\n  }, [config, isOpen, selectedModelId, selectedProviderId]);\n\n  const handleOpenChange = (open: boolean) => {\n    setIsOpen(open);\n    if (open) {\n      const selected =\n        selectedModelId && config\n          ? getSelectedModel(config.models, selectedProviderId, selectedModelId)\n          : null;\n      setExpandedProviderId(selected?.provider_id ?? selectedProviderId ?? '');\n    } else {\n      persistPendingSelections();\n    }\n  };\n\n  useEffect(() => {\n    if (isOpen) return;\n    persistPendingSelections();\n  }, [isOpen, persistPendingSelections]);\n\n  const presetLabel = resolvedPreset\n    ? toPrettyCase(resolvedPreset)\n    : defaultLabel;\n\n  if (!config) {\n    return (\n      <>\n        <DropdownMenu>\n          <DropdownMenuTriggerButton size=\"sm\" label={loadingLabel} disabled />\n        </DropdownMenu>\n      </>\n    );\n  }\n\n  const showModelSelector = loadingModels || config.models.length > 0;\n  const showDefaultOption = !config.default_model && config.models.length > 0;\n  const displaySelectedModel = showModelSelector\n    ? getSelectedModel(config.models, selectedProviderId, selectedModelId)\n    : null;\n  const reasoningLabel = displaySelectedModel\n    ? getReasoningLabel(\n        displaySelectedModel.reasoning_options,\n        selectedReasoningId\n      )\n    : null;\n  const modelLabelBase = loadingModels\n    ? loadingLabel\n    : (displaySelectedModel?.name ?? selectedModelId ?? defaultLabel);\n  const modelLabel = reasoningLabel\n    ? `${modelLabelBase} · ${reasoningLabel}`\n    : modelLabelBase;\n\n  const agentLabel = selectedAgentId\n    ? (config.agents.find((entry) => entry.id === selectedAgentId)?.label ??\n      toPrettyCase(selectedAgentId))\n    : defaultLabel;\n\n  const permissionMeta = permissionPolicy\n    ? (permissionMetaByPolicy[permissionPolicy] ?? null)\n    : null;\n  const permissionIcon = permissionMeta?.icon ?? HandIcon;\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTriggerButton\n          size=\"sm\"\n          icon={SlidersHorizontalIcon}\n          label={\n            resolvedPreset?.toLowerCase() !== 'default'\n              ? presetLabel\n              : undefined\n          }\n          showCaret={false}\n        />\n        <DropdownMenuContent align=\"start\">\n          <DropdownMenuLabel>{t('modelSelector.preset')}</DropdownMenuLabel>\n          {presets.length > 0 ? (\n            presets.map((preset) => (\n              <DropdownMenuItem\n                key={preset}\n                icon={preset === resolvedPreset ? CheckIcon : undefined}\n                onClick={() => onPresetSelect?.(preset)}\n              >\n                {toPrettyCase(preset)}\n              </DropdownMenuItem>\n            ))\n          ) : (\n            <DropdownMenuItem disabled>{presetLabel}</DropdownMenuItem>\n          )}\n          <DropdownMenuSeparator />\n          <DropdownMenuItem icon={GearIcon} onClick={onAdvancedSettings}>\n            {t('modelSelector.custom')}\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      {showModelSelector && (\n        <ModelSelectorPopover\n          isOpen={isOpen}\n          onOpenChange={handleOpenChange}\n          trigger={\n            <DropdownMenuTriggerButton\n              size=\"sm\"\n              label={modelLabel}\n              disabled={loadingModels}\n            />\n          }\n          config={config}\n          error={streamError}\n          selectedProviderId={selectedProviderId}\n          selectedModelId={selectedModelId}\n          selectedReasoningId={selectedReasoningId}\n          searchQuery={searchQuery}\n          onSearchChange={setSearchQuery}\n          onModelSelect={handleModelSelect}\n          onReasoningSelect={handleReasoningSelect}\n          recentModelEntries={recentModelEntries}\n          showDefaultOption={showDefaultOption}\n          onSelectDefault={() => handleModelSelect(null)}\n          scrollRef={scrollRef}\n          expandedProviderId={expandedProviderId}\n          onExpandedProviderIdChange={setExpandedProviderId}\n          resolvedTheme={resolvedTheme}\n        />\n      )}\n\n      {permissionPolicy && config.permissions.length > 0 && (\n        <DropdownMenu>\n          <DropdownMenuTriggerButton\n            size=\"sm\"\n            icon={permissionIcon}\n            showCaret={false}\n          />\n          <DropdownMenuContent align=\"start\">\n            <DropdownMenuLabel>\n              {t('modelSelector.permissions')}\n            </DropdownMenuLabel>\n            {config.permissions.map((policy) => {\n              const meta = permissionMetaByPolicy[policy];\n              return (\n                <DropdownMenuItem\n                  key={policy}\n                  icon={meta?.icon ?? HandIcon}\n                  onClick={() => handlePermissionPolicyChange(policy)}\n                >\n                  {meta?.label ?? toPrettyCase(policy)}\n                </DropdownMenuItem>\n              );\n            })}\n          </DropdownMenuContent>\n        </DropdownMenu>\n      )}\n\n      {config.agents.length > 0 && (\n        <DropdownMenu>\n          <DropdownMenuTriggerButton size=\"sm\" label={agentLabel} />\n          <DropdownMenuContent align=\"start\">\n            <DropdownMenuLabel>{t('modelSelector.agent')}</DropdownMenuLabel>\n            <DropdownMenuItem\n              icon={selectedAgentId === null ? CheckIcon : undefined}\n              onClick={() => handleAgentSelect(null)}\n            >\n              {t('modelSelector.default')}\n            </DropdownMenuItem>\n            <DropdownMenuSeparator />\n            {config.agents.map((agentOption) => (\n              <DropdownMenuItem\n                key={agentOption.id}\n                icon={\n                  agentOption.id === selectedAgentId ? CheckIcon : undefined\n                }\n                onClick={() => handleAgentSelect(agentOption.id)}\n              >\n                {agentOption.label}\n              </DropdownMenuItem>\n            ))}\n          </DropdownMenuContent>\n        </DropdownMenu>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/NormalizedConversation/EditDiffRenderer.tsx",
    "content": "import { useMemo } from 'react';\nimport {\n  DiffView,\n  DiffModeEnum,\n  DiffLineType,\n  parseInstance,\n} from '@git-diff-view/react';\nimport { SquarePen } from 'lucide-react';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { getHighLightLanguageFromPath } from '@/shared/lib/extToLanguage';\nimport { getActualTheme } from '@/shared/lib/theme';\nimport '@/styles/diff-style-overrides.css';\nimport '@/styles/edit-diff-overrides.css';\nimport { cn } from '@/shared/lib/utils';\n\ntype Props = {\n  path: string;\n  unifiedDiff: string;\n  hasLineNumbers: boolean;\n  expansionKey: string;\n  defaultExpanded?: boolean;\n  statusAppearance?: 'default' | 'denied' | 'timed_out';\n  forceExpanded?: boolean;\n};\n\n/**\n * Process hunks for @git-diff-view/react\n * - Extract additions/deletions for display\n * - Decide whether to hide line numbers based on backend data\n */\nfunction processUnifiedDiff(unifiedDiff: string, hasLineNumbers: boolean) {\n  // Hide line numbers when backend says they are unreliable\n  const hideNums = !hasLineNumbers;\n  let isValidDiff;\n\n  // Pre-compute additions/deletions using the library parser so counts are available while collapsed\n  let additions = 0;\n  let deletions = 0;\n  try {\n    const parsed = parseInstance.parse(unifiedDiff);\n    for (const h of parsed.hunks) {\n      for (const line of h.lines) {\n        if (line.type === DiffLineType.Add) additions++;\n        else if (line.type === DiffLineType.Delete) deletions++;\n      }\n    }\n    isValidDiff = parsed.hunks.length > 0;\n  } catch (err) {\n    console.error('Failed to parse diff hunks:', err);\n    isValidDiff = false;\n  }\n\n  return {\n    hunks: [unifiedDiff],\n    hideLineNumbers: hideNums,\n    additions,\n    deletions,\n    isValidDiff,\n  };\n}\n\nimport { useExpandable } from '@/shared/stores/useExpandableStore';\n\nfunction EditDiffRenderer({\n  path,\n  unifiedDiff,\n  hasLineNumbers,\n  expansionKey,\n  defaultExpanded = false,\n  statusAppearance = 'default',\n  forceExpanded = false,\n}: Props) {\n  const { config } = useUserSystem();\n  const [expanded, setExpanded] = useExpandable(expansionKey, defaultExpanded);\n  const effectiveExpanded = forceExpanded || expanded;\n\n  const theme = getActualTheme(config?.theme);\n  const { hunks, hideLineNumbers, additions, deletions, isValidDiff } = useMemo(\n    () => processUnifiedDiff(unifiedDiff, hasLineNumbers),\n    [unifiedDiff, hasLineNumbers]\n  );\n\n  const hideLineNumbersClass = hideLineNumbers ? ' edit-diff-hide-nums' : '';\n\n  const diffData = useMemo(() => {\n    const lang = getHighLightLanguageFromPath(path) || 'plaintext';\n    return {\n      hunks,\n      oldFile: { fileName: path, fileLang: lang },\n      newFile: { fileName: path, fileLang: lang },\n    };\n  }, [hunks, path]);\n\n  const headerClass = cn(\n    'flex items-center gap-1.5 text-secondary-foreground',\n    statusAppearance === 'denied' && 'text-red-700 dark:text-red-300',\n    statusAppearance === 'timed_out' && 'text-amber-700 dark:text-amber-200'\n  );\n\n  return (\n    <div>\n      <div className={headerClass}>\n        <SquarePen className=\"h-3 w-3\" />\n        <p\n          onClick={() => setExpanded()}\n          className=\"text-sm font-mono overflow-x-auto flex-1 cursor-pointer\"\n        >\n          {path}{' '}\n          <span style={{ color: 'hsl(var(--console-success))' }}>\n            +{additions}\n          </span>{' '}\n          <span style={{ color: 'hsl(var(--console-error))' }}>\n            -{deletions}\n          </span>\n        </p>\n      </div>\n\n      {effectiveExpanded && (\n        <div className={'mt-2 border ' + hideLineNumbersClass}>\n          {isValidDiff ? (\n            <DiffView\n              data={diffData}\n              diffViewWrap={false}\n              diffViewTheme={theme}\n              diffViewHighlight\n              diffViewMode={DiffModeEnum.Unified}\n              diffViewFontSize={12}\n            />\n          ) : (\n            <>\n              <pre\n                className=\"px-4 pb-4 text-xs font-mono overflow-x-auto whitespace-pre-wrap\"\n                style={{ color: 'hsl(var(--muted-foreground) / 0.9)' }}\n              >\n                {unifiedDiff}\n              </pre>\n            </>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default EditDiffRenderer;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/NormalizedConversation/FileChangeRenderer.tsx",
    "content": "import { type FileChange } from 'shared/types';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { Trash2, FilePlus2, ArrowRight, FileX, FileClock } from 'lucide-react';\nimport { getHighLightLanguageFromPath } from '@/shared/lib/extToLanguage';\nimport { getActualTheme } from '@/shared/lib/theme';\nimport EditDiffRenderer from './EditDiffRenderer';\nimport FileContentView from './FileContentView';\nimport '@/styles/diff-style-overrides.css';\nimport { useExpandable } from '@/shared/stores/useExpandableStore';\nimport { cn } from '@/shared/lib/utils';\n\ntype Props = {\n  path: string;\n  change: FileChange;\n  expansionKey: string;\n  defaultExpanded?: boolean;\n  statusAppearance?: 'default' | 'denied' | 'timed_out';\n  forceExpanded?: boolean;\n};\n\nfunction isWrite(\n  change: FileChange\n): change is Extract<FileChange, { action: 'write'; content: string }> {\n  return change?.action === 'write';\n}\nfunction isDelete(\n  change: FileChange\n): change is Extract<FileChange, { action: 'delete' }> {\n  return change?.action === 'delete';\n}\nfunction isRename(\n  change: FileChange\n): change is Extract<FileChange, { action: 'rename'; new_path: string }> {\n  return change?.action === 'rename';\n}\nfunction isEdit(\n  change: FileChange\n): change is Extract<FileChange, { action: 'edit' }> {\n  return change?.action === 'edit';\n}\n\nconst FileChangeRenderer = ({\n  path,\n  change,\n  expansionKey,\n  defaultExpanded = false,\n  statusAppearance = 'default',\n  forceExpanded = false,\n}: Props) => {\n  const { config } = useUserSystem();\n  const [expanded, setExpanded] = useExpandable(expansionKey, defaultExpanded);\n  const effectiveExpanded = forceExpanded || expanded;\n\n  const theme = getActualTheme(config?.theme);\n  const headerClass = cn('flex items-center gap-1.5 text-secondary-foreground');\n\n  const statusIcon =\n    statusAppearance === 'denied' ? (\n      <FileX className=\"h-3 w-3\" />\n    ) : statusAppearance === 'timed_out' ? (\n      <FileClock className=\"h-3 w-3\" />\n    ) : null;\n\n  if (statusIcon) {\n    return (\n      <div>\n        <div className={headerClass}>\n          {statusIcon}\n          <p className=\"text-sm font-light overflow-x-auto flex-1\">{path}</p>\n        </div>\n      </div>\n    );\n  }\n\n  // Edit: delegate to EditDiffRenderer for identical styling and behavior\n  if (isEdit(change)) {\n    return (\n      <EditDiffRenderer\n        path={path}\n        unifiedDiff={change.unified_diff}\n        hasLineNumbers={change.has_line_numbers}\n        expansionKey={expansionKey}\n        defaultExpanded={defaultExpanded}\n        statusAppearance={statusAppearance}\n        forceExpanded={forceExpanded}\n      />\n    );\n  }\n\n  // Title row content and whether the row is expandable\n  const { titleNode, icon, expandable } = (() => {\n    if (isDelete(change)) {\n      return {\n        titleNode: path,\n        icon: <Trash2 className=\"h-3 w-3\" />,\n        expandable: false,\n      };\n    }\n\n    if (isRename(change)) {\n      return {\n        titleNode: (\n          <>\n            Rename {path} to {change.new_path}\n          </>\n        ),\n        icon: <ArrowRight className=\"h-3 w-3\" />,\n        expandable: false,\n      };\n    }\n\n    if (isWrite(change)) {\n      return {\n        titleNode: path,\n        icon: <FilePlus2 className=\"h-3 w-3\" />,\n        expandable: true,\n      };\n    }\n\n    // No fallback: render nothing for unknown change types\n    return {\n      titleNode: null,\n      icon: null,\n      expandable: false,\n    };\n  })();\n\n  // nothing to display\n  if (!titleNode) {\n    return null;\n  }\n\n  return (\n    <div>\n      <div className={headerClass}>\n        {icon}\n        <p\n          onClick={() => expandable && setExpanded()}\n          className=\"text-sm font-mono overflow-x-auto flex-1 cursor-pointer\"\n        >\n          {titleNode}\n        </p>\n      </div>\n\n      {/* Body */}\n      {isWrite(change) && effectiveExpanded && (\n        <FileContentView\n          content={change.content}\n          lang={getHighLightLanguageFromPath(path)}\n          theme={theme}\n        />\n      )}\n    </div>\n  );\n};\n\nexport default FileChangeRenderer;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/NormalizedConversation/FileContentView.tsx",
    "content": "import { useMemo } from 'react';\nimport { DiffView, DiffModeEnum } from '@git-diff-view/react';\nimport { generateDiffFile } from '@git-diff-view/file';\nimport '@/styles/diff-style-overrides.css';\nimport '@/styles/edit-diff-overrides.css';\n\ntype Props = {\n  content: string;\n  lang: string | null;\n  theme?: 'light' | 'dark';\n};\n\n/**\n * View syntax highlighted file content.\n */\nfunction FileContentView({ content, lang, theme }: Props) {\n  // Uses the syntax highlighter from @git-diff-view/react without any diff-related features.\n  // This allows uniform styling with EditDiffRenderer.\n  const diffFile = useMemo(() => {\n    try {\n      const instance = generateDiffFile(\n        '', // old file\n        '', // old content (empty)\n        '', // new file\n        content, // new content\n        '', // old lang\n        lang || 'plaintext' // new lang\n      );\n      instance.initRaw();\n      return instance;\n    } catch {\n      return null;\n    }\n  }, [content, lang]);\n\n  return diffFile ? (\n    <div className=\"border mt-2\">\n      <DiffView\n        diffFile={diffFile}\n        diffViewWrap={false}\n        diffViewTheme={theme}\n        diffViewHighlight\n        diffViewMode={DiffModeEnum.Unified}\n        diffViewFontSize={12}\n      />\n    </div>\n  ) : (\n    <pre className=\"text-xs font-mono overflow-x-auto whitespace-pre\">\n      {content}\n    </pre>\n  );\n}\n\nexport default FileContentView;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/NormalizedConversation/PendingApprovalEntry.tsx",
    "content": "import {\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from 'react';\nimport type { ReactNode } from 'react';\nimport type { ApprovalStatus, ToolStatus } from 'shared/types';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@vibe/ui/components/RadixTooltip';\nimport { approvalsApi } from '@/shared/lib/api';\nimport { Check, X } from 'lucide-react';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\n\nimport { useHotkeysContext } from 'react-hotkeys-hook';\nimport { TabNavContext } from '@/shared/hooks/TabNavigationContext';\nimport {\n  useKeyApproveRequest,\n  useKeyDenyApproval,\n  Scope,\n} from '@/shared/keyboard';\nimport { useApprovalForm } from '@/shared/hooks/ApprovalForm';\nimport { useApprovals } from '@/shared/hooks/useApprovals';\n\nconst DEFAULT_DENIAL_REASON = 'User denied this tool use request.';\n\n// ---------- Types ----------\ninterface PendingApprovalEntryProps {\n  pendingStatus: Extract<ToolStatus, { status: 'pending_approval' }>;\n  executionProcessId?: string;\n  children: ReactNode;\n}\n\nfunction useApprovalCountdown(\n  requestedAt: string | number | Date,\n  timeoutAt: string | number | Date,\n  paused: boolean\n) {\n  const totalSeconds = useMemo(() => {\n    const total = Math.floor(\n      (new Date(timeoutAt).getTime() - new Date(requestedAt).getTime()) / 1000\n    );\n    return Math.max(1, total);\n  }, [requestedAt, timeoutAt]);\n\n  const [timeLeft, setTimeLeft] = useState<number>(() => {\n    const remaining = new Date(timeoutAt).getTime() - Date.now();\n    return Math.max(0, Math.floor(remaining / 1000));\n  });\n\n  useEffect(() => {\n    if (paused) return;\n    const id = window.setInterval(() => {\n      const remaining = new Date(timeoutAt).getTime() - Date.now();\n      const next = Math.max(0, Math.floor(remaining / 1000));\n      setTimeLeft(next);\n      if (next <= 0) window.clearInterval(id);\n    }, 1000);\n\n    return () => window.clearInterval(id);\n  }, [timeoutAt, paused]);\n\n  const percent = useMemo(\n    () =>\n      Math.max(0, Math.min(100, Math.round((timeLeft / totalSeconds) * 100))),\n    [timeLeft, totalSeconds]\n  );\n\n  return { timeLeft, percent };\n}\n\nfunction ActionButtons({\n  disabled,\n  isResponding,\n  onApprove,\n  onStartDeny,\n}: {\n  disabled: boolean;\n  isResponding: boolean;\n  onApprove: () => void;\n  onStartDeny: () => void;\n}) {\n  return (\n    <div className=\"flex items-center gap-1.5 pr-4\">\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            onClick={onApprove}\n            variant=\"ghost\"\n            className=\"h-8 w-8 rounded-full p-0\"\n            disabled={disabled}\n            aria-label={isResponding ? 'Submitting approval' : 'Approve'}\n            aria-busy={isResponding}\n          >\n            <Check className=\"h-5 w-5\" />\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>{isResponding ? 'Submitting…' : 'Approve request'}</p>\n        </TooltipContent>\n      </Tooltip>\n\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            onClick={onStartDeny}\n            variant=\"ghost\"\n            className=\"h-8 w-8 rounded-full p-0\"\n            disabled={disabled}\n            aria-label={isResponding ? 'Submitting denial' : 'Deny'}\n            aria-busy={isResponding}\n          >\n            <X className=\"h-5 w-5\" />\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>{isResponding ? 'Submitting…' : 'Provide denial reason'}</p>\n        </TooltipContent>\n      </Tooltip>\n    </div>\n  );\n}\n\nfunction DenyReasonForm({\n  isResponding,\n  value,\n  onChange,\n  onCancel,\n  onSubmit,\n}: {\n  isResponding: boolean;\n  value: string;\n  onChange: (v: string) => void;\n  onCancel: () => void;\n  onSubmit: () => void;\n}) {\n  return (\n    <div className=\"flex flex-col gap-2 p-4\">\n      <WYSIWYGEditor\n        value={value}\n        onChange={onChange}\n        placeholder=\"Let the agent know why this request was denied... Type @ to insert tags or search files.\"\n        disabled={isResponding}\n        className=\"min-h-[80px]\"\n        onCmdEnter={onSubmit}\n      />\n      <div className=\"flex flex-wrap items-center justify-end gap-2\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onCancel}\n          disabled={isResponding}\n        >\n          Cancel\n        </Button>\n        <Button size=\"sm\" onClick={onSubmit} disabled={isResponding}>\n          Deny\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n// ---------- Main Component ----------\nconst PendingApprovalEntry = ({\n  pendingStatus,\n  executionProcessId,\n  children,\n}: PendingApprovalEntryProps) => {\n  const [isResponding, setIsResponding] = useState(false);\n  const [hasResponded, setHasResponded] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const {\n    isEnteringReason,\n    denyReason,\n    setIsEnteringReason,\n    setDenyReason,\n    clear,\n  } = useApprovalForm(pendingStatus.approval_id);\n\n  const { enableScope, disableScope, activeScopes } = useHotkeysContext();\n  const tabNav = useContext(TabNavContext);\n  const isLogsTabActive = tabNav ? tabNav.activeTab === 'logs' : true;\n  const dialogScopeActive = activeScopes.includes(Scope.DIALOG);\n  const shouldControlScopes = isLogsTabActive && !dialogScopeActive;\n  const approvalsScopeEnabledRef = useRef(false);\n  const dialogScopeActiveRef = useRef(dialogScopeActive);\n\n  useEffect(() => {\n    dialogScopeActiveRef.current = dialogScopeActive;\n  }, [dialogScopeActive]);\n\n  const { getPendingById } = useApprovals();\n  const approvalInfo = getPendingById(pendingStatus.approval_id);\n\n  const { timeLeft } = useApprovalCountdown(\n    approvalInfo?.created_at ?? new Date().toISOString(),\n    approvalInfo?.timeout_at ?? new Date().toISOString(),\n    hasResponded\n  );\n\n  const disabled = isResponding || hasResponded || timeLeft <= 0;\n\n  const shouldEnableApprovalsScope = shouldControlScopes && !disabled;\n\n  useEffect(() => {\n    const shouldEnable = shouldEnableApprovalsScope;\n\n    if (shouldEnable && !approvalsScopeEnabledRef.current) {\n      enableScope(Scope.APPROVALS);\n      disableScope(Scope.KANBAN);\n      approvalsScopeEnabledRef.current = true;\n    } else if (!shouldEnable && approvalsScopeEnabledRef.current) {\n      disableScope(Scope.APPROVALS);\n      if (!dialogScopeActive) {\n        enableScope(Scope.KANBAN);\n      }\n      approvalsScopeEnabledRef.current = false;\n    }\n\n    return () => {\n      if (approvalsScopeEnabledRef.current) {\n        disableScope(Scope.APPROVALS);\n        if (!dialogScopeActiveRef.current) {\n          enableScope(Scope.KANBAN);\n        }\n        approvalsScopeEnabledRef.current = false;\n      }\n    };\n  }, [\n    disableScope,\n    enableScope,\n    dialogScopeActive,\n    shouldEnableApprovalsScope,\n  ]);\n\n  const respond = useCallback(\n    async (approved: boolean, reason?: string) => {\n      if (disabled) return;\n      if (!executionProcessId) {\n        setError('Missing executionProcessId');\n        return;\n      }\n\n      setIsResponding(true);\n      setError(null);\n\n      const status: ApprovalStatus = approved\n        ? { status: 'approved' }\n        : { status: 'denied', reason };\n\n      try {\n        await approvalsApi.respond(pendingStatus.approval_id, {\n          execution_process_id: executionProcessId,\n          status,\n        });\n        setHasResponded(true);\n        clear();\n      } catch (e: unknown) {\n        console.error('Approval respond failed:', e);\n        const errorMessage =\n          e instanceof Error ? e.message : 'Failed to send response';\n        setError(errorMessage);\n      } finally {\n        setIsResponding(false);\n      }\n    },\n    [disabled, executionProcessId, pendingStatus.approval_id, clear]\n  );\n\n  const handleApprove = useCallback(() => respond(true), [respond]);\n  const handleStartDeny = useCallback(() => {\n    if (disabled) return;\n    setError(null);\n    setIsEnteringReason(true);\n  }, [disabled, setIsEnteringReason]);\n\n  const handleCancelDeny = useCallback(() => {\n    if (isResponding) return;\n    clear();\n  }, [isResponding, clear]);\n\n  const handleSubmitDeny = useCallback(() => {\n    const trimmed = denyReason.trim();\n    respond(false, trimmed || DEFAULT_DENIAL_REASON);\n  }, [denyReason, respond]);\n\n  const triggerDeny = useCallback(\n    (event?: KeyboardEvent) => {\n      if (!isEnteringReason || disabled || hasResponded) return;\n      event?.preventDefault();\n      handleSubmitDeny();\n    },\n    [isEnteringReason, disabled, hasResponded, handleSubmitDeny]\n  );\n\n  useKeyApproveRequest(handleApprove, {\n    scope: Scope.APPROVALS,\n    when: () => shouldEnableApprovalsScope && !isEnteringReason,\n    preventDefault: true,\n  });\n\n  useKeyDenyApproval(triggerDeny, {\n    scope: Scope.APPROVALS,\n    when: () => shouldEnableApprovalsScope && !hasResponded,\n    enableOnFormTags: ['textarea', 'TEXTAREA'],\n    preventDefault: true,\n  });\n\n  return (\n    <div className=\"relative mt-3\">\n      <div className=\"overflow-hidden\">\n        {children}\n\n        <div className=\"bg-background px-2 py-1.5 text-xs sm:text-sm\">\n          <TooltipProvider>\n            <div className=\"flex items-center justify-between gap-1.5 pl-4\">\n              <div className=\"flex items-center gap-1.5\">\n                {!isEnteringReason && (\n                  <span className=\"text-muted-foreground\">\n                    Would you like to approve this?\n                  </span>\n                )}\n              </div>\n              {!isEnteringReason && (\n                <ActionButtons\n                  disabled={disabled}\n                  isResponding={isResponding}\n                  onApprove={handleApprove}\n                  onStartDeny={handleStartDeny}\n                />\n              )}\n            </div>\n\n            {error && (\n              <div\n                className=\"mt-1 text-xs text-red-600\"\n                role=\"alert\"\n                aria-live=\"polite\"\n              >\n                {error}\n              </div>\n            )}\n\n            {isEnteringReason && !hasResponded && (\n              <DenyReasonForm\n                isResponding={isResponding}\n                value={denyReason}\n                onChange={setDenyReason}\n                onCancel={handleCancelDeny}\n                onSubmit={handleSubmitDeny}\n              />\n            )}\n          </TooltipProvider>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default PendingApprovalEntry;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/NormalizedConversation/RetryEditorInline.tsx",
    "content": "import { useCallback, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport WYSIWYGEditor from '@/shared/components/WYSIWYGEditor';\nimport { cn } from '@/shared/lib/utils';\nimport { VariantSelector } from '@/shared/components/VariantSelector';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { AlertCircle, Loader2, Paperclip, Send, X } from 'lucide-react';\nimport { attachmentsApi } from '@/shared/lib/api';\nimport type { WorkspaceWithSession } from '@/shared/types/attempt';\nimport { useWorkspaceExecution } from '@/shared/hooks/useWorkspaceExecution';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useBranchStatus } from '@/shared/hooks/useBranchStatus';\nimport { useVariant } from '@/shared/hooks/useVariant';\nimport { useRetryProcess } from '@/shared/hooks/useRetryProcess';\nimport { executorConfigFromAction } from '@/shared/lib/executor';\nimport { buildWorkspaceAttachmentMarkdown } from '@/shared/lib/workspaceAttachments';\n\nexport function RetryEditorInline({\n  attempt,\n  executionProcessId,\n  initialContent,\n  onCancelled,\n}: {\n  attempt: WorkspaceWithSession;\n  executionProcessId: string;\n  initialContent: string;\n  onCancelled?: () => void;\n}) {\n  const { t } = useTranslation(['common']);\n  const workspaceId = attempt.id;\n  const { isAttemptRunning, attemptData } = useWorkspaceExecution(workspaceId);\n  const { data: branchStatus } = useBranchStatus(workspaceId);\n  const { profiles } = useUserSystem();\n\n  const [message, setMessage] = useState(initialContent);\n  const [sendError, setSendError] = useState<string | null>(null);\n\n  // Get sessionId from attempt's session\n  const sessionId = attempt.session?.id;\n\n  // Extract executor and variant from the process being retried\n  const processProfile = useMemo(() => {\n    const process = attemptData.processes?.find(\n      (p) => p.id === executionProcessId\n    );\n    if (!process?.executor_action) return null;\n    return executorConfigFromAction(process.executor_action);\n  }, [attemptData.processes, executionProcessId]);\n\n  const { selectedVariant, setSelectedVariant } = useVariant({\n    processVariant: processProfile?.variant ?? null,\n    scratchVariant: undefined,\n  });\n\n  const retryMutation = useRetryProcess(\n    sessionId ?? '',\n    () => onCancelled?.(),\n    (err) => setSendError((err as Error)?.message || 'Failed to send retry')\n  );\n\n  const isSending = retryMutation.isPending;\n  const canSend =\n    !isAttemptRunning && !!message.trim() && !!sessionId && !!processProfile;\n\n  const onCancel = () => {\n    onCancelled?.();\n  };\n\n  const onSend = useCallback(() => {\n    if (!canSend || !processProfile) return;\n    setSendError(null);\n    retryMutation.mutate({\n      message,\n      executor: processProfile.executor,\n      variant: selectedVariant,\n      executionProcessId,\n      branchStatus,\n      processes: attemptData.processes,\n    });\n  }, [\n    canSend,\n    retryMutation,\n    message,\n    processProfile,\n    selectedVariant,\n    executionProcessId,\n    branchStatus,\n    attemptData.processes,\n  ]);\n\n  const handleCmdEnter = useCallback(() => {\n    if (canSend && !isSending) {\n      onSend();\n    }\n  }, [canSend, isSending, onSend]);\n\n  const handlePasteFiles = useCallback(\n    async (files: File[]) => {\n      const sessionId = attempt.session?.id;\n      if (!sessionId) {\n        console.warn(\n          'Skipping retry image upload: missing session id for attempt',\n          workspaceId\n        );\n        return;\n      }\n\n      for (const file of files) {\n        try {\n          const response = await attachmentsApi.uploadForAttempt(\n            workspaceId,\n            sessionId,\n            file\n          );\n          const imageMarkdown = buildWorkspaceAttachmentMarkdown(response);\n          setMessage((prev) =>\n            prev ? `${prev}\\n\\n${imageMarkdown}` : imageMarkdown\n          );\n        } catch (error) {\n          console.error('Failed to upload attachment:', error);\n        }\n      }\n    },\n    [attempt.session?.id, workspaceId]\n  );\n\n  // Attachment button handlers\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const handleAttachClick = useCallback(() => {\n    fileInputRef.current?.click();\n  }, []);\n  const handleFileInputChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const files = Array.from(e.target.files || []);\n      if (files.length > 0) {\n        handlePasteFiles(files);\n      }\n      e.target.value = '';\n    },\n    [handlePasteFiles]\n  );\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"relative\">\n        <WYSIWYGEditor\n          placeholder=\"Edit and resend your message...\"\n          value={message}\n          onChange={setMessage}\n          disabled={isSending}\n          onCmdEnter={handleCmdEnter}\n          onPasteFiles={handlePasteFiles}\n          className={cn('min-h-[40px]', 'bg-background')}\n          workspaceId={workspaceId}\n          sessionId={attempt.session?.id}\n        />\n        {isSending && (\n          <div className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/60\">\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n          </div>\n        )}\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        <VariantSelector\n          selectedVariant={selectedVariant}\n          onChange={setSelectedVariant}\n          currentProfile={profiles?.[attempt.session?.executor ?? ''] ?? null}\n        />\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          multiple\n          className=\"hidden\"\n          onChange={handleFileInputChange}\n        />\n        <div className=\"ml-auto flex items-center gap-2\">\n          <Button\n            variant=\"outline\"\n            onClick={handleAttachClick}\n            disabled={isSending}\n            title=\"Attach file\"\n            aria-label=\"Attach file\"\n          >\n            <Paperclip className=\"h-3 w-3\" />\n          </Button>\n          <Button variant=\"outline\" onClick={onCancel} disabled={isSending}>\n            <X className=\"h-3 w-3 mr-1\" />{' '}\n            {t('buttons.cancel', { ns: 'common' })}\n          </Button>\n          <Button onClick={onSend} disabled={!canSend || isSending}>\n            <Send className=\"h-3 w-3 mr-1\" />{' '}\n            {t('buttons.send', { ns: 'common', defaultValue: 'Send' })}\n          </Button>\n        </div>\n      </div>\n\n      {sendError && (\n        <Alert variant=\"destructive\">\n          <AlertCircle className=\"h-4 w-4\" />\n          <AlertDescription>{sendError}</AlertDescription>\n        </Alert>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/OpenInIdeButton.tsx",
    "content": "import { useMemo } from 'react';\nimport { Button } from '@vibe/ui/components/Button';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { IdeIcon } from '@/shared/components/IdeIcon';\nimport { getIdeName } from '@/shared/lib/ideName';\n\ntype OpenInIdeButtonProps = {\n  onClick: () => void;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function OpenInIdeButton({\n  onClick,\n  disabled = false,\n  className,\n}: OpenInIdeButtonProps) {\n  const { config } = useUserSystem();\n  const editorType = config?.editor?.editor_type ?? null;\n\n  const label = useMemo(() => {\n    const ideName = getIdeName(editorType);\n    return `Open in ${ideName}`;\n  }, [editorType]);\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"sm\"\n      className={`h-10 w-10 p-0 hover:opacity-70 transition-opacity ${className ?? ''}`}\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={label}\n      title={label}\n    >\n      <IdeIcon editorType={editorType} className=\"h-4 w-4\" />\n      <span className=\"sr-only\">{label}</span>\n    </Button>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/RawLogText.tsx",
    "content": "import { memo } from 'react';\nimport { AnsiHtml } from 'fancy-ansi/react';\nimport { hasAnsi } from 'fancy-ansi';\nimport { clsx } from 'clsx';\n\ninterface RawLogTextProps {\n  content: string;\n  channel?: 'stdout' | 'stderr';\n  as?: 'div' | 'span';\n  className?: string;\n  linkifyUrls?: boolean;\n  searchQuery?: string;\n  isCurrentMatch?: boolean;\n}\n\nconst RawLogText = memo(\n  ({\n    content,\n    channel = 'stdout',\n    as: Component = 'div',\n    className,\n    linkifyUrls = false,\n    searchQuery,\n    isCurrentMatch = false,\n  }: RawLogTextProps) => {\n    // Only apply stderr fallback color when no ANSI codes are present\n    const hasAnsiCodes = hasAnsi(content);\n    const shouldApplyStderrFallback = channel === 'stderr' && !hasAnsiCodes;\n\n    const highlightClass = isCurrentMatch\n      ? 'bg-yellow-500/60 ring-1 ring-yellow-500 rounded-sm'\n      : 'bg-yellow-500/30 rounded-sm';\n\n    const highlightMatches = (text: string, key: string | number) => {\n      if (!searchQuery) {\n        return <AnsiHtml key={key} text={text} />;\n      }\n\n      const regex = new RegExp(\n        `(${searchQuery.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')})`,\n        'gi'\n      );\n      const parts = text.split(regex);\n\n      return parts.map((part, idx) => {\n        if (part.toLowerCase() === searchQuery.toLowerCase()) {\n          return (\n            <mark key={`${key}-${idx}`} className={highlightClass}>\n              <AnsiHtml text={part} />\n            </mark>\n          );\n        }\n        return <AnsiHtml key={`${key}-${idx}`} text={part} />;\n      });\n    };\n\n    const renderContent = () => {\n      if (!linkifyUrls) {\n        return highlightMatches(content, 'content');\n      }\n\n      const urlRegex = /(https?:\\/\\/\\S+)/g;\n      const parts = content.split(urlRegex);\n\n      return parts.map((part, index) => {\n        if (/^https?:\\/\\/\\S+$/.test(part)) {\n          return (\n            <a\n              key={index}\n              href={part}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline text-info hover:text-info/80 cursor-pointer\"\n              onClick={(e) => e.stopPropagation()}\n            >\n              {part}\n            </a>\n          );\n        }\n        // For non-URL parts, apply ANSI formatting with highlighting\n        return highlightMatches(part, index);\n      });\n    };\n\n    return (\n      <Component\n        className={clsx(\n          'font-mono text-xs break-all whitespace-pre-wrap',\n          shouldApplyStderrFallback && 'text-error',\n          className\n        )}\n      >\n        {renderContent()}\n      </Component>\n    );\n  }\n);\n\nRawLogText.displayName = 'RawLogText';\n\nexport default RawLogText;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/SearchableTagDropdownContainer.tsx",
    "content": "import { useState, useMemo, useCallback, useRef, useEffect } from 'react';\nimport { VirtuosoHandle } from 'react-virtuoso';\nimport type { Tag } from 'shared/remote-types';\nimport {\n  SearchableTagDropdown,\n  TAG_COLORS,\n} from '@vibe/ui/components/SearchableTagDropdown';\n\ninterface SearchableTagDropdownContainerProps {\n  tags: Tag[];\n  selectedTagIds: string[];\n  onTagToggle: (tagId: string) => void;\n  onCreateTag: (data: { name: string; color: string }) => string;\n  trigger: React.ReactNode;\n  disabled: boolean;\n  contentClassName: string;\n}\n\nexport function SearchableTagDropdownContainer({\n  tags,\n  selectedTagIds,\n  onTagToggle,\n  onCreateTag,\n  trigger,\n  disabled,\n  contentClassName,\n}: SearchableTagDropdownContainerProps) {\n  const [searchTerm, setSearchTerm] = useState('');\n  const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);\n  const [dropdownOpen, setDropdownOpen] = useState(false);\n  const [isCreating, setIsCreating] = useState(false);\n  const [colorIndex, setColorIndex] = useState(0);\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n  const colorPickerRef = useRef<HTMLDivElement>(null);\n\n  // Auto-focus color picker when entering create mode\n  useEffect(() => {\n    if (isCreating && colorPickerRef.current) {\n      colorPickerRef.current.focus();\n    }\n  }, [isCreating]);\n\n  // Derive newTagColor from colorIndex\n  const newTagColor = TAG_COLORS[colorIndex];\n\n  // Filter tags based on search term\n  const filteredTags = useMemo(() => {\n    if (!searchTerm.trim()) return tags;\n    const query = searchTerm.toLowerCase();\n    return tags.filter((tag) => tag.name.toLowerCase().includes(query));\n  }, [tags, searchTerm]);\n\n  // Check if search term matches any existing tag exactly\n  const hasExactMatch = useMemo(() => {\n    if (!searchTerm.trim()) return true; // Don't show create when empty\n    const query = searchTerm.toLowerCase().trim();\n    return tags.some((tag) => tag.name.toLowerCase() === query);\n  }, [tags, searchTerm]);\n\n  // Show create option when there's a search term and no exact match\n  const showCreateOption = searchTerm.trim().length > 0 && !hasExactMatch;\n\n  // Safe highlight index\n  const safeHighlightedIndex = useMemo(() => {\n    if (highlightedIndex === null) return null;\n    if (highlightedIndex >= filteredTags.length) return null;\n    return highlightedIndex;\n  }, [highlightedIndex, filteredTags.length]);\n\n  // Highlight create option when no tag is highlighted and create is available\n  const createOptionHighlighted =\n    safeHighlightedIndex === null && showCreateOption;\n\n  const handleSearchTermChange = useCallback((value: string) => {\n    setSearchTerm(value);\n    setHighlightedIndex(null);\n    setIsCreating(false);\n  }, []);\n\n  const moveHighlight = useCallback(\n    (delta: 1 | -1) => {\n      if (filteredTags.length === 0) return;\n      const start = safeHighlightedIndex ?? -1;\n      const next = (start + delta + filteredTags.length) % filteredTags.length;\n      setHighlightedIndex(next);\n      virtuosoRef.current?.scrollIntoView({ index: next, behavior: 'auto' });\n    },\n    [filteredTags, safeHighlightedIndex]\n  );\n\n  const attemptToggle = useCallback(() => {\n    if (safeHighlightedIndex == null) return;\n    const tag = filteredTags[safeHighlightedIndex];\n    if (!tag) return;\n    onTagToggle(tag.id);\n  }, [safeHighlightedIndex, filteredTags, onTagToggle]);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (isCreating) {\n        // Color picker mode keyboard navigation\n        switch (e.key) {\n          case 'ArrowLeft':\n            e.preventDefault();\n            e.stopPropagation();\n            setColorIndex(\n              (prev) => (prev - 1 + TAG_COLORS.length) % TAG_COLORS.length\n            );\n            return;\n          case 'ArrowRight':\n            e.preventDefault();\n            e.stopPropagation();\n            setColorIndex((prev) => (prev + 1) % TAG_COLORS.length);\n            return;\n          case 'Enter':\n            e.preventDefault();\n            e.stopPropagation();\n            if (searchTerm.trim()) {\n              const newTagId = onCreateTag({\n                name: searchTerm.trim(),\n                color: newTagColor,\n              });\n              onTagToggle(newTagId); // Auto-select the newly created tag\n              setSearchTerm('');\n              setIsCreating(false);\n              setColorIndex(0);\n            }\n            return;\n          case 'Escape':\n            e.preventDefault();\n            e.stopPropagation();\n            setIsCreating(false);\n            return;\n          default:\n            e.stopPropagation();\n            return;\n        }\n      }\n\n      switch (e.key) {\n        case 'ArrowDown':\n          e.preventDefault();\n          e.stopPropagation();\n          moveHighlight(1);\n          return;\n        case 'ArrowUp':\n          e.preventDefault();\n          e.stopPropagation();\n          moveHighlight(-1);\n          return;\n        case 'Enter':\n          e.preventDefault();\n          e.stopPropagation();\n          if (safeHighlightedIndex !== null) {\n            attemptToggle();\n          } else if (showCreateOption) {\n            setIsCreating(true);\n          }\n          return;\n        case 'Escape':\n          e.preventDefault();\n          e.stopPropagation();\n          setDropdownOpen(false);\n          return;\n        case 'Tab':\n          return;\n        default:\n          e.stopPropagation();\n      }\n    },\n    [\n      isCreating,\n      moveHighlight,\n      safeHighlightedIndex,\n      attemptToggle,\n      showCreateOption,\n      searchTerm,\n      newTagColor,\n      onCreateTag,\n      onTagToggle,\n    ]\n  );\n\n  const handleOpenChange = useCallback((next: boolean) => {\n    setDropdownOpen(next);\n    if (!next) {\n      setSearchTerm('');\n      setHighlightedIndex(null);\n      setIsCreating(false);\n      setColorIndex(0);\n    }\n  }, []);\n\n  const handleStartCreate = useCallback(() => {\n    setIsCreating(true);\n  }, []);\n\n  const handleConfirmCreate = useCallback(() => {\n    if (!searchTerm.trim()) return;\n    const newTagId = onCreateTag({\n      name: searchTerm.trim(),\n      color: newTagColor,\n    });\n    onTagToggle(newTagId); // Auto-select the newly created tag\n    setSearchTerm('');\n    setIsCreating(false);\n    setColorIndex(0);\n  }, [searchTerm, newTagColor, onCreateTag, onTagToggle]);\n\n  const handleCancelCreate = useCallback(() => {\n    setIsCreating(false);\n  }, []);\n\n  const handleColorIndexChange = useCallback((index: number) => {\n    setColorIndex(index);\n  }, []);\n\n  return (\n    <SearchableTagDropdown\n      filteredTags={filteredTags}\n      selectedTagIds={selectedTagIds}\n      onTagToggle={onTagToggle}\n      trigger={trigger}\n      searchTerm={searchTerm}\n      onSearchTermChange={handleSearchTermChange}\n      highlightedIndex={safeHighlightedIndex}\n      onHighlightedIndexChange={setHighlightedIndex}\n      open={dropdownOpen}\n      onOpenChange={handleOpenChange}\n      onKeyDown={handleKeyDown}\n      virtuosoRef={virtuosoRef}\n      showCreateOption={showCreateOption}\n      createOptionHighlighted={createOptionHighlighted}\n      isCreating={isCreating}\n      colorIndex={colorIndex}\n      onColorIndexChange={handleColorIndexChange}\n      onStartCreate={handleStartCreate}\n      onConfirmCreate={handleConfirmCreate}\n      onCancelCreate={handleCancelCreate}\n      colorPickerRef={colorPickerRef}\n      contentClassName={contentClassName}\n      disabled={disabled}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/SimpleMarkdown.tsx",
    "content": "import React from 'react';\n\ninterface SimpleMarkdownProps {\n  content: string;\n  className?: string;\n}\n\n/**\n * Lightweight markdown renderer for GitHub release note bodies.\n * Handles: ## headers, bullet lists (* / -), **bold**, [links](url),\n * bare URLs (with PR shortening), and @mentions.\n */\nexport function SimpleMarkdown({ content, className }: SimpleMarkdownProps) {\n  const lines = content.split('\\n');\n  const elements: React.ReactNode[] = [];\n  let currentList: string[] = [];\n  let key = 0;\n\n  const flushList = () => {\n    if (currentList.length > 0) {\n      elements.push(\n        <ul key={key++} className=\"space-y-0.5 text-sm text-normal\">\n          {currentList.map((item, i) => (\n            <li key={i} className=\"flex gap-1.5\">\n              <span className=\"text-low shrink-0\">{'·'}</span>\n              <span>{renderInline(item)}</span>\n            </li>\n          ))}\n        </ul>\n      );\n      currentList = [];\n    }\n  };\n\n  for (const line of lines) {\n    const trimmed = line.trim();\n\n    if (!trimmed) {\n      flushList();\n      continue;\n    }\n\n    if (trimmed.startsWith('## ')) {\n      flushList();\n      elements.push(\n        <h3 key={key++} className=\"text-sm font-semibold text-high\">\n          {trimmed.slice(3)}\n        </h3>\n      );\n      continue;\n    }\n\n    if (trimmed.startsWith('* ') || trimmed.startsWith('- ')) {\n      currentList.push(trimmed.slice(2));\n      continue;\n    }\n\n    flushList();\n    elements.push(\n      <p key={key++} className=\"text-xs text-low\">\n        {renderInline(trimmed)}\n      </p>\n    );\n  }\n\n  flushList();\n\n  return <div className={className}>{elements}</div>;\n}\n\nfunction renderInline(text: string): React.ReactNode {\n  const parts: React.ReactNode[] = [];\n  const regex =\n    /(\\*\\*(.+?)\\*\\*)|(\\[([^\\]]+)\\]\\(([^)]+)\\))|(https?:\\/\\/[^\\s)]+)|(@[\\w-]+)/g;\n  let lastIndex = 0;\n  let match: RegExpExecArray | null;\n  let i = 0;\n\n  while ((match = regex.exec(text)) !== null) {\n    if (match.index > lastIndex) {\n      parts.push(text.slice(lastIndex, match.index));\n    }\n\n    if (match[1]) {\n      // **bold**\n      parts.push(\n        <strong key={i++} className=\"font-semibold text-high\">\n          {match[2]}\n        </strong>\n      );\n    } else if (match[3]) {\n      // [text](url)\n      parts.push(\n        <a\n          key={i++}\n          href={match[5]}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-brand hover:underline\"\n        >\n          {match[4]}\n        </a>\n      );\n    } else if (match[6]) {\n      // Bare URL — shorten PR links\n      const url = match[6];\n      const prMatch = url.match(/\\/pull\\/(\\d+)$/);\n      const label = prMatch ? `#${prMatch[1]}` : 'link';\n      parts.push(\n        <a\n          key={i++}\n          href={url}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-brand hover:underline\"\n        >\n          {label}\n        </a>\n      );\n    } else if (match[7]) {\n      // @mention\n      parts.push(\n        <span key={i++} className=\"text-low\">\n          {match[7]}\n        </span>\n      );\n    }\n\n    lastIndex = match.index + match[0].length;\n  }\n\n  if (lastIndex < text.length) {\n    parts.push(text.slice(lastIndex));\n  }\n\n  return parts.length === 1 ? parts[0] : parts;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/TagManager.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { PlusIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';\nimport { SpinnerGap } from '@phosphor-icons/react';\nimport { tagsApi } from '@/shared/lib/api';\nimport { TagEditDialog } from '@/shared/dialogs/shared/TagEditDialog';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { IconButton } from '@vibe/ui/components/IconButton';\nimport type { Tag } from 'shared/types';\n\nexport function TagManager() {\n  const { t } = useTranslation('settings');\n  const [tags, setTags] = useState<Tag[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  const fetchTags = useCallback(async () => {\n    setLoading(true);\n    try {\n      const data = await tagsApi.list();\n      setTags(data);\n    } catch (err) {\n      console.error('Failed to fetch tags:', err);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchTags();\n  }, [fetchTags]);\n\n  const handleOpenDialog = useCallback(\n    async (tag?: Tag) => {\n      try {\n        const result = await TagEditDialog.show({\n          tag: tag || null,\n        });\n\n        if (result === 'saved') {\n          await fetchTags();\n        }\n      } catch (error) {\n        // User cancelled - do nothing\n      }\n    },\n    [fetchTags]\n  );\n\n  const handleDelete = useCallback(\n    async (tag: Tag) => {\n      if (\n        !confirm(\n          t('settings.general.tags.manager.deleteConfirm', {\n            tagName: tag.tag_name,\n          })\n        )\n      ) {\n        return;\n      }\n\n      try {\n        await tagsApi.delete(tag.id);\n        await fetchTags();\n      } catch (err) {\n        console.error('Failed to delete tag:', err);\n      }\n    },\n    [fetchTags, t]\n  );\n\n  if (loading) {\n    return (\n      <div className=\"flex items-center justify-center py-8\">\n        <SpinnerGap className=\"h-8 w-8 animate-spin\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex justify-between items-center\">\n        <h3 className=\"text-lg font-semibold\">\n          {t('settings.general.tags.manager.title')}\n        </h3>\n        <PrimaryButton\n          variant=\"tertiary\"\n          onClick={() => handleOpenDialog()}\n          actionIcon={PlusIcon}\n        >\n          {t('settings.general.tags.manager.addTag')}\n        </PrimaryButton>\n      </div>\n\n      {tags.length === 0 ? (\n        <div className=\"text-center py-8 text-muted-foreground\">\n          {t('settings.general.tags.manager.noTags')}\n        </div>\n      ) : (\n        <div className=\"border rounded-lg overflow-hidden\">\n          <div className=\"max-h-[400px] overflow-auto\">\n            <table className=\"w-full\">\n              <thead className=\"border-b bg-muted/50 sticky top-0\">\n                <tr>\n                  <th className=\"text-left p-2 text-sm font-medium\">\n                    {t('settings.general.tags.manager.table.tagName')}\n                  </th>\n                  <th className=\"text-left p-2 text-sm font-medium\">\n                    {t('settings.general.tags.manager.table.content')}\n                  </th>\n                  <th className=\"text-right p-2 text-sm font-medium\">\n                    {t('settings.general.tags.manager.table.actions')}\n                  </th>\n                </tr>\n              </thead>\n              <tbody>\n                {tags.map((tag) => (\n                  <tr\n                    key={tag.id}\n                    className=\"border-b hover:bg-muted/30 transition-colors\"\n                  >\n                    <td className=\"p-2 text-sm font-medium\">@{tag.tag_name}</td>\n                    <td className=\"p-2 text-sm\">\n                      <div\n                        className=\"max-w-[400px] truncate\"\n                        title={tag.content || ''}\n                      >\n                        {tag.content || (\n                          <span className=\"text-muted-foreground\">-</span>\n                        )}\n                      </div>\n                    </td>\n                    <td className=\"p-2\">\n                      <div className=\"flex justify-end gap-1\">\n                        <IconButton\n                          icon={PencilSimpleIcon}\n                          aria-label=\"edit\"\n                          onClick={() => handleOpenDialog(tag)}\n                          title={t(\n                            'settings.general.tags.manager.actions.editTag'\n                          )}\n                        />\n                        <IconButton\n                          icon={TrashIcon}\n                          aria-label=\"delete\"\n                          onClick={() => handleDelete(tag)}\n                          title={t(\n                            'settings.general.tags.manager.actions.deleteTag'\n                          )}\n                        />\n                      </div>\n                    </td>\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/TerminalPanelContainer.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useTerminal } from '@/shared/hooks/useTerminal';\nimport { TerminalPanel } from '@vibe/ui/components/TerminalPanel';\nimport { XTermInstance } from './XTermInstance';\n\nexport function TerminalPanelContainer() {\n  const { workspace } = useWorkspaceContext();\n  const {\n    getTabsForWorkspace,\n    getActiveTab,\n    createTab,\n    closeTab,\n    clearWorkspaceTabs,\n  } = useTerminal();\n\n  const workspaceId = workspace?.id;\n  const containerRef = workspace?.container_ref ?? null;\n  const tabs = workspaceId ? getTabsForWorkspace(workspaceId) : [];\n  const activeTab = workspaceId ? getActiveTab(workspaceId) : null;\n\n  const creatingRef = useRef(false);\n  const prevWorkspaceIdRef = useRef<string | null>(null);\n\n  // Clean up terminals when workspace changes\n  useEffect(() => {\n    if (\n      prevWorkspaceIdRef.current &&\n      prevWorkspaceIdRef.current !== workspaceId\n    ) {\n      clearWorkspaceTabs(prevWorkspaceIdRef.current);\n    }\n    prevWorkspaceIdRef.current = workspaceId ?? null;\n  }, [workspaceId, clearWorkspaceTabs]);\n\n  // Auto-create first tab when workspace is selected and terminal mode is active\n  useEffect(() => {\n    if (\n      workspaceId &&\n      containerRef &&\n      tabs.length === 0 &&\n      !creatingRef.current\n    ) {\n      creatingRef.current = true;\n      createTab(workspaceId, containerRef);\n    }\n    if (tabs.length > 0) {\n      creatingRef.current = false;\n    }\n  }, [workspaceId, containerRef, tabs.length, createTab]);\n\n  return (\n    <TerminalPanel\n      tabs={tabs}\n      activeTabId={activeTab?.id ?? null}\n      renderTab={(tabId, isActive) => (\n        <XTermInstance\n          key={tabId}\n          tabId={tabId}\n          workspaceId={workspaceId ?? ''}\n          isActive={isActive}\n          onClose={() => workspaceId && closeTab(workspaceId, tabId)}\n        />\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/VariantSelector.tsx",
    "content": "import { memo, forwardRef, useEffect, useState } from 'react';\nimport { ChevronDown, Settings2 } from 'lucide-react';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/DropdownMenu';\nimport { cn } from '@/shared/lib/utils';\nimport { getSortedExecutorVariantKeys } from '@/shared/lib/executor';\nimport type { ExecutorProfile } from 'shared/types';\n\ntype Props = {\n  currentProfile: ExecutorProfile | null;\n  selectedVariant: string | null;\n  onChange: (variant: string | null) => void;\n  disabled?: boolean;\n  className?: string;\n};\n\nconst VariantSelectorInner = forwardRef<HTMLButtonElement, Props>(\n  ({ currentProfile, selectedVariant, onChange, disabled, className }, ref) => {\n    // Bump-effect animation when cycling through variants\n    const [isAnimating, setIsAnimating] = useState(false);\n    useEffect(() => {\n      if (!currentProfile) return;\n      setIsAnimating(true);\n      const t = setTimeout(() => setIsAnimating(false), 300);\n      return () => clearTimeout(t);\n    }, [selectedVariant, currentProfile]);\n\n    const variantOptions = currentProfile\n      ? getSortedExecutorVariantKeys(currentProfile)\n      : [];\n    const hasVariants = variantOptions.length > 0;\n\n    if (!currentProfile) return null;\n\n    if (!hasVariants) {\n      return (\n        <Button\n          ref={ref}\n          variant=\"outline\"\n          size=\"sm\"\n          className={cn(\n            'h-10 w-24 px-2 flex items-center justify-between',\n            className\n          )}\n          disabled\n        />\n      );\n    }\n\n    return (\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            ref={ref}\n            variant=\"secondary\"\n            size=\"sm\"\n            className={cn(\n              'px-2 flex items-center justify-between transition-all',\n              isAnimating && 'scale-105 bg-accent',\n              className\n            )}\n            disabled={disabled}\n          >\n            <Settings2 className=\"h-3 w-3 mr-1 flex-shrink-0\" />\n            <span className=\"text-xs truncate flex-1 text-left\">\n              {selectedVariant || 'DEFAULT'}\n            </span>\n            <ChevronDown className=\"h-3 w-3 ml-1 flex-shrink-0\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent>\n          {variantOptions.map((variantLabel) => (\n            <DropdownMenuItem\n              key={variantLabel}\n              onClick={() => onChange(variantLabel)}\n              className={selectedVariant === variantLabel ? 'bg-accent' : ''}\n            >\n              {variantLabel}\n            </DropdownMenuItem>\n          ))}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    );\n  }\n);\n\nVariantSelectorInner.displayName = 'VariantSelector';\nexport const VariantSelector = memo(VariantSelectorInner);\n"
  },
  {
    "path": "packages/web-core/src/shared/components/VirtualizedProcessLogs.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  DataWithScrollModifier,\n  ScrollModifier,\n  VirtuosoMessageList,\n  VirtuosoMessageListLicense,\n  VirtuosoMessageListMethods,\n  VirtuosoMessageListProps,\n} from '@virtuoso.dev/message-list';\nimport { WarningCircleIcon } from '@phosphor-icons/react/dist/ssr';\nimport RawLogText from '@/shared/components/RawLogText';\nimport {\n  INITIAL_TOP_ITEM,\n  InitialDataScrollModifier,\n  ScrollToBottomModifier as ScrollToLastItem,\n} from '@/shared/lib/virtuoso-modifiers';\nimport type { PatchType } from 'shared/types';\n\nexport type LogEntry = Extract<\n  PatchType,\n  { type: 'STDOUT' } | { type: 'STDERR' }\n>;\n\nexport interface VirtualizedProcessLogsProps {\n  logs: LogEntry[];\n  error: string | null;\n  searchQuery: string;\n  matchIndices: number[];\n  currentMatchIndex: number;\n}\n\ntype LogEntryWithKey = LogEntry & { key: string; originalIndex: number };\n\ninterface SearchContext {\n  searchQuery: string;\n  matchIndices: number[];\n  currentMatchIndex: number;\n}\n\nconst computeItemKey: VirtuosoMessageListProps<\n  LogEntryWithKey,\n  SearchContext\n>['computeItemKey'] = ({ data }) => data.key;\n\nconst ItemContent: VirtuosoMessageListProps<\n  LogEntryWithKey,\n  SearchContext\n>['ItemContent'] = ({ data, context }) => {\n  const isMatch = context.matchIndices.includes(data.originalIndex);\n  const isCurrentMatch =\n    context.matchIndices[context.currentMatchIndex] === data.originalIndex;\n\n  return (\n    <RawLogText\n      content={data.content}\n      channel={data.type === 'STDERR' ? 'stderr' : 'stdout'}\n      className=\"text-sm px-4 py-1\"\n      linkifyUrls\n      searchQuery={isMatch ? context.searchQuery : undefined}\n      isCurrentMatch={isCurrentMatch}\n    />\n  );\n};\n\nexport function VirtualizedProcessLogs({\n  logs,\n  error,\n  searchQuery,\n  matchIndices,\n  currentMatchIndex,\n}: VirtualizedProcessLogsProps) {\n  const { t } = useTranslation('tasks');\n  const [channelData, setChannelData] =\n    useState<DataWithScrollModifier<LogEntryWithKey> | null>(null);\n  const messageListRef = useRef<VirtuosoMessageListMethods<\n    LogEntryWithKey,\n    SearchContext\n  > | null>(null);\n  const hasInitializedRef = useRef(false);\n  const prevCurrentMatchRef = useRef<number | undefined>(undefined);\n  const isAtBottomRef = useRef(true);\n\n  useEffect(() => {\n    const timeoutId = setTimeout(() => {\n      const logsWithKeys: LogEntryWithKey[] = logs.map((entry, index) => ({\n        ...entry,\n        key: `log-${index}`,\n        originalIndex: index,\n      }));\n\n      // Use InitialDataScrollModifier (with purgeItemSizes) only on the\n      // very first data load. For all subsequent updates, use ScrollToLastItem\n      // which always jumps to the end — unlike auto-scroll-to-bottom which\n      // only follows if the viewport is already at the bottom.\n      let scrollModifier: ScrollModifier | null = null;\n      if (!hasInitializedRef.current && logs.length > 0) {\n        hasInitializedRef.current = true;\n        scrollModifier = InitialDataScrollModifier;\n      } else if (isAtBottomRef.current) {\n        scrollModifier = ScrollToLastItem;\n      }\n\n      if (scrollModifier) {\n        setChannelData({ data: logsWithKeys, scrollModifier });\n      } else {\n        setChannelData({ data: logsWithKeys });\n      }\n    }, 100);\n\n    return () => clearTimeout(timeoutId);\n  }, [logs]);\n\n  // Scroll to current match when it changes\n  useEffect(() => {\n    if (\n      matchIndices.length > 0 &&\n      currentMatchIndex >= 0 &&\n      currentMatchIndex !== prevCurrentMatchRef.current\n    ) {\n      const logIndex = matchIndices[currentMatchIndex];\n      messageListRef.current?.scrollToItem({\n        index: logIndex,\n        align: 'center',\n        behavior: 'smooth',\n      });\n      prevCurrentMatchRef.current = currentMatchIndex;\n    }\n  }, [currentMatchIndex, matchIndices]);\n\n  if (logs.length === 0 && !error) {\n    return (\n      <div className=\"h-full flex items-center justify-center\">\n        <p className=\"text-center text-muted-foreground text-sm\">\n          {t('processes.noLogsAvailable')}\n        </p>\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <div className=\"h-full flex items-center justify-center\">\n        <p className=\"text-center text-destructive text-sm\">\n          <WarningCircleIcon className=\"size-icon-base inline mr-2\" />\n          {error}\n        </p>\n      </div>\n    );\n  }\n\n  const context: SearchContext = {\n    searchQuery,\n    matchIndices,\n    currentMatchIndex,\n  };\n\n  return (\n    <div className=\"virtuoso-license-wrapper h-full overflow-hidden\">\n      <VirtuosoMessageListLicense\n        licenseKey={import.meta.env.VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY}\n      >\n        <VirtuosoMessageList<LogEntryWithKey, SearchContext>\n          ref={messageListRef}\n          className=\"h-full\"\n          data={channelData}\n          context={context}\n          initialLocation={INITIAL_TOP_ITEM}\n          onScroll={(location) => {\n            isAtBottomRef.current = location.isAtBottom;\n          }}\n          computeItemKey={computeItemKey}\n          ItemContent={ItemContent}\n        />\n      </VirtuosoMessageListLicense>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/WYSIWYGEditor.tsx",
    "content": "import {\n  useMemo,\n  useState,\n  useCallback,\n  useContext,\n  memo,\n  forwardRef,\n  useImperativeHandle,\n  useRef,\n  useEffect,\n  type ReactNode,\n} from 'react';\nimport { LexicalComposer } from '@lexical/react/LexicalComposer';\nimport { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';\nimport { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';\nimport { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';\nimport { ContentEditable } from '@lexical/react/LexicalContentEditable';\nimport {\n  TRANSFORMERS,\n  TEXT_FORMAT_TRANSFORMERS,\n  CODE,\n  type Transformer,\n} from '@lexical/markdown';\nimport { MarkdownInsertPlugin } from '@vibe/ui/components/MarkdownInsertPlugin';\nimport { MarkdownListContinuePlugin } from '@vibe/ui/components/MarkdownListContinuePlugin';\nimport {\n  PrCommentNode,\n  PR_COMMENT_TRANSFORMER,\n  PR_COMMENT_EXPORT_TRANSFORMER,\n} from '@vibe/ui/components/pr-comment-node';\nimport { createImageNode } from '@vibe/ui/components/image-node';\nimport { createAttachmentNode } from '@vibe/ui/components/attachment-node';\nimport {\n  ComponentInfoNode,\n  COMPONENT_INFO_TRANSFORMER,\n  COMPONENT_INFO_EXPORT_TRANSFORMER,\n  $isComponentInfoNode,\n} from '@vibe/ui/components/component-info-node';\nimport { TABLE_TRANSFORMER } from '@vibe/ui/lib/table-transformer';\nimport {\n  WorkspaceContext as EditorWorkspaceContext,\n  SessionContext,\n  LocalAttachmentsContext,\n  type LocalAttachmentMetadata,\n} from '@vibe/ui/components/WorkspaceContext';\nimport { TypeaheadOpenProvider } from '@vibe/ui/components/TypeaheadOpenContext';\nimport {\n  FileTagTypeaheadPlugin,\n  type RepoLike,\n  type SearchResultItemLike,\n} from '@vibe/ui/components/FileTagTypeaheadPlugin';\nimport { SlashCommandTypeaheadPlugin } from '@vibe/ui/components/SlashCommandTypeaheadPlugin';\nimport { KeyboardCommandsPlugin } from '@vibe/ui/components/KeyboardCommandsPlugin';\nimport { ImageKeyboardPlugin } from '@vibe/ui/components/ImageKeyboardPlugin';\nimport { ComponentInfoKeyboardPlugin } from '@vibe/ui/components/ComponentInfoKeyboardPlugin';\nimport { ReadOnlyLinkPlugin } from '@vibe/ui/components/ReadOnlyLinkPlugin';\nimport { ClickableCodePlugin } from '@vibe/ui/components/ClickableCodePlugin';\nimport { ToolbarPlugin } from '@vibe/ui/components/ToolbarPlugin';\nimport { StaticToolbarPlugin } from '@vibe/ui/components/StaticToolbarPlugin';\nimport { PasteMarkdownPlugin } from '@vibe/ui/components/PasteMarkdownPlugin';\nimport { MarkdownSyncPlugin } from '@vibe/ui/components/MarkdownSyncPlugin';\nimport { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';\nimport { HeadingNode, QuoteNode } from '@lexical/rich-text';\nimport { ListNode, ListItemNode } from '@lexical/list';\nimport { ListPlugin } from '@lexical/react/LexicalListPlugin';\nimport { CodeNode, CodeHighlightNode } from '@lexical/code';\nimport { CodeHighlightPlugin } from '@vibe/ui/components/CodeHighlightPlugin';\nimport { CODE_HIGHLIGHT_CLASSES } from '@vibe/ui/lib/code-highlight-theme';\nimport { LinkNode } from '@lexical/link';\nimport { TableNode, TableRowNode, TableCellNode } from '@lexical/table';\nimport { TablePlugin } from '@lexical/react/LexicalTablePlugin';\nimport { type EditorState, type LexicalEditor } from 'lexical';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { WorkspaceDiffContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useSlashCommands } from '@/shared/hooks/useExecutorDiscovery';\nimport { useUiPreferencesStore } from '@/shared/stores/useUiPreferencesStore';\nimport { cn } from '@/shared/lib/utils';\nimport { repoApi } from '@/shared/lib/api';\nimport { searchTagsAndFiles } from '@/shared/lib/searchTagsAndFiles';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Check, Clipboard, Pencil, Trash2 } from 'lucide-react';\nimport type { RepoItem } from '@/shared/types/selectionItems';\nimport { TagEditDialog } from '@/shared/dialogs/shared/TagEditDialog';\nimport { ImagePreviewDialog } from '@/shared/dialogs/wysiwyg/ImagePreviewDialog';\nimport {\n  SelectionDialog,\n  type SelectionPage,\n} from '@/shared/dialogs/command-bar/SelectionDialog';\nimport {\n  buildRepoSelectionPages,\n  type RepoSelectionResult,\n} from '@/shared/dialogs/command-bar/selections/repoSelection';\nimport { fetchAttachmentSasUrl } from '@/shared/lib/remoteApi';\nimport { writeClipboardViaBridge } from '@/shared/lib/clipboard';\nimport type { SendMessageShortcut } from 'shared/types';\nimport type { BaseCodingAgent } from 'shared/types';\n\n/** Markdown string representing the editor content */\nexport type SerializedEditorState = string;\n\ntype WysiwygProps = {\n  placeholder?: string;\n  /** Markdown string representing the editor content */\n  value: SerializedEditorState;\n  onChange?: (state: SerializedEditorState) => void;\n  onEditorStateChange?: (s: EditorState) => void;\n  disabled?: boolean;\n  onPasteFiles?: (files: File[]) => void;\n  className?: string;\n  /** Repo IDs for file search in typeahead */\n  repoIds?: string[];\n  /** Enables `/` command autocomplete (agent-specific). */\n  executor?: BaseCodingAgent | null;\n  onCmdEnter?: () => void;\n  onShiftCmdEnter?: () => void;\n  /** Keyboard shortcut mode for sending messages */\n  sendShortcut?: SendMessageShortcut;\n  /** Task attempt ID for resolving .vibe-attachments paths */\n  workspaceId?: string;\n  /** Session ID used for workspace-scoped APIs (attachments, slash command discovery) */\n  sessionId?: string;\n  /** Repo ID for slash commands when no workspace yet */\n  repoId?: string;\n  /** Local attachments for immediate rendering (before saved to server) */\n  localAttachments?: LocalAttachmentMetadata[];\n  /** Optional edit callback - shows edit button in read-only mode when provided */\n  onEdit?: () => void;\n  /** Optional delete callback - shows delete button in read-only mode when provided */\n  onDelete?: () => void;\n  /** Auto-focus the editor on mount */\n  autoFocus?: boolean;\n  /** Function to find a matching diff path for clickable inline code (only in read-only mode) */\n  findMatchingDiffPath?: (text: string) => string | null;\n  /** Callback when clickable inline code is clicked (only in read-only mode) */\n  onCodeClick?: (fullPath: string) => void;\n  /** Hide the copy/edit/delete action buttons in read-only mode */\n  hideActions?: boolean;\n  /** Show a static toolbar below the editor content */\n  showStaticToolbar?: boolean;\n  /** Save status indicator for static toolbar */\n  saveStatus?: 'idle' | 'saved';\n  /** Additional actions to render in static toolbar */\n  staticToolbarActions?: ReactNode;\n  /** Called when a toolbar button is clicked in preview mode to request edit */\n  onRequestEdit?: () => void;\n};\n\n/** Ref interface for WYSIWYGEditor, exposing imperative methods */\nexport interface WYSIWYGEditorRef {\n  /** Focus the editor */\n  focus: () => void;\n}\n\nconst GENERIC_CLIPBOARD_IMAGE_BASE_NAMES = new Set([\n  'image',\n  'output',\n  'clipboard',\n  'pasted-image',\n  'screenshot',\n]);\nconst MAX_CLIPBOARD_PASTED_FILES = 10;\n\nfunction getImageMimePriority(mimeType: string): number {\n  if (mimeType === 'image/png') return 5;\n  if (mimeType === 'image/webp') return 4;\n  if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return 3;\n  if (mimeType === 'image/gif') return 2;\n  return 1;\n}\n\nfunction isGenericClipboardImageName(fileName: string): boolean {\n  const baseName = fileName\n    .replace(/\\.[^.]+$/, '')\n    .trim()\n    .toLowerCase()\n    .replace(/\\s+/g, '-');\n  return GENERIC_CLIPBOARD_IMAGE_BASE_NAMES.has(baseName);\n}\n\nfunction dedupeClipboardFiles(files: File[]): File[] {\n  if (files.length <= 1) {\n    return files;\n  }\n\n  const uniqueByMetadata: File[] = [];\n  const seen = new Set<string>();\n  for (const file of files) {\n    const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    uniqueByMetadata.push(file);\n  }\n\n  if (uniqueByMetadata.length <= 1) {\n    return uniqueByMetadata;\n  }\n\n  const imageFiles = uniqueByMetadata.filter((f) =>\n    f.type.startsWith('image/')\n  );\n  const nonImageFiles = uniqueByMetadata.filter(\n    (f) => !f.type.startsWith('image/')\n  );\n\n  if (nonImageFiles.length > 0 || imageFiles.length <= 1) {\n    return uniqueByMetadata.slice(0, MAX_CLIPBOARD_PASTED_FILES);\n  }\n\n  const nonGenericImageFiles = imageFiles.filter(\n    (f) => !isGenericClipboardImageName(f.name)\n  );\n\n  if (imageFiles.length >= 3 && nonGenericImageFiles.length === 1) {\n    return [nonGenericImageFiles[0]];\n  }\n\n  if (imageFiles.length >= 3 && nonGenericImageFiles.length === 0) {\n    const [preferredImage] = [...imageFiles].sort((a, b) => {\n      const priorityDiff =\n        getImageMimePriority(b.type) - getImageMimePriority(a.type);\n      if (priorityDiff !== 0) return priorityDiff;\n      return b.size - a.size;\n    });\n\n    return preferredImage ? [preferredImage] : uniqueByMetadata;\n  }\n\n  return uniqueByMetadata.slice(0, MAX_CLIPBOARD_PASTED_FILES);\n}\n\nfunction getRepoDisplayName(repo: RepoLike): string {\n  return repo.display_name || repo.name;\n}\n\nfunction toRepoItem(repo: RepoLike): RepoItem {\n  return {\n    id: repo.id,\n    display_name: getRepoDisplayName(repo),\n  };\n}\n\n/** Plugin to capture the Lexical editor instance into a ref */\nfunction EditorRefPlugin({\n  editorRef,\n}: {\n  editorRef: React.MutableRefObject<LexicalEditor | null>;\n}) {\n  const [editor] = useLexicalComposerContext();\n  useEffect(() => {\n    editorRef.current = editor;\n  }, [editor, editorRef]);\n  return null;\n}\n\nconst WYSIWYGEditor = forwardRef<WYSIWYGEditorRef, WysiwygProps>(\n  function WYSIWYGEditor(\n    {\n      placeholder = '',\n      value,\n      onChange,\n      onEditorStateChange,\n      disabled = false,\n      onPasteFiles,\n      className,\n      repoIds,\n      executor = null,\n      onCmdEnter,\n      onShiftCmdEnter,\n      sendShortcut,\n      workspaceId,\n      sessionId,\n      repoId,\n      localAttachments,\n      onEdit,\n      onDelete,\n      autoFocus = false,\n      findMatchingDiffPath,\n      onCodeClick,\n      hideActions = false,\n      showStaticToolbar = false,\n      saveStatus,\n      staticToolbarActions,\n      onRequestEdit,\n    }: WysiwygProps,\n    ref: React.ForwardedRef<WYSIWYGEditorRef>\n  ) {\n    // Ref to capture the Lexical editor instance for imperative methods\n    const editorInstanceRef = useRef<LexicalEditor | null>(null);\n\n    // Expose focus method via ref.\n    // Guard: only pass a valid ref to useImperativeHandle. When the component\n    // is rendered without a ref (e.g. the nested preview editor), React dev\n    // mode may pass a frozen empty object which causes \"Cannot add property\n    // current, object is not extensible\".\n    const safeRef =\n      typeof ref === 'function' || (ref && 'current' in ref) ? ref : null;\n    useImperativeHandle(safeRef, () => ({\n      focus: () => {\n        editorInstanceRef.current?.focus();\n      },\n    }));\n\n    // Copy button state\n    const [copied, setCopied] = useState(false);\n    const diffContext = useContext(WorkspaceDiffContext);\n    const diffPaths = useMemo(\n      () => diffContext?.diffPaths ?? new Set<string>(),\n      [diffContext?.diffPaths]\n    );\n    const preferredRepoId = useUiPreferencesStore(\n      (state) => state.fileSearchRepoId\n    );\n    const setFileSearchRepo = useUiPreferencesStore(\n      (state) => state.setFileSearchRepo\n    );\n    const slashCommandsQuery = useSlashCommands(executor, {\n      workspaceId: sessionId ? workspaceId : undefined,\n      sessionId,\n      repoId,\n    });\n    const listRecentRepos = useCallback(async () => repoApi.listRecent(), []);\n    const getRepoById = useCallback(async (targetRepoId: string) => {\n      try {\n        return await repoApi.getById(targetRepoId);\n      } catch {\n        return null;\n      }\n    }, []);\n    const chooseRepo = useCallback(async (repos: RepoLike[]) => {\n      const repoResult = (await SelectionDialog.show({\n        initialPageId: 'selectRepo',\n        pages: buildRepoSelectionPages(repos.map(toRepoItem)) as Record<\n          string,\n          SelectionPage\n        >,\n      })) as RepoSelectionResult | undefined;\n      return repoResult;\n    }, []);\n    const handleCreateTag = useCallback(async () => {\n      try {\n        const result = await TagEditDialog.show({\n          tag: null,\n        });\n        return result === 'saved';\n      } catch {\n        return false;\n      }\n    }, []);\n    const searchFileTags = useCallback(\n      async (\n        query: string,\n        options: { repoIds?: string[] }\n      ): Promise<SearchResultItemLike[]> => {\n        const results = await searchTagsAndFiles(query, options);\n        const mappedResults: SearchResultItemLike[] = [];\n        for (const result of results) {\n          if (result.type === 'tag' && result.tag) {\n            mappedResults.push({ type: 'tag', tag: result.tag });\n          }\n          if (result.type === 'file' && result.file) {\n            mappedResults.push({ type: 'file', file: result.file });\n          }\n        }\n        return mappedResults;\n      },\n      []\n    );\n    const handleCopy = useCallback(async () => {\n      if (!value) return;\n      try {\n        // Unescape markdown-escaped underscores for cleaner clipboard output\n        const unescaped = value.replace(/\\\\_/g, '_');\n        await writeClipboardViaBridge(unescaped);\n        setCopied(true);\n        window.setTimeout(() => setCopied(false), 400);\n      } catch {\n        // noop – bridge handles fallback\n      }\n    }, [value]);\n    const imageNodeDefinition = useMemo(\n      () =>\n        createImageNode({\n          fetchAttachmentUrl: fetchAttachmentSasUrl,\n          openImagePreview: (options) => {\n            ImagePreviewDialog.show(options);\n          },\n        }),\n      []\n    );\n    const attachmentNodeDefinition = useMemo(\n      () =>\n        createAttachmentNode({\n          fetchAttachmentUrl: fetchAttachmentSasUrl,\n        }),\n      []\n    );\n    const { ImageNode, IMAGE_TRANSFORMER, $isImageNode } = imageNodeDefinition;\n    const { AttachmentNode, ATTACHMENT_TRANSFORMER } = attachmentNodeDefinition;\n\n    const initialConfig = useMemo(\n      () => ({\n        namespace: 'md-wysiwyg',\n        onError: console.error,\n        theme: {\n          paragraph: 'mb-1 last:mb-0',\n          heading: {\n            h1: 'mt-4 mb-2 text-2xl font-semibold',\n            h2: 'mt-3 mb-2 text-xl font-semibold',\n            h3: 'mt-3 mb-2 text-lg font-semibold',\n            h4: 'mt-2 mb-1 text-base font-medium',\n            h5: 'mt-2 mb-1 text-sm font-medium',\n            h6: 'mt-2 mb-1 text-xs font-medium uppercase tracking-wide',\n          },\n          quote:\n            'my-3 border-l-4 border-primary-foreground pl-4 text-muted-foreground',\n          list: {\n            ul: 'my-1 list-disc list-inside',\n            ol: 'my-1 list-decimal list-inside',\n            listitem: '',\n            nested: {\n              // Hide the structural wrapper marker Lexical adds for nested items.\n              listitem: 'list-none pl-4',\n            },\n          },\n          link: 'text-blue-600 dark:text-blue-400 underline underline-offset-2 cursor-pointer hover:text-blue-800 dark:hover:text-blue-300',\n          text: {\n            bold: 'font-semibold',\n            italic: 'italic',\n            underline: 'underline underline-offset-2',\n            strikethrough: 'line-through',\n            code: 'font-mono bg-muted bg-panel px-1 py-0.5 rounded',\n          },\n          code: 'block font-mono bg-secondary rounded-md px-3 py-2 my-2 whitespace-pre overflow-x-auto',\n          codeHighlight: CODE_HIGHLIGHT_CLASSES,\n          table: 'border-collapse my-2 w-full text-sm',\n          tableRow: '',\n          tableCell: 'border border-low px-3 py-2 text-left align-top',\n          tableCellHeader:\n            'bg-muted font-semibold border border-low px-3 py-2 text-left align-top',\n        },\n        nodes: [\n          HeadingNode,\n          QuoteNode,\n          ListNode,\n          ListItemNode,\n          CodeNode,\n          CodeHighlightNode,\n          LinkNode,\n          ImageNode,\n          AttachmentNode,\n          PrCommentNode,\n          ComponentInfoNode,\n          TableNode,\n          TableRowNode,\n          TableCellNode,\n        ],\n      }),\n      [AttachmentNode, ImageNode]\n    );\n\n    // Edit mode: custom elements + text format transformers (so asterisks\n    // aren't escaped during $convertToMarkdownString and preview can parse them).\n    // CODE is excluded so triple-backtick fences stay as raw text.\n    const editTransformers: Transformer[] = useMemo(\n      () => [\n        IMAGE_TRANSFORMER,\n        ATTACHMENT_TRANSFORMER,\n        PR_COMMENT_EXPORT_TRANSFORMER,\n        PR_COMMENT_TRANSFORMER,\n        COMPONENT_INFO_EXPORT_TRANSFORMER,\n        COMPONENT_INFO_TRANSFORMER,\n        ...TEXT_FORMAT_TRANSFORMERS,\n      ],\n      [ATTACHMENT_TRANSFORMER, IMAGE_TRANSFORMER]\n    );\n\n    // Display mode: full markdown rendering\n    const displayTransformers: Transformer[] = useMemo(\n      () => [\n        TABLE_TRANSFORMER,\n        IMAGE_TRANSFORMER,\n        ATTACHMENT_TRANSFORMER,\n        PR_COMMENT_EXPORT_TRANSFORMER,\n        PR_COMMENT_TRANSFORMER,\n        COMPONENT_INFO_EXPORT_TRANSFORMER,\n        COMPONENT_INFO_TRANSFORMER,\n        CODE,\n        ...TRANSFORMERS,\n      ],\n      [ATTACHMENT_TRANSFORMER, IMAGE_TRANSFORMER]\n    );\n\n    // Use display transformers for read-only, edit transformers for editing\n    const activeTransformers = disabled\n      ? displayTransformers\n      : editTransformers;\n\n    // Preview toggle state (only used in edit mode with static toolbar)\n    const [isPreviewMode, setIsPreviewMode] = useState(false);\n\n    // Memoized handlers for ContentEditable to prevent re-renders\n    const handlePaste = useCallback(\n      (event: React.ClipboardEvent) => {\n        if (!onPasteFiles || disabled) return;\n\n        const dt = event.clipboardData;\n        if (!dt) return;\n\n        const filesFromItems = Array.from(dt.items || [])\n          .filter((item) => item.kind === 'file')\n          .map((item) => item.getAsFile())\n          .filter((file): file is File => file !== null);\n\n        const clipboardFiles =\n          filesFromItems.length > 0\n            ? filesFromItems\n            : Array.from(dt.files || []);\n\n        const files: File[] = dedupeClipboardFiles(clipboardFiles);\n\n        if (files.length > 0) {\n          event.preventDefault();\n          event.stopPropagation();\n          onPasteFiles(files);\n        }\n      },\n      [onPasteFiles, disabled]\n    );\n\n    // Memoized placeholder element\n    const placeholderElement = useMemo(\n      () => (\n        <div\n          className={cn(\n            'absolute top-0 left-0 text-base text-secondary-foreground text-low pointer-events-none truncate',\n            className\n          )}\n        >\n          {placeholder}\n        </div>\n      ),\n      [placeholder, className]\n    );\n\n    const editorContent = (\n      <div className=\"wysiwyg text-base relative\">\n        {/* Preview: render a read-only editor with full markdown rendering */}\n        {!disabled && isPreviewMode && (\n          <div className={cn(className)}>\n            <WYSIWYGEditor\n              value={value}\n              disabled\n              hideActions\n              className={className}\n              workspaceId={workspaceId}\n              sessionId={sessionId}\n              localAttachments={localAttachments}\n            />\n          </div>\n        )}\n\n        <EditorWorkspaceContext.Provider value={workspaceId}>\n          <SessionContext.Provider value={sessionId}>\n            <LocalAttachmentsContext.Provider value={localAttachments ?? []}>\n              <LexicalComposer initialConfig={initialConfig}>\n                <EditorRefPlugin editorRef={editorInstanceRef} />\n                <MarkdownSyncPlugin\n                  value={value}\n                  onChange={onChange}\n                  onEditorStateChange={onEditorStateChange}\n                  editable={!disabled}\n                  transformers={activeTransformers}\n                  preserveMarkdownSyntax={!disabled}\n                />\n                {!disabled && !isPreviewMode && !showStaticToolbar && (\n                  <ToolbarPlugin />\n                )}\n\n                <div\n                  className=\"relative\"\n                  style={\n                    !disabled && isPreviewMode\n                      ? {\n                          position: 'absolute',\n                          opacity: 0,\n                          pointerEvents: 'none',\n                        }\n                      : undefined\n                  }\n                >\n                  <RichTextPlugin\n                    contentEditable={\n                      <ContentEditable\n                        className={cn('outline-none', className)}\n                        aria-label={\n                          disabled ? 'Markdown content' : 'Markdown editor'\n                        }\n                        onPasteCapture={handlePaste}\n                      />\n                    }\n                    placeholder={placeholderElement}\n                    ErrorBoundary={LexicalErrorBoundary}\n                  />\n                </div>\n\n                {showStaticToolbar && (\n                  <StaticToolbarPlugin\n                    saveStatus={saveStatus}\n                    extraActions={staticToolbarActions}\n                    isPreviewMode={isPreviewMode}\n                    onTogglePreview={\n                      !disabled && !onRequestEdit\n                        ? () => setIsPreviewMode((p) => !p)\n                        : undefined\n                    }\n                    readOnly={disabled}\n                    onRequestEdit={onRequestEdit}\n                  />\n                )}\n\n                <ListPlugin />\n                <TablePlugin />\n                <CodeHighlightPlugin />\n                {/* Only include editing plugins when not in read-only mode */}\n                {!disabled && (\n                  <>\n                    {autoFocus && <AutoFocusPlugin />}\n                    <HistoryPlugin />\n                    <MarkdownInsertPlugin />\n                    <PasteMarkdownPlugin transformers={activeTransformers} />\n                    <TypeaheadOpenProvider>\n                      <MarkdownListContinuePlugin />\n                      <FileTagTypeaheadPlugin\n                        repoIds={repoIds}\n                        diffPaths={diffPaths}\n                        preferredRepoId={preferredRepoId}\n                        setPreferredRepoId={setFileSearchRepo}\n                        listRecentRepos={listRecentRepos}\n                        getRepoById={getRepoById}\n                        chooseRepo={chooseRepo}\n                        onCreateTag={handleCreateTag}\n                        searchTagsAndFiles={searchFileTags}\n                      />\n                      {executor && (\n                        <SlashCommandTypeaheadPlugin\n                          enabled={true}\n                          commands={slashCommandsQuery.commands}\n                          isInitialized={slashCommandsQuery.isInitialized}\n                          isDiscovering={slashCommandsQuery.discovering}\n                        />\n                      )}\n                      <KeyboardCommandsPlugin\n                        onCmdEnter={onCmdEnter}\n                        onShiftCmdEnter={onShiftCmdEnter}\n                        onChange={onChange}\n                        transformers={activeTransformers}\n                        sendShortcut={sendShortcut}\n                      />\n                    </TypeaheadOpenProvider>\n                    <ImageKeyboardPlugin isTargetNode={$isImageNode} />\n                    <ComponentInfoKeyboardPlugin\n                      isTargetNode={$isComponentInfoNode}\n                    />\n                  </>\n                )}\n                {/* Link sanitization for read-only mode */}\n                {disabled && <ReadOnlyLinkPlugin />}\n                {/* Clickable code for file paths in read-only mode */}\n                {disabled && findMatchingDiffPath && onCodeClick && (\n                  <ClickableCodePlugin\n                    findMatchingDiffPath={findMatchingDiffPath}\n                    onCodeClick={onCodeClick}\n                  />\n                )}\n              </LexicalComposer>\n            </LocalAttachmentsContext.Provider>\n          </SessionContext.Provider>\n        </EditorWorkspaceContext.Provider>\n      </div>\n    );\n\n    // Wrap with action buttons in read-only mode\n    if (disabled && !hideActions) {\n      return (\n        <div className=\"relative group\">\n          <div className=\"sticky top-0 right-2 z-10 pointer-events-none h-0\">\n            <div className=\"flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150\">\n              {/* Copy button */}\n              <Button\n                type=\"button\"\n                aria-label={copied ? 'Copied!' : 'Copy as Markdown'}\n                title={copied ? 'Copied!' : 'Copy as Markdown'}\n                variant=\"icon\"\n                size=\"icon\"\n                onClick={handleCopy}\n                className=\"pointer-events-auto p-2 bg-muted h-8 w-8\"\n              >\n                {copied ? (\n                  <Check className=\"w-4 h-4 text-success\" />\n                ) : (\n                  <Clipboard className=\"w-4 h-4 text-muted-foreground\" />\n                )}\n              </Button>\n              {/* Edit button - only if onEdit provided */}\n              {onEdit && (\n                <Button\n                  type=\"button\"\n                  aria-label=\"Edit\"\n                  title=\"Edit\"\n                  variant=\"icon\"\n                  size=\"icon\"\n                  onClick={onEdit}\n                  className=\"pointer-events-auto p-2 bg-muted h-8 w-8\"\n                >\n                  <Pencil className=\"w-4 h-4 text-muted-foreground\" />\n                </Button>\n              )}\n              {/* Delete button - only if onDelete provided */}\n              {onDelete && (\n                <Button\n                  type=\"button\"\n                  aria-label=\"Delete\"\n                  title=\"Delete\"\n                  variant=\"icon\"\n                  size=\"icon\"\n                  onClick={onDelete}\n                  className=\"pointer-events-auto p-2 bg-muted h-8 w-8\"\n                >\n                  <Trash2 className=\"w-4 h-4 text-muted-foreground\" />\n                </Button>\n              )}\n            </div>\n          </div>\n          {editorContent}\n        </div>\n      );\n    }\n\n    return editorContent;\n  }\n);\n\nexport default memo(WYSIWYGEditor);\n"
  },
  {
    "path": "packages/web-core/src/shared/components/XTermInstance.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { Terminal } from '@xterm/xterm';\nimport { FitAddon } from '@xterm/addon-fit';\nimport { WebLinksAddon } from '@xterm/addon-web-links';\nimport '@xterm/xterm/css/xterm.css';\n\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport { getTerminalTheme } from '@/shared/lib/terminalTheme';\nimport { useTerminal } from '@/shared/hooks/useTerminal';\n\ninterface XTermInstanceProps {\n  tabId: string;\n  workspaceId: string;\n  isActive: boolean;\n  onClose?: () => void;\n}\n\nexport function XTermInstance({\n  tabId,\n  workspaceId,\n  isActive,\n  onClose,\n}: XTermInstanceProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const resizeRef = useRef<HTMLDivElement>(null);\n  const terminalRef = useRef<Terminal | null>(null);\n  const fitAddonRef = useRef<FitAddon | null>(null);\n  const initialSizeRef = useRef({ cols: 80, rows: 24 });\n  const { theme } = useTheme();\n  const {\n    registerTerminalInstance,\n    getTerminalInstance,\n    createTerminalConnection,\n    getTerminalConnection,\n  } = useTerminal();\n\n  const endpoint = useMemo(() => {\n    const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';\n    const host = window.location.host;\n    return `${protocol}//${host}/api/terminal/ws?workspace_id=${workspaceId}&cols=${initialSizeRef.current.cols}&rows=${initialSizeRef.current.rows}`;\n  }, [workspaceId]);\n\n  const fitTerminal = useCallback(() => {\n    fitAddonRef.current?.fit();\n    if (terminalRef.current) {\n      const conn = getTerminalConnection(tabId);\n      conn?.resize(terminalRef.current.cols, terminalRef.current.rows);\n    }\n  }, [tabId, getTerminalConnection]);\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    const existing = getTerminalInstance(tabId);\n    if (existing) {\n      const { terminal, fitAddon } = existing;\n      if (terminal.element) {\n        containerRef.current.appendChild(terminal.element);\n        fitAddon.fit();\n      }\n      terminalRef.current = terminal;\n      fitAddonRef.current = fitAddon;\n      return;\n    }\n\n    if (terminalRef.current) return;\n\n    const terminal = new Terminal({\n      cursorBlink: true,\n      fontSize: 12,\n      fontFamily: '\"IBM Plex Mono\", monospace',\n      theme: getTerminalTheme(),\n    });\n\n    const fitAddon = new FitAddon();\n    const webLinksAddon = new WebLinksAddon();\n\n    terminal.loadAddon(fitAddon);\n    terminal.loadAddon(webLinksAddon);\n    terminal.open(containerRef.current);\n\n    fitAddon.fit();\n    initialSizeRef.current = { cols: terminal.cols, rows: terminal.rows };\n\n    terminalRef.current = terminal;\n    fitAddonRef.current = fitAddon;\n\n    if (!getTerminalConnection(tabId)) {\n      createTerminalConnection(\n        tabId,\n        endpoint,\n        (data) => terminal?.write(data),\n        onClose\n      );\n    }\n\n    registerTerminalInstance(tabId, terminal, fitAddon);\n\n    terminal.onData((data) => {\n      const conn = getTerminalConnection(tabId);\n      conn?.send(data);\n    });\n\n    return () => {\n      if (terminal.element && terminal.element.parentNode) {\n        terminal.element.parentNode.removeChild(terminal.element);\n      }\n      terminalRef.current = null;\n      fitAddonRef.current = null;\n    };\n  }, [\n    tabId,\n    endpoint,\n    onClose,\n    getTerminalInstance,\n    registerTerminalInstance,\n    createTerminalConnection,\n    getTerminalConnection,\n  ]);\n\n  useEffect(() => {\n    if (!resizeRef.current) return;\n    const observer = new ResizeObserver(fitTerminal);\n    observer.observe(resizeRef.current);\n    return () => observer.disconnect();\n  }, [fitTerminal]);\n\n  useEffect(() => {\n    if (isActive) terminalRef.current?.focus();\n  }, [isActive]);\n\n  useEffect(() => {\n    if (terminalRef.current) {\n      terminalRef.current.options.theme = getTerminalTheme();\n    }\n  }, [theme]);\n\n  return (\n    <div ref={resizeRef} className=\"w-full h-full px-2 py-1\">\n      <div ref={containerRef} className=\"w-full h-full\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/common/ProfileVariantBadge.tsx",
    "content": "import type { ExecutorConfig } from 'shared/types';\nimport { cn } from '@/shared/lib/utils';\n\ninterface ProfileVariantBadgeProps {\n  executorConfig: ExecutorConfig;\n  className?: string;\n}\n\nexport function ProfileVariantBadge({\n  executorConfig,\n  className,\n}: ProfileVariantBadgeProps) {\n  return (\n    <span className={cn('text-xs text-muted-foreground', className)}>\n      {executorConfig.executor}\n      {executorConfig.variant && (\n        <>\n          <span className=\"mx-1\">/</span>\n          <span className=\"font-medium\">{executorConfig.variant}</span>\n        </>\n      )}\n    </span>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/org/MemberListItem.tsx",
    "content": "import { Badge } from '@vibe/ui/components/Badge';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@vibe/ui/components/Select';\nimport { Trash2 } from 'lucide-react';\nimport type { OrganizationMemberWithProfile, MemberRole } from 'shared/types';\nimport { MemberRole as MemberRoleEnum } from 'shared/types';\nimport { useTranslation } from 'react-i18next';\nimport { UserAvatar } from '@/shared/components/tasks/UserAvatar';\n\ninterface MemberListItemProps {\n  member: OrganizationMemberWithProfile;\n  currentUserId: string | null;\n  isAdmin: boolean;\n  onRemove: (userId: string) => void;\n  onRoleChange: (userId: string, role: MemberRole) => void;\n  isRemoving: boolean;\n  isRoleChanging: boolean;\n}\n\nexport function MemberListItem({\n  member,\n  currentUserId,\n  isAdmin,\n  onRemove,\n  onRoleChange,\n  isRemoving,\n  isRoleChanging,\n}: MemberListItemProps) {\n  const { t } = useTranslation('organization');\n  const isSelf = member.user_id === currentUserId;\n  const canRemove = isAdmin && !isSelf;\n  const canChangeRole = isAdmin && !isSelf;\n\n  const displayName = member.username || member.user_id;\n  const fullName = [member.first_name, member.last_name]\n    .filter(Boolean)\n    .join(' ');\n\n  return (\n    <div className=\"flex items-center justify-between p-3 border rounded-lg\">\n      <div className=\"flex items-center gap-3\">\n        <UserAvatar\n          firstName={member.first_name}\n          lastName={member.last_name}\n          username={member.username}\n          imageUrl={member.avatar_url}\n          className=\"h-8 w-8\"\n        />\n        <div>\n          <div className=\"font-medium text-sm\">{fullName || displayName}</div>\n          {fullName && member.username && (\n            <div className=\"text-xs text-muted-foreground\">\n              @{member.username}\n            </div>\n          )}\n          {isSelf && (\n            <div className=\"text-xs text-muted-foreground\">\n              {t('memberList.you')}\n            </div>\n          )}\n        </div>\n        <Badge\n          variant={\n            member.role === MemberRoleEnum.ADMIN ? 'default' : 'secondary'\n          }\n        >\n          {t('roles.' + member.role.toLowerCase())}\n        </Badge>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        {canChangeRole && (\n          <Select\n            value={member.role}\n            onValueChange={(value) =>\n              onRoleChange(member.user_id, value as MemberRole)\n            }\n            disabled={isRoleChanging}\n          >\n            <SelectTrigger className=\"w-32\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value={MemberRoleEnum.ADMIN}>\n                {t('roles.admin')}\n              </SelectItem>\n              <SelectItem value={MemberRoleEnum.MEMBER}>\n                {t('roles.member')}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n        )}\n        {canRemove && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => onRemove(member.user_id)}\n            disabled={isRemoving}\n          >\n            <Trash2 className=\"h-4 w-4 text-destructive\" />\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/org/PendingInvitationItem.tsx",
    "content": "import { Badge } from '@vibe/ui/components/Badge';\nimport { Button } from '@vibe/ui/components/Button';\nimport type { Invitation } from 'shared/types';\nimport { MemberRole } from 'shared/types';\nimport { useTranslation } from 'react-i18next';\nimport { Trash2 } from 'lucide-react';\n\ninterface PendingInvitationItemProps {\n  invitation: Invitation;\n  onRevoke?: (invitationId: string) => void;\n  isRevoking?: boolean;\n}\n\nexport function PendingInvitationItem({\n  invitation,\n  onRevoke,\n  isRevoking,\n}: PendingInvitationItemProps) {\n  const { t } = useTranslation('organization');\n\n  const handleRevoke = () => {\n    const confirmed = window.confirm(\n      `Are you sure you want to revoke the invitation for ${invitation.email}? This action cannot be undone.`\n    );\n    if (confirmed) {\n      onRevoke?.(invitation.id);\n    }\n  };\n\n  return (\n    <div className=\"flex items-center justify-between p-3 border rounded-lg\">\n      <div className=\"flex items-center gap-3\">\n        <div>\n          <div className=\"font-medium text-sm\">{invitation.email}</div>\n          <div className=\"text-xs text-muted-foreground\">\n            {t('invitationList.invited', {\n              date: new Date(invitation.created_at).toLocaleDateString(),\n            })}\n          </div>\n        </div>\n        <Badge\n          variant={\n            invitation.role === MemberRole.ADMIN ? 'default' : 'secondary'\n          }\n        >\n          {t('roles.' + invitation.role.toLowerCase())}\n        </Badge>\n        <Badge variant=\"outline\">{t('invitationList.pending')}</Badge>\n      </div>\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        onClick={handleRevoke}\n        disabled={isRevoking}\n        title=\"Revoke invitation\"\n      >\n        <Trash2 className=\"h-4 w-4\" />\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/settings/ExecutorProfileSelector.tsx",
    "content": "import { AgentSelector } from '@/shared/components/tasks/AgentSelector';\nimport { ConfigSelector } from '@/shared/components/tasks/ConfigSelector';\nimport { cn } from '@/shared/lib/utils';\nimport type { ExecutorProfile, ExecutorProfileId } from 'shared/types';\n\ntype Props = {\n  profiles: Record<string, ExecutorProfile> | null;\n  selectedProfile: ExecutorProfileId | null;\n  onProfileSelect: (profile: ExecutorProfileId) => void;\n  disabled?: boolean;\n  showLabel?: boolean;\n  className?: string;\n  itemClassName?: string;\n};\n\nfunction ExecutorProfileSelector({\n  profiles,\n  selectedProfile,\n  onProfileSelect,\n  disabled = false,\n  showLabel = true,\n  className,\n  itemClassName,\n}: Props) {\n  if (!profiles) {\n    return null;\n  }\n\n  return (\n    <div className={cn('flex gap-3 flex-col sm:flex-row', className)}>\n      <AgentSelector\n        profiles={profiles}\n        selectedExecutorProfile={selectedProfile}\n        onChange={onProfileSelect}\n        disabled={disabled}\n        showLabel={showLabel}\n        className={itemClassName}\n      />\n      <ConfigSelector\n        profiles={profiles}\n        selectedExecutorProfile={selectedProfile}\n        onChange={onProfileSelect}\n        disabled={disabled}\n        showLabel={showLabel}\n        className={itemClassName}\n      />\n    </div>\n  );\n}\n\nexport default ExecutorProfileSelector;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/AgentSelector.tsx",
    "content": "import { Bot, ArrowDown } from 'lucide-react';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/DropdownMenu';\nimport { Label } from '@vibe/ui/components/Label';\nimport type { ExecutorProfileId, BaseCodingAgent } from 'shared/types';\n\ninterface AgentSelectorProps {\n  profiles: Record<string, Record<string, unknown>> | null;\n  selectedExecutorProfile: ExecutorProfileId | null;\n  onChange: (profile: ExecutorProfileId) => void;\n  disabled?: boolean;\n  className?: string;\n  showLabel?: boolean;\n}\n\nexport function AgentSelector({\n  profiles,\n  selectedExecutorProfile,\n  onChange,\n  disabled,\n  className = '',\n  showLabel = false,\n}: AgentSelectorProps) {\n  const agents = profiles\n    ? (Object.keys(profiles).sort() as BaseCodingAgent[])\n    : [];\n  const selectedAgent = selectedExecutorProfile?.executor;\n\n  if (!profiles) return null;\n\n  return (\n    <div className=\"flex-1\">\n      {showLabel && (\n        <Label htmlFor=\"executor-profile\" className=\"text-sm font-medium\">\n          Agent\n        </Label>\n      )}\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className={`w-full justify-between text-xs ${showLabel ? 'mt-1.5' : ''} ${className}`}\n            disabled={disabled}\n            aria-label=\"Select agent\"\n          >\n            <div className=\"flex items-center gap-1.5 w-full\">\n              <Bot className=\"h-3 w-3\" />\n              <span className=\"truncate\">{selectedAgent || 'Agent'}</span>\n            </div>\n            <ArrowDown className=\"h-3 w-3\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent className=\"w-60\">\n          {agents.length === 0 ? (\n            <div className=\"p-2 text-sm text-muted-foreground text-center\">\n              No agents available\n            </div>\n          ) : (\n            agents.map((agent) => (\n              <DropdownMenuItem\n                key={agent}\n                onClick={() => {\n                  onChange({\n                    executor: agent,\n                    variant: null,\n                  });\n                }}\n                className={selectedAgent === agent ? 'bg-accent' : ''}\n              >\n                {agent}\n              </DropdownMenuItem>\n            ))\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/BranchSelector.tsx",
    "content": "import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react';\nimport { Virtuoso, VirtuosoHandle } from 'react-virtuoso';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '@vibe/ui/components/Button';\nimport { ArrowDown, GitBranch as GitBranchIcon, Search } from 'lucide-react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/DropdownMenu';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@vibe/ui/components/RadixTooltip';\nimport { Input } from '@vibe/ui/components/Input';\nimport type { GitBranch } from 'shared/types';\n\ntype Props = {\n  branches: GitBranch[];\n  selectedBranch: string | null;\n  onBranchSelect: (branch: string) => void;\n  placeholder?: string;\n  className?: string;\n  excludeCurrentBranch?: boolean;\n  disabledTooltip?: string;\n};\n\ntype RowProps = {\n  branch: GitBranch;\n  isSelected: boolean;\n  isHighlighted: boolean;\n  isDisabled: boolean;\n  onHover: () => void;\n  onSelect: () => void;\n  disabledTooltip?: string;\n};\n\nconst BranchRow = memo(function BranchRow({\n  branch,\n  isSelected,\n  isHighlighted,\n  isDisabled,\n  onHover,\n  onSelect,\n  disabledTooltip,\n}: RowProps) {\n  const { t } = useTranslation(['common']);\n  const classes =\n    (isSelected ? 'bg-accent text-accent-foreground ' : '') +\n    (isDisabled ? 'opacity-50 cursor-not-allowed ' : '') +\n    (!isSelected && isHighlighted ? 'bg-accent/70 ring-2 ring-accent ' : '') +\n    'transition-none';\n\n  const nameClass = branch.is_current ? 'font-medium' : '';\n\n  const item = (\n    <DropdownMenuItem\n      onMouseEnter={onHover}\n      onSelect={onSelect}\n      disabled={isDisabled}\n      className={classes.trim()}\n    >\n      <div className=\"flex items-center justify-between w-full gap-2\">\n        <span className={`${nameClass} truncate flex-1 min-w-0`}>\n          {branch.name}\n        </span>\n        <div className=\"flex gap-1 flex-shrink-0\">\n          {branch.is_current && (\n            <span className=\"text-xs bg-background px-1 rounded\">\n              {t('branchSelector.badges.current')}\n            </span>\n          )}\n          {branch.is_remote && (\n            <span className=\"text-xs bg-background px-1 rounded\">\n              {t('branchSelector.badges.remote')}\n            </span>\n          )}\n        </div>\n      </div>\n    </DropdownMenuItem>\n  );\n\n  if (isDisabled && disabledTooltip) {\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span className=\"block\">{item}</span>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>{disabledTooltip}</p>\n        </TooltipContent>\n      </Tooltip>\n    );\n  }\n\n  return item;\n});\n\nfunction BranchSelector({\n  branches,\n  selectedBranch,\n  onBranchSelect,\n  placeholder,\n  className = '',\n  excludeCurrentBranch = false,\n  disabledTooltip,\n}: Props) {\n  const { t } = useTranslation(['common']);\n  const [branchSearchTerm, setBranchSearchTerm] = useState('');\n  const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);\n  const [open, setOpen] = useState(false);\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n\n  const effectivePlaceholder = placeholder ?? t('branchSelector.placeholder');\n  const defaultDisabledTooltip = t('branchSelector.currentDisabled');\n\n  const filteredBranches = useMemo(() => {\n    let filtered = branches;\n\n    if (branchSearchTerm.trim()) {\n      const q = branchSearchTerm.toLowerCase();\n      filtered = filtered.filter((b) => b.name.toLowerCase().includes(q));\n    }\n    return filtered;\n  }, [branches, branchSearchTerm]);\n\n  const handleBranchSelect = useCallback(\n    (branchName: string) => {\n      onBranchSelect(branchName);\n      setBranchSearchTerm('');\n      setHighlightedIndex(null);\n      setOpen(false);\n    },\n    [onBranchSelect]\n  );\n\n  const isBranchDisabled = useCallback(\n    (branch: GitBranch) => excludeCurrentBranch && branch.is_current,\n    [excludeCurrentBranch]\n  );\n\n  useEffect(() => {\n    if (\n      highlightedIndex !== null &&\n      highlightedIndex >= filteredBranches.length\n    ) {\n      setHighlightedIndex(null);\n    }\n  }, [filteredBranches, highlightedIndex]);\n\n  useEffect(() => {\n    setHighlightedIndex(null);\n  }, [branchSearchTerm]);\n\n  const moveHighlight = useCallback(\n    (delta: 1 | -1) => {\n      if (filteredBranches.length === 0) return;\n\n      const start = highlightedIndex ?? -1;\n      let next = start;\n\n      for (let attempts = 0; attempts < filteredBranches.length; attempts++) {\n        next =\n          (next + delta + filteredBranches.length) % filteredBranches.length;\n        if (!isBranchDisabled(filteredBranches[next])) {\n          setHighlightedIndex(next);\n          virtuosoRef.current?.scrollIntoView({\n            index: next,\n            behavior: 'auto',\n          });\n          return;\n        }\n      }\n      setHighlightedIndex(null);\n    },\n    [filteredBranches, highlightedIndex, isBranchDisabled]\n  );\n\n  const attemptSelect = useCallback(() => {\n    if (highlightedIndex == null) return;\n    const branch = filteredBranches[highlightedIndex];\n    if (!branch) return;\n    if (isBranchDisabled(branch)) return;\n    handleBranchSelect(branch.name);\n  }, [\n    highlightedIndex,\n    filteredBranches,\n    isBranchDisabled,\n    handleBranchSelect,\n  ]);\n\n  return (\n    <DropdownMenu\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) {\n          setBranchSearchTerm('');\n          setHighlightedIndex(null);\n        }\n      }}\n    >\n      <DropdownMenuTrigger asChild>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className={`w-full justify-between text-xs ${className}`}\n        >\n          <div className=\"flex items-center gap-1.5 w-full min-w-0\">\n            <GitBranchIcon className=\"h-3 w-3 flex-shrink-0\" />\n            <span className=\"truncate\">\n              {selectedBranch || effectivePlaceholder}\n            </span>\n          </div>\n          <ArrowDown className=\"h-3 w-3 flex-shrink-0\" />\n        </Button>\n      </DropdownMenuTrigger>\n\n      <TooltipProvider>\n        <DropdownMenuContent className=\"w-80\">\n          <div className=\"p-2\">\n            <div className=\"relative\">\n              <Search className=\"absolute left-2 top-2.5 h-4 w-4 text-muted-foreground\" />\n              <Input\n                ref={searchInputRef}\n                placeholder={t('branchSelector.searchPlaceholder')}\n                value={branchSearchTerm}\n                onChange={(e) => setBranchSearchTerm(e.target.value)}\n                onKeyDown={(e) => {\n                  switch (e.key) {\n                    case 'ArrowDown':\n                      e.preventDefault();\n                      e.stopPropagation();\n                      moveHighlight(1);\n                      return;\n                    case 'ArrowUp':\n                      e.preventDefault();\n                      e.stopPropagation();\n                      moveHighlight(-1);\n                      return;\n                    case 'Enter':\n                      e.preventDefault();\n                      e.stopPropagation();\n                      attemptSelect();\n                      return;\n                    case 'Escape':\n                      e.preventDefault();\n                      e.stopPropagation();\n                      setOpen(false);\n                      return;\n                    case 'Tab':\n                      return;\n                    default:\n                      e.stopPropagation();\n                  }\n                }}\n                className=\"pl-8\"\n              />\n            </div>\n          </div>\n          <DropdownMenuSeparator />\n          {filteredBranches.length === 0 ? (\n            <div className=\"p-2 text-sm text-muted-foreground text-center\">\n              {t('branchSelector.empty')}\n            </div>\n          ) : (\n            <Virtuoso\n              ref={virtuosoRef}\n              style={{ height: '16rem' }}\n              totalCount={filteredBranches.length}\n              computeItemKey={(idx) => filteredBranches[idx]?.name ?? idx}\n              itemContent={(idx) => {\n                const branch = filteredBranches[idx];\n                const isDisabled = isBranchDisabled(branch);\n                const isHighlighted = idx === highlightedIndex;\n                const isSelected = selectedBranch === branch.name;\n\n                return (\n                  <BranchRow\n                    branch={branch}\n                    isSelected={isSelected}\n                    isDisabled={isDisabled}\n                    isHighlighted={isHighlighted}\n                    onHover={() => setHighlightedIndex(idx)}\n                    onSelect={() => handleBranchSelect(branch.name)}\n                    disabledTooltip={\n                      isDisabled\n                        ? (disabledTooltip ?? defaultDisabledTooltip)\n                        : undefined\n                    }\n                  />\n                );\n              }}\n            />\n          )}\n        </DropdownMenuContent>\n      </TooltipProvider>\n    </DropdownMenu>\n  );\n}\n\nexport default BranchSelector;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/ConfigSelector.tsx",
    "content": "import { Settings2, ArrowDown } from 'lucide-react';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/DropdownMenu';\nimport { Label } from '@vibe/ui/components/Label';\nimport { getSortedExecutorVariantKeys } from '@/shared/lib/executor';\nimport type { ExecutorProfileId } from 'shared/types';\n\ninterface ConfigSelectorProps {\n  profiles: Record<string, Record<string, unknown>> | null;\n  selectedExecutorProfile: ExecutorProfileId | null;\n  onChange: (profile: ExecutorProfileId) => void;\n  disabled?: boolean;\n  className?: string;\n  showLabel?: boolean;\n}\n\nexport function ConfigSelector({\n  profiles,\n  selectedExecutorProfile,\n  onChange,\n  disabled,\n  className = '',\n  showLabel = false,\n}: ConfigSelectorProps) {\n  const selectedAgent = selectedExecutorProfile?.executor;\n  const configs = selectedAgent && profiles ? profiles[selectedAgent] : null;\n  const configOptions = configs ? getSortedExecutorVariantKeys(configs) : [];\n  const selectedVariant = selectedExecutorProfile?.variant || 'DEFAULT';\n\n  if (!selectedAgent || !profiles || !configs || configOptions.length === 0)\n    return null;\n\n  return (\n    <div className=\"flex-1\">\n      {showLabel && (\n        <Label htmlFor=\"executor-variant\" className=\"text-sm font-medium\">\n          Configuration\n        </Label>\n      )}\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className={`w-full justify-between text-xs ${showLabel ? 'mt-1.5' : ''} ${className}`}\n            disabled={disabled}\n            aria-label=\"Select configuration\"\n          >\n            <div className=\"flex items-center gap-1.5 w-full\">\n              <Settings2 className=\"h-3 w-3\" />\n              <span className=\"truncate\">{selectedVariant}</span>\n            </div>\n            <ArrowDown className=\"h-3 w-3\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent className=\"w-60\">\n          {configOptions.map((variant) => (\n            <DropdownMenuItem\n              key={variant}\n              onClick={() => {\n                onChange({\n                  executor: selectedAgent,\n                  variant: variant === 'DEFAULT' ? null : variant,\n                });\n              }}\n              className={\n                (variant === 'DEFAULT' ? null : variant) ===\n                selectedExecutorProfile?.variant\n                  ? 'bg-accent'\n                  : ''\n              }\n            >\n              {variant}\n            </DropdownMenuItem>\n          ))}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/RepoBranchSelector.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { Label } from '@vibe/ui/components/Label';\nimport BranchSelector from './BranchSelector';\nimport type { RepoBranchConfig } from '@/shared/hooks/useRepoBranchSelection';\n\ntype Props = {\n  configs: RepoBranchConfig[];\n  onBranchChange: (repoId: string, branch: string) => void;\n  isLoading?: boolean;\n  showLabel?: boolean;\n  className?: string;\n};\n\nexport function RepoBranchSelector({\n  configs,\n  onBranchChange,\n  isLoading,\n  showLabel = true,\n  className,\n}: Props) {\n  const { t } = useTranslation('tasks');\n\n  if (configs.length === 0) {\n    return null;\n  }\n\n  if (configs.length === 1) {\n    const config = configs[0];\n    return (\n      <div className={className}>\n        {showLabel && (\n          <Label className=\"text-sm font-medium\">\n            {t('repoBranchSelector.label')}{' '}\n            <span className=\"text-destructive\">*</span>\n          </Label>\n        )}\n        <BranchSelector\n          branches={config.branches}\n          selectedBranch={config.targetBranch}\n          onBranchSelect={(branch) => onBranchChange(config.repoId, branch)}\n          placeholder={\n            isLoading\n              ? t('createAttemptDialog.loadingBranches')\n              : t('createAttemptDialog.selectBranch')\n          }\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className={className}>\n      <div className=\"space-y-3\">\n        {configs.map((config) => (\n          <div key={config.repoId} className=\"space-y-1\">\n            <Label className=\"text-sm font-medium\">\n              {config.repoDisplayName}{' '}\n              <span className=\"text-destructive\">*</span>\n            </Label>\n            <BranchSelector\n              branches={config.branches}\n              selectedBranch={config.targetBranch}\n              onBranchSelect={(branch) => onBranchChange(config.repoId, branch)}\n              placeholder={\n                isLoading\n                  ? t('createAttemptDialog.loadingBranches')\n                  : t('createAttemptDialog.selectBranch')\n              }\n            />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default RepoBranchSelector;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/RepoSelector.tsx",
    "content": "import { useState, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '@vibe/ui/components/Button';\nimport { ChevronsUpDown, FolderGit } from 'lucide-react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/DropdownMenu';\nimport type { Repo } from 'shared/types';\n\ntype Props = {\n  repos: Repo[];\n  selectedRepoId: string | null;\n  onRepoSelect: (repoId: string) => void;\n  placeholder?: string;\n  className?: string;\n  disabled?: boolean;\n};\n\nfunction RepoSelector({\n  repos,\n  selectedRepoId,\n  onRepoSelect,\n  placeholder,\n  className = '',\n  disabled = false,\n}: Props) {\n  const { t } = useTranslation(['tasks']);\n  const [open, setOpen] = useState(false);\n\n  const effectivePlaceholder =\n    placeholder ?? t('repos.selector.placeholder', 'Select repository');\n\n  const selectedRepo = repos.find((r) => r.id === selectedRepoId);\n\n  const handleRepoSelect = useCallback(\n    (repoId: string) => {\n      onRepoSelect(repoId);\n      setOpen(false);\n    },\n    [onRepoSelect]\n  );\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen}>\n      <DropdownMenuTrigger asChild>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className={`w-full justify-between text-xs ${className}`}\n          disabled={disabled}\n        >\n          <div className=\"flex items-center gap-1.5 w-full min-w-0\">\n            <FolderGit className=\"h-3 w-3 flex-shrink-0\" />\n            <span className=\"truncate\">\n              {selectedRepo?.display_name || effectivePlaceholder}\n            </span>\n          </div>\n          {repos.length > 1 && (\n            <ChevronsUpDown className=\"h-3 w-3 flex-shrink-0 text-muted-foreground\" />\n          )}\n        </Button>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent className=\"w-64\">\n        {repos.length === 0 ? (\n          <div className=\"p-2 text-sm text-muted-foreground text-center\">\n            {t('repos.selector.empty', 'No repositories available')}\n          </div>\n        ) : (\n          repos.map((repo) => {\n            const isSelected = selectedRepoId === repo.id;\n            return (\n              <DropdownMenuItem\n                key={repo.id}\n                onSelect={() => handleRepoSelect(repo.id)}\n                className={isSelected ? 'bg-accent text-accent-foreground' : ''}\n              >\n                <div className=\"flex items-center gap-2 w-full\">\n                  <FolderGit className=\"h-3.5 w-3.5 flex-shrink-0\" />\n                  <span className=\"truncate\">{repo.display_name}</span>\n                </div>\n              </DropdownMenuItem>\n            );\n          })\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nexport default RepoSelector;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/TaskDetails/ProcessLogsViewer.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport { Virtuoso, VirtuosoHandle } from 'react-virtuoso';\nimport { AlertCircle } from 'lucide-react';\nimport { useLogStream } from '@/shared/hooks/useLogStream';\nimport RawLogText from '@/shared/components/RawLogText';\nimport type { PatchType } from 'shared/types';\n\ntype LogEntry = Extract<PatchType, { type: 'STDOUT' } | { type: 'STDERR' }>;\n\ninterface ProcessLogsViewerProps {\n  processId: string;\n}\n\nexport function ProcessLogsViewerContent({\n  logs,\n  error,\n}: {\n  logs: LogEntry[];\n  error: string | null;\n}) {\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n  const didInitScroll = useRef(false);\n  const prevLenRef = useRef(0);\n  const [atBottom, setAtBottom] = useState(true);\n\n  // 1) Initial jump to bottom once data appears.\n  useEffect(() => {\n    if (!didInitScroll.current && logs.length > 0) {\n      didInitScroll.current = true;\n      requestAnimationFrame(() => {\n        virtuosoRef.current?.scrollToIndex({\n          index: logs.length - 1,\n          align: 'end',\n        });\n      });\n    }\n  }, [logs.length]);\n\n  // 2) If there's a large append and we're at bottom, force-stick to the last item.\n  useEffect(() => {\n    const prev = prevLenRef.current;\n    const grewBy = logs.length - prev;\n    prevLenRef.current = logs.length;\n\n    // tweak threshold as you like; this handles \"big bursts\"\n    const LARGE_BURST = 10;\n    if (grewBy >= LARGE_BURST && atBottom && logs.length > 0) {\n      // defer so Virtuoso can re-measure before jumping\n      requestAnimationFrame(() => {\n        virtuosoRef.current?.scrollToIndex({\n          index: logs.length - 1,\n          align: 'end',\n        });\n      });\n    }\n  }, [logs.length, atBottom, logs]);\n\n  const formatLogLine = (entry: LogEntry, index: number) => {\n    return (\n      <RawLogText\n        key={index}\n        content={entry.content}\n        channel={entry.type === 'STDERR' ? 'stderr' : 'stdout'}\n        className=\"text-sm px-4 py-1\"\n      />\n    );\n  };\n\n  return (\n    <div className=\"flex-1 min-h-0\">\n      {logs.length === 0 && !error ? (\n        <div className=\"p-4 text-center text-muted-foreground text-sm\">\n          No logs available\n        </div>\n      ) : error ? (\n        <div className=\"p-4 text-center text-destructive text-sm\">\n          <AlertCircle className=\"h-4 w-4 inline mr-2\" />\n          {error}\n        </div>\n      ) : (\n        <Virtuoso<LogEntry>\n          ref={virtuosoRef}\n          className=\"h-full rounded-lg\"\n          data={logs}\n          itemContent={(index, entry) =>\n            formatLogLine(entry as LogEntry, index)\n          }\n          // Keep pinned while user is at bottom; release when they scroll up\n          atBottomStateChange={setAtBottom}\n          followOutput={atBottom ? 'smooth' : false}\n          // Optional: a bit more overscan helps during bursts\n          increaseViewportBy={{ top: 0, bottom: 600 }}\n        />\n      )}\n    </div>\n  );\n}\n\nexport default function ProcessLogsViewer({\n  processId,\n}: ProcessLogsViewerProps) {\n  const { logs, error } = useLogStream(processId);\n  return <ProcessLogsViewerContent logs={logs} error={error} />;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/TaskDetails/ProcessesTab.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Play,\n  Square,\n  AlertCircle,\n  CheckCircle,\n  Clock,\n  Cog,\n  ArrowLeft,\n} from 'lucide-react';\nimport { executionProcessesApi } from '@/shared/lib/api';\nimport { ProfileVariantBadge } from '@/shared/components/common/ProfileVariantBadge.tsx';\nimport { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses';\nimport { useLogStream } from '@/shared/hooks/useLogStream';\nimport { ProcessLogsViewerContent } from './ProcessLogsViewer';\nimport type { ExecutionProcessStatus, ExecutionProcess } from 'shared/types';\n\nimport { useProcessSelection } from '@/shared/hooks/ProcessSelectionContext';\nimport { useRetryUi } from '@/shared/hooks/useRetryUi';\n\ninterface ProcessesTabProps {\n  sessionId?: string;\n}\n\nfunction ProcessesTab({ sessionId }: ProcessesTabProps) {\n  const { t } = useTranslation('tasks');\n  const {\n    executionProcesses,\n    executionProcessesById,\n    isLoading: processesLoading,\n    isConnected,\n    error: processesError,\n  } = useExecutionProcesses(sessionId ?? '', { showSoftDeleted: true });\n  const { selectedProcessId, setSelectedProcessId } = useProcessSelection();\n  const [loadingProcessId, setLoadingProcessId] = useState<string | null>(null);\n  const [localProcessDetails, setLocalProcessDetails] = useState<\n    Record<string, ExecutionProcess>\n  >({});\n  const [copied, setCopied] = useState(false);\n\n  const selectedProcess = selectedProcessId\n    ? localProcessDetails[selectedProcessId] ||\n      executionProcessesById[selectedProcessId]\n    : null;\n\n  const { logs, error: logsError } = useLogStream(selectedProcess?.id ?? '');\n\n  useEffect(() => {\n    setLocalProcessDetails({});\n    setLoadingProcessId(null);\n  }, [sessionId]);\n\n  const handleCopyLogs = useCallback(async () => {\n    if (logs.length === 0) return;\n\n    const text = logs.map((entry) => entry.content).join('\\n');\n    try {\n      await navigator.clipboard.writeText(text);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } catch (err) {\n      console.warn('Copy to clipboard failed:', err);\n    }\n  }, [logs]);\n\n  const getStatusIcon = (status: ExecutionProcessStatus) => {\n    switch (status) {\n      case 'running':\n        return <Play className=\"h-4 w-4 text-blue-500\" />;\n      case 'completed':\n        return <CheckCircle className=\"h-4 w-4 text-green-500\" />;\n      case 'failed':\n        return <AlertCircle className=\"h-4 w-4 text-destructive\" />;\n      case 'killed':\n        return <Square className=\"h-4 w-4 text-gray-500\" />;\n      default:\n        return <Clock className=\"h-4 w-4 text-gray-400\" />;\n    }\n  };\n\n  const getStatusColor = (status: ExecutionProcessStatus) => {\n    switch (status) {\n      case 'running':\n        return 'bg-blue-50 border-blue-200 text-blue-800';\n      case 'completed':\n        return 'bg-green-50 border-green-200 text-green-800';\n      case 'failed':\n        return 'bg-red-50 border-red-200 text-red-800';\n      case 'killed':\n        return 'bg-gray-50 border-gray-200 text-gray-800';\n      default:\n        return 'bg-gray-50 border-gray-200 text-gray-800';\n    }\n  };\n\n  const formatDate = (dateString: string) => {\n    const date = new Date(dateString);\n    return date.toLocaleString();\n  };\n\n  const fetchProcessDetails = useCallback(async (processId: string) => {\n    try {\n      setLoadingProcessId(processId);\n      const result = await executionProcessesApi.getDetails(processId);\n\n      if (result !== undefined) {\n        setLocalProcessDetails((prev) => ({\n          ...prev,\n          [processId]: result,\n        }));\n      }\n    } catch (err) {\n      console.error('Failed to fetch process details:', err);\n    } finally {\n      setLoadingProcessId((current) =>\n        current === processId ? null : current\n      );\n    }\n  }, []);\n\n  // Automatically fetch process details when selectedProcessId changes\n  useEffect(() => {\n    if (!sessionId || !selectedProcessId) {\n      return;\n    }\n\n    if (\n      !localProcessDetails[selectedProcessId] &&\n      loadingProcessId !== selectedProcessId\n    ) {\n      fetchProcessDetails(selectedProcessId);\n    }\n  }, [\n    sessionId,\n    selectedProcessId,\n    localProcessDetails,\n    loadingProcessId,\n    fetchProcessDetails,\n  ]);\n\n  const handleProcessClick = async (process: ExecutionProcess) => {\n    setSelectedProcessId(process.id);\n\n    // If we don't have details for this process, fetch them\n    if (!localProcessDetails[process.id]) {\n      await fetchProcessDetails(process.id);\n    }\n  };\n\n  const { isProcessGreyed } = useRetryUi();\n\n  if (!sessionId) {\n    return (\n      <div className=\"flex-1 flex items-center justify-center text-muted-foreground\">\n        <div className=\"text-center\">\n          <Cog className=\"h-12 w-12 mx-auto mb-4 opacity-50\" />\n          <p>{t('processes.selectAttempt')}</p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex-1 flex flex-col min-h-0\">\n      {!selectedProcessId ? (\n        <div className=\"flex-1 overflow-auto px-4 pb-20 pt-4\">\n          {processesError && (\n            <div className=\"mb-3 text-sm text-destructive\">\n              {t('processes.errorLoadingUpdates')}\n              {!isConnected && ` ${t('processes.reconnecting')}`}\n            </div>\n          )}\n          {processesLoading && executionProcesses.length === 0 ? (\n            <div className=\"flex items-center justify-center text-muted-foreground py-10\">\n              <p>{t('processes.loading')}</p>\n            </div>\n          ) : executionProcesses.length === 0 ? (\n            <div className=\"flex items-center justify-center text-muted-foreground py-10\">\n              <div className=\"text-center\">\n                <Cog className=\"h-12 w-12 mx-auto mb-4 opacity-50\" />\n                <p>{t('processes.noProcesses')}</p>\n              </div>\n            </div>\n          ) : (\n            <div className=\"space-y-3\">\n              {executionProcesses.map((process) => (\n                <div\n                  key={process.id}\n                  className={`border rounded-lg p-4 hover:bg-muted/30 cursor-pointer transition-colors ${\n                    loadingProcessId === process.id\n                      ? 'opacity-50 cursor-wait'\n                      : isProcessGreyed(process.id)\n                        ? 'opacity-50'\n                        : ''\n                  }`}\n                  onClick={() => handleProcessClick(process)}\n                >\n                  <div className=\"flex items-start justify-between\">\n                    <div className=\"flex items-center space-x-3 min-w-0\">\n                      {getStatusIcon(process.status)}\n                      <div className=\"min-w-0\">\n                        <h3 className=\"font-medium text-sm\">\n                          {process.run_reason}\n                        </h3>\n                        <p\n                          className=\"text-sm text-muted-foreground mt-1 truncate\"\n                          title={process.id}\n                        >\n                          {t('processes.processId', { id: process.id })}\n                        </p>\n                        {process.dropped && (\n                          <span\n                            className=\"inline-block mt-1 text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 border border-amber-200\"\n                            title={t('processes.deletedTooltip')}\n                          >\n                            {t('processes.deleted')}\n                          </span>\n                        )}\n                        {\n                          <p className=\"text-sm text-muted-foreground mt-1\">\n                            {t('processes.agent')}{' '}\n                            {process.executor_action.typ.type ===\n                              'CodingAgentInitialRequest' ||\n                            process.executor_action.typ.type ===\n                              'CodingAgentFollowUpRequest' ||\n                            process.executor_action.typ.type ===\n                              'ReviewRequest' ? (\n                              <ProfileVariantBadge\n                                executorConfig={\n                                  process.executor_action.typ.executor_config\n                                }\n                              />\n                            ) : null}\n                          </p>\n                        }\n                      </div>\n                    </div>\n                    <div className=\"text-right\">\n                      <span\n                        className={`inline-block px-2 py-1 text-xs font-medium border rounded-full ${getStatusColor(\n                          process.status\n                        )}`}\n                      >\n                        {process.status}\n                      </span>\n                      {process.exit_code !== null && (\n                        <p className=\"text-xs text-muted-foreground mt-1\">\n                          {t('processes.exit', {\n                            code: process.exit_code.toString(),\n                          })}\n                        </p>\n                      )}\n                    </div>\n                  </div>\n                  <div className=\"mt-3 text-xs text-muted-foreground\">\n                    <div className=\"flex justify-between\">\n                      <span>\n                        {t('processes.started', {\n                          date: formatDate(process.started_at),\n                        })}\n                      </span>\n                      {process.completed_at && (\n                        <span>\n                          {t('processes.completed', {\n                            date: formatDate(process.completed_at),\n                          })}\n                        </span>\n                      )}\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      ) : (\n        <div className=\"flex-1 flex flex-col min-h-0\">\n          <div className=\"flex items-center justify-between px-4 py-2 border-b flex-shrink-0\">\n            <h2 className=\"text-lg font-semibold\">\n              {t('processes.detailsTitle')}\n            </h2>\n            <div className=\"flex items-center gap-2\">\n              <button\n                onClick={handleCopyLogs}\n                disabled={logs.length === 0}\n                className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md border border-border transition-colors ${\n                  copied\n                    ? 'text-success'\n                    : logs.length === 0\n                      ? 'text-muted-foreground opacity-50 cursor-not-allowed'\n                      : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'\n                }`}\n              >\n                {copied ? t('processes.logsCopied') : t('processes.copyLogs')}\n              </button>\n              <button\n                onClick={() => setSelectedProcessId(null)}\n                className=\"flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-md border border-border transition-colors\"\n              >\n                <ArrowLeft className=\"h-4 w-4\" />\n                {t('processes.backToList')}\n              </button>\n            </div>\n          </div>\n          <div className=\"flex-1 min-h-0 flex flex-col\">\n            {selectedProcess ? (\n              <ProcessLogsViewerContent logs={logs} error={logsError} />\n            ) : loadingProcessId === selectedProcessId ? (\n              <div className=\"text-center text-muted-foreground\">\n                <p>{t('processes.loadingDetails')}</p>\n              </div>\n            ) : (\n              <div className=\"text-center text-muted-foreground\">\n                <p>{t('processes.errorLoadingDetails')}</p>\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default ProcessesTab;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/Toolbar/GitOperations.tsx",
    "content": "import {\n  ArrowRight,\n  GitBranch as GitBranchIcon,\n  GitPullRequest,\n  RefreshCw,\n  Settings,\n  AlertTriangle,\n  CheckCircle,\n  ExternalLink,\n} from 'lucide-react';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@vibe/ui/components/RadixTooltip';\nimport { useCallback, useMemo, useState } from 'react';\nimport type { RepoBranchStatus, Merge, Workspace } from 'shared/types';\nimport { ChangeTargetBranchDialog } from '@/shared/dialogs/command-bar/ChangeTargetBranchDialog';\nimport RepoSelector from '@/shared/components/tasks/RepoSelector';\nimport { BranchRebaseDialog } from '@/shared/dialogs/command-bar/BranchRebaseDialog';\nimport { CreatePRDialog } from '@/shared/dialogs/command-bar/CreatePRDialog';\n\nimport { useTranslation } from 'react-i18next';\nimport { useWorkspaceRepo } from '@/shared/hooks/useWorkspaceRepo';\nimport { useGitOperations } from '@/shared/hooks/useGitOperations';\nimport { useRepoBranches } from '@/shared/hooks/useRepoBranches';\n\ninterface GitOperationsProps {\n  selectedAttempt: Workspace;\n  branchStatus: RepoBranchStatus[] | null;\n  branchStatusError?: Error | null;\n  isAttemptRunning: boolean;\n  selectedBranch: string | null;\n  layout?: 'horizontal' | 'vertical';\n  issueIdentifier?: string;\n}\n\nexport type GitOperationsInputs = Omit<GitOperationsProps, 'selectedAttempt'>;\n\nfunction GitOperations({\n  selectedAttempt,\n  branchStatus,\n  branchStatusError,\n  isAttemptRunning,\n  selectedBranch,\n  layout = 'horizontal',\n  issueIdentifier,\n}: GitOperationsProps) {\n  const { t } = useTranslation('tasks');\n\n  const { repos, selectedRepoId, setSelectedRepoId } = useWorkspaceRepo(\n    selectedAttempt.id\n  );\n  const git = useGitOperations(selectedAttempt.id, selectedRepoId ?? undefined);\n  const { data: branches = [] } = useRepoBranches(selectedRepoId);\n  const isChangingTargetBranch = git.states.changeTargetBranchPending;\n\n  // Local state for git operations\n  const [merging, setMerging] = useState(false);\n  const [pushing, setPushing] = useState(false);\n  const [rebasing, setRebasing] = useState(false);\n  const [mergeSuccess, setMergeSuccess] = useState(false);\n  const [pushSuccess, setPushSuccess] = useState(false);\n\n  // Target branch change handlers\n  const handleChangeTargetBranchClick = async (newBranch: string) => {\n    const repoId = getSelectedRepoId();\n    if (!repoId) return;\n    await git.actions.changeTargetBranch({\n      newTargetBranch: newBranch,\n      repoId,\n    });\n  };\n\n  const handleChangeTargetBranchDialogOpen = async () => {\n    try {\n      const result = await ChangeTargetBranchDialog.show({\n        branches,\n        isChangingTargetBranch: isChangingTargetBranch,\n      });\n\n      if (result.action === 'confirmed' && result.branchName) {\n        await handleChangeTargetBranchClick(result.branchName);\n      }\n    } catch (error) {\n      // User cancelled - do nothing\n    }\n  };\n\n  const getSelectedRepoId = useCallback(() => {\n    return selectedRepoId ?? repos[0]?.id;\n  }, [selectedRepoId, repos]);\n\n  const getSelectedRepoStatus = useCallback(() => {\n    const repoId = getSelectedRepoId();\n    return branchStatus?.find((r) => r.repo_id === repoId);\n  }, [branchStatus, getSelectedRepoId]);\n\n  // Memoize the selected repo status for use in button disabled states\n  const selectedRepoStatus = useMemo(\n    () => getSelectedRepoStatus(),\n    [getSelectedRepoStatus]\n  );\n\n  const hasConflictsCalculated =\n    (selectedRepoStatus?.conflicted_files?.length ?? 0) > 0;\n\n  // Memoize merge status information to avoid repeated calculations\n  const mergeInfo = useMemo(() => {\n    const selectedRepoStatus = getSelectedRepoStatus();\n    if (!selectedRepoStatus?.merges)\n      return {\n        hasOpenPR: false,\n        openPR: null,\n        hasMergedPR: false,\n        mergedPR: null,\n        hasMerged: false,\n        latestMerge: null,\n      };\n\n    const openPR = selectedRepoStatus.merges.find(\n      (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open'\n    );\n\n    const mergedPR = selectedRepoStatus.merges.find(\n      (m: Merge) => m.type === 'pr' && m.pr_info.status === 'merged'\n    );\n\n    const merges = selectedRepoStatus.merges.filter(\n      (m: Merge) =>\n        m.type === 'direct' ||\n        (m.type === 'pr' && m.pr_info.status === 'merged')\n    );\n\n    return {\n      hasOpenPR: !!openPR,\n      openPR,\n      hasMergedPR: !!mergedPR,\n      mergedPR,\n      hasMerged: merges.length > 0,\n      latestMerge: selectedRepoStatus.merges[0] || null, // Most recent merge\n    };\n  }, [getSelectedRepoStatus]);\n\n  const mergeButtonLabel = useMemo(() => {\n    if (mergeSuccess) return t('git.states.merged');\n    if (merging) return t('git.states.merging');\n    return t('git.states.merge');\n  }, [mergeSuccess, merging, t]);\n\n  const rebaseButtonLabel = useMemo(() => {\n    if (rebasing) return t('git.states.rebasing');\n    return t('git.states.rebase');\n  }, [rebasing, t]);\n\n  const prButtonLabel = useMemo(() => {\n    if (mergeInfo.hasOpenPR) {\n      return pushSuccess\n        ? t('git.states.pushed')\n        : pushing\n          ? t('git.states.pushing')\n          : t('git.states.push');\n    }\n    return t('git.states.createPr');\n  }, [mergeInfo.hasOpenPR, pushSuccess, pushing, t]);\n\n  const handleMergeClick = async () => {\n    // Directly perform merge without checking branch status\n    await performMerge();\n  };\n\n  const handlePushClick = async () => {\n    try {\n      setPushing(true);\n      const repoId = getSelectedRepoId();\n      if (!repoId) return;\n      await git.actions.push({ repo_id: repoId });\n      setPushSuccess(true);\n      setTimeout(() => setPushSuccess(false), 2000);\n    } finally {\n      setPushing(false);\n    }\n  };\n\n  const performMerge = async () => {\n    try {\n      setMerging(true);\n      const repoId = getSelectedRepoId();\n      if (!repoId) return;\n      await git.actions.merge({\n        repoId,\n      });\n      setMergeSuccess(true);\n      setTimeout(() => setMergeSuccess(false), 2000);\n    } finally {\n      setMerging(false);\n    }\n  };\n\n  const handleRebaseWithNewBranchAndUpstream = async (\n    newBaseBranch: string,\n    selectedUpstream: string\n  ) => {\n    setRebasing(true);\n    try {\n      const repoId = getSelectedRepoId();\n      if (!repoId) return;\n      await git.actions.rebase({\n        repoId,\n        newBaseBranch: newBaseBranch,\n        oldBaseBranch: selectedUpstream,\n      });\n    } finally {\n      setRebasing(false);\n    }\n  };\n\n  const handleRebaseDialogOpen = async () => {\n    try {\n      const defaultTargetBranch = getSelectedRepoStatus()?.target_branch_name;\n      const result = await BranchRebaseDialog.show({\n        branches,\n        isRebasing: rebasing,\n        initialTargetBranch: defaultTargetBranch,\n        initialUpstreamBranch: defaultTargetBranch,\n      });\n      if (\n        result.action === 'confirmed' &&\n        result.branchName &&\n        result.upstreamBranch\n      ) {\n        await handleRebaseWithNewBranchAndUpstream(\n          result.branchName,\n          result.upstreamBranch\n        );\n      }\n    } catch (error) {\n      // User cancelled - do nothing\n    }\n  };\n\n  const handlePRButtonClick = async () => {\n    // If PR already exists, push to it\n    if (mergeInfo.hasOpenPR) {\n      await handlePushClick();\n      return;\n    }\n\n    CreatePRDialog.show({\n      attempt: selectedAttempt,\n      repoId: getSelectedRepoId(),\n      targetBranch: getSelectedRepoStatus()?.target_branch_name,\n      issueIdentifier,\n    });\n  };\n\n  const isVertical = layout === 'vertical';\n\n  const containerClasses = isVertical\n    ? 'grid grid-cols-1 items-start gap-3 overflow-hidden'\n    : 'flex items-center gap-2 overflow-hidden';\n\n  const settingsBtnClasses = isVertical\n    ? 'inline-flex h-5 w-5 p-0 hover:bg-muted'\n    : 'hidden md:inline-flex h-5 w-5 p-0 hover:bg-muted';\n\n  const actionsClasses = isVertical\n    ? 'flex flex-wrap items-center gap-2'\n    : 'shrink-0 flex flex-wrap items-center gap-2 overflow-y-hidden overflow-x-visible max-h-8';\n\n  const statusChips = (\n    <div className=\"flex items-center gap-2 text-xs min-w-0 overflow-hidden whitespace-nowrap\">\n      {(() => {\n        const commitsAhead = selectedRepoStatus?.commits_ahead ?? 0;\n        const commitsBehind = selectedRepoStatus?.commits_behind ?? 0;\n\n        if (hasConflictsCalculated) {\n          return (\n            <span className=\"inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300\">\n              <AlertTriangle className=\"h-3.5 w-3.5\" />\n              {t('git.status.conflicts')}\n            </span>\n          );\n        }\n\n        if (selectedRepoStatus?.is_rebase_in_progress) {\n          return (\n            <span className=\"inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300\">\n              <RefreshCw className=\"h-3.5 w-3.5 animate-spin\" />\n              {t('git.states.rebasing')}\n            </span>\n          );\n        }\n\n        if (mergeInfo.hasMergedPR) {\n          return (\n            <span className=\"inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-100/70 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300\">\n              <CheckCircle className=\"h-3.5 w-3.5\" />\n              {t('git.states.merged')}\n            </span>\n          );\n        }\n\n        if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') {\n          const prMerge = mergeInfo.openPR;\n          return (\n            <button\n              onClick={() => window.open(prMerge.pr_info.url, '_blank')}\n              className=\"inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100/60 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300 hover:underline truncate max-w-[180px] sm:max-w-none\"\n              aria-label={t('git.pr.open', {\n                number: Number(prMerge.pr_info.number),\n              })}\n            >\n              <GitPullRequest className=\"h-3.5 w-3.5\" />\n              {t('git.pr.number', {\n                number: Number(prMerge.pr_info.number),\n              })}\n              <ExternalLink className=\"h-3.5 w-3.5\" />\n            </button>\n          );\n        }\n\n        const chips: React.ReactNode[] = [];\n        if (commitsAhead > 0) {\n          chips.push(\n            <span\n              key=\"ahead\"\n              className=\"hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-100/70 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300\"\n            >\n              +{commitsAhead} {t('git.status.commits', { count: commitsAhead })}{' '}\n              {t('git.status.ahead')}\n            </span>\n          );\n        }\n        if (commitsBehind > 0) {\n          chips.push(\n            <span\n              key=\"behind\"\n              className=\"inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300\"\n            >\n              {commitsBehind}{' '}\n              {t('git.status.commits', { count: commitsBehind })}{' '}\n              {t('git.status.behind')}\n            </span>\n          );\n        }\n        if (chips.length > 0)\n          return <div className=\"flex items-center gap-2\">{chips}</div>;\n\n        return (\n          <span className=\"text-muted-foreground hidden sm:inline\">\n            {t('git.status.upToDate')}\n          </span>\n        );\n      })()}\n    </div>\n  );\n\n  const branchChips = (\n    <>\n      {/* Task branch chip */}\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <span className=\"hidden sm:inline-flex items-center gap-1.5 max-w-[280px] px-2 py-0.5 rounded-full bg-muted text-xs font-medium min-w-0\">\n              <GitBranchIcon className=\"h-3.5 w-3.5 text-muted-foreground shrink-0\" />\n              <span className=\"truncate\">{selectedAttempt.branch}</span>\n            </span>\n          </TooltipTrigger>\n          <TooltipContent side=\"bottom\">\n            {t('git.labels.taskBranch')}\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n\n      <ArrowRight className=\"hidden sm:inline h-4 w-4 text-muted-foreground\" />\n\n      {/* Target branch chip + change button */}\n      <div className=\"flex items-center gap-1 min-w-0\">\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <span className=\"inline-flex items-center gap-1.5 max-w-[280px] px-2 py-0.5 rounded-full bg-muted text-xs font-medium min-w-0\">\n                <GitBranchIcon className=\"h-3.5 w-3.5 text-muted-foreground shrink-0\" />\n                <span className=\"truncate\">\n                  {getSelectedRepoStatus()?.target_branch_name ||\n                    selectedBranch ||\n                    t('git.branch.current')}\n                </span>\n              </span>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              {t('rebase.dialog.targetLabel')}\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                onClick={handleChangeTargetBranchDialogOpen}\n                disabled={isAttemptRunning || hasConflictsCalculated}\n                className={settingsBtnClasses}\n                aria-label={t('branches.changeTarget.dialog.title')}\n              >\n                <Settings className=\"h-3.5 w-3.5\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              {t('branches.changeTarget.dialog.title')}\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </div>\n    </>\n  );\n\n  return (\n    <div className=\"w-full border-b py-2\">\n      <div className={containerClasses}>\n        {isVertical ? (\n          <>\n            {repos.length > 1 && (\n              <RepoSelector\n                repos={repos}\n                selectedRepoId={getSelectedRepoId() ?? null}\n                onRepoSelect={setSelectedRepoId}\n                disabled={isAttemptRunning}\n                placeholder={t('repos.selector.placeholder', 'Select repo')}\n              />\n            )}\n            <div className=\"flex flex-wrap items-center gap-2 min-w-0\">\n              {branchChips}\n              {statusChips}\n            </div>\n          </>\n        ) : (\n          <>\n            {repos.length > 0 && (\n              <RepoSelector\n                repos={repos}\n                selectedRepoId={getSelectedRepoId() ?? null}\n                onRepoSelect={setSelectedRepoId}\n                disabled={isAttemptRunning}\n                placeholder={t('repos.selector.placeholder', 'Select repo')}\n                className=\"w-auto max-w-[200px] rounded-full bg-muted border-0 h-6 px-2 py-0.5 text-xs font-medium\"\n              />\n            )}\n            <div className=\"flex flex-1 items-center justify-center gap-2 min-w-0 overflow-hidden\">\n              <div className=\"flex items-center gap-2 min-w-0 overflow-hidden\">\n                {branchChips}\n              </div>\n              {statusChips}\n            </div>\n          </>\n        )}\n\n        {/* Right: Actions */}\n        {branchStatusError && !selectedRepoStatus ? (\n          <div className=\"flex items-center gap-2 text-xs text-destructive\">\n            <AlertTriangle className=\"h-3.5 w-3.5\" />\n            <span>{t('git.errors.branchStatusUnavailable')}</span>\n          </div>\n        ) : selectedRepoStatus ? (\n          <div className={actionsClasses}>\n            <Button\n              onClick={handleMergeClick}\n              disabled={\n                mergeInfo.hasMergedPR ||\n                mergeInfo.hasOpenPR ||\n                merging ||\n                hasConflictsCalculated ||\n                isAttemptRunning ||\n                selectedRepoStatus?.is_target_remote ||\n                ((selectedRepoStatus?.commits_ahead ?? 0) === 0 &&\n                  !pushSuccess &&\n                  !mergeSuccess)\n              }\n              variant=\"outline\"\n              size=\"xs\"\n              className=\"border-success text-success hover:bg-success gap-1 shrink-0\"\n              aria-label={mergeButtonLabel}\n            >\n              <GitBranchIcon className=\"h-3.5 w-3.5\" />\n              <span className=\"truncate max-w-[10ch]\">{mergeButtonLabel}</span>\n            </Button>\n\n            <Button\n              onClick={handlePRButtonClick}\n              disabled={\n                mergeInfo.hasMergedPR ||\n                pushing ||\n                isAttemptRunning ||\n                hasConflictsCalculated ||\n                (mergeInfo.hasOpenPR &&\n                  (selectedRepoStatus?.remote_commits_ahead ?? 0) === 0) ||\n                ((selectedRepoStatus?.commits_ahead ?? 0) === 0 &&\n                  (selectedRepoStatus?.remote_commits_ahead ?? 0) === 0 &&\n                  !pushSuccess &&\n                  !mergeSuccess)\n              }\n              variant=\"outline\"\n              size=\"xs\"\n              className=\"border-info text-info hover:bg-info gap-1 shrink-0\"\n              aria-label={prButtonLabel}\n            >\n              <GitPullRequest className=\"h-3.5 w-3.5\" />\n              <span className=\"truncate max-w-[10ch]\">{prButtonLabel}</span>\n            </Button>\n\n            <Button\n              onClick={handleRebaseDialogOpen}\n              disabled={rebasing || isAttemptRunning || hasConflictsCalculated}\n              variant=\"outline\"\n              size=\"xs\"\n              className=\"border-warning text-warning hover:bg-warning gap-1 shrink-0\"\n              aria-label={rebaseButtonLabel}\n            >\n              <RefreshCw\n                className={`h-3.5 w-3.5 ${rebasing ? 'animate-spin' : ''}`}\n              />\n              <span className=\"truncate max-w-[10ch]\">{rebaseButtonLabel}</span>\n            </Button>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nexport default GitOperations;\n"
  },
  {
    "path": "packages/web-core/src/shared/components/tasks/UserAvatar.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react';\nimport { cn } from '@/shared/lib/utils';\n\ninterface UserAvatarProps {\n  firstName?: string | null;\n  lastName?: string | null;\n  username?: string | null;\n  imageUrl?: string | null;\n  className?: string;\n}\n\nconst buildInitials = (\n  firstName?: string | null,\n  lastName?: string | null,\n  username?: string | null\n) => {\n  const first = firstName?.trim().charAt(0)?.toUpperCase() ?? '';\n  const last = lastName?.trim().charAt(0)?.toUpperCase() ?? '';\n\n  if (first || last) {\n    return `${first}${last}`.trim() || first || last || '?';\n  }\n\n  const handle = username?.trim().charAt(0)?.toUpperCase();\n  return handle ?? '?';\n};\n\nconst buildLabel = (\n  firstName?: string | null,\n  lastName?: string | null,\n  username?: string | null\n) => {\n  const name = [firstName, lastName]\n    .filter((value): value is string => Boolean(value && value.trim()))\n    .join(' ');\n\n  if (name) {\n    return name;\n  }\n\n  if (username && username.trim()) {\n    return username;\n  }\n\n  return 'Unassigned';\n};\n\nconst buildOptimizedImageUrl = (rawUrl?: string | null) => {\n  if (!rawUrl) {\n    return null;\n  }\n  try {\n    const url = new URL(rawUrl);\n    url.searchParams.set('width', '64');\n    url.searchParams.set('height', '64');\n    url.searchParams.set('fit', 'crop');\n    url.searchParams.set('quality', '80');\n    return url.toString();\n  } catch (error) {\n    const separator = rawUrl.includes('?') ? '&' : '?';\n    return `${rawUrl}${separator}width=64&height=64&fit=crop&quality=80`;\n  }\n};\n\nexport const UserAvatar = ({\n  firstName,\n  lastName,\n  username,\n  imageUrl,\n  className,\n}: UserAvatarProps) => {\n  const [imageError, setImageError] = useState(false);\n\n  const effectiveFirstName = firstName ?? null;\n  const effectiveLastName = lastName ?? null;\n  const effectiveUsername = username ?? null;\n\n  const optimizedImageUrl = useMemo(() => {\n    return buildOptimizedImageUrl(imageUrl);\n  }, [imageUrl]);\n\n  useEffect(() => {\n    setImageError(false);\n  }, [optimizedImageUrl]);\n\n  const shouldShowImage = Boolean(optimizedImageUrl) && !imageError;\n\n  const initials = buildInitials(\n    effectiveFirstName,\n    effectiveLastName,\n    effectiveUsername\n  );\n  const label = buildLabel(\n    effectiveFirstName,\n    effectiveLastName,\n    effectiveUsername\n  );\n\n  return (\n    <div\n      className={cn(\n        'flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-muted-foreground text-xs font-medium text-muted',\n        className\n      )}\n      title={label}\n      aria-label={label}\n    >\n      {shouldShowImage ? (\n        <img\n          src={optimizedImageUrl ?? undefined}\n          alt={label}\n          className=\"h-full w-full object-cover\"\n          loading=\"lazy\"\n          onError={() => setImageError(true)}\n        />\n      ) : (\n        initials\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/components/ui-new/containers/AppBarUserPopoverContainer.tsx",
    "content": "import { useState } from 'react';\nimport type { OrganizationWithRole } from 'shared/types';\nimport { AppBarUserPopover } from '@vibe/ui/components/AppBarUserPopover';\nimport { SettingsDialog } from '@/shared/dialogs/settings/SettingsDialog';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { Actions } from '@/shared/actions';\n\ninterface AppBarUserPopoverContainerProps {\n  organizations: OrganizationWithRole[];\n  selectedOrgId: string;\n  onOrgSelect: (orgId: string) => void;\n  onCreateOrg: () => void;\n}\n\nexport function AppBarUserPopoverContainer({\n  organizations,\n  selectedOrgId,\n  onOrgSelect,\n  onCreateOrg,\n}: AppBarUserPopoverContainerProps) {\n  const { executeAction } = useActions();\n  const { isSignedIn } = useAuth();\n  const { loginStatus } = useUserSystem();\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n  const [open, setOpen] = useState(false);\n  const [avatarError, setAvatarError] = useState(false);\n\n  // Extract avatar URL from first provider\n  const avatarUrl =\n    loginStatus?.status === 'loggedin'\n      ? (loginStatus.profile.providers[0]?.avatar_url ?? null)\n      : null;\n\n  const handleSignIn = async () => {\n    await executeAction(Actions.SignIn);\n  };\n\n  const handleLogout = async () => {\n    await executeAction(Actions.SignOut);\n  };\n\n  const handleOrgSettings = async (orgId: string) => {\n    setSelectedOrgId(orgId);\n    await SettingsDialog.show({ initialSection: 'organizations' });\n  };\n\n  const handleSettings = async () => {\n    setOpen(false);\n    await SettingsDialog.show();\n  };\n\n  return (\n    <AppBarUserPopover\n      isSignedIn={isSignedIn}\n      avatarUrl={avatarUrl}\n      avatarError={avatarError}\n      organizations={organizations}\n      selectedOrgId={selectedOrgId}\n      open={open}\n      onOpenChange={setOpen}\n      onOrgSelect={onOrgSelect}\n      onCreateOrg={onCreateOrg}\n      onOrgSettings={handleOrgSettings}\n      onSignIn={handleSignIn}\n      onLogout={handleLogout}\n      onAvatarError={() => setAvatarError(true)}\n      onSettings={handleSettings}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/ui-new/containers/ColorPickerContainer.tsx",
    "content": "import { useState, useCallback } from 'react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/Dropdown';\nimport { InlineColorPicker } from '@vibe/ui/components/ColorPicker';\n\nexport interface ColorPickerProps {\n  value: string;\n  onChange: (color: string) => void;\n  colors: readonly string[];\n  children: React.ReactNode;\n  disabled: boolean;\n  align: 'start' | 'center' | 'end';\n  side: 'top' | 'bottom' | 'left' | 'right';\n}\n\nexport function ColorPicker({\n  value,\n  onChange,\n  colors,\n  children,\n  disabled,\n  align,\n  side,\n}: ColorPickerProps) {\n  const [open, setOpen] = useState(false);\n\n  const handleColorChange = (color: string) => {\n    onChange(color);\n    setOpen(false);\n  };\n\n  // Use ref callback to focus when the element mounts\n  // This fires when the portal content is actually in the DOM\n  const handlePickerRef = useCallback((node: HTMLDivElement | null) => {\n    if (node) {\n      node.focus();\n    }\n  }, []);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      const currentIndex = (colors as readonly string[]).indexOf(value);\n\n      if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n        e.preventDefault();\n        e.stopPropagation();\n        const newIndex =\n          currentIndex <= 0 ? colors.length - 1 : currentIndex - 1;\n        onChange(colors[newIndex]);\n      } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n        e.preventDefault();\n        e.stopPropagation();\n        const newIndex =\n          currentIndex >= colors.length - 1 ? 0 : currentIndex + 1;\n        onChange(colors[newIndex]);\n      }\n    },\n    [colors, value, onChange]\n  );\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen}>\n      <DropdownMenuTrigger asChild disabled={disabled}>\n        {children}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align={align} side={side}>\n        <div\n          ref={handlePickerRef}\n          className=\"p-base outline-none\"\n          tabIndex={-1}\n          onKeyDown={handleKeyDown}\n        >\n          <InlineColorPicker\n            value={value}\n            onChange={handleColorChange}\n            colors={colors}\n            disabled={disabled}\n          />\n        </div>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/ui-new/containers/NavbarContainer.tsx",
    "content": "import { useMemo, useCallback } from 'react';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useUserContext } from '@/shared/hooks/useUserContext';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useSyncErrorContext } from '@/shared/hooks/useSyncErrorContext';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport {\n  Navbar,\n  type NavbarSectionItem,\n  type NavbarBreadcrumbItem,\n  type MobileTabId,\n} from '@vibe/ui/components/Navbar';\nimport { useAllOrganizationProjects } from '@/shared/hooks/useAllOrganizationProjects';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport { PROJECT_ISSUES_SHAPE } from 'shared/remote-types';\nimport { RemoteIssueLink } from './RemoteIssueLink';\nimport { AppBarUserPopoverContainer } from './AppBarUserPopoverContainer';\nimport { NavbarActionGroups } from '@/shared/actions';\nimport {\n  NavbarDivider,\n  type ActionDefinition,\n  type NavbarItem as ActionNavbarItem,\n  type ActionVisibilityContext,\n  isSpecialIcon,\n  getActionIcon,\n  getActionTooltip,\n  isActionActive,\n  isActionEnabled,\n  isActionVisible,\n} from '@/shared/types/actions';\nimport { useActionVisibilityContext } from '@/shared/hooks/useActionVisibilityContext';\nimport { useMobileActiveTab } from '@/shared/stores/useUiPreferencesStore';\nimport { CommandBarDialog } from '@/shared/dialogs/command-bar/CommandBarDialog';\nimport { SettingsDialog } from '@/shared/dialogs/settings/SettingsDialog';\nimport { getProjectDestination } from '@/shared/lib/routes/appNavigation';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\n\n/**\n * Check if a NavbarItem is a divider\n */\nfunction isDivider(item: ActionNavbarItem): item is typeof NavbarDivider {\n  return 'type' in item && item.type === 'divider';\n}\n\n/**\n * Filter navbar items by visibility, keeping dividers but removing them\n * if they would appear at the start, end, or consecutively.\n */\nfunction filterNavbarItems(\n  items: readonly ActionNavbarItem[],\n  ctx: ActionVisibilityContext\n): ActionNavbarItem[] {\n  // Filter actions by visibility, keep dividers\n  const filtered = items.filter((item) => {\n    if (isDivider(item)) return true;\n    if (!isActionVisible(item, ctx)) return false;\n    return !isSpecialIcon(getActionIcon(item, ctx));\n  });\n\n  // Remove leading/trailing dividers and consecutive dividers\n  const result: ActionNavbarItem[] = [];\n  for (const item of filtered) {\n    if (isDivider(item)) {\n      // Only add divider if we have items before it and last item wasn't a divider\n      if (result.length > 0 && !isDivider(result[result.length - 1])) {\n        result.push(item);\n      }\n    } else {\n      result.push(item);\n    }\n  }\n\n  // Remove trailing divider\n  if (result.length > 0 && isDivider(result[result.length - 1])) {\n    result.pop();\n  }\n\n  return result;\n}\n\nfunction toNavbarSectionItems(\n  items: readonly ActionNavbarItem[],\n  ctx: ActionVisibilityContext,\n  onExecuteAction: (action: ActionDefinition) => void\n): NavbarSectionItem[] {\n  return items.reduce<NavbarSectionItem[]>((result, item) => {\n    if (isDivider(item)) {\n      result.push({ type: 'divider' });\n      return result;\n    }\n\n    const icon = getActionIcon(item, ctx);\n    if (isSpecialIcon(icon)) {\n      return result;\n    }\n\n    result.push({\n      type: 'action',\n      id: item.id,\n      icon,\n      isActive: isActionActive(item, ctx),\n      tooltip: getActionTooltip(item, ctx),\n      shortcut: item.shortcut,\n      disabled: !isActionEnabled(item, ctx),\n      onClick: () => onExecuteAction(item),\n    });\n    return result;\n  }, []);\n}\n\nexport function NavbarContainer({\n  mobileMode = false,\n  onCreateOrg,\n  onOrgSelect,\n  onOpenDrawer,\n}: {\n  mobileMode?: boolean;\n  onCreateOrg?: () => void;\n  onOrgSelect?: (orgId: string) => void;\n  onOpenDrawer?: () => void;\n}) {\n  const { executeAction } = useActions();\n  const { workspace: selectedWorkspace, isCreateMode } = useWorkspaceContext();\n  const { workspaces } = useUserContext();\n  const syncErrorContext = useSyncErrorContext();\n  const appNavigation = useAppNavigation();\n  const destination = useCurrentAppDestination();\n  const projectDestination = useMemo(\n    () => getProjectDestination(destination),\n    [destination]\n  );\n  const isOnProjectPage = projectDestination !== null;\n  const projectId = projectDestination?.projectId ?? null;\n  const isOnProjectSubRoute =\n    projectDestination !== null && projectDestination.kind !== 'project';\n  const [mobileActiveTab, setMobileActiveTab] = useMobileActiveTab();\n\n  // Find remote workspace linked to current local workspace\n  const linkedRemoteWorkspace = useMemo(() => {\n    if (!selectedWorkspace?.id) return null;\n    return (\n      workspaces.find((w) => w.local_workspace_id === selectedWorkspace.id) ??\n      null\n    );\n  }, [workspaces, selectedWorkspace?.id]);\n\n  const { data: orgsData } = useUserOrganizations();\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  const orgName =\n    orgsData?.organizations.find((o) => o.id === selectedOrgId)?.name ?? '';\n\n  // Get action visibility context (includes all state for visibility/active/enabled)\n  const actionCtx = useActionVisibilityContext();\n\n  // Action handler - all actions go through the standard executeAction\n  const handleExecuteAction = useCallback(\n    (action: ActionDefinition) => {\n      if (action.requiresTarget && selectedWorkspace?.id) {\n        executeAction(action, selectedWorkspace.id);\n      } else {\n        executeAction(action);\n      }\n    },\n    [executeAction, selectedWorkspace?.id]\n  );\n\n  const isMigratePage = actionCtx.layoutMode === 'migrate';\n\n  // Filter visible actions for each section (empty on migrate page)\n  const leftItems = useMemo(\n    () =>\n      isMigratePage\n        ? []\n        : toNavbarSectionItems(\n            filterNavbarItems(NavbarActionGroups.left, actionCtx),\n            actionCtx,\n            handleExecuteAction\n          ),\n    [actionCtx, handleExecuteAction, isMigratePage]\n  );\n\n  const rightItems = useMemo(\n    () =>\n      isMigratePage\n        ? []\n        : toNavbarSectionItems(\n            filterNavbarItems(NavbarActionGroups.right, actionCtx),\n            actionCtx,\n            handleExecuteAction\n          ),\n    [actionCtx, handleExecuteAction, isMigratePage]\n  );\n\n  const navbarTitle = isCreateMode\n    ? 'Create Workspace'\n    : isMigratePage\n      ? 'Migrate'\n      : isOnProjectPage\n        ? orgName\n        : selectedWorkspace?.branch;\n\n  // Breadcrumbs: Project / Issue / Workspace (only on workspace pages with linked project)\n  const linkedProjectId = linkedRemoteWorkspace?.project_id ?? null;\n  const linkedIssueId = linkedRemoteWorkspace?.issue_id ?? null;\n  const shouldResolveBreadcrumbData =\n    !isOnProjectPage && !isCreateMode && !isMigratePage && !!linkedProjectId;\n  const shouldResolveIssueBreadcrumb =\n    shouldResolveBreadcrumbData && !!linkedIssueId;\n\n  const { data: allProjects, isLoading: isProjectsLoading } =\n    useAllOrganizationProjects({\n      enabled: shouldResolveBreadcrumbData,\n    });\n  const { data: projectIssues, isLoading: isProjectIssuesLoading } = useShape(\n    PROJECT_ISSUES_SHAPE,\n    { project_id: linkedProjectId || '' },\n    { enabled: shouldResolveIssueBreadcrumb }\n  );\n  const linkedProject = allProjects.find((p) => p.id === linkedProjectId);\n  const isWaitingForProjectBreadcrumb =\n    shouldResolveBreadcrumbData && !linkedProject && isProjectsLoading;\n  const isWaitingForIssueBreadcrumb =\n    shouldResolveIssueBreadcrumb && isProjectIssuesLoading;\n  const isWaitingForBreadcrumbData =\n    isWaitingForProjectBreadcrumb || isWaitingForIssueBreadcrumb;\n\n  const breadcrumbs = useMemo((): NavbarBreadcrumbItem[] | undefined => {\n    if (\n      !shouldResolveBreadcrumbData ||\n      !linkedProjectId ||\n      isWaitingForBreadcrumbData\n    ) {\n      return undefined;\n    }\n\n    const project = linkedProject;\n    if (!project) return undefined;\n\n    const items: NavbarBreadcrumbItem[] = [\n      {\n        label: project.name,\n        onClick: () => appNavigation.goToProject(linkedProjectId),\n      },\n    ];\n\n    if (linkedIssueId) {\n      const issue = projectIssues.find((i) => i.id === linkedIssueId);\n      if (issue) {\n        items.push({\n          label: issue.simple_id,\n          onClick: () =>\n            appNavigation.goToProjectIssue(linkedProjectId, linkedIssueId),\n        });\n      }\n    }\n\n    const workspaceLabel =\n      selectedWorkspace?.name || selectedWorkspace?.branch || '';\n    if (workspaceLabel) {\n      items.push({ label: workspaceLabel });\n    }\n\n    return items.length > 1 ? items : undefined;\n  }, [\n    shouldResolveBreadcrumbData,\n    linkedProjectId,\n    linkedIssueId,\n    linkedProject,\n    isWaitingForBreadcrumbData,\n    projectIssues,\n    selectedWorkspace?.name,\n    selectedWorkspace?.branch,\n    appNavigation,\n  ]);\n\n  // Mobile-specific callbacks\n  const handleOpenCommandBar = useCallback(() => {\n    CommandBarDialog.show();\n  }, []);\n\n  const handleOpenSettings = useCallback(() => {\n    SettingsDialog.show();\n  }, []);\n\n  const handleNavigateBack = useCallback(() => {\n    if (isOnProjectPage && projectId) {\n      // On project sub-route: go back to project root (kanban board)\n      appNavigation.goToProject(projectId);\n    } else {\n      // Non-project page: go to workspaces\n      appNavigation.goToWorkspaces();\n    }\n  }, [isOnProjectPage, projectId, appNavigation]);\n\n  const handleNavigateToBoard = useMemo(() => {\n    if (!isOnProjectPage || !projectId) return null;\n    return () => {\n      appNavigation.goToProject(projectId);\n    };\n  }, [isOnProjectPage, projectId, appNavigation]);\n\n  // Build user popover slot for mobile mode\n  const userPopoverSlot = useMemo(() => {\n    if (!mobileMode) return undefined;\n    return (\n      <AppBarUserPopoverContainer\n        organizations={orgsData?.organizations ?? []}\n        selectedOrgId={selectedOrgId ?? ''}\n        onOrgSelect={onOrgSelect ?? (() => {})}\n        onCreateOrg={onCreateOrg ?? (() => {})}\n      />\n    );\n  }, [\n    mobileMode,\n    orgsData?.organizations,\n    selectedOrgId,\n    onCreateOrg,\n    onOrgSelect,\n  ]);\n  return (\n    <Navbar\n      workspaceTitle={navbarTitle}\n      breadcrumbs={breadcrumbs}\n      leftItems={leftItems}\n      rightItems={rightItems}\n      syncErrors={syncErrorContext?.errors}\n      mobileMode={mobileMode}\n      mobileUserSlot={userPopoverSlot}\n      isOnProjectPage={isOnProjectPage}\n      isOnProjectSubRoute={isOnProjectSubRoute}\n      onOpenCommandBar={handleOpenCommandBar}\n      onOpenSettings={handleOpenSettings}\n      onNavigateBack={handleNavigateBack}\n      onNavigateToBoard={handleNavigateToBoard}\n      onOpenDrawer={onOpenDrawer}\n      mobileActiveTab={mobileActiveTab as MobileTabId}\n      onMobileTabChange={(tab) => setMobileActiveTab(tab)}\n      leftSlot={\n        !breadcrumbs &&\n        !isWaitingForBreadcrumbData &&\n        linkedRemoteWorkspace?.issue_id ? (\n          <RemoteIssueLink\n            projectId={linkedRemoteWorkspace.project_id}\n            issueId={linkedRemoteWorkspace.issue_id}\n          />\n        ) : null\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/ui-new/containers/RemoteIssueLink.tsx",
    "content": "import { useShape } from '@/shared/integrations/electric/hooks';\nimport { PROJECT_ISSUES_SHAPE } from 'shared/remote-types';\nimport { LinkIcon } from '@phosphor-icons/react';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\n\ninterface RemoteIssueLinkProps {\n  projectId: string;\n  issueId: string;\n}\n\nexport function RemoteIssueLink({ projectId, issueId }: RemoteIssueLinkProps) {\n  const appNavigation = useAppNavigation();\n\n  // Subscribe to issues for this project via Electric sync\n  const { data: issues, isLoading } = useShape(PROJECT_ISSUES_SHAPE, {\n    project_id: projectId,\n  });\n\n  // Find the specific issue\n  const issue = issues.find((i) => i.id === issueId);\n\n  if (isLoading || !issue) {\n    return null;\n  }\n\n  return (\n    <button\n      type=\"button\"\n      className=\"flex items-center gap-half px-base text-sm text-low hover:text-normal hover:bg-secondary rounded-sm transition-colors\"\n      onClick={() => {\n        appNavigation.goToProjectIssue(projectId, issueId);\n      }}\n    >\n      <LinkIcon className=\"size-icon-xs\" weight=\"bold\" />\n      <span>{issue.simple_id}</span>\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/ui-new/containers/SearchableDropdownContainer.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { useState, useMemo, useCallback, useRef } from 'react';\nimport { VirtuosoHandle } from 'react-virtuoso';\nimport { SearchableDropdown } from '@vibe/ui/components/SearchableDropdown';\n\ninterface SearchableDropdownContainerProps<T> {\n  /** Array of items to display */\n  items: T[];\n  /** Currently selected value (matched against getItemKey) */\n  selectedValue: string | null;\n\n  /** Extract unique key from item */\n  getItemKey: (item: T) => string;\n  /** Extract display label from item */\n  getItemLabel: (item: T) => string;\n  /** Custom filter function (null = default label.includes(query)) */\n  filterItem: ((item: T, query: string) => boolean) | null;\n\n  /** Called when an item is selected */\n  onSelect: (item: T) => void;\n\n  /** Trigger element (uses asChild pattern) */\n  trigger: React.ReactNode;\n\n  /** Class name for dropdown content */\n  contentClassName: string;\n  /** Placeholder text for search input */\n  placeholder: string;\n  /** Message shown when no items match */\n  emptyMessage: string;\n\n  /** Badge text for each item (null = no badges) */\n  getItemBadge: ((item: T) => string | undefined) | null;\n\n  /** Icon/avatar to render before each item's label (null = no icons) */\n  getItemIcon: ((item: T) => ReactNode) | null;\n}\n\nexport function SearchableDropdownContainer<T>({\n  items,\n  selectedValue,\n  getItemKey,\n  getItemLabel,\n  filterItem,\n  onSelect,\n  trigger,\n  contentClassName,\n  placeholder,\n  emptyMessage,\n  getItemBadge,\n  getItemIcon,\n}: SearchableDropdownContainerProps<T>) {\n  const [searchTerm, setSearchTerm] = useState('');\n  const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);\n  const [dropdownOpen, setDropdownOpen] = useState(false);\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n\n  const filteredItems = useMemo(() => {\n    if (!searchTerm.trim()) return items;\n    const query = searchTerm.toLowerCase();\n    if (filterItem !== null) {\n      return items.filter((item) => filterItem(item, query));\n    }\n    return items.filter((item) =>\n      getItemLabel(item).toLowerCase().includes(query)\n    );\n  }, [items, searchTerm, filterItem, getItemLabel]);\n\n  // Derive safe highlight index (clamp to valid range)\n  const safeHighlightedIndex = useMemo(() => {\n    if (highlightedIndex === null) return null;\n    if (highlightedIndex >= filteredItems.length) return null;\n    return highlightedIndex;\n  }, [highlightedIndex, filteredItems.length]);\n\n  const handleSearchTermChange = useCallback((value: string) => {\n    setSearchTerm(value);\n    setHighlightedIndex(null);\n  }, []);\n\n  const moveHighlight = useCallback(\n    (delta: 1 | -1) => {\n      if (filteredItems.length === 0) return;\n      const start = safeHighlightedIndex ?? -1;\n      const next =\n        (start + delta + filteredItems.length) % filteredItems.length;\n      setHighlightedIndex(next);\n      virtuosoRef.current?.scrollIntoView({ index: next, behavior: 'auto' });\n    },\n    [filteredItems, safeHighlightedIndex]\n  );\n\n  const attemptSelect = useCallback(() => {\n    if (safeHighlightedIndex == null) return;\n    const item = filteredItems[safeHighlightedIndex];\n    if (!item) return;\n    onSelect(item);\n    setDropdownOpen(false);\n  }, [safeHighlightedIndex, filteredItems, onSelect]);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      switch (e.key) {\n        case 'ArrowDown':\n          e.preventDefault();\n          e.stopPropagation();\n          moveHighlight(1);\n          return;\n        case 'ArrowUp':\n          e.preventDefault();\n          e.stopPropagation();\n          moveHighlight(-1);\n          return;\n        case 'Enter':\n          e.preventDefault();\n          e.stopPropagation();\n          attemptSelect();\n          return;\n        case 'Escape':\n          e.preventDefault();\n          e.stopPropagation();\n          setDropdownOpen(false);\n          return;\n        case 'Tab':\n          return;\n        default:\n          e.stopPropagation(); // Prevents Radix typeahead from stealing focus\n      }\n    },\n    [moveHighlight, attemptSelect]\n  );\n\n  const handleOpenChange = useCallback((next: boolean) => {\n    setDropdownOpen(next);\n    if (!next) {\n      setSearchTerm('');\n      setHighlightedIndex(null);\n    }\n  }, []);\n\n  const handleSelect = useCallback(\n    (item: T) => {\n      onSelect(item);\n      setDropdownOpen(false);\n    },\n    [onSelect]\n  );\n\n  return (\n    <SearchableDropdown\n      filteredItems={filteredItems}\n      selectedValue={selectedValue}\n      getItemKey={getItemKey}\n      getItemLabel={getItemLabel}\n      onSelect={handleSelect}\n      trigger={trigger}\n      searchTerm={searchTerm}\n      onSearchTermChange={handleSearchTermChange}\n      highlightedIndex={safeHighlightedIndex}\n      onHighlightedIndexChange={setHighlightedIndex}\n      open={dropdownOpen}\n      onOpenChange={handleOpenChange}\n      onKeyDown={handleKeyDown}\n      virtuosoRef={virtuosoRef}\n      contentClassName={contentClassName}\n      placeholder={placeholder}\n      emptyMessage={emptyMessage}\n      getItemBadge={getItemBadge ?? undefined}\n      getItemIcon={getItemIcon ?? undefined}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/components/ui-new/containers/SharedAppLayout.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { DropResult } from '@hello-pangea/dnd';\nimport { Outlet } from '@tanstack/react-router';\nimport { siDiscord, siGithub } from 'simple-icons';\nimport { XIcon, PlusIcon, LayoutIcon, KanbanIcon } from '@phosphor-icons/react';\nimport { SyncErrorProvider } from '@/shared/providers/SyncErrorProvider';\nimport { useIsMobile } from '@/shared/hooks/useIsMobile';\nimport { useUiPreferencesStore } from '@/shared/stores/useUiPreferencesStore';\nimport { cn } from '@/shared/lib/utils';\nimport { isTauriMac } from '@/shared/lib/platform';\n\nimport { NavbarContainer } from './NavbarContainer';\nimport { AppBar } from '@vibe/ui/components/AppBar';\nimport { MobileDrawer } from '@vibe/ui/components/MobileDrawer';\nimport { AppBarUserPopoverContainer } from './AppBarUserPopoverContainer';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useDiscordOnlineCount } from '@/shared/hooks/useDiscordOnlineCount';\nimport { useGitHubStars } from '@/shared/hooks/useGitHubStars';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useAppUpdateStore } from '@/shared/stores/useAppUpdateStore';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\nimport {\n  getProjectDestination,\n  isWorkspacesDestination,\n} from '@/shared/lib/routes/appNavigation';\nimport {\n  CreateOrganizationDialog,\n  type CreateOrganizationResult,\n} from '@/shared/dialogs/org/CreateOrganizationDialog';\nimport {\n  CreateRemoteProjectDialog,\n  type CreateRemoteProjectResult,\n} from '@/shared/dialogs/org/CreateRemoteProjectDialog';\nimport { OAuthDialog } from '@/shared/dialogs/global/OAuthDialog';\nimport { CommandBarDialog } from '@/shared/dialogs/command-bar/CommandBarDialog';\nimport { useCommandBarShortcut } from '@/shared/hooks/useCommandBarShortcut';\nimport { useWorkspaceSidebarPreviewController } from '@/shared/hooks/useWorkspaceSidebarPreviewController';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport { sortProjectsByOrder } from '@/shared/lib/projectOrder';\nimport {\n  PROJECT_MUTATION,\n  PROJECTS_SHAPE,\n  type Project as RemoteProject,\n} from 'shared/remote-types';\nimport { AppBarNotificationBellContainer } from '@/pages/workspaces/AppBarNotificationBellContainer';\nimport { WorkspacesSidebarContainer } from '@/pages/workspaces/WorkspacesSidebarContainer';\nimport { WorkspacesSidebarReopenTag } from '@vibe/ui/components/WorkspacesSidebar';\n\nexport function SharedAppLayout() {\n  const appNavigation = useAppNavigation();\n  const currentDestination = useCurrentAppDestination();\n  const isMigrateRoute = currentDestination?.kind === 'migrate';\n  const isMobile = useIsMobile();\n  const mobileFontScale = useUiPreferencesStore((s) => s.mobileFontScale);\n  const isLeftSidebarVisible = useUiPreferencesStore(\n    (s) => s.isLeftSidebarVisible\n  );\n  const { isSignedIn } = useAuth();\n  const { appVersion } = useUserSystem();\n  const updateVersion = useAppUpdateStore((s) => s.updateVersion);\n  const restartForUpdate = useAppUpdateStore((s) => s.restart);\n  const { data: onlineCount } = useDiscordOnlineCount();\n  const { data: starCount } = useGitHubStars();\n  const [isDrawerOpen, setIsDrawerOpen] = useState(false);\n  const [isAppBarHovered, setIsAppBarHovered] = useState(false);\n\n  // Register CMD+K shortcut globally for all routes under SharedAppLayout\n  useCommandBarShortcut(() => CommandBarDialog.show());\n\n  // Apply mobile font scale CSS variable\n  useEffect(() => {\n    if (!isMobile) {\n      document.documentElement.style.removeProperty('--mobile-font-scale');\n      return;\n    }\n    const scaleMap = { default: '1', small: '0.9', smaller: '0.8' } as const;\n    document.documentElement.style.setProperty(\n      '--mobile-font-scale',\n      scaleMap[mobileFontScale]\n    );\n    return () => {\n      document.documentElement.style.removeProperty('--mobile-font-scale');\n    };\n  }, [isMobile, mobileFontScale]);\n\n  // AppBar state - organizations and projects\n  const { data: orgsData } = useUserOrganizations();\n  const organizations = useMemo(\n    () => orgsData?.organizations ?? [],\n    [orgsData?.organizations]\n  );\n\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n  const prevOrgIdRef = useRef<string | null>(null);\n\n  // Auto-select first org if none selected or selection is invalid\n  useEffect(() => {\n    if (organizations.length === 0) return;\n\n    const hasValidSelection = selectedOrgId\n      ? organizations.some((org) => org.id === selectedOrgId)\n      : false;\n\n    if (!selectedOrgId || !hasValidSelection) {\n      const firstNonPersonal = organizations.find((org) => !org.is_personal);\n      setSelectedOrgId((firstNonPersonal ?? organizations[0]).id);\n    }\n  }, [organizations, selectedOrgId, setSelectedOrgId]);\n\n  const projectParams = useMemo(\n    () => ({ organization_id: selectedOrgId || '' }),\n    [selectedOrgId]\n  );\n  const {\n    data: orgProjects = [],\n    isLoading,\n    updateMany: updateManyProjects,\n  } = useShape(PROJECTS_SHAPE, projectParams, {\n    enabled: isSignedIn && !!selectedOrgId,\n    mutation: PROJECT_MUTATION,\n  });\n  const sortedProjects = useMemo(\n    () => sortProjectsByOrder(orgProjects),\n    [orgProjects]\n  );\n  const [orderedProjects, setOrderedProjects] =\n    useState<RemoteProject[]>(sortedProjects);\n  const [isSavingProjectOrder, setIsSavingProjectOrder] = useState(false);\n\n  useEffect(() => {\n    if (isSavingProjectOrder) {\n      return;\n    }\n    setOrderedProjects(sortedProjects);\n  }, [isSavingProjectOrder, sortedProjects]);\n\n  // Navigate to the first ordered project when org changes\n  useEffect(() => {\n    // Skip auto-navigation when on migration flow\n    if (isMigrateRoute) {\n      prevOrgIdRef.current = selectedOrgId;\n      return;\n    }\n\n    if (\n      prevOrgIdRef.current !== null &&\n      prevOrgIdRef.current !== selectedOrgId &&\n      selectedOrgId &&\n      !isLoading\n    ) {\n      if (sortedProjects.length > 0) {\n        appNavigation.goToProject(sortedProjects[0].id);\n      } else {\n        appNavigation.goToWorkspaces();\n      }\n      prevOrgIdRef.current = selectedOrgId;\n    } else if (prevOrgIdRef.current === null && selectedOrgId) {\n      prevOrgIdRef.current = selectedOrgId;\n    }\n  }, [selectedOrgId, sortedProjects, isLoading, isMigrateRoute, appNavigation]);\n\n  // Navigation state for AppBar active indicators\n  const projectDestination = useMemo(\n    () => getProjectDestination(currentDestination),\n    [currentDestination]\n  );\n  const isWorkspacesActive = isWorkspacesDestination(currentDestination);\n  const isWorkspaceSidebarPreviewEnabled =\n    !isMobile && isWorkspacesActive && !isLeftSidebarVisible;\n  const activeProjectId = projectDestination?.projectId ?? null;\n  const sidebarPreview = useWorkspaceSidebarPreviewController({\n    enabled: isWorkspaceSidebarPreviewEnabled,\n    isAppBarHovered,\n  });\n\n  // Persist last selected project to scratch store\n  const setSelectedProjectId = useUiPreferencesStore(\n    (s) => s.setSelectedProjectId\n  );\n  useEffect(() => {\n    if (activeProjectId) {\n      setSelectedProjectId(activeProjectId);\n    }\n  }, [activeProjectId, setSelectedProjectId]);\n\n  const handleWorkspacesClick = useCallback(() => {\n    appNavigation.goToWorkspaces();\n  }, [appNavigation]);\n\n  const handleProjectClick = useCallback(\n    (projectId: string) => {\n      appNavigation.goToProject(projectId);\n    },\n    [appNavigation]\n  );\n\n  const handleProjectsDragEnd = useCallback(\n    async ({ source, destination }: DropResult) => {\n      if (isSavingProjectOrder) {\n        return;\n      }\n      if (!destination || source.index === destination.index) {\n        return;\n      }\n\n      const previousOrder = orderedProjects;\n      const reordered = [...orderedProjects];\n      const [moved] = reordered.splice(source.index, 1);\n\n      if (!moved) {\n        return;\n      }\n\n      reordered.splice(destination.index, 0, moved);\n      setOrderedProjects(reordered);\n      setIsSavingProjectOrder(true);\n\n      try {\n        await updateManyProjects(\n          reordered.map((project, index) => ({\n            id: project.id,\n            changes: { sort_order: index },\n          }))\n        ).persisted;\n      } catch (error) {\n        console.error('Failed to reorder projects:', error);\n        setOrderedProjects(previousOrder);\n      } finally {\n        setIsSavingProjectOrder(false);\n      }\n    },\n    [isSavingProjectOrder, orderedProjects, updateManyProjects]\n  );\n\n  const handleCreateOrg = useCallback(async () => {\n    try {\n      const result: CreateOrganizationResult =\n        await CreateOrganizationDialog.show();\n\n      if (result.action === 'created' && result.organizationId) {\n        setSelectedOrgId(result.organizationId);\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  }, [setSelectedOrgId]);\n\n  const handleCreateProject = useCallback(async () => {\n    if (!selectedOrgId) return;\n\n    try {\n      const result: CreateRemoteProjectResult =\n        await CreateRemoteProjectDialog.show({ organizationId: selectedOrgId });\n\n      if (result.action === 'created' && result.project) {\n        appNavigation.goToProject(result.project.id);\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  }, [selectedOrgId, appNavigation]);\n\n  const handleSignIn = useCallback(async () => {\n    try {\n      await OAuthDialog.show({});\n    } catch {\n      // Dialog cancelled\n    }\n  }, []);\n\n  const handleMigrate = useCallback(async () => {\n    if (!isSignedIn) {\n      try {\n        const profile = await OAuthDialog.show({});\n        if (profile) {\n          appNavigation.goToMigrate();\n        }\n      } catch {\n        // Dialog cancelled\n      }\n    } else {\n      appNavigation.goToMigrate();\n    }\n  }, [isSignedIn, appNavigation]);\n\n  return (\n    <SyncErrorProvider>\n      <div\n        className={cn(\n          'bg-primary',\n          isMobile\n            ? 'flex fixed inset-0 pb-[env(safe-area-inset-bottom)]'\n            : !isMigrateRoute\n              ? 'grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] h-screen'\n              : 'flex h-screen'\n        )}\n      >\n        {!isMobile && !isMigrateRoute && (\n          <>\n            {/* Row 1, col 1: corner spacer — seamless with AppBar bg */}\n            <div\n              data-tauri-drag-region\n              className=\"bg-secondary\"\n              style={isTauriMac() ? { minWidth: 56 } : undefined}\n            />\n            {/* Row 1, col 2: Navbar stretches full width */}\n            <NavbarContainer\n              onCreateOrg={handleCreateOrg}\n              onOrgSelect={setSelectedOrgId}\n              onOpenDrawer={() => setIsDrawerOpen(true)}\n            />\n            {/* Row 2, col 1: AppBar sidebar */}\n            <AppBar\n              projects={orderedProjects}\n              onCreateProject={handleCreateProject}\n              onWorkspacesClick={handleWorkspacesClick}\n              onProjectClick={handleProjectClick}\n              onProjectsDragEnd={handleProjectsDragEnd}\n              isSavingProjectOrder={isSavingProjectOrder}\n              isWorkspacesActive={isWorkspacesActive}\n              activeProjectId={activeProjectId}\n              isSignedIn={isSignedIn}\n              isLoadingProjects={isLoading}\n              onSignIn={handleSignIn}\n              onMigrate={handleMigrate}\n              onHoverStart={() => setIsAppBarHovered(true)}\n              onHoverEnd={() => setIsAppBarHovered(false)}\n              notificationBell={\n                isSignedIn ? <AppBarNotificationBellContainer /> : undefined\n              }\n              userPopover={\n                <AppBarUserPopoverContainer\n                  organizations={organizations}\n                  selectedOrgId={selectedOrgId ?? ''}\n                  onOrgSelect={setSelectedOrgId}\n                  onCreateOrg={handleCreateOrg}\n                />\n              }\n              starCount={starCount}\n              onlineCount={onlineCount}\n              appVersion={appVersion}\n              updateVersion={updateVersion}\n              onUpdateClick={restartForUpdate ?? undefined}\n              githubIconPath={siGithub.path}\n              discordIconPath={siDiscord.path}\n            />\n            {/* Row 2, col 2: Content */}\n            <div className=\"relative min-h-0 overflow-hidden\">\n              {isWorkspaceSidebarPreviewEnabled && (\n                <div className=\"absolute inset-y-0 left-0 z-20 flex items-center\">\n                  <WorkspacesSidebarReopenTag\n                    active={sidebarPreview.isPreviewOpen}\n                    onHoverStart={sidebarPreview.handleHandleHoverStart}\n                    onHoverEnd={sidebarPreview.handleHandleHoverEnd}\n                    ariaLabel=\"Workspaces\"\n                  />\n                </div>\n              )}\n\n              {isWorkspaceSidebarPreviewEnabled && (\n                <div\n                  className={cn(\n                    'absolute left-0 top-0 z-30 h-full w-[300px] transition-transform duration-150 ease-out',\n                    sidebarPreview.isPreviewOpen\n                      ? 'translate-x-0 pointer-events-auto'\n                      : '-translate-x-full pointer-events-none'\n                  )}\n                  onMouseEnter={sidebarPreview.handlePreviewHoverStart}\n                  onMouseLeave={sidebarPreview.handlePreviewHoverEnd}\n                >\n                  <div className=\"h-full w-full overflow-hidden border-r border-border bg-secondary shadow-lg\">\n                    <WorkspacesSidebarContainer />\n                  </div>\n                </div>\n              )}\n\n              <Outlet />\n            </div>\n          </>\n        )}\n\n        {(isMobile || isMigrateRoute) && (\n          <div className=\"flex flex-col flex-1 min-w-0 overflow-hidden\">\n            <NavbarContainer\n              mobileMode={isMobile}\n              onCreateOrg={handleCreateOrg}\n              onOrgSelect={setSelectedOrgId}\n              onOpenDrawer={() => setIsDrawerOpen(true)}\n            />\n            <div className=\"flex-1 min-h-0 overflow-hidden\">\n              <Outlet />\n            </div>\n          </div>\n        )}\n\n        {/* Mobile project navigation drawer */}\n        <MobileDrawer\n          open={isDrawerOpen && isMobile}\n          onClose={() => setIsDrawerOpen(false)}\n        >\n          <div className=\"flex flex-col h-full\">\n            {/* Header: org name + close button */}\n            <div className=\"flex items-center justify-between p-4 border-b border-border\">\n              <span className=\"text-sm font-medium text-high truncate\">\n                {organizations.find((o) => o.id === selectedOrgId)?.name ??\n                  'Organization'}\n              </span>\n              <button\n                type=\"button\"\n                onClick={() => setIsDrawerOpen(false)}\n                className=\"p-1 rounded-sm text-low hover:text-normal cursor-pointer\"\n              >\n                <XIcon className=\"h-4 w-4\" weight=\"bold\" />\n              </button>\n            </div>\n\n            {/* Workspaces link */}\n            <button\n              type=\"button\"\n              onClick={() => {\n                appNavigation.goToWorkspaces();\n                setIsDrawerOpen(false);\n              }}\n              className=\"flex items-center gap-2 px-4 py-3 text-sm text-normal hover:bg-secondary cursor-pointer\"\n            >\n              <LayoutIcon className=\"h-4 w-4\" />\n              Workspaces\n            </button>\n\n            {/* Divider */}\n            <div className=\"border-t border-border mx-4\" />\n\n            {/* Project list */}\n            <div className=\"flex-1 overflow-y-auto p-2\">\n              {isSignedIn ? (\n                orderedProjects.map((project) => (\n                  <button\n                    type=\"button\"\n                    key={project.id}\n                    onClick={() => {\n                      handleProjectClick(project.id);\n                      setIsDrawerOpen(false);\n                    }}\n                    className={cn(\n                      'flex items-center gap-3 w-full px-3 py-2.5 rounded-md text-sm text-left cursor-pointer',\n                      'transition-colors',\n                      project.id === activeProjectId\n                        ? 'bg-brand/10 text-high'\n                        : 'text-normal hover:bg-secondary'\n                    )}\n                  >\n                    <span\n                      className=\"h-2.5 w-2.5 rounded-full shrink-0\"\n                      style={{ backgroundColor: `hsl(${project.color})` }}\n                    />\n                    <span className=\"truncate\">{project.name}</span>\n                  </button>\n                ))\n              ) : (\n                <div className=\"px-4 py-6 text-center\">\n                  <KanbanIcon\n                    className=\"h-8 w-8 mx-auto text-low\"\n                    weight=\"bold\"\n                  />\n                  <p className=\"mt-3 text-sm font-medium text-high\">\n                    Kanban Boards\n                  </p>\n                  <p className=\"mt-1 text-xs text-low\">\n                    Sign in to organise your coding agents with kanban boards.\n                  </p>\n                  <div className=\"mt-4 flex flex-col gap-2\">\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        handleSignIn();\n                        setIsDrawerOpen(false);\n                      }}\n                      className=\"w-full px-3 py-2 rounded-md text-sm font-medium bg-brand text-on-brand hover:bg-brand-hover cursor-pointer\"\n                    >\n                      Sign in\n                    </button>\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        handleMigrate();\n                        setIsDrawerOpen(false);\n                      }}\n                      className=\"w-full px-3 py-2 rounded-md text-sm text-normal bg-secondary hover:bg-panel border border-border cursor-pointer\"\n                    >\n                      Migrate old projects\n                    </button>\n                  </div>\n                </div>\n              )}\n            </div>\n\n            {/* Create Project button */}\n            {isSignedIn && (\n              <div className=\"p-3 border-t border-border\">\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    handleCreateProject();\n                    setIsDrawerOpen(false);\n                  }}\n                  className=\"flex items-center gap-2 w-full px-3 py-2.5 rounded-md text-sm text-low hover:text-normal hover:bg-secondary cursor-pointer\"\n                >\n                  <PlusIcon className=\"h-4 w-4\" />\n                  Create Project\n                </button>\n              </div>\n            )}\n          </div>\n        </MobileDrawer>\n      </div>\n    </SyncErrorProvider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/constants/processes.ts",
    "content": "import type { ExecutionProcessRunReason } from 'shared/types';\n\n// Process run reasons\nexport const PROCESS_RUN_REASONS = {\n  SETUP_SCRIPT: 'setupscript' as ExecutionProcessRunReason,\n  CLEANUP_SCRIPT: 'cleanupscript' as ExecutionProcessRunReason,\n  ARCHIVE_SCRIPT: 'archivescript' as ExecutionProcessRunReason,\n  CODING_AGENT: 'codingagent' as ExecutionProcessRunReason,\n  DEV_SERVER: 'devserver' as ExecutionProcessRunReason,\n} as const;\n\nexport const isCodingAgent = (\n  runReason: ExecutionProcessRunReason\n): boolean => {\n  return runReason === PROCESS_RUN_REASONS.CODING_AGENT;\n};\n\nexport const shouldShowInLogs = (\n  runReason: ExecutionProcessRunReason\n): boolean => {\n  return runReason !== PROCESS_RUN_REASONS.DEV_SERVER;\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/auth/GhCliSetupDialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal, getErrorMessage } from '@/shared/lib/modals';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { GhCliSetupError } from 'shared/types';\nimport { useRef, useState } from 'react';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { Loader2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\n\ninterface GhCliSetupDialogProps {\n  workspaceId: string;\n}\n\nexport type GhCliSupportVariant = 'homebrew' | 'manual';\n\nexport interface GhCliSupportContent {\n  message: string;\n  variant: GhCliSupportVariant | null;\n}\n\nexport const mapGhCliErrorToUi = (\n  error: GhCliSetupError | null,\n  fallbackMessage: string,\n  t: (key: string) => string\n): GhCliSupportContent => {\n  if (!error) {\n    return { message: fallbackMessage, variant: null };\n  }\n\n  if (error === 'BREW_MISSING') {\n    return {\n      message: t('settings:integrations.github.cliSetup.errors.brewMissing'),\n      variant: 'homebrew',\n    };\n  }\n\n  if (error === 'SETUP_HELPER_NOT_SUPPORTED') {\n    return {\n      message: t('settings:integrations.github.cliSetup.errors.notSupported'),\n      variant: 'manual',\n    };\n  }\n\n  if (typeof error === 'object' && 'OTHER' in error) {\n    return {\n      message: error.OTHER.message || fallbackMessage,\n      variant: null,\n    };\n  }\n\n  return { message: fallbackMessage, variant: null };\n};\n\nexport const GhCliHelpInstructions = ({\n  variant,\n  t,\n}: {\n  variant: GhCliSupportVariant;\n  t: (key: string) => string;\n}) => {\n  if (variant === 'homebrew') {\n    return (\n      <div className=\"space-y-2 text-sm\">\n        <p>\n          {t('settings:integrations.github.cliSetup.help.homebrew.description')}{' '}\n          <a\n            href=\"https://brew.sh/\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"underline\"\n          >\n            {t('settings:integrations.github.cliSetup.help.homebrew.brewSh')}\n          </a>{' '}\n          {t(\n            'settings:integrations.github.cliSetup.help.homebrew.manualInstall'\n          )}\n        </p>\n        <pre className=\"rounded bg-muted px-2 py-1 text-xs\">\n          brew install gh\n        </pre>\n        <p>\n          {t(\n            'settings:integrations.github.cliSetup.help.homebrew.afterInstall'\n          )}\n          <br />\n          <code className=\"rounded bg-muted px-1 py-0.5 text-xs\">\n            gh auth login --web --git-protocol https\n          </code>\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-2 text-sm\">\n      <p>\n        {t('settings:integrations.github.cliSetup.help.manual.description')}{' '}\n        <a\n          href=\"https://cli.github.com/\"\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          className=\"underline\"\n        >\n          {t('settings:integrations.github.cliSetup.help.manual.officialDocs')}\n        </a>{' '}\n        {t('settings:integrations.github.cliSetup.help.manual.andAuthenticate')}\n      </p>\n      <pre className=\"rounded bg-muted px-2 py-1 text-xs\">\n        gh auth login --web --git-protocol https\n      </pre>\n    </div>\n  );\n};\n\nconst GhCliSetupDialogImpl = create<GhCliSetupDialogProps>(\n  ({ workspaceId }) => {\n    const modal = useModal();\n    const { t } = useTranslation();\n    const [isRunning, setIsRunning] = useState(false);\n    const [errorInfo, setErrorInfo] = useState<{\n      error: GhCliSetupError;\n      message: string;\n      variant: GhCliSupportVariant | null;\n    } | null>(null);\n    const pendingResultRef = useRef<GhCliSetupError | null>(null);\n    const hasResolvedRef = useRef(false);\n\n    const handleRunSetup = async () => {\n      setIsRunning(true);\n      setErrorInfo(null);\n      pendingResultRef.current = null;\n\n      try {\n        await workspacesApi.setupGhCli(workspaceId);\n        hasResolvedRef.current = true;\n        modal.resolve(null);\n        modal.hide();\n      } catch (err: unknown) {\n        const rawMessage =\n          getErrorMessage(err) ||\n          t('settings:integrations.github.cliSetup.errors.setupFailed');\n\n        const maybeErrorData =\n          typeof err === 'object' && err !== null && 'error_data' in err\n            ? (err as { error_data?: unknown }).error_data\n            : undefined;\n\n        const isGhCliSetupError = (x: unknown): x is GhCliSetupError =>\n          x === 'BREW_MISSING' ||\n          x === 'SETUP_HELPER_NOT_SUPPORTED' ||\n          (typeof x === 'object' && x !== null && 'OTHER' in x);\n\n        const errorData = isGhCliSetupError(maybeErrorData)\n          ? maybeErrorData\n          : undefined;\n\n        const resolvedError: GhCliSetupError = errorData ?? {\n          OTHER: { message: rawMessage },\n        };\n        const ui = mapGhCliErrorToUi(resolvedError, rawMessage, t);\n\n        pendingResultRef.current = resolvedError;\n        setErrorInfo({\n          error: resolvedError,\n          message: ui.message,\n          variant: ui.variant,\n        });\n      } finally {\n        setIsRunning(false);\n      }\n    };\n\n    const handleClose = () => {\n      if (!hasResolvedRef.current) {\n        modal.resolve(pendingResultRef.current);\n      }\n      modal.hide();\n    };\n\n    return (\n      <Dialog\n        open={modal.visible}\n        onOpenChange={(open) => !open && handleClose()}\n      >\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>\n              {t('settings:integrations.github.cliSetup.title')}\n            </DialogTitle>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <p>{t('settings:integrations.github.cliSetup.description')}</p>\n\n            <div className=\"space-y-2\">\n              <p className=\"text-sm\">\n                {t('settings:integrations.github.cliSetup.setupWillTitle')}\n              </p>\n              <ol className=\"text-sm list-decimal list-inside space-y-1 ml-2\">\n                <li>\n                  {t(\n                    'settings:integrations.github.cliSetup.steps.checkInstalled'\n                  )}\n                </li>\n                <li>\n                  {t(\n                    'settings:integrations.github.cliSetup.steps.installHomebrew'\n                  )}\n                </li>\n                <li>\n                  {t(\n                    'settings:integrations.github.cliSetup.steps.authenticate'\n                  )}\n                </li>\n              </ol>\n              <p className=\"text-sm text-muted-foreground mt-4\">\n                {t('settings:integrations.github.cliSetup.setupNote')}\n              </p>\n            </div>\n            {errorInfo && (\n              <Alert variant=\"destructive\">\n                <AlertDescription className=\"space-y-2\">\n                  <p>{errorInfo.message}</p>\n                  {errorInfo.variant && (\n                    <GhCliHelpInstructions variant={errorInfo.variant} t={t} />\n                  )}\n                </AlertDescription>\n              </Alert>\n            )}\n          </div>\n          <DialogFooter>\n            <Button onClick={handleRunSetup} disabled={isRunning}>\n              {isRunning ? (\n                <>\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                  {t('settings:integrations.github.cliSetup.running')}\n                </>\n              ) : (\n                t('settings:integrations.github.cliSetup.runSetup')\n              )}\n            </Button>\n            <Button\n              variant=\"outline\"\n              onClick={handleClose}\n              disabled={isRunning}\n            >\n              {t('common:buttons.close')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const GhCliSetupDialog = defineModal<\n  GhCliSetupDialogProps,\n  GhCliSetupError | null\n>(GhCliSetupDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/BranchRebaseDialog.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { ChevronRight } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport BranchSelector from '@/shared/components/tasks/BranchSelector';\nimport type { GitBranch } from 'shared/types';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface BranchRebaseDialogProps {\n  branches: GitBranch[];\n  isRebasing?: boolean;\n  initialTargetBranch?: string;\n  initialUpstreamBranch?: string;\n}\n\nexport type BranchRebaseDialogResult = {\n  action: 'confirmed' | 'canceled';\n  branchName?: string;\n  upstreamBranch?: string;\n};\n\nconst BranchRebaseDialogImpl = create<BranchRebaseDialogProps>(\n  ({\n    branches,\n    isRebasing = false,\n    initialTargetBranch,\n    initialUpstreamBranch,\n  }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['tasks', 'common']);\n    const [selectedBranch, setSelectedBranch] = useState<string>(\n      initialTargetBranch ?? ''\n    );\n    const [selectedUpstream, setSelectedUpstream] = useState<string>(\n      initialUpstreamBranch ?? ''\n    );\n\n    useEffect(() => {\n      if (initialTargetBranch) {\n        setSelectedBranch(initialTargetBranch);\n      }\n    }, [initialTargetBranch]);\n\n    useEffect(() => {\n      if (initialUpstreamBranch) {\n        setSelectedUpstream(initialUpstreamBranch);\n      }\n    }, [initialUpstreamBranch]);\n\n    const [showAdvanced, setShowAdvanced] = useState(false);\n\n    const handleConfirm = () => {\n      if (selectedBranch) {\n        modal.resolve({\n          action: 'confirmed',\n          branchName: selectedBranch,\n          upstreamBranch: selectedUpstream,\n        } as BranchRebaseDialogResult);\n        modal.hide();\n      }\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as BranchRebaseDialogResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('rebase.dialog.title')}</DialogTitle>\n            <DialogDescription>\n              {t('rebase.dialog.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <label htmlFor=\"target-branch\" className=\"text-sm font-medium\">\n                {t('rebase.dialog.targetLabel')}\n              </label>\n              <BranchSelector\n                branches={branches}\n                selectedBranch={selectedBranch}\n                onBranchSelect={setSelectedBranch}\n                placeholder={t('rebase.dialog.targetPlaceholder')}\n                excludeCurrentBranch={false}\n              />\n            </div>\n            <div className=\"space-y-2\">\n              <button\n                type=\"button\"\n                onClick={() => setShowAdvanced((prev) => !prev)}\n                className=\"flex w-full items-center gap-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                <ChevronRight\n                  className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}\n                />\n                <span>{t('rebase.dialog.advanced')}</span>\n              </button>\n              {showAdvanced && (\n                <div className=\"space-y-2\">\n                  <label\n                    htmlFor=\"upstream-branch\"\n                    className=\"text-sm font-medium\"\n                  >\n                    {t('rebase.dialog.upstreamLabel')}\n                  </label>\n                  <BranchSelector\n                    branches={branches}\n                    selectedBranch={selectedUpstream}\n                    onBranchSelect={setSelectedUpstream}\n                    placeholder={t('rebase.dialog.upstreamPlaceholder')}\n                    excludeCurrentBranch={false}\n                  />\n                </div>\n              )}\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isRebasing}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              onClick={handleConfirm}\n              disabled={isRebasing || !selectedBranch}\n            >\n              {isRebasing\n                ? t('rebase.common.inProgress')\n                : t('rebase.common.action')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const BranchRebaseDialog = defineModal<\n  BranchRebaseDialogProps,\n  BranchRebaseDialogResult\n>(BranchRebaseDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/ChangeTargetBranchDialog.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport BranchSelector from '@/shared/components/tasks/BranchSelector';\nimport type { GitBranch } from 'shared/types';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface ChangeTargetBranchDialogProps {\n  branches: GitBranch[];\n  isChangingTargetBranch?: boolean;\n}\n\nexport type ChangeTargetBranchDialogResult = {\n  action: 'confirmed' | 'canceled';\n  branchName?: string;\n};\n\nconst ChangeTargetBranchDialogImpl = create<ChangeTargetBranchDialogProps>(\n  ({ branches, isChangingTargetBranch: isChangingTargetBranch = false }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['tasks', 'common']);\n    const [selectedBranch, setSelectedBranch] = useState<string>('');\n\n    const handleConfirm = () => {\n      if (selectedBranch) {\n        modal.resolve({\n          action: 'confirmed',\n          branchName: selectedBranch,\n        } as ChangeTargetBranchDialogResult);\n        modal.hide();\n      }\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as ChangeTargetBranchDialogResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('branches.changeTarget.dialog.title')}</DialogTitle>\n            <DialogDescription>\n              {t('branches.changeTarget.dialog.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <label htmlFor=\"base-branch\" className=\"text-sm font-medium\">\n                {t('rebase.dialog.targetLabel')}\n              </label>\n              <BranchSelector\n                branches={branches}\n                selectedBranch={selectedBranch}\n                onBranchSelect={setSelectedBranch}\n                placeholder={t('branches.changeTarget.dialog.placeholder')}\n                excludeCurrentBranch={false}\n              />\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isChangingTargetBranch}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              onClick={handleConfirm}\n              disabled={isChangingTargetBranch || !selectedBranch}\n            >\n              {isChangingTargetBranch\n                ? t('branches.changeTarget.dialog.inProgress')\n                : t('branches.changeTarget.dialog.action')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const ChangeTargetBranchDialog = defineModal<\n  ChangeTargetBranchDialogProps,\n  ChangeTargetBranchDialogResult\n>(ChangeTargetBranchDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/CommandBarDialog.tsx",
    "content": "import { useRef, useEffect, useCallback, useMemo } from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport type { Workspace } from 'shared/types';\nimport { defineModal } from '@/shared/lib/modals';\nimport { CommandDialog } from '@vibe/ui/components/Command';\nimport {\n  CommandBar,\n  type CommandBarGroupItem,\n} from '@vibe/ui/components/CommandBar';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { workspaceRecordKeys } from '@/shared/hooks/useWorkspaceRecord';\nimport { IdeIcon } from '@/shared/components/IdeIcon';\nimport type { PageId, ResolvedGroupItem } from '@/shared/types/commandBar';\nimport {\n  ActionTargetType,\n  type ActionDefinition,\n} from '@/shared/types/actions';\nimport { useActionVisibilityContext } from '@/shared/hooks/useActionVisibilityContext';\nimport type { SelectionPage } from './SelectionDialog';\nimport type { RepoSelectionResult } from './selections/repoSelection';\nimport { useCommandBarState } from './commandBar/useCommandBarState';\nimport { useResolvedPage } from './commandBar/useResolvedPage';\nimport { useIssueSelectionStore } from '@/shared/stores/useIssueSelectionStore';\n\nexport interface CommandBarDialogProps {\n  page?: PageId;\n  workspaceId?: string;\n  repoId?: string;\n  /** Issue context for kanban mode - projectId */\n  projectId?: string;\n  /** Issue context for kanban mode - selected issue IDs */\n  issueIds?: string[];\n}\n\nfunction CommandBarContent({\n  page,\n  workspaceId,\n  initialRepoId,\n  propProjectId,\n  propIssueIds,\n}: {\n  page: PageId;\n  workspaceId?: string;\n  initialRepoId?: string;\n  propProjectId?: string;\n  propIssueIds?: string[];\n}) {\n  const modal = useModal();\n  const previousFocusRef = useRef<HTMLElement | null>(null);\n  const queryClient = useQueryClient();\n  const { executeAction, getLabel } = useActions();\n  const { workspaceId: contextWorkspaceId, repos } = useWorkspaceContext();\n\n  // Get issue context from props, multi-selection store, or route params\n  const { projectId: routeProjectId, issueId: routeIssueId } = useParams({\n    strict: false,\n  });\n  const multiSelectedIssueIds = useIssueSelectionStore(\n    (s) => s.selectedIssueIds\n  );\n\n  // Effective issue context: props > multi-selection > route param\n  const effectiveProjectId = propProjectId ?? routeProjectId;\n  const effectiveIssueIds = useMemo(() => {\n    if (propIssueIds) return propIssueIds;\n    if (multiSelectedIssueIds.size > 0) return [...multiSelectedIssueIds];\n    return routeIssueId ? [routeIssueId] : [];\n  }, [propIssueIds, multiSelectedIssueIds, routeIssueId]);\n  const visibilityContext = useActionVisibilityContext({\n    projectId: effectiveProjectId,\n    issueIds: effectiveIssueIds,\n  });\n\n  const effectiveWorkspaceId = workspaceId ?? contextWorkspaceId;\n  const workspace = effectiveWorkspaceId\n    ? queryClient.getQueryData<Workspace>(\n        workspaceRecordKeys.byId(effectiveWorkspaceId)\n      )\n    : undefined;\n\n  // State machine\n  const { state, currentPage, canGoBack, dispatch } = useCommandBarState(page);\n\n  // Reset state and capture focus when dialog opens\n  useEffect(() => {\n    if (modal.visible) {\n      dispatch({ type: 'RESET', page });\n      previousFocusRef.current = document.activeElement as HTMLElement;\n    }\n  }, [modal.visible, page, dispatch]);\n\n  // Resolve current page to renderable data\n  const resolvedPage = useResolvedPage(\n    currentPage,\n    state.search,\n    visibilityContext,\n    workspace\n  );\n\n  // Handle item selection with side effects\n  const handleSelect = useCallback(\n    async (item: CommandBarGroupItem<ActionDefinition, PageId>) => {\n      const effect = dispatch({\n        type: 'SELECT_ITEM',\n        item: item as ResolvedGroupItem,\n      });\n      if (effect.type !== 'execute') return;\n\n      modal.hide();\n\n      if (effect.action.requiresTarget === ActionTargetType.ISSUE) {\n        executeAction(\n          effect.action,\n          undefined,\n          effectiveProjectId,\n          effectiveIssueIds\n        );\n      } else if (effect.action.requiresTarget === ActionTargetType.GIT) {\n        // Resolve repoId: use initialRepoId, single repo, or show selection dialog\n        let repoId: string | undefined = initialRepoId;\n        if (!repoId && repos.length === 1) {\n          repoId = repos[0].id;\n        } else if (!repoId && repos.length > 1) {\n          const { SelectionDialog } = await import('./SelectionDialog');\n          const { buildRepoSelectionPages } = await import(\n            './selections/repoSelection'\n          );\n          const result = await SelectionDialog.show({\n            initialPageId: 'selectRepo',\n            pages: buildRepoSelectionPages(repos) as Record<\n              string,\n              SelectionPage\n            >,\n          });\n          if (result && typeof result === 'object' && 'repoId' in result) {\n            repoId = (result as RepoSelectionResult).repoId;\n          }\n        }\n        if (repoId) {\n          executeAction(effect.action, effectiveWorkspaceId, repoId);\n        }\n      } else {\n        executeAction(effect.action, effectiveWorkspaceId);\n      }\n    },\n    [\n      dispatch,\n      modal,\n      executeAction,\n      effectiveWorkspaceId,\n      effectiveProjectId,\n      effectiveIssueIds,\n      repos,\n      initialRepoId,\n    ]\n  );\n\n  // Restore focus when dialog closes (unless another dialog has taken focus)\n  const handleCloseAutoFocus = useCallback((event: Event) => {\n    event.preventDefault();\n    // Don't restore focus if another dialog has taken over (e.g., action opened a new dialog)\n    const activeElement = document.activeElement;\n    const isInDialog = activeElement?.closest('[role=\"dialog\"]');\n    if (!isInDialog) {\n      previousFocusRef.current?.focus();\n    }\n  }, []);\n\n  return (\n    <CommandDialog\n      open={modal.visible}\n      onOpenChange={(open) => !open && modal.hide()}\n      onCloseAutoFocus={handleCloseAutoFocus}\n    >\n      <CommandBar\n        page={resolvedPage}\n        canGoBack={canGoBack}\n        onGoBack={() => dispatch({ type: 'GO_BACK' })}\n        onSelect={handleSelect}\n        getLabel={(action) => getLabel(action, workspace, visibilityContext)}\n        search={state.search}\n        onSearchChange={(query) => dispatch({ type: 'SEARCH_CHANGE', query })}\n        renderSpecialActionIcon={(iconName) =>\n          iconName === 'ide-icon' ? (\n            <IdeIcon\n              editorType={visibilityContext.editorType}\n              className=\"h-4 w-4\"\n            />\n          ) : null\n        }\n      />\n    </CommandDialog>\n  );\n}\n\nconst CommandBarDialogImpl = create<CommandBarDialogProps>(\n  ({\n    page = 'root',\n    workspaceId,\n    repoId: initialRepoId,\n    projectId: propProjectId,\n    issueIds: propIssueIds,\n  }) => (\n    <CommandBarContent\n      page={page}\n      workspaceId={workspaceId}\n      initialRepoId={initialRepoId}\n      propProjectId={propProjectId}\n      propIssueIds={propIssueIds}\n    />\n  )\n);\n\nexport const CommandBarDialog = defineModal<CommandBarDialogProps | void, void>(\n  CommandBarDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/CreatePRDialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Label } from '@radix-ui/react-label';\nimport { Textarea } from '@vibe/ui/components/Textarea';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport { Checkbox } from '@vibe/ui/components/Checkbox';\nimport { Alert, AlertDescription, AlertTitle } from '@vibe/ui/components/Alert';\nimport BranchSelector from '@/shared/components/tasks/BranchSelector';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { workspacesApi } from '@/shared/lib/api';\nimport { useTranslation } from 'react-i18next';\n\nimport { Workspace } from 'shared/types';\nimport { Loader2 } from 'lucide-react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useRepoBranches } from '@/shared/hooks/useRepoBranches';\nimport {\n  GhCliHelpInstructions,\n  GhCliSetupDialog,\n  mapGhCliErrorToUi,\n} from '@/shared/dialogs/auth/GhCliSetupDialog';\nimport type {\n  GhCliSupportContent,\n  GhCliSupportVariant,\n} from '@/shared/dialogs/auth/GhCliSetupDialog';\nimport type { GhCliSetupError } from 'shared/types';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { defineModal } from '@/shared/lib/modals';\nimport { splitMessageToTitleDescription } from '@/shared/lib/string';\n\ninterface CreatePRDialogProps {\n  attempt: Workspace;\n  repoId: string;\n  targetBranch?: string;\n  issueIdentifier?: string;\n}\n\nexport type CreatePRDialogResult = {\n  success: boolean;\n  error?: string;\n};\n\nconst PR_TITLE_SUFFIX = ' (vibe-kanban)';\n\nconst appendPrTitleSuffix = (title: string): string => {\n  const trimmedTitle = title.trim();\n  if (!trimmedTitle) return trimmedTitle;\n  if (trimmedTitle.endsWith(PR_TITLE_SUFFIX)) return trimmedTitle;\n  return `${trimmedTitle}${PR_TITLE_SUFFIX}`;\n};\n\nconst CreatePRDialogImpl = create<CreatePRDialogProps>(\n  ({ attempt, repoId, targetBranch, issueIdentifier }) => {\n    const modal = useModal();\n    const { t } = useTranslation('tasks');\n    const { isLoaded } = useAuth();\n    const { environment, config } = useUserSystem();\n    const [prTitle, setPrTitle] = useState('');\n    const [prBody, setPrBody] = useState('');\n    const [prBaseBranch, setPrBaseBranch] = useState('');\n    const [creatingPR, setCreatingPR] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n    const [ghCliHelp, setGhCliHelp] = useState<GhCliSupportContent | null>(\n      null\n    );\n    const [isDraft, setIsDraft] = useState(false);\n    const [autoGenerateDescription, setAutoGenerateDescription] = useState(\n      config?.pr_auto_description_enabled ?? false\n    );\n    const resetFormState = useCallback(() => {\n      setPrTitle('');\n      setPrBody('');\n      setPrBaseBranch('');\n      setCreatingPR(false);\n      setError(null);\n      setGhCliHelp(null);\n      setIsDraft(false);\n      setAutoGenerateDescription(config?.pr_auto_description_enabled ?? false);\n    }, [config?.pr_auto_description_enabled]);\n\n    const { data: branches = [], isLoading: branchesLoading } = useRepoBranches(\n      repoId,\n      { enabled: modal.visible && !!repoId }\n    );\n\n    useEffect(() => {\n      if (!modal.visible) {\n        resetFormState();\n      }\n    }, [modal.visible, resetFormState]);\n\n    const getGhCliHelpTitle = (variant: GhCliSupportVariant) =>\n      variant === 'homebrew'\n        ? 'Homebrew is required for automatic setup'\n        : 'GitHub CLI needs manual setup';\n\n    // Initialize form when dialog opens\n    useEffect(() => {\n      if (!modal.visible || !isLoaded) {\n        return;\n      }\n\n      let isCancelled = false;\n\n      const initializePRFields = async () => {\n        try {\n          const firstUserMessage = await workspacesApi.getFirstUserMessage(\n            attempt.id\n          );\n\n          if (isCancelled) return;\n\n          if (firstUserMessage?.trim()) {\n            const { title, description } =\n              splitMessageToTitleDescription(firstUserMessage);\n            setPrTitle(appendPrTitleSuffix(title));\n            setPrBody(description ?? '');\n            return;\n          }\n        } catch {\n          // Fall back to empty fields if prompt loading fails.\n        }\n\n        if (isCancelled) return;\n        setPrTitle('');\n        setPrBody('');\n      };\n\n      initializePRFields();\n      setError(null);\n      setGhCliHelp(null);\n\n      return () => {\n        isCancelled = true;\n      };\n    }, [attempt.id, modal.visible, isLoaded, issueIdentifier]);\n\n    // Set default base branch when branches are loaded\n    useEffect(() => {\n      if (!modal.visible || branches.length === 0) {\n        return;\n      }\n\n      const hasSelectedBranch = prBaseBranch\n        ? branches.some((branch) => branch.name === prBaseBranch)\n        : false;\n\n      if (hasSelectedBranch) {\n        return;\n      }\n\n      if (\n        targetBranch &&\n        branches.some((branch) => branch.name === targetBranch)\n      ) {\n        setPrBaseBranch(targetBranch);\n        return;\n      }\n\n      // Leave empty when target branch cannot be resolved; backend falls back to repo target branch.\n      setPrBaseBranch('');\n    }, [branches, modal.visible, prBaseBranch, targetBranch]);\n\n    const isMacEnvironment = useMemo(\n      () => environment?.os_type?.toLowerCase().includes('mac'),\n      [environment?.os_type]\n    );\n\n    const handleConfirmCreatePR = useCallback(async () => {\n      if (!repoId || !attempt.id) return;\n\n      setError(null);\n      setGhCliHelp(null);\n      setCreatingPR(true);\n\n      const handleGhCliSetupOutcome = (\n        setupResult: GhCliSetupError | null,\n        fallbackMessage: string\n      ) => {\n        if (setupResult === null) {\n          setError(null);\n          setGhCliHelp(null);\n          setCreatingPR(false);\n          modal.hide();\n          return;\n        }\n\n        const ui = mapGhCliErrorToUi(setupResult, fallbackMessage, t);\n\n        if (ui.variant) {\n          setGhCliHelp(ui);\n          setError(null);\n          return;\n        }\n\n        setGhCliHelp(null);\n        setError(ui.message);\n      };\n\n      const result = await workspacesApi.createPR(attempt.id, {\n        title: prTitle,\n        body: prBody || null,\n        target_branch: prBaseBranch || null,\n        draft: isDraft,\n        auto_generate_description: autoGenerateDescription,\n        repo_id: repoId,\n      });\n\n      if (result.success) {\n        setCreatingPR(false);\n        modal.resolve({ success: true } as CreatePRDialogResult);\n        modal.hide();\n        return;\n      }\n\n      setCreatingPR(false);\n\n      const defaultGhCliErrorMessage =\n        result.message || 'Failed to run GitHub CLI setup.';\n\n      const showGhCliSetupDialog = async () => {\n        const setupResult = await GhCliSetupDialog.show({\n          workspaceId: attempt.id,\n        });\n\n        handleGhCliSetupOutcome(setupResult, defaultGhCliErrorMessage);\n      };\n\n      if (result.error) {\n        if (\n          result.error.type === 'cli_not_installed' ||\n          result.error.type === 'cli_not_logged_in'\n        ) {\n          // Only show setup dialog for GitHub CLI on Mac\n          if (result.error.provider === 'git_hub' && isMacEnvironment) {\n            await showGhCliSetupDialog();\n          } else {\n            const providerName =\n              result.error.provider === 'git_hub'\n                ? 'GitHub'\n                : result.error.provider === 'azure_dev_ops'\n                  ? 'Azure DevOps'\n                  : 'Git host';\n            const action =\n              result.error.type === 'cli_not_installed'\n                ? 'not installed'\n                : 'not logged in';\n            setError(`${providerName} CLI is ${action}`);\n            setGhCliHelp(null);\n          }\n          return;\n        } else if (\n          result.error.type === 'git_cli_not_installed' ||\n          result.error.type === 'git_cli_not_logged_in'\n        ) {\n          const gitCliErrorKey =\n            result.error.type === 'git_cli_not_logged_in'\n              ? 'createPrDialog.errors.gitCliNotLoggedIn'\n              : 'createPrDialog.errors.gitCliNotInstalled';\n\n          setError(result.message || t(gitCliErrorKey));\n          setGhCliHelp(null);\n          return;\n        } else if (result.error.type === 'target_branch_not_found') {\n          setError(\n            t('createPrDialog.errors.targetBranchNotFound', {\n              branch: result.error.branch,\n            })\n          );\n          setGhCliHelp(null);\n          return;\n        }\n      }\n\n      if (result.message) {\n        setError(result.message);\n        setGhCliHelp(null);\n      } else {\n        setError(t('createPrDialog.errors.failedToCreate'));\n        setGhCliHelp(null);\n      }\n    }, [\n      attempt,\n      repoId,\n      prBaseBranch,\n      prBody,\n      prTitle,\n      isDraft,\n      autoGenerateDescription,\n      config?.pr_auto_description_enabled,\n      modal,\n      isMacEnvironment,\n      t,\n    ]);\n\n    const handleCancelCreatePR = useCallback(() => {\n      // Return error if one was set, otherwise just canceled\n      const result: CreatePRDialogResult = error\n        ? { success: false, error }\n        : { success: false };\n      modal.resolve(result);\n      modal.hide();\n    }, [modal, error]);\n\n    return (\n      <>\n        <Dialog\n          open={modal.visible}\n          onOpenChange={(open) => {\n            if (!open) {\n              handleCancelCreatePR();\n            }\n          }}\n        >\n          <DialogContent className=\"sm:max-w-[525px]\">\n            <DialogHeader>\n              <DialogTitle>{t('createPrDialog.title')}</DialogTitle>\n              <DialogDescription>\n                {t('createPrDialog.description')}\n              </DialogDescription>\n            </DialogHeader>\n            {!isLoaded ? (\n              <div className=\"flex justify-center py-8\">\n                <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n              </div>\n            ) : (\n              <div className=\"space-y-4 py-4\">\n                <div className=\"flex items-center space-x-2\">\n                  <Checkbox\n                    id=\"pr-auto-generate\"\n                    checked={autoGenerateDescription}\n                    onCheckedChange={setAutoGenerateDescription}\n                    className=\"h-5 w-5\"\n                  />\n                  <Label\n                    htmlFor=\"pr-auto-generate\"\n                    className=\"cursor-pointer text-sm\"\n                  >\n                    {t('createPrDialog.autoGenerateLabel')}\n                  </Label>\n                </div>\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"pr-title\">\n                    {t('createPrDialog.titleLabel')}\n                  </Label>\n                  <Input\n                    id=\"pr-title\"\n                    value={prTitle}\n                    onChange={(e) => setPrTitle(e.target.value)}\n                    placeholder={t('createPrDialog.titlePlaceholder')}\n                    disabled={autoGenerateDescription}\n                    className={\n                      autoGenerateDescription\n                        ? 'opacity-50 cursor-not-allowed'\n                        : ''\n                    }\n                  />\n                </div>\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"pr-body\">\n                    {t('createPrDialog.descriptionLabel')}\n                  </Label>\n                  <Textarea\n                    id=\"pr-body\"\n                    value={prBody}\n                    onChange={(e) => setPrBody(e.target.value)}\n                    placeholder={t('createPrDialog.descriptionPlaceholder')}\n                    rows={4}\n                    disabled={autoGenerateDescription}\n                    className={\n                      autoGenerateDescription\n                        ? 'opacity-50 cursor-not-allowed'\n                        : ''\n                    }\n                  />\n                </div>\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"pr-base\">\n                    {t('createPrDialog.baseBranchLabel')}\n                  </Label>\n                  <BranchSelector\n                    branches={branches}\n                    selectedBranch={prBaseBranch}\n                    onBranchSelect={setPrBaseBranch}\n                    placeholder={\n                      branchesLoading\n                        ? t('createPrDialog.loadingBranches')\n                        : t('createPrDialog.selectBaseBranch')\n                    }\n                    className={\n                      branchesLoading ? 'opacity-50 cursor-not-allowed' : ''\n                    }\n                  />\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <Checkbox\n                    id=\"pr-draft\"\n                    checked={isDraft}\n                    onCheckedChange={setIsDraft}\n                    className=\"h-5 w-5\"\n                  />\n                  <Label htmlFor=\"pr-draft\" className=\"cursor-pointer text-sm\">\n                    {t('createPrDialog.draftLabel')}\n                  </Label>\n                </div>\n                {ghCliHelp?.variant && (\n                  <Alert variant=\"default\">\n                    <AlertTitle>\n                      {getGhCliHelpTitle(ghCliHelp.variant)}\n                    </AlertTitle>\n                    <AlertDescription className=\"space-y-3\">\n                      <p>{ghCliHelp.message}</p>\n                      <GhCliHelpInstructions\n                        variant={ghCliHelp.variant}\n                        t={t}\n                      />\n                    </AlertDescription>\n                  </Alert>\n                )}\n                {error && <Alert variant=\"destructive\">{error}</Alert>}\n              </div>\n            )}\n            <DialogFooter>\n              <Button variant=\"outline\" onClick={handleCancelCreatePR}>\n                {t('common:buttons.cancel')}\n              </Button>\n              <Button\n                onClick={handleConfirmCreatePR}\n                disabled={creatingPR || !prTitle.trim()}\n                className=\"bg-blue-600 hover:bg-blue-700\"\n              >\n                {creatingPR ? (\n                  <>\n                    <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                    {t('createPrDialog.creating')}\n                  </>\n                ) : (\n                  t('createPrDialog.createButton')\n                )}\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      </>\n    );\n  }\n);\n\nexport const CreatePRDialog = defineModal<\n  CreatePRDialogProps,\n  CreatePRDialogResult\n>(CreatePRDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/CreateWorkspaceFromPrDialog.tsx",
    "content": "import { useState, useEffect, useContext, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { ArrowSquareOut } from '@phosphor-icons/react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Checkbox } from '@vibe/ui/components/Checkbox';\nimport { Label } from '@vibe/ui/components/Label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@vibe/ui/components/Select';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { workspacesApi, repoApi } from '@/shared/lib/api';\nimport { WorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { SearchableDropdownContainer } from '@/shared/components/ui-new/containers/SearchableDropdownContainer';\nimport type { OpenPrInfo, GitRemote } from 'shared/types';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\n\nexport interface CreateWorkspaceFromPrDialogProps {}\n\nconst CreateWorkspaceFromPrDialogImpl =\n  create<CreateWorkspaceFromPrDialogProps>(() => {\n    const modal = useModal();\n    const appNavigation = useAppNavigation();\n    const { t } = useTranslation('tasks');\n    const queryClient = useQueryClient();\n\n    const workspaceContext = useContext(WorkspaceContext);\n    const currentWorkspaceRepoId = workspaceContext?.repos[0]?.id ?? null;\n\n    const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);\n    const [selectedRemote, setSelectedRemote] = useState<string | null>(null);\n    const [selectedPrNumber, setSelectedPrNumber] = useState<number | null>(\n      null\n    );\n    const [runSetup, setRunSetup] = useState(true);\n\n    const { data: repos = [], isLoading: isLoadingRepos } = useQuery({\n      queryKey: ['repos'],\n      queryFn: () => repoApi.list(),\n      enabled: modal.visible,\n    });\n\n    useEffect(() => {\n      if (selectedRepoId) return;\n      if (\n        currentWorkspaceRepoId &&\n        repos.some((r) => r.id === currentWorkspaceRepoId)\n      ) {\n        setSelectedRepoId(currentWorkspaceRepoId);\n      } else if (repos.length === 1) {\n        setSelectedRepoId(repos[0].id);\n      }\n    }, [repos, selectedRepoId, currentWorkspaceRepoId]);\n\n    const { data: remotes = [], isLoading: isLoadingRemotes } = useQuery({\n      queryKey: ['repo-remotes', selectedRepoId],\n      queryFn: async () => {\n        if (!selectedRepoId) return [];\n        return repoApi.listRemotes(selectedRepoId);\n      },\n      enabled: modal.visible && !!selectedRepoId,\n    });\n\n    useEffect(() => {\n      if (remotes.length > 0 && !selectedRemote) {\n        setSelectedRemote(remotes[0].name);\n      }\n    }, [remotes, selectedRemote]);\n\n    const {\n      data: prsResult,\n      isLoading: isLoadingPrs,\n      error: prsError,\n    } = useQuery({\n      queryKey: ['open-prs', selectedRepoId, selectedRemote],\n      queryFn: async () => {\n        if (!selectedRepoId || !selectedRemote) return null;\n        return repoApi.listOpenPrs(selectedRepoId, selectedRemote);\n      },\n      enabled: modal.visible && !!selectedRepoId && !!selectedRemote,\n    });\n\n    const openPrs = useMemo<OpenPrInfo[]>(\n      () => (prsResult?.success === true ? prsResult.data : []),\n      [prsResult]\n    );\n\n    const selectedPr = useMemo(\n      () =>\n        openPrs.find((pr) => Number(pr.number) === selectedPrNumber) ?? null,\n      [openPrs, selectedPrNumber]\n    );\n\n    let prsErrorMessage: string | null = null;\n    if (prsResult?.success === false) {\n      switch (prsResult.error?.type) {\n        case 'cli_not_installed':\n          prsErrorMessage = t('createWorkspaceFromPr.errors.cliNotInstalled', {\n            provider: prsResult.error.provider,\n          });\n          break;\n        case 'auth_failed':\n          prsErrorMessage = prsResult.error.message;\n          break;\n        case 'unsupported_provider':\n          prsErrorMessage = t(\n            'createWorkspaceFromPr.errors.unsupportedProvider'\n          );\n          break;\n        default:\n          prsErrorMessage =\n            prsResult.message ||\n            t('createWorkspaceFromPr.errors.failedToLoadPrs');\n      }\n    } else if (prsError) {\n      prsErrorMessage = t('createWorkspaceFromPr.errors.failedToLoadPrs');\n    }\n\n    const createMutation = useMutation({\n      mutationFn: async () => {\n        if (\n          !selectedRepoId ||\n          !selectedPrNumber ||\n          !selectedRemote ||\n          !selectedPr\n        ) {\n          throw new Error('Missing required fields');\n        }\n        const result = await workspacesApi.createFromPr({\n          repo_id: selectedRepoId,\n          pr_number: selectedPrNumber as unknown as bigint,\n          pr_title: selectedPr.title,\n          pr_url: selectedPr.url,\n          head_branch: selectedPr.head_branch,\n          base_branch: selectedPr.base_branch,\n          run_setup: runSetup,\n          remote_name: selectedRemote,\n        });\n        if (!result.success) {\n          switch (result.error?.type) {\n            case 'branch_fetch_failed':\n              throw new Error(result.error.message);\n            case 'auth_failed':\n              throw new Error(result.error.message);\n            case 'cli_not_installed':\n              throw new Error(\n                t('createWorkspaceFromPr.errors.cliNotInstalled', {\n                  provider: result.error.provider,\n                })\n              );\n            case 'pr_not_found':\n              throw new Error(t('createWorkspaceFromPr.errors.prNotFound'));\n            case 'unsupported_provider':\n              throw new Error(\n                t('createWorkspaceFromPr.errors.unsupportedProvider')\n              );\n            default:\n              throw new Error(\n                result.message ||\n                  t('createWorkspaceFromPr.errors.failedToCreateWorkspace')\n              );\n          }\n        }\n        return result.data;\n      },\n      onSuccess: (data) => {\n        queryClient.invalidateQueries({ queryKey: ['tasks'] });\n        queryClient.invalidateQueries({ queryKey: ['workspaces'] });\n        modal.hide();\n        appNavigation.goToWorkspace(data.workspace.id);\n      },\n    });\n\n    useEffect(() => {\n      if (!modal.visible) {\n        setSelectedRepoId(null);\n        setSelectedRemote(null);\n        setSelectedPrNumber(null);\n        setRunSetup(true);\n      }\n    }, [modal.visible]);\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) modal.hide();\n    };\n\n    const canCreate =\n      selectedRepoId &&\n      selectedRemote &&\n      selectedPrNumber &&\n      !createMutation.isPending &&\n      !isLoadingPrs;\n\n    const handleCreate = () => {\n      if (canCreate) {\n        createMutation.mutate();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-[500px]\">\n          <DialogHeader>\n            <DialogTitle>{t('createWorkspaceFromPr.title')}</DialogTitle>\n            <DialogDescription>\n              {t('createWorkspaceFromPr.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4 py-4\">\n            <div className=\"space-y-2\">\n              <Label>{t('createWorkspaceFromPr.repositoryLabel')}</Label>\n              {isLoadingRepos ? (\n                <div className=\"text-sm text-muted-foreground\">\n                  {t('createWorkspaceFromPr.loadingRepositories')}\n                </div>\n              ) : repos.length === 0 ? (\n                <div className=\"text-sm text-muted-foreground\">\n                  {t('createWorkspaceFromPr.noRepositoriesFound')}\n                </div>\n              ) : (\n                <Select\n                  value={selectedRepoId ?? undefined}\n                  onValueChange={(value) => {\n                    setSelectedRepoId(value);\n                    setSelectedRemote(null);\n                    setSelectedPrNumber(null);\n                  }}\n                >\n                  <SelectTrigger>\n                    <SelectValue\n                      placeholder={t('createWorkspaceFromPr.selectRepository')}\n                    />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {repos.map((repo) => (\n                      <SelectItem key={repo.id} value={repo.id}>\n                        {repo.display_name || repo.name}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              )}\n            </div>\n\n            {selectedRepoId && remotes.length > 1 && (\n              <div className=\"space-y-2\">\n                <Label>{t('createWorkspaceFromPr.remoteLabel')}</Label>\n                {isLoadingRemotes ? (\n                  <div className=\"text-sm text-muted-foreground\">\n                    {t('createWorkspaceFromPr.loadingRemotes')}\n                  </div>\n                ) : (\n                  <Select\n                    value={selectedRemote ?? undefined}\n                    onValueChange={(value) => {\n                      setSelectedRemote(value);\n                      setSelectedPrNumber(null);\n                    }}\n                  >\n                    <SelectTrigger>\n                      <SelectValue\n                        placeholder={t('createWorkspaceFromPr.selectRemote')}\n                      />\n                    </SelectTrigger>\n                    <SelectContent>\n                      {remotes.map((remote: GitRemote, index: number) => (\n                        <SelectItem key={remote.name} value={remote.name}>\n                          {remote.name}\n                          {index === 0 &&\n                            ` (${t('createWorkspaceFromPr.default')})`}\n                        </SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                )}\n              </div>\n            )}\n\n            <div className=\"space-y-2\">\n              <Label>{t('createWorkspaceFromPr.pullRequestLabel')}</Label>\n              {isLoadingPrs || isLoadingRemotes ? (\n                <div className=\"text-sm text-muted-foreground\">\n                  {t('createWorkspaceFromPr.loadingPullRequests')}\n                </div>\n              ) : prsErrorMessage ? (\n                <div className=\"text-sm text-destructive\">\n                  {prsErrorMessage}\n                </div>\n              ) : !selectedRepoId ? (\n                <div className=\"text-sm text-muted-foreground\">\n                  {t('createWorkspaceFromPr.selectRepositoryFirst')}\n                </div>\n              ) : openPrs.length === 0 ? (\n                <div className=\"text-sm text-muted-foreground\">\n                  {t('createWorkspaceFromPr.noPullRequestsFound')}\n                </div>\n              ) : (\n                <div className=\"flex items-center gap-2\">\n                  <SearchableDropdownContainer\n                    items={openPrs}\n                    selectedValue={selectedPrNumber?.toString() ?? null}\n                    getItemKey={(pr) => String(pr.number)}\n                    getItemLabel={(pr) => `#${pr.number}: ${pr.title}`}\n                    filterItem={(pr, query) =>\n                      String(pr.number).includes(query) ||\n                      pr.title.toLowerCase().includes(query)\n                    }\n                    onSelect={(pr) => setSelectedPrNumber(Number(pr.number))}\n                    trigger={\n                      <Button\n                        variant=\"outline\"\n                        className=\"flex-1 justify-start font-normal min-w-0\"\n                      >\n                        <span className=\"truncate\">\n                          {selectedPr\n                            ? `#${selectedPr.number}: ${selectedPr.title}`\n                            : t('createWorkspaceFromPr.selectPullRequest')}\n                        </span>\n                      </Button>\n                    }\n                    contentClassName=\"w-[400px]\"\n                    placeholder={t(\n                      'createWorkspaceFromPr.searchPrsPlaceholder'\n                    )}\n                    emptyMessage={t('createWorkspaceFromPr.noMatchingPrs')}\n                    getItemBadge={null}\n                    getItemIcon={null}\n                  />\n                  {selectedPr && (\n                    <a\n                      href={selectedPr.url}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"flex-shrink-0 p-2 text-muted-foreground hover:text-foreground transition-colors\"\n                      title={t('createWorkspaceFromPr.openPrInBrowser')}\n                    >\n                      <ArrowSquareOut className=\"size-4\" />\n                    </a>\n                  )}\n                </div>\n              )}\n            </div>\n\n            <div className=\"flex items-center space-x-2\">\n              <Checkbox\n                id=\"run-setup\"\n                checked={runSetup}\n                onCheckedChange={(checked) => setRunSetup(checked === true)}\n              />\n              <Label htmlFor=\"run-setup\">\n                {t('createWorkspaceFromPr.runSetupScript')}\n              </Label>\n            </div>\n\n            {createMutation.error && (\n              <div className=\"text-sm text-destructive\">\n                {createMutation.error.message}\n              </div>\n            )}\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => modal.hide()}\n              disabled={createMutation.isPending}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button onClick={handleCreate} disabled={!canCreate}>\n              {createMutation.isPending\n                ? t('createWorkspaceFromPr.creating')\n                : t('createWorkspaceFromPr.createWorkspace')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  });\n\nexport const CreateWorkspaceFromPrDialog = defineModal<\n  CreateWorkspaceFromPrDialogProps,\n  void\n>(CreateWorkspaceFromPrDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/EditBranchNameDialog.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal, getErrorMessage } from '@/shared/lib/modals';\nimport { useRenameBranch } from '@/shared/hooks/useRenameBranch';\n\nexport interface EditBranchNameDialogProps {\n  workspaceId: string;\n  currentBranchName: string;\n}\n\nexport type EditBranchNameDialogResult = {\n  action: 'confirmed' | 'canceled';\n  branchName?: string;\n};\n\nconst EditBranchNameDialogImpl = create<EditBranchNameDialogProps>(\n  ({ workspaceId, currentBranchName }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['tasks', 'common']);\n    const [branchName, setBranchName] = useState<string>(currentBranchName);\n    const [error, setError] = useState<string | null>(null);\n\n    useEffect(() => {\n      setBranchName(currentBranchName);\n      setError(null);\n    }, [currentBranchName]);\n\n    const renameMutation = useRenameBranch(\n      workspaceId,\n      (newBranch) => {\n        modal.resolve({\n          action: 'confirmed',\n          branchName: newBranch,\n        } as EditBranchNameDialogResult);\n        modal.hide();\n      },\n      (err: unknown) => {\n        setError(getErrorMessage(err) || 'Failed to rename branch');\n      }\n    );\n\n    const handleConfirm = () => {\n      const trimmedName = branchName.trim();\n\n      if (!trimmedName) {\n        setError('Branch name cannot be empty');\n        return;\n      }\n\n      if (trimmedName === currentBranchName) {\n        modal.resolve({ action: 'canceled' } as EditBranchNameDialogResult);\n        modal.hide();\n        return;\n      }\n\n      if (trimmedName.includes(' ')) {\n        setError('Branch name cannot contain spaces');\n        return;\n      }\n\n      setError(null);\n      renameMutation.mutate(trimmedName);\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as EditBranchNameDialogResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('editBranchName.dialog.title')}</DialogTitle>\n            <DialogDescription>\n              {t('editBranchName.dialog.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <label htmlFor=\"branch-name\" className=\"text-sm font-medium\">\n                {t('editBranchName.dialog.branchNameLabel')}\n              </label>\n              <Input\n                id=\"branch-name\"\n                type=\"text\"\n                value={branchName}\n                onChange={(e) => {\n                  setBranchName(e.target.value);\n                  setError(null);\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter' && !renameMutation.isPending) {\n                    handleConfirm();\n                  }\n                }}\n                placeholder={t('editBranchName.dialog.placeholder')}\n                disabled={renameMutation.isPending}\n                autoFocus\n              />\n              {error && <p className=\"text-sm text-destructive\">{error}</p>}\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={renameMutation.isPending}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              onClick={handleConfirm}\n              disabled={renameMutation.isPending || !branchName.trim()}\n            >\n              {renameMutation.isPending\n                ? t('editBranchName.dialog.renaming')\n                : t('editBranchName.dialog.action')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const EditBranchNameDialog = defineModal<\n  EditBranchNameDialogProps,\n  EditBranchNameDialogResult\n>(EditBranchNameDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/EditorSelectionDialog.tsx",
    "content": "import { useState } from 'react';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@vibe/ui/components/Select';\nimport { EditorType } from 'shared/types';\nimport { useOpenInEditor } from '@/shared/hooks/useOpenInEditor';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface EditorSelectionDialogProps {\n  selectedAttemptId?: string;\n  filePath?: string;\n}\n\nconst EditorSelectionDialogImpl = create<EditorSelectionDialogProps>(\n  ({ selectedAttemptId, filePath }) => {\n    const modal = useModal();\n    const handleOpenInEditor = useOpenInEditor(selectedAttemptId, () =>\n      modal.hide()\n    );\n    const [selectedEditor, setSelectedEditor] = useState<EditorType>(\n      EditorType.VS_CODE\n    );\n\n    const handleConfirm = () => {\n      handleOpenInEditor({ editorType: selectedEditor, filePath });\n      modal.resolve(selectedEditor);\n      modal.hide();\n    };\n\n    const handleCancel = () => {\n      modal.resolve(null);\n      modal.hide();\n    };\n\n    return (\n      <Dialog\n        open={modal.visible}\n        onOpenChange={(open) => !open && handleCancel()}\n      >\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>Choose Editor</DialogTitle>\n            <DialogDescription>\n              The default editor failed to open. Please select an alternative\n              editor to open the task worktree.\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"grid gap-4 py-4\">\n            <div className=\"space-y-2\">\n              <label className=\"text-sm font-medium\">Editor</label>\n              <Select\n                value={selectedEditor}\n                onValueChange={(value) =>\n                  setSelectedEditor(value as EditorType)\n                }\n              >\n                <SelectTrigger>\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {Object.values(EditorType).map((editor) => (\n                    <SelectItem key={editor} value={editor}>\n                      {editor}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={handleCancel}>\n              Cancel\n            </Button>\n            <Button onClick={handleConfirm}>Open Editor</Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const EditorSelectionDialog = defineModal<\n  EditorSelectionDialogProps,\n  EditorType | null\n>(EditorSelectionDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/ForcePushDialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { AlertTriangle, Loader2 } from 'lucide-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { useForcePush } from '@/shared/hooks/useForcePush';\nimport { useState } from 'react';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { useTranslation } from 'react-i18next';\n\nexport interface ForcePushDialogProps {\n  workspaceId: string;\n  repoId: string;\n  branchName?: string;\n}\n\nconst ForcePushDialogImpl = create<ForcePushDialogProps>((props) => {\n  const modal = useModal();\n  const { workspaceId, repoId, branchName } = props;\n  const [error, setError] = useState<string | null>(null);\n  const { t } = useTranslation(['tasks', 'common']);\n  const branchLabel = branchName ? ` \"${branchName}\"` : '';\n\n  const forcePush = useForcePush(\n    workspaceId,\n    () => {\n      // Success - close dialog\n      modal.resolve('success');\n      modal.hide();\n    },\n    (err: unknown) => {\n      // Error - show in dialog and keep open\n      const message =\n        err && typeof err === 'object' && 'message' in err\n          ? String(err.message)\n          : t('tasks:git.forcePushDialog.error');\n      setError(message);\n    }\n  );\n\n  const handleConfirm = async () => {\n    setError(null);\n    try {\n      await forcePush.mutateAsync({ repo_id: repoId });\n    } catch {\n      // Error already handled by onError callback\n    }\n  };\n\n  const handleCancel = () => {\n    modal.resolve('canceled');\n    modal.hide();\n  };\n\n  const isProcessing = forcePush.isPending;\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleCancel}>\n      <DialogContent className=\"sm:max-w-[500px]\">\n        <DialogHeader>\n          <div className=\"flex items-center gap-3\">\n            <AlertTriangle className=\"h-6 w-6 text-destructive\" />\n            <DialogTitle>{t('tasks:git.forcePushDialog.title')}</DialogTitle>\n          </div>\n          <DialogDescription className=\"text-left pt-2 space-y-2\">\n            <p>{t('tasks:git.forcePushDialog.description', { branchLabel })}</p>\n            <p className=\"font-medium\">\n              {t('tasks:git.forcePushDialog.warning')}\n            </p>\n            <p className=\"text-sm text-muted-foreground\">\n              {t('tasks:git.forcePushDialog.note')}\n            </p>\n          </DialogDescription>\n        </DialogHeader>\n        {error && (\n          <Alert variant=\"destructive\">\n            <AlertDescription>{error}</AlertDescription>\n          </Alert>\n        )}\n        <DialogFooter className=\"gap-2\">\n          <Button\n            variant=\"outline\"\n            onClick={handleCancel}\n            disabled={isProcessing}\n          >\n            {t('common:buttons.cancel')}\n          </Button>\n          <Button\n            variant=\"destructive\"\n            onClick={handleConfirm}\n            disabled={isProcessing}\n          >\n            {isProcessing && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n            {isProcessing\n              ? t('tasks:git.states.forcePushing')\n              : t('tasks:git.states.forcePush')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const ForcePushDialog = defineModal<ForcePushDialogProps, string>(\n  ForcePushDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/GitActionsDialog.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { ExternalLink, GitPullRequest } from 'lucide-react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Loader } from '@vibe/ui/components/Loader';\nimport GitOperations from '@/shared/components/tasks/Toolbar/GitOperations';\nimport { useWorkspaceWithSession } from '@/shared/hooks/useWorkspace';\nimport { useBranchStatus } from '@/shared/hooks/useBranchStatus';\nimport { useWorkspaceExecution } from '@/shared/hooks/useWorkspaceExecution';\nimport { useWorkspaceRepo } from '@/shared/hooks/useWorkspaceRepo';\nimport { ExecutionProcessesProvider } from '@/shared/providers/ExecutionProcessesProvider';\nimport {\n  GitOperationsProvider,\n  useGitOperationsError,\n} from '@/shared/hooks/GitOperationsContext';\nimport type { Merge } from 'shared/types';\nimport type { WorkspaceWithSession } from '@/shared/types/attempt';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface GitActionsDialogProps {\n  workspaceId: string;\n}\n\ninterface GitActionsDialogContentProps {\n  attempt: WorkspaceWithSession;\n}\n\nfunction GitActionsDialogContent({ attempt }: GitActionsDialogContentProps) {\n  const { t } = useTranslation('tasks');\n  const { data: branchStatus, error: branchStatusError } = useBranchStatus(\n    attempt.id\n  );\n  const { isAttemptRunning } = useWorkspaceExecution(attempt.id);\n  const { error: gitError } = useGitOperationsError();\n  const { repos, selectedRepoId } = useWorkspaceRepo(attempt.id);\n\n  const getSelectedRepoStatus = () => {\n    const repoId = selectedRepoId ?? repos[0]?.id;\n    return branchStatus?.find((r) => r.repo_id === repoId);\n  };\n\n  const mergedPR = getSelectedRepoStatus()?.merges?.find(\n    (m: Merge) => m.type === 'pr' && m.pr_info?.status === 'merged'\n  );\n\n  return (\n    <div className=\"space-y-4\">\n      {mergedPR && mergedPR.type === 'pr' && (\n        <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n          <span>\n            {t('git.actions.prMerged', {\n              number: mergedPR.pr_info.number || '',\n            })}\n          </span>\n          {mergedPR.pr_info.url && (\n            <a\n              href={mergedPR.pr_info.url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-1 text-primary hover:underline\"\n            >\n              <GitPullRequest className=\"h-3.5 w-3.5\" />\n              {t('git.pr.number', {\n                number: Number(mergedPR.pr_info.number),\n              })}\n              <ExternalLink className=\"h-3.5 w-3.5\" />\n            </a>\n          )}\n        </div>\n      )}\n      {gitError && (\n        <div className=\"p-3 border border-destructive rounded text-destructive text-sm\">\n          {gitError}\n        </div>\n      )}\n      <GitOperations\n        selectedAttempt={attempt}\n        branchStatus={branchStatus ?? null}\n        branchStatusError={branchStatusError}\n        isAttemptRunning={isAttemptRunning}\n        selectedBranch={getSelectedRepoStatus()?.target_branch_name ?? null}\n        layout=\"vertical\"\n      />\n    </div>\n  );\n}\n\nconst GitActionsDialogImpl = create<GitActionsDialogProps>(\n  ({ workspaceId }) => {\n    const modal = useModal();\n    const { t } = useTranslation('tasks');\n\n    const { data: attempt } = useWorkspaceWithSession(workspaceId);\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        modal.hide();\n      }\n    };\n\n    const isLoading = !attempt;\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-2xl\">\n          <DialogHeader>\n            <DialogTitle>{t('git.actions.title')}</DialogTitle>\n          </DialogHeader>\n\n          {isLoading ? (\n            <div className=\"py-8\">\n              <Loader size={24} />\n            </div>\n          ) : (\n            <GitOperationsProvider workspaceId={attempt.id}>\n              <ExecutionProcessesProvider\n                key={attempt.id}\n                sessionId={attempt.session?.id}\n              >\n                <GitActionsDialogContent attempt={attempt} />\n              </ExecutionProcessesProvider>\n            </GitOperationsProvider>\n          )}\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const GitActionsDialog = defineModal<GitActionsDialogProps, void>(\n  GitActionsDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/RebaseDialog.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { CaretRightIcon, SpinnerIcon } from '@phosphor-icons/react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport BranchSelector from '@/shared/components/tasks/BranchSelector';\nimport type { GitOperationError } from 'shared/types';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { GitOperationsProvider } from '@/shared/hooks/GitOperationsContext';\nimport { useGitOperations } from '@/shared/hooks/useGitOperations';\nimport { useWorkspaceRecord } from '@/shared/hooks/useWorkspaceRecord';\nimport { useRepoBranches } from '@/shared/hooks/useRepoBranches';\nimport {\n  useWorkspaceRepo,\n  workspaceRepoKeys,\n} from '@/shared/hooks/useWorkspaceRepo';\nimport { useBranchStatus } from '@/shared/hooks/useBranchStatus';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useWorkspaces } from '@/shared/hooks/useWorkspaces';\nimport { workspacesApi, type Result } from '@/shared/lib/api';\nimport { ResolveConflictsDialog } from '@/shared/dialogs/tasks/ResolveConflictsDialog';\nimport { RebaseInProgressDialog } from '@vibe/ui/components/RebaseInProgressDialog';\n\nexport interface RebaseDialogProps {\n  workspaceId: string;\n  repoId: string;\n}\n\ninterface RebaseDialogContentProps {\n  workspaceId: string;\n  repoId: string;\n}\n\nfunction RebaseDialogContent({\n  workspaceId,\n  repoId,\n}: RebaseDialogContentProps) {\n  const modal = useModal();\n  const queryClient = useQueryClient();\n  const { t } = useTranslation(['tasks', 'common']);\n  const [selectedBranch, setSelectedBranch] = useState<string>('');\n  const [selectedUpstream, setSelectedUpstream] = useState<string>('');\n  const [showAdvanced, setShowAdvanced] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [hasInitializedBranches, setHasInitializedBranches] = useState(false);\n\n  const git = useGitOperations(workspaceId, repoId);\n  const { data: workspace } = useWorkspaceRecord(workspaceId);\n  const { workspaceId: activeWorkspaceId } = useWorkspaceContext();\n  const { workspaces } = useWorkspaces();\n  const isWorkspaceRunning =\n    workspaces.find((w) => w.id === workspaceId)?.isRunning ?? false;\n\n  // Load branches and repo data internally\n  const { data: branches = [], isLoading: branchesLoading } =\n    useRepoBranches(repoId);\n  const { repos, isLoading: reposLoading } = useWorkspaceRepo(workspaceId);\n  const { data: branchStatus, isLoading: branchStatusLoading } =\n    useBranchStatus(workspaceId);\n\n  const repo = repos.find((r) => r.id === repoId);\n  const repoStatus = branchStatus?.find((s) => s.repo_id === repoId);\n  const initialTargetBranch = repo?.target_branch;\n\n  const isInitialLoading =\n    branchesLoading || reposLoading || branchStatusLoading;\n\n  // Check for existing conflicts and rebase state\n  const isRebaseInProgress = repoStatus?.is_rebase_in_progress ?? false;\n  const hasConflictedFiles = (repoStatus?.conflicted_files?.length ?? 0) > 0;\n\n  const invalidateRebaseQueries = useCallback(async () => {\n    await Promise.all([\n      queryClient.invalidateQueries({\n        queryKey: ['branchStatus', workspaceId],\n      }),\n      queryClient.invalidateQueries({\n        queryKey: workspaceRepoKeys.byWorkspace(workspaceId),\n      }),\n    ]);\n  }, [queryClient, workspaceId]);\n\n  const continueRebaseInProgress = useCallback(async () => {\n    await workspacesApi.continueRebase(workspaceId, { repo_id: repoId });\n    await invalidateRebaseQueries();\n  }, [workspaceId, repoId, invalidateRebaseQueries]);\n\n  const abortRebaseInProgress = useCallback(async () => {\n    await workspacesApi.abortConflicts(workspaceId, { repo_id: repoId });\n    await invalidateRebaseQueries();\n  }, [workspaceId, repoId, invalidateRebaseQueries]);\n\n  // Prevent the redirect useEffect from firing more than once. Without this,\n  // every 5-second branchStatus poll that still returns conflicts would\n  // re-open the ResolveConflictsDialog (e.g. during long multi-commit rebases).\n  const hasRedirectedRef = useRef(false);\n\n  // If rebase is in progress, redirect to the appropriate dialog\n  // Only show if the user is still viewing this workspace\n  useEffect(() => {\n    if (\n      !isInitialLoading &&\n      (isRebaseInProgress || hasConflictedFiles) &&\n      repoStatus &&\n      !hasRedirectedRef.current\n    ) {\n      hasRedirectedRef.current = true;\n      modal.hide();\n\n      // Don't show the dialog if the user switched away or if\n      // a process is already running (e.g. resolving these conflicts).\n      if (activeWorkspaceId !== workspaceId || isWorkspaceRunning) return;\n\n      if (hasConflictedFiles) {\n        // Rebase in progress WITH conflicts -> show resolve conflicts dialog\n        ResolveConflictsDialog.show({\n          workspaceId: workspaceId,\n          conflictOp: repoStatus.conflict_op ?? 'rebase',\n          sourceBranch: workspace?.branch ?? null,\n          targetBranch: repoStatus.target_branch_name,\n          conflictedFiles: repoStatus.conflicted_files ?? [],\n          repoName: repoStatus.repo_name,\n        });\n      } else {\n        // Rebase in progress WITHOUT conflicts -> show simpler dialog\n        RebaseInProgressDialog.show({\n          targetBranch: repoStatus.target_branch_name,\n          onContinue: continueRebaseInProgress,\n          onAbort: abortRebaseInProgress,\n        });\n      }\n    }\n  }, [\n    isInitialLoading,\n    isRebaseInProgress,\n    hasConflictedFiles,\n    repoStatus,\n    workspaceId,\n    repoId,\n    workspace?.branch,\n    modal,\n    activeWorkspaceId,\n    isWorkspaceRunning,\n    continueRebaseInProgress,\n    abortRebaseInProgress,\n  ]);\n\n  // Reset initialization flag when workspaceId or repoId changes\n  useEffect(() => {\n    setHasInitializedBranches(false);\n  }, [workspaceId, repoId]);\n\n  // Initialize branch selection once data is loaded\n  useEffect(() => {\n    if (!hasInitializedBranches && initialTargetBranch && !isInitialLoading) {\n      setSelectedBranch(initialTargetBranch);\n      setSelectedUpstream(initialTargetBranch);\n      setHasInitializedBranches(true);\n    }\n  }, [initialTargetBranch, isInitialLoading, hasInitializedBranches]);\n\n  const handleConfirm = async () => {\n    if (!selectedBranch) return;\n\n    setError(null);\n    try {\n      await git.actions.rebase({\n        repoId,\n        newBaseBranch: selectedBranch,\n        oldBaseBranch: selectedUpstream,\n      });\n      modal.hide();\n    } catch (err) {\n      // Check if this is a conflict error (Result type with success=false)\n      const resultErr = err as Result<void, GitOperationError> | undefined;\n      const errorData =\n        resultErr && !resultErr.success ? resultErr.error : undefined;\n\n      if (errorData?.type === 'merge_conflicts') {\n        // Hide this dialog and show the resolve conflicts dialog\n        // Only show if the user is still viewing this workspace\n        modal.hide();\n        if (activeWorkspaceId === workspaceId) {\n          await ResolveConflictsDialog.show({\n            workspaceId: workspaceId,\n            conflictOp: errorData.op,\n            sourceBranch: workspace?.branch ?? null,\n            targetBranch: errorData.target_branch,\n            conflictedFiles: errorData.conflicted_files,\n            repoName: undefined,\n          });\n        }\n        return;\n      }\n\n      if (errorData?.type === 'rebase_in_progress') {\n        // Hide this dialog and show the simpler rebase in progress dialog\n        modal.hide();\n        await RebaseInProgressDialog.show({\n          targetBranch: selectedBranch,\n          onContinue: continueRebaseInProgress,\n          onAbort: abortRebaseInProgress,\n        });\n        return;\n      }\n\n      // Handle other errors\n      let message = 'Failed to rebase';\n      if (err && typeof err === 'object') {\n        // Handle Result<void, GitOperationError> structure\n        if (\n          'error' in err &&\n          err.error &&\n          typeof err.error === 'object' &&\n          'message' in err.error\n        ) {\n          message = String(err.error.message);\n        } else if ('message' in err && err.message) {\n          message = String(err.message);\n        }\n      }\n      setError(message);\n    }\n  };\n\n  const handleCancel = () => {\n    modal.hide();\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    if (!open) {\n      handleCancel();\n    }\n  };\n\n  const isRebasePending = git.states.rebasePending;\n\n  // Don't render if we're redirecting to another dialog\n  if (!isInitialLoading && (isRebaseInProgress || hasConflictedFiles)) {\n    return null;\n  }\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>{t('rebase.dialog.title')}</DialogTitle>\n          <DialogDescription>\n            {t('rebase.dialog.description')}\n          </DialogDescription>\n        </DialogHeader>\n\n        {isInitialLoading ? (\n          <div className=\"flex items-center justify-center py-8\">\n            <SpinnerIcon className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n          </div>\n        ) : (\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <label htmlFor=\"target-branch\" className=\"text-sm font-medium\">\n                {t('rebase.dialog.targetLabel')}\n              </label>\n              <BranchSelector\n                branches={branches}\n                selectedBranch={selectedBranch}\n                onBranchSelect={setSelectedBranch}\n                placeholder={t('rebase.dialog.targetPlaceholder')}\n                excludeCurrentBranch={false}\n              />\n            </div>\n            <div className=\"space-y-2\">\n              <button\n                type=\"button\"\n                onClick={() => setShowAdvanced((prev) => !prev)}\n                className=\"flex w-full items-center gap-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                <CaretRightIcon\n                  className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}\n                />\n                <span>{t('rebase.dialog.advanced')}</span>\n              </button>\n              {showAdvanced && (\n                <div className=\"space-y-2\">\n                  <label\n                    htmlFor=\"upstream-branch\"\n                    className=\"text-sm font-medium\"\n                  >\n                    {t('rebase.dialog.upstreamLabel')}\n                  </label>\n                  <BranchSelector\n                    branches={branches}\n                    selectedBranch={selectedUpstream}\n                    onBranchSelect={setSelectedUpstream}\n                    placeholder={t('rebase.dialog.upstreamPlaceholder')}\n                    excludeCurrentBranch={false}\n                  />\n                </div>\n              )}\n            </div>\n            {error && <p className=\"text-sm text-destructive\">{error}</p>}\n          </div>\n        )}\n\n        <DialogFooter>\n          <Button\n            variant=\"outline\"\n            onClick={handleCancel}\n            disabled={isRebasePending}\n          >\n            {t('common:buttons.cancel')}\n          </Button>\n          <Button\n            onClick={handleConfirm}\n            disabled={isInitialLoading || isRebasePending || !selectedBranch}\n          >\n            {isRebasePending\n              ? t('rebase.common.inProgress')\n              : t('rebase.common.action')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nconst RebaseDialogImpl = create<RebaseDialogProps>(\n  ({ workspaceId, repoId }) => {\n    return (\n      <GitOperationsProvider workspaceId={workspaceId}>\n        <RebaseDialogContent workspaceId={workspaceId} repoId={repoId} />\n      </GitOperationsProvider>\n    );\n  }\n);\n\nexport const RebaseDialog = defineModal<RebaseDialogProps, void>(\n  RebaseDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/SelectionDialog.tsx",
    "content": "import { useState, useCallback, useRef, useEffect } from 'react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { CommandDialog } from '@vibe/ui/components/Command';\nimport {\n  CommandBar,\n  type CommandBarGroupItem,\n} from '@vibe/ui/components/CommandBar';\nimport type {\n  PageId,\n  ResolvedGroup,\n  ResolvedGroupItem,\n} from '@/shared/types/commandBar';\nimport type { StatusItem } from '@/shared/types/selectionItems';\nimport { resolveLabel, type ActionDefinition } from '@/shared/types/actions';\n\nexport interface SelectionPage<TResult = unknown> {\n  id: string;\n  title: string;\n  buildGroups: () => ResolvedGroup[];\n  onSelect: (\n    item: ResolvedGroupItem\n  ) =>\n    | { type: 'complete'; data: TResult }\n    | { type: 'navigate'; pageId: string };\n}\n\nexport interface SelectionDialogProps {\n  initialPageId: string;\n  pages: Record<string, SelectionPage>;\n  statuses?: StatusItem[];\n}\n\nconst SelectionDialogImpl = create<SelectionDialogProps>(\n  ({ initialPageId, pages, statuses = [] }) => {\n    const modal = useModal();\n    const previousFocusRef = useRef<HTMLElement | null>(null);\n    const [search, setSearch] = useState('');\n    const [currentPageId, setCurrentPageId] = useState(initialPageId);\n    const [pageStack, setPageStack] = useState<string[]>([]);\n\n    // Reset transient state and capture focus each time dialog opens.\n    useEffect(() => {\n      if (!modal.visible) return;\n      previousFocusRef.current = document.activeElement as HTMLElement;\n      setSearch('');\n      setPageStack([]);\n      setCurrentPageId(initialPageId);\n    }, [modal.visible, initialPageId]);\n\n    // Ensure cmdk search input is focused when dialog opens or page changes.\n    useEffect(() => {\n      if (!modal.visible) return;\n      const rafId = requestAnimationFrame(() => {\n        const activeDialog = document.querySelector(\n          '[role=\"dialog\"][data-state=\"open\"]'\n        );\n        const input =\n          activeDialog?.querySelector<HTMLInputElement>('[cmdk-input]');\n        input?.focus();\n      });\n\n      return () => cancelAnimationFrame(rafId);\n    }, [modal.visible, currentPageId]);\n\n    // Guard against stale page IDs when opening with different page sets.\n    useEffect(() => {\n      if (pages[currentPageId]) return;\n      if (pages[initialPageId]) {\n        setCurrentPageId(initialPageId);\n        return;\n      }\n      const fallbackPageId = Object.keys(pages)[0];\n      if (fallbackPageId) {\n        setCurrentPageId(fallbackPageId);\n      }\n    }, [currentPageId, initialPageId, pages]);\n\n    const currentPage =\n      pages[currentPageId] ??\n      pages[initialPageId] ??\n      pages[Object.keys(pages)[0] ?? ''];\n\n    if (!currentPage) {\n      return null;\n    }\n\n    const resolvedPage = {\n      id: currentPage.id,\n      title: currentPage.title,\n      groups: currentPage.buildGroups(),\n    };\n\n    const handleSelect = useCallback(\n      (item: CommandBarGroupItem<ActionDefinition, PageId>) => {\n        const result = currentPage.onSelect(item as ResolvedGroupItem);\n        if (result.type === 'complete') {\n          modal.resolve(result.data);\n          modal.hide();\n        } else if (result.type === 'navigate') {\n          if (!pages[result.pageId]) return;\n          setPageStack((prev) => [...prev, currentPageId]);\n          setCurrentPageId(result.pageId);\n          setSearch('');\n        }\n      },\n      [currentPage, currentPageId, modal, pages]\n    );\n\n    const handleGoBack = useCallback(() => {\n      const prevPage = pageStack[pageStack.length - 1];\n      if (prevPage) {\n        setPageStack((prev) => prev.slice(0, -1));\n        setCurrentPageId(prevPage);\n        setSearch('');\n      }\n    }, [pageStack]);\n\n    const handleClose = useCallback(() => {\n      modal.resolve(undefined);\n      modal.hide();\n    }, [modal]);\n\n    const handleCloseAutoFocus = useCallback((event: Event) => {\n      event.preventDefault();\n      const activeElement = document.activeElement;\n      const isInDialog = activeElement?.closest('[role=\"dialog\"]');\n      if (!isInDialog) {\n        previousFocusRef.current?.focus();\n      }\n    }, []);\n\n    return (\n      <CommandDialog\n        open={modal.visible}\n        onOpenChange={(open) => !open && handleClose()}\n        onCloseAutoFocus={handleCloseAutoFocus}\n      >\n        <CommandBar\n          page={resolvedPage}\n          canGoBack={pageStack.length > 0}\n          onGoBack={handleGoBack}\n          onSelect={handleSelect}\n          getLabel={(action) => resolveLabel(action)}\n          search={search}\n          onSearchChange={setSearch}\n          statuses={statuses}\n        />\n      </CommandDialog>\n    );\n  }\n);\n\nexport const SelectionDialog = defineModal<\n  SelectionDialogProps,\n  unknown | undefined\n>(SelectionDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/StartReviewDialog.tsx",
    "content": "import { useState, useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Textarea } from '@vibe/ui/components/Textarea';\nimport { Label } from '@vibe/ui/components/Label';\nimport { Switch } from '@vibe/ui/components/Switch';\nimport { Checkbox } from '@vibe/ui/components/Checkbox';\nimport { AgentSelector } from '@/shared/components/tasks/AgentSelector';\nimport { ConfigSelector } from '@/shared/components/tasks/ConfigSelector';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { sessionsApi } from '@/shared/lib/api';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport type { BaseCodingAgent, ExecutorProfileId } from 'shared/types';\n\nexport interface StartReviewDialogProps {\n  sessionId?: string;\n  workspaceId: string;\n  reviewMarkdown?: string;\n  defaultProfile?: ExecutorProfileId | null;\n  onSuccess?: (newSessionId?: string) => void;\n}\n\nconst StartReviewDialogImpl = create<StartReviewDialogProps>(\n  ({ sessionId, workspaceId, reviewMarkdown, defaultProfile, onSuccess }) => {\n    const modal = useModal();\n    const queryClient = useQueryClient();\n    const { profiles, config } = useUserSystem();\n    const { sessions, selectedSession, selectedSessionId, selectSession } =\n      useWorkspaceContext();\n    const { t } = useTranslation(['tasks', 'common']);\n\n    const resolvedSessionId = sessionId ?? selectedSessionId;\n    const resolvedSession = useMemo(() => {\n      if (!resolvedSessionId) return selectedSession ?? null;\n      return (\n        sessions.find((session) => session.id === resolvedSessionId) ??\n        selectedSession ??\n        null\n      );\n    }, [sessions, resolvedSessionId, selectedSession]);\n    const sessionExecutor = resolvedSession?.executor as BaseCodingAgent | null;\n\n    const resolvedDefaultProfile = useMemo(() => {\n      if (defaultProfile) return defaultProfile;\n      if (sessionExecutor) {\n        const variant =\n          config?.executor_profile?.executor === sessionExecutor\n            ? config.executor_profile.variant\n            : null;\n        return { executor: sessionExecutor, variant };\n      }\n      return config?.executor_profile ?? null;\n    }, [defaultProfile, sessionExecutor, config?.executor_profile]);\n\n    const [userSelectedProfile, setUserSelectedProfile] =\n      useState<ExecutorProfileId | null>(null);\n    const [additionalPrompt, setAdditionalPrompt] = useState('');\n    const [createNewSession, setCreateNewSession] = useState(\n      () => !resolvedSessionId\n    );\n    const [includeGitContext, setIncludeGitContext] = useState(true);\n    const [isSubmitting, setIsSubmitting] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n\n    const effectiveProfile = userSelectedProfile ?? resolvedDefaultProfile;\n\n    const canSubmit = Boolean(effectiveProfile && !isSubmitting);\n\n    const handleSubmit = useCallback(async () => {\n      if (!effectiveProfile) return;\n\n      setIsSubmitting(true);\n      setError(null);\n\n      try {\n        let targetSessionId = resolvedSessionId;\n\n        if (createNewSession || !resolvedSessionId) {\n          const session = await sessionsApi.create({\n            workspace_id: workspaceId,\n            executor: effectiveProfile.executor,\n            name: t('startReviewDialog.sessionName'),\n          });\n          targetSessionId = session.id;\n\n          queryClient.invalidateQueries({\n            queryKey: ['workspaceSessions', workspaceId],\n          });\n        }\n\n        if (!targetSessionId) {\n          setError('Failed to create session');\n          setIsSubmitting(false);\n          return;\n        }\n\n        const promptParts = [reviewMarkdown, additionalPrompt].filter(Boolean);\n        const combinedPrompt = promptParts.join('\\n\\n');\n\n        await sessionsApi.startReview(targetSessionId, {\n          executor_config: {\n            executor: effectiveProfile.executor,\n            variant: effectiveProfile.variant,\n          },\n          additional_prompt: combinedPrompt || null,\n          use_all_workspace_commits: includeGitContext,\n        });\n\n        queryClient.invalidateQueries({\n          queryKey: ['processes', workspaceId],\n        });\n        queryClient.invalidateQueries({\n          queryKey: ['branchStatus', workspaceId],\n        });\n\n        const createdNewSession = targetSessionId !== resolvedSessionId;\n        if (createdNewSession && targetSessionId) {\n          selectSession(targetSessionId);\n        }\n        onSuccess?.(createdNewSession ? targetSessionId : undefined);\n        modal.hide();\n      } catch (err) {\n        console.error('Failed to start review:', err);\n        setError('Failed to start review. Please try again.');\n      } finally {\n        setIsSubmitting(false);\n      }\n    }, [\n      effectiveProfile,\n      resolvedSessionId,\n      workspaceId,\n      createNewSession,\n      includeGitContext,\n      reviewMarkdown,\n      additionalPrompt,\n      queryClient,\n      selectSession,\n      onSuccess,\n      modal,\n    ]);\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) modal.hide();\n    };\n\n    const handleNewSessionChange = (checked: boolean) => {\n      setCreateNewSession(checked);\n      if (!checked && resolvedDefaultProfile) {\n        setUserSelectedProfile(resolvedDefaultProfile);\n      }\n    };\n\n    const hasReviewComments = Boolean(reviewMarkdown);\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-[500px]\">\n          <DialogHeader>\n            <DialogTitle>{t('startReviewDialog.title')}</DialogTitle>\n            <DialogDescription>\n              {t('startReviewDialog.description')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Label\n                htmlFor=\"additional-prompt\"\n                className=\"text-sm font-medium\"\n              >\n                {t('startReviewDialog.additionalInstructions')}\n              </Label>\n              <Textarea\n                id=\"additional-prompt\"\n                value={additionalPrompt}\n                onChange={(e) => setAdditionalPrompt(e.target.value)}\n                placeholder=\"Add any specific instructions for the review...\"\n                className=\"min-h-[80px] resize-none\"\n              />\n            </div>\n\n            {hasReviewComments && (\n              <div className=\"space-y-2\">\n                <Label className=\"text-sm font-medium\">\n                  {t('startReviewDialog.reviewComments', {\n                    count:\n                      reviewMarkdown\n                        ?.split('\\n')\n                        .filter((l) => l.startsWith('-')).length ?? 0,\n                  })}\n                </Label>\n                <div className=\"text-sm text-muted-foreground bg-muted/50 rounded-md p-3 max-h-32 overflow-y-auto\">\n                  <pre className=\"whitespace-pre-wrap font-sans text-xs\">\n                    {reviewMarkdown}\n                  </pre>\n                </div>\n              </div>\n            )}\n\n            {error && <div className=\"text-sm text-destructive\">{error}</div>}\n\n            <div className=\"space-y-1\">\n              <div className=\"flex items-center space-x-2\">\n                <Checkbox\n                  id=\"include-git-context\"\n                  checked={includeGitContext}\n                  onCheckedChange={(checked) =>\n                    setIncludeGitContext(checked === true)\n                  }\n                />\n                <Label\n                  htmlFor=\"include-git-context\"\n                  className=\"cursor-pointer text-sm\"\n                >\n                  {t('startReviewDialog.includeGitContext')}\n                </Label>\n              </div>\n              <p className=\"text-xs text-muted-foreground ml-6\">\n                {t('startReviewDialog.includeGitContextDescription')}\n              </p>\n            </div>\n\n            {profiles && (\n              <div className=\"flex gap-3 flex-col sm:flex-row\">\n                <AgentSelector\n                  profiles={profiles}\n                  selectedExecutorProfile={effectiveProfile}\n                  onChange={setUserSelectedProfile}\n                  disabled={!createNewSession}\n                  showLabel={false}\n                />\n                <ConfigSelector\n                  profiles={profiles}\n                  selectedExecutorProfile={effectiveProfile}\n                  onChange={setUserSelectedProfile}\n                  showLabel={false}\n                />\n              </div>\n            )}\n          </div>\n\n          <DialogFooter className=\"sm:!justify-between\">\n            <Button\n              variant=\"outline\"\n              onClick={() => modal.hide()}\n              disabled={isSubmitting}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <div className=\"flex items-center gap-3\">\n              <div className=\"flex items-center gap-2\">\n                <Switch\n                  id=\"new-session-switch\"\n                  checked={createNewSession}\n                  onCheckedChange={handleNewSessionChange}\n                  disabled={!resolvedSessionId}\n                  aria-label={t('startReviewDialog.newSession')}\n                />\n                <Label\n                  htmlFor=\"new-session-switch\"\n                  className=\"text-sm cursor-pointer\"\n                >\n                  {t('startReviewDialog.newSession')}\n                </Label>\n              </div>\n              <Button onClick={handleSubmit} disabled={!canSubmit}>\n                {isSubmitting\n                  ? t('actionsMenu.startingReview')\n                  : t('actionsMenu.startReview')}\n              </Button>\n            </div>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const StartReviewDialog = defineModal<StartReviewDialogProps, void>(\n  StartReviewDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/ViewProcessesDialog.tsx",
    "content": "import { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport ProcessesTab from '@/shared/components/tasks/TaskDetails/ProcessesTab';\nimport { ProcessSelectionProvider } from '@/shared/hooks/ProcessSelectionContext';\n\nexport interface ViewProcessesDialogProps {\n  sessionId: string | undefined;\n  initialProcessId?: string | null;\n}\n\nconst ViewProcessesDialogImpl = create<ViewProcessesDialogProps>(\n  ({ sessionId, initialProcessId }) => {\n    const { t } = useTranslation('tasks');\n    const modal = useModal();\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        modal.hide();\n      }\n    };\n\n    return (\n      <Dialog\n        open={modal.visible}\n        onOpenChange={handleOpenChange}\n        className=\"max-w-5xl w-[92vw] p-0 overflow-x-hidden\"\n      >\n        <DialogContent\n          className=\"p-0 min-w-0\"\n          onKeyDownCapture={(e) => {\n            if (e.key === 'Escape') {\n              e.stopPropagation();\n              modal.hide();\n            }\n          }}\n        >\n          <DialogHeader className=\"px-4 py-3 border-b\">\n            <DialogTitle>{t('viewProcessesDialog.title')}</DialogTitle>\n          </DialogHeader>\n          <div className=\"h-[75vh] flex flex-col min-h-0 min-w-0\">\n            <ProcessSelectionProvider initialProcessId={initialProcessId}>\n              <ProcessesTab sessionId={sessionId} />\n            </ProcessSelectionProvider>\n          </div>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const ViewProcessesDialog = defineModal<ViewProcessesDialogProps, void>(\n  ViewProcessesDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/WorkspaceSelectionDialog.tsx",
    "content": "import { useState, useCallback, useMemo, useRef, useEffect } from 'react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useTranslation } from 'react-i18next';\nimport { GitBranchIcon, PlusIcon } from '@phosphor-icons/react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { ApiError, workspacesApi } from '@/shared/lib/api';\nimport { getWorkspaceDefaults } from '@/shared/lib/workspaceDefaults';\nimport { ErrorDialog } from '@vibe/ui/components/ErrorDialog';\nimport { useProjectWorkspaceCreateDraft } from '@/shared/hooks/useProjectWorkspaceCreateDraft';\nimport {\n  buildLinkedIssueCreateState,\n  buildLocalWorkspaceIdSet,\n  buildWorkspaceCreateInitialState,\n  buildWorkspaceCreatePrompt,\n} from '@/shared/lib/workspaceCreateState';\nimport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n} from '@vibe/ui/components/Command';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { ProjectProvider } from '@/shared/providers/remote/ProjectProvider';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { UserProvider } from '@/shared/providers/remote/UserProvider';\nimport { useUserContext } from '@/shared/hooks/useUserContext';\n\nexport interface WorkspaceSelectionDialogProps {\n  projectId: string;\n  issueId: string;\n}\n\nconst PAGE_SIZE = 50;\n\nfunction getLinkWorkspaceErrorMessage(error: unknown): string | null {\n  if (error instanceof ApiError && error.status === 409) {\n    return 'This workspace is already linked to an issue.';\n  }\n\n  if (error instanceof Error) {\n    const normalizedMessage = error.message.toLowerCase();\n    if (\n      normalizedMessage.includes('already exists') ||\n      normalizedMessage.includes('already linked')\n    ) {\n      return 'This workspace is already linked to an issue.';\n    }\n    return error.message;\n  }\n\n  return null;\n}\n\n/** Inner component that uses contexts to render the selection UI */\nfunction WorkspaceSelectionContent({\n  projectId,\n  issueId,\n}: {\n  projectId: string;\n  issueId: string;\n}) {\n  const { t } = useTranslation('common');\n  const modal = useModal();\n  const { openWorkspaceCreateFromState } = useProjectWorkspaceCreateDraft();\n  const previousFocusRef = useRef<HTMLElement | null>(null);\n\n  // Get local workspaces from WorkspaceContext (both active and archived)\n  const { activeWorkspaces, archivedWorkspaces } = useWorkspaceContext();\n\n  // Get already-linked workspaces from UserContext (workspaces are user-scoped)\n  const { getWorkspacesForIssue, workspaces } = useUserContext();\n\n  // Get issue data from ProjectContext (issues are project-scoped)\n  const { getIssue } = useProjectContext();\n\n  const [search, setSearch] = useState('');\n  const [isLinking, setIsLinking] = useState(false);\n\n  // Capture focus when dialog opens and reset state\n  useEffect(() => {\n    if (modal.visible) {\n      previousFocusRef.current = document.activeElement as HTMLElement;\n      setSearch('');\n      setIsLinking(false);\n    }\n  }, [modal.visible]);\n\n  // Get IDs of workspaces already linked to this issue\n  const linkedLocalWorkspaceIds = useMemo(() => {\n    const remoteWorkspaces = getWorkspacesForIssue(issueId);\n    return new Set(\n      remoteWorkspaces\n        .map((w) => w.local_workspace_id)\n        .filter((id): id is string => id !== null)\n    );\n  }, [getWorkspacesForIssue, issueId]);\n\n  // Combine active and archived workspaces with archived flag\n  const allWorkspaces = useMemo(() => {\n    const active = activeWorkspaces.map((ws) => ({ ...ws, isArchived: false }));\n    const archived = archivedWorkspaces.map((ws) => ({\n      ...ws,\n      isArchived: true,\n    }));\n    return [...active, ...archived];\n  }, [activeWorkspaces, archivedWorkspaces]);\n\n  // Filter and paginate workspaces\n  const searchLower = search.toLowerCase();\n  const isSearching = search.length > 0;\n\n  const filteredWorkspaces = useMemo(() => {\n    return allWorkspaces.filter((ws) => {\n      // Exclude already-linked workspaces\n      if (linkedLocalWorkspaceIds.has(ws.id)) return false;\n      // Filter by search if searching\n      if (isSearching) {\n        return (\n          ws.name.toLowerCase().includes(searchLower) ||\n          ws.branch.toLowerCase().includes(searchLower)\n        );\n      }\n      return true;\n    });\n  }, [allWorkspaces, linkedLocalWorkspaceIds, isSearching, searchLower]);\n\n  // Apply pagination when not searching\n  const displayedWorkspaces = useMemo(() => {\n    return isSearching\n      ? filteredWorkspaces\n      : filteredWorkspaces.slice(0, PAGE_SIZE);\n  }, [filteredWorkspaces, isSearching]);\n\n  const handleLinkWorkspace = useCallback(\n    async (workspaceId: string) => {\n      if (isLinking) return;\n\n      setIsLinking(true);\n      try {\n        await workspacesApi.linkToIssue(workspaceId, projectId, issueId);\n        // Success - close dialog. UI will auto-update via Electric sync.\n        modal.hide();\n      } catch (err) {\n        const errorMessage =\n          getLinkWorkspaceErrorMessage(err) ??\n          t('workspaces.linkError', 'Failed to link workspace');\n\n        await ErrorDialog.show({\n          title: t('common:error'),\n          message: errorMessage,\n          buttonText: t('common:ok'),\n        });\n      } finally {\n        setIsLinking(false);\n      }\n    },\n    [projectId, issueId, isLinking, modal, t]\n  );\n\n  const handleCreateNewWorkspace = useCallback(async () => {\n    if (isLinking) return;\n    setIsLinking(true);\n\n    try {\n      // Get issue details for initial prompt\n      const issue = getIssue(issueId);\n      const initialPrompt = buildWorkspaceCreatePrompt(\n        issue?.title ?? null,\n        issue?.description ?? null\n      );\n\n      // Build set of local workspace IDs that exist on this machine\n      const localWorkspaceIds = buildLocalWorkspaceIdSet(\n        activeWorkspaces,\n        archivedWorkspaces\n      );\n\n      // Get defaults from most recent workspace\n      const defaults = await getWorkspaceDefaults(\n        workspaces,\n        localWorkspaceIds,\n        projectId\n      );\n\n      const createState = buildWorkspaceCreateInitialState({\n        prompt: initialPrompt,\n        defaults,\n        linkedIssue: buildLinkedIssueCreateState(issue, projectId),\n      });\n\n      modal.hide();\n      const draftId = await openWorkspaceCreateFromState(createState, {\n        issueId,\n      });\n      if (!draftId) {\n        await ErrorDialog.show({\n          title: t('common:error'),\n          message: t(\n            'workspaces.createDraftError',\n            'Failed to prepare workspace draft. Please try again.'\n          ),\n          buttonText: t('common:ok'),\n        });\n      }\n    } finally {\n      setIsLinking(false);\n    }\n  }, [\n    modal,\n    openWorkspaceCreateFromState,\n    getIssue,\n    issueId,\n    projectId,\n    workspaces,\n    isLinking,\n    activeWorkspaces,\n    archivedWorkspaces,\n    t,\n  ]);\n\n  // Restore focus when dialog closes\n  const handleCloseAutoFocus = useCallback((event: Event) => {\n    event.preventDefault();\n    previousFocusRef.current?.focus();\n  }, []);\n\n  // Prevent Radix from managing focus on open - let cmdk handle it\n  const handleOpenAutoFocus = useCallback((event: Event) => {\n    event.preventDefault();\n  }, []);\n\n  return (\n    <CommandDialog\n      open={modal.visible}\n      onOpenChange={(open) => !open && modal.hide()}\n      onCloseAutoFocus={handleCloseAutoFocus}\n      onOpenAutoFocus={handleOpenAutoFocus}\n    >\n      <Command\n        className=\"rounded-sm border border-border [&_[cmdk-group-heading]]:px-base [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-low [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-half [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-base [&_[cmdk-item]]:py-half\"\n        loop\n        filter={(value, search) => {\n          if (value.toLowerCase().includes(search.toLowerCase())) return 1;\n          return 0;\n        }}\n      >\n        <div className=\"flex items-center border-b border-border\">\n          <CommandInput\n            placeholder={t('kanban.linkWorkspace', 'Link workspace...')}\n            value={search}\n            onValueChange={setSearch}\n          />\n        </div>\n        <CommandList className=\"min-h-[200px]\">\n          <CommandEmpty>\n            {t('commandBar.noResults', 'No results found')}\n          </CommandEmpty>\n\n          {/* Create new workspace option - stubbed */}\n          <CommandGroup>\n            <CommandItem\n              value=\"__create_new__\"\n              onSelect={handleCreateNewWorkspace}\n              disabled={isLinking}\n            >\n              <PlusIcon className=\"h-4 w-4\" weight=\"bold\" />\n              <span>\n                {t('kanban.createNewWorkspace', 'Create new workspace')}\n              </span>\n            </CommandItem>\n          </CommandGroup>\n\n          {/* Available workspaces */}\n          {displayedWorkspaces.length > 0 && (\n            <CommandGroup heading={t('kanban.workspaces', 'Workspaces')}>\n              {displayedWorkspaces.map((workspace) => (\n                <CommandItem\n                  key={workspace.id}\n                  value={`${workspace.id} ${workspace.name} ${workspace.branch}${workspace.isArchived ? ' archived' : ''}`}\n                  onSelect={() => handleLinkWorkspace(workspace.id)}\n                  disabled={isLinking}\n                >\n                  <GitBranchIcon\n                    className={`h-4 w-4 shrink-0 ${workspace.isArchived ? 'text-low' : ''}`}\n                    weight=\"regular\"\n                  />\n                  <span\n                    className={`truncate ${workspace.isArchived ? 'text-low' : ''}`}\n                  >\n                    {workspace.name}\n                  </span>\n                  {workspace.isArchived && (\n                    <span className=\"text-xs text-low\">\n                      ({t('workspaces.archived').toLowerCase()})\n                    </span>\n                  )}\n                  <span className=\"ml-auto text-xs text-low truncate max-w-[120px]\">\n                    {workspace.branch}\n                  </span>\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          )}\n\n          {/* Show count when paginated */}\n          {!isSearching && filteredWorkspaces.length > PAGE_SIZE && (\n            <div className=\"px-base py-half text-xs text-low text-center\">\n              {t('kanban.showingWorkspaces', 'Showing {{count}} of {{total}}', {\n                count: PAGE_SIZE,\n                total: filteredWorkspaces.length,\n              })}\n            </div>\n          )}\n        </CommandList>\n      </Command>\n    </CommandDialog>\n  );\n}\n\n/** Wrapper that provides UserContext and ProjectContext */\nfunction WorkspaceSelectionWithContext({\n  projectId,\n  issueId,\n}: WorkspaceSelectionDialogProps) {\n  if (!projectId) {\n    return null;\n  }\n\n  return (\n    <UserProvider>\n      <ProjectProvider projectId={projectId}>\n        <WorkspaceSelectionContent projectId={projectId} issueId={issueId} />\n      </ProjectProvider>\n    </UserProvider>\n  );\n}\n\nconst WorkspaceSelectionDialogImpl = create<WorkspaceSelectionDialogProps>(\n  ({ projectId, issueId }) => {\n    return (\n      <WorkspaceSelectionWithContext projectId={projectId} issueId={issueId} />\n    );\n  }\n);\n\nexport const WorkspaceSelectionDialog = defineModal<\n  WorkspaceSelectionDialogProps,\n  void\n>(WorkspaceSelectionDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/commandBar/injectSearchMatches.ts",
    "content": "import type { Workspace } from 'shared/types';\nimport { Pages, getPageActions } from '@/shared/command-bar/actions/pages';\nimport type { StaticPageId, ResolvedGroup } from '@/shared/types/commandBar';\nimport {\n  resolveLabel,\n  isActionVisible,\n  type ActionVisibilityContext,\n} from '@/shared/types/actions';\n\n// Derive injectable pages from Pages - all child pages of root\nconst INJECTABLE_PAGE_IDS = (Object.keys(Pages) as StaticPageId[]).filter(\n  (id) => id !== 'root' && Pages[id].parent === 'root'\n);\n\nexport function injectSearchMatches(\n  searchQuery: string,\n  ctx: ActionVisibilityContext,\n  workspace: Workspace | undefined\n): ResolvedGroup[] {\n  const searchLower = searchQuery.toLowerCase();\n\n  return INJECTABLE_PAGE_IDS.reduce<ResolvedGroup[]>((groups, id) => {\n    const page = Pages[id];\n\n    // Check page visibility condition\n    if (page.isVisible && !page.isVisible(ctx)) return groups;\n\n    const items = getPageActions(id)\n      .filter((a) => isActionVisible(a, ctx))\n      .filter((a) => {\n        const label = resolveLabel(a, workspace);\n        return (\n          label.toLowerCase().includes(searchLower) ||\n          a.id.toLowerCase().includes(searchLower) ||\n          (a.keywords?.some((kw) => kw.toLowerCase().includes(searchLower)) ??\n            false)\n        );\n      })\n      .map((action) => ({ type: 'action' as const, action }));\n\n    if (items.length) groups.push({ label: page.title || id, items });\n    return groups;\n  }, []);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/commandBar/useCommandBarState.ts",
    "content": "import { useReducer, useCallback, useRef } from 'react';\nimport type { PageId, ResolvedGroupItem } from '@/shared/types/commandBar';\nimport type { ActionDefinition } from '@/shared/types/actions';\n\nexport interface CommandBarState {\n  page: PageId;\n  stack: PageId[];\n  search: string;\n}\n\nexport type CommandBarEvent =\n  | { type: 'RESET'; page: PageId }\n  | { type: 'SEARCH_CHANGE'; query: string }\n  | { type: 'GO_BACK' }\n  | { type: 'SELECT_ITEM'; item: ResolvedGroupItem };\n\nexport type CommandBarEffect =\n  | { type: 'none' }\n  | { type: 'execute'; action: ActionDefinition };\n\nconst initial = (page: PageId): CommandBarState => ({\n  page,\n  stack: [],\n  search: '',\n});\n\nconst noEffect: CommandBarEffect = { type: 'none' };\n\nfunction reducer(\n  state: CommandBarState,\n  event: CommandBarEvent\n): [CommandBarState, CommandBarEffect] {\n  if (event.type === 'RESET') {\n    return [initial(event.page), noEffect];\n  }\n  if (event.type === 'SEARCH_CHANGE') {\n    return [{ ...state, search: event.query }, noEffect];\n  }\n  if (event.type === 'GO_BACK') {\n    const prevPage = state.stack[state.stack.length - 1];\n    if (!prevPage) return [state, noEffect];\n    return [initial(prevPage), noEffect];\n  }\n  if (event.type === 'SELECT_ITEM') {\n    const { item } = event;\n    if (item.type === 'page') {\n      return [\n        {\n          page: item.pageId,\n          stack: [...state.stack, state.page],\n          search: '',\n        },\n        noEffect,\n      ];\n    }\n    if (item.type === 'action') {\n      return [state, { type: 'execute', action: item.action }];\n    }\n  }\n\n  return [state, noEffect];\n}\n\nexport function useCommandBarState(initialPage: PageId) {\n  const stateRef = useRef<CommandBarState>(initial(initialPage));\n\n  const [state, rawDispatch] = useReducer(\n    (s: CommandBarState, e: CommandBarEvent) => {\n      const [newState] = reducer(s, e);\n      stateRef.current = newState;\n      return newState;\n    },\n    undefined,\n    () => initial(initialPage)\n  );\n\n  // Keep stateRef in sync\n  stateRef.current = state;\n\n  // Stable dispatch that doesn't change on every render\n  const dispatch = useCallback(\n    (event: CommandBarEvent): CommandBarEffect => {\n      const [, effect] = reducer(stateRef.current, event);\n      rawDispatch(event);\n      return effect;\n    },\n    [] // No dependencies - uses refs for current values\n  );\n\n  return {\n    state,\n    currentPage: state.page,\n    canGoBack: state.stack.length > 0,\n    dispatch,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/commandBar/useResolvedPage.ts",
    "content": "import { useMemo } from 'react';\nimport {\n  StackIcon,\n  SlidersIcon,\n  SquaresFourIcon,\n  GitBranchIcon,\n  KanbanIcon,\n} from '@phosphor-icons/react';\nimport type { Workspace } from 'shared/types';\nimport { Pages } from '@/shared/command-bar/actions/pages';\nimport type {\n  PageId,\n  StaticPageId,\n  CommandBarGroupItem,\n  ResolvedGroup,\n  ResolvedGroupItem,\n} from '@/shared/types/commandBar';\nimport {\n  isActionVisible,\n  type ActionVisibilityContext,\n} from '@/shared/types/actions';\nimport { isPageVisible } from '@/shared/command-bar/actions/useActionVisibility';\nimport { injectSearchMatches } from './injectSearchMatches';\n\nexport interface ResolvedCommandBarPage {\n  id: string;\n  title?: string;\n  groups: ResolvedGroup[];\n}\n\nconst PAGE_ICONS = {\n  root: SquaresFourIcon,\n  workspaceActions: StackIcon,\n  diffOptions: SlidersIcon,\n  viewOptions: SquaresFourIcon,\n  repoActions: GitBranchIcon,\n  issueActions: KanbanIcon,\n} as const satisfies Record<StaticPageId, typeof StackIcon>;\n\nfunction expandGroupItems(\n  items: CommandBarGroupItem[],\n  ctx: ActionVisibilityContext\n): ResolvedGroupItem[] {\n  return items.flatMap((item) => {\n    if (item.type === 'childPages') {\n      const page = Pages[item.id as StaticPageId];\n      if (!isPageVisible(page, ctx)) return [];\n      return [\n        {\n          type: 'page' as const,\n          pageId: item.id,\n          label: page.title ?? item.id,\n          icon: PAGE_ICONS[item.id as StaticPageId],\n        },\n      ];\n    }\n    if (item.type === 'action') {\n      if (!isActionVisible(item.action, ctx)) return [];\n    }\n    return [item];\n  });\n}\n\nfunction buildPageGroups(\n  pageId: StaticPageId,\n  ctx: ActionVisibilityContext\n): ResolvedGroup[] {\n  return Pages[pageId].items\n    .map((group) => {\n      const items = expandGroupItems(group.items, ctx);\n      return items.length ? { label: group.label, items } : null;\n    })\n    .filter((g): g is ResolvedGroup => g !== null);\n}\n\nexport function useResolvedPage(\n  pageId: PageId,\n  search: string,\n  ctx: ActionVisibilityContext,\n  workspace: Workspace | undefined\n): ResolvedCommandBarPage {\n  return useMemo(() => {\n    const groups = buildPageGroups(pageId, ctx);\n    if (pageId === 'root' && search.trim()) {\n      groups.push(...injectSearchMatches(search, ctx, workspace));\n    }\n\n    return {\n      id: Pages[pageId].id,\n      title: Pages[pageId].title,\n      groups,\n    };\n  }, [pageId, search, ctx, workspace]);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/selections/ProjectSelectionDialog.tsx",
    "content": "import { useState, useCallback, useMemo, useRef, useEffect } from 'react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { ProjectProvider } from '@/shared/providers/remote/ProjectProvider';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { CommandDialog } from '@vibe/ui/components/Command';\nimport {\n  CommandBar,\n  type CommandBarGroupItem,\n} from '@vibe/ui/components/CommandBar';\nimport type { PageId, ResolvedGroupItem } from '@/shared/types/commandBar';\nimport type { StatusItem } from '@/shared/types/selectionItems';\nimport type { Issue } from 'shared/remote-types';\nimport { buildStatusSelectionPages } from './statusSelection';\nimport { buildPrioritySelectionPages } from './prioritySelection';\nimport { buildSubIssueSelectionPages } from './subIssueSelection';\nimport { buildRelationshipSelectionPages } from './relationshipSelection';\nimport { resolveLabel, type ActionDefinition } from '@/shared/types/actions';\nimport type { SelectionPage } from '../SelectionDialog';\nimport type { StatusSelectionResult } from './statusSelection';\nimport type { PrioritySelectionResult } from './prioritySelection';\nimport type { SubIssueSelectionResult } from './subIssueSelection';\nimport type { RelationshipSelectionResult } from './relationshipSelection';\n\n// Union of all selection modes\nexport type SelectionMode =\n  | { type: 'status'; issueIds: string[]; isCreateMode?: boolean }\n  | { type: 'priority'; issueIds: string[]; isCreateMode?: boolean }\n  | {\n      type: 'subIssue';\n      parentIssueId: string;\n      mode: 'addChild' | 'setParent';\n    }\n  | {\n      type: 'relationship';\n      issueId: string;\n      relationshipType: 'blocking' | 'related' | 'has_duplicate';\n      direction: 'forward' | 'reverse';\n    };\n\ninterface ProjectSelectionDialogProps {\n  projectId: string;\n  selection: SelectionMode;\n}\n\nfunction getInitialPageId(selectionType: SelectionMode['type']): string {\n  switch (selectionType) {\n    case 'status':\n      return 'selectStatus';\n    case 'priority':\n      return 'selectPriority';\n    case 'subIssue':\n      return 'selectSubIssue';\n    case 'relationship':\n      return 'selectRelationshipIssue';\n  }\n}\n\n// Inner component that has access to ProjectContext\nfunction ProjectSelectionContent({ selection }: { selection: SelectionMode }) {\n  const modal = useModal();\n  const previousFocusRef = useRef<HTMLElement | null>(null);\n  const {\n    statuses,\n    issues,\n    issueRelationships,\n    updateIssue,\n    insertIssueRelationship,\n  } = useProjectContext();\n  const initialPageId = useMemo(\n    () => getInitialPageId(selection.type),\n    [selection.type]\n  );\n  const [search, setSearch] = useState('');\n  const [currentPageId, setCurrentPageId] = useState(initialPageId);\n  const [pageStack, setPageStack] = useState<string[]>([]);\n\n  // Capture focus on mount\n  if (!previousFocusRef.current && modal.visible) {\n    previousFocusRef.current = document.activeElement as HTMLElement;\n  }\n\n  // NiceModal reuses dialog instances; reset local navigation when mode changes.\n  useEffect(() => {\n    setCurrentPageId(initialPageId);\n    setPageStack([]);\n    setSearch('');\n  }, [initialPageId]);\n\n  const sortedStatuses: StatusItem[] = useMemo(\n    () =>\n      [...statuses]\n        .sort((a, b) => a.sort_order - b.sort_order)\n        .map((s) => ({ id: s.id, name: s.name, color: s.color })),\n    [statuses]\n  );\n\n  // Build filtered issue list for sub-issue selection\n  const filteredIssuesForSubIssue = useMemo((): Issue[] => {\n    if (selection.type !== 'subIssue') return [];\n    const { parentIssueId, mode } = selection;\n\n    const issuesById = new Map(issues.map((i) => [i.id, i]));\n\n    const getAncestorIds = (issueId: string): Set<string> => {\n      const ancestors = new Set<string>();\n      let current = issuesById.get(issueId);\n      while (current?.parent_issue_id) {\n        ancestors.add(current.parent_issue_id);\n        current = issuesById.get(current.parent_issue_id);\n      }\n      return ancestors;\n    };\n\n    const getDescendantIds = (issueId: string): Set<string> => {\n      const descendants = new Set<string>();\n      const queue = [issueId];\n      while (queue.length > 0) {\n        const currentId = queue.shift()!;\n        for (const issue of issues) {\n          if (\n            issue.parent_issue_id === currentId &&\n            !descendants.has(issue.id)\n          ) {\n            descendants.add(issue.id);\n            queue.push(issue.id);\n          }\n        }\n      }\n      return descendants;\n    };\n\n    const anchorIssue = issuesById.get(parentIssueId);\n\n    if (mode === 'addChild') {\n      const ancestorIds = getAncestorIds(parentIssueId);\n      return issues.filter((issue) => {\n        if (issue.id === parentIssueId) return false;\n        if (issue.parent_issue_id === parentIssueId) return false;\n        if (ancestorIds.has(issue.id)) return false;\n        return true;\n      });\n    } else {\n      const descendantIds = getDescendantIds(parentIssueId);\n      return issues.filter((issue) => {\n        if (issue.id === parentIssueId) return false;\n        if (anchorIssue?.parent_issue_id === issue.id) return false;\n        if (descendantIds.has(issue.id)) return false;\n        return true;\n      });\n    }\n  }, [issues, selection]);\n\n  // Build filtered issue list for relationship selection\n  const filteredIssuesForRelationship = useMemo((): Issue[] => {\n    if (selection.type !== 'relationship') return [];\n    const { issueId } = selection;\n\n    const existingRelatedIds = new Set(\n      issueRelationships\n        .filter((r) => r.issue_id === issueId || r.related_issue_id === issueId)\n        .flatMap((r) => [r.issue_id, r.related_issue_id])\n    );\n\n    return issues.filter((issue) => {\n      if (issue.id === issueId) return false;\n      if (existingRelatedIds.has(issue.id)) return false;\n      return true;\n    });\n  }, [issues, issueRelationships, selection]);\n\n  // Build pages based on selection mode\n  const pages = useMemo((): Record<string, SelectionPage> => {\n    switch (selection.type) {\n      case 'status':\n        return buildStatusSelectionPages(sortedStatuses) as Record<\n          string,\n          SelectionPage\n        >;\n      case 'priority':\n        return buildPrioritySelectionPages() as Record<string, SelectionPage>;\n      case 'subIssue':\n        return buildSubIssueSelectionPages(\n          filteredIssuesForSubIssue,\n          selection.mode\n        ) as Record<string, SelectionPage>;\n      case 'relationship':\n        return buildRelationshipSelectionPages(\n          filteredIssuesForRelationship\n        ) as Record<string, SelectionPage>;\n    }\n  }, [\n    selection,\n    sortedStatuses,\n    filteredIssuesForSubIssue,\n    filteredIssuesForRelationship,\n  ]);\n\n  // Handle mutation after selection\n  const handleResult = useCallback(\n    (data: unknown) => {\n      if (!data) return;\n\n      if (selection.type === 'status') {\n        const result = data as StatusSelectionResult;\n        if (selection.isCreateMode) return; // Create mode: caller handles URL update\n        for (const issueId of selection.issueIds) {\n          updateIssue(issueId, { status_id: result.statusId });\n        }\n      } else if (selection.type === 'priority') {\n        const result = data as PrioritySelectionResult;\n        if (selection.isCreateMode) return;\n        for (const issueId of selection.issueIds) {\n          updateIssue(issueId, { priority: result.priority });\n        }\n      } else if (selection.type === 'subIssue') {\n        const result = data as SubIssueSelectionResult;\n        if (result.type === 'selected') {\n          if (selection.mode === 'addChild') {\n            updateIssue(result.issueId, {\n              parent_issue_id: selection.parentIssueId,\n            });\n          } else {\n            updateIssue(selection.parentIssueId, {\n              parent_issue_id: result.issueId,\n            });\n          }\n        }\n        // 'createNew' is handled by the caller (AddSubIssue action)\n      } else if (selection.type === 'relationship') {\n        const result = data as RelationshipSelectionResult;\n        if (selection.direction === 'forward') {\n          insertIssueRelationship({\n            issue_id: selection.issueId,\n            related_issue_id: result.issueId,\n            relationship_type: selection.relationshipType,\n          });\n        } else {\n          insertIssueRelationship({\n            issue_id: result.issueId,\n            related_issue_id: selection.issueId,\n            relationship_type: selection.relationshipType,\n          });\n        }\n      }\n    },\n    [selection, updateIssue, insertIssueRelationship]\n  );\n\n  const fallbackPage = pages[initialPageId] ?? Object.values(pages)[0];\n  const currentPage = pages[currentPageId] ?? fallbackPage;\n\n  const resolvedPage = useMemo(\n    () =>\n      currentPage\n        ? {\n            id: currentPage.id,\n            title: currentPage.title,\n            groups: currentPage.buildGroups(),\n          }\n        : { id: initialPageId, title: '', groups: [] },\n    [currentPage, initialPageId]\n  );\n\n  const handleSelect = useCallback(\n    (item: CommandBarGroupItem<ActionDefinition, PageId>) => {\n      const result = currentPage.onSelect(item as ResolvedGroupItem);\n      if (result.type === 'complete') {\n        handleResult(result.data);\n        modal.resolve(result.data);\n        modal.hide();\n      } else if (result.type === 'navigate') {\n        setPageStack((prev) => [...prev, currentPage.id]);\n        setCurrentPageId(result.pageId);\n        setSearch('');\n      }\n    },\n    [currentPage, modal, handleResult]\n  );\n\n  const handleGoBack = useCallback(() => {\n    const prevPage = pageStack[pageStack.length - 1];\n    if (prevPage) {\n      setPageStack((prev) => prev.slice(0, -1));\n      setCurrentPageId(prevPage);\n      setSearch('');\n    }\n  }, [pageStack]);\n\n  const handleClose = useCallback(() => {\n    modal.resolve(undefined);\n    modal.hide();\n  }, [modal]);\n\n  const handleCloseAutoFocus = useCallback((event: Event) => {\n    event.preventDefault();\n    const activeElement = document.activeElement;\n    const isInDialog = activeElement?.closest('[role=\"dialog\"]');\n    if (!isInDialog) {\n      previousFocusRef.current?.focus();\n    }\n  }, []);\n\n  if (!currentPage) {\n    return null;\n  }\n\n  return (\n    <CommandDialog\n      open={modal.visible}\n      onOpenChange={(open) => !open && handleClose()}\n      onCloseAutoFocus={handleCloseAutoFocus}\n    >\n      <CommandBar\n        page={resolvedPage}\n        canGoBack={pageStack.length > 0}\n        onGoBack={handleGoBack}\n        onSelect={handleSelect}\n        getLabel={(action) => resolveLabel(action)}\n        search={search}\n        onSearchChange={setSearch}\n        statuses={sortedStatuses}\n      />\n    </CommandDialog>\n  );\n}\n\nconst ProjectSelectionDialogImpl = create<ProjectSelectionDialogProps>(\n  ({ projectId, selection }) => {\n    return (\n      <ProjectProvider projectId={projectId}>\n        <ProjectSelectionContent selection={selection} />\n      </ProjectProvider>\n    );\n  }\n);\n\nexport const ProjectSelectionDialog = defineModal<\n  ProjectSelectionDialogProps,\n  unknown | undefined\n>(ProjectSelectionDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/selections/branchSelection.ts",
    "content": "import i18n from '@/i18n';\nimport type { BranchItem } from '@/shared/types/selectionItems';\nimport type { SelectionPage } from '../SelectionDialog';\n\nexport interface BranchSelectionResult {\n  branch: string;\n}\n\nexport function buildBranchSelectionPages(\n  branches: BranchItem[],\n  repoDisplayName?: string\n): Record<string, SelectionPage<BranchSelectionResult>> {\n  return {\n    selectBranch: {\n      id: 'selectBranch',\n      title: repoDisplayName\n        ? i18n.t('commandBar.selectBranchFor', { repoName: repoDisplayName })\n        : i18n.t('commandBar.selectBranch'),\n      buildGroups: () => [\n        {\n          label: 'Branches',\n          items: branches.map((b) => ({\n            type: 'branch' as const,\n            branch: b,\n          })),\n        },\n      ],\n      onSelect: (item) => {\n        if (item.type === 'branch') {\n          return { type: 'complete', data: { branch: item.branch.name } };\n        }\n        return { type: 'complete', data: undefined as never };\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/selections/prioritySelection.ts",
    "content": "import type { IssuePriority } from 'shared/remote-types';\nimport type { PriorityItem } from '@/shared/types/selectionItems';\nimport type { SelectionPage } from '../SelectionDialog';\n\nexport interface PrioritySelectionResult {\n  priority: IssuePriority | null;\n}\n\nconst PRIORITY_ITEMS: PriorityItem[] = [\n  { id: null, name: 'No priority' },\n  { id: 'urgent', name: 'Urgent' },\n  { id: 'high', name: 'High' },\n  { id: 'medium', name: 'Medium' },\n  { id: 'low', name: 'Low' },\n];\n\nexport function buildPrioritySelectionPages(): Record<\n  string,\n  SelectionPage<PrioritySelectionResult>\n> {\n  return {\n    selectPriority: {\n      id: 'selectPriority',\n      title: 'Select Priority',\n      buildGroups: () => [\n        {\n          label: 'Priority',\n          items: PRIORITY_ITEMS.map((p) => ({\n            type: 'priority' as const,\n            priority: p,\n          })),\n        },\n      ],\n      onSelect: (item) => {\n        if (item.type === 'priority') {\n          return {\n            type: 'complete',\n            data: { priority: item.priority.id },\n          };\n        }\n        return { type: 'complete', data: undefined as never };\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/selections/relationshipSelection.ts",
    "content": "import type { Issue } from 'shared/remote-types';\nimport type { SelectionPage } from '../SelectionDialog';\n\nexport interface RelationshipSelectionResult {\n  issueId: string;\n}\n\nexport function buildRelationshipSelectionPages(\n  issues: Issue[]\n): Record<string, SelectionPage<RelationshipSelectionResult>> {\n  return {\n    selectRelationshipIssue: {\n      id: 'selectRelationshipIssue',\n      title: 'Select Issue',\n      buildGroups: () => [\n        {\n          label: 'Issues',\n          items: issues.map((issue) => ({ type: 'issue' as const, issue })),\n        },\n      ],\n      onSelect: (item) => {\n        if (item.type === 'issue') {\n          return {\n            type: 'complete',\n            data: { issueId: item.issue.id },\n          };\n        }\n        return { type: 'complete', data: undefined as never };\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/selections/repoSelection.ts",
    "content": "import type { RepoItem } from '@/shared/types/selectionItems';\nimport type { SelectionPage } from '../SelectionDialog';\n\nexport interface RepoSelectionResult {\n  repoId: string;\n}\n\nexport function buildRepoSelectionPages(\n  repos: RepoItem[]\n): Record<string, SelectionPage<RepoSelectionResult>> {\n  return {\n    selectRepo: {\n      id: 'selectRepo',\n      title: 'Select Repository',\n      buildGroups: () => [\n        {\n          label: 'Repositories',\n          items: repos.map((r) => ({ type: 'repo' as const, repo: r })),\n        },\n      ],\n      onSelect: (item) => {\n        if (item.type === 'repo') {\n          return { type: 'complete', data: { repoId: item.repo.id } };\n        }\n        return { type: 'complete', data: undefined as never };\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/selections/statusSelection.ts",
    "content": "import type { StatusItem } from '@/shared/types/selectionItems';\nimport type { SelectionPage } from '../SelectionDialog';\n\nexport interface StatusSelectionResult {\n  statusId: string;\n}\n\nexport function buildStatusSelectionPages(\n  statuses: StatusItem[]\n): Record<string, SelectionPage<StatusSelectionResult>> {\n  return {\n    selectStatus: {\n      id: 'selectStatus',\n      title: 'Select Status',\n      buildGroups: () => [\n        {\n          label: 'Statuses',\n          items: statuses.map((s) => ({ type: 'status' as const, status: s })),\n        },\n      ],\n      onSelect: (item) => {\n        if (item.type === 'status') {\n          return {\n            type: 'complete',\n            data: { statusId: item.status.id },\n          };\n        }\n        return { type: 'complete', data: undefined as never };\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/command-bar/selections/subIssueSelection.ts",
    "content": "import type { Issue } from 'shared/remote-types';\nimport type { SelectionPage } from '../SelectionDialog';\n\nexport type SubIssueSelectionResult =\n  | { type: 'selected'; issueId: string }\n  | { type: 'createNew' };\n\nexport function buildSubIssueSelectionPages(\n  issues: Issue[],\n  mode: 'addChild' | 'setParent'\n): Record<string, SelectionPage<SubIssueSelectionResult>> {\n  const title = mode === 'setParent' ? 'Make Sub-issue of' : 'Add Sub-issue';\n  return {\n    selectSubIssue: {\n      id: 'selectSubIssue',\n      title,\n      buildGroups: () => [\n        {\n          label: 'Issues',\n          items: [\n            ...(mode === 'addChild'\n              ? [{ type: 'createSubIssue' as const }]\n              : []),\n            ...issues.map((issue) => ({ type: 'issue' as const, issue })),\n          ],\n        },\n      ],\n      onSelect: (item) => {\n        if (item.type === 'issue') {\n          return {\n            type: 'complete',\n            data: { type: 'selected', issueId: item.issue.id },\n          };\n        }\n        if (item.type === 'createSubIssue') {\n          return {\n            type: 'complete',\n            data: { type: 'createNew' },\n          };\n        }\n        return { type: 'complete', data: undefined as never };\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/global/OAuthDialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { LogIn, Loader2 } from 'lucide-react';\nimport { OAuthSignInButton } from '@vibe/ui/components/OAuthButtons';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useCallback, useState, useRef, useEffect } from 'react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useAuthMutations } from '@/shared/hooks/auth/useAuthMutations';\nimport { useAuthStatus } from '@/shared/hooks/auth/useAuthStatus';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { organizationKeys } from '@/shared/hooks/organizationKeys';\nimport { tokenManager } from '@/shared/lib/auth/tokenManager';\nimport type { ProfileResponse } from 'shared/types';\nimport { useTranslation } from 'react-i18next';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport type OAuthProvider = 'github' | 'google';\ntype OAuthDialogProps = { initialProvider?: OAuthProvider };\n\ntype OAuthState =\n  | { type: 'select' }\n  | { type: 'waiting'; provider: OAuthProvider }\n  | { type: 'success'; profile: ProfileResponse }\n  | { type: 'error'; message: string };\n\nconst OAuthDialogImpl = create<OAuthDialogProps>(({ initialProvider }) => {\n  const modal = useModal();\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const { reloadSystem } = useUserSystem();\n  const [state, setState] = useState<OAuthState>({ type: 'select' });\n  const popupRef = useRef<Window | null>(null);\n  const autoStartedRef = useRef(false);\n  const [isPolling, setIsPolling] = useState(false);\n\n  // Auth mutations hook\n  const { initHandoff } = useAuthMutations({\n    onInitSuccess: (data) => {\n      // Open popup window with authorize URL\n      const width = 600;\n      const height = 700;\n      const left = window.screenX + (window.outerWidth - width) / 2;\n      const top = window.screenY + (window.outerHeight - height) / 2;\n\n      popupRef.current = window.open(\n        data.authorize_url,\n        'oauth-popup',\n        `width=${width},height=${height},left=${left},top=${top},popup=yes,noopener=yes`\n      );\n\n      // Start polling\n      setIsPolling(true);\n    },\n    onInitError: (error) => {\n      setState({\n        type: 'error',\n        message:\n          error instanceof Error\n            ? error.message\n            : 'Failed to initialize OAuth flow',\n      });\n    },\n  });\n\n  // Poll for auth status using proper query hook\n  const { data: statusData, isError: isStatusError } = useAuthStatus({\n    enabled: isPolling,\n  });\n\n  // Handle status check errors\n  useEffect(() => {\n    if (isStatusError && isPolling) {\n      setIsPolling(false);\n      setState({\n        type: 'error',\n        message: 'Failed to check OAuth status',\n      });\n    }\n  }, [isStatusError, isPolling]);\n\n  // Monitor status changes\n  useEffect(() => {\n    if (!isPolling || !statusData) return;\n\n    // Check if popup is closed\n    if (popupRef.current?.closed) {\n      setIsPolling(false);\n      if (!statusData.logged_in) {\n        setState({\n          type: 'error',\n          message: 'OAuth window was closed before completing authentication',\n        });\n      }\n    }\n\n    // If logged in, stop polling and trigger success\n    if (statusData.logged_in && statusData.profile) {\n      setIsPolling(false);\n      if (popupRef.current && !popupRef.current.closed) {\n        popupRef.current.close();\n      }\n\n      // Reload user system, then refresh token so paused Electric shapes\n      // resume after re-authentication without requiring a full page reload.\n      void (async () => {\n        await reloadSystem();\n        await tokenManager.triggerRefresh();\n      })();\n\n      // Invalidate organization caches to force fresh fetch after login\n      queryClient.invalidateQueries({ queryKey: organizationKeys.all });\n\n      setState({ type: 'success', profile: statusData.profile });\n      setTimeout(() => {\n        modal.resolve(statusData.profile);\n        modal.remove();\n      }, 1500);\n    }\n  }, [statusData, isPolling, modal, reloadSystem, queryClient]);\n\n  const handleProviderSelect = useCallback(\n    (provider: OAuthProvider) => {\n      setState({ type: 'waiting', provider });\n\n      // Get the current window location as return_to.\n      // When running inside Tauri the OAuth flow opens in the system browser,\n      // so we tag the callback URL so the server knows not to auto-close the tab.\n      const isTauri = '__TAURI_INTERNALS__' in window;\n      const returnTo = `${window.location.origin}/api/auth/handoff/complete${isTauri ? '?source=desktop' : ''}`;\n\n      // Initialize handoff flow\n      initHandoff.mutate({ provider, returnTo });\n    },\n    [initHandoff]\n  );\n\n  const handleClose = () => {\n    setIsPolling(false);\n    if (popupRef.current && !popupRef.current.closed) {\n      popupRef.current.close();\n    }\n    setState({ type: 'select' });\n    modal.resolve(null);\n    modal.remove();\n  };\n\n  const handleBack = () => {\n    setIsPolling(false);\n    if (popupRef.current && !popupRef.current.closed) {\n      popupRef.current.close();\n    }\n    setState({ type: 'select' });\n  };\n\n  // Cleanup polling when dialog closes\n  useEffect(() => {\n    if (!modal.visible) {\n      autoStartedRef.current = false;\n      setIsPolling(false);\n      if (popupRef.current && !popupRef.current.closed) {\n        popupRef.current.close();\n      }\n    }\n  }, [modal.visible]);\n\n  // Auto-start OAuth if a provider was preselected\n  useEffect(() => {\n    if (!modal.visible || !initialProvider) return;\n    if (state.type !== 'select') return;\n    if (autoStartedRef.current) return;\n\n    autoStartedRef.current = true;\n    handleProviderSelect(initialProvider);\n  }, [handleProviderSelect, initialProvider, modal.visible, state.type]);\n\n  const renderContent = () => {\n    switch (state.type) {\n      case 'select':\n        return (\n          <>\n            <DialogHeader>\n              <div className=\"flex items-center gap-3\">\n                <LogIn className=\"h-6 w-6 text-primary-foreground\" />\n                <DialogTitle>{t('oauth.title')}</DialogTitle>\n              </div>\n              <DialogDescription className=\"text-left pt-2\">\n                {t('oauth.description')}\n              </DialogDescription>\n            </DialogHeader>\n\n            <div className=\"space-y-3 py-4\">\n              <OAuthSignInButton\n                provider=\"github\"\n                className=\"w-full\"\n                onClick={() => handleProviderSelect('github')}\n              />\n              <OAuthSignInButton\n                provider=\"google\"\n                className=\"w-full\"\n                onClick={() => handleProviderSelect('google')}\n              />\n            </div>\n\n            <DialogFooter>\n              <Button variant=\"ghost\" onClick={handleClose}>\n                {t('buttons.cancel')}\n              </Button>\n            </DialogFooter>\n          </>\n        );\n\n      case 'waiting':\n        return (\n          <>\n            <DialogHeader>\n              <div className=\"flex items-center gap-3\">\n                <LogIn className=\"h-6 w-6 text-primary-foreground\" />\n                <DialogTitle>{t('oauth.waitingTitle')}</DialogTitle>\n              </div>\n              <DialogDescription className=\"text-left pt-2\">\n                {t('oauth.waitingDescription')}\n              </DialogDescription>\n            </DialogHeader>\n\n            <div className=\"space-y-4 py-6\">\n              <div className=\"flex items-center justify-center gap-3 text-sm text-muted-foreground\">\n                <Loader2 className=\"h-5 w-5 animate-spin\" />\n                <span>{t('oauth.waitingForAuth')}</span>\n              </div>\n              <p className=\"text-sm text-center text-muted-foreground\">\n                {t('oauth.popupInstructions')}\n              </p>\n            </div>\n\n            <DialogFooter className=\"gap-2 sm:gap-0\">\n              <Button variant=\"ghost\" onClick={handleBack}>\n                {t('oauth.back')}\n              </Button>\n              <Button variant=\"ghost\" onClick={handleClose}>\n                {t('buttons.cancel')}\n              </Button>\n            </DialogFooter>\n          </>\n        );\n\n      case 'success':\n        return (\n          <>\n            <DialogHeader>\n              <DialogTitle>{t('oauth.successTitle')}</DialogTitle>\n              <DialogDescription className=\"text-left pt-2\">\n                {t('oauth.welcomeBack', {\n                  name: state.profile.username || state.profile.email,\n                })}\n              </DialogDescription>\n            </DialogHeader>\n\n            <div className=\"py-4 flex items-center justify-center\">\n              <div className=\"text-green-500\">\n                <svg\n                  className=\"h-16 w-16\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M5 13l4 4L19 7\"\n                  />\n                </svg>\n              </div>\n            </div>\n          </>\n        );\n\n      case 'error':\n        return (\n          <>\n            <DialogHeader>\n              <DialogTitle>{t('oauth.errorTitle')}</DialogTitle>\n              <DialogDescription className=\"text-left pt-2\">\n                {t('oauth.errorDescription')}\n              </DialogDescription>\n            </DialogHeader>\n\n            <div className=\"py-4\">\n              <Alert variant=\"destructive\">\n                <AlertDescription>{state.message}</AlertDescription>\n              </Alert>\n            </div>\n\n            <DialogFooter className=\"gap-2 sm:gap-0\">\n              <Button variant=\"ghost\" onClick={handleBack}>\n                {t('oauth.tryAgain')}\n              </Button>\n              <Button variant=\"ghost\" onClick={handleClose}>\n                {t('buttons.close')}\n              </Button>\n            </DialogFooter>\n          </>\n        );\n    }\n  };\n\n  return (\n    <Dialog\n      open={modal.visible}\n      onOpenChange={(open) => {\n        if (!open) {\n          handleClose();\n        }\n      }}\n    >\n      <DialogContent className=\"sm:max-w-[500px]\">\n        {renderContent()}\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const OAuthDialog = defineModal<\n  OAuthDialogProps,\n  ProfileResponse | null\n>(OAuthDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/global/ReleaseNotesDialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { AlertCircle, ExternalLink, Loader2 } from 'lucide-react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal, type NoProps } from '@/shared/lib/modals';\nimport { useReleases } from '@/shared/hooks/useReleases';\nimport { SimpleMarkdown } from '@/shared/components/SimpleMarkdown';\n\nconst GITHUB_RELEASES_URL = 'https://github.com/BloopAI/vibe-kanban/releases';\n\nfunction formatDate(dateStr: string): string {\n  try {\n    return new Date(dateStr).toLocaleDateString(undefined, {\n      year: 'numeric',\n      month: 'short',\n      day: 'numeric',\n    });\n  } catch {\n    return dateStr;\n  }\n}\n\nfunction extractVersion(tagName: string): string {\n  return tagName.replace(/-\\d{14}$/, '');\n}\n\nconst ReleaseNotesDialogImpl = create<NoProps>(() => {\n  const modal = useModal();\n  const { data: releases, isLoading, isError } = useReleases();\n\n  const handleOpenInBrowser = () => {\n    window.open(GITHUB_RELEASES_URL, '_blank');\n  };\n\n  return (\n    <Dialog\n      open={modal.visible}\n      onOpenChange={(open) => !open && modal.resolve()}\n      className=\"h-[calc(100%-4rem)]\"\n    >\n      <DialogContent className=\"flex flex-col w-full h-full max-w-2xl max-h-[calc(100dvh-4rem)] p-0\">\n        <DialogHeader className=\"px-6 pt-5 pb-4 border-b flex-shrink-0\">\n          <DialogTitle className=\"text-lg font-semibold text-high\">\n            What&apos;s New\n          </DialogTitle>\n        </DialogHeader>\n\n        <div className=\"flex-1 overflow-y-auto px-6 py-4 space-y-6 scrollbar-thin\">\n          {isLoading && (\n            <div className=\"flex items-center justify-center py-12\">\n              <Loader2 className=\"h-5 w-5 animate-spin text-low\" />\n            </div>\n          )}\n\n          {isError && (\n            <div className=\"flex flex-col items-center justify-center py-12 text-center space-y-3\">\n              <AlertCircle className=\"h-8 w-8 text-low\" />\n              <p className=\"text-sm text-low\">Unable to load release notes.</p>\n              <Button variant=\"outline\" size=\"sm\" onClick={handleOpenInBrowser}>\n                <ExternalLink className=\"h-3.5 w-3.5 mr-1.5\" />\n                View on GitHub\n              </Button>\n            </div>\n          )}\n\n          {releases?.map((release) => (\n            <article key={release.tag_name} className=\"space-y-1.5\">\n              <div className=\"flex items-baseline gap-2\">\n                <h2 className=\"text-sm font-semibold text-high\">\n                  {extractVersion(release.tag_name)}\n                </h2>\n                <span className=\"text-xs text-low\">\n                  {formatDate(release.published_at)}\n                </span>\n              </div>\n              {release.body && (\n                <SimpleMarkdown\n                  content={release.body}\n                  className=\"space-y-1.5 pl-0.5\"\n                />\n              )}\n            </article>\n          ))}\n        </div>\n\n        <DialogFooter className=\"px-6 py-3 border-t flex-shrink-0\">\n          <Button variant=\"outline\" size=\"sm\" onClick={handleOpenInBrowser}>\n            <ExternalLink className=\"h-3.5 w-3.5 mr-1.5\" />\n            Open on GitHub\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const ReleaseNotesDialog = defineModal<void, void>(\n  ReleaseNotesDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/kanban/AssigneeSelectionDialog.tsx",
    "content": "import { useCallback, useMemo, useRef, useEffect, useState } from 'react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useTranslation } from 'react-i18next';\nimport type { Project } from 'shared/remote-types';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\nimport { defineModal } from '@/shared/lib/modals';\nimport { CommandDialog } from '@vibe/ui/components/Command';\nimport {\n  MultiSelectCommandBar,\n  type MultiSelectOption,\n} from '@vibe/ui/components/MultiSelectCommandBar';\nimport { UserAvatar } from '@vibe/ui/components/UserAvatar';\nimport { OrgProvider } from '@/shared/providers/remote/OrgProvider';\nimport { useOrgContext } from '@/shared/hooks/useOrgContext';\nimport { ProjectProvider } from '@/shared/providers/remote/ProjectProvider';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { useOrganizationProjects } from '@/shared/hooks/useOrganizationProjects';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\nimport {\n  getDestinationHostId,\n  getProjectDestination,\n} from '@/shared/lib/routes/appNavigation';\nimport {\n  buildKanbanIssueComposerKey,\n  patchKanbanIssueComposer,\n  useKanbanIssueComposer,\n} from '@/shared/stores/useKanbanIssueComposerStore';\nexport interface AssigneeSelectionDialogProps {\n  projectId: string;\n  issueIds: string[];\n  isCreateMode?: boolean;\n  /** Initial assignee IDs for create mode (used instead of URL params when provided) */\n  createModeAssigneeIds?: string[];\n  /** Callback for create-mode assignee changes (bypasses URL params when provided) */\n  onCreateModeAssigneesChange?: (assigneeIds: string[]) => void;\n  /** Optional additional options for create-mode selection (e.g. \"Me\", \"Unassigned\"). */\n  additionalOptions?: MultiSelectOption<string>[];\n}\n\nconst getUserDisplayName = (user: OrganizationMemberWithProfile): string => {\n  return (\n    [user.first_name, user.last_name].filter(Boolean).join(' ') ||\n    user.username ||\n    'User'\n  );\n};\n\n/** Inner component that uses contexts to render the selection UI */\nfunction AssigneeSelectionContent({\n  projectId,\n  issueIds,\n  isCreateMode,\n  createModeAssigneeIds,\n  onCreateModeAssigneesChange,\n  additionalOptions,\n}: {\n  projectId: string;\n  issueIds: string[];\n  isCreateMode: boolean;\n  createModeAssigneeIds?: string[];\n  onCreateModeAssigneesChange?: (assigneeIds: string[]) => void;\n  additionalOptions?: MultiSelectOption<string>[];\n}) {\n  const { t } = useTranslation('common');\n  const modal = useModal();\n  const previousFocusRef = useRef<HTMLElement | null>(null);\n  const hasCreateCallback = onCreateModeAssigneesChange != null;\n  const destination = useCurrentAppDestination();\n  const projectDestination = useMemo(\n    () => getProjectDestination(destination),\n    [destination]\n  );\n  const resolvedProjectId = projectId || projectDestination?.projectId || null;\n  const issueComposerKey = useMemo(() => {\n    if (!resolvedProjectId) return null;\n    const hostId = getDestinationHostId(projectDestination);\n    return buildKanbanIssueComposerKey(hostId, resolvedProjectId);\n  }, [resolvedProjectId, projectDestination]);\n  const issueComposer = useKanbanIssueComposer(issueComposerKey);\n\n  // Get users from OrgContext - use membersWithProfilesById for OrganizationMemberWithProfile\n  const { membersWithProfilesById } = useOrgContext();\n  const users = useMemo(\n    () => [...membersWithProfilesById.values()],\n    [membersWithProfilesById]\n  );\n\n  // Get issue assignees and mutation functions from ProjectContext\n  const { issueAssignees, insertIssueAssignee, removeIssueAssignee } =\n    useProjectContext();\n\n  // Local state for create mode when using callback pattern\n  const [localCreateAssignees, setLocalCreateAssignees] = useState<string[]>(\n    createModeAssigneeIds ?? []\n  );\n\n  // Keep local create-mode state aligned with incoming source-of-truth values.\n  // This avoids stale selections when the draft is reset outside the dialog.\n  useEffect(() => {\n    if (!hasCreateCallback) return;\n    setLocalCreateAssignees(createModeAssigneeIds ?? []);\n  }, [hasCreateCallback, createModeAssigneeIds, modal.visible]);\n\n  // Fallback: get/set create mode defaults from shared in-memory state.\n  const issueComposerAssigneeIds = issueComposer?.draft.assigneeIds ?? [];\n\n  const setIssueComposerAssigneeIds = useCallback(\n    (assigneeIds: string[]) => {\n      if (!issueComposerKey) return;\n      patchKanbanIssueComposer(issueComposerKey, { assigneeIds });\n    },\n    [issueComposerKey]\n  );\n\n  // Derive selected assignee IDs based on mode and callback availability\n  const selectedIds = useMemo(() => {\n    if (isCreateMode) {\n      return hasCreateCallback\n        ? localCreateAssignees\n        : issueComposerAssigneeIds;\n    }\n    return issueAssignees\n      .filter((a) => issueIds.includes(a.issue_id))\n      .map((a) => a.user_id);\n  }, [\n    isCreateMode,\n    issueIds,\n    issueAssignees,\n    hasCreateCallback,\n    localCreateAssignees,\n    issueComposerAssigneeIds,\n  ]);\n\n  const [search, setSearch] = useState('');\n\n  // Capture focus when dialog opens and reset search\n  useEffect(() => {\n    if (modal.visible) {\n      previousFocusRef.current = document.activeElement as HTMLElement;\n      setSearch('');\n    }\n  }, [modal.visible]);\n\n  const options: MultiSelectOption<string>[] = useMemo(() => {\n    const userOptions = users.map((user) => ({\n      value: user.user_id,\n      label: getUserDisplayName(user),\n      searchValue: `${user.user_id} ${getUserDisplayName(user)} ${user.email ?? ''}`,\n      renderOption: () => (\n        <div className=\"flex items-center gap-base\">\n          <UserAvatar user={user} className=\"h-5 w-5 text-[10px]\" />\n          <span>{getUserDisplayName(user)}</span>\n        </div>\n      ),\n    }));\n\n    if (!isCreateMode || !additionalOptions || additionalOptions.length === 0) {\n      return userOptions;\n    }\n\n    return [...additionalOptions, ...userOptions];\n  }, [users, isCreateMode, additionalOptions]);\n\n  const handleToggle = useCallback(\n    (userId: string) => {\n      const isSelected = selectedIds.includes(userId);\n\n      if (isCreateMode) {\n        const newIds = isSelected\n          ? selectedIds.filter((id: string) => id !== userId)\n          : [...selectedIds, userId];\n        if (onCreateModeAssigneesChange) {\n          setLocalCreateAssignees(newIds);\n          onCreateModeAssigneesChange(newIds);\n        } else {\n          setIssueComposerAssigneeIds(newIds);\n        }\n      } else {\n        // Edit mode: apply mutation immediately for each issue\n        for (const issueId of issueIds) {\n          if (isSelected) {\n            // Remove the assignee\n            const record = issueAssignees.find(\n              (a) => a.issue_id === issueId && a.user_id === userId\n            );\n            if (record) {\n              removeIssueAssignee(record.id);\n            }\n          } else {\n            // Add the assignee\n            insertIssueAssignee({ issue_id: issueId, user_id: userId });\n          }\n        }\n      }\n\n      setSearch('');\n    },\n    [\n      isCreateMode,\n      selectedIds,\n      issueIds,\n      issueAssignees,\n      onCreateModeAssigneesChange,\n      setIssueComposerAssigneeIds,\n      insertIssueAssignee,\n      removeIssueAssignee,\n    ]\n  );\n\n  const handleClose = useCallback(() => {\n    modal.hide();\n  }, [modal]);\n\n  // Restore focus when dialog closes\n  const handleCloseAutoFocus = useCallback((event: Event) => {\n    event.preventDefault();\n    previousFocusRef.current?.focus();\n  }, []);\n\n  return (\n    <CommandDialog\n      open={modal.visible}\n      onOpenChange={(open) => !open && modal.hide()}\n      onCloseAutoFocus={handleCloseAutoFocus}\n    >\n      <MultiSelectCommandBar\n        title={t('kanban.selectAssignees', 'Select assignees...')}\n        options={options}\n        selectedValues={selectedIds}\n        onToggle={handleToggle}\n        onClose={handleClose}\n        search={search}\n        onSearchChange={setSearch}\n      />\n    </CommandDialog>\n  );\n}\n\n/** Wrapper that provides OrgContext and ProjectContext */\nfunction AssigneeSelectionWithContext({\n  projectId,\n  issueIds,\n  isCreateMode = false,\n  createModeAssigneeIds,\n  onCreateModeAssigneesChange,\n  additionalOptions,\n}: AssigneeSelectionDialogProps) {\n  const destination = useCurrentAppDestination();\n  const projectDestination = useMemo(\n    () => getProjectDestination(destination),\n    [destination]\n  );\n  const resolvedProjectId = projectId || projectDestination?.projectId;\n  // Get organization ID from store (set when navigating to project)\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n\n  // Fallback: try to find org from projects if not in store\n  const { data: projects = [] } = useOrganizationProjects(selectedOrgId);\n  const project = projects.find((p: Project) => p.id === resolvedProjectId);\n  const organizationId = project?.organization_id ?? selectedOrgId;\n\n  // If we don't have the required IDs, render nothing\n  if (!organizationId || !resolvedProjectId) {\n    return null;\n  }\n\n  return (\n    <OrgProvider organizationId={organizationId}>\n      <ProjectProvider projectId={resolvedProjectId}>\n        <AssigneeSelectionContent\n          projectId={resolvedProjectId}\n          issueIds={issueIds}\n          isCreateMode={isCreateMode}\n          createModeAssigneeIds={createModeAssigneeIds}\n          onCreateModeAssigneesChange={onCreateModeAssigneesChange}\n          additionalOptions={additionalOptions}\n        />\n      </ProjectProvider>\n    </OrgProvider>\n  );\n}\n\nconst AssigneeSelectionDialogImpl = create<AssigneeSelectionDialogProps>(\n  ({\n    projectId,\n    issueIds,\n    isCreateMode,\n    createModeAssigneeIds,\n    onCreateModeAssigneesChange,\n    additionalOptions,\n  }) => {\n    return (\n      <AssigneeSelectionWithContext\n        projectId={projectId}\n        issueIds={issueIds}\n        isCreateMode={isCreateMode}\n        createModeAssigneeIds={createModeAssigneeIds}\n        onCreateModeAssigneesChange={onCreateModeAssigneesChange}\n        additionalOptions={additionalOptions}\n      />\n    );\n  }\n);\n\nexport const AssigneeSelectionDialog = defineModal<\n  AssigneeSelectionDialogProps,\n  void\n>(AssigneeSelectionDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/kanban/KanbanFiltersDialog.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  SortAscendingIcon,\n  SortDescendingIcon,\n  TagIcon,\n  UsersIcon,\n} from '@phosphor-icons/react';\nimport type { IssuePriority, Tag } from 'shared/remote-types';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\nimport { cn } from '@/shared/lib/utils';\nimport {\n  KANBAN_ASSIGNEE_FILTER_VALUES,\n  type KanbanFilterState,\n  type KanbanSortField,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { UserAvatar } from '@vibe/ui/components/UserAvatar';\nimport { KanbanAssignee } from '@vibe/ui/components/KanbanAssignee';\nimport { Badge } from '@vibe/ui/components/Badge';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/Dialog';\nimport { Switch } from '@vibe/ui/components/Switch';\nimport { AssigneeSelectionDialog } from '@/shared/dialogs/kanban/AssigneeSelectionDialog';\nimport { PriorityFilterDropdown } from '@vibe/ui/components/PriorityFilterDropdown';\nimport {\n  MultiSelectDropdown,\n  type MultiSelectDropdownOption,\n} from '@vibe/ui/components/MultiSelectDropdown';\nimport {\n  PropertyDropdown,\n  type PropertyDropdownOption,\n} from '@vibe/ui/components/PropertyDropdown';\n\nconst SORT_OPTIONS: PropertyDropdownOption<KanbanSortField>[] = [\n  { value: 'sort_order', label: 'Manual' },\n  { value: 'priority', label: 'Priority' },\n  { value: 'created_at', label: 'Created' },\n  { value: 'updated_at', label: 'Updated' },\n  { value: 'title', label: 'Title' },\n];\n\ninterface KanbanFiltersDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  projectId: string;\n  currentUserId: string | null;\n  tags: Tag[];\n  users: OrganizationMemberWithProfile[];\n  filters: KanbanFilterState;\n  showSubIssues: boolean;\n  showWorkspaces: boolean;\n  onPrioritiesChange: (priorities: IssuePriority[]) => void;\n  onAssigneesChange: (assigneeIds: string[]) => void;\n  onTagsChange: (tagIds: string[]) => void;\n  onSortChange: (\n    sortField: KanbanSortField,\n    sortDirection: 'asc' | 'desc'\n  ) => void;\n  onShowSubIssuesChange: (show: boolean) => void;\n  onShowWorkspacesChange: (show: boolean) => void;\n}\n\nexport function KanbanFiltersDialog({\n  open,\n  onOpenChange,\n  projectId,\n  currentUserId,\n  tags,\n  users,\n  filters,\n  showSubIssues,\n  showWorkspaces,\n  onPrioritiesChange,\n  onAssigneesChange,\n  onTagsChange,\n  onSortChange,\n  onShowSubIssuesChange,\n  onShowWorkspacesChange,\n}: KanbanFiltersDialogProps) {\n  const { t } = useTranslation('common');\n\n  const currentUser = useMemo(\n    () => users.find((user) => user.user_id === currentUserId) ?? null,\n    [users, currentUserId]\n  );\n\n  const assigneeDialogOptions = useMemo(\n    () => [\n      {\n        value: KANBAN_ASSIGNEE_FILTER_VALUES.UNASSIGNED,\n        label: t('kanban.unassigned', 'Unassigned'),\n        renderOption: () => (\n          <div className=\"flex items-center gap-base\">\n            <UsersIcon className=\"size-icon-xs text-low\" weight=\"bold\" />\n            {t('kanban.unassigned', 'Unassigned')}\n          </div>\n        ),\n      },\n      {\n        value: KANBAN_ASSIGNEE_FILTER_VALUES.SELF,\n        label: t('kanban.self', 'Me'),\n        renderOption: () => (\n          <div className=\"flex items-center gap-base\">\n            {currentUser ? (\n              <UserAvatar user={currentUser} className=\"h-4 w-4 text-[8px]\" />\n            ) : (\n              <UsersIcon className=\"size-icon-xs text-low\" weight=\"bold\" />\n            )}\n            {t('kanban.self', 'Me')}\n          </div>\n        ),\n      },\n    ],\n    [t, currentUser]\n  );\n\n  const tagOptions: MultiSelectDropdownOption<string>[] = useMemo(\n    () =>\n      tags.map((tag) => ({\n        value: tag.id,\n        label: tag.name,\n        renderOption: () => (\n          <div className=\"flex items-center gap-base\">\n            <span\n              className=\"h-2 w-2 shrink-0 rounded-full\"\n              style={{ backgroundColor: tag.color }}\n            />\n            {tag.name}\n          </div>\n        ),\n      })),\n    [tags]\n  );\n\n  const usersById = useMemo(() => {\n    const map = new Map<string, OrganizationMemberWithProfile>();\n    for (const user of users) {\n      map.set(user.user_id, user);\n    }\n    return map;\n  }, [users]);\n\n  const renderAssigneeBadge = useMemo(\n    () => (selectedIds: string[]) => {\n      const resolved = selectedIds\n        .filter((id) => id !== KANBAN_ASSIGNEE_FILTER_VALUES.UNASSIGNED)\n        .map((id) => {\n          if (id === KANBAN_ASSIGNEE_FILTER_VALUES.SELF) {\n            return currentUser;\n          }\n\n          return usersById.get(id);\n        })\n        .filter((member): member is OrganizationMemberWithProfile => !!member);\n\n      if (resolved.length === 0) {\n        return (\n          <Badge\n            variant=\"secondary\"\n            className=\"h-5 min-w-5 justify-center border-none bg-brand px-1.5 py-0 text-xs\"\n          >\n            {selectedIds.length}\n          </Badge>\n        );\n      }\n\n      return <KanbanAssignee assignees={resolved} />;\n    },\n    [currentUser, usersById]\n  );\n\n  const handleOpenAssigneeDialog = useCallback(() => {\n    void AssigneeSelectionDialog.show({\n      projectId,\n      issueIds: [],\n      isCreateMode: true,\n      createModeAssigneeIds: filters.assigneeIds,\n      onCreateModeAssigneesChange: onAssigneesChange,\n      additionalOptions: assigneeDialogOptions,\n    });\n  }, [\n    assigneeDialogOptions,\n    filters.assigneeIds,\n    onAssigneesChange,\n    projectId,\n  ]);\n\n  const toggleSortDirection = useCallback(() => {\n    onSortChange(\n      filters.sortField,\n      filters.sortDirection === 'asc' ? 'desc' : 'asc'\n    );\n  }, [filters.sortDirection, filters.sortField, onSortChange]);\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-[720px] p-0\">\n        <div className=\"border-b border-border px-double pb-base pt-double\">\n          <DialogHeader className=\"space-y-half\">\n            <DialogTitle>{t('kanban.filters', 'Filters')}</DialogTitle>\n            <DialogDescription>\n              {t(\n                'kanban.filtersDescription',\n                'Adjust filters and sorting for this board view.'\n              )}\n            </DialogDescription>\n          </DialogHeader>\n        </div>\n\n        <div className=\"max-h-[72vh] overflow-y-auto px-double py-double\">\n          <div className=\"flex flex-wrap items-center gap-base\">\n            <PriorityFilterDropdown\n              values={filters.priorities}\n              onChange={onPrioritiesChange}\n            />\n\n            <button\n              type=\"button\"\n              onClick={handleOpenAssigneeDialog}\n              className={cn(\n                'flex items-center gap-half rounded-sm bg-panel px-base py-half',\n                'text-sm text-normal transition-colors hover:bg-secondary'\n              )}\n            >\n              <UsersIcon className=\"size-icon-xs\" weight=\"bold\" />\n              <span>{t('kanban.assignee', 'Assignee')}</span>\n              {filters.assigneeIds.length > 0 &&\n                renderAssigneeBadge(filters.assigneeIds)}\n            </button>\n\n            {tags.length > 0 && (\n              <MultiSelectDropdown\n                values={filters.tagIds}\n                options={tagOptions}\n                onChange={onTagsChange}\n                icon={TagIcon}\n                label={t('kanban.tags', 'Tags')}\n                menuLabel={t('kanban.filterByTag', 'Filter by tag')}\n              />\n            )}\n\n            <PropertyDropdown\n              value={filters.sortField}\n              options={SORT_OPTIONS}\n              onChange={(field) => onSortChange(field, filters.sortDirection)}\n              icon={\n                filters.sortDirection === 'asc'\n                  ? SortAscendingIcon\n                  : SortDescendingIcon\n              }\n              label={t('kanban.sortBy', 'Sort')}\n            />\n\n            <button\n              type=\"button\"\n              onClick={toggleSortDirection}\n              className={cn(\n                'flex items-center justify-center rounded-sm p-half',\n                'text-normal transition-colors hover:bg-secondary'\n              )}\n              title={\n                filters.sortDirection === 'asc'\n                  ? t('kanban.sortAscending', 'Ascending')\n                  : t('kanban.sortDescending', 'Descending')\n              }\n            >\n              {filters.sortDirection === 'asc' ? (\n                <SortAscendingIcon className=\"size-icon-base\" />\n              ) : (\n                <SortDescendingIcon className=\"size-icon-base\" />\n              )}\n            </button>\n\n            <div className=\"flex items-center gap-half rounded-sm bg-panel px-base py-half\">\n              <span className=\"whitespace-nowrap text-sm text-normal\">\n                {t('kanban.subIssuesFilterLabel', 'Sub-issues')}\n              </span>\n              <Switch\n                checked={showSubIssues}\n                onCheckedChange={onShowSubIssuesChange}\n              />\n            </div>\n\n            <div className=\"flex items-center gap-half rounded-sm bg-panel px-base py-half\">\n              <span className=\"whitespace-nowrap text-sm text-normal\">\n                {t('kanban.workspacesFilterLabel', 'Workspaces')}\n              </span>\n              <Switch\n                checked={showWorkspaces}\n                onCheckedChange={onShowWorkspacesChange}\n              />\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/org/CreateOrganizationDialog.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport { Label } from '@vibe/ui/components/Label';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useOrganizationMutations } from '@/shared/hooks/useOrganizationMutations';\nimport { useTranslation } from 'react-i18next';\nimport { defineModal, type NoProps } from '@/shared/lib/modals';\n\nexport type CreateOrganizationResult = {\n  action: 'created' | 'canceled';\n  organizationId?: string;\n};\n\nconst CreateOrganizationDialogImpl = create<NoProps>(() => {\n  const modal = useModal();\n  const { t } = useTranslation('organization');\n  const [name, setName] = useState('');\n  const [slug, setSlug] = useState('');\n  const [isManualSlug, setIsManualSlug] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const { createOrganization } = useOrganizationMutations({\n    onCreateSuccess: (result) => {\n      modal.resolve({\n        action: 'created',\n        organizationId: result.organization.id,\n      } as CreateOrganizationResult);\n      modal.hide();\n    },\n    onCreateError: (err) => {\n      setError(\n        err instanceof Error ? err.message : 'Failed to create organization'\n      );\n    },\n  });\n\n  useEffect(() => {\n    // Reset form when dialog opens\n    if (modal.visible) {\n      setName('');\n      setSlug('');\n      setIsManualSlug(false);\n      setError(null);\n    }\n  }, [modal.visible]);\n\n  // Auto-generate slug from name if not manually edited\n  useEffect(() => {\n    if (!isManualSlug && name) {\n      const generatedSlug = name\n        .toLowerCase()\n        .trim()\n        .replace(/[^a-z0-9\\s-]/g, '')\n        .replace(/\\s+/g, '-')\n        .replace(/-+/g, '-')\n        .replace(/^-|-$/g, '');\n      setSlug(generatedSlug);\n    }\n  }, [name, isManualSlug]);\n\n  const validateName = (value: string): string | null => {\n    const trimmedValue = value.trim();\n    if (!trimmedValue) return 'Organization name is required';\n    if (trimmedValue.length < 3)\n      return 'Organization name must be at least 3 characters';\n    if (trimmedValue.length > 50)\n      return 'Organization name must be 50 characters or less';\n    return null;\n  };\n\n  const validateSlug = (value: string): string | null => {\n    const trimmedValue = value.trim();\n    if (!trimmedValue) return 'Slug is required';\n    if (trimmedValue.length < 3) return 'Slug must be at least 3 characters';\n    if (trimmedValue.length > 50) return 'Slug must be 50 characters or less';\n    if (!/^[a-z0-9-]+$/.test(trimmedValue)) {\n      return 'Slug can only contain lowercase letters, numbers, and hyphens';\n    }\n    if (trimmedValue.startsWith('-') || trimmedValue.endsWith('-')) {\n      return 'Slug cannot start or end with a hyphen';\n    }\n    return null;\n  };\n\n  const handleCreate = () => {\n    const nameError = validateName(name);\n    if (nameError) {\n      setError(nameError);\n      return;\n    }\n\n    const slugError = validateSlug(slug);\n    if (slugError) {\n      setError(slugError);\n      return;\n    }\n\n    setError(null);\n    createOrganization.mutate({\n      name: name.trim(),\n      slug: slug.trim(),\n    });\n  };\n\n  const handleCancel = () => {\n    modal.resolve({ action: 'canceled' } as CreateOrganizationResult);\n    modal.hide();\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    if (!open) {\n      handleCancel();\n    }\n  };\n\n  const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setIsManualSlug(true);\n    setSlug(e.target.value);\n    setError(null);\n  };\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>{t('createDialog.title')}</DialogTitle>\n          <DialogDescription>{t('createDialog.description')}</DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"org-name\">{t('createDialog.nameLabel')}</Label>\n            <Input\n              id=\"org-name\"\n              value={name}\n              onChange={(e) => {\n                setName(e.target.value);\n                setError(null);\n              }}\n              placeholder={t('createDialog.namePlaceholder')}\n              maxLength={50}\n              autoFocus\n              disabled={createOrganization.isPending}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"org-slug\">{t('createDialog.slugLabel')}</Label>\n            <Input\n              id=\"org-slug\"\n              value={slug}\n              onChange={handleSlugChange}\n              placeholder={t('createDialog.slugPlaceholder')}\n              maxLength={50}\n              disabled={createOrganization.isPending}\n            />\n            <p className=\"text-xs text-muted-foreground\">\n              {t('createDialog.slugHelper')}\n            </p>\n          </div>\n\n          {error && (\n            <Alert variant=\"destructive\">\n              <AlertDescription>{error}</AlertDescription>\n            </Alert>\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button\n            variant=\"outline\"\n            onClick={handleCancel}\n            disabled={createOrganization.isPending}\n          >\n            {t('common:buttons.cancel')}\n          </Button>\n          <Button\n            onClick={handleCreate}\n            disabled={\n              !name.trim() || !slug.trim() || createOrganization.isPending\n            }\n          >\n            {createOrganization.isPending\n              ? t('createDialog.creating')\n              : t('createDialog.createButton')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const CreateOrganizationDialog = defineModal<\n  void,\n  CreateOrganizationResult\n>(CreateOrganizationDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/org/CreateRemoteProjectDialog.tsx",
    "content": "import { useState, useEffect, useMemo } from 'react';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport { Label } from '@vibe/ui/components/Label';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useTranslation } from 'react-i18next';\nimport { defineModal } from '@/shared/lib/modals';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport {\n  PROJECTS_SHAPE,\n  PROJECT_MUTATION,\n  type Project,\n} from 'shared/remote-types';\nimport { getRandomPresetColor, PRESET_COLORS } from '@/shared/lib/colors';\nimport { ColorPicker } from '@/shared/components/ui-new/containers/ColorPickerContainer';\n\nexport type CreateRemoteProjectDialogProps = {\n  organizationId: string;\n};\n\nexport type CreateRemoteProjectResult = {\n  action: 'created' | 'canceled';\n  project?: Project;\n};\n\nconst CreateRemoteProjectDialogImpl = create<CreateRemoteProjectDialogProps>(\n  ({ organizationId }) => {\n    const modal = useModal();\n    const { t } = useTranslation('projects');\n    const [name, setName] = useState('');\n    const [color, setColor] = useState<string>(() => getRandomPresetColor());\n    const [error, setError] = useState<string | null>(null);\n    const [isCreating, setIsCreating] = useState(false);\n\n    const params = useMemo(\n      () => ({ organization_id: organizationId }),\n      [organizationId]\n    );\n\n    const { insert, error: syncError } = useShape(PROJECTS_SHAPE, params, {\n      mutation: PROJECT_MUTATION,\n    });\n\n    useEffect(() => {\n      // Reset form when dialog opens\n      if (modal.visible) {\n        setName('');\n        setColor(getRandomPresetColor());\n        setError(null);\n        setIsCreating(false);\n      }\n    }, [modal.visible]);\n\n    useEffect(() => {\n      if (syncError) {\n        setError(syncError.message || 'Failed to create project');\n        setIsCreating(false);\n      }\n    }, [syncError]);\n\n    const validateName = (value: string): string | null => {\n      const trimmedValue = value.trim();\n      if (!trimmedValue) return 'Project name is required';\n      if (trimmedValue.length < 2)\n        return 'Project name must be at least 2 characters';\n      if (trimmedValue.length > 100)\n        return 'Project name must be 100 characters or less';\n      return null;\n    };\n\n    const handleCreate = async () => {\n      const nameError = validateName(name);\n      if (nameError) {\n        setError(nameError);\n        return;\n      }\n\n      setError(null);\n      setIsCreating(true);\n\n      try {\n        const { data: project, persisted } = insert({\n          organization_id: organizationId,\n          name: name.trim(),\n          color: color,\n        });\n\n        const persistedProject = await persisted;\n\n        modal.resolve({\n          action: 'created',\n          project: persistedProject ?? project,\n        } as CreateRemoteProjectResult);\n        modal.hide();\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : 'Failed to create project'\n        );\n        setIsCreating(false);\n      }\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as CreateRemoteProjectResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (isCreating) return;\n\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n      if (e.key === 'Enter' && name.trim() && !isCreating) {\n        e.preventDefault();\n        void handleCreate();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>\n              {t('createProjectDialog.title', 'Create Project')}\n            </DialogTitle>\n            <DialogDescription>\n              {t(\n                'createProjectDialog.description',\n                'Create a new project in this organization.'\n              )}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"project-name\">\n                {t('createProjectDialog.nameLabel', 'Project name')}\n              </Label>\n              <div className=\"flex items-center gap-2\">\n                <Input\n                  id=\"project-name\"\n                  value={name}\n                  onChange={(e) => {\n                    setName(e.target.value);\n                    setError(null);\n                  }}\n                  onKeyDown={handleKeyDown}\n                  placeholder={t(\n                    'createProjectDialog.namePlaceholder',\n                    'Enter project name'\n                  )}\n                  maxLength={100}\n                  autoFocus\n                  disabled={isCreating}\n                  className=\"flex-1\"\n                />\n                <ColorPicker\n                  value={color}\n                  onChange={setColor}\n                  colors={PRESET_COLORS}\n                  disabled={isCreating}\n                  align=\"start\"\n                  side=\"bottom\"\n                >\n                  <button\n                    type=\"button\"\n                    className=\"w-10 h-10 rounded border cursor-pointer shrink-0 disabled:opacity-50 disabled:cursor-not-allowed\"\n                    style={{ backgroundColor: `hsl(${color})` }}\n                    disabled={isCreating}\n                    aria-label={t(\n                      'createProjectDialog.selectColor',\n                      'Select project color'\n                    )}\n                  />\n                </ColorPicker>\n              </div>\n            </div>\n\n            {error && (\n              <Alert variant=\"destructive\">\n                <AlertDescription>{error}</AlertDescription>\n              </Alert>\n            )}\n          </div>\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isCreating}\n            >\n              {t('common:buttons.cancel', 'Cancel')}\n            </Button>\n            <Button\n              onClick={handleCreate}\n              disabled={!name.trim() || isCreating}\n            >\n              {isCreating\n                ? t('createProjectDialog.creating', 'Creating...')\n                : t('createProjectDialog.createButton', 'Create Project')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const CreateRemoteProjectDialog = defineModal<\n  CreateRemoteProjectDialogProps,\n  CreateRemoteProjectResult\n>(CreateRemoteProjectDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/org/DeleteRemoteProjectDialog.tsx",
    "content": "import { useState } from 'react';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { Loader2 } from 'lucide-react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useTranslation } from 'react-i18next';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface DeleteRemoteProjectDialogProps {\n  projectName: string;\n}\n\nexport type DeleteRemoteProjectResult = 'deleted' | 'canceled';\n\nconst DeleteRemoteProjectDialogImpl = create<DeleteRemoteProjectDialogProps>(\n  ({ projectName }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['projects', 'common']);\n    const [isDeleting, setIsDeleting] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n\n    const handleDelete = async () => {\n      setIsDeleting(true);\n      setError(null);\n\n      try {\n        // Resolve with 'deleted' to let parent handle the deletion\n        modal.resolve('deleted' as DeleteRemoteProjectResult);\n        modal.hide();\n      } catch {\n        setError(\n          t(\n            'deleteProjectDialog.error',\n            'Failed to delete project. Please try again.'\n          )\n        );\n      } finally {\n        setIsDeleting(false);\n      }\n    };\n\n    const handleCancel = () => {\n      modal.resolve('canceled' as DeleteRemoteProjectResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>\n              {t('deleteProjectDialog.title', 'Delete Project?')}\n            </DialogTitle>\n            <DialogDescription>\n              {t(\n                'deleteProjectDialog.description',\n                'This will permanently delete \"{{name}}\" and all its issues. This action cannot be undone.',\n                { name: projectName }\n              )}\n            </DialogDescription>\n          </DialogHeader>\n\n          {error && (\n            <Alert variant=\"destructive\">\n              <AlertDescription>{error}</AlertDescription>\n            </Alert>\n          )}\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isDeleting}\n            >\n              {t('common:buttons.cancel', 'Cancel')}\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleDelete}\n              disabled={isDeleting}\n            >\n              {isDeleting && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n              {t('common:buttons.delete', 'Delete')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const DeleteRemoteProjectDialog = defineModal<\n  DeleteRemoteProjectDialogProps,\n  DeleteRemoteProjectResult\n>(DeleteRemoteProjectDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/org/InviteMemberDialog.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport { Label } from '@vibe/ui/components/Label';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@vibe/ui/components/Select';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useOrganizationMutations } from '@/shared/hooks/useOrganizationMutations';\nimport { MemberRole } from 'shared/types';\nimport { useTranslation } from 'react-i18next';\nimport { defineModal } from '@/shared/lib/modals';\nimport { ApiError } from '@/shared/lib/api';\nimport { getRemoteApiUrl } from '@/shared/lib/remoteApi';\nimport { ArrowSquareOut } from '@phosphor-icons/react';\n\nexport type InviteMemberResult = {\n  action: 'invited' | 'canceled';\n};\n\nexport interface InviteMemberDialogProps {\n  organizationId: string;\n}\n\nconst InviteMemberDialogImpl = create<InviteMemberDialogProps>((props) => {\n  const modal = useModal();\n  const { organizationId } = props;\n  const { t } = useTranslation('organization');\n  const [email, setEmail] = useState('');\n  const [role, setRole] = useState<MemberRole>(MemberRole.MEMBER);\n  const [error, setError] = useState<string | null>(null);\n  const [isSubscriptionRequired, setIsSubscriptionRequired] = useState(false);\n\n  const { createInvitation } = useOrganizationMutations({\n    onInviteSuccess: () => {\n      modal.resolve({ action: 'invited' } as InviteMemberResult);\n      modal.hide();\n    },\n    onInviteError: (err) => {\n      if (err instanceof ApiError && err.statusCode === 402) {\n        setIsSubscriptionRequired(true);\n        setError(t('inviteDialog.subscriptionRequired'));\n      } else {\n        setIsSubscriptionRequired(false);\n        setError(\n          err instanceof Error ? err.message : 'Failed to send invitation'\n        );\n      }\n    },\n  });\n\n  useEffect(() => {\n    // Reset form when dialog opens\n    if (modal.visible) {\n      setEmail('');\n      setRole(MemberRole.MEMBER);\n      setError(null);\n      setIsSubscriptionRequired(false);\n    }\n  }, [modal.visible]);\n\n  const validateEmail = (value: string): string | null => {\n    const trimmedValue = value.trim();\n    if (!trimmedValue) return 'Email is required';\n\n    // Basic email validation regex\n    const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n    if (!emailRegex.test(trimmedValue)) {\n      return 'Please enter a valid email address';\n    }\n\n    return null;\n  };\n\n  const handleInvite = () => {\n    const emailError = validateEmail(email);\n    if (emailError) {\n      setError(emailError);\n      return;\n    }\n\n    if (!organizationId) {\n      setError('No organization selected');\n      return;\n    }\n\n    setError(null);\n    createInvitation.mutate({\n      orgId: organizationId,\n      data: {\n        email: email.trim(),\n        role: role,\n      },\n    });\n  };\n\n  const handleCancel = () => {\n    modal.resolve({ action: 'canceled' } as InviteMemberResult);\n    modal.hide();\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    if (!open) {\n      handleCancel();\n    }\n  };\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>{t('inviteDialog.title')}</DialogTitle>\n          <DialogDescription>{t('inviteDialog.description')}</DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"invite-email\">{t('inviteDialog.emailLabel')}</Label>\n            <Input\n              id=\"invite-email\"\n              type=\"email\"\n              value={email}\n              onChange={(e) => {\n                setEmail(e.target.value);\n                setError(null);\n              }}\n              placeholder={t('inviteDialog.emailPlaceholder')}\n              autoFocus\n              disabled={createInvitation.isPending}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"invite-role\">{t('inviteDialog.roleLabel')}</Label>\n            <Select\n              value={role}\n              onValueChange={(value) => setRole(value as MemberRole)}\n              disabled={createInvitation.isPending}\n            >\n              <SelectTrigger id=\"invite-role\">\n                <SelectValue placeholder={t('inviteDialog.rolePlaceholder')} />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value={MemberRole.MEMBER}>\n                  {t('roles.member')}\n                </SelectItem>\n                <SelectItem value={MemberRole.ADMIN}>\n                  {t('roles.admin')}\n                </SelectItem>\n              </SelectContent>\n            </Select>\n            <p className=\"text-xs text-muted-foreground\">\n              {t('inviteDialog.roleHelper')}\n            </p>\n          </div>\n\n          {error && (\n            <Alert variant={isSubscriptionRequired ? 'default' : 'destructive'}>\n              <AlertDescription>\n                {error}\n                {isSubscriptionRequired && getRemoteApiUrl() && (\n                  <div className=\"mt-2\">\n                    <p className=\"text-sm text-muted-foreground mb-2\">\n                      {t('inviteDialog.upgradePrompt')}\n                    </p>\n                    <PrimaryButton\n                      onClick={() =>\n                        window.open(\n                          `${getRemoteApiUrl()}/upgrade?org_id=${organizationId}`,\n                          '_blank'\n                        )\n                      }\n                      actionIcon={ArrowSquareOut}\n                    >\n                      {t('inviteDialog.upgradeButton')}\n                    </PrimaryButton>\n                  </div>\n                )}\n              </AlertDescription>\n            </Alert>\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button\n            variant=\"outline\"\n            onClick={handleCancel}\n            disabled={createInvitation.isPending}\n          >\n            {t('common:buttons.cancel')}\n          </Button>\n          <Button\n            onClick={handleInvite}\n            disabled={!email.trim() || createInvitation.isPending}\n          >\n            {createInvitation.isPending\n              ? t('inviteDialog.sending')\n              : t('inviteDialog.sendButton')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const InviteMemberDialog = defineModal<\n  InviteMemberDialogProps,\n  InviteMemberResult\n>(InviteMemberDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/scripts/ScriptFixerDialog.tsx",
    "content": "import { useState, useEffect, useMemo, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { Loader2 } from 'lucide-react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Label } from '@vibe/ui/components/Label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@vibe/ui/components/Select';\nimport { AutoExpandingTextarea } from '@vibe/ui/components/AutoExpandingTextarea';\nimport { VirtualizedProcessLogs } from '@/shared/components/VirtualizedProcessLogs';\nimport { RunningDots } from '@vibe/ui/components/RunningDots';\nimport { defineModal } from '@/shared/lib/modals';\nimport { repoApi, workspacesApi } from '@/shared/lib/api';\nimport { useLogStream } from '@/shared/hooks/useLogStream';\nimport { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses';\nimport type { RepoWithTargetBranch, PatchType, UpdateRepo } from 'shared/types';\n\nexport type ScriptType = 'setup' | 'cleanup' | 'dev_server' | 'archive';\n\nexport interface ScriptFixerDialogProps {\n  scriptType: ScriptType;\n  repos: RepoWithTargetBranch[];\n  workspaceId: string;\n  sessionId?: string;\n  initialRepoId?: string;\n}\n\nexport type ScriptFixerDialogResult = {\n  action: 'saved' | 'saved_and_tested' | 'canceled';\n};\n\ntype LogEntry = Extract<PatchType, { type: 'STDOUT' } | { type: 'STDERR' }>;\n\nconst ScriptFixerDialogImpl = create<ScriptFixerDialogProps>(\n  ({ scriptType, repos, workspaceId, sessionId, initialRepoId }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['tasks', 'common']);\n    const queryClient = useQueryClient();\n\n    // State\n    const [selectedRepoId, setSelectedRepoId] = useState<string>(\n      initialRepoId || repos[0]?.id || ''\n    );\n    const [script, setScript] = useState('');\n    const [originalScript, setOriginalScript] = useState('');\n    const [isLoadingRepo, setIsLoadingRepo] = useState(true);\n    const [isSaving, setIsSaving] = useState(false);\n    const [isTesting, setIsTesting] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n    // Track session ID locally so we can update it after starting a script\n    const [activeSessionId, setActiveSessionId] = useState<string | undefined>(\n      sessionId\n    );\n\n    // Get execution processes for the session to find latest script process\n    const { executionProcesses } = useExecutionProcesses(activeSessionId);\n\n    // Find the latest process for this script type\n    const latestProcess = useMemo(() => {\n      const runReason =\n        scriptType === 'setup'\n          ? 'setupscript'\n          : scriptType === 'cleanup'\n            ? 'cleanupscript'\n            : scriptType === 'archive'\n              ? 'archivescript'\n              : 'devserver';\n      const filtered = executionProcesses.filter(\n        (p) => p.run_reason === runReason && !p.dropped\n      );\n      // Sort by created_at descending and return the first one\n      return filtered.sort(\n        (a, b) =>\n          new Date(b.created_at as unknown as string).getTime() -\n          new Date(a.created_at as unknown as string).getTime()\n      )[0];\n    }, [executionProcesses, scriptType]);\n\n    // Stream logs for the latest process\n    const { logs: rawLogs, error: logsError } = useLogStream(\n      latestProcess?.id ?? ''\n    );\n    const logs: LogEntry[] = rawLogs.filter(\n      (l): l is LogEntry => l.type === 'STDOUT' || l.type === 'STDERR'\n    );\n\n    // Compute status for the latest process\n    const isProcessRunning = latestProcess?.status === 'running';\n    const isProcessCompleted = latestProcess?.status === 'completed';\n    const isProcessKilled = latestProcess?.status === 'killed';\n    const isProcessFailed = latestProcess?.status === 'failed';\n    // exit_code can be null, number, or BigInt - convert to Number for comparison\n    const exitCode = latestProcess?.exit_code;\n    const isExitCodeZero = exitCode == null || Number(exitCode) === 0;\n    const isProcessSuccessful = isProcessCompleted && isExitCodeZero;\n    const hasProcessError =\n      isProcessFailed || (isProcessCompleted && !isExitCodeZero);\n\n    // Reset selectedRepoId on dialog re-open\n    useEffect(() => {\n      if (!initialRepoId) return;\n      setSelectedRepoId(initialRepoId);\n    }, [initialRepoId]);\n\n    // Fetch the selected repo's script\n    useEffect(() => {\n      if (!selectedRepoId) return;\n\n      let cancelled = false;\n      setIsLoadingRepo(true);\n      setError(null);\n\n      (async () => {\n        try {\n          const repo = await repoApi.getById(selectedRepoId);\n          if (cancelled) return;\n\n          const scriptContent =\n            scriptType === 'setup'\n              ? (repo.setup_script ?? '')\n              : scriptType === 'cleanup'\n                ? (repo.cleanup_script ?? '')\n                : scriptType === 'archive'\n                  ? (repo.archive_script ?? '')\n                  : (repo.dev_server_script ?? '');\n\n          setScript(scriptContent);\n          setOriginalScript(scriptContent);\n        } catch (err) {\n          if (cancelled) return;\n          setError(\n            err instanceof Error ? err.message : t('common:errors.generic')\n          );\n        } finally {\n          if (!cancelled) setIsLoadingRepo(false);\n        }\n      })();\n\n      return () => {\n        cancelled = true;\n      };\n    }, [selectedRepoId, scriptType, t]);\n\n    const hasChanges = script !== originalScript;\n\n    const handleClose = useCallback(() => {\n      modal.resolve({ action: 'canceled' } as ScriptFixerDialogResult);\n      modal.hide();\n    }, [modal]);\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleClose();\n      }\n    };\n\n    const handleSave = useCallback(async () => {\n      if (!selectedRepoId) return;\n\n      setIsSaving(true);\n      setError(null);\n\n      try {\n        // Only send the field being edited - other fields will be preserved by the backend\n        const scriptValue = script.trim() || null;\n        const updateData: Partial<UpdateRepo> =\n          scriptType === 'setup'\n            ? { setup_script: scriptValue }\n            : scriptType === 'cleanup'\n              ? { cleanup_script: scriptValue }\n              : scriptType === 'archive'\n                ? { archive_script: scriptValue }\n                : { dev_server_script: scriptValue };\n\n        await repoApi.update(selectedRepoId, updateData as UpdateRepo);\n\n        // Invalidate repos cache\n        queryClient.invalidateQueries({ queryKey: ['repos'] });\n\n        setOriginalScript(script);\n        modal.resolve({ action: 'saved' } as ScriptFixerDialogResult);\n        modal.hide();\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : t('common:errors.generic')\n        );\n      } finally {\n        setIsSaving(false);\n      }\n    }, [selectedRepoId, script, scriptType, queryClient, modal, t]);\n\n    const handleSaveAndTest = useCallback(async () => {\n      if (!selectedRepoId) return;\n\n      setIsTesting(true);\n      setError(null);\n\n      try {\n        // Only send the field being edited - other fields will be preserved by the backend\n        const scriptValue = script.trim() || null;\n        const updateData: Partial<UpdateRepo> =\n          scriptType === 'setup'\n            ? { setup_script: scriptValue }\n            : scriptType === 'cleanup'\n              ? { cleanup_script: scriptValue }\n              : scriptType === 'archive'\n                ? { archive_script: scriptValue }\n                : { dev_server_script: scriptValue };\n\n        await repoApi.update(selectedRepoId, updateData as UpdateRepo);\n\n        // Invalidate repos cache\n        queryClient.invalidateQueries({ queryKey: ['repos'] });\n\n        setOriginalScript(script);\n\n        // Then run the script and capture the session ID from the returned process\n        if (scriptType === 'setup') {\n          const result = await workspacesApi.runSetupScript(workspaceId);\n          if (result.success) {\n            setActiveSessionId(result.data.session_id);\n          }\n        } else if (scriptType === 'cleanup') {\n          const result = await workspacesApi.runCleanupScript(workspaceId);\n          if (result.success) {\n            setActiveSessionId(result.data.session_id);\n          }\n        } else if (scriptType === 'archive') {\n          const result = await workspacesApi.runArchiveScript(workspaceId);\n          if (result.success) {\n            setActiveSessionId(result.data.session_id);\n          }\n        } else {\n          // Start the dev server\n          const processes = await workspacesApi.startDevServer(workspaceId);\n          if (processes.length > 0) {\n            setActiveSessionId(processes[0].session_id);\n          }\n        }\n\n        // Keep dialog open so user can see the new execution logs\n        // The logs will update automatically via useLogStream/useExecutionProcesses\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : t('common:errors.generic')\n        );\n      } finally {\n        setIsTesting(false);\n      }\n    }, [selectedRepoId, script, scriptType, workspaceId, queryClient, t]);\n\n    const dialogTitle =\n      scriptType === 'setup'\n        ? t('scriptFixer.setupScriptTitle')\n        : scriptType === 'cleanup'\n          ? t('scriptFixer.cleanupScriptTitle')\n          : scriptType === 'archive'\n            ? t('scriptFixer.archiveScriptTitle')\n            : t('scriptFixer.devServerTitle');\n\n    return (\n      <Dialog\n        open={modal.visible}\n        onOpenChange={handleOpenChange}\n        className=\"max-w-4xl w-[90vw]\"\n      >\n        <DialogContent className=\"max-h-[80vh] flex flex-col overflow-hidden\">\n          <DialogHeader>\n            <DialogTitle>{dialogTitle}</DialogTitle>\n          </DialogHeader>\n\n          <div className=\"flex-1 flex flex-col gap-4 min-h-0 min-w-0 overflow-hidden\">\n            {/* Repo selector (only show if multiple repos) */}\n            {repos.length > 1 && (\n              <div className=\"flex items-center gap-2\">\n                <Label htmlFor=\"repo-select\" className=\"shrink-0\">\n                  {t('scriptFixer.selectRepo')}\n                </Label>\n                <Select\n                  value={selectedRepoId}\n                  onValueChange={setSelectedRepoId}\n                >\n                  <SelectTrigger id=\"repo-select\" className=\"flex-1\">\n                    <SelectValue />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {repos.map((repo) => (\n                      <SelectItem key={repo.id} value={repo.id}>\n                        {repo.display_name || repo.path}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              </div>\n            )}\n\n            {/* Script editor */}\n            <div className=\"flex flex-col gap-2 flex-1 min-h-0 min-w-0\">\n              <Label>{t('scriptFixer.scriptLabel')}</Label>\n              <div className=\"bg-panel flex-1 min-h-[150px] max-h-[300px] overflow-auto border rounded-md min-w-0\">\n                {isLoadingRepo ? (\n                  <div className=\"h-full flex items-center justify-center\">\n                    <Loader2 className=\"h-6 w-6 animate-spin\" />\n                  </div>\n                ) : (\n                  <AutoExpandingTextarea\n                    value={script}\n                    onChange={(e) => setScript(e.target.value)}\n                    className=\"font-mono text-sm p-3 border-0 min-h-full bg-panel\"\n                    placeholder={\n                      scriptType === 'setup'\n                        ? 'npm install'\n                        : scriptType === 'cleanup'\n                          ? 'npm run lint'\n                          : 'npm run dev'\n                    }\n                    disableInternalScroll\n                  />\n                )}\n              </div>\n            </div>\n\n            {/* Logs section */}\n            <div\n              className=\"flex flex-col gap-2 min-h-0 min-w-0\"\n              style={{ height: '200px' }}\n            >\n              <div className=\"flex items-center justify-between gap-2\">\n                <Label>{t('scriptFixer.logsLabel')}</Label>\n                {/* Status indicator */}\n                {latestProcess && (\n                  <div className=\"flex items-center gap-2 text-sm\">\n                    {isProcessRunning ? (\n                      <>\n                        <RunningDots />\n                        <span className=\"text-muted-foreground\">\n                          {t('scriptFixer.statusRunning')}\n                        </span>\n                      </>\n                    ) : isProcessSuccessful ? (\n                      <>\n                        <span className=\"size-2 rounded-full bg-success\" />\n                        <span className=\"text-success\">\n                          {t('scriptFixer.statusSuccess')}\n                        </span>\n                      </>\n                    ) : hasProcessError ? (\n                      <>\n                        <span className=\"size-2 rounded-full bg-destructive bg-error\" />\n                        <span className=\"text-destructive text-error\">\n                          {t('scriptFixer.statusFailed', {\n                            exitCode: Number(latestProcess.exit_code ?? 0),\n                          })}\n                        </span>\n                      </>\n                    ) : isProcessKilled ? (\n                      <>\n                        <span className=\"size-2 rounded-full bg-low\" />\n                        <span className=\"text-muted-foreground\">\n                          {t('scriptFixer.statusKilled')}\n                        </span>\n                      </>\n                    ) : null}\n                  </div>\n                )}\n              </div>\n              <div className=\"bg-secondary py-base flex-1 border rounded-md bg-muted overflow-hidden min-w-0\">\n                {latestProcess ? (\n                  <VirtualizedProcessLogs\n                    logs={logs}\n                    error={logsError}\n                    searchQuery=\"\"\n                    matchIndices={[]}\n                    currentMatchIndex={-1}\n                  />\n                ) : (\n                  <div className=\"h-full flex items-center justify-center text-muted-foreground text-sm\">\n                    {t('scriptFixer.noLogs')}\n                  </div>\n                )}\n              </div>\n            </div>\n\n            {/* Error display */}\n            {error && <div className=\"text-destructive text-sm\">{error}</div>}\n          </div>\n\n          <DialogFooter className=\"gap-2 sm:gap-0\">\n            <Button variant=\"outline\" onClick={handleClose}>\n              {t('common:buttons.close')}\n            </Button>\n            <Button\n              variant=\"outline\"\n              onClick={handleSave}\n              disabled={!hasChanges || isSaving || isTesting}\n            >\n              {isSaving && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n              {t('scriptFixer.saveButton')}\n            </Button>\n            <Button\n              onClick={handleSaveAndTest}\n              disabled={isSaving || isTesting}\n            >\n              {isTesting && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n              {t('scriptFixer.saveAndTestButton')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const ScriptFixerDialog = defineModal<\n  ScriptFixerDialogProps,\n  ScriptFixerDialogResult\n>(ScriptFixerDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/CreateConfigurationDialog.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport { Label } from '@vibe/ui/components/Label';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@vibe/ui/components/Select';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface CreateConfigurationDialogProps {\n  executorType: string;\n  existingConfigs: string[];\n}\n\nexport type CreateConfigurationResult = {\n  action: 'created' | 'canceled';\n  configName?: string;\n  cloneFrom?: string | null;\n};\n\nconst CreateConfigurationDialogImpl = create<CreateConfigurationDialogProps>(\n  ({ executorType, existingConfigs }) => {\n    const modal = useModal();\n    const [configName, setConfigName] = useState('');\n    const [cloneFrom, setCloneFrom] = useState<string | null>(null);\n    const [error, setError] = useState<string | null>(null);\n\n    useEffect(() => {\n      // Reset form when dialog opens\n      if (modal.visible) {\n        setConfigName('');\n        setCloneFrom(null);\n        setError(null);\n      }\n    }, [modal.visible]);\n\n    const validateConfigName = (name: string): string | null => {\n      const trimmedName = name.trim();\n      if (!trimmedName) return 'Configuration name cannot be empty';\n      if (trimmedName.length > 40)\n        return 'Configuration name must be 40 characters or less';\n      if (!/^[a-zA-Z0-9_-]+$/.test(trimmedName)) {\n        return 'Configuration name can only contain letters, numbers, underscores, and hyphens';\n      }\n      if (existingConfigs.includes(trimmedName)) {\n        return 'A configuration with this name already exists';\n      }\n      return null;\n    };\n\n    const handleCreate = () => {\n      const validationError = validateConfigName(configName);\n      if (validationError) {\n        setError(validationError);\n        return;\n      }\n\n      modal.resolve({\n        action: 'created',\n        configName: configName.trim(),\n        cloneFrom,\n      } as CreateConfigurationResult);\n      modal.hide();\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as CreateConfigurationResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>Create New Configuration</DialogTitle>\n            <DialogDescription>\n              Add a new configuration for the {executorType} executor.\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"config-name\">Configuration Name</Label>\n              <Input\n                id=\"config-name\"\n                value={configName}\n                onChange={(e) => {\n                  setConfigName(e.target.value);\n                  setError(null);\n                }}\n                placeholder=\"e.g., PRODUCTION, DEVELOPMENT\"\n                maxLength={40}\n                autoFocus\n              />\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"clone-from\">Clone from (optional)</Label>\n              <Select\n                value={cloneFrom || '__blank__'}\n                onValueChange={(value) =>\n                  setCloneFrom(value === '__blank__' ? null : value)\n                }\n              >\n                <SelectTrigger id=\"clone-from\">\n                  <SelectValue placeholder=\"Start blank or clone existing\" />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"__blank__\">Start blank</SelectItem>\n                  {existingConfigs.map((configuration) => (\n                    <SelectItem key={configuration} value={configuration}>\n                      Clone from {configuration}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n\n            {error && (\n              <Alert variant=\"destructive\">\n                <AlertDescription>{error}</AlertDescription>\n              </Alert>\n            )}\n          </div>\n\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={handleCancel}>\n              Cancel\n            </Button>\n            <Button onClick={handleCreate} disabled={!configName.trim()}>\n              Create Configuration\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const CreateConfigurationDialog = defineModal<\n  CreateConfigurationDialogProps,\n  CreateConfigurationResult\n>(CreateConfigurationDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/DeleteConfigurationDialog.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { Loader2 } from 'lucide-react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface DeleteConfigurationDialogProps {\n  configName: string;\n  executorType: string;\n}\n\nexport type DeleteConfigurationResult = 'deleted' | 'canceled';\n\nconst DeleteConfigurationDialogImpl = create<DeleteConfigurationDialogProps>(\n  ({ configName, executorType }) => {\n    const { t } = useTranslation(['settings', 'common']);\n    const modal = useModal();\n    const [isDeleting, setIsDeleting] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n\n    const handleDelete = async () => {\n      setIsDeleting(true);\n      setError(null);\n\n      try {\n        // Resolve with 'deleted' to let parent handle the deletion\n        modal.resolve('deleted' as DeleteConfigurationResult);\n        modal.hide();\n      } catch {\n        setError('Failed to delete configuration. Please try again.');\n      } finally {\n        setIsDeleting(false);\n      }\n    };\n\n    const handleCancel = () => {\n      modal.resolve('canceled' as DeleteConfigurationResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>\n              {t('settings:settings.agents.deleteConfigDialog.title')}\n            </DialogTitle>\n            <DialogDescription>\n              {t('settings:settings.agents.deleteConfigDialog.description', {\n                configName,\n                executorType,\n              })}\n            </DialogDescription>\n          </DialogHeader>\n\n          {error && (\n            <Alert variant=\"destructive\">\n              <AlertDescription>{error}</AlertDescription>\n            </Alert>\n          )}\n\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isDeleting}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleDelete}\n              disabled={isDeleting}\n            >\n              {isDeleting && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n              {t('common:buttons.delete')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const DeleteConfigurationDialog = defineModal<\n  DeleteConfigurationDialogProps,\n  DeleteConfigurationResult\n>(DeleteConfigurationDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/SettingsDialog.tsx",
    "content": "import { useState, useEffect, useCallback, useMemo, useRef } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useTranslation } from 'react-i18next';\nimport {\n  GearIcon,\n  GitBranchIcon,\n  BuildingsIcon,\n  CloudIcon,\n  CpuIcon,\n  PlugIcon,\n  BroadcastIcon,\n  CaretLeftIcon,\n  XIcon,\n} from '@phosphor-icons/react';\nimport type { Icon } from '@phosphor-icons/react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nimport { cn } from '@/shared/lib/utils';\nimport { SettingsSection } from './settings/SettingsSection';\nimport type {\n  SettingsSectionType,\n  SettingsSectionInitialState,\n} from './settings/SettingsSection';\nimport {\n  SettingsDirtyProvider,\n  useSettingsDirty,\n} from './settings/SettingsDirtyContext';\nimport { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog';\n\nconst SETTINGS_SECTIONS: {\n  id: SettingsSectionType;\n  icon: Icon;\n}[] = [\n  { id: 'general', icon: GearIcon },\n  { id: 'repos', icon: GitBranchIcon },\n  { id: 'organizations', icon: BuildingsIcon },\n  { id: 'remote-projects', icon: CloudIcon },\n  { id: 'agents', icon: CpuIcon },\n  { id: 'mcp', icon: PlugIcon },\n  { id: 'relay', icon: BroadcastIcon },\n];\n\nexport interface SettingsDialogProps {\n  initialSection?: SettingsSectionType;\n  initialState?: SettingsSectionInitialState[SettingsSectionType];\n  sections?: SettingsSectionType[];\n}\n\ninterface SettingsDialogContentProps {\n  initialSection?: SettingsSectionType;\n  initialState?: SettingsSectionInitialState[SettingsSectionType];\n  sections?: SettingsSectionType[];\n  onClose: () => void;\n}\n\nfunction SettingsDialogContent({\n  initialSection,\n  initialState,\n  sections,\n  onClose,\n}: SettingsDialogContentProps) {\n  const { t } = useTranslation('settings');\n  const { isDirty } = useSettingsDirty();\n  const availableSections = useMemo(() => {\n    if (!sections || sections.length === 0) {\n      return SETTINGS_SECTIONS;\n    }\n\n    const allowed = new Set(sections);\n    const filtered = SETTINGS_SECTIONS.filter((section) =>\n      allowed.has(section.id)\n    );\n    return filtered.length > 0 ? filtered : SETTINGS_SECTIONS;\n  }, [sections]);\n\n  const resolvedInitialSection = useMemo<SettingsSectionType>(() => {\n    if (\n      initialSection &&\n      availableSections.some((section) => section.id === initialSection)\n    ) {\n      return initialSection;\n    }\n\n    return availableSections[0]?.id ?? 'general';\n  }, [availableSections, initialSection]);\n\n  const [activeSection, setActiveSection] = useState<SettingsSectionType>(\n    resolvedInitialSection\n  );\n  // On mobile, null means show the nav menu, a section means show that section\n  const [mobileShowContent, setMobileShowContent] = useState<boolean>(\n    initialSection === resolvedInitialSection\n  );\n  const isConfirmingRef = useRef(false);\n\n  const handleCloseWithConfirmation = useCallback(async () => {\n    if (isConfirmingRef.current) return;\n\n    if (isDirty) {\n      isConfirmingRef.current = true;\n      try {\n        const result = await ConfirmDialog.show({\n          title: t('settings.unsavedChanges.title'),\n          message: t('settings.unsavedChanges.message'),\n          confirmText: t('settings.unsavedChanges.discard'),\n          cancelText: t('settings.unsavedChanges.cancel'),\n          variant: 'destructive',\n        });\n        if (result === 'confirmed') {\n          onClose();\n        }\n      } finally {\n        isConfirmingRef.current = false;\n      }\n    } else {\n      onClose();\n    }\n  }, [isDirty, onClose, t]);\n\n  const handleSectionSelect = (sectionId: SettingsSectionType) => {\n    setActiveSection(sectionId);\n    setMobileShowContent(true);\n  };\n\n  const handleMobileBack = () => {\n    setMobileShowContent(false);\n  };\n\n  // Handle ESC key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        handleCloseWithConfirmation();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [handleCloseWithConfirmation]);\n\n  return (\n    <>\n      {/* Overlay */}\n      <div\n        data-tauri-drag-region\n        className=\"fixed inset-0 z-[9998] bg-black/50 animate-in fade-in-0 duration-200\"\n        onClick={handleCloseWithConfirmation}\n      />\n      {/* Dialog wrapper - handles positioning */}\n      <div\n        className={cn(\n          'fixed z-[9999]',\n          // Mobile: full screen\n          'inset-0',\n          // Desktop: centered with fixed size\n          'md:inset-auto md:left-1/2 md:top-1/2 md:-translate-x-1/2 md:-translate-y-1/2'\n        )}\n      >\n        {/* Dialog content - handles animation */}\n        <div\n          className={cn(\n            'h-full w-full flex overflow-hidden',\n            'bg-panel/95 backdrop-blur-sm shadow-lg',\n            'animate-in fade-in-0 slide-in-from-bottom-4 duration-200',\n            // Mobile: full screen, no rounded corners\n            'rounded-none border-0',\n            // Desktop: fixed size with rounded corners\n            'md:w-[900px] md:h-[700px] md:rounded-sm md:border md:border-border/50'\n          )}\n        >\n          {/* Sidebar - hidden on mobile when showing content */}\n          <div\n            className={cn(\n              'bg-secondary/80 border-r border-border flex flex-col',\n              // Mobile: full width, hidden when showing content\n              'w-full',\n              mobileShowContent && 'hidden',\n              // Desktop: fixed width sidebar, always visible\n              'md:w-56 md:block'\n            )}\n          >\n            {/* Header */}\n            <div className=\"p-4 border-b border-border flex items-center justify-between\">\n              <h2 className=\"text-lg font-semibold text-high\">\n                {t('settings.layout.nav.title')}\n              </h2>\n              {/* Close button - mobile only */}\n              <button\n                onClick={handleCloseWithConfirmation}\n                className=\"p-1 rounded-sm hover:bg-secondary text-low hover:text-normal md:hidden\"\n              >\n                <XIcon className=\"size-icon-sm\" weight=\"bold\" />\n              </button>\n            </div>\n            {/* Navigation */}\n            <nav className=\"flex-1 p-2 flex flex-col gap-1 overflow-y-auto\">\n              {availableSections.map((section) => {\n                const Icon = section.icon;\n                const isActive = activeSection === section.id;\n                return (\n                  <button\n                    key={section.id}\n                    onClick={() => handleSectionSelect(section.id)}\n                    className={cn(\n                      'flex items-center gap-3 text-left px-3 py-2 rounded-sm text-sm transition-colors',\n                      isActive\n                        ? 'bg-brand/10 text-brand font-medium'\n                        : 'text-normal hover:bg-primary/10'\n                    )}\n                  >\n                    <Icon className=\"size-icon-sm shrink-0\" weight=\"bold\" />\n                    <span className=\"truncate\">\n                      {t(`settings.layout.nav.${section.id}`)}\n                    </span>\n                  </button>\n                );\n              })}\n            </nav>\n          </div>\n          {/* Content - hidden on mobile when showing nav */}\n          <div\n            className={cn(\n              'flex-1 flex flex-col relative overflow-hidden',\n              // Mobile: full width, hidden when showing nav\n              !mobileShowContent && 'hidden',\n              // Desktop: always visible\n              'md:flex'\n            )}\n          >\n            {/* Mobile header with back button */}\n            <div className=\"flex items-center gap-2 p-3 border-b border-border md:hidden\">\n              <button\n                onClick={handleMobileBack}\n                className=\"p-1 rounded-sm hover:bg-secondary text-low hover:text-normal\"\n              >\n                <CaretLeftIcon className=\"size-icon-sm\" weight=\"bold\" />\n              </button>\n              <span className=\"text-sm font-medium text-high\">\n                {t(`settings.layout.nav.${activeSection}`)}\n              </span>\n              <button\n                onClick={handleCloseWithConfirmation}\n                className=\"ml-auto p-1 rounded-sm hover:bg-secondary text-low hover:text-normal\"\n              >\n                <XIcon className=\"size-icon-sm\" weight=\"bold\" />\n              </button>\n            </div>\n            {/* Section content */}\n            <div className=\"flex-1 overflow-y-auto\">\n              <SettingsSection\n                type={activeSection}\n                onClose={handleCloseWithConfirmation}\n                initialState={initialState}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n\nconst SettingsDialogImpl = create<SettingsDialogProps>(\n  ({ initialSection, initialState, sections }) => {\n    const modal = useModal();\n    const handleClose = useCallback(() => {\n      modal.hide();\n      modal.resolve();\n      modal.remove();\n    }, [modal]);\n\n    return createPortal(\n      <SettingsDirtyProvider>\n        <SettingsDialogContent\n          initialSection={initialSection}\n          initialState={initialState}\n          sections={sections}\n          onClose={handleClose}\n        />\n      </SettingsDirtyProvider>,\n      document.body\n    );\n  }\n);\n\nexport const SettingsDialog = defineModal<SettingsDialogProps | void, void>(\n  SettingsDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/AgentsSettingsSection.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  SpinnerIcon,\n  PlusIcon,\n  TrashIcon,\n  DotsThreeIcon,\n  StarIcon,\n} from '@phosphor-icons/react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/Dropdown';\nimport { ExecutorConfigForm } from './ExecutorConfigForm';\nimport { useProfiles } from '@/shared/hooks/useProfiles';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { CreateConfigurationDialog } from '../CreateConfigurationDialog';\nimport { DeleteConfigurationDialog } from '../DeleteConfigurationDialog';\nimport type { BaseCodingAgent, ExecutorConfigs } from 'shared/types';\nimport { cn } from '@/shared/lib/utils';\nimport { toPrettyCase } from '@/shared/lib/string';\nimport {\n  SettingsSaveBar,\n  TwoColumnPicker,\n  TwoColumnPickerColumn,\n  TwoColumnPickerItem,\n  TwoColumnPickerBadge,\n  TwoColumnPickerEmpty,\n} from './SettingsComponents';\nimport { useSettingsDirty } from './SettingsDirtyContext';\nimport { AgentIcon } from '@/shared/components/AgentIcon';\nimport { getExecutorVariantKeys } from '@/shared/lib/executor';\n\ntype ExecutorsMap = Record<string, Record<string, Record<string, unknown>>>;\n\nexport function AgentsSettingsSection() {\n  const { t } = useTranslation(['settings', 'common']);\n  const { setDirty: setContextDirty } = useSettingsDirty();\n\n  // Profiles hook for server state\n  const {\n    profilesContent: serverProfilesContent,\n    isLoading: profilesLoading,\n    isSaving: profilesSaving,\n    error: profilesError,\n    save: saveProfiles,\n  } = useProfiles();\n\n  const { config, updateAndSaveConfig, reloadSystem } = useUserSystem();\n\n  // Local editor state\n  const [profilesSuccess, setProfilesSuccess] = useState(false);\n  const [saveError, setSaveError] = useState<string | null>(null);\n\n  // Form-based editor state\n  const [selectedExecutorType, setSelectedExecutorType] =\n    useState<BaseCodingAgent | null>(null);\n  const [selectedConfiguration, setSelectedConfiguration] = useState<\n    string | null\n  >(null);\n  const [localParsedProfiles, setLocalParsedProfiles] =\n    useState<ExecutorConfigs | null>(null);\n  const [isDirty, setIsDirty] = useState(false);\n\n  // Initialize selection with default executor when config loads\n  useEffect(() => {\n    if (config?.executor_profile && !selectedExecutorType) {\n      setSelectedExecutorType(config.executor_profile.executor);\n      setSelectedConfiguration(config.executor_profile.variant || 'DEFAULT');\n    }\n  }, [config?.executor_profile, selectedExecutorType]);\n\n  // Sync server state to local state when not dirty\n  useEffect(() => {\n    if (!isDirty && serverProfilesContent) {\n      try {\n        const parsed = JSON.parse(serverProfilesContent);\n        setLocalParsedProfiles(parsed);\n      } catch (err) {\n        console.error('Failed to parse profiles JSON:', err);\n        setLocalParsedProfiles(null);\n      }\n    }\n  }, [serverProfilesContent, isDirty]);\n\n  // Sync dirty state to context for unsaved changes confirmation\n  useEffect(() => {\n    setContextDirty('agents', isDirty);\n    return () => setContextDirty('agents', false);\n  }, [isDirty, setContextDirty]);\n\n  const markDirty = (nextProfiles: unknown) => {\n    setLocalParsedProfiles(nextProfiles as ExecutorConfigs);\n    setIsDirty(true);\n  };\n\n  const handleCreateConfig = async (executor: string) => {\n    try {\n      const result = await CreateConfigurationDialog.show({\n        executorType: executor as BaseCodingAgent,\n        existingConfigs: getExecutorVariantKeys(\n          localParsedProfiles?.executors?.[executor as BaseCodingAgent]\n        ),\n      });\n\n      if (result.action === 'created' && result.configName) {\n        createConfiguration(executor, result.configName, result.cloneFrom);\n      }\n    } catch {\n      // User cancelled\n    }\n  };\n\n  const createConfiguration = (\n    executorType: string,\n    configName: string,\n    baseConfig?: string | null\n  ) => {\n    if (!localParsedProfiles || !localParsedProfiles.executors) return;\n\n    const executorsMap =\n      localParsedProfiles.executors as unknown as ExecutorsMap;\n    const base =\n      baseConfig && executorsMap[executorType]?.[baseConfig]?.[executorType]\n        ? executorsMap[executorType][baseConfig][executorType]\n        : {};\n\n    const updatedProfiles = {\n      ...localParsedProfiles,\n      executors: {\n        ...localParsedProfiles.executors,\n        [executorType]: {\n          ...executorsMap[executorType],\n          [configName]: {\n            [executorType]: base,\n          },\n        },\n      },\n    };\n\n    markDirty(updatedProfiles);\n    setSelectedExecutorType(executorType as BaseCodingAgent);\n    setSelectedConfiguration(configName);\n  };\n\n  const handleDeleteConfig = async (executor: string, configName: string) => {\n    try {\n      const result = await DeleteConfigurationDialog.show({\n        configName,\n        executorType: executor as BaseCodingAgent,\n      });\n\n      if (result === 'deleted') {\n        await deleteConfiguration(executor, configName);\n      }\n    } catch {\n      // User cancelled\n    }\n  };\n\n  const deleteConfiguration = async (\n    executorType: string,\n    configToDelete: string\n  ) => {\n    if (!localParsedProfiles) return;\n\n    setSaveError(null);\n\n    try {\n      const executorConfigs =\n        localParsedProfiles.executors[executorType as BaseCodingAgent];\n      if (!executorConfigs?.[configToDelete]) {\n        return;\n      }\n\n      const currentConfigs = getExecutorVariantKeys(executorConfigs);\n      if (currentConfigs.length <= 1) {\n        return;\n      }\n\n      const remainingConfigs = { ...executorConfigs };\n      delete remainingConfigs[configToDelete];\n\n      const updatedProfiles = {\n        ...localParsedProfiles,\n        executors: {\n          ...localParsedProfiles.executors,\n          [executorType]: remainingConfigs,\n        },\n      };\n\n      const executorsMap = updatedProfiles.executors as unknown as ExecutorsMap;\n      if (getExecutorVariantKeys(remainingConfigs).length === 0) {\n        executorsMap[executorType] = {\n          DEFAULT: { [executorType]: {} },\n        };\n      }\n\n      try {\n        await saveProfiles(JSON.stringify(updatedProfiles, null, 2));\n        setLocalParsedProfiles(updatedProfiles);\n        setIsDirty(false);\n\n        // Select another config if we deleted the selected one\n        if (\n          selectedExecutorType === executorType &&\n          selectedConfiguration === configToDelete\n        ) {\n          const nextConfigs = getExecutorVariantKeys(\n            executorsMap[executorType] || {}\n          );\n          setSelectedConfiguration(nextConfigs[0] || 'DEFAULT');\n        }\n\n        setProfilesSuccess(true);\n        setTimeout(() => setProfilesSuccess(false), 3000);\n        reloadSystem();\n      } catch (error: unknown) {\n        console.error('Failed to save deletion to backend:', error);\n        setSaveError(t('settings.agents.errors.deleteFailed'));\n      }\n    } catch (error) {\n      console.error('Error deleting configuration:', error);\n    }\n  };\n\n  const handleMakeDefault = async (executor: string, config: string) => {\n    try {\n      await updateAndSaveConfig({\n        executor_profile: {\n          executor: executor as BaseCodingAgent,\n          variant: config,\n        },\n      });\n      reloadSystem();\n    } catch (err) {\n      console.error('Error setting default:', err);\n    }\n  };\n\n  const handleExecutorConfigChange = (\n    executorType: string,\n    configuration: string,\n    formData: unknown\n  ) => {\n    if (!localParsedProfiles || !localParsedProfiles.executors) return;\n\n    const executorsMap =\n      localParsedProfiles.executors as unknown as ExecutorsMap;\n    const updatedProfiles = {\n      ...localParsedProfiles,\n      executors: {\n        ...localParsedProfiles.executors,\n        [executorType]: {\n          ...executorsMap[executorType],\n          [configuration]: {\n            [executorType]: formData,\n          },\n        },\n      },\n    };\n\n    markDirty(updatedProfiles);\n  };\n\n  const handleExecutorConfigSave = async (formData: unknown) => {\n    if (\n      !localParsedProfiles ||\n      !localParsedProfiles.executors ||\n      !selectedExecutorType ||\n      !selectedConfiguration\n    )\n      return;\n\n    setSaveError(null);\n\n    const updatedProfiles = {\n      ...localParsedProfiles,\n      executors: {\n        ...localParsedProfiles.executors,\n        [selectedExecutorType]: {\n          ...localParsedProfiles.executors[selectedExecutorType],\n          [selectedConfiguration]: {\n            [selectedExecutorType]: formData,\n          },\n        },\n      },\n    };\n\n    setLocalParsedProfiles(updatedProfiles);\n\n    try {\n      await saveProfiles(JSON.stringify(updatedProfiles, null, 2));\n      setProfilesSuccess(true);\n      setIsDirty(false);\n      setTimeout(() => setProfilesSuccess(false), 3000);\n      reloadSystem();\n    } catch (err: unknown) {\n      console.error('Failed to save profiles:', err);\n      setSaveError(t('settings.agents.errors.saveConfigFailed'));\n    }\n  };\n\n  // Save handler for agent configuration\n  const handleSave = async () => {\n    if (\n      isDirty &&\n      localParsedProfiles &&\n      selectedExecutorType &&\n      selectedConfiguration\n    ) {\n      const executorsMap =\n        localParsedProfiles.executors as unknown as ExecutorsMap;\n      const formData =\n        executorsMap[selectedExecutorType]?.[selectedConfiguration]?.[\n          selectedExecutorType\n        ];\n      if (formData) {\n        await handleExecutorConfigSave(formData);\n      }\n    }\n  };\n\n  // Discard handler for agent configuration\n  const handleDiscard = () => {\n    if (isDirty && serverProfilesContent) {\n      setIsDirty(false);\n      try {\n        const parsed = JSON.parse(serverProfilesContent);\n        setLocalParsedProfiles(parsed);\n      } catch {\n        // Ignore parse errors on discard\n      }\n    }\n  };\n\n  if (profilesLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-8 gap-2\">\n        <SpinnerIcon\n          className=\"size-icon-lg animate-spin text-brand\"\n          weight=\"bold\"\n        />\n        <span className=\"text-normal\">{t('settings.agents.loading')}</span>\n      </div>\n    );\n  }\n\n  const executorsMap =\n    localParsedProfiles?.executors as unknown as ExecutorsMap;\n\n  return (\n    <>\n      {/* Status messages */}\n      {!!profilesError && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error mb-4\">\n          {profilesError instanceof Error\n            ? profilesError.message\n            : String(profilesError)}\n        </div>\n      )}\n\n      {profilesSuccess && (\n        <div className=\"bg-success/10 border border-success/50 rounded-sm p-4 text-success font-medium mb-4\">\n          {t('settings.agents.save.success')}\n        </div>\n      )}\n\n      {saveError && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error mb-4\">\n          {saveError}\n        </div>\n      )}\n\n      {localParsedProfiles?.executors ? (\n        /* Two-column layout: agents and variants on top, config form below */\n        <div className=\"space-y-4\">\n          {/* Two-column selector - Finder-like style, stacked on mobile */}\n          <TwoColumnPicker>\n            {/* Agents column */}\n            <TwoColumnPickerColumn\n              label={t('settings.agents.editor.agentLabel')}\n              isFirst\n            >\n              {Object.keys(localParsedProfiles.executors).map((executor) => {\n                const isDefault =\n                  config?.executor_profile?.executor === executor;\n                return (\n                  <TwoColumnPickerItem\n                    key={executor}\n                    selected={selectedExecutorType === executor}\n                    onClick={() => {\n                      setSelectedExecutorType(executor as BaseCodingAgent);\n                      const configs = getExecutorVariantKeys(\n                        localParsedProfiles.executors[\n                          executor as BaseCodingAgent\n                        ]\n                      );\n                      if (configs.length > 0) {\n                        setSelectedConfiguration(configs[0]);\n                      }\n                    }}\n                    leading={\n                      <AgentIcon\n                        agent={executor as BaseCodingAgent}\n                        className=\"size-icon-sm shrink-0\"\n                      />\n                    }\n                    trailing={\n                      isDefault && (\n                        <TwoColumnPickerBadge variant=\"brand\">\n                          {t('settings.agents.editor.isDefault')}\n                        </TwoColumnPickerBadge>\n                      )\n                    }\n                  >\n                    {toPrettyCase(executor)}\n                  </TwoColumnPickerItem>\n                );\n              })}\n            </TwoColumnPickerColumn>\n\n            {/* Variants column */}\n            <TwoColumnPickerColumn\n              label={t('settings.agents.editor.configLabel')}\n              headerAction={\n                selectedExecutorType && (\n                  <button\n                    className=\"p-half rounded-sm hover:bg-secondary text-low hover:text-normal\"\n                    onClick={() => handleCreateConfig(selectedExecutorType)}\n                    disabled={profilesSaving}\n                    title={t('settings.agents.editor.createNew')}\n                  >\n                    <PlusIcon className=\"size-icon-2xs\" weight=\"bold\" />\n                  </button>\n                )\n              }\n            >\n              {selectedExecutorType &&\n              localParsedProfiles.executors[selectedExecutorType] ? (\n                getExecutorVariantKeys(\n                  localParsedProfiles.executors[selectedExecutorType]\n                ).map((configName) => {\n                  const isDefault =\n                    config?.executor_profile?.executor ===\n                      selectedExecutorType &&\n                    config?.executor_profile?.variant === configName;\n                  const configCount = getExecutorVariantKeys(\n                    localParsedProfiles.executors[selectedExecutorType]\n                  ).length;\n                  return (\n                    <TwoColumnPickerItem\n                      key={configName}\n                      selected={selectedConfiguration === configName}\n                      onClick={() => setSelectedConfiguration(configName)}\n                      trailing={\n                        <>\n                          {isDefault && (\n                            <TwoColumnPickerBadge variant=\"brand\">\n                              {t('settings.agents.editor.isDefault')}\n                            </TwoColumnPickerBadge>\n                          )}\n                          <ConfigActionsDropdown\n                            executorType={selectedExecutorType}\n                            configName={configName}\n                            isDefault={isDefault}\n                            configCount={configCount}\n                            onMakeDefault={handleMakeDefault}\n                            onDelete={handleDeleteConfig}\n                          />\n                        </>\n                      }\n                    >\n                      {toPrettyCase(configName)}\n                    </TwoColumnPickerItem>\n                  );\n                })\n              ) : (\n                <TwoColumnPickerEmpty>\n                  {t('settings.agents.selectAgent')}\n                </TwoColumnPickerEmpty>\n              )}\n            </TwoColumnPickerColumn>\n          </TwoColumnPicker>\n\n          {/* Config form */}\n          {selectedExecutorType && selectedConfiguration && (\n            <div className=\"bg-secondary/50 border border-border rounded-sm p-4\">\n              <ExecutorConfigForm\n                key={`${selectedExecutorType}-${selectedConfiguration}`}\n                executor={selectedExecutorType}\n                value={\n                  (executorsMap?.[selectedExecutorType]?.[\n                    selectedConfiguration\n                  ]?.[selectedExecutorType] as Record<string, unknown>) || {}\n                }\n                onChange={(formData) =>\n                  handleExecutorConfigChange(\n                    selectedExecutorType,\n                    selectedConfiguration,\n                    formData\n                  )\n                }\n                disabled={profilesSaving}\n              />\n            </div>\n          )}\n        </div>\n      ) : null}\n\n      <SettingsSaveBar\n        show={isDirty}\n        saving={profilesSaving}\n        saveDisabled={!!profilesError}\n        unsavedMessage={t('settings.agents.save.unsavedChanges')}\n        onSave={handleSave}\n        onDiscard={handleDiscard}\n      />\n    </>\n  );\n}\n\n// Helper component for config actions dropdown\nfunction ConfigActionsDropdown({\n  executorType,\n  configName,\n  isDefault,\n  configCount,\n  onMakeDefault,\n  onDelete,\n}: {\n  executorType: BaseCodingAgent;\n  configName: string;\n  isDefault: boolean;\n  configCount: number;\n  onMakeDefault: (executor: string, config: string) => void;\n  onDelete: (executor: string, config: string) => void;\n}) {\n  const { t } = useTranslation(['settings']);\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <button\n          className={cn(\n            'p-half rounded-sm hover:bg-panel text-low hover:text-normal',\n            'opacity-0 group-hover:opacity-100 transition-opacity'\n          )}\n          onClick={(e) => e.stopPropagation()}\n        >\n          <DotsThreeIcon className=\"size-icon-xs\" weight=\"bold\" />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem\n          onClick={(e) => {\n            e.stopPropagation();\n            onMakeDefault(executorType, configName);\n          }}\n          disabled={isDefault}\n        >\n          <div className=\"flex items-center gap-half w-full\">\n            <StarIcon className=\"size-icon-xs mr-base\" />\n            {t('settings.agents.editor.makeDefault')}\n          </div>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={(e) => {\n            e.stopPropagation();\n            onDelete(executorType, configName);\n          }}\n          disabled={configCount <= 1}\n          className=\"text-error focus:text-error\"\n        >\n          <div className=\"flex items-center gap-half w-full\">\n            <TrashIcon className=\"size-icon-xs mr-base\" />\n            {t('settings.agents.editor.deleteText')}\n          </div>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\n// Alias for backwards compatibility\nexport { AgentsSettingsSection as AgentsSettingsSectionContent };\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/ExecutorConfigForm.tsx",
    "content": "import { useMemo, useEffect, useState, useCallback } from 'react';\nimport Form from '@rjsf/core';\nimport type { IChangeEvent } from '@rjsf/core';\nimport { RJSFValidationError } from '@rjsf/utils';\nimport validator from '@rjsf/validator-ajv8';\nimport { useTranslation } from 'react-i18next';\nimport { BaseCodingAgent } from 'shared/types';\nimport { settingsRjsfTheme } from './rjsf/theme';\nimport { SettingsSaveBar } from './SettingsComponents';\n\ninterface ExecutorConfigFormProps {\n  executor: BaseCodingAgent;\n  value: unknown;\n  onChange?: (formData: unknown) => void;\n  onSave?: (formData: unknown) => Promise<void>;\n  onDiscard?: () => void;\n  disabled?: boolean;\n  saving?: boolean;\n  isDirty?: boolean;\n}\n\nimport schemas from 'virtual:executor-schemas';\n\nexport function ExecutorConfigForm({\n  executor,\n  value,\n  onChange,\n  onSave,\n  onDiscard,\n  disabled = false,\n  saving = false,\n  isDirty = false,\n}: ExecutorConfigFormProps) {\n  const { t } = useTranslation('settings');\n  const [formData, setFormData] = useState<unknown>(value || {});\n  const [validationErrors, setValidationErrors] = useState<\n    RJSFValidationError[]\n  >([]);\n\n  const schema = useMemo(() => {\n    return schemas[executor];\n  }, [executor]);\n\n  // Custom handler for env field updates\n  const handleEnvChange = useCallback(\n    (envData: Record<string, string> | undefined) => {\n      const newFormData = {\n        ...(formData as Record<string, unknown>),\n        env: envData,\n      };\n      setFormData(newFormData);\n      if (onChange) {\n        onChange(newFormData);\n      }\n    },\n    [formData, onChange]\n  );\n\n  const uiSchema = useMemo(\n    () => ({\n      env: {\n        'ui:field': 'KeyValueField',\n      },\n    }),\n    []\n  );\n\n  // Pass the env update handler via formContext\n  const formContext = useMemo(\n    () => ({\n      onEnvChange: handleEnvChange,\n    }),\n    [handleEnvChange]\n  );\n\n  useEffect(() => {\n    setFormData(value || {});\n    setValidationErrors([]);\n  }, [value, executor]);\n\n  const handleChange = (event: IChangeEvent<unknown>) => {\n    const newFormData = event.formData;\n    setFormData(newFormData);\n    if (onChange) {\n      onChange(newFormData);\n    }\n  };\n\n  const handleSave = async () => {\n    if (onSave) {\n      await onSave(formData);\n    }\n  };\n\n  const handleError = (errors: RJSFValidationError[]) => {\n    setValidationErrors(errors);\n  };\n\n  if (!schema) {\n    return (\n      <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n        {t('settings.agents.errors.schemaNotFound', { executor })}\n      </div>\n    );\n  }\n\n  const hasValidationErrors = validationErrors.length > 0;\n\n  return (\n    <div className=\"space-y-4\">\n      <Form\n        schema={schema}\n        uiSchema={uiSchema}\n        formData={formData}\n        formContext={formContext}\n        onChange={handleChange}\n        onError={handleError}\n        validator={validator}\n        disabled={disabled}\n        liveValidate\n        showErrorList={false}\n        widgets={settingsRjsfTheme.widgets}\n        templates={settingsRjsfTheme.templates}\n        fields={settingsRjsfTheme.fields}\n      >\n        {/* No submit button - SettingsSaveBar handles saving */}\n        <></>\n      </Form>\n\n      {hasValidationErrors && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          <ul className=\"list-disc list-inside space-y-1\">\n            {validationErrors.map((error, index) => (\n              <li key={index}>\n                {error.property}: {error.message}\n              </li>\n            ))}\n          </ul>\n        </div>\n      )}\n\n      {onSave && (\n        <SettingsSaveBar\n          show={isDirty}\n          saving={saving}\n          saveDisabled={hasValidationErrors}\n          unsavedMessage={t('settings.agents.save.unsavedChanges')}\n          onSave={handleSave}\n          onDiscard={onDiscard}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/GeneralSettingsSection.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { cloneDeep, isEqual, merge } from 'lodash';\nimport {\n  FolderSimpleIcon,\n  SpeakerHighIcon,\n  SpinnerIcon,\n} from '@phosphor-icons/react';\nimport { FolderPickerDialog } from '@/shared/dialogs/shared/FolderPickerDialog';\nimport {\n  type BaseCodingAgent,\n  DEFAULT_COMMIT_REMINDER_PROMPT,\n  DEFAULT_PR_DESCRIPTION_PROMPT,\n  EditorType,\n  type ExecutorProfileId,\n  type SendMessageShortcut,\n  SoundFile,\n  ThemeMode,\n  UiLanguage,\n} from 'shared/types';\nimport { getModifierKey } from '@/shared/lib/platform';\nimport { getLanguageOptions } from '@/i18n/languages';\nimport { toPrettyCase } from '@/shared/lib/string';\nimport {\n  getExecutorVariantKeys,\n  getSortedExecutorVariantKeys,\n} from '@/shared/lib/executor';\nimport { useTheme } from '@/shared/hooks/useTheme';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { TagManager } from '@/shared/components/TagManager';\nimport { useIsMobile } from '@/shared/hooks/useIsMobile';\nimport {\n  type MobileFontScale,\n  useMobileFontScale,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { cn, playSound } from '@/shared/lib/utils';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { IconButton } from '@vibe/ui/components/IconButton';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuTriggerButton,\n} from '@vibe/ui/components/Dropdown';\nimport {\n  SettingsCard,\n  SettingsCheckbox,\n  SettingsField,\n  SettingsInput,\n  SettingsSaveBar,\n  SettingsSelect,\n  SettingsTextarea,\n} from './SettingsComponents';\nimport { useSettingsDirty } from './SettingsDirtyContext';\n\nexport function GeneralSettingsSection() {\n  const { t } = useTranslation(['settings', 'common']);\n  const { setDirty: setContextDirty } = useSettingsDirty();\n\n  const isMobile = useIsMobile();\n  const [mobileFontScale, setMobileFontScale] = useMobileFontScale();\n  const languageOptions = getLanguageOptions(\n    t('language.browserDefault', {\n      ns: 'common',\n      defaultValue: 'Browser Default',\n    })\n  );\n  const { config, loading, updateAndSaveConfig, profiles } = useUserSystem();\n\n  const [draft, setDraft] = useState(() => (config ? cloneDeep(config) : null));\n  const [dirty, setDirty] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState(false);\n  const [branchPrefixError, setBranchPrefixError] = useState<string | null>(\n    null\n  );\n  const { setTheme } = useTheme();\n\n  // Executor options for the default coding agent dropdown\n  const executorOptions = profiles\n    ? Object.keys(profiles)\n        .sort()\n        .map((key) => ({ value: key, label: toPrettyCase(key) }))\n    : [];\n\n  const selectedAgentProfile =\n    profiles?.[draft?.executor_profile?.executor || ''];\n  const variantOptions = selectedAgentProfile\n    ? getSortedExecutorVariantKeys(selectedAgentProfile)\n    : [];\n  const hasVariants = variantOptions.length > 0;\n\n  const validateBranchPrefix = useCallback(\n    (prefix: string): string | null => {\n      if (!prefix) return null;\n      if (prefix.includes('/'))\n        return t('settings.general.git.branchPrefix.errors.slash');\n      if (prefix.startsWith('.'))\n        return t('settings.general.git.branchPrefix.errors.startsWithDot');\n      if (prefix.endsWith('.') || prefix.endsWith('.lock'))\n        return t('settings.general.git.branchPrefix.errors.endsWithDot');\n      if (prefix.includes('..') || prefix.includes('@{'))\n        return t('settings.general.git.branchPrefix.errors.invalidSequence');\n      if (/[ \\t~^:?*[\\\\]/.test(prefix))\n        return t('settings.general.git.branchPrefix.errors.invalidChars');\n      for (let i = 0; i < prefix.length; i++) {\n        const code = prefix.charCodeAt(i);\n        if (code < 0x20 || code === 0x7f)\n          return t('settings.general.git.branchPrefix.errors.controlChars');\n      }\n      return null;\n    },\n    [t]\n  );\n\n  const handleBrowseWorkspaceDir = async () => {\n    const result = await FolderPickerDialog.show({\n      value: draft?.workspace_dir ?? '',\n      title: t('settings.general.git.workspaceDir.dialogTitle'),\n      description: t('settings.general.git.workspaceDir.dialogDescription'),\n    });\n    if (result) {\n      updateDraft({ workspace_dir: result });\n    }\n  };\n\n  useEffect(() => {\n    if (!config) return;\n    if (!dirty) {\n      setDraft(cloneDeep(config));\n    }\n  }, [config, dirty]);\n\n  const hasUnsavedChanges = useMemo(() => {\n    if (!draft || !config) return false;\n    return !isEqual(draft, config);\n  }, [draft, config]);\n\n  // Sync dirty state to context for unsaved changes confirmation\n  useEffect(() => {\n    setContextDirty('general', hasUnsavedChanges);\n    return () => setContextDirty('general', false);\n  }, [hasUnsavedChanges, setContextDirty]);\n\n  const updateDraft = useCallback(\n    (patch: Partial<typeof config>) => {\n      setDraft((prev: typeof config) => {\n        if (!prev) return prev;\n        const next = merge({}, prev, patch);\n        if (!isEqual(next, config)) {\n          setDirty(true);\n        }\n        return next;\n      });\n    },\n    [config]\n  );\n\n  useEffect(() => {\n    const handler = (e: BeforeUnloadEvent) => {\n      if (hasUnsavedChanges) {\n        e.preventDefault();\n      }\n    };\n    window.addEventListener('beforeunload', handler);\n    return () => window.removeEventListener('beforeunload', handler);\n  }, [hasUnsavedChanges]);\n\n  const previewSound = async (soundFile: SoundFile) => {\n    try {\n      await playSound(`/api/sounds/${soundFile}`);\n    } catch (err) {\n      console.error('Failed to play sound:', err);\n    }\n  };\n\n  const handleSave = async () => {\n    if (!draft) return;\n\n    setSaving(true);\n    setError(null);\n    setSuccess(false);\n\n    try {\n      await updateAndSaveConfig(draft);\n      setTheme(draft.theme);\n      setDirty(false);\n      setSuccess(true);\n      setTimeout(() => setSuccess(false), 3000);\n    } catch (err) {\n      setError(t('settings.general.save.error'));\n      console.error('Error saving config:', err);\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleDiscard = () => {\n    if (!config) return;\n    setDraft(cloneDeep(config));\n    setDirty(false);\n  };\n\n  const resetOnboarding = async () => {\n    if (!config) return;\n    updateAndSaveConfig({\n      onboarding_acknowledged: false,\n      remote_onboarding_acknowledged: false,\n    });\n  };\n\n  if (loading) {\n    return (\n      <div className=\"flex items-center justify-center py-8 gap-2\">\n        <SpinnerIcon\n          className=\"size-icon-lg animate-spin text-brand\"\n          weight=\"bold\"\n        />\n        <span className=\"text-normal\">{t('settings.general.loading')}</span>\n      </div>\n    );\n  }\n\n  if (!config) {\n    return (\n      <div className=\"py-8\">\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {t('settings.general.loadError')}\n        </div>\n      </div>\n    );\n  }\n\n  const themeOptions = Object.values(ThemeMode).map((theme) => ({\n    value: theme,\n    label: toPrettyCase(theme),\n  }));\n\n  const editorOptions = Object.values(EditorType).map((editor) => ({\n    value: editor,\n    label: toPrettyCase(editor),\n  }));\n\n  const soundOptions = Object.values(SoundFile).map((sound) => ({\n    value: sound,\n    label: toPrettyCase(sound),\n  }));\n\n  return (\n    <>\n      {/* Status messages */}\n      {error && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {error}\n        </div>\n      )}\n\n      {success && (\n        <div className=\"bg-success/10 border border-success/50 rounded-sm p-4 text-success font-medium\">\n          {t('settings.general.save.success')}\n        </div>\n      )}\n\n      {/* Appearance */}\n      <SettingsCard\n        title={t('settings.general.appearance.title')}\n        description={t('settings.general.appearance.description')}\n      >\n        <SettingsField\n          label={t('settings.general.appearance.theme.label')}\n          description={t('settings.general.appearance.theme.helper')}\n        >\n          <SettingsSelect\n            value={draft?.theme}\n            options={themeOptions}\n            onChange={(value) => updateDraft({ theme: value })}\n            placeholder={t('settings.general.appearance.theme.placeholder')}\n          />\n        </SettingsField>\n\n        <SettingsField\n          label={t('settings.general.appearance.language.label')}\n          description={t('settings.general.appearance.language.helper')}\n        >\n          <SettingsSelect\n            value={draft?.language}\n            options={languageOptions}\n            onChange={(value: UiLanguage) => updateDraft({ language: value })}\n            placeholder={t('settings.general.appearance.language.placeholder')}\n          />\n        </SettingsField>\n\n        {isMobile && (\n          <SettingsField\n            label=\"Mobile Font Size\"\n            description=\"Scale text size on mobile for better readability\"\n          >\n            <SettingsSelect\n              value={mobileFontScale}\n              options={[\n                {\n                  value: 'default' as MobileFontScale,\n                  label: 'Default (100%)',\n                },\n                { value: 'small' as MobileFontScale, label: 'Small (95%)' },\n                { value: 'smaller' as MobileFontScale, label: 'Smaller (90%)' },\n              ]}\n              onChange={(value: MobileFontScale) => setMobileFontScale(value)}\n            />\n          </SettingsField>\n        )}\n      </SettingsCard>\n\n      {/* Editor */}\n      <SettingsCard\n        title={t('settings.general.editor.title')}\n        description={t('settings.general.editor.description')}\n      >\n        <SettingsField\n          label={t('settings.general.editor.type.label')}\n          description={t('settings.general.editor.type.helper')}\n        >\n          <SettingsSelect\n            value={draft?.editor.editor_type}\n            options={editorOptions}\n            onChange={(value: EditorType) =>\n              updateDraft({\n                editor: { ...draft!.editor, editor_type: value },\n              })\n            }\n            placeholder={t('settings.general.editor.type.placeholder')}\n          />\n        </SettingsField>\n\n        {draft?.editor.editor_type === EditorType.CUSTOM && (\n          <SettingsField\n            label={t('settings.general.editor.customCommand.label')}\n            description={t('settings.general.editor.customCommand.helper')}\n          >\n            <SettingsInput\n              value={draft?.editor.custom_command || ''}\n              onChange={(value) =>\n                updateDraft({\n                  editor: {\n                    ...draft!.editor,\n                    custom_command: value || null,\n                  },\n                })\n              }\n              placeholder={t(\n                'settings.general.editor.customCommand.placeholder'\n              )}\n            />\n          </SettingsField>\n        )}\n\n        {(draft?.editor.editor_type === EditorType.VS_CODE ||\n          draft?.editor.editor_type === EditorType.CURSOR ||\n          draft?.editor.editor_type === EditorType.WINDSURF ||\n          draft?.editor.editor_type === EditorType.GOOGLE_ANTIGRAVITY ||\n          draft?.editor.editor_type === EditorType.ZED) && (\n          <>\n            <SettingsField\n              label={t('settings.general.editor.remoteSsh.host.label')}\n              description={t('settings.general.editor.remoteSsh.host.helper')}\n            >\n              <SettingsInput\n                value={draft?.editor.remote_ssh_host || ''}\n                onChange={(value) =>\n                  updateDraft({\n                    editor: {\n                      ...draft!.editor,\n                      remote_ssh_host: value || null,\n                    },\n                  })\n                }\n                placeholder={t(\n                  'settings.general.editor.remoteSsh.host.placeholder'\n                )}\n              />\n            </SettingsField>\n\n            {draft?.editor.remote_ssh_host && (\n              <SettingsField\n                label={t('settings.general.editor.remoteSsh.user.label')}\n                description={t('settings.general.editor.remoteSsh.user.helper')}\n              >\n                <SettingsInput\n                  value={draft?.editor.remote_ssh_user || ''}\n                  onChange={(value) =>\n                    updateDraft({\n                      editor: {\n                        ...draft!.editor,\n                        remote_ssh_user: value || null,\n                      },\n                    })\n                  }\n                  placeholder={t(\n                    'settings.general.editor.remoteSsh.user.placeholder'\n                  )}\n                />\n              </SettingsField>\n            )}\n          </>\n        )}\n\n        {(draft?.editor.editor_type === EditorType.VS_CODE ||\n          draft?.editor.editor_type === EditorType.VS_CODE_INSIDERS ||\n          draft?.editor.editor_type === EditorType.CURSOR) && (\n          <SettingsCheckbox\n            id=\"auto-install-extension\"\n            label={t('settings.general.editor.autoInstallExtension.label')}\n            description={t(\n              'settings.general.editor.autoInstallExtension.helper'\n            )}\n            checked={draft?.editor.auto_install_extension ?? true}\n            onChange={(checked) =>\n              updateDraft({\n                editor: {\n                  ...draft!.editor,\n                  auto_install_extension: checked,\n                },\n              })\n            }\n          />\n        )}\n      </SettingsCard>\n\n      {/* Default Coding Agent */}\n      <SettingsCard\n        title={t('settings.general.taskExecution.title')}\n        description={t('settings.general.taskExecution.description')}\n      >\n        <SettingsField\n          label={t('settings.general.taskExecution.executor.label')}\n          description={t('settings.general.taskExecution.executor.helper')}\n        >\n          <div className=\"grid grid-cols-2 gap-2\">\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <DropdownMenuTriggerButton\n                  label={\n                    draft?.executor_profile?.executor\n                      ? toPrettyCase(draft.executor_profile.executor)\n                      : t('settings.agents.selectAgent')\n                  }\n                  className=\"w-full justify-between\"\n                  disabled={!profiles}\n                />\n              </DropdownMenuTrigger>\n              <DropdownMenuContent className=\"w-[var(--radix-dropdown-menu-trigger-width)]\">\n                {executorOptions.map((option) => (\n                  <DropdownMenuItem\n                    key={option.value}\n                    onClick={() => {\n                      const variants = profiles?.[option.value];\n                      const variantKeys = variants\n                        ? getExecutorVariantKeys(variants)\n                        : [];\n                      const keepCurrentVariant =\n                        variantKeys.length > 0 &&\n                        draft?.executor_profile?.variant &&\n                        variantKeys.includes(draft.executor_profile.variant);\n\n                      const newProfile: ExecutorProfileId = {\n                        executor: option.value as BaseCodingAgent,\n                        variant: keepCurrentVariant\n                          ? draft!.executor_profile!.variant\n                          : null,\n                      };\n                      updateDraft({ executor_profile: newProfile });\n                    }}\n                  >\n                    {option.label}\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuContent>\n            </DropdownMenu>\n\n            {hasVariants ? (\n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <DropdownMenuTriggerButton\n                    label={\n                      draft?.executor_profile?.variant\n                        ? toPrettyCase(draft.executor_profile.variant)\n                        : t('settings.general.taskExecution.defaultLabel')\n                    }\n                    className=\"w-full justify-between\"\n                  />\n                </DropdownMenuTrigger>\n                <DropdownMenuContent className=\"w-[var(--radix-dropdown-menu-trigger-width)]\">\n                  {variantOptions.map((variantLabel) => (\n                    <DropdownMenuItem\n                      key={variantLabel}\n                      onClick={() => {\n                        const newProfile: ExecutorProfileId = {\n                          executor: draft!.executor_profile!.executor,\n                          variant: variantLabel,\n                        };\n                        updateDraft({ executor_profile: newProfile });\n                      }}\n                    >\n                      {toPrettyCase(variantLabel)}\n                    </DropdownMenuItem>\n                  ))}\n                </DropdownMenuContent>\n              </DropdownMenu>\n            ) : selectedAgentProfile ? (\n              <button\n                disabled\n                className={cn(\n                  'flex items-center justify-between w-full px-base py-half rounded-sm border border-border bg-secondary',\n                  'text-base text-low opacity-50 cursor-not-allowed'\n                )}\n              >\n                <span className=\"truncate\">\n                  {t('settings.general.taskExecution.defaultLabel')}\n                </span>\n              </button>\n            ) : null}\n          </div>\n        </SettingsField>\n      </SettingsCard>\n\n      {/* Git */}\n      <SettingsCard\n        title={t('settings.general.git.title')}\n        description={t('settings.general.git.description')}\n      >\n        <SettingsField\n          label={t('settings.general.git.branchPrefix.label')}\n          error={branchPrefixError}\n          description={\n            <>\n              {t('settings.general.git.branchPrefix.helper')}{' '}\n              {draft?.git_branch_prefix ? (\n                <>\n                  {t('settings.general.git.branchPrefix.preview')}{' '}\n                  <code className=\"text-xs bg-secondary px-1 py-0.5 rounded\">\n                    {t('settings.general.git.branchPrefix.previewWithPrefix', {\n                      prefix: draft.git_branch_prefix,\n                    })}\n                  </code>\n                </>\n              ) : (\n                <>\n                  {t('settings.general.git.branchPrefix.preview')}{' '}\n                  <code className=\"text-xs bg-secondary px-1 py-0.5 rounded\">\n                    {t('settings.general.git.branchPrefix.previewNoPrefix')}\n                  </code>\n                </>\n              )}\n            </>\n          }\n        >\n          <SettingsInput\n            value={draft?.git_branch_prefix ?? ''}\n            onChange={(value) => {\n              const trimmed = value.trim();\n              updateDraft({ git_branch_prefix: trimmed });\n              setBranchPrefixError(validateBranchPrefix(trimmed));\n            }}\n            placeholder={t('settings.general.git.branchPrefix.placeholder')}\n            error={!!branchPrefixError}\n          />\n        </SettingsField>\n\n        <SettingsField\n          label={t('settings.general.git.workspaceDir.label')}\n          description={t('settings.general.git.workspaceDir.helper')}\n        >\n          <div className=\"flex gap-2\">\n            <div className=\"flex-1\">\n              <SettingsInput\n                value={draft?.workspace_dir ?? ''}\n                onChange={(value) =>\n                  updateDraft({ workspace_dir: value || null })\n                }\n                placeholder={t('settings.general.git.workspaceDir.placeholder')}\n              />\n            </div>\n            <PrimaryButton\n              variant=\"tertiary\"\n              onClick={handleBrowseWorkspaceDir}\n            >\n              <FolderSimpleIcon className=\"size-icon-sm\" weight=\"bold\" />\n              {t('settings.general.git.workspaceDir.browse')}\n            </PrimaryButton>\n          </div>\n        </SettingsField>\n      </SettingsCard>\n\n      {/* Pull Requests */}\n      <SettingsCard\n        title={t('settings.general.pullRequests.title')}\n        description={t('settings.general.pullRequests.description')}\n      >\n        <SettingsCheckbox\n          id=\"pr-auto-description\"\n          label={t('settings.general.pullRequests.autoDescription.label')}\n          description={t(\n            'settings.general.pullRequests.autoDescription.helper'\n          )}\n          checked={draft?.pr_auto_description_enabled ?? false}\n          onChange={(checked) =>\n            updateDraft({ pr_auto_description_enabled: checked })\n          }\n        />\n\n        <SettingsCheckbox\n          id=\"use-custom-prompt\"\n          label={t('settings.general.pullRequests.customPrompt.useCustom')}\n          checked={draft?.pr_auto_description_prompt != null}\n          onChange={(checked) => {\n            if (checked) {\n              updateDraft({\n                pr_auto_description_prompt: DEFAULT_PR_DESCRIPTION_PROMPT,\n              });\n            } else {\n              updateDraft({ pr_auto_description_prompt: null });\n            }\n          }}\n        />\n\n        <SettingsField\n          label=\"\"\n          description={t('settings.general.pullRequests.customPrompt.helper')}\n        >\n          <SettingsTextarea\n            value={\n              draft?.pr_auto_description_prompt ?? DEFAULT_PR_DESCRIPTION_PROMPT\n            }\n            onChange={(value) =>\n              updateDraft({ pr_auto_description_prompt: value })\n            }\n            disabled={draft?.pr_auto_description_prompt == null}\n          />\n        </SettingsField>\n      </SettingsCard>\n\n      {/* Commits */}\n      <SettingsCard\n        title={t('settings.general.commits.title')}\n        description={t('settings.general.commits.description')}\n      >\n        <SettingsCheckbox\n          id=\"commit-reminder\"\n          label={t('settings.general.commits.reminder.label')}\n          description={t('settings.general.commits.reminder.helper')}\n          checked={draft?.commit_reminder_enabled ?? true}\n          onChange={(checked) =>\n            updateDraft({ commit_reminder_enabled: checked })\n          }\n        />\n\n        {draft?.commit_reminder_enabled && (\n          <>\n            <SettingsCheckbox\n              id=\"use-custom-commit-prompt\"\n              label={t('settings.general.commits.customPrompt.useCustom')}\n              checked={draft?.commit_reminder_prompt != null}\n              onChange={(checked) => {\n                if (checked) {\n                  updateDraft({\n                    commit_reminder_prompt: DEFAULT_COMMIT_REMINDER_PROMPT,\n                  });\n                } else {\n                  updateDraft({ commit_reminder_prompt: null });\n                }\n              }}\n            />\n\n            <SettingsField\n              label=\"\"\n              description={t('settings.general.commits.customPrompt.helper')}\n            >\n              <SettingsTextarea\n                value={\n                  draft?.commit_reminder_prompt ??\n                  DEFAULT_COMMIT_REMINDER_PROMPT\n                }\n                onChange={(value) =>\n                  updateDraft({ commit_reminder_prompt: value })\n                }\n                disabled={draft?.commit_reminder_prompt == null}\n              />\n            </SettingsField>\n          </>\n        )}\n      </SettingsCard>\n\n      {/* Notifications */}\n      <SettingsCard\n        title={t('settings.general.notifications.title')}\n        description={t('settings.general.notifications.description')}\n      >\n        <SettingsCheckbox\n          id=\"sound-enabled\"\n          label={t('settings.general.notifications.sound.label')}\n          description={t('settings.general.notifications.sound.helper')}\n          checked={draft?.notifications.sound_enabled ?? false}\n          onChange={(checked) =>\n            updateDraft({\n              notifications: {\n                ...draft!.notifications,\n                sound_enabled: checked,\n              },\n            })\n          }\n        />\n\n        {draft?.notifications.sound_enabled && (\n          <div className=\"ml-7 space-y-2\">\n            <label className=\"text-sm font-medium text-normal\">\n              {t('settings.general.notifications.sound.fileLabel')}\n            </label>\n            <div className=\"flex gap-2\">\n              <div className=\"flex-1\">\n                <SettingsSelect\n                  value={draft.notifications.sound_file}\n                  options={soundOptions}\n                  onChange={(value: SoundFile) =>\n                    updateDraft({\n                      notifications: {\n                        ...draft.notifications,\n                        sound_file: value,\n                      },\n                    })\n                  }\n                  placeholder={t(\n                    'settings.general.notifications.sound.filePlaceholder'\n                  )}\n                />\n              </div>\n              <IconButton\n                icon={SpeakerHighIcon}\n                onClick={() => previewSound(draft.notifications.sound_file)}\n                aria-label=\"Preview sound\"\n                title=\"Preview sound\"\n              />\n            </div>\n            <p className=\"text-sm text-low\">\n              {t('settings.general.notifications.sound.fileHelper')}\n            </p>\n          </div>\n        )}\n\n        <SettingsCheckbox\n          id=\"push-notifications\"\n          label={t('settings.general.notifications.push.label')}\n          description={t('settings.general.notifications.push.helper')}\n          checked={draft?.notifications.push_enabled ?? false}\n          onChange={(checked) =>\n            updateDraft({\n              notifications: {\n                ...draft!.notifications,\n                push_enabled: checked,\n              },\n            })\n          }\n        />\n      </SettingsCard>\n\n      {/* Message Input */}\n      <SettingsCard\n        title={t('settings.general.messageInput.title')}\n        description={t('settings.general.messageInput.description')}\n      >\n        <SettingsField\n          label={t('settings.general.messageInput.shortcut.label')}\n          description={t('settings.general.messageInput.shortcut.helper')}\n        >\n          <SettingsSelect\n            value={draft?.send_message_shortcut ?? 'ModifierEnter'}\n            options={[\n              {\n                value: 'ModifierEnter' as SendMessageShortcut,\n                label: `${getModifierKey()}+Enter`,\n              },\n              {\n                value: 'Enter' as SendMessageShortcut,\n                label: t('settings.general.messageInput.shortcut.enterLabel'),\n              },\n            ]}\n            onChange={(value: SendMessageShortcut) =>\n              updateDraft({ send_message_shortcut: value })\n            }\n          />\n        </SettingsField>\n      </SettingsCard>\n\n      {/* Privacy */}\n      <SettingsCard\n        title={t('settings.general.privacy.title')}\n        description={t('settings.general.privacy.description')}\n      >\n        <SettingsCheckbox\n          id=\"analytics-enabled\"\n          label={t('settings.general.privacy.telemetry.label')}\n          description={t('settings.general.privacy.telemetry.helper')}\n          checked={draft?.analytics_enabled ?? false}\n          onChange={(checked) => updateDraft({ analytics_enabled: checked })}\n        />\n      </SettingsCard>\n\n      {/* Task Templates */}\n      <SettingsCard\n        title={t('settings.general.taskTemplates.title')}\n        description={t('settings.general.taskTemplates.description')}\n      >\n        <TagManager />\n      </SettingsCard>\n\n      {/* Safety */}\n      <SettingsCard\n        title={t('settings.general.safety.title')}\n        description={t('settings.general.safety.description')}\n      >\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <p className=\"text-sm font-medium text-normal\">\n              {t('settings.general.safety.onboarding.title')}\n            </p>\n            <p className=\"text-sm text-low\">\n              {t('settings.general.safety.onboarding.description')}\n            </p>\n          </div>\n          <PrimaryButton\n            variant=\"tertiary\"\n            value={t('settings.general.safety.onboarding.button')}\n            onClick={resetOnboarding}\n          />\n        </div>\n      </SettingsCard>\n\n      <SettingsSaveBar\n        show={hasUnsavedChanges}\n        saving={saving}\n        saveDisabled={!!branchPrefixError}\n        onSave={handleSave}\n        onDiscard={handleDiscard}\n      />\n    </>\n  );\n}\n\n// Alias for backwards compatibility\nexport { GeneralSettingsSection as GeneralSettingsSectionContent };\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/McpSettingsSection.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { PlusIcon } from '@phosphor-icons/react';\nimport type { BaseCodingAgent, ExecutorProfile } from 'shared/types';\nimport { McpConfig } from 'shared/types';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { mcpServersApi } from '@/shared/lib/api';\nimport { McpConfigStrategyGeneral } from '@/shared/lib/mcpStrategies';\nimport { cn } from '@/shared/lib/utils';\nimport { toPrettyCase } from '@/shared/lib/string';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuTriggerButton,\n} from '@vibe/ui/components/Dropdown';\nimport {\n  SettingsCard,\n  SettingsField,\n  SettingsSaveBar,\n  SettingsTextarea,\n} from './SettingsComponents';\nimport { useSettingsDirty } from './SettingsDirtyContext';\n\nexport function McpSettingsSection() {\n  const { t } = useTranslation('settings');\n  const { setDirty: setContextDirty } = useSettingsDirty();\n  const { config, profiles } = useUserSystem();\n  const [mcpServers, setMcpServers] = useState('{}');\n  const [originalMcpServers, setOriginalMcpServers] = useState('{}');\n  const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);\n  const [mcpError, setMcpError] = useState<string | null>(null);\n  const [mcpLoading, setMcpLoading] = useState(true);\n  const [selectedProfile, setSelectedProfile] =\n    useState<ExecutorProfile | null>(null);\n  const [mcpApplying, setMcpApplying] = useState(false);\n  const [mcpConfigPath, setMcpConfigPath] = useState<string>('');\n  const [success, setSuccess] = useState(false);\n\n  const isDirty = mcpServers !== originalMcpServers;\n\n  // Sync dirty state to context for unsaved changes confirmation\n  useEffect(() => {\n    setContextDirty('mcp', isDirty);\n    return () => setContextDirty('mcp', false);\n  }, [isDirty, setContextDirty]);\n\n  // Initialize selected profile when config loads\n  useEffect(() => {\n    if (config?.executor_profile && profiles && !selectedProfile) {\n      const currentProfile = profiles[config.executor_profile.executor];\n      if (currentProfile) {\n        setSelectedProfile(currentProfile);\n      } else if (Object.keys(profiles).length > 0) {\n        setSelectedProfile(Object.values(profiles)[0]);\n      }\n    }\n  }, [config?.executor_profile, profiles, selectedProfile]);\n\n  // Load MCP configuration when selected profile changes\n  useEffect(() => {\n    const loadMcpServersForProfile = async (profile: ExecutorProfile) => {\n      setMcpLoading(true);\n      setMcpError(null);\n      setMcpConfigPath('');\n\n      try {\n        const profileKey = profiles\n          ? Object.keys(profiles).find((key) => profiles[key] === profile)\n          : null;\n        if (!profileKey) {\n          throw new Error('Profile key not found');\n        }\n\n        const result = await mcpServersApi.load({\n          executor: profileKey as BaseCodingAgent,\n        });\n        setMcpConfig(result.mcp_config);\n        const fullConfig = McpConfigStrategyGeneral.createFullConfig(\n          result.mcp_config\n        );\n        const configJson = JSON.stringify(fullConfig, null, 2);\n        setMcpServers(configJson);\n        setOriginalMcpServers(configJson);\n        setMcpConfigPath(result.config_path);\n      } catch (err: unknown) {\n        if (\n          err instanceof Error &&\n          err.message.includes('does not support MCP')\n        ) {\n          setMcpError(err.message);\n        } else {\n          console.error('Error loading MCP servers:', err);\n        }\n      } finally {\n        setMcpLoading(false);\n      }\n    };\n\n    if (selectedProfile) {\n      loadMcpServersForProfile(selectedProfile);\n    }\n  }, [selectedProfile, profiles]);\n\n  const handleMcpServersChange = (value: string) => {\n    setMcpServers(value);\n    setMcpError(null);\n\n    if (value.trim() && mcpConfig) {\n      try {\n        const parsedConfig = JSON.parse(value);\n        McpConfigStrategyGeneral.validateFullConfig(mcpConfig, parsedConfig);\n      } catch (err) {\n        if (err instanceof SyntaxError) {\n          setMcpError(t('settings.mcp.errors.invalidJson'));\n        } else {\n          setMcpError(\n            err instanceof Error\n              ? err.message\n              : t('settings.mcp.errors.validationError')\n          );\n        }\n      }\n    }\n  };\n\n  const handleApplyMcpServers = async () => {\n    if (!selectedProfile || !mcpConfig) return;\n\n    setMcpApplying(true);\n    setMcpError(null);\n\n    try {\n      if (mcpServers.trim()) {\n        try {\n          const fullConfig = JSON.parse(mcpServers);\n          McpConfigStrategyGeneral.validateFullConfig(mcpConfig, fullConfig);\n          const mcpServersConfig =\n            McpConfigStrategyGeneral.extractServersForApi(\n              mcpConfig,\n              fullConfig\n            );\n\n          const selectedProfileKey = profiles\n            ? Object.keys(profiles).find(\n                (key) => profiles[key] === selectedProfile\n              )\n            : null;\n          if (!selectedProfileKey) {\n            throw new Error('Selected profile key not found');\n          }\n\n          await mcpServersApi.save(\n            {\n              executor: selectedProfileKey as BaseCodingAgent,\n            },\n            { servers: mcpServersConfig }\n          );\n\n          setOriginalMcpServers(mcpServers);\n          setSuccess(true);\n          setTimeout(() => setSuccess(false), 3000);\n        } catch (mcpErr) {\n          if (mcpErr instanceof SyntaxError) {\n            setMcpError(t('settings.mcp.errors.invalidJson'));\n          } else {\n            setMcpError(\n              mcpErr instanceof Error\n                ? mcpErr.message\n                : t('settings.mcp.errors.saveFailed')\n            );\n          }\n        }\n      }\n    } catch (err) {\n      setMcpError(t('settings.mcp.errors.applyFailed'));\n      console.error('Error applying MCP servers:', err);\n    } finally {\n      setMcpApplying(false);\n    }\n  };\n\n  const handleDiscard = () => {\n    setMcpServers(originalMcpServers);\n    setMcpError(null);\n  };\n\n  const addServer = (key: string) => {\n    try {\n      const existing = mcpServers.trim() ? JSON.parse(mcpServers) : {};\n      const updated = McpConfigStrategyGeneral.addPreconfiguredToConfig(\n        mcpConfig!,\n        existing,\n        key\n      );\n      setMcpServers(JSON.stringify(updated, null, 2));\n      setMcpError(null);\n    } catch (err) {\n      console.error(err);\n      setMcpError(\n        err instanceof Error\n          ? err.message\n          : t('settings.mcp.errors.addServerFailed')\n      );\n    }\n  };\n\n  const preconfiguredObj = (mcpConfig?.preconfigured ?? {}) as Record<\n    string,\n    unknown\n  >;\n  const meta =\n    typeof preconfiguredObj.meta === 'object' && preconfiguredObj.meta !== null\n      ? (preconfiguredObj.meta as Record<\n          string,\n          { name?: string; description?: string; url?: string; icon?: string }\n        >)\n      : {};\n  const servers = Object.fromEntries(\n    Object.entries(preconfiguredObj).filter(([k]) => k !== 'meta')\n  ) as Record<string, unknown>;\n  const getMetaFor = (key: string) => meta[key] || {};\n\n  const profileOptions = profiles\n    ? Object.keys(profiles)\n        .sort()\n        .map((key) => ({ value: key, label: toPrettyCase(key) }))\n    : [];\n\n  const selectedProfileKey = selectedProfile\n    ? Object.keys(profiles || {}).find(\n        (key) => profiles![key] === selectedProfile\n      ) || ''\n    : '';\n\n  if (!config) {\n    return (\n      <div className=\"py-8\">\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {t('settings.mcp.errors.loadFailed')}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {/* Status messages */}\n      {mcpError && !mcpError.includes('does not support MCP') && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {t('settings.mcp.errors.mcpError', { error: mcpError })}\n        </div>\n      )}\n\n      {success && (\n        <div className=\"bg-success/10 border border-success/50 rounded-sm p-4 text-success font-medium\">\n          {t('settings.mcp.save.successMessage')}\n        </div>\n      )}\n\n      {/* MCP Configuration */}\n      <SettingsCard\n        title={t('settings.mcp.title')}\n        description={t('settings.mcp.description')}\n      >\n        <SettingsField\n          label={t('settings.mcp.labels.agent')}\n          description={t('settings.mcp.labels.agentHelper')}\n        >\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <DropdownMenuTriggerButton\n                label={\n                  selectedProfileKey\n                    ? toPrettyCase(selectedProfileKey)\n                    : t('settings.mcp.labels.agentPlaceholder')\n                }\n                className=\"w-full justify-between\"\n              />\n            </DropdownMenuTrigger>\n            <DropdownMenuContent className=\"w-[var(--radix-dropdown-menu-trigger-width)]\">\n              {profileOptions.map((option) => (\n                <DropdownMenuItem\n                  key={option.value}\n                  onClick={() => {\n                    const profile = profiles?.[option.value];\n                    if (profile) setSelectedProfile(profile);\n                  }}\n                >\n                  {option.label}\n                </DropdownMenuItem>\n              ))}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </SettingsField>\n\n        {mcpError && mcpError.includes('does not support MCP') ? (\n          <div className=\"rounded-sm border border-warning/50 bg-warning/10 p-4\">\n            <h3 className=\"text-sm font-medium text-warning\">\n              {t('settings.mcp.errors.notSupported')}\n            </h3>\n            <div className=\"mt-2 text-sm text-low\">\n              <p>{mcpError}</p>\n              <p className=\"mt-1\">{t('settings.mcp.errors.supportMessage')}</p>\n            </div>\n          </div>\n        ) : (\n          <>\n            <SettingsField\n              label={t('settings.mcp.labels.serverConfig')}\n              description={\n                mcpLoading ? (\n                  t('settings.mcp.loadingStates.configuration')\n                ) : (\n                  <>\n                    {t('settings.mcp.labels.saveLocation')}\n                    {mcpConfigPath && (\n                      <span className=\"ml-2 font-mono text-xs\">\n                        {mcpConfigPath}\n                      </span>\n                    )}\n                  </>\n                )\n              }\n            >\n              <SettingsTextarea\n                value={\n                  mcpLoading\n                    ? t('settings.mcp.loadingStates.jsonEditor')\n                    : mcpServers\n                }\n                onChange={handleMcpServersChange}\n                disabled={mcpLoading}\n                rows={14}\n                placeholder='{\\n  \"server-name\": {\\n    \"type\": \"stdio\",\\n    \"command\": \"your-command\",\\n    \"args\": [\"arg1\", \"arg2\"]\\n  }\\n}'\n              />\n            </SettingsField>\n\n            {/* Preconfigured servers */}\n            {mcpConfig?.preconfigured &&\n              typeof mcpConfig.preconfigured === 'object' &&\n              Object.keys(servers).length > 0 && (\n                <div className=\"space-y-2\">\n                  <label className=\"text-sm font-medium text-normal\">\n                    {t('settings.mcp.labels.popularServers')}\n                  </label>\n                  <p className=\"text-sm text-low\">\n                    {t('settings.mcp.labels.serverHelper')}\n                  </p>\n\n                  <div className=\"grid grid-cols-2 gap-2\">\n                    {Object.entries(servers).map(([key]) => {\n                      const metaObj = getMetaFor(key) as {\n                        name?: string;\n                        description?: string;\n                        icon?: string;\n                      };\n                      const name = metaObj.name || key;\n                      const description =\n                        metaObj.description || 'No description';\n                      const icon = metaObj.icon ? `/${metaObj.icon}` : null;\n\n                      return (\n                        <button\n                          key={key}\n                          type=\"button\"\n                          onClick={() => addServer(key)}\n                          className={cn(\n                            'flex items-start gap-3 p-3 rounded-sm border border-border/50 bg-secondary/30',\n                            'hover:bg-secondary hover:border-border transition-colors text-left'\n                          )}\n                        >\n                          <div className=\"w-6 h-6 rounded-sm border border-border bg-secondary flex items-center justify-center overflow-hidden shrink-0\">\n                            {icon ? (\n                              <img\n                                src={icon}\n                                alt=\"\"\n                                className=\"w-full h-full object-cover\"\n                              />\n                            ) : (\n                              <span className=\"text-xs font-semibold text-normal\">\n                                {name.slice(0, 1).toUpperCase()}\n                              </span>\n                            )}\n                          </div>\n                          <div className=\"min-w-0 flex-1\">\n                            <div className=\"text-sm font-medium text-normal truncate\">\n                              {name}\n                            </div>\n                            <div className=\"text-xs text-low line-clamp-2\">\n                              {description}\n                            </div>\n                          </div>\n                          <PlusIcon\n                            className=\"size-icon-xs text-low shrink-0\"\n                            weight=\"bold\"\n                          />\n                        </button>\n                      );\n                    })}\n                  </div>\n                </div>\n              )}\n          </>\n        )}\n      </SettingsCard>\n\n      <SettingsSaveBar\n        show={isDirty && !mcpError?.includes('does not support MCP')}\n        saving={mcpApplying}\n        saveDisabled={!!mcpError}\n        onSave={handleApplyMcpServers}\n        onDiscard={handleDiscard}\n      />\n    </>\n  );\n}\n\n// Alias for backwards compatibility\nexport { McpSettingsSection as McpSettingsSectionContent };\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/OrganizationsSettingsSection.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  SpinnerIcon,\n  PlusIcon,\n  UserPlusIcon,\n  TrashIcon,\n  SignInIcon,\n  ArrowSquareOutIcon,\n  InfoIcon,\n} from '@phosphor-icons/react';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { useOrganizationSelection } from '@/shared/hooks/useOrganizationSelection';\nimport { useOrganizationMembers } from '@/shared/hooks/useOrganizationMembers';\nimport { useOrganizationInvitations } from '@/shared/hooks/useOrganizationInvitations';\nimport { useOrganizationMutations } from '@/shared/hooks/useOrganizationMutations';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { OAuthDialog } from '@/shared/dialogs/global/OAuthDialog';\nimport {\n  CreateOrganizationDialog,\n  type CreateOrganizationResult,\n} from '@/shared/dialogs/org/CreateOrganizationDialog';\nimport {\n  InviteMemberDialog,\n  type InviteMemberResult,\n} from '@/shared/dialogs/org/InviteMemberDialog';\nimport { MemberListItem } from '@/shared/components/org/MemberListItem';\nimport { PendingInvitationItem } from '@/shared/components/org/PendingInvitationItem';\nimport type { MemberRole } from 'shared/types';\nimport { MemberRole as MemberRoleEnum } from 'shared/types';\nimport { ApiError, organizationsApi } from '@/shared/lib/api';\nimport { cn } from '@/shared/lib/utils';\nimport { getRemoteApiUrl } from '@/shared/lib/remoteApi';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuTriggerButton,\n} from '@vibe/ui/components/Dropdown';\nimport { SettingsCard, SettingsField } from './SettingsComponents';\n\nexport function OrganizationsSettingsSection() {\n  const { t } = useTranslation('organization');\n  const { isSignedIn, isLoaded, userId } = useAuth();\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState<string | null>(null);\n  const [isOpeningBilling, setIsOpeningBilling] = useState(false);\n\n  // Fetch all organizations\n  const {\n    data: orgsResponse,\n    isLoading: orgsLoading,\n    error: orgsError,\n    refetch: refetchOrgs,\n  } = useUserOrganizations();\n\n  // Organization selection\n  const { selectedOrgId, selectedOrg, handleOrgSelect } =\n    useOrganizationSelection({\n      organizations: orgsResponse,\n      onSelectionChange: () => {\n        setSuccess(null);\n        setError(null);\n      },\n    });\n\n  // Get current user's role and ID\n  const currentUserRole = selectedOrg?.user_role;\n  const isAdmin = currentUserRole === MemberRoleEnum.ADMIN;\n  const isPersonalOrg = selectedOrg?.is_personal ?? false;\n  const currentUserId = userId;\n\n  // Fetch members\n  const { data: members = [], isLoading: loadingMembers } =\n    useOrganizationMembers(selectedOrgId);\n\n  // Fetch invitations (admin only)\n  const { data: invitations = [], isLoading: loadingInvitations } =\n    useOrganizationInvitations({\n      organizationId: selectedOrgId || null,\n      isAdmin,\n      isPersonal: isPersonalOrg,\n    });\n\n  // Organization mutations\n  const {\n    removeMember,\n    updateMemberRole,\n    revokeInvitation,\n    deleteOrganization,\n  } = useOrganizationMutations({\n    onRevokeSuccess: () => {\n      setSuccess('Invitation revoked successfully');\n      setTimeout(() => setSuccess(null), 3000);\n    },\n    onRevokeError: (err) => {\n      setError(\n        err instanceof Error ? err.message : 'Failed to revoke invitation'\n      );\n    },\n    onRemoveSuccess: () => {\n      setSuccess('Member removed successfully');\n      setTimeout(() => setSuccess(null), 3000);\n    },\n    onRemoveError: (err) => {\n      setError(err instanceof Error ? err.message : 'Failed to remove member');\n    },\n    onRoleChangeSuccess: () => {\n      setSuccess('Member role updated successfully');\n      setTimeout(() => setSuccess(null), 3000);\n    },\n    onRoleChangeError: (err) => {\n      setError(\n        err instanceof Error ? err.message : 'Failed to update member role'\n      );\n    },\n    onDeleteSuccess: async () => {\n      setSuccess(t('settings.deleteSuccess'));\n      setTimeout(() => setSuccess(null), 3000);\n      await refetchOrgs();\n      if (orgsResponse?.organizations) {\n        const personalOrg = orgsResponse.organizations.find(\n          (org) => org.is_personal\n        );\n        if (personalOrg) {\n          handleOrgSelect(personalOrg.id);\n        }\n      }\n    },\n    onDeleteError: (err) => {\n      setError(err instanceof Error ? err.message : t('settings.deleteError'));\n    },\n  });\n\n  const handleCreateOrganization = async () => {\n    try {\n      const result: CreateOrganizationResult =\n        await CreateOrganizationDialog.show();\n\n      if (result.action === 'created' && result.organizationId) {\n        handleOrgSelect(result.organizationId ?? '');\n        setSuccess('Organization created successfully');\n        setTimeout(() => setSuccess(null), 3000);\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  };\n\n  const handleInviteMember = async () => {\n    if (!selectedOrgId) return;\n\n    try {\n      const result: InviteMemberResult = await InviteMemberDialog.show({\n        organizationId: selectedOrgId,\n      });\n\n      if (result.action === 'invited') {\n        setSuccess('Member invited successfully');\n        setTimeout(() => setSuccess(null), 3000);\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  };\n\n  const handleRevokeInvitation = (invitationId: string) => {\n    if (!selectedOrgId) return;\n    setError(null);\n    revokeInvitation.mutate({ orgId: selectedOrgId, invitationId });\n  };\n\n  const handleRemoveMember = async (userId: string) => {\n    if (!selectedOrgId) return;\n\n    const confirmed = window.confirm(t('confirmRemoveMember'));\n    if (!confirmed) return;\n\n    setError(null);\n    removeMember.mutate({ orgId: selectedOrgId, userId });\n  };\n\n  const handleRoleChange = async (userId: string, newRole: MemberRole) => {\n    if (!selectedOrgId) return;\n    setError(null);\n    updateMemberRole.mutate({ orgId: selectedOrgId, userId, role: newRole });\n  };\n\n  const handleDeleteOrganization = async () => {\n    if (!selectedOrgId || !selectedOrg) return;\n\n    const confirmed = window.confirm(\n      t('settings.confirmDelete', { orgName: selectedOrg.name })\n    );\n    if (!confirmed) return;\n\n    setError(null);\n    deleteOrganization.mutate(selectedOrgId);\n  };\n\n  const handleManageBilling = async () => {\n    if (!selectedOrgId || isOpeningBilling) {\n      return;\n    }\n\n    // Open tab immediately so browsers treat it as user-initiated.\n    const stripeTab = window.open('', '_blank');\n    setError(null);\n    setIsOpeningBilling(true);\n\n    try {\n      const returnUrl = window.location.href;\n      const billingStatus =\n        await organizationsApi.getBillingStatus(selectedOrgId);\n\n      const createCheckoutUrl = async () => {\n        const { url: checkoutUrl } =\n          await organizationsApi.createCheckoutSession(\n            selectedOrgId,\n            returnUrl,\n            returnUrl\n          );\n        return checkoutUrl;\n      };\n\n      const url = await (async () => {\n        if (billingStatus.status === 'requires_subscription') {\n          return createCheckoutUrl();\n        }\n\n        try {\n          const { url: portalUrl } = await organizationsApi.createPortalSession(\n            selectedOrgId,\n            returnUrl\n          );\n          return portalUrl;\n        } catch (err) {\n          if (\n            err instanceof ApiError &&\n            (err.statusCode === 402 || err.statusCode === 503)\n          ) {\n            return createCheckoutUrl();\n          }\n\n          throw err;\n        }\n      })();\n\n      if (stripeTab) {\n        stripeTab.opener = null;\n        stripeTab.location.href = url;\n      } else {\n        window.open(url, '_blank', 'noopener,noreferrer');\n      }\n    } catch (err) {\n      stripeTab?.close();\n      setError(err instanceof Error ? err.message : 'Failed to open billing');\n    } finally {\n      setIsOpeningBilling(false);\n    }\n  };\n\n  if (!isLoaded || orgsLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-8 gap-2\">\n        <SpinnerIcon\n          className=\"size-icon-lg animate-spin text-brand\"\n          weight=\"bold\"\n        />\n        <span className=\"text-normal\">\n          {t('settings.loadingOrganizations')}\n        </span>\n      </div>\n    );\n  }\n\n  if (!isSignedIn) {\n    return (\n      <div className=\"space-y-4\">\n        <div>\n          <h3 className=\"text-base font-medium text-high\">\n            {t('loginRequired.title')}\n          </h3>\n          <p className=\"text-sm text-low mt-1\">\n            {t('loginRequired.description')}\n          </p>\n        </div>\n        <PrimaryButton\n          variant=\"secondary\"\n          value={t('loginRequired.action')}\n          onClick={() => void OAuthDialog.show({})}\n        >\n          <SignInIcon className=\"size-icon-xs mr-1\" weight=\"bold\" />\n        </PrimaryButton>\n      </div>\n    );\n  }\n\n  if (orgsError) {\n    return (\n      <div className=\"py-8\">\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {orgsError instanceof Error\n            ? orgsError.message\n            : t('settings.loadError')}\n        </div>\n      </div>\n    );\n  }\n\n  const organizations = orgsResponse?.organizations ?? [];\n  const orgOptions = organizations.map((org) => ({\n    value: org.id,\n    label: org.name,\n  }));\n\n  return (\n    <>\n      {/* Status messages */}\n      {error && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {error}\n        </div>\n      )}\n\n      {success && (\n        <div className=\"bg-success/10 border border-success/50 rounded-sm p-4 text-success font-medium\">\n          {success}\n        </div>\n      )}\n\n      {/* Organization selector */}\n      <SettingsCard\n        title={t('settings.title')}\n        description={t('settings.description')}\n        headerAction={\n          <PrimaryButton\n            variant=\"secondary\"\n            value={t('createDialog.createButton')}\n            onClick={handleCreateOrganization}\n          >\n            <PlusIcon className=\"size-icon-xs mr-1\" weight=\"bold\" />\n          </PrimaryButton>\n        }\n      >\n        <SettingsField\n          label={t('settings.selectLabel')}\n          description={t('settings.selectHelper')}\n        >\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <DropdownMenuTriggerButton\n                label={\n                  orgOptions.find((o) => o.value === selectedOrgId)?.label ||\n                  t('settings.selectPlaceholder')\n                }\n                className=\"w-full justify-between\"\n              />\n            </DropdownMenuTrigger>\n            <DropdownMenuContent className=\"w-[var(--radix-dropdown-menu-trigger-width)]\">\n              {orgOptions.length > 0 ? (\n                orgOptions.map((option) => (\n                  <DropdownMenuItem\n                    key={option.value}\n                    onClick={() => handleOrgSelect(option.value)}\n                  >\n                    {option.label}\n                  </DropdownMenuItem>\n                ))\n              ) : (\n                <DropdownMenuItem disabled>\n                  {t('settings.noOrganizations')}\n                </DropdownMenuItem>\n              )}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </SettingsField>\n      </SettingsCard>\n\n      {/* Pending Invitations (admin only) */}\n      {selectedOrg &&\n        isAdmin &&\n        !isPersonalOrg &&\n        (loadingInvitations || invitations.length > 0) && (\n          <SettingsCard\n            title={t('invitationList.title')}\n            description={t('invitationList.description', {\n              orgName: selectedOrg.name,\n            })}\n          >\n            {loadingInvitations ? (\n              <div className=\"flex items-center justify-center py-4 gap-2\">\n                <SpinnerIcon className=\"size-icon-sm animate-spin\" />\n                <span className=\"text-sm text-low\">\n                  {t('invitationList.loading')}\n                </span>\n              </div>\n            ) : (\n              <div className=\"space-y-2\">\n                {invitations.map((invitation) => (\n                  <PendingInvitationItem\n                    key={invitation.id}\n                    invitation={invitation}\n                    onRevoke={handleRevokeInvitation}\n                    isRevoking={revokeInvitation.isPending}\n                  />\n                ))}\n              </div>\n            )}\n          </SettingsCard>\n        )}\n\n      {/* Members */}\n      {selectedOrg && (\n        <SettingsCard\n          title={t('memberList.title')}\n          description={t('memberList.description', {\n            orgName: selectedOrg.name,\n          })}\n          headerAction={\n            isAdmin && !isPersonalOrg ? (\n              <PrimaryButton\n                variant=\"secondary\"\n                value={t('memberList.inviteButton')}\n                onClick={handleInviteMember}\n              >\n                <UserPlusIcon className=\"size-icon-xs mr-1\" weight=\"bold\" />\n              </PrimaryButton>\n            ) : undefined\n          }\n        >\n          {isPersonalOrg && (\n            <div className=\"bg-info/10 border border-info/50 rounded-sm p-4 mb-4\">\n              <div className=\"flex items-start gap-3\">\n                <InfoIcon\n                  className=\"size-icon-sm text-info flex-shrink-0 mt-0.5\"\n                  weight=\"bold\"\n                />\n                <div className=\"flex-1\">\n                  <p className=\"text-sm font-medium text-high\">\n                    {t('personalOrg.cannotInvite')}\n                  </p>\n                  <p className=\"text-sm text-low mt-1\">\n                    {t('personalOrg.createOrgPrompt')}\n                  </p>\n                  <PrimaryButton\n                    variant=\"secondary\"\n                    value={t('personalOrg.createOrgButton')}\n                    onClick={handleCreateOrganization}\n                    className=\"mt-3\"\n                  >\n                    <PlusIcon className=\"size-icon-xs mr-1\" weight=\"bold\" />\n                  </PrimaryButton>\n                </div>\n              </div>\n            </div>\n          )}\n          {loadingMembers ? (\n            <div className=\"flex items-center justify-center py-4 gap-2\">\n              <SpinnerIcon className=\"size-icon-sm animate-spin\" />\n              <span className=\"text-sm text-low\">\n                {t('memberList.loading')}\n              </span>\n            </div>\n          ) : members.length === 0 ? (\n            <div className=\"text-center py-4 text-sm text-low\">\n              {t('memberList.none')}\n            </div>\n          ) : (\n            <div className=\"space-y-2\">\n              {members.map((member) => (\n                <MemberListItem\n                  key={member.user_id}\n                  member={member}\n                  currentUserId={currentUserId}\n                  isAdmin={isAdmin}\n                  onRemove={handleRemoveMember}\n                  onRoleChange={handleRoleChange}\n                  isRemoving={removeMember.isPending}\n                  isRoleChanging={updateMemberRole.isPending}\n                />\n              ))}\n            </div>\n          )}\n        </SettingsCard>\n      )}\n\n      {/* Billing CTA (admin only, non-personal orgs, when remote URL is configured) */}\n      {selectedOrg && isAdmin && !isPersonalOrg && getRemoteApiUrl() && (\n        <SettingsCard\n          title={t('billing.title')}\n          description={t('billing.description')}\n        >\n          <div className=\"flex items-center justify-between\">\n            <p className=\"text-sm text-low\">{t('billing.openInBrowser')}</p>\n            <button\n              type=\"button\"\n              onClick={() => void handleManageBilling()}\n              disabled={isOpeningBilling}\n              className={cn(\n                'flex items-center gap-2 px-base py-half rounded-sm text-sm font-medium whitespace-nowrap shrink-0',\n                'bg-brand/10 text-brand hover:bg-brand/20 border border-brand/50',\n                'transition-colors disabled:cursor-not-allowed disabled:opacity-50'\n              )}\n            >\n              {isOpeningBilling ? (\n                <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n              ) : (\n                <ArrowSquareOutIcon className=\"size-icon-xs\" weight=\"bold\" />\n              )}\n              {t('billing.manageButton')}\n            </button>\n          </div>\n        </SettingsCard>\n      )}\n\n      {/* Danger Zone */}\n      {selectedOrg && isAdmin && !isPersonalOrg && (\n        <SettingsCard\n          title={t('settings.dangerZone')}\n          description={t('settings.dangerZoneDescription')}\n        >\n          <div className=\"flex items-center justify-between\">\n            <div>\n              <p className=\"text-sm font-medium text-normal\">\n                {t('settings.deleteOrganization')}\n              </p>\n              <p className=\"text-sm text-low\">\n                {t('settings.deleteOrganizationDescription')}\n              </p>\n            </div>\n            <button\n              onClick={handleDeleteOrganization}\n              disabled={deleteOrganization.isPending}\n              className={cn(\n                'flex items-center gap-2 px-base py-half rounded-sm text-sm font-medium whitespace-nowrap shrink-0',\n                'bg-error/10 text-error hover:bg-error/20 border border-error/50',\n                'disabled:opacity-50 disabled:cursor-not-allowed transition-colors'\n              )}\n            >\n              {deleteOrganization.isPending ? (\n                <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n              ) : (\n                <TrashIcon className=\"size-icon-xs\" weight=\"bold\" />\n              )}\n              {t('common:buttons.delete')}\n            </button>\n          </div>\n        </SettingsCard>\n      )}\n    </>\n  );\n}\n\n// Alias for backwards compatibility\nexport { OrganizationsSettingsSection as OrganizationsSettingsSectionContent };\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/RelaySettingsSection.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { cloneDeep, isEqual, merge } from 'lodash';\nimport {\n  CheckIcon,\n  CopyIcon,\n  SignInIcon,\n  SpinnerIcon,\n} from '@phosphor-icons/react';\nimport { OAuthDialog } from '@/shared/dialogs/global/OAuthDialog';\nimport { useAppRuntime } from '@/shared/hooks/useAppRuntime';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { relayApi } from '@/shared/lib/api';\nimport { normalizeEnrollmentCode } from '@/shared/lib/relayPake';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport {\n  usePairRelayHostMutation,\n  useRelayRemoteHostsQuery,\n  useRelayRemotePairedHostsQuery,\n  useRemovePairedRelayHostMutation,\n} from '@/shared/dialogs/settings/settings/useRelayRemoteHostMutations';\nimport {\n  SettingsCard,\n  SettingsCheckbox,\n  SettingsField,\n  SettingsInput,\n  SettingsSaveBar,\n  SettingsSelect,\n} from './SettingsComponents';\nimport { useSettingsDirty } from './SettingsDirtyContext';\n\ninterface PairedHostRow {\n  id: string;\n  name: string;\n  status: string;\n  agentVersion: string | null;\n  pairedAt: string;\n}\n\nconst RELAY_PAIRED_CLIENTS_QUERY_KEY = ['relay', 'paired-clients'] as const;\nconst RELAY_REMOTE_CONTROL_DOCS_URL =\n  'https://www.vibekanban.com/docs/remote-control';\n\ninterface RelaySettingsSectionInitialState {\n  hostId?: string;\n}\nexport function RelaySettingsSectionContent({\n  initialState,\n}: {\n  initialState?: RelaySettingsSectionInitialState;\n}) {\n  const runtime = useAppRuntime();\n\n  if (runtime === 'local') {\n    return <LocalRelaySettingsSectionContent />;\n  }\n\n  return <RemoteRelaySettingsSectionContent initialState={initialState} />;\n}\n\nfunction LocalRelaySettingsSectionContent() {\n  const { t } = useTranslation(['settings', 'common']);\n  const { setDirty: setContextDirty } = useSettingsDirty();\n  const userSystem = useUserSystem();\n  const { config, loading, updateAndSaveConfig } = userSystem;\n  const { isSignedIn } = useAuth();\n  const queryClient = useQueryClient();\n\n  const [draft, setDraft] = useState(() => (config ? cloneDeep(config) : null));\n  const [dirty, setDirty] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState(false);\n\n  const [enrollmentCode, setEnrollmentCode] = useState<string | null>(null);\n  const [enrollmentLoading, setEnrollmentLoading] = useState(false);\n  const [enrollmentError, setEnrollmentError] = useState<string | null>(null);\n  const [removingClientId, setRemovingClientId] = useState<string | null>(null);\n  const [enrollmentCodeCopied, setEnrollmentCodeCopied] = useState(false);\n\n  const {\n    data: pairedClients = [],\n    isLoading: pairedClientsLoading,\n    error: pairedClientsError,\n  } = useQuery({\n    queryKey: RELAY_PAIRED_CLIENTS_QUERY_KEY,\n    queryFn: () => relayApi.listPairedClients(),\n    enabled: isSignedIn && (draft?.relay_enabled ?? false),\n    refetchInterval: 10000,\n  });\n\n  const removePairedClientMutation = useMutation({\n    mutationFn: (clientId: string) => relayApi.removePairedClient(clientId),\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({\n        queryKey: RELAY_PAIRED_CLIENTS_QUERY_KEY,\n      });\n    },\n  });\n\n  useEffect(() => {\n    if (!config) return;\n    if (!dirty) {\n      setDraft(cloneDeep(config));\n    }\n  }, [config, dirty]);\n\n  const hasUnsavedChanges = useMemo(() => {\n    if (!draft || !config) return false;\n    return !isEqual(draft, config);\n  }, [draft, config]);\n\n  useEffect(() => {\n    setContextDirty('relay', hasUnsavedChanges);\n    return () => setContextDirty('relay', false);\n  }, [hasUnsavedChanges, setContextDirty]);\n\n  const updateDraft = useCallback(\n    (patch: Partial<typeof config>) => {\n      setDraft((prev: typeof config) => {\n        if (!prev) return prev;\n        const next = merge({}, prev, patch);\n        if (!isEqual(next, config)) {\n          setDirty(true);\n        }\n        return next;\n      });\n    },\n    [config]\n  );\n\n  const handleSave = async () => {\n    if (!draft) return;\n\n    setSaving(true);\n    setError(null);\n    setSuccess(false);\n\n    try {\n      await updateAndSaveConfig(draft);\n      setDirty(false);\n      setSuccess(true);\n      setTimeout(() => setSuccess(false), 3000);\n    } catch {\n      setError(t('settings.general.save.error'));\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleDiscard = () => {\n    if (!config) return;\n    setDraft(cloneDeep(config));\n    setDirty(false);\n  };\n\n  const handleShowEnrollmentCode = async () => {\n    setEnrollmentLoading(true);\n    setEnrollmentError(null);\n    try {\n      const result = await relayApi.getEnrollmentCode();\n      setEnrollmentCode(result.enrollment_code);\n    } catch {\n      setEnrollmentError(t('settings.relay.enrollmentCode.fetchError'));\n    } finally {\n      setEnrollmentLoading(false);\n    }\n  };\n\n  const handleRemovePairedClient = async (clientId: string) => {\n    setRemovingClientId(clientId);\n    try {\n      await removePairedClientMutation.mutateAsync(clientId);\n    } finally {\n      setRemovingClientId(null);\n    }\n  };\n\n  if (loading) {\n    return (\n      <div className=\"flex items-center justify-center py-8 gap-2\">\n        <SpinnerIcon\n          className=\"size-icon-lg animate-spin text-brand\"\n          weight=\"bold\"\n        />\n        <span className=\"text-normal\">{t('settings.general.loading')}</span>\n      </div>\n    );\n  }\n\n  if (!config) {\n    return (\n      <div className=\"py-8\">\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {t('settings.general.loadError')}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {error && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {error}\n        </div>\n      )}\n\n      {success && (\n        <div className=\"bg-success/10 border border-success/50 rounded-sm p-4 text-success font-medium\">\n          {t('settings.general.save.success')}\n        </div>\n      )}\n\n      <SettingsCard\n        title={t('settings.relay.title')}\n        description={\n          <>\n            {t('settings.relay.description')}{' '}\n            <a\n              href={RELAY_REMOTE_CONTROL_DOCS_URL}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"text-brand hover:underline\"\n            >\n              {t('settings.relay.docsLink', 'Read docs')}\n            </a>\n          </>\n        }\n      >\n        <SettingsCheckbox\n          id=\"relay-enabled\"\n          label={t('settings.relay.enabled.label')}\n          description={t('settings.relay.enabled.helper')}\n          checked={draft?.relay_enabled ?? true}\n          onChange={(checked) => updateDraft({ relay_enabled: checked })}\n        />\n\n        {draft?.relay_enabled && (\n          <div className=\"space-y-3 mt-2\">\n            <SettingsField\n              label={t('settings.relay.hostName.label', 'Host name')}\n              description={t(\n                'settings.relay.hostName.helper',\n                'Shown when pairing from browser. Leave blank to use the default format.'\n              )}\n            >\n              <SettingsInput\n                value={draft.relay_host_name ?? ''}\n                onChange={(value) =>\n                  updateDraft({\n                    relay_host_name: value === '' ? null : value,\n                  })\n                }\n                placeholder={t(\n                  'settings.relay.hostName.placeholder',\n                  '<os_type> host (<user_id>)'\n                )}\n              />\n            </SettingsField>\n\n            {isSignedIn ? (\n              <>\n                {!enrollmentCode && (\n                  <PrimaryButton\n                    variant=\"secondary\"\n                    value={t('settings.relay.enrollmentCode.show')}\n                    onClick={handleShowEnrollmentCode}\n                    disabled={enrollmentLoading}\n                    actionIcon={enrollmentLoading ? 'spinner' : undefined}\n                  />\n                )}\n\n                {enrollmentError && (\n                  <p className=\"text-sm text-error\">{enrollmentError}</p>\n                )}\n\n                {enrollmentCode && (\n                  <div className=\"space-y-1\">\n                    <label className=\"text-sm font-medium text-normal\">\n                      {t('settings.relay.enrollmentCode.label')}\n                    </label>\n                    <div className=\"relative bg-secondary border border-border rounded-sm px-base py-half font-mono text-lg text-high tracking-widest select-all pr-10\">\n                      {enrollmentCode}\n                      <button\n                        onClick={() => {\n                          void navigator.clipboard.writeText(enrollmentCode);\n                          setEnrollmentCodeCopied(true);\n                          setTimeout(\n                            () => setEnrollmentCodeCopied(false),\n                            2000\n                          );\n                        }}\n                        className=\"absolute right-1 top-1/2 -translate-y-1/2 p-1 text-low hover:text-normal transition-colors rounded-sm\"\n                        aria-label={t(\n                          'settings.relay.enrollmentCode.copy',\n                          'Copy code'\n                        )}\n                      >\n                        {enrollmentCodeCopied ? (\n                          <CheckIcon\n                            className=\"size-icon-sm text-success\"\n                            weight=\"bold\"\n                          />\n                        ) : (\n                          <CopyIcon className=\"size-icon-sm\" weight=\"bold\" />\n                        )}\n                      </button>\n                    </div>\n                    <p className=\"text-sm text-low\">\n                      {t('settings.relay.enrollmentCode.helper')}\n                    </p>\n                  </div>\n                )}\n\n                <div className=\"space-y-2 pt-2 border-t border-border/70\">\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <h4 className=\"text-sm font-medium text-normal\">\n                      {t(\n                        'settings.relay.pairedClients.title',\n                        'Paired clients'\n                      )}\n                    </h4>\n                    <div className=\"flex items-center gap-2 text-xs text-low\">\n                      <SpinnerIcon\n                        className=\"size-icon-xs animate-spin\"\n                        weight=\"bold\"\n                      />\n                      <span>\n                        {t(\n                          'settings.relay.pairedClients.checking',\n                          'Checking for new clients'\n                        )}\n                      </span>\n                    </div>\n                  </div>\n\n                  {pairedClientsLoading && (\n                    <div className=\"flex items-center gap-2 text-sm text-low\">\n                      <SpinnerIcon\n                        className=\"size-icon-sm animate-spin\"\n                        weight=\"bold\"\n                      />\n                      <span>\n                        {t(\n                          'settings.relay.pairedClients.loading',\n                          'Loading paired clients...'\n                        )}\n                      </span>\n                    </div>\n                  )}\n\n                  {pairedClientsError instanceof Error && (\n                    <p className=\"text-sm text-error\">\n                      {pairedClientsError.message}\n                    </p>\n                  )}\n\n                  {removePairedClientMutation.error instanceof Error && (\n                    <p className=\"text-sm text-error\">\n                      {removePairedClientMutation.error.message}\n                    </p>\n                  )}\n\n                  {!pairedClientsLoading && pairedClients.length === 0 && (\n                    <div className=\"rounded-sm border border-border bg-secondary/30 p-3 text-sm text-low\">\n                      {t(\n                        'settings.relay.pairedClients.empty',\n                        'No paired clients found.'\n                      )}\n                    </div>\n                  )}\n\n                  {!pairedClientsLoading && pairedClients.length > 0 && (\n                    <div className=\"space-y-2\">\n                      {pairedClients.map((client) => (\n                        <div\n                          key={client.client_id}\n                          className=\"rounded-sm border border-border bg-secondary/30 p-3 flex items-center justify-between gap-3\"\n                        >\n                          <div className=\"min-w-0\">\n                            <p className=\"text-sm font-medium text-high truncate\">\n                              {client.client_name}\n                            </p>\n                            <p className=\"text-xs text-low\">\n                              {client.client_browser} · {client.client_os} ·{' '}\n                              {formatDeviceLabel(client.client_device)}\n                            </p>\n                          </div>\n                          <PrimaryButton\n                            variant=\"tertiary\"\n                            value={t(\n                              'settings.relay.pairedClients.remove',\n                              'Remove'\n                            )}\n                            onClick={() =>\n                              void handleRemovePairedClient(client.client_id)\n                            }\n                            disabled={\n                              removePairedClientMutation.isPending &&\n                              removingClientId === client.client_id\n                            }\n                            actionIcon={\n                              removePairedClientMutation.isPending &&\n                              removingClientId === client.client_id\n                                ? 'spinner'\n                                : undefined\n                            }\n                          />\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </div>\n              </>\n            ) : (\n              <div className=\"space-y-2\">\n                <p className=\"text-sm text-low\">\n                  {t('settings.relay.enrollmentCode.loginRequired')}\n                </p>\n                <PrimaryButton\n                  variant=\"secondary\"\n                  value={t(\n                    'settings.remoteProjects.loginRequired.action',\n                    'Sign in'\n                  )}\n                  onClick={() => void OAuthDialog.show({})}\n                >\n                  <SignInIcon className=\"size-icon-xs mr-1\" weight=\"bold\" />\n                </PrimaryButton>\n              </div>\n            )}\n          </div>\n        )}\n      </SettingsCard>\n\n      <SettingsSaveBar\n        show={hasUnsavedChanges}\n        saving={saving}\n        onSave={handleSave}\n        onDiscard={handleDiscard}\n      />\n    </>\n  );\n}\n\nfunction RemoteRelaySettingsSectionContent({\n  initialState,\n}: {\n  initialState?: RelaySettingsSectionInitialState;\n}) {\n  const { t } = useTranslation(['settings', 'common']);\n  const { isSignedIn } = useAuth();\n  const initialHostId = initialState?.hostId;\n  const hasAppliedInitialHostRef = useRef(false);\n\n  const [showPairForm, setShowPairForm] = useState(false);\n  const [selectedHostId, setSelectedHostId] = useState<string | undefined>();\n  const [pairingCode, setPairingCode] = useState('');\n  const [pairError, setPairError] = useState<string | null>(null);\n  const [pairSuccess, setPairSuccess] = useState<string | null>(null);\n  const [removingHostId, setRemovingHostId] = useState<string | null>(null);\n  const [removeError, setRemoveError] = useState<string | null>(null);\n\n  const {\n    data: hosts = [],\n    isLoading: hostsLoading,\n    error: hostsQueryError,\n  } = useQuery({\n    ...useRelayRemoteHostsQuery(),\n    enabled: isSignedIn,\n  });\n\n  const { data: pairedHosts = [], isLoading: pairedHostsLoading } = useQuery({\n    ...useRelayRemotePairedHostsQuery(),\n    enabled: isSignedIn,\n  });\n\n  const hostsError =\n    hostsQueryError != null\n      ? t('settings.relay.remote.hosts.loadError', 'Failed to load hosts.')\n      : null;\n\n  const { mutateAsync: pairRelayHostMutation, isPending: isPairing } =\n    usePairRelayHostMutation();\n\n  const { mutateAsync: removePairedHostMutation } =\n    useRemovePairedRelayHostMutation();\n\n  const pairedHostIds = useMemo(\n    () => new Set(pairedHosts.map((host) => host.host_id)),\n    [pairedHosts]\n  );\n\n  const availableHostsToPair = useMemo(\n    () => hosts.filter((host) => !pairedHostIds.has(host.id)),\n    [hosts, pairedHostIds]\n  );\n\n  useEffect(() => {\n    if (!showPairForm) {\n      return;\n    }\n\n    if (\n      availableHostsToPair.length > 0 &&\n      (!selectedHostId ||\n        !availableHostsToPair.some((host) => host.id === selectedHostId))\n    ) {\n      setSelectedHostId(availableHostsToPair[0]?.id);\n    }\n  }, [availableHostsToPair, selectedHostId, showPairForm]);\n\n  useEffect(() => {\n    if (!initialHostId || hasAppliedInitialHostRef.current) {\n      return;\n    }\n\n    if (hostsLoading || pairedHostsLoading) {\n      return;\n    }\n\n    if (!availableHostsToPair.some((host) => host.id === initialHostId)) {\n      hasAppliedInitialHostRef.current = true;\n      return;\n    }\n\n    setPairSuccess(null);\n    setPairError(null);\n    setSelectedHostId(initialHostId);\n    setShowPairForm(true);\n    hasAppliedInitialHostRef.current = true;\n  }, [availableHostsToPair, hostsLoading, initialHostId, pairedHostsLoading]);\n\n  const pairedHostRows = useMemo<PairedHostRow[]>(() => {\n    return pairedHosts.map((entry) => {\n      const liveHost = hosts.find((host) => host.id === entry.host_id);\n      return {\n        id: entry.host_id,\n        name: liveHost?.name ?? entry.host_name,\n        status: liveHost?.status ?? 'offline',\n        agentVersion: liveHost?.agent_version ?? null,\n        pairedAt: entry.paired_at,\n      };\n    });\n  }, [hosts, pairedHosts]);\n\n  const hostOptions = useMemo(\n    () =>\n      availableHostsToPair.map((host) => ({\n        value: host.id,\n        label: host.name,\n      })),\n    [availableHostsToPair]\n  );\n\n  const canSubmitPairing =\n    !!selectedHostId &&\n    normalizeEnrollmentCode(pairingCode).length === 6 &&\n    !isPairing;\n\n  const resetPairForm = () => {\n    setPairingCode('');\n    setPairError(null);\n    setPairSuccess(null);\n    setShowPairForm(false);\n  };\n\n  const handlePairHost = useCallback(async () => {\n    if (!selectedHostId) {\n      return;\n    }\n\n    const normalizedCode = normalizeEnrollmentCode(pairingCode);\n    if (normalizedCode.length !== 6) {\n      setPairError(\n        t(\n          'settings.relay.remote.pair.code.invalid',\n          'Enter a 6-character code.'\n        )\n      );\n      return;\n    }\n\n    setPairError(null);\n    setPairSuccess(null);\n\n    try {\n      const selectedHost = hosts.find((host) => host.id === selectedHostId);\n      await pairRelayHostMutation({\n        hostId: selectedHostId,\n        hostName: selectedHost?.name ?? selectedHostId,\n        normalizedCode,\n      });\n      setPairSuccess(\n        t('settings.relay.remote.pair.success', 'Host paired successfully.')\n      );\n      setPairingCode('');\n      setShowPairForm(false);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      setPairError(message);\n    }\n  }, [hosts, pairRelayHostMutation, pairingCode, selectedHostId, t]);\n\n  const handleRemovePairedHost = useCallback(\n    async (hostId: string) => {\n      setRemovingHostId(hostId);\n      setRemoveError(null);\n      setPairSuccess(null);\n\n      try {\n        await removePairedHostMutation(hostId);\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        setRemoveError(message);\n      } finally {\n        setRemovingHostId(null);\n      }\n    },\n    [removePairedHostMutation]\n  );\n\n  if (!isSignedIn) {\n    return (\n      <SettingsCard\n        title={t('settings.relay.title')}\n        description={\n          <>\n            {t('settings.relay.description')}{' '}\n            <a\n              href={RELAY_REMOTE_CONTROL_DOCS_URL}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"text-brand hover:underline\"\n            >\n              {t('settings.relay.docsLink', 'Read docs')}\n            </a>\n          </>\n        }\n      >\n        <div className=\"space-y-2\">\n          <p className=\"text-sm text-low\">\n            {t(\n              'settings.relay.remote.loginRequired',\n              'Sign in to view and pair relay hosts.'\n            )}\n          </p>\n          <PrimaryButton\n            variant=\"secondary\"\n            value={t('settings.remoteProjects.loginRequired.action', 'Sign in')}\n            onClick={() => void OAuthDialog.show({})}\n          >\n            <SignInIcon className=\"size-icon-xs mr-1\" weight=\"bold\" />\n          </PrimaryButton>\n        </div>\n      </SettingsCard>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4 pb-6\">\n      <SettingsCard\n        title={t('settings.relay.title')}\n        description={\n          <>\n            {t(\n              'settings.relay.remote.description',\n              'Pair browser access to your relay hosts using a one-time code.'\n            )}{' '}\n            <a\n              href={RELAY_REMOTE_CONTROL_DOCS_URL}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"text-brand hover:underline\"\n            >\n              {t('settings.relay.docsLink', 'Read docs')}\n            </a>\n          </>\n        }\n        headerAction={\n          <PrimaryButton\n            variant=\"secondary\"\n            value={t('settings.relay.remote.pair.button', 'Pair new host')}\n            onClick={() => {\n              setPairSuccess(null);\n              setPairError(null);\n              setShowPairForm((current) => !current);\n            }}\n            disabled={availableHostsToPair.length === 0 || isPairing}\n          />\n        }\n      >\n        {pairSuccess && (\n          <div className=\"bg-success/10 border border-success/50 rounded-sm p-3 text-success text-sm\">\n            {pairSuccess}\n          </div>\n        )}\n\n        {hostsError && (\n          <div className=\"bg-error/10 border border-error/50 rounded-sm p-3 text-error text-sm\">\n            {hostsError}\n          </div>\n        )}\n\n        {showPairForm && (\n          <div className=\"border border-border rounded-sm bg-secondary/40 p-4 space-y-4\">\n            <div className=\"space-y-1\">\n              <label className=\"text-sm font-medium text-normal\">\n                {t('settings.relay.remote.pair.host.label', 'Host')}\n              </label>\n              <SettingsSelect\n                value={selectedHostId}\n                options={hostOptions}\n                onChange={setSelectedHostId}\n                placeholder={t(\n                  'settings.relay.remote.pair.host.placeholder',\n                  'Select a host'\n                )}\n                disabled={isPairing || hostOptions.length === 0}\n              />\n            </div>\n\n            <div className=\"space-y-2\">\n              <label className=\"text-sm font-medium text-normal\">\n                {t('settings.relay.remote.pair.code.label', 'Pairing code')}\n              </label>\n              <RelayCodeInput\n                value={pairingCode}\n                onChange={setPairingCode}\n                disabled={isPairing}\n              />\n              <p className=\"text-sm text-low\">\n                {t(\n                  'settings.relay.remote.pair.code.helper',\n                  'Enter the 6-character code shown on the host settings page.'\n                )}\n              </p>\n            </div>\n\n            {pairError && <p className=\"text-sm text-error\">{pairError}</p>}\n\n            {isPairing && (\n              <div className=\"flex items-center gap-2 text-sm text-normal\">\n                <SpinnerIcon\n                  className=\"size-icon-sm animate-spin\"\n                  weight=\"bold\"\n                />\n                <span>\n                  {t(\n                    'settings.relay.remote.pair.inProgress',\n                    'Pairing host, please wait...'\n                  )}\n                </span>\n              </div>\n            )}\n\n            <div className=\"flex items-center gap-2\">\n              <PrimaryButton\n                value={t('settings.relay.remote.pair.confirm', 'Pair host')}\n                onClick={() => void handlePairHost()}\n                disabled={!canSubmitPairing}\n                actionIcon={isPairing ? 'spinner' : undefined}\n              />\n              <PrimaryButton\n                variant=\"tertiary\"\n                value={t('common:buttons.cancel')}\n                onClick={resetPairForm}\n                disabled={isPairing}\n              />\n            </div>\n          </div>\n        )}\n\n        <div className=\"space-y-2\">\n          <h4 className=\"text-sm font-medium text-normal\">\n            {t('settings.relay.remote.pairedHosts.title', 'Paired hosts')}\n          </h4>\n\n          {(hostsLoading || pairedHostsLoading) && (\n            <div className=\"flex items-center gap-2 text-sm text-low\">\n              <SpinnerIcon\n                className=\"size-icon-sm animate-spin\"\n                weight=\"bold\"\n              />\n              <span>\n                {t(\n                  'settings.relay.remote.pairedHosts.loading',\n                  'Loading hosts...'\n                )}\n              </span>\n            </div>\n          )}\n\n          {removeError && <p className=\"text-sm text-error\">{removeError}</p>}\n\n          {!hostsLoading &&\n            !pairedHostsLoading &&\n            pairedHostRows.length === 0 && (\n              <div className=\"rounded-sm border border-border bg-secondary/30 p-3 text-sm text-low\">\n                {t(\n                  'settings.relay.remote.pairedHosts.empty',\n                  'No hosts are paired yet.'\n                )}\n              </div>\n            )}\n\n          {!hostsLoading &&\n            !pairedHostsLoading &&\n            pairedHostRows.length > 0 && (\n              <div className=\"space-y-2\">\n                {pairedHostRows.map((host) => (\n                  <div\n                    key={host.id}\n                    className=\"rounded-sm border border-border bg-secondary/30 p-3 flex items-center justify-between gap-3\"\n                  >\n                    <div className=\"min-w-0\">\n                      <p className=\"text-sm font-medium text-high truncate\">\n                        {host.name}\n                      </p>\n                      <p className=\"text-xs text-low\">\n                        {host.status === 'online'\n                          ? t('settings.relay.remote.status.online', 'Online')\n                          : t(\n                              'settings.relay.remote.status.offline',\n                              'Offline'\n                            )}\n                        {host.agentVersion ? ` · v${host.agentVersion}` : ''}\n                      </p>\n                    </div>\n                    <div className=\"flex items-center gap-3 shrink-0\">\n                      <p className=\"text-xs text-low shrink-0\">\n                        {t(\n                          'settings.relay.remote.pairedHosts.pairedOn',\n                          'Paired'\n                        )}{' '}\n                        · {new Date(host.pairedAt).toLocaleDateString()}\n                      </p>\n                      <PrimaryButton\n                        variant=\"tertiary\"\n                        value={t(\n                          'settings.relay.remote.pairedHosts.remove',\n                          'Remove'\n                        )}\n                        onClick={() => void handleRemovePairedHost(host.id)}\n                        disabled={removingHostId !== null}\n                        actionIcon={\n                          removingHostId === host.id ? 'spinner' : undefined\n                        }\n                      />\n                    </div>\n                  </div>\n                ))}\n              </div>\n            )}\n        </div>\n      </SettingsCard>\n    </div>\n  );\n}\n\nfunction formatDeviceLabel(device: string): string {\n  if (!device) {\n    return '';\n  }\n  return `${device[0]?.toUpperCase() ?? ''}${device.slice(1)}`;\n}\n\nfunction RelayCodeInput({\n  value,\n  onChange,\n  disabled,\n}: {\n  value: string;\n  onChange: (nextValue: string) => void;\n  disabled?: boolean;\n}) {\n  const inputsRef = useRef<Array<HTMLInputElement | null>>([]);\n  const normalizedValue = normalizeEnrollmentCode(value).slice(0, 6);\n  const characters = useMemo(\n    () => Array.from({ length: 6 }, (_, index) => normalizedValue[index] ?? ''),\n    [normalizedValue]\n  );\n\n  const setCharacterAt = (index: number, char: string) => {\n    const next = [...characters];\n    next[index] = char;\n    onChange(next.join(''));\n  };\n\n  return (\n    <div\n      className=\"flex gap-2\"\n      onPaste={(event) => {\n        const pasted = normalizeEnrollmentCode(\n          event.clipboardData.getData('text')\n        ).slice(0, 6);\n        if (!pasted) {\n          return;\n        }\n\n        event.preventDefault();\n        onChange(pasted);\n        const focusIndex = Math.min(pasted.length, 5);\n        inputsRef.current[focusIndex]?.focus();\n      }}\n    >\n      {characters.map((char, index) => (\n        <input\n          key={index}\n          ref={(element) => {\n            inputsRef.current[index] = element;\n          }}\n          type=\"text\"\n          inputMode=\"text\"\n          autoComplete=\"one-time-code\"\n          value={char}\n          maxLength={1}\n          disabled={disabled}\n          onChange={(event) => {\n            const nextChar = normalizeEnrollmentCode(event.target.value).slice(\n              -1\n            );\n            setCharacterAt(index, nextChar);\n            if (nextChar && index < 5) {\n              inputsRef.current[index + 1]?.focus();\n            }\n          }}\n          onKeyDown={(event) => {\n            if (event.key === 'Backspace' && !characters[index] && index > 0) {\n              inputsRef.current[index - 1]?.focus();\n            }\n            if (event.key === 'ArrowLeft' && index > 0) {\n              event.preventDefault();\n              inputsRef.current[index - 1]?.focus();\n            }\n            if (event.key === 'ArrowRight' && index < 5) {\n              event.preventDefault();\n              inputsRef.current[index + 1]?.focus();\n            }\n          }}\n          className=\"w-10 h-12 rounded-sm border border-border bg-panel text-center font-mono text-lg uppercase text-high focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50\"\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/RemoteProjectsSettingsSection.tsx",
    "content": "import { useEffect, useState, useMemo, useCallback } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useTranslation } from 'react-i18next';\nimport {\n  DragDropContext,\n  Droppable,\n  Draggable,\n  type DropResult,\n  type DraggableProvided,\n  type DraggableStateSnapshot,\n  type DroppableProvided,\n  type DraggableRubric,\n} from '@hello-pangea/dnd';\nimport {\n  SpinnerIcon,\n  PlusIcon,\n  TrashIcon,\n  DotsThreeIcon,\n  SignInIcon,\n  XIcon,\n  DotsSixVerticalIcon,\n  PencilSimpleLineIcon,\n  CheckIcon,\n  CaretDownIcon,\n} from '@phosphor-icons/react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@vibe/ui/components/Dropdown';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@vibe/ui/components/Popover';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport { Switch } from '@vibe/ui/components/Switch';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { OAuthDialog } from '@/shared/dialogs/global/OAuthDialog';\nimport { CreateRemoteProjectDialog } from '@/shared/dialogs/org/CreateRemoteProjectDialog';\nimport { DeleteRemoteProjectDialog } from '@/shared/dialogs/org/DeleteRemoteProjectDialog';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport { bulkUpdateProjectStatuses } from '@/shared/lib/remoteApi';\n\nimport {\n  PROJECTS_SHAPE,\n  PROJECT_MUTATION,\n  PROJECT_PROJECT_STATUSES_SHAPE,\n  PROJECT_STATUS_MUTATION,\n  PROJECT_ISSUES_SHAPE,\n  type Project,\n} from 'shared/remote-types';\nimport { getRandomPresetColor, PRESET_COLORS } from '@/shared/lib/colors';\nimport { InlineColorPicker } from '@vibe/ui/components/ColorPicker';\nimport { cn } from '@/shared/lib/utils';\nimport {\n  SettingsCard,\n  SettingsField,\n  SettingsInput,\n  SettingsSaveBar,\n  TwoColumnPicker,\n  TwoColumnPickerColumn,\n  TwoColumnPickerItem,\n  TwoColumnPickerBadge,\n  TwoColumnPickerEmpty,\n} from './SettingsComponents';\nimport { useSettingsDirty } from './SettingsDirtyContext';\nimport type { DraftWorkspaceRepo, GitBranch, Repo } from 'shared/types';\nimport { repoApi } from '@/shared/lib/api';\nimport {\n  SelectionDialog,\n  type SelectionPage,\n} from '@/shared/dialogs/command-bar/SelectionDialog';\nimport {\n  buildBranchSelectionPages,\n  type BranchSelectionResult,\n} from '@/shared/dialogs/command-bar/selections/branchSelection';\nimport { FolderPickerDialog } from '@/shared/dialogs/shared/FolderPickerDialog';\nimport {\n  getProjectRepoDefaults,\n  saveProjectRepoDefaults,\n} from '@/shared/hooks/useProjectRepoDefaults';\n\ninterface FormState {\n  name: string;\n  color: string;\n}\n\ninterface RemoteProjectsSettingsSectionProps {\n  initialState?: { organizationId?: string; projectId?: string };\n}\n\ninterface StatusItem {\n  id: string;\n  name: string;\n  color: string;\n  hidden: boolean;\n  sort_order: number;\n  isNew: boolean;\n}\n\ninterface StatusRowCloneProps {\n  status: StatusItem;\n  provided: DraggableProvided;\n}\n\nfunction StatusRowClone({ status, provided }: StatusRowCloneProps) {\n  return createPortal(\n    <div\n      ref={provided.innerRef}\n      {...provided.draggableProps}\n      {...provided.dragHandleProps}\n      className={cn(\n        'flex items-center gap-base px-base py-half rounded-sm shadow-lg',\n        status.isNew ? 'bg-panel' : 'bg-secondary',\n        status.hidden && 'opacity-50'\n      )}\n      style={{\n        ...provided.draggableProps.style,\n        zIndex: 10001,\n      }}\n    >\n      <div className=\"flex items-center justify-center size-icon-sm cursor-grabbing\">\n        <DotsSixVerticalIcon className=\"size-icon-xs text-low\" weight=\"bold\" />\n      </div>\n      <div\n        className=\"size-dot rounded-full shrink-0\"\n        style={{ backgroundColor: `hsl(${status.color})` }}\n      />\n      <span className=\"text-sm text-high\">{status.name}</span>\n    </div>,\n    document.body\n  );\n}\n\ninterface StatusRowProps {\n  status: StatusItem;\n  index: number;\n  issueCount: number;\n  visibleCount: number;\n  editingId: string | null;\n  editingColorId: string | null;\n  onToggleHidden: (id: string, hidden: boolean) => void;\n  onNameChange: (id: string, name: string) => void;\n  onColorChange: (id: string, color: string) => void;\n  onDelete: (id: string) => void;\n  onStartEditing: (id: string) => void;\n  onStartEditingColor: (id: string | null) => void;\n  onStopEditing: () => void;\n}\n\nfunction StatusRow({\n  status,\n  index,\n  issueCount,\n  visibleCount,\n  editingId,\n  editingColorId,\n  onToggleHidden,\n  onNameChange,\n  onColorChange,\n  onDelete,\n  onStartEditing,\n  onStartEditingColor,\n  onStopEditing,\n}: StatusRowProps) {\n  const { t } = useTranslation('common');\n  const [localName, setLocalName] = useState(status.name);\n  const isEditing = editingId === status.id;\n  const isEditingColor = editingColorId === status.id;\n  const isLastVisible = !status.hidden && visibleCount === 1;\n  const canDelete = issueCount === 0;\n\n  useEffect(() => {\n    setLocalName(status.name);\n  }, [status.name]);\n\n  const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      if (localName.trim()) {\n        onNameChange(status.id, localName.trim());\n      } else {\n        setLocalName(status.name);\n      }\n      onStopEditing();\n    } else if (e.key === 'Escape') {\n      setLocalName(status.name);\n      onStopEditing();\n    }\n  };\n\n  const handleNameBlur = () => {\n    if (localName.trim() && localName !== status.name) {\n      onNameChange(status.id, localName.trim());\n    } else {\n      setLocalName(status.name);\n    }\n    onStopEditing();\n  };\n\n  return (\n    <Draggable draggableId={status.id} index={index}>\n      {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (\n        <div\n          ref={provided.innerRef}\n          {...provided.draggableProps}\n          className={cn(\n            'flex items-center justify-between px-base py-half rounded-sm',\n            status.isNew ? 'bg-panel' : 'bg-secondary',\n            status.hidden && 'opacity-50',\n            snapshot.isDragging && 'shadow-lg opacity-80'\n          )}\n          style={{\n            ...provided.draggableProps.style,\n            zIndex: snapshot.isDragging ? 10 : undefined,\n          }}\n        >\n          <div className=\"flex items-center gap-base\">\n            <div\n              {...provided.dragHandleProps}\n              className=\"flex items-center justify-center size-icon-sm cursor-grab\"\n            >\n              <DotsSixVerticalIcon\n                className=\"size-icon-xs text-low\"\n                weight=\"bold\"\n              />\n            </div>\n\n            <Popover\n              open={isEditingColor}\n              onOpenChange={(open) =>\n                onStartEditingColor(open ? status.id : null)\n              }\n            >\n              <PopoverTrigger asChild>\n                <button\n                  type=\"button\"\n                  className=\"flex items-center justify-center size-icon-sm\"\n                  title={t('kanban.changeColor', 'Change color')}\n                >\n                  <div\n                    className=\"size-dot rounded-full shrink-0\"\n                    style={{ backgroundColor: `hsl(${status.color})` }}\n                  />\n                </button>\n              </PopoverTrigger>\n              <PopoverContent\n                align=\"start\"\n                className=\"w-auto p-base\"\n                onInteractOutside={(e) => {\n                  e.preventDefault();\n                  onStartEditingColor(null);\n                }}\n              >\n                <InlineColorPicker\n                  value={status.color}\n                  onChange={(color) => onColorChange(status.id, color)}\n                  colors={PRESET_COLORS}\n                />\n              </PopoverContent>\n            </Popover>\n\n            {isEditing ? (\n              <input\n                type=\"text\"\n                value={localName}\n                onChange={(e) => setLocalName(e.target.value)}\n                onKeyDown={handleNameKeyDown}\n                onBlur={handleNameBlur}\n                autoFocus\n                className=\"bg-transparent text-sm text-high outline-none border-b border-brand w-24\"\n              />\n            ) : (\n              <span\n                className=\"text-sm text-high cursor-pointer\"\n                onClick={() => onStartEditing(status.id)}\n              >\n                {status.name}\n              </span>\n            )}\n          </div>\n\n          <div className=\"flex items-center gap-base\">\n            <button\n              type=\"button\"\n              onClick={() => onStartEditing(status.id)}\n              className=\"flex items-center justify-center size-icon-sm text-low hover:text-normal\"\n              title={t('kanban.editName', 'Edit name')}\n            >\n              <PencilSimpleLineIcon className=\"size-icon-xs\" weight=\"bold\" />\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => canDelete && onDelete(status.id)}\n              className={cn(\n                'flex items-center justify-center size-icon-sm',\n                canDelete\n                  ? 'text-low hover:text-normal'\n                  : 'text-low opacity-50 cursor-not-allowed'\n              )}\n              title={\n                canDelete\n                  ? t('kanban.deleteStatus', 'Delete status')\n                  : t('kanban.cannotDeleteWithIssues', 'Move issues first')\n              }\n              disabled={!canDelete}\n            >\n              <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n            </button>\n            <Switch\n              checked={!status.hidden}\n              onCheckedChange={(checked) => onToggleHidden(status.id, !checked)}\n              disabled={isLastVisible && !status.hidden}\n              title={\n                isLastVisible\n                  ? t(\n                      'kanban.lastVisibleStatus',\n                      'At least one status must be visible'\n                    )\n                  : status.hidden\n                    ? t('kanban.showStatus', 'Show status')\n                    : t('kanban.hideStatus', 'Hide status')\n              }\n            />\n          </div>\n        </div>\n      )}\n    </Draggable>\n  );\n}\n\nexport function RemoteProjectsSettingsSection({\n  initialState,\n}: RemoteProjectsSettingsSectionProps) {\n  const { t } = useTranslation(['settings', 'common', 'projects']);\n  const { setDirty: setContextDirty } = useSettingsDirty();\n  const { isSignedIn, isLoaded } = useAuth();\n\n  // Selection state - initialize with provided values\n  const [selectedOrgId, setSelectedOrgId] = useState<string | null>(\n    initialState?.organizationId ?? null\n  );\n  const [selectedProjectId, setSelectedProjectId] = useState<string | null>(\n    initialState?.projectId ?? null\n  );\n\n  // Form state for editing\n  const [formState, setFormState] = useState<FormState | null>(null);\n\n  // UI state\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState<string | null>(null);\n  const [isSaving, setIsSaving] = useState(false);\n\n  // Default repos state\n  const [defaultRepos, setDefaultRepos] = useState<DraftWorkspaceRepo[]>([]);\n  const [isLoadingDefaults, setIsLoadingDefaults] = useState(false);\n  const [allRepos, setAllRepos] = useState<Repo[]>([]);\n  const [defaultReposError, setDefaultReposError] = useState<string | null>(\n    null\n  );\n  const [branchCache, setBranchCache] = useState<Map<string, GitBranch[]>>(\n    new Map()\n  );\n  const [loadingBranches, setLoadingBranches] = useState<Set<string>>(\n    new Set()\n  );\n\n  // Fetch organizations\n  const {\n    data: orgsResponse,\n    isLoading: orgsLoading,\n    error: orgsError,\n  } = useUserOrganizations();\n\n  const organizations = useMemo(\n    () => orgsResponse?.organizations ?? [],\n    [orgsResponse?.organizations]\n  );\n\n  // Auto-select first org when loaded (only if no initial org was provided)\n  useEffect(() => {\n    if (\n      !initialState?.organizationId &&\n      organizations.length > 0 &&\n      !selectedOrgId\n    ) {\n      setSelectedOrgId(organizations[0].id);\n    }\n  }, [organizations, selectedOrgId, initialState?.organizationId]);\n\n  // Fetch projects for selected org\n  const params = useMemo(\n    () => ({ organization_id: selectedOrgId || '' }),\n    [selectedOrgId]\n  );\n\n  const {\n    data: projects,\n    isLoading: projectsLoading,\n    update,\n    remove,\n  } = useShape(PROJECTS_SHAPE, params, {\n    enabled: !!selectedOrgId,\n    mutation: PROJECT_MUTATION,\n  });\n\n  // Initialize form state when project is pre-selected and projects are loaded\n  useEffect(() => {\n    if (initialState?.projectId && projects.length > 0 && !formState) {\n      const project = projects.find((p) => p.id === initialState.projectId);\n      if (project) {\n        setFormState({ name: project.name, color: project.color });\n      }\n    }\n  }, [initialState?.projectId, projects, formState]);\n\n  // Find selected project\n  const selectedProject = useMemo(\n    () => projects.find((p) => p.id === selectedProjectId) ?? null,\n    [projects, selectedProjectId]\n  );\n\n  // Fetch statuses and issues for selected project (for status settings)\n  const projectParams = useMemo(\n    () => ({ project_id: selectedProjectId ?? '' }),\n    [selectedProjectId]\n  );\n\n  const {\n    data: projectStatuses,\n    insert: insertProjectStatus,\n    update: updateProjectStatus,\n    remove: removeProjectStatus,\n  } = useShape(PROJECT_PROJECT_STATUSES_SHAPE, projectParams, {\n    enabled: !!selectedProjectId,\n    mutation: PROJECT_STATUS_MUTATION,\n  });\n\n  const { data: projectIssues } = useShape(\n    PROJECT_ISSUES_SHAPE,\n    projectParams,\n    {\n      enabled: !!selectedProjectId,\n    }\n  );\n\n  const issueCountByStatus = useMemo(() => {\n    const counts: Record<string, number> = {};\n\n    for (const status of projectStatuses) {\n      counts[status.id] = 0;\n    }\n\n    for (const issue of projectIssues) {\n      counts[issue.status_id] = (counts[issue.status_id] ?? 0) + 1;\n    }\n\n    return counts;\n  }, [projectStatuses, projectIssues]);\n\n  const sortedProjectStatuses = useMemo(\n    () => [...projectStatuses].sort((a, b) => a.sort_order - b.sort_order),\n    [projectStatuses]\n  );\n\n  const [localStatuses, setLocalStatuses] = useState<StatusItem[]>([]);\n  const [editingStatusId, setEditingStatusId] = useState<string | null>(null);\n  const [editingStatusColorId, setEditingStatusColorId] = useState<\n    string | null\n  >(null);\n  const [hasStatusChanges, setHasStatusChanges] = useState(false);\n\n  useEffect(() => {\n    if (!selectedProjectId) {\n      setLocalStatuses([]);\n      setHasStatusChanges(false);\n      return;\n    }\n\n    if (!hasStatusChanges) {\n      setLocalStatuses(\n        sortedProjectStatuses.map((status) => ({\n          id: status.id,\n          name: status.name,\n          color: status.color,\n          hidden: status.hidden,\n          sort_order: status.sort_order,\n          isNew: false,\n        }))\n      );\n    }\n  }, [selectedProjectId, sortedProjectStatuses, hasStatusChanges]);\n\n  // Load default repos and registered repos when project changes\n  useEffect(() => {\n    if (!selectedProjectId) {\n      setDefaultRepos([]);\n      setAllRepos([]);\n      setDefaultReposError(null);\n      return;\n    }\n    setIsLoadingDefaults(true);\n    setDefaultReposError(null);\n\n    Promise.all([\n      getProjectRepoDefaults(selectedProjectId),\n      repoApi.list().catch(() => {\n        setDefaultReposError(\n          t('settings:settings.remoteProjects.form.defaultRepos.fetchError')\n        );\n        return [] as Repo[];\n      }),\n    ])\n      .then(([repos, registeredRepos]) => {\n        setDefaultRepos(repos ?? []);\n        setAllRepos(registeredRepos);\n      })\n      .catch(() => setDefaultRepos([]))\n      .finally(() => setIsLoadingDefaults(false));\n  }, [selectedProjectId, t]);\n\n  const defaultRepoIds = useMemo(\n    () => new Set(defaultRepos.map((r) => r.repo_id)),\n    [defaultRepos]\n  );\n\n  const availableRepos = useMemo(\n    () => allRepos.filter((r) => !defaultRepoIds.has(r.id)),\n    [allRepos, defaultRepoIds]\n  );\n\n  const pickBranchForRepo = useCallback(async (repo: Repo) => {\n    const branches = await repoApi.getBranches(repo.id);\n    const branchItems = branches.map((b) => ({\n      name: b.name,\n      isCurrent: b.is_current,\n    }));\n    if (branchItems.length === 0) return null;\n    const branchResult = (await SelectionDialog.show({\n      initialPageId: 'selectBranch',\n      pages: buildBranchSelectionPages(\n        branchItems,\n        repo.display_name || repo.name\n      ) as Record<string, SelectionPage>,\n    })) as BranchSelectionResult | undefined;\n\n    return branchResult?.branch ?? null;\n  }, []);\n\n  const handleAddDefaultRepo = useCallback(\n    async (repo: Repo) => {\n      if (!selectedProjectId) return;\n      setDefaultReposError(null);\n      try {\n        const branch = await pickBranchForRepo(repo);\n        if (!branch) {\n          const branches = await repoApi.getBranches(repo.id);\n          if (branches.length === 0) {\n            setDefaultReposError(\n              t('settings:settings.remoteProjects.form.defaultRepos.noBranches')\n            );\n          }\n          return;\n        }\n        const updated = [\n          ...defaultRepos,\n          { repo_id: repo.id, target_branch: branch },\n        ];\n        setDefaultRepos(updated);\n        await saveProjectRepoDefaults(selectedProjectId, updated);\n      } catch (error) {\n        setDefaultReposError(\n          error instanceof Error\n            ? error.message\n            : t('settings:settings.remoteProjects.form.defaultRepos.saveError')\n        );\n      }\n    },\n    [selectedProjectId, defaultRepos, pickBranchForRepo, t]\n  );\n\n  const handleAddNewDefaultRepo = useCallback(async () => {\n    if (!selectedProjectId) return;\n    setDefaultReposError(null);\n    try {\n      const result = await FolderPickerDialog.show({});\n      if (!result) return;\n      const newRepo = await repoApi.register({ path: result });\n      setAllRepos((prev) => [...prev, newRepo]);\n      await handleAddDefaultRepo(newRepo);\n    } catch (error) {\n      setDefaultReposError(\n        error instanceof Error\n          ? error.message\n          : t('settings:settings.remoteProjects.form.defaultRepos.saveError')\n      );\n    }\n  }, [selectedProjectId, handleAddDefaultRepo, t]);\n\n  const handleRemoveDefaultRepo = useCallback(\n    async (repoId: string) => {\n      if (!selectedProjectId) return;\n      setDefaultReposError(null);\n      const updated = defaultRepos.filter((r) => r.repo_id !== repoId);\n      setDefaultRepos(updated);\n      try {\n        await saveProjectRepoDefaults(selectedProjectId, updated);\n      } catch (error) {\n        setDefaultReposError(\n          error instanceof Error\n            ? error.message\n            : t('settings:settings.remoteProjects.form.defaultRepos.saveError')\n        );\n      }\n    },\n    [selectedProjectId, defaultRepos, t]\n  );\n\n  const fetchBranchesForRepo = useCallback(\n    async (repoId: string) => {\n      if (branchCache.has(repoId)) return;\n      setLoadingBranches((prev) => new Set(prev).add(repoId));\n      try {\n        const branches = await repoApi.getBranches(repoId);\n        setBranchCache((prev) => new Map(prev).set(repoId, branches));\n      } catch {\n        setDefaultReposError(\n          t('settings:settings.remoteProjects.form.defaultRepos.fetchError')\n        );\n      } finally {\n        setLoadingBranches((prev) => {\n          const next = new Set(prev);\n          next.delete(repoId);\n          return next;\n        });\n      }\n    },\n    [branchCache, t]\n  );\n\n  const handleChangeBranch = useCallback(\n    async (repoId: string, newBranch: string) => {\n      if (!selectedProjectId) return;\n      const updated = defaultRepos.map((r) =>\n        r.repo_id === repoId ? { ...r, target_branch: newBranch } : r\n      );\n      setDefaultRepos(updated);\n      try {\n        await saveProjectRepoDefaults(selectedProjectId, updated);\n      } catch (error) {\n        setDefaultReposError(\n          error instanceof Error\n            ? error.message\n            : t('settings:settings.remoteProjects.form.defaultRepos.saveError')\n        );\n      }\n    },\n    [selectedProjectId, defaultRepos, t]\n  );\n\n  const visibleStatusCount = useMemo(\n    () => localStatuses.filter((status) => !status.hidden).length,\n    [localStatuses]\n  );\n\n  const isProjectDirty = useMemo(() => {\n    if (!selectedProject || !formState) return false;\n    return (\n      formState.name !== selectedProject.name ||\n      formState.color !== selectedProject.color\n    );\n  }, [selectedProject, formState]);\n\n  const isDirty = isProjectDirty || hasStatusChanges;\n\n  // Sync dirty state to context for unsaved changes confirmation\n  useEffect(() => {\n    setContextDirty('remote-projects', isDirty);\n    return () => setContextDirty('remote-projects', false);\n  }, [isDirty, setContextDirty]);\n\n  const handleStatusToggleHidden = useCallback(\n    (id: string, hidden: boolean) => {\n      setLocalStatuses((prev) =>\n        prev.map((status) =>\n          status.id === id ? { ...status, hidden } : status\n        )\n      );\n      setHasStatusChanges(true);\n    },\n    []\n  );\n\n  const handleStatusNameChange = useCallback((id: string, name: string) => {\n    setLocalStatuses((prev) =>\n      prev.map((status) => (status.id === id ? { ...status, name } : status))\n    );\n    setHasStatusChanges(true);\n  }, []);\n\n  const handleStatusColorChange = useCallback((id: string, color: string) => {\n    setLocalStatuses((prev) =>\n      prev.map((status) => (status.id === id ? { ...status, color } : status))\n    );\n    setHasStatusChanges(true);\n  }, []);\n\n  const handleStatusDelete = useCallback((id: string) => {\n    setLocalStatuses((prev) => prev.filter((status) => status.id !== id));\n    setHasStatusChanges(true);\n  }, []);\n\n  const handleStatusAdd = useCallback(() => {\n    const newId = crypto.randomUUID();\n    const maxSortOrder = localStatuses.reduce(\n      (max, status) => Math.max(max, status.sort_order),\n      0\n    );\n\n    setLocalStatuses((prev) => [\n      ...prev,\n      {\n        id: newId,\n        name: t('kanban.newStatus', 'New Status'),\n        color: getRandomPresetColor(),\n        hidden: false,\n        sort_order: maxSortOrder + 1000,\n        isNew: true,\n      },\n    ]);\n    setEditingStatusId(newId);\n    setHasStatusChanges(true);\n  }, [localStatuses, t]);\n\n  const handleStatusDragEnd = useCallback((result: DropResult) => {\n    const { source, destination } = result;\n    if (!destination || source.index === destination.index) return;\n\n    setLocalStatuses((prev) => {\n      const reordered = [...prev];\n      const [moved] = reordered.splice(source.index, 1);\n      reordered.splice(destination.index, 0, moved);\n      return reordered.map((status, index) => ({\n        ...status,\n        sort_order: index,\n      }));\n    });\n    setHasStatusChanges(true);\n  }, []);\n\n  const persistStatusChanges = useCallback(async () => {\n    const originalById = new Map(\n      projectStatuses.map((status) => [status.id, status])\n    );\n    const mutationPromises: Promise<unknown>[] = [];\n    const localIds = new Set(localStatuses.map((status) => status.id));\n\n    for (const original of projectStatuses) {\n      if (!localIds.has(original.id)) {\n        const result = removeProjectStatus(original.id);\n        mutationPromises.push(result.persisted);\n      }\n    }\n\n    const bulkUpdates: {\n      id: string;\n      changes: Partial<{\n        name: string;\n        color: string;\n        sort_order: number;\n        hidden: boolean;\n      }>;\n    }[] = [];\n\n    for (const local of localStatuses) {\n      const original = originalById.get(local.id);\n\n      if (!original) {\n        const result = insertProjectStatus({\n          id: local.id,\n          project_id: selectedProjectId ?? '',\n          name: local.name,\n          color: local.color,\n          sort_order: local.sort_order,\n          hidden: local.hidden,\n        });\n        mutationPromises.push(result.persisted);\n        continue;\n      }\n\n      const changes: Partial<{\n        name: string;\n        color: string;\n        sort_order: number;\n        hidden: boolean;\n      }> = {\n        sort_order: local.sort_order,\n      };\n\n      if (local.name !== original.name) changes.name = local.name;\n      if (local.color !== original.color) changes.color = local.color;\n      if (local.hidden !== original.hidden) changes.hidden = local.hidden;\n\n      bulkUpdates.push({ id: local.id, changes });\n    }\n\n    if (bulkUpdates.length > 1) {\n      await bulkUpdateProjectStatuses(bulkUpdates);\n    } else if (bulkUpdates.length === 1) {\n      const result = updateProjectStatus(\n        bulkUpdates[0].id,\n        bulkUpdates[0].changes\n      );\n      mutationPromises.push(result.persisted);\n    }\n\n    await Promise.all(mutationPromises);\n  }, [\n    projectStatuses,\n    localStatuses,\n    selectedProjectId,\n    removeProjectStatus,\n    insertProjectStatus,\n    updateProjectStatus,\n  ]);\n\n  // Handlers\n  const handleOrgSelect = (orgId: string) => {\n    if (isDirty) {\n      const confirmed = window.confirm(\n        t('settings.common.discardChangesConfirm', 'Discard unsaved changes?')\n      );\n      if (!confirmed) return;\n    }\n    setSelectedOrgId(orgId);\n    setSelectedProjectId(null);\n    setFormState(null);\n    setLocalStatuses([]);\n    setHasStatusChanges(false);\n    setEditingStatusId(null);\n    setEditingStatusColorId(null);\n    setError(null);\n    setSuccess(null);\n  };\n\n  const handleProjectSelect = (projectId: string) => {\n    if (isDirty) {\n      const confirmed = window.confirm(\n        t('settings.common.discardChangesConfirm', 'Discard unsaved changes?')\n      );\n      if (!confirmed) return;\n    }\n    const project = projects.find((p) => p.id === projectId);\n    setSelectedProjectId(projectId);\n    setFormState(project ? { name: project.name, color: project.color } : null);\n    setHasStatusChanges(false);\n    setEditingStatusId(null);\n    setEditingStatusColorId(null);\n    setError(null);\n    setSuccess(null);\n  };\n\n  const handleCreateProject = async () => {\n    if (!selectedOrgId) return;\n\n    try {\n      const result = await CreateRemoteProjectDialog.show({\n        organizationId: selectedOrgId,\n      });\n\n      if (result.action === 'created' && result.project) {\n        setSelectedProjectId(result.project.id);\n        setFormState({\n          name: result.project.name,\n          color: result.project.color,\n        });\n        setSuccess(\n          t(\n            'settings.remoteProjects.createSuccess',\n            'Project created successfully'\n          )\n        );\n        setTimeout(() => setSuccess(null), 3000);\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  };\n\n  const handleDeleteProject = async (project: Project) => {\n    try {\n      const result = await DeleteRemoteProjectDialog.show({\n        projectName: project.name,\n      });\n\n      if (result === 'deleted') {\n        remove(project.id);\n        if (selectedProjectId === project.id) {\n          setSelectedProjectId(null);\n          setFormState(null);\n        }\n        setSuccess(\n          t(\n            'settings.remoteProjects.deleteSuccess',\n            'Project deleted successfully'\n          )\n        );\n        setTimeout(() => setSuccess(null), 3000);\n      }\n    } catch {\n      // Dialog cancelled\n    }\n  };\n\n  const handleSave = async () => {\n    if (!selectedProjectId || !formState) return;\n\n    const trimmedName = formState.name.trim();\n    if (!trimmedName) {\n      setError(\n        t('settings.remoteProjects.nameRequired', 'Project name is required')\n      );\n      return;\n    }\n\n    setError(null);\n    setIsSaving(true);\n\n    try {\n      if (isProjectDirty) {\n        const result = update(selectedProjectId, {\n          name: trimmedName,\n          color: formState.color,\n        });\n        await result.persisted;\n      }\n\n      if (hasStatusChanges) {\n        await persistStatusChanges();\n        setLocalStatuses((prev) =>\n          prev.map((status) => ({ ...status, isNew: false }))\n        );\n        setHasStatusChanges(false);\n      }\n\n      setSuccess(\n        t('settings.remoteProjects.saveSuccess', 'Project updated successfully')\n      );\n      setTimeout(() => setSuccess(null), 3000);\n    } catch (err) {\n      setError(\n        err instanceof Error\n          ? err.message\n          : t('settings.remoteProjects.saveError', 'Failed to update project')\n      );\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const handleDiscard = () => {\n    if (selectedProject) {\n      setFormState({\n        name: selectedProject.name,\n        color: selectedProject.color,\n      });\n    }\n    setLocalStatuses(\n      sortedProjectStatuses.map((status) => ({\n        id: status.id,\n        name: status.name,\n        color: status.color,\n        hidden: status.hidden,\n        sort_order: status.sort_order,\n        isNew: false,\n      }))\n    );\n    setHasStatusChanges(false);\n    setEditingStatusId(null);\n    setEditingStatusColorId(null);\n  };\n\n  // Loading state\n  if (!isLoaded || orgsLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-8 gap-2\">\n        <SpinnerIcon\n          className=\"size-icon-lg animate-spin text-brand\"\n          weight=\"bold\"\n        />\n        <span className=\"text-normal\">\n          {t('settings.remoteProjects.loading', 'Loading remote projects...')}\n        </span>\n      </div>\n    );\n  }\n\n  // Auth check - show sign-in prompt if not signed in\n  if (!isSignedIn) {\n    return (\n      <div className=\"space-y-4\">\n        <div>\n          <h3 className=\"text-base font-medium text-high\">\n            {t(\n              'settings.remoteProjects.loginRequired.title',\n              'Sign in required'\n            )}\n          </h3>\n          <p className=\"text-sm text-low mt-1\">\n            {t(\n              'settings.remoteProjects.loginRequired.description',\n              'Sign in to manage your remote projects.'\n            )}\n          </p>\n        </div>\n        <PrimaryButton\n          variant=\"secondary\"\n          value={t('settings.remoteProjects.loginRequired.action', 'Sign in')}\n          onClick={() => void OAuthDialog.show({})}\n        >\n          <SignInIcon className=\"size-icon-xs mr-1\" weight=\"bold\" />\n        </PrimaryButton>\n      </div>\n    );\n  }\n\n  // Error state\n  if (orgsError) {\n    return (\n      <div className=\"py-8\">\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {orgsError instanceof Error\n            ? orgsError.message\n            : t(\n                'settings.remoteProjects.loadError',\n                'Failed to load organizations'\n              )}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {/* Status messages */}\n      {error && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error mb-4\">\n          {error}\n        </div>\n      )}\n\n      {success && (\n        <div className=\"bg-success/10 border border-success/50 rounded-sm p-4 text-success font-medium mb-4\">\n          {success}\n        </div>\n      )}\n\n      <SettingsCard\n        title={t('settings.remoteProjects.title', 'Remote Projects')}\n        description={t(\n          'settings.remoteProjects.description',\n          'Manage cloud-synced projects across organizations.'\n        )}\n      >\n        {/* Two-column picker */}\n        <TwoColumnPicker>\n          {/* Organizations column */}\n          <TwoColumnPickerColumn\n            label={t(\n              'settings.remoteProjects.columns.organizations',\n              'Organizations'\n            )}\n            isFirst\n          >\n            {organizations.map((org) => (\n              <TwoColumnPickerItem\n                key={org.id}\n                selected={selectedOrgId === org.id}\n                onClick={() => handleOrgSelect(org.id)}\n                trailing={\n                  org.is_personal && (\n                    <TwoColumnPickerBadge>\n                      {t('common:personal', 'Personal')}\n                    </TwoColumnPickerBadge>\n                  )\n                }\n              >\n                {org.name}\n              </TwoColumnPickerItem>\n            ))}\n          </TwoColumnPickerColumn>\n\n          {/* Projects column */}\n          <TwoColumnPickerColumn\n            label={t('settings.remoteProjects.columns.projects', 'Projects')}\n            headerAction={\n              selectedOrgId && (\n                <button\n                  className=\"p-half rounded-sm hover:bg-secondary text-low hover:text-normal\"\n                  onClick={handleCreateProject}\n                  disabled={isSaving}\n                  title={t(\n                    'settings.remoteProjects.actions.addProject',\n                    'Add Project'\n                  )}\n                >\n                  <PlusIcon className=\"size-icon-2xs\" weight=\"bold\" />\n                </button>\n              )\n            }\n          >\n            {projectsLoading ? (\n              <div className=\"flex items-center justify-center py-double gap-base\">\n                <SpinnerIcon className=\"size-icon-sm animate-spin\" />\n              </div>\n            ) : selectedOrgId && projects.length > 0 ? (\n              projects.map((project) => (\n                <TwoColumnPickerItem\n                  key={project.id}\n                  selected={selectedProjectId === project.id}\n                  onClick={() => handleProjectSelect(project.id)}\n                  leading={\n                    <span\n                      className=\"w-3 h-3 rounded-full shrink-0\"\n                      style={{ backgroundColor: `hsl(${project.color})` }}\n                    />\n                  }\n                  trailing={\n                    <ProjectActionsDropdown\n                      project={project}\n                      onDelete={handleDeleteProject}\n                    />\n                  }\n                >\n                  {project.name}\n                </TwoColumnPickerItem>\n              ))\n            ) : selectedOrgId ? (\n              <TwoColumnPickerEmpty>\n                {t(\n                  'settings.remoteProjects.noProjects',\n                  'No projects yet. Create one to get started.'\n                )}\n              </TwoColumnPickerEmpty>\n            ) : (\n              <TwoColumnPickerEmpty>\n                {t(\n                  'settings.remoteProjects.selectOrg',\n                  'Select an organization'\n                )}\n              </TwoColumnPickerEmpty>\n            )}\n          </TwoColumnPickerColumn>\n        </TwoColumnPicker>\n\n        {/* Edit form (when project selected) */}\n        {selectedProjectId && formState && (\n          <div className=\"bg-secondary/50 border border-border rounded-sm p-4 space-y-4\">\n            <SettingsField\n              label={t(\n                'settings.remoteProjects.form.name.label',\n                'Project Name'\n              )}\n            >\n              <SettingsInput\n                value={formState.name}\n                onChange={(name) =>\n                  setFormState((s) => (s ? { ...s, name } : null))\n                }\n                placeholder={t(\n                  'settings.remoteProjects.form.name.placeholder',\n                  'Enter project name'\n                )}\n                disabled={isSaving}\n              />\n            </SettingsField>\n\n            <SettingsField\n              label={t(\n                'settings.remoteProjects.form.color.label',\n                'Project Color'\n              )}\n            >\n              <InlineColorPicker\n                value={formState.color}\n                onChange={(color) =>\n                  setFormState((s) => (s ? { ...s, color } : null))\n                }\n                colors={PRESET_COLORS}\n                disabled={isSaving}\n              />\n            </SettingsField>\n          </div>\n        )}\n\n        {selectedProjectId && (\n          <div\n            className={cn(\n              'bg-secondary/50 border border-border rounded-sm p-4 space-y-base',\n              isSaving && 'opacity-60 pointer-events-none'\n            )}\n          >\n            <div>\n              <p className=\"text-sm font-medium text-normal\">\n                {t('settings:settings.remoteProjects.form.defaultRepos.label')}\n              </p>\n              <p className=\"text-sm text-low mt-1\">\n                {t(\n                  'settings:settings.remoteProjects.form.defaultRepos.description'\n                )}\n              </p>\n            </div>\n\n            {defaultReposError && (\n              <p className=\"text-sm text-red-500\">{defaultReposError}</p>\n            )}\n\n            {isLoadingDefaults ? (\n              <div className=\"flex items-center gap-half py-half\">\n                <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n              </div>\n            ) : defaultRepos.length === 0 ? (\n              <p className=\"text-sm text-low\">\n                {t('settings:settings.remoteProjects.form.defaultRepos.empty')}\n              </p>\n            ) : (\n              <div className=\"space-y-half\">\n                {defaultRepos.map((dr) => {\n                  const repo = allRepos.find((r) => r.id === dr.repo_id);\n                  return (\n                    <div\n                      key={dr.repo_id}\n                      className=\"flex items-center gap-base px-base py-half rounded-sm bg-secondary\"\n                    >\n                      <span className=\"text-sm text-high flex-1 truncate\">\n                        {repo?.display_name || repo?.name || dr.repo_id}\n                      </span>\n                      <DropdownMenu\n                        onOpenChange={(open) => {\n                          if (open) void fetchBranchesForRepo(dr.repo_id);\n                        }}\n                      >\n                        <DropdownMenuTrigger asChild>\n                          <button\n                            type=\"button\"\n                            className=\"flex items-center gap-1 text-xs text-low bg-panel border border-border rounded-sm px-1.5 py-0.5 hover:bg-secondary cursor-pointer transition-colors\"\n                          >\n                            {loadingBranches.has(dr.repo_id) ? (\n                              <SpinnerIcon className=\"size-icon-2xs animate-spin\" />\n                            ) : (\n                              <>\n                                {dr.target_branch}\n                                <CaretDownIcon\n                                  className=\"size-icon-2xs opacity-50\"\n                                  weight=\"bold\"\n                                />\n                              </>\n                            )}\n                          </button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent\n                          align=\"end\"\n                          className=\"max-h-60 overflow-y-auto\"\n                        >\n                          {loadingBranches.has(dr.repo_id) ? (\n                            <div className=\"flex items-center justify-center py-2 px-3\">\n                              <SpinnerIcon className=\"size-icon-xs animate-spin\" />\n                            </div>\n                          ) : (branchCache.get(dr.repo_id) ?? []).length ===\n                            0 ? (\n                            <div className=\"py-2 px-3 text-xs text-low\">\n                              {t(\n                                'settings:settings.remoteProjects.form.defaultRepos.noBranches'\n                              )}\n                            </div>\n                          ) : (\n                            (branchCache.get(dr.repo_id) ?? []).map(\n                              (branch) => (\n                                <DropdownMenuItem\n                                  key={branch.name}\n                                  onClick={() =>\n                                    handleChangeBranch(dr.repo_id, branch.name)\n                                  }\n                                >\n                                  <div className=\"flex items-center justify-between w-full gap-2\">\n                                    <span\n                                      className={cn(\n                                        'truncate',\n                                        branch.name === dr.target_branch &&\n                                          'font-medium text-high'\n                                      )}\n                                    >\n                                      {branch.name}\n                                    </span>\n                                    <div className=\"flex items-center gap-1 shrink-0\">\n                                      {branch.is_current && (\n                                        <span className=\"text-[10px] text-low bg-secondary border border-border rounded-sm px-1\">\n                                          current\n                                        </span>\n                                      )}\n                                      {branch.name === dr.target_branch && (\n                                        <CheckIcon\n                                          className=\"size-icon-2xs text-brand\"\n                                          weight=\"bold\"\n                                        />\n                                      )}\n                                    </div>\n                                  </div>\n                                </DropdownMenuItem>\n                              )\n                            )\n                          )}\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                      <button\n                        type=\"button\"\n                        onClick={() => handleRemoveDefaultRepo(dr.repo_id)}\n                        className=\"flex items-center justify-center size-icon-sm text-low hover:text-normal\"\n                      >\n                        <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n                      </button>\n                    </div>\n                  );\n                })}\n              </div>\n            )}\n\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <button\n                  type=\"button\"\n                  className=\"flex items-center gap-half px-base py-half text-high hover:bg-secondary rounded-sm transition-colors\"\n                >\n                  <div className=\"flex items-center justify-center size-icon-sm\">\n                    <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n                  </div>\n                  <span className=\"text-xs font-light\">\n                    {t(\n                      'settings:settings.remoteProjects.form.defaultRepos.addButton'\n                    )}\n                  </span>\n                </button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"start\">\n                {availableRepos.map((repo) => (\n                  <DropdownMenuItem\n                    key={repo.id}\n                    onClick={() => handleAddDefaultRepo(repo)}\n                  >\n                    {repo.display_name || repo.name}\n                  </DropdownMenuItem>\n                ))}\n                {availableRepos.length > 0 && <DropdownMenuSeparator />}\n                <DropdownMenuItem onClick={handleAddNewDefaultRepo}>\n                  {t(\n                    'settings:settings.remoteProjects.form.defaultRepos.addNew'\n                  )}\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        )}\n\n        {/* Project status settings (kanban columns) */}\n        {selectedProjectId && (\n          <div\n            className={cn(\n              'bg-secondary/50 border border-border rounded-sm p-4 space-y-base',\n              isSaving && 'opacity-60 pointer-events-none'\n            )}\n          >\n            <div>\n              <div>\n                <p className=\"text-sm font-medium text-normal\">\n                  {t(\n                    'settings.remoteProjects.form.statuses.label',\n                    'Project Statuses'\n                  )}\n                </p>\n                <p className=\"text-sm text-low mt-1\">\n                  {t(\n                    'settings.remoteProjects.form.statuses.description',\n                    'Manage kanban columns for this project.'\n                  )}\n                </p>\n              </div>\n            </div>\n\n            <div className=\"flex items-center justify-between text-normal\">\n              <span className=\"text-sm font-semibold\">\n                {t('kanban.visibleColumns', 'Visible Columns')}\n              </span>\n              <span className=\"text-xs text-low\">\n                {t('kanban.dragToRearrange', 'Drag to re-arrange')}\n              </span>\n            </div>\n\n            <DragDropContext onDragEnd={handleStatusDragEnd}>\n              <Droppable\n                droppableId=\"status-list\"\n                renderClone={(\n                  provided: DraggableProvided,\n                  _snapshot: DraggableStateSnapshot,\n                  rubric: DraggableRubric\n                ) => (\n                  <StatusRowClone\n                    provided={provided}\n                    status={localStatuses[rubric.source.index]}\n                  />\n                )}\n              >\n                {(provided: DroppableProvided) => (\n                  <div\n                    ref={provided.innerRef}\n                    {...provided.droppableProps}\n                    className=\"flex flex-col gap-[2px]\"\n                  >\n                    {localStatuses.map((status, index) => (\n                      <StatusRow\n                        key={status.id}\n                        status={status}\n                        index={index}\n                        issueCount={issueCountByStatus[status.id] ?? 0}\n                        visibleCount={visibleStatusCount}\n                        editingId={editingStatusId}\n                        editingColorId={editingStatusColorId}\n                        onToggleHidden={handleStatusToggleHidden}\n                        onNameChange={handleStatusNameChange}\n                        onColorChange={handleStatusColorChange}\n                        onDelete={handleStatusDelete}\n                        onStartEditing={setEditingStatusId}\n                        onStartEditingColor={setEditingStatusColorId}\n                        onStopEditing={() => setEditingStatusId(null)}\n                      />\n                    ))}\n                    {provided.placeholder}\n\n                    <button\n                      type=\"button\"\n                      onClick={handleStatusAdd}\n                      className=\"flex items-center gap-half px-base py-half text-high hover:bg-secondary rounded-sm transition-colors\"\n                    >\n                      <div className=\"flex items-center justify-center size-icon-sm\">\n                        <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n                      </div>\n                      <span className=\"text-xs font-light\">\n                        {t('kanban.addColumn', 'Add column')}\n                      </span>\n                    </button>\n                  </div>\n                )}\n              </Droppable>\n            </DragDropContext>\n          </div>\n        )}\n      </SettingsCard>\n\n      <SettingsSaveBar\n        show={isDirty}\n        saving={isSaving}\n        onSave={handleSave}\n        onDiscard={handleDiscard}\n      />\n    </>\n  );\n}\n\n// Helper component for project actions dropdown\nfunction ProjectActionsDropdown({\n  project,\n  onDelete,\n}: {\n  project: Project;\n  onDelete: (project: Project) => void;\n}) {\n  const { t } = useTranslation(['common']);\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <button\n          className={cn(\n            'p-half rounded-sm hover:bg-panel text-low hover:text-normal',\n            'opacity-0 group-hover:opacity-100 transition-opacity'\n          )}\n          onClick={(e) => e.stopPropagation()}\n        >\n          <DotsThreeIcon className=\"size-icon-xs\" weight=\"bold\" />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem\n          onClick={(e) => {\n            e.stopPropagation();\n            onDelete(project);\n          }}\n          className=\"text-error focus:text-error\"\n        >\n          <div className=\"flex items-center gap-half w-full\">\n            <TrashIcon className=\"size-icon-xs mr-base\" />\n            {t('common:buttons.delete', 'Delete')}\n          </div>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\n// Alias for backwards compatibility\nexport { RemoteProjectsSettingsSection as RemoteProjectsSettingsSectionContent };\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/ReposSettingsSection.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { isEqual } from 'lodash';\nimport { GitBranchIcon, PlusIcon, SpinnerIcon } from '@phosphor-icons/react';\nimport { Loader2 } from 'lucide-react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { useRepoBranches } from '@/shared/hooks/useRepoBranches';\nimport { useScriptPlaceholders } from '@/shared/hooks/useScriptPlaceholders';\nimport { useAllOrganizationProjects } from '@/shared/hooks/useAllOrganizationProjects';\nimport { getProjectRepoDefaults } from '@/shared/hooks/useProjectRepoDefaults';\nimport { repoApi, ApiError } from '@/shared/lib/api';\nimport { defineModal } from '@/shared/lib/modals';\nimport type { Repo, UpdateRepo } from 'shared/types';\nimport { SearchableDropdownContainer } from '@/shared/components/ui-new/containers/SearchableDropdownContainer';\nimport { FolderPickerDialog } from '@/shared/dialogs/shared/FolderPickerDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuTriggerButton,\n} from '@vibe/ui/components/Dropdown';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\nimport {\n  SettingsCard,\n  SettingsField,\n  SettingsInput,\n  SettingsTextarea,\n  SettingsCheckbox,\n  SettingsSaveBar,\n} from './SettingsComponents';\n\ninterface RepoScriptsFormState {\n  display_name: string;\n  default_working_dir: string;\n  default_target_branch: string;\n  setup_script: string;\n  parallel_setup_script: boolean;\n  cleanup_script: string;\n  archive_script: string;\n  copy_files: string;\n  dev_server_script: string;\n}\n\nfunction repoToFormState(repo: Repo): RepoScriptsFormState {\n  return {\n    display_name: repo.display_name,\n    default_working_dir: repo.default_working_dir ?? '',\n    default_target_branch: repo.default_target_branch ?? '',\n    setup_script: repo.setup_script ?? '',\n    parallel_setup_script: repo.parallel_setup_script,\n    cleanup_script: repo.cleanup_script ?? '',\n    archive_script: repo.archive_script ?? '',\n    copy_files: repo.copy_files ?? '',\n    dev_server_script: repo.dev_server_script ?? '',\n  };\n}\n\n// ── Remove Repo confirmation dialog ──────────────────────────────────\ninterface RemoveRepoDialogProps {\n  repoName: string;\n}\n\ntype RemoveRepoResult = 'removed' | 'canceled';\n\nconst RemoveRepoDialogImpl = create<RemoveRepoDialogProps>(({ repoName }) => {\n  const modal = useModal();\n  const { t } = useTranslation(['settings', 'common']);\n\n  const handleRemove = () => {\n    modal.resolve('removed' as RemoveRepoResult);\n    modal.hide();\n  };\n\n  const handleCancel = () => {\n    modal.resolve('canceled' as RemoveRepoResult);\n    modal.hide();\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    if (!open) handleCancel();\n  };\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>\n            {t('settings:settings.repos.remove.dialogTitle', {\n              name: repoName,\n            })}\n          </DialogTitle>\n          <DialogDescription>\n            {t('settings:settings.repos.remove.dialogDescription')}\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={handleCancel}>\n            {t('common:buttons.cancel')}\n          </Button>\n          <Button variant=\"destructive\" onClick={handleRemove}>\n            {t('settings:settings.repos.remove.confirm')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nconst RemoveRepoDialog = defineModal<RemoveRepoDialogProps, RemoveRepoResult>(\n  RemoveRepoDialogImpl\n);\n\n// ── Main section ─────────────────────────────────────────────────────\ninterface ReposSettingsSectionProps {\n  initialState?: { repoId?: string };\n}\n\nexport function ReposSettingsSection({\n  initialState,\n}: ReposSettingsSectionProps) {\n  const { t } = useTranslation('settings');\n  const queryClient = useQueryClient();\n\n  // Fetch all repos\n  const {\n    data: repos,\n    isLoading: reposLoading,\n    error: reposError,\n  } = useQuery({\n    queryKey: ['repos'],\n    queryFn: () => repoApi.list(),\n  });\n\n  // Selected repo state - initialize from props if provided\n  const [selectedRepoId, setSelectedRepoId] = useState<string>(\n    initialState?.repoId ?? ''\n  );\n\n  // Fetch branches for the selected repo\n  const { data: branches = [], isLoading: branchesLoading } = useRepoBranches(\n    selectedRepoId || null,\n    { enabled: !!selectedRepoId }\n  );\n\n  // Add \"Use current branch\" option at the top of branches list\n  const branchItems = useMemo(() => {\n    const clearOption = {\n      name: '',\n      is_current: false,\n      is_remote: false,\n      last_commit_date: new Date(),\n    };\n    return [clearOption, ...branches];\n  }, [branches]);\n\n  const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null);\n\n  // Form state\n  const [draft, setDraft] = useState<RepoScriptsFormState | null>(null);\n  const [saving, setSaving] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState(false);\n\n  // Get OS-appropriate script placeholders\n  const placeholders = useScriptPlaceholders();\n\n  // Linked projects: find which remote projects reference this repo\n  const { data: allProjects, isLoading: projectsLoading } =\n    useAllOrganizationProjects();\n  const [linkedProjectNames, setLinkedProjectNames] = useState<string[]>([]);\n  const [linkedProjectsLoading, setLinkedProjectsLoading] = useState(false);\n\n  useEffect(() => {\n    if (!selectedRepoId || allProjects.length === 0) {\n      setLinkedProjectNames([]);\n      return;\n    }\n\n    let cancelled = false;\n    setLinkedProjectsLoading(true);\n\n    (async () => {\n      const names: string[] = [];\n      for (const project of allProjects) {\n        const defaults = await getProjectRepoDefaults(project.id);\n        if (cancelled) return;\n        if (defaults?.some((r) => r.repo_id === selectedRepoId)) {\n          names.push(project.name);\n        }\n      }\n      if (!cancelled) {\n        setLinkedProjectNames(names);\n        setLinkedProjectsLoading(false);\n      }\n    })();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [selectedRepoId, allProjects]);\n\n  // Check for unsaved changes\n  const hasUnsavedChanges = useMemo(() => {\n    if (!draft || !selectedRepo) return false;\n    return !isEqual(draft, repoToFormState(selectedRepo));\n  }, [draft, selectedRepo]);\n\n  // Handle repo selection\n  const handleRepoSelect = useCallback(\n    (id: string) => {\n      if (id === selectedRepoId) return;\n\n      if (hasUnsavedChanges) {\n        const confirmed = window.confirm(\n          t('settings.repos.save.confirmSwitch')\n        );\n        if (!confirmed) return;\n        setDraft(null);\n        setSelectedRepo(null);\n        setSuccess(false);\n        setError(null);\n      }\n\n      setSelectedRepoId(id);\n    },\n    [hasUnsavedChanges, selectedRepoId, t]\n  );\n\n  const [removing, setRemoving] = useState(false);\n\n  const handleRemoveRepo = useCallback(async () => {\n    if (!selectedRepo) return;\n\n    try {\n      const result = await RemoveRepoDialog.show({\n        repoName: selectedRepo.display_name,\n      });\n      if (result !== 'removed') return;\n\n      setRemoving(true);\n      setError(null);\n\n      await repoApi.delete(selectedRepo.id);\n      await queryClient.invalidateQueries({ queryKey: ['repos'] });\n      setSelectedRepoId('');\n      setSelectedRepo(null);\n      setDraft(null);\n      setSuccess(true);\n      setTimeout(() => setSuccess(false), 3000);\n    } catch (err) {\n      if (err instanceof ApiError && err.status === 409) {\n        setError(err.message);\n      } else if (err instanceof Error) {\n        setError(err.message);\n      }\n    } finally {\n      setRemoving(false);\n    }\n  }, [selectedRepo, queryClient]);\n\n  // Handle adding a new repo via folder picker\n  const handleAddRepo = useCallback(async () => {\n    try {\n      const selectedPath = await FolderPickerDialog.show({\n        title: t('settings.repos.addRepo.dialogTitle'),\n        description: t('settings.repos.addRepo.dialogDescription'),\n      });\n      if (!selectedPath) return;\n\n      const repo = await repoApi.register({ path: selectedPath });\n      await queryClient.invalidateQueries({ queryKey: ['repos'] });\n      setSelectedRepoId(repo.id);\n    } catch (err) {\n      setError(\n        err instanceof Error ? err.message : t('settings.repos.addRepo.error')\n      );\n    }\n  }, [queryClient, t]);\n\n  // Populate draft from server data\n  useEffect(() => {\n    if (!repos) return;\n\n    const nextRepo = selectedRepoId\n      ? repos.find((r) => r.id === selectedRepoId)\n      : null;\n\n    setSelectedRepo((prev) =>\n      prev?.id === nextRepo?.id ? prev : (nextRepo ?? null)\n    );\n\n    if (!nextRepo) {\n      if (!hasUnsavedChanges) setDraft(null);\n      return;\n    }\n\n    if (hasUnsavedChanges) return;\n\n    setDraft(repoToFormState(nextRepo));\n  }, [repos, selectedRepoId, hasUnsavedChanges]);\n\n  const handleSave = async () => {\n    if (!draft || !selectedRepo) return;\n\n    setSaving(true);\n    setError(null);\n    setSuccess(false);\n\n    try {\n      const updateData: UpdateRepo = {\n        display_name: draft.display_name.trim() || null,\n        default_working_dir: draft.default_working_dir.trim() || null,\n        default_target_branch: draft.default_target_branch.trim() || null,\n        setup_script: draft.setup_script.trim() || null,\n        cleanup_script: draft.cleanup_script.trim() || null,\n        archive_script: draft.archive_script.trim() || null,\n        copy_files: draft.copy_files.trim() || null,\n        parallel_setup_script: draft.parallel_setup_script,\n        dev_server_script: draft.dev_server_script.trim() || null,\n      };\n\n      const updatedRepo = await repoApi.update(selectedRepo.id, updateData);\n      setSelectedRepo(updatedRepo);\n      setDraft(repoToFormState(updatedRepo));\n      queryClient.setQueryData(['repos'], (old: Repo[] | undefined) =>\n        old?.map((r) => (r.id === updatedRepo.id ? updatedRepo : r))\n      );\n      setSuccess(true);\n      setTimeout(() => setSuccess(false), 3000);\n    } catch (err) {\n      setError(\n        err instanceof Error ? err.message : t('settings.repos.save.error')\n      );\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleDiscard = () => {\n    if (!selectedRepo) return;\n    setDraft(repoToFormState(selectedRepo));\n  };\n\n  const updateDraft = (updates: Partial<RepoScriptsFormState>) => {\n    setDraft((prev) => {\n      if (!prev) return prev;\n      return { ...prev, ...updates };\n    });\n  };\n\n  if (reposLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-8 gap-2\">\n        <SpinnerIcon\n          className=\"size-icon-lg animate-spin text-brand\"\n          weight=\"bold\"\n        />\n        <span className=\"text-normal\">{t('settings.repos.loading')}</span>\n      </div>\n    );\n  }\n\n  if (reposError) {\n    return (\n      <div className=\"py-8\">\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {reposError instanceof Error\n            ? reposError.message\n            : t('settings.repos.loadError')}\n        </div>\n      </div>\n    );\n  }\n\n  const repoOptions =\n    repos?.map((r) => ({ value: r.id, label: r.display_name })) ?? [];\n\n  return (\n    <>\n      {/* Status messages */}\n      {error && (\n        <div className=\"bg-error/10 border border-error/50 rounded-sm p-4 text-error\">\n          {error}\n        </div>\n      )}\n\n      {success && (\n        <div className=\"bg-success/10 border border-success/50 rounded-sm p-4 text-success font-medium\">\n          {t('settings.repos.save.success')}\n        </div>\n      )}\n\n      {/* Repo selector */}\n      <SettingsCard\n        title={t('settings.repos.title')}\n        description={t('settings.repos.description')}\n      >\n        <SettingsField\n          label={t('settings.repos.selector.label')}\n          description={t('settings.repos.selector.helper')}\n        >\n          <div className=\"flex gap-2 items-center\">\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <DropdownMenuTriggerButton\n                  label={\n                    repoOptions.find((r) => r.value === selectedRepoId)\n                      ?.label || t('settings.repos.selector.placeholder')\n                  }\n                  className=\"w-full justify-between\"\n                />\n              </DropdownMenuTrigger>\n              <DropdownMenuContent className=\"w-[var(--radix-dropdown-menu-trigger-width)]\">\n                {repoOptions.length > 0 ? (\n                  repoOptions.map((option) => (\n                    <DropdownMenuItem\n                      key={option.value}\n                      onClick={() => handleRepoSelect(option.value)}\n                    >\n                      {option.label}\n                    </DropdownMenuItem>\n                  ))\n                ) : (\n                  <DropdownMenuItem disabled>\n                    {t('settings.repos.selector.noRepos')}\n                  </DropdownMenuItem>\n                )}\n              </DropdownMenuContent>\n            </DropdownMenu>\n            <PrimaryButton variant=\"default\" onClick={handleAddRepo}>\n              <PlusIcon className=\"size-icon-sm\" weight=\"bold\" />\n              {t('common:buttons.add')}\n            </PrimaryButton>\n          </div>\n        </SettingsField>\n      </SettingsCard>\n\n      {selectedRepo && draft && (\n        <>\n          {/* General settings */}\n          <SettingsCard\n            title={t('settings.repos.general.title')}\n            description={t('settings.repos.general.description')}\n          >\n            <SettingsField\n              label={t('settings.repos.general.displayName.label')}\n              description={t('settings.repos.general.displayName.helper')}\n            >\n              <SettingsInput\n                value={draft.display_name}\n                onChange={(value) => updateDraft({ display_name: value })}\n                placeholder={t(\n                  'settings.repos.general.displayName.placeholder'\n                )}\n              />\n            </SettingsField>\n\n            <SettingsField\n              label={t('settings.repos.general.path.label')}\n              description=\"\"\n            >\n              <div className=\"text-sm text-low font-mono bg-secondary px-base py-half rounded-sm\">\n                {selectedRepo.path}\n              </div>\n            </SettingsField>\n\n            <SettingsField\n              label={t('settings.repos.general.defaultWorkingDir.label')}\n              description={t('settings.repos.general.defaultWorkingDir.helper')}\n            >\n              <SettingsInput\n                value={draft.default_working_dir}\n                onChange={(value) =>\n                  updateDraft({ default_working_dir: value })\n                }\n                placeholder={t(\n                  'settings.repos.general.defaultWorkingDir.placeholder'\n                )}\n              />\n            </SettingsField>\n\n            <SettingsField\n              label={t('settings.repos.general.defaultTargetBranch.label')}\n              description={t(\n                'settings.repos.general.defaultTargetBranch.helper'\n              )}\n            >\n              <SearchableDropdownContainer\n                items={branchItems}\n                selectedValue={draft.default_target_branch || null}\n                getItemKey={(b) => b.name || '__clear__'}\n                getItemLabel={(b) =>\n                  b.name ||\n                  t('settings.repos.general.defaultTargetBranch.useCurrent')\n                }\n                filterItem={(b, query) =>\n                  b.name === '' ||\n                  b.name.toLowerCase().includes(query.toLowerCase())\n                }\n                getItemBadge={(b) => (b.is_current ? 'Current' : undefined)}\n                getItemIcon={null}\n                onSelect={(b) => updateDraft({ default_target_branch: b.name })}\n                placeholder={t(\n                  'settings.repos.general.defaultTargetBranch.search'\n                )}\n                emptyMessage={t(\n                  'settings.repos.general.defaultTargetBranch.noBranches'\n                )}\n                contentClassName=\"w-[var(--radix-dropdown-menu-trigger-width)]\"\n                trigger={\n                  <DropdownMenuTriggerButton\n                    icon={GitBranchIcon}\n                    label={\n                      branchesLoading\n                        ? t(\n                            'settings.repos.general.defaultTargetBranch.loading'\n                          )\n                        : draft.default_target_branch ||\n                          t(\n                            'settings.repos.general.defaultTargetBranch.placeholder'\n                          )\n                    }\n                    className=\"w-full justify-between\"\n                    disabled={branchesLoading}\n                  />\n                }\n              />\n            </SettingsField>\n\n            <div className=\"border-t border-primary pt-base mt-base\">\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-sm font-medium text-normal\">\n                    {t('settings.repos.remove.title')}\n                  </p>\n                  <p className=\"text-sm text-low\">\n                    {t('settings.repos.remove.description')}\n                  </p>\n                </div>\n                <Button\n                  variant=\"destructive\"\n                  onClick={handleRemoveRepo}\n                  disabled={removing}\n                >\n                  {removing && (\n                    <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                  )}\n                  {t('settings.repos.remove.button')}\n                </Button>\n              </div>\n            </div>\n          </SettingsCard>\n\n          {/* Linked projects (read-only) */}\n          <SettingsCard\n            title={t('settings.repos.linkedProjects.title')}\n            description={t('settings.repos.linkedProjects.description')}\n          >\n            {linkedProjectsLoading || projectsLoading ? (\n              <div className=\"flex items-center gap-2 py-half\">\n                <SpinnerIcon\n                  className=\"size-icon-xs animate-spin text-low\"\n                  weight=\"bold\"\n                />\n                <span className=\"text-sm text-low\">\n                  {t('settings.repos.linkedProjects.loading')}\n                </span>\n              </div>\n            ) : linkedProjectNames.length > 0 ? (\n              <div className=\"flex flex-wrap gap-1.5\">\n                {linkedProjectNames.map((name) => (\n                  <span\n                    key={name}\n                    className=\"inline-flex items-center rounded-sm bg-secondary px-2 py-0.5 text-sm text-normal\"\n                  >\n                    {name}\n                  </span>\n                ))}\n              </div>\n            ) : (\n              <p className=\"text-sm text-low\">\n                {t('settings.repos.linkedProjects.none')}\n              </p>\n            )}\n          </SettingsCard>\n\n          {/* Scripts settings */}\n          <SettingsCard\n            title={t('settings.repos.scripts.title')}\n            description={t('settings.repos.scripts.description')}\n          >\n            <SettingsField\n              label={t('settings.repos.scripts.devServer.label')}\n              description={t('settings.repos.scripts.devServer.helper')}\n            >\n              <SettingsTextarea\n                value={draft.dev_server_script}\n                onChange={(value) => updateDraft({ dev_server_script: value })}\n                placeholder={placeholders.dev}\n                monospace\n              />\n            </SettingsField>\n\n            <SettingsField\n              label={t('settings.repos.scripts.setup.label')}\n              description={t('settings.repos.scripts.setup.helper')}\n            >\n              <SettingsTextarea\n                value={draft.setup_script}\n                onChange={(value) => updateDraft({ setup_script: value })}\n                placeholder={placeholders.setup}\n                monospace\n              />\n            </SettingsField>\n\n            <SettingsCheckbox\n              id=\"parallel-setup-script\"\n              label={t('settings.repos.scripts.setup.parallelLabel')}\n              description={t('settings.repos.scripts.setup.parallelHelper')}\n              checked={draft.parallel_setup_script}\n              onChange={(checked) =>\n                updateDraft({ parallel_setup_script: checked })\n              }\n              disabled={!draft.setup_script.trim()}\n            />\n\n            <SettingsField\n              label={t('settings.repos.scripts.cleanup.label')}\n              description={t('settings.repos.scripts.cleanup.helper')}\n            >\n              <SettingsTextarea\n                value={draft.cleanup_script}\n                onChange={(value) => updateDraft({ cleanup_script: value })}\n                placeholder={placeholders.cleanup}\n                monospace\n              />\n            </SettingsField>\n\n            <SettingsField\n              label={t('settings.repos.scripts.archive.label')}\n              description={t('settings.repos.scripts.archive.helper')}\n            >\n              <SettingsTextarea\n                value={draft.archive_script}\n                onChange={(value) => updateDraft({ archive_script: value })}\n                placeholder={placeholders.archive}\n                monospace\n              />\n            </SettingsField>\n\n            <SettingsField\n              label={t('settings.repos.scripts.copyFiles.label')}\n              description={t('settings.repos.scripts.copyFiles.helper')}\n            >\n              <SettingsTextarea\n                value={draft.copy_files}\n                onChange={(value) => updateDraft({ copy_files: value })}\n                placeholder={t('settings.repos.scripts.copyFiles.placeholder')}\n                rows={3}\n              />\n            </SettingsField>\n          </SettingsCard>\n\n          <SettingsSaveBar\n            show={hasUnsavedChanges}\n            saving={saving}\n            onSave={handleSave}\n            onDiscard={handleDiscard}\n          />\n        </>\n      )}\n    </>\n  );\n}\n\n// Alias for backwards compatibility\nexport { ReposSettingsSection as ReposSettingsSectionContent };\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/SettingsComponents.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '@/shared/lib/utils';\nimport { useTranslation } from 'react-i18next';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuTriggerButton,\n} from '@vibe/ui/components/Dropdown';\nimport { PrimaryButton } from '@vibe/ui/components/PrimaryButton';\n\n// ============================================================================\n// Two-Column Picker Components\n// ============================================================================\n\n// TwoColumnPicker - Container for the two-column layout\ninterface TwoColumnPickerProps {\n  children: ReactNode;\n}\n\nexport function TwoColumnPicker({ children }: TwoColumnPickerProps) {\n  return (\n    <div className=\"flex flex-col md:flex-row border border-border rounded-sm overflow-hidden\">\n      {children}\n    </div>\n  );\n}\n\n// TwoColumnPickerColumn - A single column within the picker\ninterface TwoColumnPickerColumnProps {\n  label: string;\n  headerAction?: ReactNode;\n  isFirst?: boolean;\n  children: ReactNode;\n}\n\nexport function TwoColumnPickerColumn({\n  label,\n  headerAction,\n  isFirst,\n  children,\n}: TwoColumnPickerColumnProps) {\n  return (\n    <div\n      className={cn(\n        'flex-1',\n        isFirst && 'border-b md:border-b-0 md:border-r border-border'\n      )}\n    >\n      <div className=\"h-9 px-base border-b border-border bg-secondary/50 flex items-center justify-between\">\n        <span className=\"text-sm font-medium text-low tracking-wide\">\n          {label}\n        </span>\n        {headerAction}\n      </div>\n      <div className=\"max-h-32 md:h-32 overflow-y-auto bg-panel\">\n        {children}\n      </div>\n    </div>\n  );\n}\n\n// TwoColumnPickerItem - A selectable row within a column\ninterface TwoColumnPickerItemProps {\n  selected?: boolean;\n  onClick?: () => void;\n  leading?: ReactNode;\n  trailing?: ReactNode;\n  children: ReactNode;\n}\n\nexport function TwoColumnPickerItem({\n  selected,\n  onClick,\n  leading,\n  trailing,\n  children,\n}: TwoColumnPickerItemProps) {\n  return (\n    <div\n      className={cn(\n        'group flex items-center gap-half px-base py-half cursor-pointer transition-colors',\n        'hover:bg-secondary',\n        selected && 'bg-brand/10 text-brand'\n      )}\n      onClick={onClick}\n    >\n      {leading}\n      <span\n        className={cn(\n          'text-sm truncate flex-1',\n          selected ? 'text-brand font-medium' : 'text-normal'\n        )}\n      >\n        {children}\n      </span>\n      {trailing}\n    </div>\n  );\n}\n\n// TwoColumnPickerBadge - A small badge/tag for items\ninterface TwoColumnPickerBadgeProps {\n  variant?: 'default' | 'brand';\n  children: ReactNode;\n}\n\nexport function TwoColumnPickerBadge({\n  variant = 'default',\n  children,\n}: TwoColumnPickerBadgeProps) {\n  return (\n    <span\n      className={cn(\n        'text-xs px-half rounded font-medium shrink-0',\n        variant === 'brand' ? 'bg-brand/15 text-brand' : 'bg-secondary text-low'\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\n// TwoColumnPickerEmpty - Empty state message for a column\ninterface TwoColumnPickerEmptyProps {\n  children: ReactNode;\n}\n\nexport function TwoColumnPickerEmpty({ children }: TwoColumnPickerEmptyProps) {\n  return (\n    <div className=\"px-base py-plusfifty text-sm text-low text-center\">\n      {children}\n    </div>\n  );\n}\n\n// ============================================================================\n// Settings Card Components\n// ============================================================================\n\n// SettingsCard - A card container for a settings subsection\nexport function SettingsCard({\n  title,\n  description,\n  children,\n  headerAction,\n}: {\n  title: string;\n  description?: ReactNode;\n  children: React.ReactNode;\n  headerAction?: React.ReactNode;\n}) {\n  return (\n    <div className=\"space-y-4 pb-6 border-b border-border last:border-b-0 last:pb-0\">\n      <div className=\"flex items-start justify-between\">\n        <div>\n          <h3 className=\"text-base font-medium text-high\">{title}</h3>\n          {description && (\n            <p className=\"text-sm text-low mt-1\">{description}</p>\n          )}\n        </div>\n        {headerAction && <div className=\"shrink-0 ml-2\">{headerAction}</div>}\n      </div>\n      <div className=\"space-y-4\">{children}</div>\n    </div>\n  );\n}\n\n// SettingsField - A labeled field wrapper\nexport function SettingsField({\n  label,\n  description,\n  error,\n  children,\n}: {\n  label: string;\n  description?: React.ReactNode;\n  error?: string | null;\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"space-y-2\">\n      {label && (\n        <label className=\"text-sm font-medium text-normal\">{label}</label>\n      )}\n      {children}\n      {error && <p className=\"text-sm text-error\">{error}</p>}\n      {description && !error && (\n        <p className=\"text-sm text-low\">{description}</p>\n      )}\n    </div>\n  );\n}\n\n// SettingsCheckbox - A checkbox with label and optional description\nexport function SettingsCheckbox({\n  id,\n  label,\n  description,\n  checked,\n  onChange,\n  disabled,\n}: {\n  id: string;\n  label: string;\n  description?: string;\n  checked: boolean;\n  onChange: (checked: boolean) => void;\n  disabled?: boolean;\n}) {\n  return (\n    <div className=\"flex items-center gap-3\">\n      <input\n        type=\"checkbox\"\n        id={id}\n        checked={checked}\n        onChange={(e) => onChange(e.target.checked)}\n        disabled={disabled}\n        className={cn(\n          'mt-0.5 h-4 w-4 rounded border-border bg-secondary text-brand focus:ring-brand focus:ring-offset-0',\n          disabled && 'opacity-50 cursor-not-allowed'\n        )}\n      />\n      <div className=\"space-y-0.5\">\n        <label\n          htmlFor={id}\n          className={cn(\n            'text-sm font-medium text-normal cursor-pointer',\n            disabled && 'opacity-50 cursor-not-allowed'\n          )}\n        >\n          {label}\n        </label>\n        {description && <p className=\"text-sm text-low\">{description}</p>}\n      </div>\n    </div>\n  );\n}\n\n// SettingsSelect - A dropdown select component\nexport function SettingsSelect<T extends string>({\n  value,\n  options,\n  onChange,\n  placeholder,\n  disabled,\n  className,\n}: {\n  value: T | undefined;\n  options: { value: T; label: string }[];\n  onChange: (value: T) => void;\n  placeholder?: string;\n  disabled?: boolean;\n  className?: string;\n}) {\n  const selectedOption = options.find((opt) => opt.value === value);\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <DropdownMenuTriggerButton\n          label={selectedOption?.label || placeholder}\n          className={cn('w-full justify-between', className)}\n          disabled={disabled}\n        />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent className=\"w-[var(--radix-dropdown-menu-trigger-width)]\">\n        {options.map((option) => (\n          <DropdownMenuItem\n            key={option.value}\n            onClick={() => onChange(option.value)}\n          >\n            {option.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\n// SettingsInput - A text input field\nexport function SettingsInput({\n  value,\n  onChange,\n  placeholder,\n  error,\n  disabled,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  placeholder?: string;\n  error?: boolean;\n  disabled?: boolean;\n}) {\n  return (\n    <input\n      type=\"text\"\n      value={value}\n      onChange={(e) => onChange(e.target.value)}\n      placeholder={placeholder}\n      disabled={disabled}\n      className={cn(\n        'w-full bg-secondary border rounded-sm px-base py-half text-sm text-high',\n        'placeholder:text-low placeholder:opacity-80 focus:outline-none focus:ring-1 focus:ring-brand',\n        error ? 'border-error' : 'border-border',\n        disabled && 'opacity-50 cursor-not-allowed'\n      )}\n    />\n  );\n}\n\n// SettingsTextarea - A multi-line text input\nexport function SettingsTextarea({\n  value,\n  onChange,\n  placeholder,\n  disabled,\n  rows = 4,\n  monospace = false,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  placeholder?: string;\n  disabled?: boolean;\n  rows?: number;\n  monospace?: boolean;\n}) {\n  return (\n    <textarea\n      value={value}\n      onChange={(e) => onChange(e.target.value)}\n      placeholder={placeholder}\n      disabled={disabled}\n      rows={rows}\n      className={cn(\n        'w-full bg-secondary border border-border rounded-sm px-base py-half text-sm text-high',\n        'placeholder:text-low placeholder:opacity-80 focus:outline-none focus:ring-1 focus:ring-brand',\n        'resize-y',\n        monospace && 'font-mono',\n        disabled && 'opacity-50 cursor-not-allowed'\n      )}\n    />\n  );\n}\n\n// SettingsSaveBar - A sticky save bar for unsaved changes\nexport function SettingsSaveBar({\n  show,\n  saving,\n  saveDisabled,\n  onSave,\n  onDiscard,\n}: {\n  show: boolean;\n  saving: boolean;\n  saveDisabled?: boolean;\n  unsavedMessage?: string;\n  onSave: () => void;\n  onDiscard?: () => void;\n}) {\n  const { t } = useTranslation(['settings', 'common']);\n\n  if (!show) {\n    return <div />;\n  }\n\n  return (\n    <div className=\"sticky bottom-0 z-10 bg-panel/80 backdrop-blur-sm border-t border-border/50 py-4 -mx-6 px-6 -mb-6\">\n      <div\n        className={cn(\n          'flex items-center',\n          onDiscard ? 'justify-between' : 'justify-end'\n        )}\n      >\n        {onDiscard && (\n          <span className=\"text-sm text-low\">\n            {t('settings.common.unsavedChanges')}\n          </span>\n        )}\n        <div className=\"flex gap-2\">\n          {onDiscard && (\n            <PrimaryButton\n              variant=\"tertiary\"\n              value={t('common:buttons.discard')}\n              onClick={onDiscard}\n              disabled={saving}\n            />\n          )}\n          <PrimaryButton\n            value={t('common:buttons.save')}\n            onClick={onSave}\n            disabled={saving || saveDisabled}\n            actionIcon={saving ? 'spinner' : undefined}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/SettingsDirtyContext.tsx",
    "content": "import { useContext, useState, useCallback } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { ReactNode } from 'react';\n\ninterface SettingsDirtyContextValue {\n  isDirty: boolean;\n  setDirty: (sectionId: string, dirty: boolean) => void;\n  clearAll: () => void;\n}\n\nconst SettingsDirtyContext = createHmrContext<SettingsDirtyContextValue | null>(\n  'SettingsDirtyContext',\n  null\n);\n\nexport function SettingsDirtyProvider({ children }: { children: ReactNode }) {\n  const [dirtySections, setDirtySections] = useState<Set<string>>(new Set());\n\n  const setDirty = useCallback((sectionId: string, dirty: boolean) => {\n    setDirtySections((prev) => {\n      const next = new Set(prev);\n      if (dirty) {\n        next.add(sectionId);\n      } else {\n        next.delete(sectionId);\n      }\n      return next;\n    });\n  }, []);\n\n  const clearAll = useCallback(() => {\n    setDirtySections(new Set());\n  }, []);\n\n  const isDirty = dirtySections.size > 0;\n\n  return (\n    <SettingsDirtyContext.Provider value={{ isDirty, setDirty, clearAll }}>\n      {children}\n    </SettingsDirtyContext.Provider>\n  );\n}\n\nexport function useSettingsDirty() {\n  const context = useContext(SettingsDirtyContext);\n  if (!context) {\n    throw new Error(\n      'useSettingsDirty must be used within a SettingsDirtyProvider'\n    );\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/SettingsSection.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { XIcon } from '@phosphor-icons/react';\n\nimport { GeneralSettingsSectionContent } from './GeneralSettingsSection';\nimport { ReposSettingsSectionContent } from './ReposSettingsSection';\nimport { OrganizationsSettingsSectionContent } from './OrganizationsSettingsSection';\nimport { RemoteProjectsSettingsSectionContent } from './RemoteProjectsSettingsSection';\nimport { AgentsSettingsSectionContent } from './AgentsSettingsSection';\nimport { McpSettingsSectionContent } from './McpSettingsSection';\nimport { RelaySettingsSectionContent } from './RelaySettingsSection';\n\nexport type SettingsSectionType =\n  | 'general'\n  | 'repos'\n  | 'organizations'\n  | 'remote-projects'\n  | 'agents'\n  | 'mcp'\n  | 'relay';\n\n// Section-specific initial state types\nexport type SettingsSectionInitialState = {\n  general: undefined;\n  repos: { repoId?: string } | undefined;\n  organizations: { organizationId?: string } | undefined;\n  'remote-projects':\n    | { organizationId?: string; projectId?: string }\n    | undefined;\n  agents: { executor?: string; variant?: string } | undefined;\n  mcp: undefined;\n  relay: { hostId?: string } | undefined;\n};\n\ninterface SettingsSectionProps {\n  type: SettingsSectionType;\n  onClose?: () => void;\n  initialState?: SettingsSectionInitialState[SettingsSectionType];\n}\n\nexport function SettingsSection({\n  type,\n  onClose,\n  initialState,\n}: SettingsSectionProps) {\n  const { t } = useTranslation('settings');\n\n  const renderContent = () => {\n    switch (type) {\n      case 'general':\n        return <GeneralSettingsSectionContent />;\n      case 'repos':\n        return (\n          <ReposSettingsSectionContent\n            initialState={initialState as SettingsSectionInitialState['repos']}\n          />\n        );\n      case 'organizations':\n        return <OrganizationsSettingsSectionContent />;\n      case 'remote-projects':\n        return (\n          <RemoteProjectsSettingsSectionContent\n            initialState={\n              initialState as SettingsSectionInitialState['remote-projects']\n            }\n          />\n        );\n      case 'agents':\n        return <AgentsSettingsSectionContent />;\n      case 'mcp':\n        return <McpSettingsSectionContent />;\n      case 'relay':\n        return (\n          <RelaySettingsSectionContent\n            initialState={initialState as SettingsSectionInitialState['relay']}\n          />\n        );\n      default:\n        return <GeneralSettingsSectionContent />;\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col h-full\">\n      {/* Header - sticky */}\n      <div className=\"p-4 border-b border-border bg-panel/95 backdrop-blur-sm hidden sm:flex items-center justify-between\">\n        <h2 className=\"text-lg font-semibold text-high\">\n          {t(`settings.layout.nav.${type}`)}\n        </h2>\n        {onClose && (\n          <button\n            onClick={onClose}\n            className=\"rounded-sm opacity-70 ring-offset-panel transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2\"\n          >\n            <XIcon className=\"h-4 w-4 text-normal\" weight=\"bold\" />\n            <span className=\"sr-only\">\n              {t('buttons.close', { ns: 'common' })}\n            </span>\n          </button>\n        )}\n      </div>\n\n      {/* Content */}\n      <div className=\"space-y-6 px-6 pt-4 overflow-y-auto\">\n        {renderContent()}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/rjsf/Fields.tsx",
    "content": "import { FieldProps } from '@rjsf/utils';\nimport { PlusIcon, XIcon } from '@phosphor-icons/react';\nimport { useState, useCallback, useMemo } from 'react';\nimport { cn } from '@/shared/lib/utils';\n\ntype KeyValueData = Record<string, string>;\n\ninterface EnvFormContext {\n  onEnvChange?: (envData: KeyValueData | undefined) => void;\n}\n\n// KeyValueField - Key-value pairs editor matching settings dialog styling\nexport function KeyValueField({\n  formData,\n  disabled,\n  readonly,\n  registry,\n}: FieldProps<KeyValueData>) {\n  const [newKey, setNewKey] = useState('');\n  const [newValue, setNewValue] = useState('');\n\n  const formContext = registry.formContext as EnvFormContext | undefined;\n\n  const data: KeyValueData = useMemo(() => formData ?? {}, [formData]);\n  const entries = useMemo(() => Object.entries(data), [data]);\n\n  const updateValue = useCallback(\n    (newData: KeyValueData | undefined) => {\n      formContext?.onEnvChange?.(newData);\n    },\n    [formContext]\n  );\n\n  const handleAdd = useCallback(() => {\n    const trimmedKey = newKey.trim();\n    if (trimmedKey) {\n      updateValue({\n        ...data,\n        [trimmedKey]: newValue,\n      });\n      setNewKey('');\n      setNewValue('');\n    }\n  }, [data, newKey, newValue, updateValue]);\n\n  const handleRemove = useCallback(\n    (key: string) => {\n      const updated = { ...data };\n      delete updated[key];\n      updateValue(Object.keys(updated).length > 0 ? updated : undefined);\n    },\n    [data, updateValue]\n  );\n\n  const handleValueChange = useCallback(\n    (key: string, value: string) => {\n      updateValue({ ...data, [key]: value });\n    },\n    [data, updateValue]\n  );\n\n  const isDisabled = disabled || readonly;\n\n  const inputClassName = cn(\n    'min-w-[50px] flex-1 bg-secondary border border-border rounded-sm px-base py-half text-base text-high font-mono text-sm',\n    'placeholder:text-low placeholder:opacity-80 focus:outline-none focus:ring-1 focus:ring-brand',\n    isDisabled && 'opacity-50 cursor-not-allowed'\n  );\n\n  return (\n    <div className=\"space-y-3\">\n      {entries.map(([key, value]) => (\n        <div key={key} className=\"flex gap-2 items-center\">\n          <input\n            value={key}\n            disabled\n            className={cn(inputClassName, 'opacity-70')}\n            aria-label=\"Environment variable key\"\n          />\n          <input\n            value={value ?? ''}\n            onChange={(e) => handleValueChange(key, e.target.value)}\n            disabled={isDisabled}\n            className={inputClassName}\n            placeholder=\"Value\"\n            aria-label={`Value for ${key}`}\n          />\n          <button\n            type=\"button\"\n            onClick={() => handleRemove(key)}\n            disabled={isDisabled}\n            className={cn(\n              'h-8 w-8 p-0 flex items-center justify-center shrink-0 rounded-sm',\n              'text-low hover:text-error hover:bg-error/10',\n              'focus:outline-none focus:ring-1 focus:ring-brand',\n              'disabled:opacity-50 disabled:cursor-not-allowed transition-colors'\n            )}\n            aria-label={`Remove ${key}`}\n          >\n            <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n          </button>\n        </div>\n      ))}\n\n      {/* Add new entry row */}\n      <div className=\"flex gap-2 items-center\">\n        <input\n          value={newKey}\n          onChange={(e) => setNewKey(e.target.value)}\n          disabled={isDisabled}\n          placeholder=\"KEY\"\n          className={inputClassName}\n          aria-label=\"New environment variable key\"\n        />\n        <input\n          value={newValue}\n          onChange={(e) => setNewValue(e.target.value)}\n          disabled={isDisabled}\n          placeholder=\"value\"\n          className={inputClassName}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              e.preventDefault();\n              handleAdd();\n            }\n          }}\n          aria-label=\"New environment variable value\"\n        />\n        <button\n          type=\"button\"\n          onClick={handleAdd}\n          disabled={isDisabled || !newKey.trim()}\n          className={cn(\n            'h-8 w-8 p-0 flex items-center justify-center shrink-0 rounded-sm',\n            'bg-secondary border border-border text-normal hover:bg-secondary/80',\n            'focus:outline-none focus:ring-1 focus:ring-brand',\n            'disabled:opacity-50 disabled:cursor-not-allowed transition-colors'\n          )}\n          aria-label=\"Add environment variable\"\n        >\n          <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/rjsf/Templates.tsx",
    "content": "import type {\n  FieldTemplateProps,\n  ObjectFieldTemplateProps,\n  ArrayFieldTemplateProps,\n  ArrayFieldItemTemplateProps,\n} from '@rjsf/utils';\nimport { PlusIcon, XIcon } from '@phosphor-icons/react';\nimport { cn } from '@/shared/lib/utils';\nimport { useTranslation } from 'react-i18next';\nimport { toPrettyCase } from '@/shared/lib/string';\n\n// FieldTemplate - Two-column layout matching settings dialog styling\nexport const FieldTemplate = (props: FieldTemplateProps) => {\n  const {\n    children,\n    rawErrors = [],\n    rawHelp,\n    rawDescription,\n    label,\n    required,\n    schema,\n  } = props;\n\n  if (schema.type === 'object') {\n    return children;\n  }\n\n  return (\n    <div className=\"grid grid-cols-2 gap-4 py-4\">\n      {/* Left column: Label and description */}\n      <div className=\"space-y-1\">\n        {label && (\n          <div className=\"text-sm font-medium text-normal\">\n            {toPrettyCase(label)}\n            {required && <span className=\"text-error ml-1\">*</span>}\n          </div>\n        )}\n\n        {rawDescription && (\n          <p className=\"text-sm text-low leading-relaxed\">{rawDescription}</p>\n        )}\n\n        {rawHelp && (\n          <p className=\"text-sm text-low leading-relaxed\">{rawHelp}</p>\n        )}\n      </div>\n\n      {/* Right column: Field content */}\n      <div className=\"space-y-2\">\n        {children}\n\n        {rawErrors.length > 0 && (\n          <div className=\"space-y-1\">\n            {rawErrors.map((error, index) => (\n              <p key={index} className=\"text-sm text-error\">\n                {error}\n              </p>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n\n// ObjectFieldTemplate - Container for object fields\nexport const ObjectFieldTemplate = (props: ObjectFieldTemplateProps) => {\n  const { properties } = props;\n\n  return (\n    <div className=\"divide-y divide-border\">\n      {properties.map((element) => (\n        <div key={element.name}>{element.content}</div>\n      ))}\n    </div>\n  );\n};\n\n// ArrayFieldTemplate - Array field with add button\nexport const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => {\n  const { t } = useTranslation('common');\n  const { canAdd, items, onAddClick, disabled, readonly } = props;\n\n  if (!items || (items.length === 0 && !canAdd)) {\n    return null;\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div>{items}</div>\n\n      {canAdd && (\n        <button\n          type=\"button\"\n          onClick={onAddClick}\n          disabled={disabled || readonly}\n          className={cn(\n            'w-full flex items-center justify-center gap-2 px-base py-half rounded-sm text-sm font-medium',\n            'bg-secondary border border-border text-normal hover:bg-secondary/80',\n            'focus:outline-none focus:ring-1 focus:ring-brand',\n            'disabled:opacity-50 disabled:cursor-not-allowed transition-colors'\n          )}\n        >\n          <PlusIcon className=\"size-icon-xs\" weight=\"bold\" />\n          {t('buttons.addItem')}\n        </button>\n      )}\n    </div>\n  );\n};\n\n// ArrayFieldItemTemplate - Individual array item with remove button\nexport const ArrayFieldItemTemplate = (props: ArrayFieldItemTemplateProps) => {\n  const { children, buttonsProps, disabled, readonly } = props;\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <div className=\"flex-1\">{children}</div>\n\n      {buttonsProps.hasRemove && (\n        <button\n          type=\"button\"\n          onClick={buttonsProps.onRemoveItem}\n          disabled={disabled || readonly || buttonsProps.disabled}\n          className={cn(\n            'h-8 w-8 p-0 flex items-center justify-center shrink-0 rounded-sm',\n            'text-low hover:text-error hover:bg-error/10',\n            'focus:outline-none focus:ring-1 focus:ring-brand',\n            'disabled:opacity-50 disabled:cursor-not-allowed transition-colors'\n          )}\n          title=\"Remove item\"\n        >\n          <XIcon className=\"size-icon-xs\" weight=\"bold\" />\n        </button>\n      )}\n    </div>\n  );\n};\n\n// FormTemplate - Root form container\nexport const FormTemplate = ({ children }: React.PropsWithChildren) => {\n  return <div className=\"w-full\">{children}</div>;\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/rjsf/Widgets.tsx",
    "content": "import { WidgetProps } from '@rjsf/utils';\nimport { cn } from '@/shared/lib/utils';\nimport { useTranslation } from 'react-i18next';\nimport { useMemo } from 'react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuTriggerButton,\n} from '@vibe/ui/components/Dropdown';\n\n// TextWidget - Text input matching settings dialog styling\nexport const TextWidget = (props: WidgetProps) => {\n  const {\n    id,\n    value,\n    disabled,\n    readonly,\n    onChange,\n    onBlur,\n    onFocus,\n    placeholder,\n    options,\n  } = props;\n\n  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    const newValue = event.target.value;\n    onChange(newValue === '' ? options.emptyValue : newValue);\n  };\n\n  const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {\n    if (onBlur) {\n      onBlur(id, event.target.value);\n    }\n  };\n\n  const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {\n    if (onFocus) {\n      onFocus(id, event.target.value);\n    }\n  };\n\n  return (\n    <input\n      id={id}\n      type=\"text\"\n      value={value ?? ''}\n      placeholder={placeholder || ''}\n      disabled={disabled || readonly}\n      onChange={handleChange}\n      onBlur={handleBlur}\n      onFocus={handleFocus}\n      className={cn(\n        'w-full bg-secondary border border-border rounded-sm px-base py-half text-base text-high',\n        'placeholder:text-low placeholder:opacity-80 focus:outline-none focus:ring-1 focus:ring-brand',\n        (disabled || readonly) && 'opacity-50 cursor-not-allowed'\n      )}\n    />\n  );\n};\n\n// SelectWidget - Dropdown select matching settings dialog styling\nexport const SelectWidget = (props: WidgetProps) => {\n  const {\n    id,\n    value,\n    disabled,\n    readonly,\n    onChange,\n    options,\n    schema,\n    placeholder,\n  } = props;\n\n  const { t } = useTranslation('common');\n  const { enumOptions } = options;\n\n  const handleChange = (newValue: string) => {\n    const finalValue = newValue === '__null__' ? options.emptyValue : newValue;\n    onChange(finalValue);\n  };\n\n  // Handle nullable types\n  const isNullable = Array.isArray(schema.type) && schema.type.includes('null');\n  const allOptions = useMemo(() => {\n    const selectOptions = enumOptions || [];\n    if (isNullable) {\n      return [\n        { value: '__null__', label: t('form.notSpecified') },\n        ...selectOptions.filter((opt) => opt.value !== null),\n      ];\n    }\n    return selectOptions;\n  }, [isNullable, enumOptions, t]);\n\n  const currentValue = value === null ? '__null__' : (value ?? '');\n  const selectedOption = allOptions.find(\n    (opt) => String(opt.value) === String(currentValue)\n  );\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <DropdownMenuTriggerButton\n          id={id}\n          label={selectedOption?.label || placeholder || t('form.selectOption')}\n          className=\"w-full justify-between\"\n          disabled={disabled || readonly}\n        />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent className=\"w-[var(--radix-dropdown-menu-trigger-width)]\">\n        {allOptions.map((option) => (\n          <DropdownMenuItem\n            key={String(option.value)}\n            onClick={() => handleChange(String(option.value))}\n          >\n            {option.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n\n// CheckboxWidget - Checkbox matching settings dialog styling\n// Note: Label is shown in the FieldTemplate's left column, not here\nexport const CheckboxWidget = (props: WidgetProps) => {\n  const { id, value, disabled, readonly, onChange } = props;\n\n  const handleChange = (checked: boolean) => {\n    onChange(checked);\n  };\n\n  const checked = Boolean(value);\n\n  return (\n    <input\n      type=\"checkbox\"\n      id={id}\n      checked={checked}\n      onChange={(e) => handleChange(e.target.checked)}\n      disabled={disabled || readonly}\n      className={cn(\n        'h-4 w-4 rounded border-border bg-secondary text-brand focus:ring-brand focus:ring-offset-0',\n        (disabled || readonly) && 'opacity-50 cursor-not-allowed'\n      )}\n    />\n  );\n};\n\n// TextareaWidget - Textarea matching settings dialog styling\nexport const TextareaWidget = (props: WidgetProps) => {\n  const {\n    id,\n    value,\n    disabled,\n    readonly,\n    onChange,\n    onBlur,\n    onFocus,\n    placeholder,\n    options,\n  } = props;\n\n  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const newValue = event.target.value;\n    onChange(newValue === '' ? options.emptyValue : newValue);\n  };\n\n  const handleBlur = (event: React.FocusEvent<HTMLTextAreaElement>) => {\n    if (onBlur) {\n      onBlur(id, event.target.value);\n    }\n  };\n\n  const handleFocus = (event: React.FocusEvent<HTMLTextAreaElement>) => {\n    if (onFocus) {\n      onFocus(id, event.target.value);\n    }\n  };\n\n  return (\n    <textarea\n      id={id}\n      value={value ?? ''}\n      placeholder={placeholder || ''}\n      disabled={disabled || readonly}\n      onChange={handleChange}\n      onBlur={handleBlur}\n      onFocus={handleFocus}\n      rows={4}\n      className={cn(\n        'w-full bg-secondary border border-border rounded-sm px-base py-half text-base text-high',\n        'placeholder:text-low placeholder:opacity-80 focus:outline-none focus:ring-1 focus:ring-brand',\n        'resize-y',\n        (disabled || readonly) && 'opacity-50 cursor-not-allowed'\n      )}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/rjsf/theme.ts",
    "content": "import { RegistryFieldsType, RegistryWidgetsType } from '@rjsf/utils';\nimport {\n  TextWidget,\n  SelectWidget,\n  CheckboxWidget,\n  TextareaWidget,\n} from './Widgets.tsx';\nimport {\n  FieldTemplate,\n  ObjectFieldTemplate,\n  ArrayFieldTemplate,\n  ArrayFieldItemTemplate,\n  FormTemplate,\n} from './Templates.tsx';\nimport { KeyValueField } from './Fields.tsx';\n\nexport const settingsWidgets: RegistryWidgetsType = {\n  TextWidget,\n  SelectWidget,\n  CheckboxWidget,\n  TextareaWidget,\n  textarea: TextareaWidget,\n};\n\nexport const settingsTemplates = {\n  ArrayFieldTemplate,\n  ArrayFieldItemTemplate,\n  FieldTemplate,\n  ObjectFieldTemplate,\n  FormTemplate,\n};\n\nexport const settingsFields: RegistryFieldsType = {\n  KeyValueField,\n};\n\nexport const settingsRjsfTheme = {\n  widgets: settingsWidgets,\n  templates: settingsTemplates,\n  fields: settingsFields,\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/settings/settings/useRelayRemoteHostMutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { createRelaySession, listRelayHosts } from '@/shared/lib/remoteApi';\nimport {\n  createRelaySessionAuthCode,\n  establishRelaySessionBaseUrl,\n  finishRelaySpake2Enrollment,\n  getRelayApiUrl,\n  startRelaySpake2Enrollment,\n} from '@/shared/lib/relayBackendApi';\nimport {\n  buildClientProofB64,\n  finishSpake2Enrollment,\n  generateRelaySigningKeyPair,\n  startSpake2Enrollment,\n  verifyServerProof,\n} from '@/shared/lib/relayPake';\nimport {\n  listPairedRelayHosts,\n  removePairedRelayHost,\n  savePairedRelayHost,\n} from '@/shared/lib/relayPairingStorage';\nimport { createRelayClientIdentity } from '@/shared/lib/relayClientIdentity';\n\nexport const RELAY_REMOTE_HOSTS_QUERY_KEY = [\n  'relay',\n  'remote',\n  'hosts',\n] as const;\nexport const RELAY_REMOTE_PAIRED_HOSTS_QUERY_KEY = [\n  'relay',\n  'remote',\n  'paired-hosts',\n] as const;\nexport const RELAY_APP_BAR_HOSTS_QUERY_KEY = ['relay-app-bar-hosts'] as const;\n\ninterface PairRelayHostInput {\n  hostId: string;\n  hostName: string;\n  normalizedCode: string;\n}\n\nasync function pairRelayHost({\n  hostId,\n  hostName,\n  normalizedCode,\n}: PairRelayHostInput): Promise<void> {\n  const relaySession = await createRelaySession(hostId);\n  const authCode = await createRelaySessionAuthCode(relaySession.id);\n\n  const relaySessionBaseUrl = await establishRelaySessionBaseUrl(\n    getRelayApiUrl(),\n    hostId,\n    authCode.code\n  );\n\n  const { state, clientMessageB64 } =\n    await startSpake2Enrollment(normalizedCode);\n\n  const startData = await startRelaySpake2Enrollment(relaySessionBaseUrl, {\n    enrollment_code: normalizedCode,\n    client_message_b64: clientMessageB64,\n  });\n\n  const sharedKey = await finishSpake2Enrollment(\n    state,\n    startData.server_message_b64\n  );\n\n  const { privateKeyJwk, publicKeyB64, publicKeyBytes } =\n    await generateRelaySigningKeyPair();\n  const clientProofB64 = await buildClientProofB64(\n    sharedKey,\n    startData.enrollment_id,\n    publicKeyBytes\n  );\n  const relayClientIdentity = createRelayClientIdentity();\n\n  const finishData = await finishRelaySpake2Enrollment(relaySessionBaseUrl, {\n    enrollment_id: startData.enrollment_id,\n    client_id: relayClientIdentity.clientId,\n    client_name: relayClientIdentity.clientName,\n    client_browser: relayClientIdentity.clientBrowser,\n    client_os: relayClientIdentity.clientOs,\n    client_device: relayClientIdentity.clientDevice,\n    public_key_b64: publicKeyB64,\n    client_proof_b64: clientProofB64,\n  });\n\n  const serverProofValid = await verifyServerProof(\n    sharedKey,\n    startData.enrollment_id,\n    publicKeyBytes,\n    finishData.server_public_key_b64,\n    finishData.server_proof_b64\n  );\n  if (!serverProofValid) {\n    throw new Error('Server proof verification failed.');\n  }\n\n  await savePairedRelayHost({\n    host_id: hostId,\n    host_name: hostName,\n    client_id: relayClientIdentity.clientId,\n    client_name: relayClientIdentity.clientName,\n    signing_session_id: finishData.signing_session_id,\n    public_key_b64: publicKeyB64,\n    private_key_jwk: privateKeyJwk,\n    server_public_key_b64: finishData.server_public_key_b64,\n    paired_at: new Date().toISOString(),\n  });\n}\n\nexport function useRelayRemoteHostsQuery() {\n  return {\n    queryKey: RELAY_REMOTE_HOSTS_QUERY_KEY,\n    queryFn: listRelayHosts,\n  };\n}\n\nexport function useRelayRemotePairedHostsQuery() {\n  return {\n    queryKey: RELAY_REMOTE_PAIRED_HOSTS_QUERY_KEY,\n    queryFn: async () => {\n      try {\n        return await listPairedRelayHosts();\n      } catch (error) {\n        console.error('Failed to load paired hosts', error);\n        return [];\n      }\n    },\n  };\n}\n\nexport function usePairRelayHostMutation() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: pairRelayHost,\n    onSuccess: async () => {\n      await Promise.all([\n        queryClient.invalidateQueries({\n          queryKey: RELAY_REMOTE_PAIRED_HOSTS_QUERY_KEY,\n        }),\n        queryClient.invalidateQueries({\n          queryKey: RELAY_APP_BAR_HOSTS_QUERY_KEY,\n        }),\n      ]);\n    },\n  });\n}\n\nexport function useRemovePairedRelayHostMutation() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: removePairedRelayHost,\n    onSuccess: async () => {\n      await Promise.all([\n        queryClient.invalidateQueries({\n          queryKey: RELAY_REMOTE_PAIRED_HOSTS_QUERY_KEY,\n        }),\n        queryClient.invalidateQueries({\n          queryKey: RELAY_APP_BAR_HOSTS_QUERY_KEY,\n        }),\n      ]);\n    },\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/shared/ConfirmDialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { AlertTriangle, Info, CheckCircle, XCircle } from 'lucide-react';\nimport { defineModal, type ConfirmResult } from '@/shared/lib/modals';\n\nexport interface ConfirmDialogProps {\n  title: string;\n  message: string;\n  confirmText?: string;\n  cancelText?: string;\n  variant?: 'default' | 'destructive' | 'info' | 'success';\n  icon?: boolean;\n}\n\nconst ConfirmDialogImpl = create<ConfirmDialogProps>((props) => {\n  const modal = useModal();\n  const {\n    title,\n    message,\n    confirmText = 'Confirm',\n    cancelText = 'Cancel',\n    variant = 'default',\n    icon = true,\n  } = props;\n\n  const handleConfirm = () => {\n    modal.resolve('confirmed' as ConfirmResult);\n  };\n\n  const handleCancel = () => {\n    modal.resolve('canceled' as ConfirmResult);\n  };\n\n  const getIcon = () => {\n    if (!icon) return null;\n\n    switch (variant) {\n      case 'destructive':\n        return <AlertTriangle className=\"h-6 w-6 text-destructive\" />;\n      case 'info':\n        return <Info className=\"h-6 w-6 text-blue-500\" />;\n      case 'success':\n        return <CheckCircle className=\"h-6 w-6 text-green-500\" />;\n      default:\n        return <XCircle className=\"h-6 w-6 text-muted-foreground\" />;\n    }\n  };\n\n  const getConfirmButtonVariant = () => {\n    return variant === 'destructive' ? 'destructive' : 'default';\n  };\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleCancel}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader>\n          <div className=\"flex items-center gap-3\">\n            {getIcon()}\n            <DialogTitle>{title}</DialogTitle>\n          </div>\n          <DialogDescription className=\"text-left pt-2\">\n            {message}\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter className=\"gap-2\">\n          <Button variant=\"outline\" onClick={handleCancel}>\n            {cancelText}\n          </Button>\n          <Button variant={getConfirmButtonVariant()} onClick={handleConfirm}>\n            {confirmText}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const ConfirmDialog = defineModal<ConfirmDialogProps, ConfirmResult>(\n  ConfirmDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/shared/FolderPickerDialog.tsx",
    "content": "import React, { useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport {\n  AlertCircle,\n  ChevronUp,\n  File,\n  Folder,\n  FolderOpen,\n  Home,\n  Search,\n} from 'lucide-react';\nimport { fileSystemApi } from '@/shared/lib/api';\nimport { DirectoryEntry, DirectoryListResponse } from 'shared/types';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\n\nexport interface FolderPickerDialogProps {\n  value?: string;\n  title?: string;\n  description?: string;\n}\n\nconst FolderPickerDialogImpl = create<FolderPickerDialogProps>(\n  ({\n    value = '',\n    title = 'Select Folder',\n    description = 'Choose a folder for your project',\n  }) => {\n    const modal = useModal();\n    const { t } = useTranslation('common');\n    const [currentPath, setCurrentPath] = useState<string>('');\n    const [entries, setEntries] = useState<DirectoryEntry[]>([]);\n    const [loading, setLoading] = useState(false);\n    const [error, setError] = useState('');\n    const [manualPath, setManualPath] = useState(value);\n    const [searchTerm, setSearchTerm] = useState('');\n\n    const filteredEntries = useMemo(() => {\n      if (!searchTerm.trim()) return entries;\n      return entries.filter((entry) =>\n        entry.name.toLowerCase().includes(searchTerm.toLowerCase())\n      );\n    }, [entries, searchTerm]);\n\n    useEffect(() => {\n      if (modal.visible) {\n        setManualPath(value);\n        loadDirectory();\n      }\n    }, [modal.visible, value]);\n\n    const loadDirectory = async (path?: string) => {\n      setLoading(true);\n      setError('');\n\n      try {\n        const result: DirectoryListResponse = await fileSystemApi.list(path);\n\n        // Ensure result exists and has the expected structure\n        if (!result || typeof result !== 'object') {\n          throw new Error('Invalid response from file system API');\n        }\n        // Safely access entries, ensuring it's an array\n        const entries = Array.isArray(result.entries) ? result.entries : [];\n        setEntries(entries);\n        const newPath = result.current_path || '';\n        setCurrentPath(newPath);\n        // Update manual path if we have a specific path (not for initial home directory load)\n        if (path) {\n          setManualPath(newPath);\n        }\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : 'Failed to load directory'\n        );\n        // Reset entries to empty array on error\n        setEntries([]);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    const handleFolderClick = (entry: DirectoryEntry) => {\n      if (entry.is_directory) {\n        setSearchTerm('');\n        loadDirectory(entry.path);\n        setManualPath(entry.path); // Auto-populate the manual path field\n      }\n    };\n\n    const handleParentDirectory = () => {\n      const parentPath = currentPath.split('/').slice(0, -1).join('/');\n      const newPath = parentPath || '/';\n      loadDirectory(newPath);\n      setManualPath(newPath);\n    };\n\n    const handleHomeDirectory = () => {\n      loadDirectory();\n      // Don't set manual path here since home directory path varies by system\n    };\n\n    const handleManualPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n      setManualPath(e.target.value);\n    };\n\n    const handleManualPathSubmit = () => {\n      loadDirectory(manualPath);\n    };\n\n    const handleSelectCurrent = () => {\n      const selectedPath = manualPath || currentPath;\n      modal.resolve(selectedPath);\n      modal.hide();\n    };\n\n    const handleSelectManual = () => {\n      modal.resolve(manualPath);\n      modal.hide();\n    };\n\n    const handleCancel = () => {\n      modal.resolve(null);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    return (\n      <div className=\"fixed inset-0 z-[10000] pointer-events-none [&>*]:pointer-events-auto\">\n        <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n          <DialogContent className=\"max-w-[600px] w-full h-[700px] flex flex-col overflow-hidden\">\n            <DialogHeader>\n              <DialogTitle>{title}</DialogTitle>\n              <DialogDescription>{description}</DialogDescription>\n            </DialogHeader>\n\n            <div className=\"flex-1 flex flex-col space-y-4 overflow-hidden\">\n              {/* Legend */}\n              <div className=\"text-xs text-muted-foreground border-b pb-2\">\n                {t('folderPicker.legend')}\n              </div>\n\n              {/* Manual path input */}\n              <div className=\"space-y-2\">\n                <div className=\"text-sm font-medium\">\n                  {t('folderPicker.manualPathLabel')}\n                </div>\n                <div className=\"flex space-x-2 min-w-0\">\n                  <Input\n                    value={manualPath}\n                    onChange={handleManualPathChange}\n                    placeholder=\"/path/to/your/project\"\n                    className=\"flex-1 min-w-0\"\n                  />\n                  <Button\n                    onClick={handleManualPathSubmit}\n                    variant=\"outline\"\n                    size=\"sm\"\n                    className=\"flex-shrink-0\"\n                  >\n                    {t('folderPicker.go')}\n                  </Button>\n                </div>\n              </div>\n\n              {/* Search input */}\n              <div className=\"space-y-2\">\n                <div className=\"text-sm font-medium\">\n                  {t('folderPicker.searchLabel')}\n                </div>\n                <div className=\"relative\">\n                  <Search className=\"absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n                  <Input\n                    value={searchTerm}\n                    onChange={(e) => setSearchTerm(e.target.value)}\n                    placeholder=\"Filter folders and files...\"\n                    className=\"pl-10\"\n                  />\n                </div>\n              </div>\n\n              {/* Navigation */}\n              <div className=\"flex items-center space-x-2 min-w-0\">\n                <Button\n                  onClick={handleHomeDirectory}\n                  variant=\"outline\"\n                  size=\"sm\"\n                  className=\"flex-shrink-0\"\n                >\n                  <Home className=\"h-4 w-4\" />\n                </Button>\n                <Button\n                  onClick={handleParentDirectory}\n                  variant=\"outline\"\n                  size=\"sm\"\n                  disabled={!currentPath || currentPath === '/'}\n                  className=\"flex-shrink-0\"\n                >\n                  <ChevronUp className=\"h-4 w-4\" />\n                </Button>\n                <div className=\"text-sm text-muted-foreground flex-1 truncate min-w-0\">\n                  {currentPath || 'Home'}\n                </div>\n                <Button\n                  onClick={handleSelectCurrent}\n                  variant=\"outline\"\n                  size=\"sm\"\n                  disabled={!currentPath}\n                  className=\"flex-shrink-0\"\n                >\n                  {t('folderPicker.selectCurrent')}\n                </Button>\n              </div>\n\n              {/* Directory listing */}\n              <div className=\"flex-1 border rounded-md overflow-auto\">\n                {loading ? (\n                  <div className=\"p-4 text-center text-muted-foreground\">\n                    Loading...\n                  </div>\n                ) : error ? (\n                  <Alert variant=\"destructive\" className=\"m-4\">\n                    <AlertCircle className=\"h-4 w-4\" />\n                    <AlertDescription>{error}</AlertDescription>\n                  </Alert>\n                ) : filteredEntries.length === 0 ? (\n                  <div className=\"p-4 text-center text-muted-foreground\">\n                    {searchTerm.trim()\n                      ? 'No matches found'\n                      : 'No folders found'}\n                  </div>\n                ) : (\n                  <div className=\"p-2\">\n                    {filteredEntries.map((entry, index) => (\n                      <div\n                        key={index}\n                        className={`flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-accent ${\n                          !entry.is_directory\n                            ? 'opacity-50 cursor-not-allowed'\n                            : ''\n                        }`}\n                        onClick={() =>\n                          entry.is_directory && handleFolderClick(entry)\n                        }\n                        title={entry.name} // Show full name on hover\n                      >\n                        {entry.is_directory ? (\n                          entry.is_git_repo ? (\n                            <FolderOpen className=\"h-4 w-4 text-success flex-shrink-0\" />\n                          ) : (\n                            <Folder className=\"h-4 w-4 text-blue-600 flex-shrink-0\" />\n                          )\n                        ) : (\n                          <File className=\"h-4 w-4 text-gray-400 flex-shrink-0\" />\n                        )}\n                        <span className=\"text-sm flex-1 truncate min-w-0\">\n                          {entry.name}\n                        </span>\n                        {entry.is_git_repo && (\n                          <span className=\"text-xs text-success bg-green-100 px-2 py-1 rounded flex-shrink-0\">\n                            {t('folderPicker.gitRepo')}\n                          </span>\n                        )}\n                      </div>\n                    ))}\n                  </div>\n                )}\n              </div>\n            </div>\n\n            <DialogFooter>\n              <Button type=\"button\" variant=\"outline\" onClick={handleCancel}>\n                {t('buttons.cancel')}\n              </Button>\n              <Button\n                onClick={handleSelectManual}\n                disabled={!manualPath.trim()}\n              >\n                {t('folderPicker.selectPath')}\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      </div>\n    );\n  }\n);\n\nexport const FolderPickerDialog = defineModal<\n  FolderPickerDialogProps,\n  string | null\n>(FolderPickerDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/shared/KeyboardShortcutsDialog.tsx",
    "content": "import { useMemo, useCallback, useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useTranslation } from 'react-i18next';\nimport { XIcon, GearIcon } from '@phosphor-icons/react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal, type NoProps } from '@/shared/lib/modals';\n\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { cn } from '@/shared/lib/utils';\nimport {\n  sequentialBindings,\n  formatSequentialKeys,\n  Scope,\n} from '@/shared/keyboard/registry';\nimport { isMac, getModifierKey } from '@/shared/lib/platform';\nimport { Tooltip } from '@vibe/ui/components/Tooltip';\n\ninterface ShortcutItem {\n  keys: string | string[];\n  description: string;\n  hasScope?: boolean;\n  useHintKey?: boolean;\n}\n\ninterface ShortcutGroup {\n  name: string;\n  shortcuts: ShortcutItem[];\n}\n\nfunction useShortcutGroups(): ShortcutGroup[] {\n  const { config } = useUserSystem();\n  const { t } = useTranslation('common');\n  const sendShortcut = config?.send_message_shortcut ?? 'ModifierEnter';\n\n  return useMemo(() => {\n    const mod = getModifierKey();\n    const enterKey = isMac() ? '↩' : 'Enter';\n\n    // Quick Actions - single key shortcuts\n    const quickActions: ShortcutGroup = {\n      name: t('shortcuts.groups.quickActions'),\n      shortcuts: [\n        { keys: '?', description: t('shortcuts.actions.showHelp') },\n        { keys: 'Esc', description: t('shortcuts.actions.closeCancel') },\n        { keys: 'C', description: t('shortcuts.actions.createNewTask') },\n        { keys: 'D', description: t('shortcuts.actions.deleteSelected') },\n        { keys: '/', description: t('shortcuts.actions.focusSearch') },\n      ],\n    };\n\n    // Navigation - Vim-style\n    const navigation: ShortcutGroup = {\n      name: t('shortcuts.groups.navigation'),\n      shortcuts: [\n        { keys: 'J', description: t('shortcuts.actions.moveDown') },\n        { keys: 'K', description: t('shortcuts.actions.moveUp') },\n        { keys: 'H', description: t('shortcuts.actions.moveLeft') },\n        { keys: 'L', description: t('shortcuts.actions.moveRight') },\n      ],\n    };\n\n    const modifiers: ShortcutGroup = {\n      name: t('shortcuts.groups.modifiers'),\n      shortcuts: [\n        {\n          keys: [mod, 'K'],\n          description: t('shortcuts.actions.openCommandBar'),\n        },\n        {\n          keys: [mod, 'E'],\n          description: t('shortcuts.actions.formatInlineCode'),\n        },\n        sendShortcut === 'Enter'\n          ? {\n              keys: enterKey,\n              description: t('shortcuts.actions.sendMessage'),\n              useHintKey: true,\n            }\n          : {\n              keys: [mod, enterKey],\n              description: t('shortcuts.actions.sendMessage'),\n              useHintKey: true,\n            },\n      ],\n    };\n\n    // Group sequential bindings by their first key\n    const sequentialByFirstKey = new Map<string, ShortcutItem[]>();\n    for (const binding of sequentialBindings) {\n      const firstKey = binding.keys[0];\n      if (!sequentialByFirstKey.has(firstKey)) {\n        sequentialByFirstKey.set(firstKey, []);\n      }\n      const hasWorkspaceScope =\n        binding.scopes?.includes(Scope.WORKSPACE) ?? false;\n\n      sequentialByFirstKey.get(firstKey)!.push({\n        keys: formatSequentialKeys(binding.keys),\n        description: t(\n          `shortcuts.actions.${binding.actionId}`,\n          binding.description\n        ),\n        hasScope: hasWorkspaceScope,\n      });\n    }\n\n    // Create named groups for sequential shortcuts\n    const sequentialGroups: ShortcutGroup[] = [\n      {\n        name: t('shortcuts.groups.goTo'),\n        shortcuts: sequentialByFirstKey.get('g') || [],\n      },\n      {\n        name: t('shortcuts.groups.workspace'),\n        shortcuts: sequentialByFirstKey.get('w') || [],\n      },\n      {\n        name: t('shortcuts.groups.view'),\n        shortcuts: sequentialByFirstKey.get('v') || [],\n      },\n      {\n        name: t('shortcuts.groups.issues'),\n        shortcuts: sequentialByFirstKey.get('i') || [],\n      },\n      {\n        name: t('shortcuts.groups.git'),\n        shortcuts: sequentialByFirstKey.get('x') || [],\n      },\n      {\n        name: t('shortcuts.groups.yank'),\n        shortcuts: sequentialByFirstKey.get('y') || [],\n      },\n      {\n        name: t('shortcuts.groups.toggle'),\n        shortcuts: sequentialByFirstKey.get('t') || [],\n      },\n      {\n        name: t('shortcuts.groups.run'),\n        shortcuts: sequentialByFirstKey.get('r') || [],\n      },\n    ].filter((g) => g.shortcuts.length > 0);\n\n    return [quickActions, navigation, modifiers, ...sequentialGroups];\n  }, [sendShortcut, t]);\n}\n\nfunction ShortcutRow({ item }: { item: ShortcutItem }) {\n  const { t } = useTranslation('common');\n  const keysArray = Array.isArray(item.keys) ? item.keys : [item.keys];\n\n  return (\n    <div className=\"flex items-center justify-between py-1\">\n      <span className=\"text-normal text-sm flex items-center gap-1\">\n        {item.description}\n        {item.hasScope && (\n          <span className=\"text-low text-xs\">{t('shortcuts.inWorkspace')}</span>\n        )}\n        {item.useHintKey && (\n          <Tooltip content={t('shortcuts.configurableHint')} side=\"top\">\n            <GearIcon className=\"size-icon-xs text-low cursor-help\" />\n          </Tooltip>\n        )}\n      </span>\n      <div className=\"flex items-center gap-1\">\n        {keysArray.map((key, i) => (\n          <kbd\n            key={i}\n            className={cn(\n              'inline-flex items-center justify-center',\n              'min-w-[24px] h-6 px-1.5',\n              'rounded-sm border border-border bg-secondary',\n              'font-ibm-plex-mono text-xs text-high'\n            )}\n          >\n            {key}\n          </kbd>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction ShortcutSection({ group }: { group: ShortcutGroup }) {\n  return (\n    <div className=\"mb-6\">\n      <h3 className=\"text-sm font-medium text-high mb-2 border-b border-border pb-1\">\n        {group.name}\n      </h3>\n      <div className=\"space-y-1\">\n        {group.shortcuts.map((shortcut, i) => (\n          <ShortcutRow key={i} item={shortcut} />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nconst KeyboardShortcutsDialogImpl = create<NoProps>(() => {\n  const { t } = useTranslation('common');\n  const modal = useModal();\n  const groups = useShortcutGroups();\n\n  const handleClose = useCallback(() => {\n    modal.hide();\n    modal.resolve();\n    modal.remove();\n  }, [modal]);\n\n  // Handle ESC key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        handleClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [handleClose]);\n\n  return createPortal(\n    <>\n      {/* Overlay */}\n      <div\n        data-tauri-drag-region\n        className=\"fixed inset-0 z-[9998] bg-black/50 animate-in fade-in-0 duration-200\"\n        onClick={handleClose}\n      />\n      {/* Dialog wrapper - handles positioning */}\n      <div className=\"fixed z-[9999] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">\n        {/* Dialog content - handles animation */}\n        <div\n          className={cn(\n            'w-[700px] max-h-[80vh]',\n            'bg-panel/95 backdrop-blur-sm rounded-sm border border-border/50 shadow-lg',\n            'animate-in fade-in-0 slide-in-from-bottom-4 duration-200',\n            'flex flex-col overflow-hidden'\n          )}\n        >\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-border\">\n            <h2 className=\"text-lg font-semibold text-high\">\n              {t('shortcuts.title')}\n            </h2>\n            <button\n              onClick={handleClose}\n              className=\"p-1 rounded-sm hover:bg-secondary text-low hover:text-normal\"\n            >\n              <XIcon className=\"size-icon-sm\" weight=\"bold\" />\n            </button>\n          </div>\n          {/* Content */}\n          <div className=\"flex-1 overflow-y-auto p-4\">\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-x-8\">\n              {groups.map((group, i) => (\n                <ShortcutSection key={i} group={group} />\n              ))}\n            </div>\n            {/* Footer hint */}\n            <div className=\"mt-4 pt-4 border-t border-border text-center\">\n              <p className=\"text-xs text-low\">\n                {t('shortcuts.sequentialHint')}\n              </p>\n            </div>\n          </div>\n        </div>\n      </div>\n    </>,\n    document.body\n  );\n});\n\nexport const KeyboardShortcutsDialog = defineModal<void, void>(\n  KeyboardShortcutsDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/shared/LoginRequiredPrompt.tsx",
    "content": "import { useCallback, type ComponentProps } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { LogIn, type LucideIcon } from 'lucide-react';\nimport { OAuthDialog } from '@/shared/dialogs/global/OAuthDialog';\n\nimport { Alert } from '@vibe/ui/components/Alert';\nimport { Button } from '@vibe/ui/components/Button';\nimport { cn } from '@/shared/lib/utils';\n\ninterface LoginRequiredPromptProps {\n  className?: string;\n  buttonVariant?: ComponentProps<typeof Button>['variant'];\n  buttonSize?: ComponentProps<typeof Button>['size'];\n  buttonClassName?: string;\n  title?: string;\n  description?: string;\n  actionLabel?: string;\n  onAction?: () => void;\n  icon?: LucideIcon;\n}\n\nexport function LoginRequiredPrompt({\n  className,\n  buttonVariant = 'outline',\n  buttonSize = 'sm',\n  buttonClassName,\n  title,\n  description,\n  actionLabel,\n  onAction,\n  icon,\n}: LoginRequiredPromptProps) {\n  const { t } = useTranslation('tasks');\n\n  const handleRedirect = useCallback(() => {\n    if (onAction) {\n      onAction();\n      return;\n    }\n    void OAuthDialog.show({});\n  }, [onAction]);\n\n  const Icon = icon ?? LogIn;\n\n  return (\n    <Alert\n      variant=\"default\"\n      className={cn('flex items-start gap-3', className)}\n    >\n      <Icon className=\"h-5 w-5 mt-0.5 text-muted-foreground\" />\n      <div className=\"space-y-2\">\n        <div className=\"font-medium\">\n          {title ?? t('shareDialog.loginRequired.title')}\n        </div>\n        <p className=\"text-sm text-muted-foreground\">\n          {description ?? t('shareDialog.loginRequired.description')}\n        </p>\n        <Button\n          variant={buttonVariant}\n          size={buttonSize}\n          onClick={handleRedirect}\n          className={cn('gap-2', buttonClassName)}\n        >\n          <Icon className=\"h-4 w-4\" />\n          {actionLabel ?? t('shareDialog.loginRequired.action')}\n        </Button>\n      </div>\n    </Alert>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/shared/TagEditDialog.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Input } from '@vibe/ui/components/Input';\nimport { Label } from '@vibe/ui/components/Label';\nimport { Textarea } from '@vibe/ui/components/Textarea';\nimport { Alert } from '@vibe/ui/components/Alert';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Loader2 } from 'lucide-react';\nimport { tagsApi } from '@/shared/lib/api';\nimport type { Tag, CreateTag, UpdateTag } from 'shared/types';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal, getErrorMessage } from '@/shared/lib/modals';\n\nexport interface TagEditDialogProps {\n  tag?: Tag | null; // null for create mode\n}\n\nexport type TagEditResult = 'saved' | 'canceled';\n\nconst TagEditDialogImpl = create<TagEditDialogProps>(({ tag }) => {\n  const modal = useModal();\n  const { t } = useTranslation('settings');\n  const [formData, setFormData] = useState({\n    tag_name: '',\n    content: '',\n  });\n  const [saving, setSaving] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [tagNameError, setTagNameError] = useState<string | null>(null);\n\n  const isEditMode = Boolean(tag);\n\n  useEffect(() => {\n    if (tag) {\n      setFormData({\n        tag_name: tag.tag_name,\n        content: tag.content,\n      });\n    } else {\n      setFormData({\n        tag_name: '',\n        content: '',\n      });\n    }\n    setError(null);\n    setTagNameError(null);\n  }, [tag]);\n\n  const handleSave = async () => {\n    if (!formData.tag_name.trim()) {\n      setError(t('settings.general.tags.dialog.errors.nameRequired'));\n      return;\n    }\n\n    setSaving(true);\n    setError(null);\n\n    try {\n      if (isEditMode && tag) {\n        const updateData: UpdateTag = {\n          tag_name: formData.tag_name,\n          content: formData.content || null, // null means \"don't update\"\n        };\n        await tagsApi.update(tag.id, updateData);\n      } else {\n        const createData: CreateTag = {\n          tag_name: formData.tag_name,\n          content: formData.content,\n        };\n        await tagsApi.create(createData);\n      }\n\n      modal.resolve('saved' as TagEditResult);\n      modal.hide();\n    } catch (err: unknown) {\n      setError(\n        getErrorMessage(err) ||\n          t('settings.general.tags.dialog.errors.saveFailed')\n      );\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleCancel = () => {\n    modal.resolve('canceled' as TagEditResult);\n    modal.hide();\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    if (!open) {\n      // Reset form data when dialog closes\n      setFormData({\n        tag_name: '',\n        content: '',\n      });\n      setError(null);\n      handleCancel();\n    }\n  };\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n      <DialogContent className=\"sm:max-w-[500px]\">\n        <DialogHeader>\n          <DialogTitle>\n            {isEditMode\n              ? t('settings.general.tags.dialog.editTitle')\n              : t('settings.general.tags.dialog.createTitle')}\n          </DialogTitle>\n        </DialogHeader>\n        <div className=\"space-y-4 py-4\">\n          <div>\n            <Label htmlFor=\"tag-name\">\n              {t('settings.general.tags.dialog.tagName.label')}{' '}\n              <span className=\"text-destructive\">\n                {t('settings.general.tags.dialog.tagName.required')}\n              </span>\n            </Label>\n            <p className=\"text-xs text-muted-foreground mb-1.5\">\n              {t('settings.general.tags.dialog.tagName.hint', {\n                tagName: formData.tag_name || 'tag_name',\n              })}\n            </p>\n            <Input\n              id=\"tag-name\"\n              value={formData.tag_name}\n              onChange={(e) => {\n                const value = e.target.value;\n                setFormData({ ...formData, tag_name: value });\n\n                // Validate in real-time for spaces\n                if (value.includes(' ')) {\n                  setTagNameError(\n                    t('settings.general.tags.dialog.tagName.error')\n                  );\n                } else {\n                  setTagNameError(null);\n                }\n              }}\n              placeholder={t(\n                'settings.general.tags.dialog.tagName.placeholder'\n              )}\n              disabled={saving}\n              autoFocus\n              aria-invalid={!!tagNameError}\n              className={tagNameError ? 'border-destructive' : undefined}\n            />\n            {tagNameError && (\n              <p className=\"text-sm text-destructive\">{tagNameError}</p>\n            )}\n          </div>\n          <div>\n            <Label htmlFor=\"tag-content\">\n              {t('settings.general.tags.dialog.content.label')}{' '}\n              <span className=\"text-destructive\">\n                {t('settings.general.tags.dialog.content.required')}\n              </span>\n            </Label>\n            <p className=\"text-xs text-muted-foreground mb-1.5\">\n              {t('settings.general.tags.dialog.content.hint', {\n                tagName: formData.tag_name || 'tag_name',\n              })}\n            </p>\n            <Textarea\n              id=\"tag-content\"\n              value={formData.content}\n              onChange={(e) => {\n                const value = e.target.value;\n                setFormData({ ...formData, content: value });\n              }}\n              placeholder={t(\n                'settings.general.tags.dialog.content.placeholder'\n              )}\n              rows={6}\n              disabled={saving}\n            />\n          </div>\n          {error && <Alert variant=\"destructive\">{error}</Alert>}\n        </div>\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={handleCancel} disabled={saving}>\n            {t('settings.general.tags.dialog.buttons.cancel')}\n          </Button>\n          <Button\n            onClick={handleSave}\n            disabled={saving || !!tagNameError || !formData.content.trim()}\n          >\n            {saving && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n            {isEditMode\n              ? t('settings.general.tags.dialog.buttons.update')\n              : t('settings.general.tags.dialog.buttons.create')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const TagEditDialog = defineModal<TagEditDialogProps, TagEditResult>(\n  TagEditDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/shared/WorkspacesGuideDialog.tsx",
    "content": "import { useEffect, useCallback } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useTranslation } from 'react-i18next';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal, type NoProps } from '@/shared/lib/modals';\nimport {\n  GuideDialogShell,\n  type GuideDialogTopic,\n} from '@vibe/ui/components/GuideDialogShell';\n\nconst TOPIC_IDS = [\n  'welcome',\n  'commandBar',\n  'contextBar',\n  'sidebar',\n  'multiRepo',\n  'sessions',\n  'preview',\n  'diffs',\n] as const;\n\nconst TOPIC_IMAGES: Record<(typeof TOPIC_IDS)[number], string> = {\n  welcome: '/guide-images/welcome.png',\n  commandBar: '/guide-images/command-bar.png',\n  contextBar: '/guide-images/context-bar.png',\n  sidebar: '/guide-images/sidebar.png',\n  multiRepo: '/guide-images/multi-repo.png',\n  sessions: '/guide-images/sessions.png',\n  preview: '/guide-images/preview.png',\n  diffs: '/guide-images/diffs.png',\n};\n\nconst WorkspacesGuideDialogImpl = create<NoProps>(() => {\n  const modal = useModal();\n  const { t } = useTranslation('common');\n  const topics: GuideDialogTopic[] = TOPIC_IDS.map((topicId) => ({\n    id: topicId,\n    title: t(`workspacesGuide.${topicId}.title`),\n    content: t(`workspacesGuide.${topicId}.content`),\n    imageSrc: TOPIC_IMAGES[topicId],\n  }));\n\n  const handleClose = useCallback(() => {\n    modal.hide();\n    modal.resolve();\n    modal.remove();\n  }, [modal]);\n\n  // Handle ESC key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        handleClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [handleClose]);\n\n  return createPortal(\n    <GuideDialogShell\n      topics={topics}\n      closeLabel={t('buttons.close')}\n      onClose={handleClose}\n    />,\n    document.body\n  );\n});\n\nexport const WorkspacesGuideDialog = defineModal<void, void>(\n  WorkspacesGuideDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/tasks/PrCommentsDialog.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Alert, AlertDescription } from '@vibe/ui/components/Alert';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Checkbox } from '@vibe/ui/components/Checkbox';\nimport { MessageSquare, AlertCircle, Loader2 } from 'lucide-react';\nimport { usePrComments } from '@/shared/hooks/usePrComments';\nimport { PrCommentCard } from '@vibe/ui/components/pr-comment-card';\nimport type { UnifiedPrComment } from 'shared/types';\n\nexport interface PrCommentsDialogProps {\n  workspaceId: string;\n  repoId: string;\n}\n\nexport interface PrCommentsDialogResult {\n  comments: UnifiedPrComment[];\n}\n\nfunction getCommentId(comment: UnifiedPrComment): string {\n  return comment.comment_type === 'general'\n    ? comment.id\n    : comment.id.toString();\n}\n\nconst PrCommentsDialogImpl = create<PrCommentsDialogProps>(\n  ({ workspaceId, repoId }) => {\n    const { t } = useTranslation(['tasks', 'common']);\n    const modal = useModal();\n    const { data, isLoading, isError, error } = usePrComments(\n      workspaceId,\n      repoId\n    );\n    const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());\n\n    const comments = data?.comments ?? [];\n\n    // Reset selection when dialog opens\n    useEffect(() => {\n      if (modal.visible) {\n        setSelectedIds(new Set());\n      }\n    }, [modal.visible]);\n\n    const toggleSelection = (id: string) => {\n      setSelectedIds((prev) => {\n        const newSet = new Set(prev);\n        if (newSet.has(id)) {\n          newSet.delete(id);\n        } else {\n          newSet.add(id);\n        }\n        return newSet;\n      });\n    };\n\n    const selectAll = () => {\n      setSelectedIds(new Set(comments.map((c) => getCommentId(c))));\n    };\n\n    const deselectAll = () => {\n      setSelectedIds(new Set());\n    };\n\n    const isAllSelected =\n      comments.length > 0 && selectedIds.size === comments.length;\n\n    const handleConfirm = () => {\n      const selected = comments.filter((c) => selectedIds.has(getCommentId(c)));\n      modal.resolve({ comments: selected });\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        modal.resolve({ comments: [] });\n        modal.hide();\n      }\n    };\n\n    // Check for specific error types from the API\n    const errorMessage = isError ? getErrorMessage(error) : null;\n\n    return (\n      <Dialog\n        open={modal.visible}\n        onOpenChange={handleOpenChange}\n        className=\"max-w-2xl p-0 overflow-hidden\"\n      >\n        <DialogContent\n          className=\"p-0\"\n          onKeyDownCapture={(e) => {\n            if (e.key === 'Escape') {\n              e.stopPropagation();\n              modal.resolve({ comments: [] });\n              modal.hide();\n            }\n          }}\n        >\n          <DialogHeader className=\"px-4 py-3 border-b\">\n            <DialogTitle className=\"flex items-center gap-2\">\n              <MessageSquare className=\"h-5 w-5\" />\n              {t('tasks:prComments.dialog.title')}\n            </DialogTitle>\n          </DialogHeader>\n\n          <div className=\"max-h-[70vh] flex flex-col min-h-0\">\n            <div className=\"p-4 overflow-auto flex-1 min-h-0\">\n              {errorMessage ? (\n                <Alert variant=\"destructive\">\n                  <AlertCircle className=\"h-4 w-4\" />\n                  <AlertDescription>{errorMessage}</AlertDescription>\n                </Alert>\n              ) : isLoading ? (\n                <div className=\"flex items-center justify-center py-8\">\n                  <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n                </div>\n              ) : comments.length === 0 ? (\n                <p className=\"text-center text-muted-foreground py-8\">\n                  {t('tasks:prComments.dialog.noComments')}\n                </p>\n              ) : (\n                <>\n                  <div className=\"flex items-center justify-between mb-3\">\n                    <span className=\"text-sm text-muted-foreground\">\n                      {t('tasks:prComments.dialog.selectedCount', {\n                        selected: selectedIds.size,\n                        total: comments.length,\n                      })}\n                    </span>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={isAllSelected ? deselectAll : selectAll}\n                    >\n                      {isAllSelected\n                        ? t('tasks:prComments.dialog.deselectAll')\n                        : t('tasks:prComments.dialog.selectAll')}\n                    </Button>\n                  </div>\n                  <div className=\"space-y-3\">\n                    {comments.map((comment) => {\n                      const id = getCommentId(comment);\n                      return (\n                        <div\n                          key={id}\n                          className=\"flex items-start gap-3 min-w-0\"\n                        >\n                          <Checkbox\n                            checked={selectedIds.has(id)}\n                            onCheckedChange={() => toggleSelection(id)}\n                            className=\"mt-3\"\n                          />\n                          <PrCommentCard\n                            author={comment.author}\n                            body={comment.body}\n                            createdAt={comment.created_at}\n                            url={comment.url}\n                            commentType={comment.comment_type}\n                            path={\n                              comment.comment_type === 'review'\n                                ? comment.path\n                                : undefined\n                            }\n                            line={\n                              comment.comment_type === 'review' &&\n                              comment.line != null\n                                ? Number(comment.line)\n                                : undefined\n                            }\n                            diffHunk={\n                              comment.comment_type === 'review'\n                                ? comment.diff_hunk\n                                : undefined\n                            }\n                            variant=\"list\"\n                            onClick={() => toggleSelection(id)}\n                            className=\"flex-1 min-w-0\"\n                          />\n                        </div>\n                      );\n                    })}\n                  </div>\n                </>\n              )}\n            </div>\n          </div>\n\n          {!errorMessage && !isLoading && comments.length > 0 && (\n            <DialogFooter className=\"px-4 py-3 border-t\">\n              <Button variant=\"outline\" onClick={() => handleOpenChange(false)}>\n                {t('common:buttons.cancel')}\n              </Button>\n              <Button onClick={handleConfirm} disabled={selectedIds.size === 0}>\n                {t('tasks:prComments.dialog.add')}\n                {selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}\n              </Button>\n            </DialogFooter>\n          )}\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nfunction getErrorMessage(error: unknown): string {\n  // Check if it's an API error with error_data\n  if (error && typeof error === 'object' && 'error_data' in error) {\n    const errorData = (error as { error_data?: { type?: string } }).error_data;\n    if (errorData?.type === 'no_pr_attached') {\n      return 'No PR is attached to this workspace. Create a PR first to see comments.';\n    }\n    if (errorData?.type === 'cli_not_installed') {\n      return 'CLI is not installed. Please install it to fetch PR comments.';\n    }\n    if (errorData?.type === 'cli_not_logged_in') {\n      return 'CLI is not logged in. Please authenticate to fetch PR comments.';\n    }\n  }\n  return 'Failed to load PR comments. Please try again.';\n}\n\nexport const PrCommentsDialog = defineModal<\n  PrCommentsDialogProps,\n  PrCommentsDialogResult\n>(PrCommentsDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx",
    "content": "import { useState, useCallback, useMemo, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { Label } from '@vibe/ui/components/Label';\nimport { Switch } from '@vibe/ui/components/Switch';\nimport { AgentSelector } from '@/shared/components/tasks/AgentSelector';\nimport { ConfigSelector } from '@/shared/components/tasks/ConfigSelector';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { sessionsApi } from '@/shared/lib/api';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { buildResolveConflictsInstructions } from '@/shared/lib/conflicts';\nimport { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses';\nimport { getLatestConfigFromProcesses } from '@/shared/lib/executor';\nimport type {\n  BaseCodingAgent,\n  ExecutorProfileId,\n  ConflictOp,\n} from 'shared/types';\n\nexport interface ResolveConflictsDialogProps {\n  workspaceId: string;\n  conflictOp: ConflictOp;\n  sourceBranch: string | null;\n  targetBranch: string;\n  conflictedFiles: string[];\n  repoName?: string;\n}\n\nexport type ResolveConflictsDialogResult =\n  | { action: 'resolved'; sessionId?: string }\n  | { action: 'cancelled' };\n\nconst ResolveConflictsDialogImpl = create<ResolveConflictsDialogProps>(\n  ({\n    workspaceId,\n    conflictOp,\n    sourceBranch,\n    targetBranch,\n    conflictedFiles,\n    repoName,\n  }) => {\n    const modal = useModal();\n    const queryClient = useQueryClient();\n    const { profiles, config } = useUserSystem();\n    const {\n      workspaceId: activeWorkspaceId,\n      sessions,\n      selectedSession,\n      selectedSessionId,\n      selectSession,\n    } = useWorkspaceContext();\n    const { t } = useTranslation(['tasks', 'common']);\n\n    // Auto-dismiss when the user switches to a different workspace\n    useEffect(() => {\n      if (activeWorkspaceId && activeWorkspaceId !== workspaceId) {\n        modal.resolve({\n          action: 'cancelled',\n        } as ResolveConflictsDialogResult);\n        modal.hide();\n      }\n    }, [activeWorkspaceId, workspaceId, modal]);\n\n    const resolvedSession = useMemo(() => {\n      if (!selectedSessionId) return selectedSession ?? null;\n      return (\n        sessions.find((session) => session.id === selectedSessionId) ??\n        selectedSession ??\n        null\n      );\n    }, [sessions, selectedSessionId, selectedSession]);\n    const sessionExecutor = resolvedSession?.executor as BaseCodingAgent | null;\n\n    // Get the variant from the session's latest process\n    const resolvedSessionId = resolvedSession?.id;\n    const { executionProcesses: sessionProcesses } =\n      useExecutionProcesses(resolvedSessionId);\n    const sessionProfileFromProcesses =\n      useMemo((): ExecutorProfileId | null => {\n        const config = getLatestConfigFromProcesses(sessionProcesses);\n        if (!config) return null;\n        return { executor: config.executor, variant: config.variant ?? null };\n      }, [sessionProcesses]);\n\n    const resolvedDefaultProfile = useMemo(() => {\n      // Prefer the full profile (executor+variant) from the session's processes\n      if (sessionProfileFromProcesses) return sessionProfileFromProcesses;\n      // Fall back to session executor with config variant hint while processes load\n      if (sessionExecutor) {\n        const variant =\n          config?.executor_profile?.executor === sessionExecutor\n            ? config.executor_profile.variant\n            : null;\n        return { executor: sessionExecutor, variant };\n      }\n      return config?.executor_profile ?? null;\n    }, [\n      sessionProfileFromProcesses,\n      sessionExecutor,\n      config?.executor_profile,\n    ]);\n\n    // Default to creating a new session if no existing session\n    const [createNewSession, setCreateNewSession] =\n      useState(!selectedSessionId);\n    const [userSelectedProfile, setUserSelectedProfile] =\n      useState<ExecutorProfileId | null>(null);\n    const [isSubmitting, setIsSubmitting] = useState(false);\n    const [error, setError] = useState<string | null>(null);\n\n    const effectiveProfile = userSelectedProfile ?? resolvedDefaultProfile;\n    const canSubmit = Boolean(effectiveProfile && !isSubmitting);\n\n    // Build the conflict resolution instructions\n    const conflictInstructions = useMemo(\n      () =>\n        buildResolveConflictsInstructions(\n          sourceBranch,\n          targetBranch,\n          conflictedFiles,\n          conflictOp,\n          repoName\n        ),\n      [sourceBranch, targetBranch, conflictedFiles, conflictOp, repoName]\n    );\n\n    const handleSubmit = useCallback(async () => {\n      if (!effectiveProfile) return;\n\n      setIsSubmitting(true);\n      setError(null);\n\n      try {\n        let targetSessionId = selectedSessionId;\n        const creatingNewSession = createNewSession || !selectedSessionId;\n\n        // Create new session if user selected that option or no existing session\n        if (creatingNewSession) {\n          const session = await sessionsApi.create({\n            workspace_id: workspaceId,\n            executor: effectiveProfile.executor,\n            name: t('resolveConflicts.dialog.sessionName'),\n          });\n          targetSessionId = session.id;\n        }\n\n        if (!targetSessionId) {\n          setError('Failed to create session');\n          setIsSubmitting(false);\n          return;\n        }\n\n        // Send follow-up with conflict resolution instructions\n        await sessionsApi.followUp(targetSessionId, {\n          prompt: conflictInstructions,\n          executor_config: {\n            executor: effectiveProfile.executor,\n            variant: effectiveProfile.variant,\n          },\n          retry_process_id: null,\n          force_when_dirty: null,\n          perform_git_reset: null,\n        });\n\n        // Invalidate queries and wait for them to complete\n        await Promise.all([\n          queryClient.invalidateQueries({\n            queryKey: ['workspaceSessions', workspaceId],\n          }),\n          queryClient.invalidateQueries({\n            queryKey: ['processes', workspaceId],\n          }),\n          queryClient.invalidateQueries({\n            queryKey: ['branchStatus', workspaceId],\n          }),\n        ]);\n\n        // Navigate to the new session if one was created\n        // Do this after queries are refreshed so the session exists in the list\n        if (creatingNewSession && targetSessionId) {\n          selectSession(targetSessionId);\n        }\n\n        modal.resolve({\n          action: 'resolved',\n          sessionId: creatingNewSession ? targetSessionId : undefined,\n        } as ResolveConflictsDialogResult);\n        modal.hide();\n      } catch (err) {\n        console.error('Failed to resolve conflicts:', err);\n        setError('Failed to start conflict resolution. Please try again.');\n      } finally {\n        setIsSubmitting(false);\n      }\n    }, [\n      effectiveProfile,\n      selectedSessionId,\n      createNewSession,\n      workspaceId,\n      conflictInstructions,\n      queryClient,\n      selectSession,\n      modal,\n    ]);\n\n    const handleCancel = useCallback(() => {\n      modal.resolve({ action: 'cancelled' } as ResolveConflictsDialogResult);\n      modal.hide();\n    }, [modal]);\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) handleCancel();\n    };\n\n    const handleNewSessionChange = (checked: boolean) => {\n      setCreateNewSession(checked);\n      // Reset to default profile when toggling back to existing session\n      if (!checked && resolvedDefaultProfile) {\n        setUserSelectedProfile(resolvedDefaultProfile);\n      }\n    };\n\n    const hasExistingSession = Boolean(selectedSessionId);\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-[500px]\">\n          <DialogHeader>\n            <DialogTitle>\n              {t('resolveConflicts.dialog.title', 'Resolve Conflicts')}\n            </DialogTitle>\n            <DialogDescription>\n              {t(\n                'resolveConflicts.dialog.description',\n                'Conflicts were detected. Choose how you want the agent to resolve them.'\n              )}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            {/* Conflict summary */}\n            <div className=\"rounded-md border border-warning/40 bg-warning/10 p-3 text-sm\">\n              <p className=\"font-medium text-warning-foreground dark:text-warning\">\n                {t('resolveConflicts.dialog.filesWithConflicts', {\n                  count: conflictedFiles.length,\n                })}\n              </p>\n              {conflictedFiles.length > 0 && (\n                <ul className=\"mt-2 space-y-1 text-xs text-warning-foreground/80 dark:text-warning/80\">\n                  {conflictedFiles.slice(0, 5).map((file) => (\n                    <li key={file} className=\"truncate\">\n                      {file}\n                    </li>\n                  ))}\n                  {conflictedFiles.length > 5 && (\n                    <li className=\"text-warning-foreground/60 dark:text-warning/60\">\n                      {t('resolveConflicts.dialog.andMore', {\n                        count: conflictedFiles.length - 5,\n                      })}\n                    </li>\n                  )}\n                </ul>\n              )}\n            </div>\n\n            {error && <div className=\"text-sm text-destructive\">{error}</div>}\n\n            {/* Agent/profile selector - only show when creating new session */}\n            {profiles && createNewSession && (\n              <div className=\"flex gap-3 flex-col sm:flex-row\">\n                <AgentSelector\n                  profiles={profiles}\n                  selectedExecutorProfile={effectiveProfile}\n                  onChange={setUserSelectedProfile}\n                  showLabel={false}\n                />\n                <ConfigSelector\n                  profiles={profiles}\n                  selectedExecutorProfile={effectiveProfile}\n                  onChange={setUserSelectedProfile}\n                  showLabel={false}\n                />\n              </div>\n            )}\n          </div>\n\n          <DialogFooter className=\"sm:!justify-between\">\n            <Button\n              variant=\"outline\"\n              onClick={handleCancel}\n              disabled={isSubmitting}\n            >\n              {t('common:buttons.cancel')}\n            </Button>\n            <div className=\"flex items-center gap-3\">\n              {hasExistingSession && (\n                <div className=\"flex items-center gap-2\">\n                  <Switch\n                    id=\"new-session-switch\"\n                    checked={createNewSession}\n                    onCheckedChange={handleNewSessionChange}\n                    aria-label={t(\n                      'resolveConflicts.dialog.newSession',\n                      'New Session'\n                    )}\n                  />\n                  <Label\n                    htmlFor=\"new-session-switch\"\n                    className=\"text-sm cursor-pointer\"\n                  >\n                    {t('resolveConflicts.dialog.newSession', 'New Session')}\n                  </Label>\n                </div>\n              )}\n              <Button onClick={handleSubmit} disabled={!canSubmit}>\n                {isSubmitting\n                  ? t('resolveConflicts.dialog.resolving', 'Starting...')\n                  : t('resolveConflicts.dialog.resolve', 'Resolve Conflicts')}\n              </Button>\n            </div>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const ResolveConflictsDialog = defineModal<\n  ResolveConflictsDialogProps,\n  ResolveConflictsDialogResult\n>(ResolveConflictsDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/tasks/RestoreLogsDialog.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { Button } from '@vibe/ui/components/Button';\nimport { AlertTriangle, GitCommit, Loader2 } from 'lucide-react';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { useKeySubmitTask } from '@/shared/keyboard/hooks';\nimport { Scope } from '@/shared/keyboard/registry';\nimport { executionProcessesApi } from '@/shared/lib/api';\nimport {\n  isCodingAgent,\n  PROCESS_RUN_REASONS,\n  shouldShowInLogs,\n} from '@/shared/constants/processes';\nimport type {\n  ExecutionProcess,\n  ExecutionProcessRepoState,\n  RepoBranchStatus,\n} from 'shared/types';\n\nexport interface RestoreLogsDialogProps {\n  executionProcessId: string;\n  branchStatus: RepoBranchStatus[] | undefined;\n  processes: ExecutionProcess[] | undefined;\n  initialWorktreeResetOn?: boolean;\n  initialForceReset?: boolean;\n  mode?: 'retry' | 'reset';\n}\n\nexport type RestoreLogsDialogResult = {\n  action: 'confirmed' | 'canceled';\n  performGitReset?: boolean;\n  forceWhenDirty?: boolean;\n};\n\nconst RestoreLogsDialogImpl = create<RestoreLogsDialogProps>(\n  ({\n    executionProcessId,\n    branchStatus,\n    processes,\n    initialWorktreeResetOn = false,\n    initialForceReset = false,\n    mode = 'retry',\n  }) => {\n    const modal = useModal();\n    const { t } = useTranslation(['tasks', 'common']);\n    const [isLoading, setIsLoading] = useState(true);\n    const [worktreeResetOn, setWorktreeResetOn] = useState(\n      initialWorktreeResetOn\n    );\n    const [forceReset, setForceReset] = useState(initialForceReset);\n    const [acknowledgeUncommitted, setAcknowledgeUncommitted] = useState(false);\n\n    // Fetched data - stores all repo states for multi-repo support\n    const [repoStates, setRepoStates] = useState<ExecutionProcessRepoState[]>(\n      []\n    );\n\n    // Fetch execution process repo states\n    useEffect(() => {\n      let cancelled = false;\n      setIsLoading(true);\n\n      (async () => {\n        try {\n          // Fetch repo states for the execution process (supports multi-repo)\n          const states =\n            await executionProcessesApi.getRepoStates(executionProcessId);\n          if (cancelled) return;\n          setRepoStates(states);\n        } finally {\n          if (!cancelled) setIsLoading(false);\n        }\n      })();\n\n      return () => {\n        cancelled = true;\n      };\n    }, [executionProcessId]);\n\n    // Compute processes to be deleted\n    // For retry mode: only processes AFTER target (target itself will be retried)\n    // For reset mode: target process AND all processes after it\n    const { deletedCount, deletedCoding, deletedSetup, deletedCleanup } =\n      useMemo(() => {\n        const procs = (processes || []).filter(\n          (p) => !p.dropped && shouldShowInLogs(p.run_reason)\n        );\n        const idx = procs.findIndex((p) => p.id === executionProcessId);\n        // For reset mode, include the target process; for retry, only later processes\n        const startIdx = mode === 'reset' ? idx : idx + 1;\n        const toDelete = idx >= 0 ? procs.slice(startIdx) : [];\n        return {\n          deletedCount: toDelete.length,\n          deletedCoding: toDelete.filter((p) => isCodingAgent(p.run_reason))\n            .length,\n          deletedSetup: toDelete.filter(\n            (p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT\n          ).length,\n          deletedCleanup: toDelete.filter(\n            (p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT\n          ).length,\n        };\n      }, [processes, executionProcessId, mode]);\n\n    // Join repo states with branch status to get repo names and compute aggregated values\n    const repoInfo = useMemo(() => {\n      return repoStates.map((state) => {\n        const bs = branchStatus?.find((b) => b.repo_id === state.repo_id);\n        return {\n          repoId: state.repo_id,\n          repoName: bs?.repo_name ?? state.repo_id,\n          targetSha: state.before_head_commit,\n          headOid: bs?.head_oid ?? null,\n          hasUncommitted: bs?.has_uncommitted_changes ?? false,\n          uncommittedCount: bs?.uncommitted_count ?? 0,\n          untrackedCount: bs?.untracked_count ?? 0,\n        };\n      });\n    }, [repoStates, branchStatus]);\n\n    // Aggregate values across all repos\n    const anyDirty = repoInfo.some(\n      (r) => r.hasUncommitted || r.untrackedCount > 0\n    );\n\n    const totalUncommitted = repoInfo.reduce(\n      (sum, r) => sum + r.uncommittedCount,\n      0\n    );\n    const totalUntracked = repoInfo.reduce(\n      (sum, r) => sum + r.untrackedCount,\n      0\n    );\n    const anyNeedsReset = repoInfo.some(\n      (r) =>\n        r.targetSha &&\n        (r.targetSha !== r.headOid || r.hasUncommitted || r.untrackedCount > 0)\n    );\n    const needGitReset = anyNeedsReset;\n    const canGitReset = needGitReset && !anyDirty;\n    const hasRisk = anyDirty;\n\n    const hasProcessesToDelete = deletedCount > 0;\n    const repoCount = repoInfo.length;\n\n    const isConfirmDisabled =\n      isLoading ||\n      (anyDirty && !acknowledgeUncommitted) ||\n      (hasRisk && worktreeResetOn && needGitReset && !forceReset);\n\n    const handleConfirm = () => {\n      modal.resolve({\n        action: 'confirmed',\n        performGitReset: worktreeResetOn,\n        forceWhenDirty: forceReset,\n      } as RestoreLogsDialogResult);\n      modal.hide();\n    };\n\n    const handleCancel = () => {\n      modal.resolve({ action: 'canceled' } as RestoreLogsDialogResult);\n      modal.hide();\n    };\n\n    const handleOpenChange = (open: boolean) => {\n      if (!open) {\n        handleCancel();\n      }\n    };\n\n    // CMD+Enter to confirm\n    useKeySubmitTask(handleConfirm, {\n      scope: Scope.DIALOG,\n      when: modal.visible && !isConfirmDisabled,\n    });\n\n    return (\n      <Dialog open={modal.visible} onOpenChange={handleOpenChange}>\n        <DialogContent\n          className=\"max-h-[92vh] sm:max-h-[88vh] overflow-y-auto overflow-x-hidden\"\n          onKeyDownCapture={(e) => {\n            if (e.key === 'Escape') {\n              e.stopPropagation();\n              handleCancel();\n            }\n          }}\n        >\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2 mb-3 md:mb-4\">\n              <AlertTriangle className=\"h-4 w-4 text-destructive\" />{' '}\n              {mode === 'reset'\n                ? t('restoreLogsDialog.titleReset')\n                : t('restoreLogsDialog.title')}\n            </DialogTitle>\n            <div className=\"mt-6 break-words text-sm text-muted-foreground\">\n              {isLoading ? (\n                <div className=\"flex items-center justify-center py-8\">\n                  <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n                </div>\n              ) : (\n                <div className=\"space-y-3\">\n                  {hasProcessesToDelete && (\n                    <div className=\"flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3\">\n                      <AlertTriangle className=\"h-4 w-4 text-destructive mt-0.5\" />\n                      <div className=\"text-sm min-w-0 w-full break-words\">\n                        <p className=\"font-medium text-destructive mb-2\">\n                          {t('restoreLogsDialog.historyChange.title')}\n                        </p>\n                        <>\n                          <p className=\"mt-0.5\">\n                            {mode === 'reset' ? (\n                              t(\n                                'restoreLogsDialog.historyChange.willDeleteProcesses',\n                                {\n                                  count: deletedCount,\n                                }\n                              )\n                            ) : (\n                              <>\n                                {t(\n                                  'restoreLogsDialog.historyChange.willDelete'\n                                )}\n                                {deletedCount > 0 && (\n                                  <>\n                                    {' '}\n                                    {t(\n                                      'restoreLogsDialog.historyChange.andLaterProcesses',\n                                      { count: deletedCount }\n                                    )}\n                                  </>\n                                )}\n                              </>\n                            )}{' '}\n                            {t('restoreLogsDialog.historyChange.fromHistory')}\n                          </p>\n                          <ul className=\"mt-1 text-xs text-muted-foreground list-disc pl-5\">\n                            {deletedCoding > 0 && (\n                              <li>\n                                {t(\n                                  'restoreLogsDialog.historyChange.codingAgentRuns',\n                                  { count: deletedCoding }\n                                )}\n                              </li>\n                            )}\n                            {deletedSetup + deletedCleanup > 0 && (\n                              <li>\n                                {t(\n                                  'restoreLogsDialog.historyChange.scriptProcesses',\n                                  { count: deletedSetup + deletedCleanup }\n                                )}\n                                {deletedSetup > 0 && deletedCleanup > 0 && (\n                                  <>\n                                    {' '}\n                                    {t(\n                                      'restoreLogsDialog.historyChange.setupCleanupBreakdown',\n                                      {\n                                        setup: deletedSetup,\n                                        cleanup: deletedCleanup,\n                                      }\n                                    )}\n                                  </>\n                                )}\n                              </li>\n                            )}\n                          </ul>\n                        </>\n                        <p className=\"mt-1 text-xs text-muted-foreground\">\n                          {t(\n                            'restoreLogsDialog.historyChange.permanentWarning'\n                          )}\n                        </p>\n                      </div>\n                    </div>\n                  )}\n\n                  {anyDirty && (\n                    <div className=\"flex items-start gap-3 rounded-md border border-amber-300/60 bg-amber-50/70 dark:border-amber-400/30 dark:bg-amber-900/20 p-3\">\n                      <AlertTriangle className=\"h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5\" />\n                      <div className=\"text-sm min-w-0 w-full break-words\">\n                        <p className=\"font-medium text-amber-700 dark:text-amber-300\">\n                          {t('restoreLogsDialog.uncommittedChanges.title')}\n                        </p>\n                        <p className=\"mt-1 text-xs text-muted-foreground\">\n                          {t(\n                            'restoreLogsDialog.uncommittedChanges.description',\n                            {\n                              count: totalUncommitted,\n                            }\n                          )}\n                          {totalUntracked > 0 &&\n                            t(\n                              'restoreLogsDialog.uncommittedChanges.andUntracked',\n                              {\n                                count: totalUntracked,\n                              }\n                            )}\n                          .\n                        </p>\n                        <div\n                          className=\"mt-2 w-full flex items-center cursor-pointer select-none\"\n                          role=\"switch\"\n                          aria-checked={acknowledgeUncommitted}\n                          onClick={() => setAcknowledgeUncommitted((v) => !v)}\n                        >\n                          <div className=\"text-xs text-muted-foreground flex-1 min-w-0 break-words\">\n                            {t(\n                              'restoreLogsDialog.uncommittedChanges.acknowledgeLabel'\n                            )}\n                          </div>\n                          <div className=\"ml-auto relative inline-flex h-5 w-9 items-center rounded-full\">\n                            <span\n                              className={\n                                (acknowledgeUncommitted\n                                  ? 'bg-amber-500'\n                                  : 'bg-panel') +\n                                ' absolute inset-0 rounded-full transition-colors'\n                              }\n                            />\n                            <span\n                              className={\n                                (acknowledgeUncommitted\n                                  ? 'translate-x-5'\n                                  : 'translate-x-1') +\n                                ' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'\n                              }\n                            />\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n\n                  {needGitReset && canGitReset && (\n                    <div\n                      className={\n                        !worktreeResetOn\n                          ? 'flex items-start gap-3 rounded-md border p-3'\n                          : hasRisk\n                            ? 'flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3'\n                            : 'flex items-start gap-3 rounded-md border p-3 border-amber-300/60 bg-amber-50/70 dark:border-amber-400/30 dark:bg-amber-900/20'\n                      }\n                    >\n                      <AlertTriangle\n                        className={\n                          !worktreeResetOn\n                            ? 'h-4 w-4 text-muted-foreground mt-0.5'\n                            : hasRisk\n                              ? 'h-4 w-4 text-destructive mt-0.5'\n                              : 'h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5'\n                        }\n                      />\n                      <div className=\"text-sm min-w-0 w-full break-words\">\n                        <p className=\"font-medium mb-2\">\n                          {t('restoreLogsDialog.resetWorktree.title')}\n                          {repoCount > 1 && ` (${repoCount} repos)`}\n                        </p>\n                        <div\n                          className=\"mt-2 w-full flex items-center cursor-pointer select-none\"\n                          role=\"switch\"\n                          aria-checked={worktreeResetOn}\n                          onClick={() => setWorktreeResetOn((v) => !v)}\n                        >\n                          <div className=\"text-xs text-muted-foreground flex-1 min-w-0 break-words\">\n                            {worktreeResetOn\n                              ? t('restoreLogsDialog.resetWorktree.enabled')\n                              : t('restoreLogsDialog.resetWorktree.disabled')}\n                          </div>\n                          <div className=\"ml-auto relative inline-flex h-5 w-9 items-center rounded-full\">\n                            <span\n                              className={\n                                (worktreeResetOn\n                                  ? 'bg-emerald-500'\n                                  : 'bg-panel') +\n                                ' absolute inset-0 rounded-full transition-colors'\n                              }\n                            />\n                            <span\n                              className={\n                                (worktreeResetOn\n                                  ? 'translate-x-5'\n                                  : 'translate-x-1') +\n                                ' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'\n                              }\n                            />\n                          </div>\n                        </div>\n                        {worktreeResetOn && (\n                          <>\n                            <p className=\"mt-2 text-xs text-muted-foreground\">\n                              {t(\n                                'restoreLogsDialog.resetWorktree.restoreDescription'\n                              )}\n                            </p>\n                            <div className=\"mt-1 space-y-1\">\n                              {repoInfo.map((repo) => (\n                                <div\n                                  key={repo.repoId}\n                                  className=\"flex flex-wrap items-center gap-2 min-w-0\"\n                                >\n                                  <GitCommit className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                  {repoCount > 1 && (\n                                    <span className=\"text-xs text-muted-foreground\">\n                                      {repo.repoName}:\n                                    </span>\n                                  )}\n                                  {repo.targetSha && (\n                                    <span className=\"font-mono text-xs px-2 py-0.5 rounded bg-muted\">\n                                      {repo.targetSha.slice(0, 7)}\n                                    </span>\n                                  )}\n                                </div>\n                              ))}\n                            </div>\n                            {(totalUncommitted > 0 || totalUntracked > 0) && (\n                              <ul className=\"mt-2 space-y-1 text-xs text-muted-foreground list-disc pl-5\">\n                                {totalUncommitted > 0 && (\n                                  <li>\n                                    {t(\n                                      'restoreLogsDialog.resetWorktree.discardChanges',\n                                      { count: totalUncommitted }\n                                    )}\n                                  </li>\n                                )}\n                                {totalUntracked > 0 && (\n                                  <li>\n                                    {t(\n                                      'restoreLogsDialog.resetWorktree.untrackedPresent',\n                                      { count: totalUntracked }\n                                    )}\n                                  </li>\n                                )}\n                              </ul>\n                            )}\n                          </>\n                        )}\n                      </div>\n                    </div>\n                  )}\n\n                  {needGitReset && !canGitReset && (\n                    <div\n                      className={\n                        forceReset && worktreeResetOn\n                          ? 'flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3'\n                          : 'flex items-start gap-3 rounded-md border p-3'\n                      }\n                    >\n                      <AlertTriangle className=\"h-4 w-4 text-destructive mt-0.5\" />\n                      <div className=\"text-sm min-w-0 w-full break-words\">\n                        <p className=\"font-medium text-destructive\">\n                          {t('restoreLogsDialog.resetWorktree.title')}\n                          {repoCount > 1 && ` (${repoCount} repos)`}\n                        </p>\n                        <div\n                          className={`mt-2 w-full flex items-center select-none cursor-pointer`}\n                          role=\"switch\"\n                          onClick={() => {\n                            setWorktreeResetOn((on) => {\n                              if (forceReset) return !on; // free toggle when forced\n                              // Without force, only allow explicitly disabling reset\n                              return false;\n                            });\n                          }}\n                        >\n                          <div className=\"text-xs text-muted-foreground flex-1 min-w-0 break-words\">\n                            {forceReset\n                              ? worktreeResetOn\n                                ? t('restoreLogsDialog.resetWorktree.enabled')\n                                : t('restoreLogsDialog.resetWorktree.disabled')\n                              : t(\n                                  'restoreLogsDialog.resetWorktree.disabledUncommitted'\n                                )}\n                          </div>\n                          <div className=\"ml-auto relative inline-flex h-5 w-9 items-center rounded-full\">\n                            <span\n                              className={\n                                (worktreeResetOn && forceReset\n                                  ? 'bg-emerald-500'\n                                  : 'bg-panel') +\n                                ' absolute inset-0 rounded-full transition-colors'\n                              }\n                            />\n                            <span\n                              className={\n                                (worktreeResetOn && forceReset\n                                  ? 'translate-x-5'\n                                  : 'translate-x-1') +\n                                ' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'\n                              }\n                            />\n                          </div>\n                        </div>\n                        <div\n                          className=\"mt-2 w-full flex items-center cursor-pointer select-none\"\n                          role=\"switch\"\n                          onClick={() => {\n                            setForceReset((v) => {\n                              const next = !v;\n                              if (next) setWorktreeResetOn(true);\n                              return next;\n                            });\n                          }}\n                        >\n                          <div className=\"text-xs font-medium text-destructive flex-1 min-w-0 break-words\">\n                            {t('restoreLogsDialog.resetWorktree.forceReset')}\n                          </div>\n                          <div className=\"ml-auto relative inline-flex h-5 w-9 items-center rounded-full\">\n                            <span\n                              className={\n                                (forceReset ? 'bg-destructive' : 'bg-panel') +\n                                ' absolute inset-0 rounded-full transition-colors'\n                              }\n                            />\n                            <span\n                              className={\n                                (forceReset\n                                  ? 'translate-x-5'\n                                  : 'translate-x-1') +\n                                ' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'\n                              }\n                            />\n                          </div>\n                        </div>\n                        <p className=\"mt-2 text-xs text-muted-foreground\">\n                          {forceReset\n                            ? t(\n                                'restoreLogsDialog.resetWorktree.uncommittedWillDiscard'\n                              )\n                            : t(\n                                'restoreLogsDialog.resetWorktree.uncommittedPresentHint'\n                              )}\n                        </p>\n                        {repoInfo.length > 0 && (\n                          <>\n                            <p className=\"mt-2 text-xs text-muted-foreground\">\n                              {t(\n                                'restoreLogsDialog.resetWorktree.restoreDescription'\n                              )}\n                            </p>\n                            <div className=\"mt-1 space-y-1\">\n                              {repoInfo.map((repo) => (\n                                <div\n                                  key={repo.repoId}\n                                  className=\"flex flex-wrap items-center gap-2 min-w-0\"\n                                >\n                                  <GitCommit className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                  {repoCount > 1 && (\n                                    <span className=\"text-xs text-muted-foreground\">\n                                      {repo.repoName}:\n                                    </span>\n                                  )}\n                                  {repo.targetSha && (\n                                    <span className=\"font-mono text-xs px-2 py-0.5 rounded bg-muted\">\n                                      {repo.targetSha.slice(0, 7)}\n                                    </span>\n                                  )}\n                                </div>\n                              ))}\n                            </div>\n                          </>\n                        )}\n                      </div>\n                    </div>\n                  )}\n                </div>\n              )}\n            </div>\n          </DialogHeader>\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={handleCancel}>\n              {t('common:buttons.cancel')}\n            </Button>\n            <Button\n              variant=\"destructive\"\n              disabled={isConfirmDisabled}\n              onClick={handleConfirm}\n            >\n              {mode === 'reset'\n                ? t('restoreLogsDialog.buttons.reset')\n                : t('restoreLogsDialog.buttons.retry')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nexport const RestoreLogsDialog = defineModal<\n  RestoreLogsDialogProps,\n  RestoreLogsDialogResult\n>(RestoreLogsDialogImpl);\n"
  },
  {
    "path": "packages/web-core/src/shared/dialogs/wysiwyg/ImagePreviewDialog.tsx",
    "content": "import { useState } from 'react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@vibe/ui/components/KeyboardDialog';\nimport { create, useModal } from '@ebay/nice-modal-react';\nimport { Loader2 } from 'lucide-react';\nimport { defineModal } from '@/shared/lib/modals';\nimport { formatFileSize } from '@/shared/lib/utils';\n\nexport interface ImagePreviewDialogProps {\n  imageUrl: string;\n  altText: string;\n  fileName?: string;\n  format?: string;\n  sizeBytes?: bigint | null;\n}\n\nconst ImagePreviewDialogImpl = create<ImagePreviewDialogProps>((props) => {\n  const modal = useModal();\n  const { imageUrl, altText, fileName, format, sizeBytes } = props;\n  const [imageLoaded, setImageLoaded] = useState(false);\n\n  const handleClose = () => {\n    modal.hide();\n  };\n\n  // Build metadata string\n  const metadataParts: string[] = [];\n  if (format) {\n    metadataParts.push(format.toUpperCase());\n  }\n  const sizeStr = formatFileSize(sizeBytes);\n  if (sizeStr) {\n    metadataParts.push(sizeStr);\n  }\n  const metadataLine = metadataParts.join(' · ');\n\n  return (\n    <Dialog open={modal.visible} onOpenChange={handleClose}>\n      <DialogContent className=\"w-full max-w-5xl p-0 overflow-hidden\">\n        {fileName && (\n          <DialogHeader className=\"px-4 pt-4 pb-0\">\n            <DialogTitle className=\"truncate\">{fileName}</DialogTitle>\n          </DialogHeader>\n        )}\n        <div className=\"relative flex items-center justify-center min-h-[220px] max-h-[76vh] px-4 pb-4\">\n          {!imageLoaded && (\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <Loader2 className=\"w-8 h-8 text-muted-foreground animate-spin\" />\n            </div>\n          )}\n          <img\n            src={imageUrl}\n            alt={altText}\n            className={`max-w-full max-h-[70vh] object-contain ${\n              imageLoaded ? 'opacity-100' : 'opacity-0'\n            }`}\n            onLoad={() => setImageLoaded(true)}\n          />\n        </div>\n        {metadataLine && (\n          <DialogFooter className=\"px-4 py-3 border-t sm:justify-start\">\n            <p className=\"text-xs text-muted-foreground\">{metadataLine}</p>\n          </DialogFooter>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nexport const ImagePreviewDialog = defineModal<ImagePreviewDialogProps, void>(\n  ImagePreviewDialogImpl\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/ApprovalForm.tsx",
    "content": "import { useContext, useState, ReactNode, useCallback } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\n\ninterface ApprovalFormState {\n  isEnteringReason: boolean;\n  denyReason: string;\n}\n\ninterface ApprovalFormStateMap {\n  [approvalId: string]: ApprovalFormState;\n}\n\ninterface ApprovalFormContextType {\n  getState: (approvalId: string) => ApprovalFormState;\n  setState: (approvalId: string, partial: Partial<ApprovalFormState>) => void;\n  clear: (approvalId: string) => void;\n}\n\nconst ApprovalFormContext = createHmrContext<ApprovalFormContextType | null>(\n  'ApprovalFormContext',\n  null\n);\n\nconst defaultState: ApprovalFormState = {\n  isEnteringReason: false,\n  denyReason: '',\n};\n\nexport function useApprovalForm(approvalId: string) {\n  const context = useContext(ApprovalFormContext);\n  if (!context) {\n    throw new Error('useApprovalForm must be used within ApprovalFormProvider');\n  }\n\n  const state = context.getState(approvalId);\n\n  const setIsEnteringReason = useCallback(\n    (value: boolean) =>\n      context.setState(approvalId, { isEnteringReason: value }),\n    [approvalId, context]\n  );\n\n  const setDenyReason = useCallback(\n    (value: string) => context.setState(approvalId, { denyReason: value }),\n    [approvalId, context]\n  );\n\n  const clear = useCallback(\n    () => context.clear(approvalId),\n    [approvalId, context]\n  );\n\n  return {\n    isEnteringReason: state.isEnteringReason,\n    denyReason: state.denyReason,\n    setIsEnteringReason,\n    setDenyReason,\n    clear,\n  };\n}\n\nexport function ApprovalFormProvider({ children }: { children: ReactNode }) {\n  const [stateMap, setStateMap] = useState<ApprovalFormStateMap>({});\n\n  const getState = useCallback(\n    (approvalId: string): ApprovalFormState => {\n      return stateMap[approvalId] ?? defaultState;\n    },\n    [stateMap]\n  );\n\n  const setState = useCallback(\n    (approvalId: string, partial: Partial<ApprovalFormState>) => {\n      setStateMap((prev) => {\n        const current = prev[approvalId] ?? defaultState;\n        const updated = { ...current, ...partial };\n        return { ...prev, [approvalId]: updated };\n      });\n    },\n    []\n  );\n\n  const clear = useCallback((approvalId: string) => {\n    setStateMap((prev) => {\n      const newMap = { ...prev };\n      delete newMap[approvalId];\n      return newMap;\n    });\n  }, []);\n\n  return (\n    <ApprovalFormContext.Provider\n      value={{\n        getState,\n        setState,\n        clear,\n      }}\n    >\n      {children}\n    </ApprovalFormContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/ChangesViewProvider.tsx",
    "content": "import React, { useState, useCallback, useMemo, useRef } from 'react';\nimport {\n  useUiPreferencesStore,\n  RIGHT_MAIN_PANEL_MODES,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { useWorkspaceDiffContext } from '@/shared/hooks/useWorkspaceContext';\nimport {\n  ChangesViewContext,\n  ChangesViewActionsContext,\n  type ScrollToFileCallback,\n} from '@/shared/hooks/useChangesView';\n\ninterface ChangesViewProviderProps {\n  children: React.ReactNode;\n}\n\nexport function ChangesViewProvider({ children }: ChangesViewProviderProps) {\n  const { diffPaths } = useWorkspaceDiffContext();\n  const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);\n  const [selectedLineNumber, setSelectedLineNumber] = useState<number | null>(\n    null\n  );\n  const [fileInView, setFileInView] = useState<string | null>(null);\n  const { setRightMainPanelMode } = useUiPreferencesStore();\n\n  const scrollToFileCallbackRef = useRef<ScrollToFileCallback | null>(null);\n  const diffPathsRef = useRef(diffPaths);\n  diffPathsRef.current = diffPaths;\n\n  const registerScrollToFile = useCallback(\n    (callback: ScrollToFileCallback | null) => {\n      scrollToFileCallbackRef.current = callback;\n    },\n    []\n  );\n\n  const selectFile = useCallback((path: string, lineNumber?: number) => {\n    setSelectedFilePath(path);\n    setSelectedLineNumber(lineNumber ?? null);\n    setFileInView(path);\n  }, []);\n\n  const scrollToFile = useCallback(\n    (path: string, lineNumber?: number) => {\n      if (scrollToFileCallbackRef.current) {\n        scrollToFileCallbackRef.current(path, lineNumber);\n      } else {\n        selectFile(path, lineNumber);\n      }\n    },\n    [selectFile]\n  );\n\n  const viewFileInChanges = useCallback(\n    (filePath: string) => {\n      setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.CHANGES);\n      setSelectedFilePath(filePath);\n    },\n    [setRightMainPanelMode]\n  );\n\n  const findMatchingDiffPath = useCallback((text: string): string | null => {\n    const currentDiffPaths = diffPathsRef.current;\n    if (currentDiffPaths.has(text)) return text;\n    for (const fullPath of currentDiffPaths) {\n      if (fullPath.endsWith('/' + text)) {\n        return fullPath;\n      }\n    }\n    return null;\n  }, []);\n\n  const hasDiffPath = useCallback((path: string): boolean => {\n    return diffPathsRef.current.has(path);\n  }, []);\n\n  const actionsValue = useMemo(\n    () => ({ viewFileInChanges, findMatchingDiffPath, hasDiffPath }),\n    [viewFileInChanges, findMatchingDiffPath, hasDiffPath]\n  );\n\n  const value = useMemo(\n    () => ({\n      selectedFilePath,\n      selectedLineNumber,\n      fileInView,\n      selectFile,\n      scrollToFile,\n      setFileInView,\n      viewFileInChanges,\n      diffPaths,\n      findMatchingDiffPath,\n      registerScrollToFile,\n    }),\n    [\n      selectedFilePath,\n      selectedLineNumber,\n      fileInView,\n      selectFile,\n      scrollToFile,\n      viewFileInChanges,\n      diffPaths,\n      findMatchingDiffPath,\n      registerScrollToFile,\n    ]\n  );\n\n  return (\n    <ChangesViewActionsContext.Provider value={actionsValue}>\n      <ChangesViewContext.Provider value={value}>\n        {children}\n      </ChangesViewContext.Provider>\n    </ChangesViewActionsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/GitOperationsContext.tsx",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\n\ntype GitOperationsContextType = {\n  error: string | null;\n  setError: (error: string | null) => void;\n};\n\nconst GitOperationsContext = createHmrContext<GitOperationsContextType | null>(\n  'GitOperationsContext',\n  null\n);\n\nexport const GitOperationsProvider: React.FC<{\n  workspaceId: string | undefined;\n  children: React.ReactNode;\n}> = ({ workspaceId, children }) => {\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    setError(null);\n  }, [workspaceId]);\n\n  return (\n    <GitOperationsContext.Provider value={{ error, setError }}>\n      {children}\n    </GitOperationsContext.Provider>\n  );\n};\n\nexport const useGitOperationsError = () => {\n  const ctx = useContext(GitOperationsContext);\n  if (!ctx) {\n    throw new Error(\n      'useGitOperationsError must be used within GitOperationsProvider'\n    );\n  }\n  return ctx;\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/ProcessSelectionContext.tsx",
    "content": "import { useContext, useState, useMemo, ReactNode } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\n\ninterface ProcessSelectionContextType {\n  selectedProcessId: string | null;\n  setSelectedProcessId: (id: string | null) => void;\n}\n\nconst ProcessSelectionContext =\n  createHmrContext<ProcessSelectionContextType | null>(\n    'ProcessSelectionContext',\n    null\n  );\n\ninterface ProcessSelectionProviderProps {\n  children: ReactNode;\n  initialProcessId?: string | null;\n}\n\nexport function ProcessSelectionProvider({\n  children,\n  initialProcessId = null,\n}: ProcessSelectionProviderProps) {\n  const [selectedProcessId, setSelectedProcessId] = useState<string | null>(\n    initialProcessId\n  );\n\n  const value = useMemo(\n    () => ({\n      selectedProcessId,\n      setSelectedProcessId,\n    }),\n    [selectedProcessId, setSelectedProcessId]\n  );\n\n  return (\n    <ProcessSelectionContext.Provider value={value}>\n      {children}\n    </ProcessSelectionContext.Provider>\n  );\n}\n\nexport const useProcessSelection = () => {\n  const context = useContext(ProcessSelectionContext);\n  if (!context) {\n    throw new Error(\n      'useProcessSelection must be used within ProcessSelectionProvider'\n    );\n  }\n  return context;\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/ReviewProvider.tsx",
    "content": "import { useState, ReactNode, useEffect, useCallback } from 'react';\nimport { genId } from '@/shared/lib/id';\nimport {\n  ReviewContext,\n  type ReviewComment,\n  type ReviewDraft,\n} from '@/shared/hooks/useReview';\n\nexport function ReviewProvider({\n  children,\n  workspaceId,\n}: {\n  children: ReactNode;\n  workspaceId?: string;\n}) {\n  const [comments, setComments] = useState<ReviewComment[]>([]);\n  const [drafts, setDrafts] = useState<Record<string, ReviewDraft>>({});\n\n  useEffect(() => {\n    return () => clearComments();\n  }, [workspaceId]);\n\n  const addComment = (comment: Omit<ReviewComment, 'id'>) => {\n    const newComment: ReviewComment = {\n      ...comment,\n      id: genId(),\n    };\n    setComments((prev) => [...prev, newComment]);\n  };\n\n  const updateComment = (id: string, text: string) => {\n    setComments((prev) =>\n      prev.map((comment) =>\n        comment.id === id ? { ...comment, text } : comment\n      )\n    );\n  };\n\n  const deleteComment = (id: string) => {\n    setComments((prev) => prev.filter((comment) => comment.id !== id));\n  };\n\n  const clearComments = () => {\n    setComments([]);\n    setDrafts({});\n  };\n\n  const setDraft = (key: string, draft: ReviewDraft | null) => {\n    setDrafts((prev) => {\n      if (draft === null) {\n        const newDrafts = { ...prev };\n        delete newDrafts[key];\n        return newDrafts;\n      }\n      return { ...prev, [key]: draft };\n    });\n  };\n\n  const generateReviewMarkdown = useCallback(() => {\n    if (comments.length === 0) return '';\n\n    const commentsNum = comments.length;\n\n    const header = `## Review Comments (${commentsNum})\\n\\n`;\n    const formatCodeLine = (line?: string) => {\n      if (!line) return '';\n      if (line.includes('`')) {\n        return `\\`\\`\\`\\n${line}\\n\\`\\`\\``;\n      }\n      return `\\`${line}\\``;\n    };\n\n    const commentsMd = comments\n      .map((comment) => {\n        const codeLine = formatCodeLine(comment.codeLine);\n        // Format file paths in comment body with backticks\n        const bodyWithFormattedPaths = comment.text\n          .trim()\n          .replace(/([/\\\\]?[\\w.-]+(?:[/\\\\][\\w.-]+)+)/g, '`$1`');\n        if (codeLine) {\n          return `**${comment.filePath}** (Line ${comment.lineNumber})\\n${codeLine}\\n\\n> ${bodyWithFormattedPaths}\\n`;\n        }\n        return `**${comment.filePath}** (Line ${comment.lineNumber})\\n\\n> ${bodyWithFormattedPaths}\\n`;\n      })\n      .join('\\n');\n\n    return header + commentsMd;\n  }, [comments]);\n\n  return (\n    <ReviewContext.Provider\n      value={{\n        comments,\n        drafts,\n        addComment,\n        updateComment,\n        deleteComment,\n        clearComments,\n        setDraft,\n        generateReviewMarkdown,\n      }}\n    >\n      {children}\n    </ReviewContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/TabNavigationContext.tsx",
    "content": "import { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { TabType } from '@/shared/types/tabs';\n\ninterface TabNavContextType {\n  activeTab: TabType;\n  setActiveTab: (tab: TabType) => void;\n}\n\nexport const TabNavContext = createHmrContext<TabNavContextType | null>(\n  'TabNavContext',\n  null\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/auth/useAuth.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\n\nexport interface AuthContextValue {\n  isSignedIn: boolean;\n  isLoaded: boolean;\n  userId: string | null;\n}\n\nexport const AuthContext = createHmrContext<AuthContextValue | undefined>(\n  'AuthContext',\n  undefined\n);\n\nexport function useAuth(): AuthContextValue {\n  const context = useContext(AuthContext);\n  if (context === undefined) {\n    throw new Error('useAuth must be used within an AuthProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/auth/useAuthMutations.ts",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { oauthApi } from '@/shared/lib/api';\n\ninterface UseAuthMutationsOptions {\n  onInitSuccess?: (data: { handoff_id: string; authorize_url: string }) => void;\n  onInitError?: (err: unknown) => void;\n}\n\nexport function useAuthMutations(options?: UseAuthMutationsOptions) {\n  const initHandoff = useMutation({\n    mutationKey: ['auth', 'init'],\n    mutationFn: ({\n      provider,\n      returnTo,\n    }: {\n      provider: string;\n      returnTo: string;\n    }) => oauthApi.handoffInit(provider, returnTo),\n    onSuccess: (data) => {\n      options?.onInitSuccess?.(data);\n    },\n    onError: (err) => {\n      console.error('Failed to initialize OAuth handoff:', err);\n      options?.onInitError?.(err);\n    },\n  });\n\n  return {\n    initHandoff,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/auth/useAuthStatus.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { oauthApi } from '@/shared/lib/api';\nimport { useEffect } from 'react';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\n\ninterface UseAuthStatusOptions {\n  enabled: boolean;\n}\n\nexport function useAuthStatus(options: UseAuthStatusOptions) {\n  const query = useQuery({\n    queryKey: ['auth', 'status'],\n    queryFn: () => oauthApi.status(),\n    enabled: options.enabled,\n    refetchInterval: options.enabled ? 1000 : false,\n    retry: 3,\n    staleTime: 0, // Always fetch fresh data when enabled\n  });\n\n  const { isSignedIn } = useAuth();\n  useEffect(() => {\n    if (query) {\n      query.refetch();\n    }\n  }, [isSignedIn, query]);\n\n  return query;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/auth/useCurrentUser.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { getAuthRuntime } from '@/shared/lib/auth/runtime';\nimport { useEffect } from 'react';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\n\nexport function useCurrentUser() {\n  const { isSignedIn } = useAuth();\n  const query = useQuery({\n    queryKey: ['auth', 'user'],\n    queryFn: () => getAuthRuntime().getCurrentUser(),\n    retry: 2,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n    refetchOnWindowFocus: false,\n    refetchOnReconnect: false,\n  });\n\n  const queryClient = useQueryClient();\n  useEffect(() => {\n    queryClient.invalidateQueries({ queryKey: ['auth', 'user'] });\n  }, [queryClient, isSignedIn]);\n\n  return query;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/organizationKeys.ts",
    "content": "export const organizationKeys = {\n  all: ['organizations'] as const,\n  userList: () => ['organizations', 'user-list'] as const,\n  byId: (orgId: string) => ['organizations', orgId] as const,\n  projects: (orgId: string) => ['organizations', orgId, 'projects'] as const,\n  members: (orgId: string) => ['organizations', orgId, 'members'] as const,\n  invitations: (orgId: string) =>\n    ['organizations', orgId, 'invitations'] as const,\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useActionVisibilityContext.ts",
    "content": "import { useMemo } from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport {\n  useUiPreferencesStore,\n  useWorkspacePanelState,\n  type LayoutMode,\n} from '@/shared/stores/useUiPreferencesStore';\nimport {\n  useDiffViewStore,\n  useDiffViewMode,\n} from '@/shared/stores/useDiffViewStore';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport { useDevServer } from '@/shared/hooks/useDevServer';\nimport { useBranchStatus } from '@/shared/hooks/useBranchStatus';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport { useExecutionProcessesContext } from '@/shared/hooks/useExecutionProcessesContext';\nimport { useLogsPanel } from '@/shared/hooks/useLogsPanel';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { isProjectDestination } from '@/shared/lib/routes/appNavigation';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\nimport { useCurrentKanbanRouteState } from '@/shared/hooks/useCurrentKanbanRouteState';\nimport { PROJECT_ISSUES_SHAPE } from 'shared/remote-types';\nimport type { Merge } from 'shared/types';\nimport type {\n  ActionVisibilityContext,\n  DevServerState,\n} from '@/shared/types/actions';\n\ninterface ActionVisibilityOptions {\n  projectId?: string;\n  issueIds?: string[];\n}\n\n/**\n * Hook that builds the visibility context from stores/context.\n * Used by both NavbarContainer and CommandBarDialog to evaluate\n * action visibility and state conditions.\n */\nexport function useActionVisibilityContext(\n  options?: ActionVisibilityOptions\n): ActionVisibilityContext {\n  const { workspace, workspaceId, isCreateMode, repos } = useWorkspaceContext();\n  // Use workspace-specific panel state (pass undefined when in create mode)\n  const panelState = useWorkspacePanelState(\n    isCreateMode ? undefined : workspaceId\n  );\n  const diffPaths = useDiffViewStore((s) => s.diffPaths);\n  const diffViewMode = useDiffViewMode();\n  const expanded = useUiPreferencesStore((s) => s.expanded);\n\n  // Derive kanban state from URL (URL is single source of truth)\n  const { projectId: routeProjectId, issueId: routeIssueId } = useParams({\n    strict: false,\n  });\n  const destination = useCurrentAppDestination();\n  const { isCreateMode: kanbanCreateMode } = useCurrentKanbanRouteState();\n  const effectiveProjectId = options?.projectId ?? routeProjectId;\n  const optionIssueIds = options?.issueIds;\n  const effectiveIssueIds = useMemo(\n    () => optionIssueIds ?? (routeIssueId ? [routeIssueId] : []),\n    [optionIssueIds, routeIssueId]\n  );\n  const hasSelectedKanbanIssue = effectiveIssueIds.length > 0;\n  const shouldResolveSelectedIssueParent =\n    !!effectiveProjectId && effectiveIssueIds.length === 1;\n\n  const projectIssuesParams = useMemo(\n    () => ({ project_id: effectiveProjectId ?? '' }),\n    [effectiveProjectId]\n  );\n  const { data: projectIssues } = useShape(\n    PROJECT_ISSUES_SHAPE,\n    projectIssuesParams,\n    {\n      enabled: shouldResolveSelectedIssueParent,\n    }\n  );\n  const hasSelectedKanbanIssueParent = useMemo(() => {\n    if (!shouldResolveSelectedIssueParent) return false;\n    const selectedIssue = projectIssues.find(\n      (issue) => issue.id === effectiveIssueIds[0]\n    );\n    return !!selectedIssue?.parent_issue_id;\n  }, [shouldResolveSelectedIssueParent, projectIssues, effectiveIssueIds]);\n\n  // Derive layoutMode from current route instead of persisted state\n  const layoutMode: LayoutMode = isProjectDestination(destination)\n    ? 'kanban'\n    : destination?.kind === 'migrate'\n      ? 'migrate'\n      : 'workspaces';\n  const { config } = useUserSystem();\n  const { isStarting, isStopping, runningDevServers } =\n    useDevServer(workspaceId);\n  const { data: branchStatus } = useBranchStatus(workspaceId);\n  const { isAttemptRunningVisible } = useExecutionProcessesContext();\n  const { logsPanelContent } = useLogsPanel();\n  const { isSignedIn } = useAuth();\n\n  return useMemo(() => {\n    // Compute isAllDiffsExpanded\n    const diffKeys = diffPaths.map((p) => `diff:${p}`);\n    const isAllDiffsExpanded =\n      diffKeys.length > 0 && diffKeys.every((k) => expanded[k] !== false);\n\n    // Compute dev server state\n    const devServerState: DevServerState = isStarting\n      ? 'starting'\n      : isStopping\n        ? 'stopping'\n        : runningDevServers.length > 0\n          ? 'running'\n          : 'stopped';\n\n    // Compute git state from branch status\n    const hasOpenPR =\n      branchStatus?.some((repo) =>\n        repo.merges?.some(\n          (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open'\n        )\n      ) ?? false;\n\n    const hasUnpushedCommits =\n      branchStatus?.some((repo) => (repo.remote_commits_ahead ?? 0) > 0) ??\n      false;\n\n    return {\n      layoutMode,\n      rightMainPanelMode: panelState.rightMainPanelMode,\n      isLeftSidebarVisible: panelState.isLeftSidebarVisible,\n      isLeftMainPanelVisible: panelState.isLeftMainPanelVisible,\n      isRightSidebarVisible: panelState.isRightSidebarVisible,\n      isCreateMode,\n      hasWorkspace: !!workspace,\n      workspaceArchived: workspace?.archived ?? false,\n      hasDiffs: diffPaths.length > 0,\n      diffViewMode,\n      isAllDiffsExpanded,\n      editorType: config?.editor?.editor_type ?? null,\n      devServerState,\n      runningDevServers,\n      hasGitRepos: repos.length > 0,\n      hasMultipleRepos: repos.length > 1,\n      hasOpenPR,\n      hasUnpushedCommits,\n      isAttemptRunning: isAttemptRunningVisible,\n      logsPanelContent,\n      hasSelectedKanbanIssue,\n      hasSelectedKanbanIssueParent,\n      isCreatingIssue: kanbanCreateMode,\n      isSignedIn,\n    };\n  }, [\n    layoutMode,\n    panelState.rightMainPanelMode,\n    panelState.isLeftSidebarVisible,\n    panelState.isLeftMainPanelVisible,\n    panelState.isRightSidebarVisible,\n    isCreateMode,\n    workspace,\n    repos,\n    diffPaths,\n    diffViewMode,\n    expanded,\n    config?.editor?.editor_type,\n    isStarting,\n    isStopping,\n    runningDevServers,\n    branchStatus,\n    isAttemptRunningVisible,\n    logsPanelContent,\n    hasSelectedKanbanIssue,\n    hasSelectedKanbanIssueParent,\n    kanbanCreateMode,\n    isSignedIn,\n  ]);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useActions.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { Workspace } from 'shared/types';\nimport type {\n  ActionDefinition,\n  ActionExecutorContext,\n  ActionVisibilityContext,\n  ProjectMutations,\n} from '@/shared/types/actions';\n\nexport interface ActionsContextValue {\n  // Execute an action with optional workspaceId and repoId/projectId\n  // For git actions: repoIdOrProjectId is repoId\n  // For issue actions: repoIdOrProjectId is projectId, issueIds are required\n  executeAction: (\n    action: ActionDefinition,\n    workspaceId?: string,\n    repoIdOrProjectId?: string,\n    issueIds?: string[]\n  ) => Promise<void>;\n\n  // Get resolved label for an action (supports dynamic labels via visibility context)\n  getLabel: (\n    action: ActionDefinition,\n    workspace?: Workspace,\n    ctx?: ActionVisibilityContext\n  ) => string;\n\n  // Open command bar in status selection mode\n  openStatusSelection: (projectId: string, issueIds: string[]) => Promise<void>;\n\n  // Open command bar in priority selection mode\n  openPrioritySelection: (\n    projectId: string,\n    issueIds: string[]\n  ) => Promise<void>;\n\n  // Open assignee selection dialog\n  openAssigneeSelection: (\n    projectId: string,\n    issueIds: string[],\n    isCreateMode?: boolean\n  ) => Promise<void>;\n\n  // Open sub-issue selection in command bar\n  openSubIssueSelection: (\n    projectId: string,\n    parentIssueId: string,\n    mode?: 'addChild' | 'setParent'\n  ) => Promise<{ type: string } | undefined>;\n\n  // Open workspace selection dialog to link a workspace to an issue\n  openWorkspaceSelection: (projectId: string, issueId: string) => Promise<void>;\n\n  // Open relationship selection in command bar\n  openRelationshipSelection: (\n    projectId: string,\n    issueId: string,\n    relationshipType: 'blocking' | 'related' | 'has_duplicate',\n    direction: 'forward' | 'reverse'\n  ) => Promise<void>;\n\n  // Set default status for issue creation based on current kanban tab\n  setDefaultCreateStatusId: (statusId: string | undefined) => void;\n\n  // Register project mutations (called by components inside ProjectProvider)\n  registerProjectMutations: (mutations: ProjectMutations | null) => void;\n\n  // The executor context (for components that need direct access)\n  executorContext: ActionExecutorContext;\n}\n\nexport const ActionsContext = createHmrContext<ActionsContextValue | null>(\n  'ActionsContext',\n  null\n);\n\nexport function useActions(): ActionsContextValue {\n  const context = useContext(ActionsContext);\n  if (!context) {\n    throw new Error('useActions must be used within an ActionsProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useAllOrganizationProjects.ts",
    "content": "import { useState, useEffect, useMemo } from 'react';\nimport { createShapeCollection } from '@/shared/lib/electric/collections';\nimport { PROJECTS_SHAPE, type Project } from 'shared/remote-types';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { useUserOrganizations } from '@/shared/hooks/useUserOrganizations';\n\ninterface UseAllOrganizationProjectsOptions {\n  enabled?: boolean;\n}\n\n/**\n * Hook that fetches remote projects across ALL user organizations.\n * Uses the raw collection API (createShapeCollection + subscribeChanges)\n * to avoid calling useShape in a loop (which would violate React hooks rules).\n *\n * Collections are cached by createShapeCollection (5-min GC),\n * so no duplicate syncs if the same org's projects are subscribed elsewhere.\n */\nexport function useAllOrganizationProjects(\n  options: UseAllOrganizationProjectsOptions = {}\n) {\n  const { enabled = true } = options;\n  const { isSignedIn } = useAuth();\n  const { data: orgsData } = useUserOrganizations();\n\n  // Stable org IDs list — only recompute when orgsData changes\n  const orgIds = useMemo(\n    () => (orgsData?.organizations ?? []).map((o) => o.id),\n    [orgsData?.organizations]\n  );\n\n  const [projects, setProjects] = useState<Project[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n\n  useEffect(() => {\n    if (!enabled || !isSignedIn || orgIds.length === 0) {\n      setProjects([]);\n      setIsLoading(false);\n      return;\n    }\n\n    setIsLoading(true);\n\n    const subscriptions: { unsubscribe: () => void }[] = [];\n    const projectsByOrg = new Map<string, Project[]>();\n\n    const updateAggregated = () => {\n      setProjects(Array.from(projectsByOrg.values()).flat());\n    };\n\n    for (const orgId of orgIds) {\n      const collection = createShapeCollection(PROJECTS_SHAPE, {\n        organization_id: orgId,\n      });\n\n      // Read initial data if already synced\n      if (collection.isReady()) {\n        projectsByOrg.set(orgId, collection.toArray as unknown as Project[]);\n      }\n\n      // Subscribe to live changes\n      const sub = collection.subscribeChanges(\n        () => {\n          projectsByOrg.set(orgId, collection.toArray as unknown as Project[]);\n          updateAggregated();\n          setIsLoading(false);\n        },\n        { includeInitialState: true }\n      );\n      subscriptions.push(sub);\n    }\n\n    // Initial aggregation from any already-ready collections\n    updateAggregated();\n\n    // Check if all collections are already ready\n    const allReady = orgIds.every((id) => {\n      const col = createShapeCollection(PROJECTS_SHAPE, {\n        organization_id: id,\n      });\n      return col.isReady();\n    });\n    if (allReady) {\n      setIsLoading(false);\n    }\n\n    return () => {\n      subscriptions.forEach((s) => s.unsubscribe());\n    };\n  }, [enabled, isSignedIn, orgIds]);\n\n  return { data: projects, isLoading };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useAppNavigation.ts",
    "content": "import { createElement, type ReactNode, useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { AppNavigation } from '@/shared/lib/routes/appNavigation';\n\nconst AppNavigationContext = createHmrContext<AppNavigation | undefined>(\n  'AppNavigationContext',\n  undefined\n);\n\nexport function AppNavigationProvider({\n  value,\n  children,\n}: {\n  value: AppNavigation;\n  children: ReactNode;\n}) {\n  return createElement(AppNavigationContext.Provider, { value }, children);\n}\n\nexport function useAppNavigation(): AppNavigation {\n  const appNavigation = useContext(AppNavigationContext);\n\n  if (!appNavigation) {\n    throw new Error(\n      'useAppNavigation must be used within an AppNavigationProvider'\n    );\n  }\n\n  return appNavigation;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useAppRuntime.tsx",
    "content": "import { type ReactNode, useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\n\nexport type AppRuntime = 'local' | 'remote';\n\nconst AppRuntimeContext = createHmrContext<AppRuntime | undefined>(\n  'AppRuntimeContext',\n  undefined\n);\n\nexport function AppRuntimeProvider({\n  runtime,\n  children,\n}: {\n  runtime: AppRuntime;\n  children: ReactNode;\n}) {\n  return (\n    <AppRuntimeContext.Provider value={runtime}>\n      {children}\n    </AppRuntimeContext.Provider>\n  );\n}\n\nexport function useAppRuntime(): AppRuntime {\n  const runtime = useContext(AppRuntimeContext);\n\n  if (!runtime) {\n    throw new Error('useAppRuntime must be used within an AppRuntimeProvider');\n  }\n\n  return runtime;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useApprovals.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport type { ApprovalInfo } from 'shared/types';\nimport { useJsonPatchWsStream } from './useJsonPatchWsStream';\n\ninterface UseApprovalsResult {\n  pendingApprovals: ApprovalInfo[];\n  getPendingForProcess: (executionProcessId: string) => ApprovalInfo | null;\n  getPendingById: (approvalId: string) => ApprovalInfo | null;\n  isConnected: boolean;\n}\n\ntype ApprovalState = {\n  pending: Record<string, ApprovalInfo>;\n};\n\nexport function useApprovals(): UseApprovalsResult {\n  const { data, isConnected } = useJsonPatchWsStream<ApprovalState>(\n    '/api/approvals/stream/ws',\n    true,\n    () => ({ pending: {} })\n  );\n\n  const pendingById = useMemo(() => data?.pending ?? {}, [data?.pending]);\n  const pendingApprovals = useMemo(\n    () => Object.values(pendingById),\n    [pendingById]\n  );\n\n  const getPendingForProcess = useCallback(\n    (executionProcessId: string): ApprovalInfo | null => {\n      for (const info of pendingApprovals) {\n        if (info.execution_process_id === executionProcessId) {\n          return info;\n        }\n      }\n      return null;\n    },\n    [pendingApprovals]\n  );\n\n  const getPendingById = useCallback(\n    (approvalId: string): ApprovalInfo | null => {\n      return pendingById[approvalId] ?? null;\n    },\n    [pendingById]\n  );\n\n  return {\n    pendingApprovals,\n    getPendingForProcess,\n    getPendingById,\n    isConnected,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useAttachmentUrl.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { fetchAttachmentSasUrl } from '@/shared/lib/remoteApi';\n\nconst SAS_URL_STALE_TIME = 4 * 60 * 1000; // 4 minutes, matches SAS URL TTL\n\ninterface AttachmentUrlResult {\n  url: string | null;\n  loading: boolean;\n  error: string | null;\n}\n\nexport function useAttachmentUrl(\n  attachmentId: string | null,\n  type: 'file' | 'thumbnail'\n): AttachmentUrlResult {\n  const { data, isLoading, error } = useQuery({\n    queryKey: ['attachment-url', attachmentId, type],\n    queryFn: () => fetchAttachmentSasUrl(attachmentId!, type),\n    enabled: !!attachmentId,\n    staleTime: SAS_URL_STALE_TIME,\n  });\n\n  return {\n    url: data ?? null,\n    loading: isLoading,\n    error: error\n      ? error instanceof Error\n        ? error.message\n        : 'Failed to load attachment'\n      : null,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useAzureAttachments.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { LocalAttachmentMetadata } from '@vibe/ui/components/WorkspaceContext';\nimport {\n  computeFileHash,\n  confirmAttachmentUpload,\n  deleteAttachment,\n  initAttachmentUpload,\n  uploadToAzure,\n} from '@/shared/lib/remoteApi';\nimport { buildAttachmentMarkdown } from '@/shared/lib/workspaceAttachments';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PendingAttachment {\n  file: File;\n  progress: number;\n  status: 'hashing' | 'uploading' | 'confirming';\n}\n\nexport interface CompletedAttachment {\n  id: string;\n  filename: string;\n  blob_id: string;\n}\n\ninterface UseAzureAttachmentsOptions {\n  projectId: string;\n  issueId?: string;\n  commentId?: string;\n  onMarkdownInsert?: (\n    markdown: string,\n    options?: { persist?: boolean }\n  ) => void;\n  onAttachmentSourceReplace?: (\n    previousSrc: string,\n    nextSrc: string,\n    options?: { persist?: boolean }\n  ) => boolean;\n  onAttachmentSourceRemove?: (\n    src: string,\n    options?: { persist?: boolean }\n  ) => boolean;\n  onError?: (message: string) => void;\n}\n\ninterface UseAzureAttachmentsReturn {\n  uploadFiles: (files: File[]) => Promise<void>;\n  pendingAttachments: PendingAttachment[];\n  completedAttachments: CompletedAttachment[];\n  getAttachmentIds: () => string[];\n  clearAttachments: () => void;\n  isUploading: boolean;\n  hasPendingAttachments: boolean;\n  uploadError: string | null;\n  clearUploadError: () => void;\n  localAttachments: LocalAttachmentMetadata[];\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB\nconst MAX_BATCH_SIZE = 10;\nconst PENDING_ATTACHMENT_PREFIX = 'pending-attachment://';\n\ntype PendingAttachmentLocal = {\n  tempSrc: string;\n  objectUrl: string;\n  markdown: string;\n  file: File;\n};\n\nfunction createPendingAttachmentId(): string {\n  if (\n    typeof crypto !== 'undefined' &&\n    typeof crypto.randomUUID === 'function'\n  ) {\n    return crypto.randomUUID();\n  }\n\n  return `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n}\n\nfunction inferFormat(file: File): string {\n  const extension = file.name.split('.').pop()?.trim();\n  if (extension && extension !== file.name) {\n    return extension.toLowerCase();\n  }\n\n  return file.type.split('/')[1] ?? 'bin';\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function useAzureAttachments({\n  projectId,\n  issueId,\n  commentId,\n  onMarkdownInsert,\n  onAttachmentSourceReplace,\n  onAttachmentSourceRemove,\n  onError,\n}: UseAzureAttachmentsOptions): UseAzureAttachmentsReturn {\n  const { t } = useTranslation('common');\n  const [pendingAttachments, setPendingAttachments] = useState<\n    PendingAttachment[]\n  >([]);\n  const [localAttachments, setLocalAttachments] = useState<\n    LocalAttachmentMetadata[]\n  >([]);\n  const [completedAttachments, setCompletedAttachments] = useState<\n    CompletedAttachment[]\n  >([]);\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n  const localObjectsRef = useRef<Map<string, string>>(new Map());\n  const pendingCountRef = useRef(0);\n\n  // Avoid stale closures — these may change during async upload\n  const issueIdRef = useRef(issueId);\n  issueIdRef.current = issueId;\n  const commentIdRef = useRef(commentId);\n  commentIdRef.current = commentId;\n  const onErrorRef = useRef(onError);\n  onErrorRef.current = onError;\n  const onMarkdownInsertRef = useRef(onMarkdownInsert);\n  onMarkdownInsertRef.current = onMarkdownInsert;\n  const onAttachmentSourceReplaceRef = useRef(onAttachmentSourceReplace);\n  onAttachmentSourceReplaceRef.current = onAttachmentSourceReplace;\n  const onAttachmentSourceRemoveRef = useRef(onAttachmentSourceRemove);\n  onAttachmentSourceRemoveRef.current = onAttachmentSourceRemove;\n\n  useEffect(() => {\n    pendingCountRef.current = pendingAttachments.length;\n  }, [pendingAttachments.length]);\n\n  useEffect(() => {\n    return () => {\n      for (const objectUrl of localObjectsRef.current.values()) {\n        URL.revokeObjectURL(objectUrl);\n      }\n      localObjectsRef.current.clear();\n    };\n  }, []);\n\n  const uploadFiles = useCallback(\n    async (files: File[]) => {\n      const reportError = onErrorRef.current ?? console.error;\n      const reportErrorMessage = (message: string) => {\n        setUploadError(message);\n        reportError(message);\n      };\n\n      setUploadError(null);\n\n      if (files.length > MAX_BATCH_SIZE) {\n        reportErrorMessage(\n          t('kanban.maxFilesAtOnce', { count: MAX_BATCH_SIZE })\n        );\n        return;\n      }\n\n      const validFiles: File[] = [];\n      for (const file of files) {\n        if (file.size > MAX_FILE_SIZE) {\n          reportErrorMessage(\n            t('kanban.fileExceedsLimit', { filename: file.name })\n          );\n          continue;\n        }\n        validFiles.push(file);\n      }\n\n      if (validFiles.length === 0) return;\n\n      setIsUploading(true);\n\n      const pendingLocals: PendingAttachmentLocal[] = validFiles.map((file) => {\n        const pendingId = createPendingAttachmentId();\n        const tempSrc = `${PENDING_ATTACHMENT_PREFIX}${pendingId}`;\n        const objectUrl = URL.createObjectURL(file);\n        localObjectsRef.current.set(tempSrc, objectUrl);\n        return {\n          tempSrc,\n          objectUrl,\n          markdown: buildAttachmentMarkdown({\n            name: file.name,\n            src: tempSrc,\n            mimeType: file.type || null,\n          }),\n          file,\n        };\n      });\n\n      setPendingAttachments((prev) => [\n        ...prev,\n        ...pendingLocals.map(({ file }) => ({\n          file,\n          progress: 0,\n          status: 'hashing' as const,\n        })),\n      ]);\n      setLocalAttachments((prev) => [\n        ...prev,\n        ...pendingLocals.map(({ tempSrc, objectUrl, file }) => ({\n          path: tempSrc,\n          proxy_url: objectUrl,\n          file_name: file.name,\n          size_bytes: file.size,\n          format: inferFormat(file),\n          mime_type: file.type || 'application/octet-stream',\n          is_pending: true,\n          pending_status: 'hashing' as const,\n          upload_progress: 0,\n        })),\n      ]);\n      onMarkdownInsertRef.current?.(\n        pendingLocals.map((local) => local.markdown).join('\\n\\n'),\n        { persist: false }\n      );\n\n      for (const pendingLocal of pendingLocals) {\n        const { file, tempSrc } = pendingLocal;\n\n        try {\n          const hash = await computeFileHash(file);\n\n          setPendingAttachments((prev) =>\n            prev.map((p) =>\n              p.file === file ? { ...p, status: 'uploading', progress: 0 } : p\n            )\n          );\n          setLocalAttachments((prev) =>\n            prev.map((localFile) =>\n              localFile.path === tempSrc\n                ? {\n                    ...localFile,\n                    pending_status: 'uploading',\n                    upload_progress: 0,\n                  }\n                : localFile\n            )\n          );\n\n          const initResult = await initAttachmentUpload({\n            project_id: projectId,\n            filename: file.name,\n            size_bytes: file.size,\n            hash,\n          });\n\n          if (!initResult.skip_upload) {\n            await uploadToAzure(initResult.upload_url, file, (pct) => {\n              setPendingAttachments((prev) =>\n                prev.map((p) => (p.file === file ? { ...p, progress: pct } : p))\n              );\n              setLocalAttachments((prev) =>\n                prev.map((localFile) =>\n                  localFile.path === tempSrc\n                    ? { ...localFile, upload_progress: pct }\n                    : localFile\n                )\n              );\n            });\n          }\n\n          setPendingAttachments((prev) =>\n            prev.map((p) =>\n              p.file === file\n                ? { ...p, status: 'confirming', progress: 100 }\n                : p\n            )\n          );\n          setLocalAttachments((prev) =>\n            prev.map((localFile) =>\n              localFile.path === tempSrc\n                ? {\n                    ...localFile,\n                    pending_status: 'confirming',\n                    upload_progress: 100,\n                  }\n                : localFile\n            )\n          );\n\n          const result = await confirmAttachmentUpload({\n            project_id: projectId,\n            upload_id: initResult.upload_id,\n            filename: file.name,\n            content_type: file.type,\n            size_bytes: file.size,\n            hash,\n            issue_id: issueIdRef.current,\n            comment_id: commentIdRef.current,\n          });\n\n          setCompletedAttachments((prev) => [\n            ...prev,\n            { id: result.id, filename: file.name, blob_id: result.blob_id },\n          ]);\n\n          setPendingAttachments((prev) => prev.filter((p) => p.file !== file));\n          const finalSrc = `attachment://${result.id}`;\n          setLocalAttachments((prev) =>\n            prev.map((localFile) =>\n              localFile.path === tempSrc\n                ? {\n                    ...localFile,\n                    path: finalSrc,\n                    is_pending: false,\n                    pending_status: undefined,\n                    upload_progress: undefined,\n                  }\n                : localFile\n            )\n          );\n          localObjectsRef.current.delete(tempSrc);\n          localObjectsRef.current.set(finalSrc, pendingLocal.objectUrl);\n\n          const replaced = onAttachmentSourceReplaceRef.current?.(\n            tempSrc,\n            finalSrc,\n            {\n              persist: pendingCountRef.current <= 1,\n            }\n          );\n          if (replaced === false) {\n            setCompletedAttachments((prev) =>\n              prev.filter((attachment) => attachment.id !== result.id)\n            );\n            setLocalAttachments((prev) =>\n              prev.filter((localFile) => localFile.path !== finalSrc)\n            );\n            const objectUrl = localObjectsRef.current.get(finalSrc);\n            if (objectUrl) {\n              URL.revokeObjectURL(objectUrl);\n              localObjectsRef.current.delete(finalSrc);\n            }\n            deleteAttachment(result.id).catch((error) => {\n              console.error('Failed to delete abandoned attachment:', error);\n            });\n          }\n        } catch (error) {\n          const message =\n            error instanceof Error ? error.message : t('kanban.unknownError');\n          reportErrorMessage(\n            t('kanban.failedToUploadFile', {\n              filename: file.name,\n              message,\n            })\n          );\n          setPendingAttachments((prev) => prev.filter((p) => p.file !== file));\n          setLocalAttachments((prev) =>\n            prev.filter((localFile) => localFile.path !== tempSrc)\n          );\n          const objectUrl = localObjectsRef.current.get(tempSrc);\n          if (objectUrl) {\n            URL.revokeObjectURL(objectUrl);\n            localObjectsRef.current.delete(tempSrc);\n          }\n          onAttachmentSourceRemoveRef.current?.(tempSrc, {\n            persist: pendingCountRef.current <= 1,\n          });\n        }\n      }\n\n      setIsUploading(false);\n    },\n    [projectId, t]\n  );\n\n  const getAttachmentIds = useCallback(\n    () => completedAttachments.map((a) => a.id),\n    [completedAttachments]\n  );\n\n  const clearAttachments = useCallback(() => {\n    setPendingAttachments([]);\n    setCompletedAttachments([]);\n    setLocalAttachments([]);\n    for (const objectUrl of localObjectsRef.current.values()) {\n      URL.revokeObjectURL(objectUrl);\n    }\n    localObjectsRef.current.clear();\n  }, []);\n\n  const clearUploadError = useCallback(() => {\n    setUploadError(null);\n  }, []);\n\n  return {\n    uploadFiles,\n    pendingAttachments,\n    completedAttachments,\n    getAttachmentIds,\n    clearAttachments,\n    isUploading,\n    hasPendingAttachments: pendingAttachments.length > 0,\n    uploadError,\n    clearUploadError,\n    localAttachments,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useBranchStatus.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\n\nexport function useBranchStatus(workspaceId?: string) {\n  return useQuery({\n    queryKey: ['branchStatus', workspaceId],\n    queryFn: () => workspacesApi.getBranchStatus(workspaceId!),\n    enabled: !!workspaceId,\n    refetchInterval: 5000,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useChangeTargetBranch.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type {\n  ChangeTargetBranchRequest,\n  ChangeTargetBranchResponse,\n} from 'shared/types';\nimport { repoBranchKeys } from '@/shared/hooks/useRepoBranches';\nimport { workspaceRepoKeys } from '@/shared/hooks/useWorkspaceRepo';\n\ntype ChangeTargetBranchParams = {\n  newTargetBranch: string;\n  repoId: string;\n};\n\nexport function useChangeTargetBranch(\n  workspaceId: string | undefined,\n  repoId: string | undefined,\n  onSuccess?: (data: ChangeTargetBranchResponse) => void,\n  onError?: (err: unknown) => void\n) {\n  const queryClient = useQueryClient();\n\n  return useMutation<\n    ChangeTargetBranchResponse,\n    unknown,\n    ChangeTargetBranchParams\n  >({\n    mutationFn: async ({ newTargetBranch, repoId }) => {\n      if (!workspaceId) {\n        throw new Error('Attempt id is not set');\n      }\n\n      const payload: ChangeTargetBranchRequest = {\n        new_target_branch: newTargetBranch,\n        repo_id: repoId,\n      };\n      return workspacesApi.change_target_branch(workspaceId, payload);\n    },\n    onSuccess: (data) => {\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: ['branchStatus', workspaceId],\n        });\n        // Invalidate workspaceWithSession query to refresh attempt.target_branch\n        queryClient.invalidateQueries({\n          queryKey: ['workspaceWithSession', workspaceId],\n        });\n        // Refresh repos to update target_branch in RepoCard\n        queryClient.invalidateQueries({\n          queryKey: workspaceRepoKeys.byWorkspace(workspaceId),\n        });\n      }\n\n      if (repoId) {\n        queryClient.invalidateQueries({\n          queryKey: repoBranchKeys.byRepo(repoId),\n        });\n      }\n\n      onSuccess?.(data);\n    },\n    onError: (err) => {\n      console.error('Failed to change target branch:', err);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: ['branchStatus', workspaceId],\n        });\n      }\n      onError?.(err);\n    },\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useChangesView.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\n\n/** Callback type for scroll-to-file implementation (provided by ChangesPanelContainer) */\nexport type ScrollToFileCallback = (path: string, lineNumber?: number) => void;\n\ninterface ChangesViewContextValue {\n  /** File path selected by user (triggers scroll-to in ChangesPanelContainer) */\n  selectedFilePath: string | null;\n  /** Line number to scroll to within the selected file (for GitHub comment navigation) */\n  selectedLineNumber: number | null;\n  /** File currently in view from scrolling (for FileTree highlighting) */\n  fileInView: string | null;\n  /** Select a file and optionally scroll to a specific line (legacy - use scrollToFile for tree clicks) */\n  selectFile: (path: string, lineNumber?: number) => void;\n  /** Scroll to a file in the diff view (for file tree clicks - uses state machine) */\n  scrollToFile: (path: string, lineNumber?: number) => void;\n  /** Update the file currently in view (from scroll observer) */\n  setFileInView: (path: string | null) => void;\n  /** Navigate to changes mode and scroll to a specific file */\n  viewFileInChanges: (filePath: string) => void;\n  /** Set of file paths currently in the diffs (for checking if inline code should be clickable) */\n  diffPaths: Set<string>;\n  /** Find a diff path matching the given text (supports partial/right-hand match) */\n  findMatchingDiffPath: (text: string) => string | null;\n  /** Register the scroll-to-file callback (called by ChangesPanelContainer) */\n  registerScrollToFile: (callback: ScrollToFileCallback | null) => void;\n}\n\ninterface ChangesViewActionsContextValue {\n  viewFileInChanges: (filePath: string) => void;\n  findMatchingDiffPath: (text: string) => string | null;\n  hasDiffPath: (path: string) => boolean;\n}\n\nconst EMPTY_SET = new Set<string>();\n\nconst defaultValue: ChangesViewContextValue = {\n  selectedFilePath: null,\n  selectedLineNumber: null,\n  fileInView: null,\n  selectFile: () => {},\n  scrollToFile: () => {},\n  setFileInView: () => {},\n  viewFileInChanges: () => {},\n  diffPaths: EMPTY_SET,\n  findMatchingDiffPath: () => null,\n  registerScrollToFile: () => {},\n};\n\nconst defaultActionsValue: ChangesViewActionsContextValue = {\n  viewFileInChanges: () => {},\n  findMatchingDiffPath: () => null,\n  hasDiffPath: () => false,\n};\n\nexport const ChangesViewContext = createHmrContext<ChangesViewContextValue>(\n  'ChangesViewContext',\n  defaultValue\n);\n\nexport const ChangesViewActionsContext =\n  createHmrContext<ChangesViewActionsContextValue>(\n    'ChangesViewActionsContext',\n    defaultActionsValue\n  );\n\nexport function useChangesView(): ChangesViewContextValue {\n  return useContext(ChangesViewContext);\n}\n\nexport function useChangesViewActions(): ChangesViewActionsContextValue {\n  return useContext(ChangesViewActionsContext);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useCommandBarShortcut.ts",
    "content": "import { useEffect, useCallback } from 'react';\n\n/**\n * Hook that listens for CMD+K (Mac) or Ctrl+K (Windows/Linux) to open the command bar.\n * Uses native DOM event listener with capture phase to intercept before other handlers\n * like Lexical editor.\n */\nexport function useCommandBarShortcut(\n  onOpen: () => void,\n  enabled: boolean = true\n) {\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent) => {\n      // CMD+K (Mac) or Ctrl+K (Windows/Linux)\n      const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;\n      const modifier = isMac ? event.metaKey : event.ctrlKey;\n\n      if (modifier && event.key.toLowerCase() === 'k') {\n        event.preventDefault();\n        event.stopPropagation();\n        onOpen();\n      }\n    },\n    [onOpen]\n  );\n\n  useEffect(() => {\n    if (!enabled) return;\n\n    // Use capture phase to intercept before other handlers (like Lexical editor)\n    window.addEventListener('keydown', handleKeyDown, { capture: true });\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown, { capture: true });\n    };\n  }, [handleKeyDown, enabled]);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useContextBarPosition.ts",
    "content": "import {\n  useState,\n  useEffect,\n  useCallback,\n  useRef,\n  type RefObject,\n} from 'react';\nimport {\n  useContextBarPosition as useContextBarPositionStore,\n  type ContextBarPosition,\n} from '@/shared/stores/useUiPreferencesStore';\n\nconst EDGE_PADDING = 16;\n\nexport type SnapPosition = ContextBarPosition;\n\ninterface DragState {\n  isDragging: boolean;\n  startX: number;\n  startY: number;\n  currentX: number;\n  currentY: number;\n}\n\ninterface UseContextBarPositionReturn {\n  position: SnapPosition;\n  style: React.CSSProperties;\n  isDragging: boolean;\n  dragHandlers: {\n    onMouseDown: (e: React.MouseEvent) => void;\n  };\n}\n\nfunction getPositionStyle(\n  position: SnapPosition,\n  _barWidth: number,\n  barHeight: number\n): React.CSSProperties {\n  const halfHeight = barHeight / 2;\n\n  switch (position) {\n    case 'top-left':\n      return { top: EDGE_PADDING, left: EDGE_PADDING };\n    case 'top-right':\n      return { top: EDGE_PADDING, right: EDGE_PADDING };\n    case 'middle-left':\n      return { top: `calc(50% - ${halfHeight}px)`, left: EDGE_PADDING };\n    case 'middle-right':\n      return { top: `calc(50% - ${halfHeight}px)`, right: EDGE_PADDING };\n    case 'bottom-left':\n      return { bottom: EDGE_PADDING, left: EDGE_PADDING };\n    case 'bottom-right':\n      return { bottom: EDGE_PADDING, right: EDGE_PADDING };\n  }\n}\n\nfunction calculateNearestSnapPosition(\n  x: number,\n  y: number,\n  containerWidth: number,\n  containerHeight: number\n): SnapPosition {\n  // Determine left/right based on which half of container\n  const xZone = x < containerWidth / 2 ? 'left' : 'right';\n\n  // Determine top/middle/bottom based on thirds\n  const yZone =\n    y < containerHeight / 3\n      ? 'top'\n      : y > (2 * containerHeight) / 3\n        ? 'bottom'\n        : 'middle';\n\n  return `${yZone}-${xZone}` as SnapPosition;\n}\n\nexport function useContextBarPosition(\n  containerRef: RefObject<HTMLElement | null>,\n  barWidth = 38,\n  barHeight = 177\n): UseContextBarPositionReturn {\n  const [position, setPosition] = useContextBarPositionStore();\n  const [dragState, setDragState] = useState<DragState>({\n    isDragging: false,\n    startX: 0,\n    startY: 0,\n    currentX: 0,\n    currentY: 0,\n  });\n\n  // Store the bar's position relative to the container during drag\n  const barRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });\n  // Store container rect at drag start for consistent calculations\n  const containerRectRef = useRef<DOMRect | null>(null);\n\n  // Handle mouse down on drag handle\n  const handleMouseDown = useCallback(\n    (e: React.MouseEvent) => {\n      e.preventDefault();\n\n      // Get container rect\n      const containerRect = containerRef.current?.getBoundingClientRect();\n      if (!containerRect) return;\n      containerRectRef.current = containerRect;\n\n      // Get current position of the bar relative to container\n      const barElement = e.currentTarget.parentElement\n        ?.parentElement as HTMLElement;\n      const barRect = barElement?.getBoundingClientRect();\n      if (barRect) {\n        // Store position relative to container\n        barRef.current = {\n          x: barRect.left - containerRect.left,\n          y: barRect.top - containerRect.top,\n        };\n      }\n\n      setDragState({\n        isDragging: true,\n        startX: e.clientX,\n        startY: e.clientY,\n        currentX: e.clientX,\n        currentY: e.clientY,\n      });\n    },\n    [containerRef]\n  );\n\n  // Handle mouse move during drag\n  useEffect(() => {\n    if (!dragState.isDragging) return;\n\n    const handleMouseMove = (e: MouseEvent) => {\n      setDragState((prev) => ({\n        ...prev,\n        currentX: e.clientX,\n        currentY: e.clientY,\n      }));\n    };\n\n    const handleMouseUp = (e: MouseEvent) => {\n      const containerRect = containerRef.current?.getBoundingClientRect();\n      if (!containerRect) {\n        setDragState({\n          isDragging: false,\n          startX: 0,\n          startY: 0,\n          currentX: 0,\n          currentY: 0,\n        });\n        return;\n      }\n\n      // Calculate the final position of the bar center relative to container\n      const deltaX = e.clientX - dragState.startX;\n      const deltaY = e.clientY - dragState.startY;\n      const finalX = barRef.current.x + deltaX + barWidth / 2;\n      const finalY = barRef.current.y + deltaY + barHeight / 2;\n\n      // Calculate nearest snap position using container dimensions\n      const newPosition = calculateNearestSnapPosition(\n        finalX,\n        finalY,\n        containerRect.width,\n        containerRect.height\n      );\n\n      setPosition(newPosition);\n      setDragState({\n        isDragging: false,\n        startX: 0,\n        startY: 0,\n        currentX: 0,\n        currentY: 0,\n      });\n    };\n\n    document.addEventListener('mousemove', handleMouseMove);\n    document.addEventListener('mouseup', handleMouseUp);\n\n    return () => {\n      document.removeEventListener('mousemove', handleMouseMove);\n      document.removeEventListener('mouseup', handleMouseUp);\n    };\n  }, [\n    dragState.isDragging,\n    dragState.startX,\n    dragState.startY,\n    barWidth,\n    barHeight,\n    containerRef,\n    setPosition,\n  ]);\n\n  // Calculate style based on position or drag state\n  const style: React.CSSProperties = dragState.isDragging\n    ? {\n        // During drag, use absolute position relative to container\n        left: barRef.current.x + (dragState.currentX - dragState.startX),\n        top: barRef.current.y + (dragState.currentY - dragState.startY),\n      }\n    : getPositionStyle(position, barWidth, barHeight);\n\n  return {\n    position,\n    style,\n    isDragging: dragState.isDragging,\n    dragHandlers: {\n      onMouseDown: handleMouseDown,\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useConversationHistory/constants.ts",
    "content": "import type { PatchTypeWithKey } from './types';\n\nexport const MIN_INITIAL_ENTRIES = 10;\nexport const REMAINING_BATCH_SIZE = 50;\n\nexport const makeLoadingPatch = (\n  executionProcessId: string\n): PatchTypeWithKey => ({\n  type: 'NORMALIZED_ENTRY',\n  content: {\n    entry_type: {\n      type: 'loading',\n    },\n    content: '',\n    timestamp: null,\n  },\n  patchKey: `${executionProcessId}:loading`,\n  executionProcessId,\n});\n\nexport const nextActionPatch: (\n  failed: boolean,\n  execution_processes: number,\n  needs_setup: boolean,\n  setup_help_text?: string\n) => PatchTypeWithKey = (\n  failed,\n  execution_processes,\n  needs_setup,\n  setup_help_text\n) => ({\n  type: 'NORMALIZED_ENTRY',\n  content: {\n    entry_type: {\n      type: 'next_action',\n      failed: failed,\n      execution_processes: execution_processes,\n      needs_setup: needs_setup,\n      setup_help_text: setup_help_text ?? null,\n    },\n    content: '',\n    timestamp: null,\n  },\n  patchKey: 'next_action',\n  executionProcessId: '',\n});\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useConversationHistory/types.ts",
    "content": "import {\n  ExecutionProcess,\n  ExecutorAction,\n  PatchType,\n  Workspace,\n} from 'shared/types';\n\nexport type PatchTypeWithKey = PatchType & {\n  patchKey: string;\n  executionProcessId: string;\n};\n\n/**\n * Aggregation types for tool use entries that can be grouped together.\n */\nexport type ToolAggregationType =\n  | 'file_read'\n  | 'search'\n  | 'web_fetch'\n  | 'command_run_read'\n  | 'command_run_search'\n  | 'command_run_edit'\n  | 'command_run_fetch';\n\n/**\n * A group of consecutive entries of the same aggregatable type (e.g., file_read, search, web_fetch).\n * Used to display multiple read/search/fetch operations in a collapsed accordion style.\n */\nexport type AggregatedPatchGroup = {\n  type: 'AGGREGATED_GROUP';\n  /** The aggregation category (e.g., 'file_read', 'search', 'web_fetch') */\n  aggregationType: ToolAggregationType;\n  /** The individual entries in this group */\n  entries: PatchTypeWithKey[];\n  /** Unique key for the group */\n  patchKey: string;\n  executionProcessId: string;\n};\n\n/**\n * A group of consecutive file_edit entries for the same file path.\n * Used to display multiple edits to the same file in a collapsed accordion style.\n */\nexport type AggregatedDiffGroup = {\n  type: 'AGGREGATED_DIFF_GROUP';\n  /** The file path being edited */\n  filePath: string;\n  /** The individual file_edit entries in this group */\n  entries: PatchTypeWithKey[];\n  /** Unique key for the group */\n  patchKey: string;\n  executionProcessId: string;\n};\n\n/**\n * A group of thinking entries from a previous conversation turn.\n * Used to collapse thinking steps in previous answers for cleaner display.\n */\nexport type AggregatedThinkingGroup = {\n  type: 'AGGREGATED_THINKING_GROUP';\n  /** The individual thinking entries in this group */\n  entries: PatchTypeWithKey[];\n  /** Unique key for the group */\n  patchKey: string;\n  executionProcessId: string;\n};\n\nexport type DisplayEntry =\n  | PatchTypeWithKey\n  | AggregatedPatchGroup\n  | AggregatedDiffGroup\n  | AggregatedThinkingGroup;\n\nexport function isAggregatedGroup(\n  entry: DisplayEntry\n): entry is AggregatedPatchGroup {\n  return entry.type === 'AGGREGATED_GROUP';\n}\n\nexport function isAggregatedDiffGroup(\n  entry: DisplayEntry\n): entry is AggregatedDiffGroup {\n  return entry.type === 'AGGREGATED_DIFF_GROUP';\n}\n\nexport function isAggregatedThinkingGroup(\n  entry: DisplayEntry\n): entry is AggregatedThinkingGroup {\n  return entry.type === 'AGGREGATED_THINKING_GROUP';\n}\n\nexport type AddEntryType = 'initial' | 'running' | 'historic' | 'plan';\n\nexport interface ConversationTimelineSource {\n  executionProcessState: ExecutionProcessStateStore;\n  liveExecutionProcesses: ExecutionProcess[];\n}\n\nexport type OnEntriesUpdated = (\n  newEntries: PatchTypeWithKey[],\n  addType: AddEntryType,\n  loading: boolean\n) => void;\n\nexport type OnTimelineUpdated = (\n  source: ConversationTimelineSource,\n  addType: AddEntryType,\n  loading: boolean\n) => void;\n\nexport type ExecutionProcessStaticInfo = {\n  id: string;\n  created_at: string;\n  updated_at: string;\n  executor_action: ExecutorAction;\n};\n\nexport type ExecutionProcessState = {\n  executionProcess: ExecutionProcessStaticInfo;\n  entries: PatchTypeWithKey[];\n};\n\nexport type ExecutionProcessStateStore = Record<string, ExecutionProcessState>;\n\nexport interface UseConversationHistoryParams {\n  attempt: Workspace;\n  onTimelineUpdated?: OnTimelineUpdated;\n  onEntriesUpdated?: OnEntriesUpdated;\n  scopeKey: string;\n}\n\nexport interface UseConversationHistoryResult {}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useCreateAttachments.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { attachmentsApi } from '@/shared/lib/api';\nimport type { LocalAttachmentMetadata } from '@vibe/ui/components/WorkspaceContext';\nimport {\n  buildWorkspaceAttachmentMarkdown,\n  toLocalAttachmentMetadata,\n} from '@/shared/lib/workspaceAttachments';\nimport type { DraftWorkspaceAttachment } from 'shared/types';\n\n/**\n * Hook for handling attachments during workspace creation.\n * Uploads attachments and tracks their IDs for association with the workspace.\n * Also tracks uploaded attachments for immediate preview in the editor.\n * Supports restoring previously uploaded attachments from a persisted draft.\n */\nexport function useCreateAttachments(\n  onInsertMarkdown: (markdown: string) => void,\n  initialAttachments?: DraftWorkspaceAttachment[],\n  onAttachmentsChange?: (attachments: DraftWorkspaceAttachment[]) => void\n) {\n  const [attachments, setAttachments] = useState<DraftWorkspaceAttachment[]>(\n    initialAttachments ?? []\n  );\n  const hasInitialized = useRef(false);\n\n  useEffect(() => {\n    if (hasInitialized.current) return;\n    if (initialAttachments && initialAttachments.length > 0) {\n      hasInitialized.current = true;\n      setAttachments(initialAttachments);\n    }\n  }, [initialAttachments]);\n\n  useEffect(() => {\n    onAttachmentsChange?.(attachments);\n  }, [attachments, onAttachmentsChange]);\n\n  const uploadFiles = useCallback(\n    async (selectedFiles: File[]) => {\n      const uploadResults: DraftWorkspaceAttachment[] = [];\n\n      for (const attachment of selectedFiles) {\n        try {\n          const response = await attachmentsApi.upload(attachment);\n          uploadResults.push({\n            id: response.id,\n            file_path: response.file_path,\n            original_name: response.original_name,\n            mime_type: response.mime_type,\n            size_bytes: Number(response.size_bytes) as unknown as bigint,\n          });\n        } catch (error) {\n          console.error('Failed to upload attachment:', error);\n        }\n      }\n\n      if (uploadResults.length > 0) {\n        setAttachments((prev) => [...prev, ...uploadResults]);\n        const allMarkdown = uploadResults\n          .map(buildWorkspaceAttachmentMarkdown)\n          .join('\\n\\n');\n        onInsertMarkdown(allMarkdown);\n      }\n    },\n    [onInsertMarkdown]\n  );\n\n  const getAttachmentIds = useCallback(() => {\n    const ids = attachments.map((attachment) => attachment.id);\n    return ids.length > 0 ? ids : null;\n  }, [attachments]);\n\n  const clearAttachments = useCallback(() => setAttachments([]), []);\n\n  const localAttachments: LocalAttachmentMetadata[] = attachments.map(\n    (attachment) =>\n      toLocalAttachmentMetadata({\n        ...attachment,\n        hash: '',\n        created_at: '',\n        updated_at: '',\n      })\n  );\n\n  return {\n    uploadFiles,\n    getAttachmentIds,\n    clearAttachments,\n    localAttachments,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useCreateWorkspace.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { CreateAndStartWorkspaceRequest } from 'shared/types';\nimport { workspaceSummaryKeys } from '@/shared/hooks/workspaceSummaryKeys';\n\ninterface CreateWorkspaceParams {\n  data: CreateAndStartWorkspaceRequest;\n  linkToIssue?: {\n    remoteProjectId: string;\n    issueId: string;\n  };\n}\n\nexport function useCreateWorkspace() {\n  const queryClient = useQueryClient();\n\n  const createWorkspace = useMutation({\n    mutationFn: async ({ data, linkToIssue }: CreateWorkspaceParams) => {\n      const { workspace } = await workspacesApi.createAndStart(data);\n\n      if (linkToIssue && workspace) {\n        try {\n          await workspacesApi.linkToIssue(\n            workspace.id,\n            linkToIssue.remoteProjectId,\n            linkToIssue.issueId\n          );\n        } catch (linkError) {\n          console.error('Failed to link workspace to issue:', linkError);\n        }\n      }\n\n      return { workspace };\n    },\n    onSuccess: () => {\n      // Invalidate workspace summaries so they refresh with the new workspace included\n      queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      // Ensure create-mode defaults refetch the latest session/model selection.\n      queryClient.invalidateQueries({ queryKey: ['workspaceCreateDefaults'] });\n    },\n    onError: (err) => {\n      console.error('Failed to create workspace:', err);\n    },\n  });\n\n  return { createWorkspace };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useCurrentAppDestination.ts",
    "content": "import { useMemo } from 'react';\nimport { useLocation } from '@tanstack/react-router';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport type { AppDestination } from '@/shared/lib/routes/appNavigation';\n\nexport function useCurrentAppDestination(): AppDestination | null {\n  const appNavigation = useAppNavigation();\n  const location = useLocation();\n\n  return useMemo(\n    () => appNavigation.resolveFromPath(location.pathname),\n    [appNavigation, location.pathname]\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useCurrentKanbanRouteState.ts",
    "content": "import { useMemo } from 'react';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\nimport {\n  resolveKanbanRouteState,\n  type KanbanRouteState,\n} from '@/shared/lib/routes/appNavigation';\nimport {\n  buildKanbanIssueComposerKey,\n  useKanbanIssueComposer,\n} from '@/shared/stores/useKanbanIssueComposerStore';\n\nexport function useCurrentKanbanRouteState(): KanbanRouteState {\n  const destination = useCurrentAppDestination();\n  const routeState = useMemo(\n    () => resolveKanbanRouteState(destination),\n    [destination]\n  );\n  const issueComposerKey = useMemo(() => {\n    if (!routeState.projectId) {\n      return null;\n    }\n\n    return buildKanbanIssueComposerKey(routeState.hostId, routeState.projectId);\n  }, [routeState.hostId, routeState.projectId]);\n  const issueComposer = useKanbanIssueComposer(issueComposerKey);\n  const isCreateMode = issueComposer !== null;\n\n  return useMemo(\n    () => ({\n      ...routeState,\n      isCreateMode,\n      isPanelOpen: routeState.isPanelOpen || isCreateMode,\n    }),\n    [routeState, isCreateMode]\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useDebouncedCallback.ts",
    "content": "import { useRef, useEffect } from 'react';\n\n/**\n * Returns a debounced version of the callback that delays invocation\n * until after `delay` milliseconds have elapsed since the last call.\n * Also returns a cancel function to clear any pending invocation.\n */\nexport function useDebouncedCallback<Args extends unknown[]>(\n  callback: (...args: Args) => void,\n  delay: number\n): { debounced: (...args: Args) => void; cancel: () => void } {\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const callbackRef = useRef(callback);\n\n  // Keep callback ref up to date\n  useEffect(() => {\n    callbackRef.current = callback;\n  }, [callback]);\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n      }\n    };\n  }, []);\n\n  // Return stable function reference\n  const debouncedRef = useRef((...args: Args) => {\n    if (timeoutRef.current) {\n      clearTimeout(timeoutRef.current);\n    }\n    timeoutRef.current = setTimeout(() => {\n      callbackRef.current(...args);\n    }, delay);\n  });\n\n  // Cancel function to clear pending timeout\n  const cancelRef = useRef(() => {\n    if (timeoutRef.current) {\n      clearTimeout(timeoutRef.current);\n      timeoutRef.current = null;\n    }\n  });\n\n  return { debounced: debouncedRef.current, cancel: cancelRef.current };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useDevServer.ts",
    "content": "import { useEffect, useMemo, useState } from 'react';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi, executionProcessesApi } from '@/shared/lib/api';\nimport { useWorkspaceExecution } from '@/shared/hooks/useWorkspaceExecution';\nimport {\n  filterRunningDevServers,\n  filterDevServerProcesses,\n  deduplicateDevServersByWorkingDir,\n} from '@/shared/lib/devServerUtils';\nimport { workspaceSummaryKeys } from '@/shared/hooks/workspaceSummaryKeys';\n\ninterface UseDevServerOptions {\n  onStartSuccess?: () => void;\n  onStartError?: (err: unknown) => void;\n  onStopSuccess?: () => void;\n  onStopError?: (err: unknown) => void;\n}\n\nexport function useDevServer(\n  workspaceId: string | undefined,\n  options?: UseDevServerOptions\n) {\n  const queryClient = useQueryClient();\n  const { attemptData } = useWorkspaceExecution(workspaceId);\n\n  const runningDevServers = useMemo(\n    () => filterRunningDevServers(attemptData.processes),\n    [attemptData.processes]\n  );\n\n  const devServerProcesses = useMemo(\n    () =>\n      deduplicateDevServersByWorkingDir(\n        filterDevServerProcesses(attemptData.processes)\n      ),\n    [attemptData.processes]\n  );\n\n  // Track when mutation succeeded but no running process exists yet\n  const [pendingStart, setPendingStart] = useState(false);\n\n  // Clear pendingStart when a running process appears\n  useEffect(() => {\n    if (runningDevServers.length > 0 && pendingStart) {\n      setPendingStart(false);\n    }\n  }, [runningDevServers.length, pendingStart]);\n\n  const startMutation = useMutation({\n    mutationKey: ['startDevServer', workspaceId],\n    mutationFn: async () => {\n      if (!workspaceId) return;\n      await workspacesApi.startDevServer(workspaceId);\n    },\n    onMutate: () => {\n      setPendingStart(true);\n    },\n    onSuccess: async () => {\n      // Don't clear pendingStart here - wait for process to appear via useEffect\n      await queryClient.invalidateQueries({\n        queryKey: ['executionProcesses', workspaceId],\n      });\n      queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      options?.onStartSuccess?.();\n    },\n    onError: (err) => {\n      setPendingStart(false);\n      console.error('Failed to start dev server:', err);\n      options?.onStartError?.(err);\n    },\n  });\n\n  const stopMutation = useMutation({\n    mutationKey: ['stopDevServer', workspaceId],\n    mutationFn: async () => {\n      if (runningDevServers.length === 0) return;\n      await Promise.all(\n        runningDevServers.map((ds) =>\n          executionProcessesApi.stopExecutionProcess(ds.id)\n        )\n      );\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({\n        queryKey: ['executionProcesses', workspaceId],\n      });\n      for (const ds of runningDevServers) {\n        queryClient.invalidateQueries({\n          queryKey: ['processDetails', ds.id],\n        });\n      }\n      queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      options?.onStopSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to stop dev server:', err);\n      options?.onStopError?.(err);\n    },\n  });\n\n  return {\n    start: startMutation.mutate,\n    stop: stopMutation.mutate,\n    isStarting: startMutation.isPending || pendingStart,\n    isStopping: stopMutation.isPending,\n    runningDevServers,\n    devServerProcesses,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useDiffStream.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport type { Diff, PatchType } from 'shared/types';\nimport { useJsonPatchWsStream } from '@/shared/hooks/useJsonPatchWsStream';\n\ninterface DiffEntries {\n  [filePath: string]: PatchType;\n}\n\ntype DiffStreamEvent = {\n  entries: DiffEntries;\n};\n\nexport interface UseDiffStreamOptions {\n  statsOnly?: boolean;\n}\n\ninterface UseDiffStreamResult {\n  diffs: Diff[];\n  error: string | null;\n  isInitialized: boolean;\n}\n\nexport const useDiffStream = (\n  workspaceId: string | null,\n  enabled: boolean,\n  options?: UseDiffStreamOptions\n): UseDiffStreamResult => {\n  const endpoint = (() => {\n    if (!workspaceId) return undefined;\n    const query = `/api/workspaces/${workspaceId}/git/diff/ws`;\n    if (typeof options?.statsOnly === 'boolean') {\n      const params = new URLSearchParams();\n      params.set('stats_only', String(options.statsOnly));\n      return `${query}?${params.toString()}`;\n    } else {\n      return query;\n    }\n  })();\n\n  const initialData = useCallback(\n    (): DiffStreamEvent => ({\n      entries: {},\n    }),\n    []\n  );\n\n  const { data, error, isInitialized } = useJsonPatchWsStream<DiffStreamEvent>(\n    endpoint,\n    enabled && !!workspaceId,\n    initialData\n    // No need for injectInitialEntry or deduplicatePatches for diffs\n  );\n\n  const diffs = useMemo(() => {\n    return Object.values(data?.entries ?? {})\n      .filter((entry) => entry?.type === 'DIFF')\n      .map((entry) => entry.content);\n  }, [data?.entries]);\n\n  return { diffs, error, isInitialized };\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useDiffSummary.ts",
    "content": "import { useDiffStream } from '@/shared/hooks/useDiffStream';\nimport { useMemo } from 'react';\n\nexport function useDiffSummary(workspaceId: string | null) {\n  const { diffs, error } = useDiffStream(workspaceId, true, {\n    statsOnly: true,\n  });\n\n  const { fileCount, added, deleted } = useMemo(() => {\n    if (!workspaceId || diffs.length === 0) {\n      return { fileCount: 0, added: 0, deleted: 0 };\n    }\n\n    return diffs.reduce(\n      (acc, d) => {\n        acc.added += d.additions ?? 0;\n        acc.deleted += d.deletions ?? 0;\n        return acc;\n      },\n      { fileCount: diffs.length, added: 0, deleted: 0 }\n    );\n  }, [workspaceId, diffs]);\n\n  return { fileCount, added, deleted, error };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useDiscordOnlineCount.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\n\nconst DISCORD_GUILD_ID = '1423630976524877857';\n\nasync function fetchDiscordOnlineCount(): Promise<number | null> {\n  try {\n    const res = await fetch(\n      `https://discord.com/api/guilds/${DISCORD_GUILD_ID}/widget.json`,\n      { cache: 'no-store' }\n    );\n\n    if (!res.ok) {\n      console.warn(`Discord API error: ${res.status}`);\n      return null;\n    }\n\n    const data = await res.json();\n    if (typeof data?.presence_count === 'number') {\n      return data.presence_count;\n    }\n\n    return null;\n  } catch (error) {\n    console.warn('Failed to fetch Discord online count:', error);\n    return null;\n  }\n}\n\nexport function useDiscordOnlineCount() {\n  return useQuery({\n    queryKey: ['discord-online-count'],\n    queryFn: fetchDiscordOnlineCount,\n    refetchInterval: 10 * 60 * 1000,\n    staleTime: 10 * 60 * 1000,\n    retry: false,\n    refetchOnMount: false,\n    placeholderData: (previousData) => previousData,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useExecutionProcesses.ts",
    "content": "import { useCallback } from 'react';\nimport { useJsonPatchWsStream } from '@/shared/hooks/useJsonPatchWsStream';\nimport type { ExecutionProcess } from 'shared/types';\n\ntype ExecutionProcessState = {\n  execution_processes: Record<string, ExecutionProcess>;\n};\n\ninterface UseExecutionProcessesResult {\n  executionProcesses: ExecutionProcess[];\n  executionProcessesById: Record<string, ExecutionProcess>;\n  isAttemptRunning: boolean;\n  isLoading: boolean;\n  isConnected: boolean;\n  error: string | null;\n}\n\n/**\n * Stream execution processes for a session via WebSocket (JSON Patch) and expose as array + map.\n * Server sends initial snapshot: replace /execution_processes with an object keyed by id.\n * Live updates arrive at /execution_processes/<id> via add/replace/remove operations.\n */\nexport const useExecutionProcesses = (\n  sessionId: string | undefined,\n  opts?: { showSoftDeleted?: boolean }\n): UseExecutionProcessesResult => {\n  const showSoftDeleted = opts?.showSoftDeleted;\n  let endpoint: string | undefined;\n\n  if (sessionId) {\n    const params = new URLSearchParams({ session_id: sessionId });\n    if (typeof showSoftDeleted === 'boolean') {\n      params.set('show_soft_deleted', String(showSoftDeleted));\n    }\n    endpoint = `/api/execution-processes/stream/session/ws?${params.toString()}`;\n  }\n\n  const initialData = useCallback(\n    (): ExecutionProcessState => ({ execution_processes: {} }),\n    []\n  );\n\n  const { data, isConnected, isInitialized, error } =\n    useJsonPatchWsStream<ExecutionProcessState>(\n      endpoint,\n      !!sessionId,\n      initialData\n    );\n\n  const streamedExecutionProcesses = Object.values(\n    data?.execution_processes ?? {}\n  ).sort(\n    (a, b) =>\n      new Date(a.created_at as unknown as string).getTime() -\n      new Date(b.created_at as unknown as string).getTime()\n  );\n\n  // Guard against stale buffered stream data when switching sessions quickly.\n  const executionProcesses = sessionId\n    ? streamedExecutionProcesses.filter(\n        (executionProcess) => executionProcess.session_id === sessionId\n      )\n    : streamedExecutionProcesses;\n\n  const executionProcessesById = executionProcesses.reduce<\n    Record<string, ExecutionProcess>\n  >((processesById, executionProcess) => {\n    processesById[executionProcess.id] = executionProcess;\n    return processesById;\n  }, {});\n\n  const isAttemptRunning = executionProcesses.some(\n    (process) =>\n      (process.run_reason === 'codingagent' ||\n        process.run_reason === 'setupscript' ||\n        process.run_reason === 'cleanupscript' ||\n        process.run_reason === 'archivescript') &&\n      process.status === 'running'\n  );\n  const isLoading = !!sessionId && !isInitialized && !error; // until first snapshot\n\n  return {\n    executionProcesses,\n    executionProcessesById,\n    isAttemptRunning,\n    isLoading,\n    isConnected,\n    error,\n  };\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useExecutionProcessesContext.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { ExecutionProcess } from 'shared/types';\n\nexport type ExecutionProcessesContextType = {\n  executionProcessesAll: ExecutionProcess[];\n  executionProcessesByIdAll: Record<string, ExecutionProcess>;\n  isAttemptRunningAll: boolean;\n\n  executionProcessesVisible: ExecutionProcess[];\n  executionProcessesByIdVisible: Record<string, ExecutionProcess>;\n  isAttemptRunningVisible: boolean;\n\n  isLoading: boolean;\n  isConnected: boolean;\n  error: string | null;\n};\n\nexport const ExecutionProcessesContext =\n  createHmrContext<ExecutionProcessesContextType | null>(\n    'ExecutionProcessesContext',\n    null\n  );\n\nexport const useExecutionProcessesContext = () => {\n  const ctx = useContext(ExecutionProcessesContext);\n  if (!ctx) {\n    throw new Error(\n      'useExecutionProcessesContext must be used within ExecutionProcessesProvider'\n    );\n  }\n  return ctx;\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useExecutorConfig.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type {\n  BaseCodingAgent,\n  ExecutorConfig,\n  ExecutorProfile,\n  ExecutorProfileId,\n} from 'shared/types';\nimport { getVariantOptions } from '@/shared/lib/executor';\nimport { usePresetOptions } from '@/shared/hooks/usePresetOptions';\n\nfunction getProfileKey(\n  executor: BaseCodingAgent | null,\n  variant: string | null\n): string | null {\n  if (!executor) return null;\n  return `${executor}:${variant ?? 'DEFAULT'}`;\n}\n\nconst OVERRIDE_FIELDS = [\n  'model_id',\n  'agent_id',\n  'reasoning_id',\n  'permission_policy',\n] as const;\n\n/**\n * Resolves effective executor.\n * userSelections.executor → scratch → lastUsedConfig → configDefault → first available\n */\nfunction useEffectiveExecutor(\n  userSelections: Partial<ExecutorConfig>,\n  profiles: Record<string, ExecutorProfile> | null,\n  scratchConfig: ExecutorConfig | null | undefined,\n  lastUsedConfig: ExecutorConfig | null,\n  configExecutorProfile: ExecutorProfileId | null | undefined\n) {\n  const options = useMemo(\n    () => Object.keys(profiles ?? {}) as BaseCodingAgent[],\n    [profiles]\n  );\n\n  const effective = useMemo(\n    () =>\n      userSelections.executor ??\n      scratchConfig?.executor ??\n      lastUsedConfig?.executor ??\n      configExecutorProfile?.executor ??\n      options[0] ??\n      null,\n    [\n      userSelections.executor,\n      scratchConfig,\n      lastUsedConfig,\n      configExecutorProfile,\n      options,\n    ]\n  );\n\n  return { effective, options };\n}\n\n/**\n * Resolves effective variant.\n * userSelections.variant → scratch (if same executor) → lastUsedConfig (if same executor)\n * → configDefault → DEFAULT/first\n */\nfunction useEffectiveVariant(\n  userSelections: Partial<ExecutorConfig>,\n  effectiveExecutor: BaseCodingAgent | null,\n  profiles: Record<string, ExecutorProfile> | null,\n  scratchConfig: ExecutorConfig | null | undefined,\n  lastUsedConfig: ExecutorConfig | null,\n  configExecutorProfile: ExecutorProfileId | null | undefined\n) {\n  const options = useMemo(\n    () => getVariantOptions(effectiveExecutor, profiles),\n    [effectiveExecutor, profiles]\n  );\n\n  const wasUserSelected = 'variant' in userSelections;\n\n  const resolved = useMemo(() => {\n    if (wasUserSelected) return userSelections.variant ?? null;\n\n    if (\n      scratchConfig !== undefined &&\n      scratchConfig?.executor === effectiveExecutor &&\n      scratchConfig?.variant !== undefined\n    ) {\n      return scratchConfig.variant ?? null;\n    }\n\n    if (lastUsedConfig?.executor === effectiveExecutor) {\n      return lastUsedConfig.variant ?? null;\n    }\n\n    if (configExecutorProfile?.executor === effectiveExecutor) {\n      return configExecutorProfile.variant ?? null;\n    }\n\n    return (options.includes('DEFAULT') ? 'DEFAULT' : options[0]) ?? null;\n  }, [\n    wasUserSelected,\n    userSelections.variant,\n    scratchConfig,\n    effectiveExecutor,\n    lastUsedConfig,\n    configExecutorProfile,\n    options,\n  ]);\n\n  return { resolved, options, wasUserSelected };\n}\n\n/**\n * Resolves each override field independently through the fallback chain:\n * userSelections[field] → scratch[field] → lastUsed[field] → preset[field]\n */\nfunction useEffectiveOverrides(\n  effectiveExecutor: BaseCodingAgent | null,\n  resolvedVariant: string | null,\n  userSelections: Partial<ExecutorConfig>,\n  scratchConfig: ExecutorConfig | null | undefined,\n  lastUsedConfig: ExecutorConfig | null,\n  variantWasUserSelected: boolean,\n  presetOptions: ExecutorConfig | null | undefined\n) {\n  return useMemo((): ExecutorConfig | null => {\n    if (!effectiveExecutor) return null;\n\n    const profileKey = getProfileKey(effectiveExecutor, resolvedVariant);\n    const scratchMatches = scratchConfig\n      ? getProfileKey(scratchConfig.executor, scratchConfig.variant ?? null) ===\n        profileKey\n      : false;\n    const lastUsedMatches = lastUsedConfig\n      ? getProfileKey(\n          lastUsedConfig.executor,\n          lastUsedConfig.variant ?? null\n        ) === profileKey\n      : false;\n\n    const resolved: ExecutorConfig = {\n      executor: effectiveExecutor,\n      variant: resolvedVariant,\n    };\n\n    for (const field of OVERRIDE_FIELDS) {\n      const modelMustMatch = field === 'reasoning_id';\n      const scratchModelMatches =\n        !modelMustMatch || scratchConfig?.model_id === resolved.model_id;\n      const lastUsedModelMatches =\n        !modelMustMatch || lastUsedConfig?.model_id === resolved.model_id;\n\n      const value =\n        field in userSelections\n          ? userSelections[field]\n          : ((scratchMatches && scratchModelMatches\n              ? scratchConfig?.[field]\n              : undefined) ??\n            (lastUsedMatches && lastUsedModelMatches\n              ? lastUsedConfig?.[field]\n              : undefined) ??\n            (variantWasUserSelected ? presetOptions?.[field] : undefined));\n      if (value !== undefined) {\n        (resolved as Record<string, unknown>)[field] = value;\n      }\n    }\n\n    return resolved;\n  }, [\n    effectiveExecutor,\n    resolvedVariant,\n    userSelections,\n    scratchConfig,\n    lastUsedConfig,\n    presetOptions,\n    variantWasUserSelected,\n  ]);\n}\n\ninterface UseExecutorConfigOptions {\n  profiles: Record<string, ExecutorProfile> | null;\n  lastUsedConfig: ExecutorConfig | null;\n  scratchConfig?: ExecutorConfig | null;\n  configExecutorProfile?: ExecutorProfileId | null;\n  onPersist?: (config: ExecutorConfig) => void;\n}\n\ninterface UseExecutorConfigResult {\n  executorConfig: ExecutorConfig | null;\n  effectiveExecutor: BaseCodingAgent | null;\n  selectedVariant: string | null;\n  executorOptions: BaseCodingAgent[];\n  variantOptions: string[];\n  presetOptions: ExecutorConfig | null | undefined;\n  setExecutor: (executor: BaseCodingAgent) => void;\n  setVariant: (variant: string | null) => void;\n  setOverrides: (partial: Partial<ExecutorConfig>) => void;\n}\n\n/** Unified executor + variant + model selector overrides management. */\nexport function useExecutorConfig({\n  profiles,\n  lastUsedConfig,\n  scratchConfig,\n  configExecutorProfile,\n  onPersist,\n}: UseExecutorConfigOptions): UseExecutorConfigResult {\n  const [userSelections, setUserSelections] = useState<Partial<ExecutorConfig>>(\n    {}\n  );\n\n  const executor = useEffectiveExecutor(\n    userSelections,\n    profiles,\n    scratchConfig,\n    lastUsedConfig,\n    configExecutorProfile\n  );\n\n  const variant = useEffectiveVariant(\n    userSelections,\n    executor.effective,\n    profiles,\n    scratchConfig,\n    lastUsedConfig,\n    configExecutorProfile\n  );\n\n  const { data: presetOptions } = usePresetOptions(\n    executor.effective,\n    variant.resolved\n  );\n\n  const executorConfig = useEffectiveOverrides(\n    executor.effective,\n    variant.resolved,\n    userSelections,\n    scratchConfig,\n    lastUsedConfig,\n    variant.wasUserSelected,\n    presetOptions\n  );\n\n  const profileKey = getProfileKey(executor.effective, variant.resolved);\n  const prevProfileKeyRef = useRef<string | null>(profileKey);\n  useEffect(() => {\n    const prev = prevProfileKeyRef.current;\n    prevProfileKeyRef.current = profileKey;\n    if (prev !== null && prev !== profileKey) {\n      setUserSelections((s) => {\n        const { executor, variant, ...rest } = s;\n        if (Object.keys(rest).length === 0) return s;\n        return { executor, variant };\n      });\n    }\n  }, [profileKey]);\n\n  const onPersistRef = useRef(onPersist);\n  onPersistRef.current = onPersist;\n\n  const persist = useCallback((config: ExecutorConfig | null) => {\n    if (config) onPersistRef.current?.(config);\n  }, []);\n\n  // Setting executor → replaces entire selections with just { executor }.\n  // Clears variant + all override fields.\n  const setExecutor = useCallback(\n    (exec: BaseCodingAgent) => {\n      setUserSelections({ executor: exec });\n      // Persist with auto-resolved variant (no overrides)\n      const newVariants = getVariantOptions(exec, profiles);\n      const newVariant = newVariants[0] ?? null;\n      persist({ executor: exec, variant: newVariant });\n    },\n    [profiles, persist]\n  );\n\n  // Setting variant → keeps executor, sets variant, clears all override fields.\n  // Since 'variant' is in userSelections → variantWasUserSelected=true\n  // → override fields fall through to preset options for the new variant.\n  const setVariant = useCallback(\n    (v: string | null) => {\n      setUserSelections((prev) => ({ executor: prev.executor, variant: v }));\n      if (executor.effective) {\n        persist({ executor: executor.effective, variant: v });\n      }\n    },\n    [executor.effective, persist]\n  );\n\n  // Model selector updates individual override fields (merge into existing).\n  // Changing model clears reasoning selection; other overrides are independent.\n  const setOverrides = useCallback(\n    (partial: Partial<ExecutorConfig>) => {\n      setUserSelections((prev) => {\n        const next = { ...prev, ...partial };\n        if ('model_id' in partial && !('reasoning_id' in partial)) {\n          delete next.reasoning_id;\n        }\n        const persistedConfig = executor.effective\n          ? {\n              ...next,\n              executor: executor.effective,\n              variant: variant.resolved,\n            }\n          : null;\n        // Persist with current effective executor/variant\n        if (persistedConfig) {\n          persist(persistedConfig);\n        }\n        return next;\n      });\n    },\n    [executor.effective, variant.resolved, persist]\n  );\n\n  return {\n    executorConfig,\n    effectiveExecutor: executor.effective,\n    selectedVariant: variant.resolved,\n    executorOptions: executor.options,\n    variantOptions: variant.options,\n    presetOptions,\n    setExecutor,\n    setVariant,\n    setOverrides,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useExecutorDiscovery.ts",
    "content": "import { useCallback, useEffect, useMemo } from 'react';\nimport type { BaseCodingAgent, ExecutorDiscoveredOptions } from 'shared/types';\nimport { useJsonPatchWsStream } from '@/shared/hooks/useJsonPatchWsStream';\nimport { agentsApi } from '@/shared/lib/api';\n\ntype ExecutorDiscoveryStreamState = {\n  options: ExecutorDiscoveredOptions | null;\n};\n\nconst defaultOptions: ExecutorDiscoveredOptions = {\n  model_selector: {\n    providers: [],\n    models: [],\n    default_model: null,\n    agents: [],\n    permissions: [],\n  },\n  slash_commands: [],\n  loading_models: true,\n  loading_agents: true,\n  loading_slash_commands: true,\n  error: null,\n};\n\nfunction useExecutorDiscovery(\n  agent: BaseCodingAgent | null | undefined,\n  opts?: { workspaceId?: string; sessionId?: string; repoId?: string }\n) {\n  const { workspaceId, sessionId, repoId } = opts ?? {};\n  const endpoint = useMemo(() => {\n    if (!agent) return undefined;\n    return agentsApi.getDiscoveredOptionsStreamUrl(agent, {\n      workspaceId,\n      sessionId,\n      repoId,\n    });\n  }, [agent, workspaceId, sessionId, repoId]);\n\n  const initialData = useCallback(\n    (): ExecutorDiscoveryStreamState => ({\n      options: { ...defaultOptions },\n    }),\n    []\n  );\n\n  const { data, error, isConnected, isInitialized } =\n    useJsonPatchWsStream<ExecutorDiscoveryStreamState>(\n      endpoint,\n      !!endpoint,\n      initialData\n    );\n\n  // Prefer the backend-reported error from the data payload. Only fall back\n  // to the WebSocket transport error when no data has been received yet —\n  // transient connection failures (e.g. React StrictMode double-mount or\n  // Safari/macOS 26 WebSocket instability) should not persist once data\n  // has successfully loaded.\n  const combinedError = data?.options?.error ?? (isInitialized ? null : error);\n\n  useEffect(() => {\n    if (combinedError) {\n      console.error(\n        'Failed to fetch executor discovery options',\n        combinedError\n      );\n    }\n  }, [combinedError]);\n\n  return {\n    options: data?.options ?? null,\n    error: combinedError,\n    isConnected,\n    isInitialized,\n  };\n}\n\nexport function useModelSelectorConfig(\n  agent: BaseCodingAgent | null | undefined,\n  opts?: { workspaceId?: string; sessionId?: string; repoId?: string }\n) {\n  const { options, error, isConnected, isInitialized } = useExecutorDiscovery(\n    agent,\n    opts\n  );\n\n  return {\n    config: options?.model_selector ?? null,\n    loadingModels: options?.loading_models ?? false,\n    loadingAgents: options?.loading_agents ?? false,\n    error,\n    isConnected,\n    isInitialized,\n  };\n}\n\nexport function useSlashCommands(\n  agent: BaseCodingAgent | null | undefined,\n  opts?: { workspaceId?: string; sessionId?: string; repoId?: string }\n) {\n  const { options, error, isConnected, isInitialized } = useExecutorDiscovery(\n    agent,\n    opts\n  );\n\n  return {\n    commands: options?.slash_commands ?? [],\n    discovering: options?.loading_slash_commands ?? false,\n    error,\n    isConnected,\n    isInitialized,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useForcePush.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { PushError, PushWorkspaceRequest } from 'shared/types';\n\nclass ForcePushErrorWithData extends Error {\n  constructor(\n    message: string,\n    public errorData?: PushError\n  ) {\n    super(message);\n    this.name = 'ForcePushErrorWithData';\n  }\n}\n\nexport function useForcePush(\n  workspaceId?: string,\n  onSuccess?: () => void,\n  onError?: (err: unknown, errorData?: PushError) => void\n) {\n  const queryClient = useQueryClient();\n\n  return useMutation<void, unknown, PushWorkspaceRequest>({\n    mutationFn: async (params: PushWorkspaceRequest) => {\n      if (!workspaceId) return;\n      const result = await workspacesApi.forcePush(workspaceId, params);\n      if (!result.success) {\n        throw new ForcePushErrorWithData(\n          result.message || 'Force push failed',\n          result.error\n        );\n      }\n    },\n    onSuccess: () => {\n      // A force push affects remote status; invalidate the same branchStatus\n      queryClient.invalidateQueries({\n        queryKey: ['branchStatus', workspaceId],\n      });\n      onSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to force push:', err);\n      const errorData =\n        err instanceof ForcePushErrorWithData ? err.errorData : undefined;\n      onError?.(err, errorData);\n    },\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useGitHubComments.ts",
    "content": "import { useMemo, useCallback } from 'react';\nimport { usePrComments } from '@/shared/hooks/usePrComments';\nimport {\n  usePersistedExpanded,\n  PERSIST_KEYS,\n} from '@/shared/stores/useUiPreferencesStore';\nimport type { UnifiedPrComment } from 'shared/types';\nimport { DiffSide } from '@/shared/types/diff';\n\nimport type { NormalizedGitHubComment } from '@/shared/hooks/useWorkspaceContext';\n\ninterface UseGitHubCommentsOptions {\n  workspaceId?: string;\n  repoId?: string;\n  enabled?: boolean;\n}\n\ninterface UseGitHubCommentsResult {\n  gitHubComments: UnifiedPrComment[];\n  isGitHubCommentsLoading: boolean;\n  showGitHubComments: boolean;\n  setShowGitHubComments: (show: boolean) => void;\n  getGitHubCommentsForFile: (filePath: string) => NormalizedGitHubComment[];\n  getGitHubCommentCountForFile: (filePath: string) => number;\n  getFilesWithGitHubComments: () => string[];\n  getFirstCommentLineForFile: (filePath: string) => number | null;\n}\n\nexport function useGitHubComments({\n  workspaceId,\n  repoId,\n  enabled = true,\n}: UseGitHubCommentsOptions): UseGitHubCommentsResult {\n  // GitHub comments toggle state (persisted)\n  const [showGitHubComments, setShowGitHubComments] = usePersistedExpanded(\n    PERSIST_KEYS.showGitHubComments,\n    true // Default to shown\n  );\n\n  // Fetch PR comments for the current workspace\n  const { data: prCommentsData, isLoading: isGitHubCommentsLoading } =\n    usePrComments(workspaceId, repoId, {\n      enabled: enabled && !!repoId,\n    });\n\n  const gitHubComments = useMemo(\n    () => prCommentsData?.comments ?? [],\n    [prCommentsData?.comments]\n  );\n\n  // Normalize GitHub review comments for file matching\n  const normalizedComments = useMemo(() => {\n    const normalized: NormalizedGitHubComment[] = [];\n    for (const comment of gitHubComments) {\n      if (comment.comment_type !== 'review') continue;\n      if (comment.line === null) continue; // Skip file-level comments\n\n      normalized.push({\n        id: String(comment.id),\n        author: comment.author,\n        body: comment.body,\n        createdAt: comment.created_at,\n        url: comment.url,\n        filePath: comment.path,\n        lineNumber: Number(comment.line),\n        // Use side from API: \"LEFT\" = old/deleted side, \"RIGHT\" = new/added side (default)\n        side: comment.side === 'LEFT' ? DiffSide.Old : DiffSide.New,\n        diffHunk: comment.diff_hunk,\n      });\n    }\n    return normalized;\n  }, [gitHubComments]);\n\n  // Helper to match paths - handles repo prefix in diff paths\n  // GitHub paths: \"packages/web-core/src/file.ts\"\n  // Diff paths: \"vibe-kanban/packages/web-core/src/file.ts\" (repo name prefixed)\n  const pathMatches = useCallback(\n    (diffPath: string, githubPath: string): boolean => {\n      return diffPath === githubPath || diffPath.endsWith('/' + githubPath);\n    },\n    []\n  );\n\n  // Get comments for a specific file (handles prefixed paths)\n  const getGitHubCommentsForFile = useCallback(\n    (filePath: string): NormalizedGitHubComment[] => {\n      return normalizedComments.filter((c) =>\n        pathMatches(filePath, c.filePath)\n      );\n    },\n    [normalizedComments, pathMatches]\n  );\n\n  // Get comment count for a specific file (handles prefixed paths)\n  const getGitHubCommentCountForFile = useCallback(\n    (filePath: string): number => {\n      return normalizedComments.filter((c) => pathMatches(filePath, c.filePath))\n        .length;\n    },\n    [normalizedComments, pathMatches]\n  );\n\n  // Get list of unique file paths that have GitHub comments\n  const getFilesWithGitHubComments = useCallback((): string[] => {\n    const filesSet = new Set<string>();\n    for (const comment of normalizedComments) {\n      filesSet.add(comment.filePath);\n    }\n    return Array.from(filesSet);\n  }, [normalizedComments]);\n\n  // Get the first (lowest line number) comment's line for a file\n  const getFirstCommentLineForFile = useCallback(\n    (filePath: string): number | null => {\n      const comments = normalizedComments.filter((c) =>\n        pathMatches(filePath, c.filePath)\n      );\n      if (comments.length === 0) return null;\n      return Math.min(...comments.map((c) => c.lineNumber));\n    },\n    [normalizedComments, pathMatches]\n  );\n\n  return {\n    gitHubComments,\n    isGitHubCommentsLoading,\n    showGitHubComments,\n    setShowGitHubComments,\n    getGitHubCommentsForFile,\n    getGitHubCommentCountForFile,\n    getFilesWithGitHubComments,\n    getFirstCommentLineForFile,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useGitHubStars.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\n\nasync function fetchGitHubStars(): Promise<number | null> {\n  try {\n    const res = await fetch(\n      'https://api.github.com/repos/BloopAI/vibe-kanban',\n      { cache: 'no-store' }\n    );\n\n    if (!res.ok) {\n      console.warn(`GitHub API error: ${res.status}`);\n      return null;\n    }\n\n    const data = await res.json();\n    if (typeof data?.stargazers_count === 'number') {\n      return data.stargazers_count;\n    }\n\n    return null;\n  } catch (error) {\n    console.warn('Failed to fetch GitHub stars:', error);\n    return null;\n  }\n}\n\nexport function useGitHubStars() {\n  return useQuery({\n    queryKey: ['github-stars'],\n    queryFn: fetchGitHubStars,\n    refetchInterval: 10 * 60 * 1000,\n    staleTime: 10 * 60 * 1000,\n    retry: false,\n    refetchOnMount: false,\n    placeholderData: (previousData) => previousData,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useGitOperations.ts",
    "content": "import { useRebase } from '@/shared/hooks/useRebase';\nimport { useMerge } from '@/shared/hooks/useMerge';\nimport { usePush } from '@/shared/hooks/usePush';\nimport { useForcePush } from '@/shared/hooks/useForcePush';\nimport { useChangeTargetBranch } from '@/shared/hooks/useChangeTargetBranch';\nimport { useGitOperationsError } from '@/shared/hooks/GitOperationsContext';\nimport { Result } from '@/shared/lib/api';\nimport type { GitOperationError, PushWorkspaceRequest } from 'shared/types';\nimport { ForcePushDialog } from '@/shared/dialogs/command-bar/ForcePushDialog';\n\nexport function useGitOperations(\n  workspaceId: string | undefined,\n  repoId: string | undefined\n) {\n  const { setError } = useGitOperationsError();\n\n  const rebase = useRebase(\n    workspaceId,\n    repoId,\n    () => setError(null),\n    (err: Result<void, GitOperationError>) => {\n      if (!err.success) {\n        const data = err?.error;\n        const isConflict =\n          data?.type === 'merge_conflicts' ||\n          data?.type === 'rebase_in_progress';\n        if (!isConflict) {\n          setError(err.message || 'Failed to rebase');\n        }\n      }\n    }\n  );\n\n  const merge = useMerge(\n    workspaceId,\n    () => setError(null),\n    (err: unknown) => {\n      const message =\n        err && typeof err === 'object' && 'message' in err\n          ? String(err.message)\n          : 'Failed to merge';\n      setError(message);\n    }\n  );\n\n  const forcePush = useForcePush(\n    workspaceId,\n    () => setError(null),\n    (err: unknown) => {\n      const message =\n        err && typeof err === 'object' && 'message' in err\n          ? String(err.message)\n          : 'Failed to force push';\n      setError(message);\n    }\n  );\n\n  const push = usePush(\n    workspaceId,\n    () => setError(null),\n    async (err: unknown, errorData, params?: PushWorkspaceRequest) => {\n      // Handle typed push errors\n      if (errorData?.type === 'force_push_required') {\n        // Show confirmation dialog - dialog handles the force push internally\n        if (workspaceId && params?.repo_id) {\n          await ForcePushDialog.show({ workspaceId, repoId: params.repo_id });\n        }\n        return;\n      }\n\n      const message =\n        err && typeof err === 'object' && 'message' in err\n          ? String(err.message)\n          : 'Failed to push';\n      setError(message);\n    }\n  );\n\n  const changeTargetBranch = useChangeTargetBranch(\n    workspaceId,\n    repoId,\n    () => setError(null),\n    (err: unknown) => {\n      const message =\n        err && typeof err === 'object' && 'message' in err\n          ? String(err.message)\n          : 'Failed to change target branch';\n      setError(message);\n    }\n  );\n\n  const isAnyLoading =\n    rebase.isPending ||\n    merge.isPending ||\n    push.isPending ||\n    forcePush.isPending ||\n    changeTargetBranch.isPending;\n\n  return {\n    actions: {\n      rebase: rebase.mutateAsync,\n      merge: merge.mutateAsync,\n      push: push.mutateAsync,\n      forcePush: forcePush.mutateAsync,\n      changeTargetBranch: changeTargetBranch.mutateAsync,\n    },\n    isAnyLoading,\n    states: {\n      rebasePending: rebase.isPending,\n      mergePending: merge.isPending,\n      pushPending: push.isPending,\n      forcePushPending: forcePush.isPending,\n      changeTargetBranchPending: changeTargetBranch.isPending,\n    },\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useIsMobile.ts",
    "content": "import { useState, useSyncExternalStore } from 'react';\n\nconst MOBILE_BREAKPOINT = 767;\nconst query = `(max-width: ${MOBILE_BREAKPOINT}px)`;\nlet mediaQuery: MediaQueryList | null = null;\n\nfunction getMediaQuery() {\n  if (!mediaQuery) {\n    mediaQuery = window.matchMedia(query);\n  }\n  return mediaQuery;\n}\n\nfunction subscribe(callback: () => void) {\n  const mq = getMediaQuery();\n  mq.addEventListener('change', callback);\n  return () => mq.removeEventListener('change', callback);\n}\n\nfunction getSnapshot() {\n  return getMediaQuery().matches;\n}\n\n/**\n * Returns true when the viewport is at or below mobile breakpoint (767px).\n * Uses a singleton MediaQueryList for efficient re-renders.\n */\nexport function useIsMobile(): boolean {\n  return useSyncExternalStore(subscribe, getSnapshot);\n}\n\n/** Raw check without React hook — for use in store actions */\nexport function isMobileViewport(): boolean {\n  return window.matchMedia(query).matches;\n}\n\n/** Detect real mobile device via user-agent (not just viewport width) */\nexport function isRealMobileDevice(): boolean {\n  // Modern API: navigator.userAgentData.mobile (Chrome, Edge, Opera — ~76% of browsers)\n  const nav = navigator as Navigator & { userAgentData?: { mobile?: boolean } };\n  if (nav.userAgentData?.mobile !== undefined) {\n    return nav.userAgentData.mobile;\n  }\n  // Fallback: user-agent string regex (Safari, Firefox)\n  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Windows Phone|Mobi/i.test(\n    navigator.userAgent\n  );\n}\n\n/** React hook version of isRealMobileDevice — stable, no re-renders on resize */\nexport function useIsRealMobile(): boolean {\n  // Device type doesn't change during session, so compute once\n  const [isReal] = useState(() => isRealMobileDevice());\n  return isReal;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useIssueContext.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { InsertResult, MutationResult } from '@/shared/lib/electric/types';\nimport type {\n  IssueComment,\n  IssueCommentReaction,\n  CreateIssueCommentRequest,\n  UpdateIssueCommentRequest,\n  CreateIssueCommentReactionRequest,\n} from 'shared/remote-types';\nimport type { SyncError } from '@/shared/lib/electric/types';\n\nexport interface IssueContextValue {\n  issueId: string;\n\n  // Normalized data arrays (Electric syncs only this issue's data)\n  comments: IssueComment[];\n  reactions: IssueCommentReaction[];\n\n  // Loading/error state\n  isLoading: boolean;\n  error: SyncError | null;\n  retry: () => void;\n\n  // Comment mutations\n  insertComment: (\n    data: CreateIssueCommentRequest\n  ) => InsertResult<IssueComment>;\n  updateComment: (\n    id: string,\n    changes: Partial<UpdateIssueCommentRequest>\n  ) => MutationResult;\n  removeComment: (id: string) => MutationResult;\n\n  // Reaction mutations\n  insertReaction: (\n    data: CreateIssueCommentReactionRequest\n  ) => InsertResult<IssueCommentReaction>;\n  removeReaction: (id: string) => MutationResult;\n\n  // Lookup helpers (within this issue's data)\n  getComment: (commentId: string) => IssueComment | undefined;\n  getReactionsForComment: (commentId: string) => IssueCommentReaction[];\n  getReactionCountForComment: (commentId: string) => number;\n  hasUserReactedToComment: (\n    commentId: string,\n    userId: string,\n    emoji: string\n  ) => boolean;\n\n  // Computed aggregations\n  commentsById: Map<string, IssueComment>;\n  reactionsByComment: Map<string, IssueCommentReaction[]>;\n}\n\nexport const IssueContext = createHmrContext<IssueContextValue | null>(\n  'IssueContext',\n  null\n);\n\nexport function useIssueContext(): IssueContextValue {\n  const context = useContext(IssueContext);\n  if (!context) {\n    throw new Error('useIssueContext must be used within an IssueProvider');\n  }\n  return context;\n}\n\nexport function useIssueContextOptional(): IssueContextValue | null {\n  return useContext(IssueContext);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useIssueMultiSelect.ts",
    "content": "import { useCallback, type MouseEvent } from 'react';\nimport { useIssueSelectionStore } from '@/shared/stores/useIssueSelectionStore';\n\nexport function useIssueMultiSelect() {\n  const selectedIssueIds = useIssueSelectionStore((s) => s.selectedIssueIds);\n  const toggleIssue = useIssueSelectionStore((s) => s.toggleIssue);\n  const selectRange = useIssueSelectionStore((s) => s.selectRange);\n  const clearSelection = useIssueSelectionStore((s) => s.clearSelection);\n  const selectAll = useIssueSelectionStore((s) => s.selectAll);\n\n  const isMultiSelectActive = selectedIssueIds.size > 1;\n\n  const handleIssueClick = useCallback(\n    (issueId: string, event: MouseEvent) => {\n      const isMetaClick = event.metaKey || event.ctrlKey;\n      const isShiftClick = event.shiftKey;\n\n      if (isMetaClick) {\n        // Cmd/Ctrl+Click: toggle this issue in multi-select\n        event.preventDefault();\n        toggleIssue(issueId);\n      } else if (isShiftClick) {\n        // Shift+Click: range select from anchor to this issue\n        event.preventDefault();\n        window.getSelection()?.removeAllRanges();\n        selectRange(issueId);\n      }\n    },\n    [toggleIssue, selectRange]\n  );\n\n  const handleCheckboxChange = useCallback(\n    (issueId: string, _checked?: boolean) => {\n      toggleIssue(issueId);\n    },\n    [toggleIssue]\n  );\n\n  return {\n    selectedIssueIds,\n    isMultiSelectActive,\n    handleIssueClick,\n    handleCheckboxChange,\n    handleSelectAll: selectAll,\n    clearSelection,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useJsonPatchWsStream.ts",
    "content": "import { useEffect, useState, useRef } from 'react';\nimport { produce } from 'immer';\nimport type { Operation } from 'rfc6902';\nimport { applyUpsertPatch } from '@/shared/lib/jsonPatch';\nimport { openLocalApiWebSocket } from '@/shared/lib/localApiTransport';\n\ntype WsJsonPatchMsg = { JsonPatch: Operation[] };\ntype WsReadyMsg = { Ready: true };\ntype WsFinishedMsg = { finished: boolean };\ntype WsMsg = WsJsonPatchMsg | WsReadyMsg | WsFinishedMsg;\n\ninterface UseJsonPatchStreamOptions<T> {\n  /**\n   * Called once when the stream starts to inject initial data\n   */\n  injectInitialEntry?: (data: T) => void;\n  /**\n   * Filter/deduplicate patches before applying them\n   */\n  deduplicatePatches?: (patches: Operation[]) => Operation[];\n}\n\ninterface UseJsonPatchStreamResult<T> {\n  data: T | undefined;\n  isConnected: boolean;\n  isInitialized: boolean;\n  error: string | null;\n}\n\n/**\n * Generic hook for consuming WebSocket streams that send JSON messages with patches\n */\nexport const useJsonPatchWsStream = <T extends object>(\n  endpoint: string | undefined,\n  enabled: boolean,\n  initialData: () => T,\n  options?: UseJsonPatchStreamOptions<T>\n): UseJsonPatchStreamResult<T> => {\n  const [data, setData] = useState<T | undefined>(undefined);\n  const [isConnected, setIsConnected] = useState(false);\n  const [isInitialized, setIsInitialized] = useState(false);\n  const initializedForEndpointRef = useRef<string | undefined>(undefined);\n  const [error, setError] = useState<string | null>(null);\n  const wsRef = useRef<WebSocket | null>(null);\n  const dataRef = useRef<T | undefined>(undefined);\n  const retryTimerRef = useRef<number | null>(null);\n  const retryAttemptsRef = useRef<number>(0);\n  const [retryNonce, setRetryNonce] = useState(0);\n  const finishedRef = useRef<boolean>(false);\n\n  const injectInitialEntry = options?.injectInitialEntry;\n  const deduplicatePatches = options?.deduplicatePatches;\n\n  function scheduleReconnect() {\n    if (retryTimerRef.current) return; // already scheduled\n    // Exponential backoff with cap: 1s, 2s, 4s, 8s (max), then stay at 8s\n    const attempt = retryAttemptsRef.current;\n    const delay = Math.min(8000, 1000 * Math.pow(2, attempt));\n    retryTimerRef.current = window.setTimeout(() => {\n      retryTimerRef.current = null;\n      setRetryNonce((n) => n + 1);\n    }, delay);\n  }\n\n  useEffect(() => {\n    if (!enabled || !endpoint) {\n      // Close connection and reset state\n      if (wsRef.current) {\n        wsRef.current.close();\n        wsRef.current = null;\n      }\n      if (retryTimerRef.current) {\n        window.clearTimeout(retryTimerRef.current);\n        retryTimerRef.current = null;\n      }\n      retryAttemptsRef.current = 0;\n      finishedRef.current = false;\n      setData(undefined);\n      setIsConnected(false);\n      setIsInitialized(false);\n      setError(null);\n      dataRef.current = undefined;\n      return;\n    }\n\n    // Initialize data\n    if (!dataRef.current) {\n      dataRef.current = initialData();\n\n      // Inject initial entry if provided\n      if (injectInitialEntry) {\n        injectInitialEntry(dataRef.current);\n      }\n    }\n\n    let cancelled = false;\n\n    // Create WebSocket if it doesn't exist\n    if (!wsRef.current) {\n      // Reset finished flag for new connection\n      finishedRef.current = false;\n\n      void (async () => {\n        try {\n          const ws = await openLocalApiWebSocket(endpoint);\n\n          if (cancelled) {\n            ws.close();\n            return;\n          }\n\n          ws.onopen = () => {\n            setError(null);\n            setIsConnected(true);\n            // Reset backoff on successful connection\n            retryAttemptsRef.current = 0;\n            if (retryTimerRef.current) {\n              window.clearTimeout(retryTimerRef.current);\n              retryTimerRef.current = null;\n            }\n          };\n\n          ws.onmessage = (event) => {\n            try {\n              const msg: WsMsg = JSON.parse(event.data);\n\n              // Handle JsonPatch messages (same as SSE json_patch event)\n              if ('JsonPatch' in msg) {\n                const patches: Operation[] = msg.JsonPatch;\n                const filtered = deduplicatePatches\n                  ? deduplicatePatches(patches)\n                  : patches;\n\n                const current = dataRef.current;\n                if (!filtered.length || !current) return;\n\n                // Use Immer for structural sharing - only modified parts get new references\n                const next = produce(current, (draft) => {\n                  applyUpsertPatch(draft, filtered);\n                });\n\n                dataRef.current = next;\n                setData(next);\n              }\n\n              // Handle Ready messages (initial data has been sent)\n              if ('Ready' in msg) {\n                initializedForEndpointRef.current = endpoint;\n                setIsInitialized(true);\n                setError(null);\n              }\n\n              // Handle finished messages ({finished: true})\n              // Treat finished as terminal - do NOT reconnect\n              if ('finished' in msg) {\n                finishedRef.current = true;\n                ws.close(1000, 'finished');\n                wsRef.current = null;\n                setIsConnected(false);\n              }\n            } catch (err) {\n              console.error('Failed to process WebSocket message:', err);\n              setError('Failed to process stream update');\n            }\n          };\n\n          ws.onerror = () => {\n            setError('Connection failed');\n          };\n\n          ws.onclose = (evt) => {\n            setIsConnected(false);\n            wsRef.current = null;\n\n            // Do not reconnect if we received a finished message or clean close\n            if (\n              cancelled ||\n              finishedRef.current ||\n              (evt?.code === 1000 && evt?.wasClean)\n            ) {\n              return;\n            }\n\n            // Otherwise, reconnect on unexpected/error closures\n            retryAttemptsRef.current += 1;\n            scheduleReconnect();\n          };\n\n          wsRef.current = ws;\n        } catch (error) {\n          if (cancelled) {\n            return;\n          }\n\n          console.error('Failed to open WebSocket stream:', error);\n          setError('Connection failed');\n          retryAttemptsRef.current += 1;\n          scheduleReconnect();\n        }\n      })();\n    }\n\n    return () => {\n      cancelled = true;\n      if (wsRef.current) {\n        const ws = wsRef.current;\n\n        // Clear all event handlers first to prevent callbacks after cleanup\n        ws.onopen = null;\n        ws.onmessage = null;\n        ws.onerror = null;\n        ws.onclose = null;\n\n        // Close regardless of state\n        ws.close();\n        wsRef.current = null;\n      }\n      if (retryTimerRef.current) {\n        window.clearTimeout(retryTimerRef.current);\n        retryTimerRef.current = null;\n      }\n      finishedRef.current = false;\n      dataRef.current = undefined;\n      setData(undefined);\n      setIsInitialized(false);\n    };\n  }, [\n    endpoint,\n    enabled,\n    initialData,\n    injectInitialEntry,\n    deduplicatePatches,\n    retryNonce,\n  ]);\n\n  const isInitializedForCurrentEndpoint =\n    isInitialized && initializedForEndpointRef.current === endpoint;\n\n  return {\n    data,\n    isConnected,\n    isInitialized: isInitializedForCurrentEndpoint,\n    error,\n  };\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useKanbanIssueComposerScratch.ts",
    "content": "import { useEffect, useRef } from 'react';\nimport { useAppRuntime } from '@/shared/hooks/useAppRuntime';\nimport {\n  useKanbanIssueComposerStore,\n  type KanbanIssueComposerEntry,\n} from '@/shared/stores/useKanbanIssueComposerStore';\n\nconst STORAGE_KEY = 'vk-kanban-issue-composer';\n\nfunction readStoredComposerState(): Record<\n  string,\n  KanbanIssueComposerEntry | undefined\n> | null {\n  try {\n    const raw = localStorage.getItem(STORAGE_KEY);\n    if (!raw) return null;\n    const parsed = JSON.parse(raw);\n    if (!parsed || typeof parsed !== 'object') return null;\n    return parsed as Record<string, KanbanIssueComposerEntry | undefined>;\n  } catch {\n    return null;\n  }\n}\n\nfunction writeStoredComposerState(\n  byKey: Record<string, KanbanIssueComposerEntry | undefined>\n): void {\n  try {\n    const filtered: Record<string, KanbanIssueComposerEntry> = {};\n    for (const [key, entry] of Object.entries(byKey)) {\n      if (entry) filtered[key] = entry;\n    }\n\n    if (Object.keys(filtered).length === 0) {\n      localStorage.removeItem(STORAGE_KEY);\n    } else {\n      localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));\n    }\n  } catch {\n    // Quota exceeded or unavailable\n  }\n}\n\n/**\n * Syncs KanbanIssueComposerStore to localStorage on remote-web.\n * No-op on local runtime. Call once at the app root level.\n *\n * Hydration happens synchronously on first call (before any effects)\n * to avoid race conditions with React StrictMode double-mounting.\n */\nexport function useKanbanIssueComposerScratch() {\n  const runtime = useAppRuntime();\n  const isRemote = runtime === 'remote';\n  const isApplyingRef = useRef(false);\n  const hasHydratedRef = useRef(false);\n  const prevByKeyRef = useRef(useKanbanIssueComposerStore.getState().byKey);\n\n  // Hydrate synchronously during render (not in an effect) to ensure\n  // the store has data before any child components mount.\n  // This avoids StrictMode double-mount issues where effects run,\n  // clean up, then run again — but refs persist across that cycle.\n  if (isRemote && !hasHydratedRef.current) {\n    hasHydratedRef.current = true;\n    const stored = readStoredComposerState();\n    if (stored && Object.keys(stored).length > 0) {\n      const current = useKanbanIssueComposerStore.getState().byKey;\n      const merged = { ...stored, ...current };\n      isApplyingRef.current = true;\n      useKanbanIssueComposerStore.setState({ byKey: merged });\n      isApplyingRef.current = false;\n      prevByKeyRef.current = merged;\n    }\n  }\n\n  useEffect(() => {\n    if (!isRemote) return;\n\n    const unsubscribe = useKanbanIssueComposerStore.subscribe((state) => {\n      if (isApplyingRef.current) return;\n      if (prevByKeyRef.current === state.byKey) return;\n      prevByKeyRef.current = state.byKey;\n      writeStoredComposerState(state.byKey);\n    });\n\n    return unsubscribe;\n  }, [isRemote]);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useLocalStorageScratch.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport type { ScratchType, Scratch, UpdateScratch } from 'shared/types';\nimport type { UseScratchResult } from './useScratch';\n\nconst STORAGE_PREFIX = 'vk-scratch';\n\nfunction buildStorageKey(scratchType: ScratchType, id: string): string {\n  return `${STORAGE_PREFIX}:${scratchType}:${id}`;\n}\n\nfunction readFromStorage(key: string): Scratch | null {\n  try {\n    const raw = localStorage.getItem(key);\n    if (!raw) return null;\n    return JSON.parse(raw) as Scratch;\n  } catch {\n    return null;\n  }\n}\n\nfunction writeToStorage(key: string, scratch: Scratch): void {\n  try {\n    localStorage.setItem(key, JSON.stringify(scratch));\n  } catch {\n    // Quota exceeded or unavailable — silently drop the write\n  }\n}\n\nfunction removeFromStorage(key: string): void {\n  try {\n    localStorage.removeItem(key);\n  } catch {\n    // Ignore errors\n  }\n}\n\nfunction buildScratchEntry(\n  id: string,\n  update: UpdateScratch,\n  existing: Scratch | null\n): Scratch {\n  const now = new Date().toISOString();\n  return {\n    id: existing?.id ?? id,\n    payload: update.payload,\n    created_at: existing?.created_at ?? now,\n    updated_at: now,\n  };\n}\n\nexport function localStorageScratchUpdate(\n  scratchType: ScratchType,\n  id: string,\n  update: UpdateScratch\n): boolean {\n  const key = buildStorageKey(scratchType, id);\n  const previousRaw = (() => {\n    try {\n      return localStorage.getItem(key);\n    } catch {\n      return null;\n    }\n  })();\n\n  const next = buildScratchEntry(id, update, readFromStorage(key));\n  const nextRaw = JSON.stringify(next);\n\n  try {\n    localStorage.setItem(key, nextRaw);\n  } catch {\n    return false;\n  }\n\n  try {\n    window.dispatchEvent(\n      new StorageEvent('storage', {\n        key,\n        oldValue: previousRaw,\n        newValue: nextRaw,\n        storageArea: localStorage,\n      })\n    );\n  } catch {}\n\n  return true;\n}\n\ninterface UseLocalStorageScratchOptions {\n  enabled?: boolean;\n}\n\n/**\n * localStorage-backed scratch storage for remote-web.\n * Mirrors the same interface as the WebSocket-based `useScratch` hook\n * so consumers can swap between them transparently.\n */\nexport const useLocalStorageScratch = (\n  scratchType: ScratchType,\n  id: string,\n  options?: UseLocalStorageScratchOptions\n): UseScratchResult => {\n  const enabled = (options?.enabled ?? true) && id.length > 0;\n  const storageKey = buildStorageKey(scratchType, id);\n\n  const [scratch, setScratch] = useState<Scratch | null>(() =>\n    enabled ? readFromStorage(storageKey) : null\n  );\n  const [loadedKey, setLoadedKey] = useState<string | null>(\n    enabled ? storageKey : null\n  );\n\n  useEffect(() => {\n    if (!enabled) {\n      setScratch(null);\n      setLoadedKey(null);\n      return;\n    }\n\n    const stored = readFromStorage(storageKey);\n    setScratch(stored);\n    setLoadedKey(storageKey);\n  }, [storageKey, enabled]);\n\n  useEffect(() => {\n    if (!enabled) return;\n\n    function onStorage(e: StorageEvent) {\n      if (e.key !== storageKey) return;\n      if (e.newValue === null) {\n        setScratch(null);\n      } else {\n        try {\n          setScratch(JSON.parse(e.newValue) as Scratch);\n        } catch {\n          // corrupt value — ignore\n        }\n      }\n    }\n\n    window.addEventListener('storage', onStorage);\n    return () => window.removeEventListener('storage', onStorage);\n  }, [storageKey, enabled]);\n\n  const updateScratch = useCallback(\n    async (update: UpdateScratch) => {\n      const next = buildScratchEntry(id, update, readFromStorage(storageKey));\n      writeToStorage(storageKey, next);\n      setScratch(next);\n    },\n    [storageKey, id]\n  );\n\n  const deleteScratch = useCallback(async () => {\n    removeFromStorage(storageKey);\n    setScratch(null);\n  }, [storageKey]);\n\n  return {\n    scratch,\n    isLoading: enabled && loadedKey !== storageKey,\n    isConnected: true,\n    error: null,\n    updateScratch,\n    deleteScratch,\n  };\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useLogStream.ts",
    "content": "import { useEffect, useState, useRef } from 'react';\nimport type { PatchType } from 'shared/types';\nimport { openLocalApiWebSocket } from '@/shared/lib/localApiTransport';\n\ntype LogEntry = Extract<PatchType, { type: 'STDOUT' } | { type: 'STDERR' }>;\n\ninterface UseLogStreamResult {\n  logs: LogEntry[];\n  error: string | null;\n}\n\nexport const useLogStream = (processId: string): UseLogStreamResult => {\n  const [logs, setLogs] = useState<LogEntry[]>([]);\n  const [error, setError] = useState<string | null>(null);\n  const wsRef = useRef<WebSocket | null>(null);\n  const retryCountRef = useRef<number>(0);\n  const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const isIntentionallyClosed = useRef<boolean>(false);\n  // Track current processId to prevent stale WebSocket messages from contaminating logs\n  const currentProcessIdRef = useRef<string>(processId);\n\n  useEffect(() => {\n    if (!processId) {\n      return;\n    }\n\n    let cancelled = false;\n\n    // Update the ref to track the current processId\n    currentProcessIdRef.current = processId;\n\n    // Clear logs when process changes\n    setLogs([]);\n    setError(null);\n\n    const open = () => {\n      // Capture processId at the time of opening the WebSocket\n      const capturedProcessId = processId;\n      void (async () => {\n        try {\n          const ws = await openLocalApiWebSocket(\n            `/api/execution-processes/${processId}/raw-logs/ws`\n          );\n\n          if (cancelled || currentProcessIdRef.current !== capturedProcessId) {\n            ws.close();\n            return;\n          }\n\n          wsRef.current = ws;\n          isIntentionallyClosed.current = false;\n\n          ws.onopen = () => {\n            // Ignore if processId has changed since WebSocket was opened\n            if (\n              cancelled ||\n              currentProcessIdRef.current !== capturedProcessId\n            ) {\n              ws.close();\n              return;\n            }\n            setError(null);\n            // Reset logs on new connection since server replays history\n            setLogs([]);\n            retryCountRef.current = 0;\n          };\n\n          const addLogEntry = (entry: LogEntry) => {\n            // Only add log entry if this WebSocket is still for the current process\n            if (\n              cancelled ||\n              currentProcessIdRef.current !== capturedProcessId\n            ) {\n              return;\n            }\n            setLogs((prev) => [...prev, entry]);\n          };\n\n          // Handle WebSocket messages\n          ws.onmessage = (event) => {\n            try {\n              const data = JSON.parse(event.data);\n\n              // Handle different message types based on LogMsg enum\n              if ('JsonPatch' in data) {\n                const patches = data.JsonPatch as Array<{ value?: PatchType }>;\n                patches.forEach((patch) => {\n                  const value = patch?.value;\n                  if (!value || !value.type) return;\n\n                  switch (value.type) {\n                    case 'STDOUT':\n                    case 'STDERR':\n                      addLogEntry({ type: value.type, content: value.content });\n                      break;\n                    // Ignore other patch types (NORMALIZED_ENTRY, DIFF, etc.)\n                    default:\n                      break;\n                  }\n                });\n              } else if (data.finished === true) {\n                isIntentionallyClosed.current = true;\n                ws.close();\n              }\n            } catch (e) {\n              console.error('Failed to parse message:', e);\n            }\n          };\n\n          ws.onerror = () => {\n            // Ignore errors from stale WebSocket connections\n            if (\n              cancelled ||\n              currentProcessIdRef.current !== capturedProcessId\n            ) {\n              return;\n            }\n            setError('Connection failed');\n          };\n\n          ws.onclose = (event) => {\n            // Don't retry for stale WebSocket connections\n            if (\n              cancelled ||\n              currentProcessIdRef.current !== capturedProcessId\n            ) {\n              return;\n            }\n            // Only retry if the close was not intentional and not a normal closure\n            if (!isIntentionallyClosed.current && event.code !== 1000) {\n              const next = retryCountRef.current + 1;\n              retryCountRef.current = next;\n              if (next <= 6) {\n                const delay = Math.min(1500, 250 * 2 ** (next - 1));\n                retryTimerRef.current = setTimeout(() => open(), delay);\n              }\n            }\n          };\n        } catch (error) {\n          if (cancelled || currentProcessIdRef.current !== capturedProcessId) {\n            return;\n          }\n          setError('Connection failed');\n          const next = retryCountRef.current + 1;\n          retryCountRef.current = next;\n          if (next <= 6) {\n            const delay = Math.min(1500, 250 * 2 ** (next - 1));\n            retryTimerRef.current = setTimeout(() => open(), delay);\n          }\n        }\n      })();\n    };\n\n    open();\n\n    return () => {\n      cancelled = true;\n      if (wsRef.current) {\n        isIntentionallyClosed.current = true;\n        wsRef.current.close();\n        wsRef.current = null;\n      }\n      if (retryTimerRef.current) {\n        clearTimeout(retryTimerRef.current);\n        retryTimerRef.current = null;\n      }\n    };\n  }, [processId]);\n\n  return { logs, error };\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useLogsPanel.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { LogsPanelContent } from '@/shared/types/actions';\n\nexport interface LogsPanelContextValue {\n  logsPanelContent: LogsPanelContent | null;\n  logSearchQuery: string;\n  logMatchIndices: number[];\n  logCurrentMatchIdx: number;\n  setLogSearchQuery: (query: string) => void;\n  setLogMatchIndices: (indices: number[]) => void;\n  handleLogPrevMatch: () => void;\n  handleLogNextMatch: () => void;\n  viewProcessInPanel: (processId: string) => void;\n  viewToolContentInPanel: (\n    toolName: string,\n    content: string,\n    command?: string\n  ) => void;\n  expandTerminal: () => void;\n  collapseTerminal: () => void;\n  isTerminalExpanded: boolean;\n}\n\nexport interface LogsPanelActionsContextValue {\n  viewProcessInPanel: (processId: string) => void;\n  viewToolContentInPanel: (\n    toolName: string,\n    content: string,\n    command?: string\n  ) => void;\n  expandTerminal: () => void;\n  collapseTerminal: () => void;\n}\n\nconst defaultValue: LogsPanelContextValue = {\n  logsPanelContent: null,\n  logSearchQuery: '',\n  logMatchIndices: [],\n  logCurrentMatchIdx: 0,\n  setLogSearchQuery: () => {},\n  setLogMatchIndices: () => {},\n  handleLogPrevMatch: () => {},\n  handleLogNextMatch: () => {},\n  viewProcessInPanel: () => {},\n  viewToolContentInPanel: () => {},\n  expandTerminal: () => {},\n  collapseTerminal: () => {},\n  isTerminalExpanded: false,\n};\n\nconst defaultActionsValue: LogsPanelActionsContextValue = {\n  viewProcessInPanel: () => {},\n  viewToolContentInPanel: () => {},\n  expandTerminal: () => {},\n  collapseTerminal: () => {},\n};\n\nexport const LogsPanelContext = createHmrContext<LogsPanelContextValue>(\n  'LogsPanelContext',\n  defaultValue\n);\n\nexport const LogsPanelActionsContext =\n  createHmrContext<LogsPanelActionsContextValue>(\n    'LogsPanelActionsContext',\n    defaultActionsValue\n  );\n\nexport function useLogsPanel(): LogsPanelContextValue {\n  return useContext(LogsPanelContext);\n}\n\nexport function useLogsPanelActions(): LogsPanelActionsContextValue {\n  return useContext(LogsPanelActionsContext);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useMerge.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport { repoBranchKeys } from '@/shared/hooks/useRepoBranches';\n\ntype MergeParams = {\n  repoId: string;\n};\n\nexport function useMerge(\n  workspaceId?: string,\n  onSuccess?: () => void,\n  onError?: (err: unknown) => void\n) {\n  const queryClient = useQueryClient();\n\n  return useMutation<void, unknown, MergeParams>({\n    mutationFn: (params: MergeParams) => {\n      if (!workspaceId) return Promise.resolve();\n      return workspacesApi.merge(workspaceId, {\n        repo_id: params.repoId,\n      });\n    },\n    onSuccess: () => {\n      // Refresh attempt-specific branch information\n      queryClient.invalidateQueries({\n        queryKey: ['branchStatus', workspaceId],\n      });\n\n      // Invalidate all repo branches queries\n      queryClient.invalidateQueries({ queryKey: repoBranchKeys.all });\n\n      onSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to merge:', err);\n      onError?.(err);\n    },\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useNotificationMembers.ts",
    "content": "import { useMemo } from 'react';\nimport { useQueries } from '@tanstack/react-query';\nimport type { Notification } from 'shared/remote-types';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\nimport { organizationKeys } from '@/shared/hooks/organizationKeys';\nimport { organizationsApi } from '@/shared/lib/api';\n\nexport function useNotificationMembers(notifications: Notification[]) {\n  const organizationIds = useMemo(\n    () =>\n      Array.from(\n        new Set(\n          notifications.map((notification) => notification.organization_id)\n        )\n      ),\n    [notifications]\n  );\n\n  const memberQueries = useQueries({\n    queries: organizationIds.map((organizationId) => ({\n      queryKey: organizationKeys.members(organizationId),\n      queryFn: () => organizationsApi.getMembers(organizationId),\n      enabled: Boolean(organizationId),\n      staleTime: 5 * 60 * 1000,\n    })),\n  });\n\n  const membersByUserId = useMemo(() => {\n    const map = new Map<string, OrganizationMemberWithProfile>();\n\n    for (const query of memberQueries) {\n      for (const member of query.data ?? []) {\n        map.set(member.user_id, member);\n      }\n    }\n\n    return map;\n  }, [memberQueries]);\n\n  return {\n    membersByUserId,\n    isLoading: memberQueries.some((query) => query.isLoading),\n    isFetching: memberQueries.some((query) => query.isFetching),\n    isError: memberQueries.some((query) => query.isError),\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useNotifications.ts",
    "content": "import { useMemo } from 'react';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport {\n  NOTIFICATIONS_SHAPE,\n  NOTIFICATION_MUTATION,\n} from 'shared/remote-types';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport { groupNotifications } from '@/shared/lib/notifications';\n\nexport function useNotifications() {\n  const { isSignedIn, userId } = useAuth();\n\n  const enabled = isSignedIn && !!userId;\n\n  const result = useShape(\n    NOTIFICATIONS_SHAPE,\n    {\n      user_id: userId || '',\n    },\n    {\n      enabled,\n      mutation: NOTIFICATION_MUTATION,\n    }\n  );\n\n  const groupedNotifications = useMemo(\n    () => groupNotifications(result.data),\n    [result.data]\n  );\n\n  const unseenCount = useMemo(\n    () => groupedNotifications.filter((group) => !group.seen).length,\n    [groupedNotifications]\n  );\n\n  return {\n    ...result,\n    enabled,\n    groupedNotifications,\n    unseenCount,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useOpenInEditor.ts",
    "content": "import { useCallback } from 'react';\nimport { workspacesApi } from '@/shared/lib/api';\nimport { EditorSelectionDialog } from '@/shared/dialogs/command-bar/EditorSelectionDialog';\nimport type { EditorType } from 'shared/types';\n\ntype OpenEditorOptions = {\n  editorType?: EditorType;\n  filePath?: string;\n};\n\nexport function useOpenInEditor(\n  workspaceId?: string,\n  onShowEditorDialog?: () => void\n) {\n  return useCallback(\n    async (options?: OpenEditorOptions): Promise<void> => {\n      if (!workspaceId) return;\n\n      const { editorType, filePath } = options ?? {};\n\n      try {\n        const response = await workspacesApi.openEditor(workspaceId, {\n          editor_type: editorType ?? null,\n          file_path: filePath ?? null,\n        });\n\n        // If a URL is returned, open it in a new window/tab\n        if (response.url) {\n          window.open(response.url, '_blank');\n        }\n      } catch (err) {\n        console.error('Failed to open editor:', err);\n        if (!editorType) {\n          if (onShowEditorDialog) {\n            onShowEditorDialog();\n          } else {\n            EditorSelectionDialog.show({\n              selectedAttemptId: workspaceId,\n              filePath,\n            });\n          }\n        }\n      }\n    },\n    [workspaceId, onShowEditorDialog]\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useOrgContext.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { InsertResult, MutationResult } from '@/shared/lib/electric/types';\nimport type { SyncError } from '@/shared/lib/electric/types';\nimport type {\n  Project,\n  CreateProjectRequest,\n  UpdateProjectRequest,\n} from 'shared/remote-types';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\n\nexport interface OrgContextValue {\n  organizationId: string;\n\n  // Data\n  projects: Project[];\n\n  // Loading/error state\n  isLoading: boolean;\n  error: SyncError | null;\n  retry: () => void;\n\n  // Project mutations\n  insertProject: (data: CreateProjectRequest) => InsertResult<Project>;\n  updateProject: (\n    id: string,\n    changes: Partial<UpdateProjectRequest>\n  ) => MutationResult;\n  removeProject: (id: string) => MutationResult;\n\n  // Lookup helpers\n  getProject: (projectId: string) => Project | undefined;\n\n  // Computed aggregations\n  projectsById: Map<string, Project>;\n  membersWithProfilesById: Map<string, OrganizationMemberWithProfile>;\n}\n\nexport const OrgContext = createHmrContext<OrgContextValue | null>(\n  'OrgContext',\n  null\n);\n\nexport function useOrgContext(): OrgContextValue {\n  const context = useContext(OrgContext);\n  if (!context) {\n    throw new Error('useOrgContext must be used within an OrgProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useOrganizationInvitations.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { organizationsApi } from '@/shared/lib/api';\nimport { InvitationStatus, type Invitation } from 'shared/types';\nimport { organizationKeys } from '@/shared/hooks/organizationKeys';\n\ninterface UseOrganizationInvitationsOptions {\n  organizationId: string | null;\n  isAdmin: boolean;\n  isPersonal: boolean;\n}\n\nexport function useOrganizationInvitations(\n  options: UseOrganizationInvitationsOptions\n) {\n  const { organizationId, isAdmin, isPersonal } = options;\n\n  return useQuery<Invitation[]>({\n    queryKey: organizationKeys.invitations(organizationId ?? ''),\n    queryFn: async () => {\n      if (!organizationId) {\n        throw new Error('No organization ID provided');\n      }\n      const invitations =\n        await organizationsApi.listInvitations(organizationId);\n      // Only return pending invitations\n      return invitations.filter(\n        (inv) => inv.status === InvitationStatus.PENDING\n      );\n    },\n    enabled: !!organizationId && !!isAdmin && !isPersonal,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useOrganizationMembers.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { organizationsApi } from '@/shared/lib/api';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\nimport { organizationKeys } from '@/shared/hooks/organizationKeys';\n\nexport function useOrganizationMembers(organizationId?: string) {\n  return useQuery<OrganizationMemberWithProfile[]>({\n    queryKey: organizationKeys.members(organizationId ?? ''),\n    queryFn: () => {\n      if (!organizationId) {\n        throw new Error('No organization ID available');\n      }\n      return organizationsApi.getMembers(organizationId);\n    },\n    enabled: Boolean(organizationId),\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useOrganizationMutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { organizationsApi } from '@/shared/lib/api';\nimport type {\n  MemberRole,\n  UpdateMemberRoleResponse,\n  CreateOrganizationRequest,\n  CreateOrganizationResponse,\n  CreateInvitationRequest,\n  CreateInvitationResponse,\n  ListOrganizationsResponse,\n} from 'shared/types';\nimport { organizationKeys } from '@/shared/hooks/organizationKeys';\n\ninterface UseOrganizationMutationsOptions {\n  onCreateSuccess?: (result: CreateOrganizationResponse) => void;\n  onCreateError?: (err: unknown) => void;\n  onInviteSuccess?: (result: CreateInvitationResponse) => void;\n  onInviteError?: (err: unknown) => void;\n  onRevokeSuccess?: () => void;\n  onRevokeError?: (err: unknown) => void;\n  onRemoveSuccess?: () => void;\n  onRemoveError?: (err: unknown) => void;\n  onRoleChangeSuccess?: () => void;\n  onRoleChangeError?: (err: unknown) => void;\n  onDeleteSuccess?: () => void;\n  onDeleteError?: (err: unknown) => void;\n}\n\nexport function useOrganizationMutations(\n  options?: UseOrganizationMutationsOptions\n) {\n  const queryClient = useQueryClient();\n\n  const createOrganization = useMutation({\n    mutationKey: ['createOrganization'],\n    mutationFn: (data: CreateOrganizationRequest) =>\n      organizationsApi.createOrganization(data),\n    onSuccess: (result: CreateOrganizationResponse) => {\n      // Immediately add new org to cache to prevent race condition with selection\n      queryClient.setQueryData<ListOrganizationsResponse>(\n        organizationKeys.userList(),\n        (old) => {\n          if (!old) return { organizations: [result.organization] };\n          return {\n            organizations: [...old.organizations, result.organization],\n          };\n        }\n      );\n\n      // Then invalidate to ensure server data stays fresh\n      queryClient.invalidateQueries({ queryKey: organizationKeys.userList() });\n      options?.onCreateSuccess?.(result);\n    },\n    onError: (err) => {\n      console.error('Failed to create organization:', err);\n      options?.onCreateError?.(err);\n    },\n  });\n\n  const createInvitation = useMutation({\n    mutationKey: ['createInvitation'],\n    mutationFn: ({\n      orgId,\n      data,\n    }: {\n      orgId: string;\n      data: CreateInvitationRequest;\n    }) => organizationsApi.createInvitation(orgId, data),\n    onSuccess: (result: CreateInvitationResponse, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: organizationKeys.members(variables.orgId),\n      });\n      queryClient.invalidateQueries({\n        queryKey: organizationKeys.invitations(variables.orgId),\n      });\n      options?.onInviteSuccess?.(result);\n    },\n    onError: (err) => {\n      console.error('Failed to create invitation:', err);\n      options?.onInviteError?.(err);\n    },\n  });\n\n  const revokeInvitation = useMutation({\n    mutationFn: ({\n      orgId,\n      invitationId,\n    }: {\n      orgId: string;\n      invitationId: string;\n    }) => organizationsApi.revokeInvitation(orgId, invitationId),\n    onSuccess: (_data, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: organizationKeys.members(variables.orgId),\n      });\n      queryClient.invalidateQueries({\n        queryKey: organizationKeys.invitations(variables.orgId),\n      });\n      options?.onRevokeSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to revoke invitation:', err);\n      options?.onRevokeError?.(err);\n    },\n  });\n\n  const removeMember = useMutation({\n    mutationFn: ({ orgId, userId }: { orgId: string; userId: string }) =>\n      organizationsApi.removeMember(orgId, userId),\n    onSuccess: (_data, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: organizationKeys.members(variables.orgId),\n      });\n      // Invalidate user's organizations in case we removed ourselves\n      queryClient.invalidateQueries({ queryKey: organizationKeys.userList() });\n      options?.onRemoveSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to remove member:', err);\n      options?.onRemoveError?.(err);\n    },\n  });\n\n  const updateMemberRole = useMutation<\n    UpdateMemberRoleResponse,\n    unknown,\n    { orgId: string; userId: string; role: MemberRole }\n  >({\n    mutationFn: ({ orgId, userId, role }) =>\n      organizationsApi.updateMemberRole(orgId, userId, { role }),\n    onSuccess: (_data, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: organizationKeys.members(variables.orgId),\n      });\n      // Invalidate user's organizations in case we changed our own role\n      queryClient.invalidateQueries({ queryKey: organizationKeys.userList() });\n      options?.onRoleChangeSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to update member role:', err);\n      options?.onRoleChangeError?.(err);\n    },\n  });\n\n  const refetchMembers = async (orgId: string) => {\n    await queryClient.invalidateQueries({\n      queryKey: organizationKeys.members(orgId),\n    });\n  };\n\n  const refetchInvitations = async (orgId: string) => {\n    await queryClient.invalidateQueries({\n      queryKey: organizationKeys.invitations(orgId),\n    });\n  };\n\n  const deleteOrganization = useMutation({\n    mutationFn: (orgId: string) => organizationsApi.deleteOrganization(orgId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: organizationKeys.userList() });\n      options?.onDeleteSuccess?.();\n    },\n    onError: (err) => {\n      console.error('Failed to delete organization:', err);\n      options?.onDeleteError?.(err);\n    },\n  });\n\n  return {\n    createOrganization,\n    createInvitation,\n    revokeInvitation,\n    removeMember,\n    updateMemberRole,\n    deleteOrganization,\n    refetchMembers,\n    refetchInvitations,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useOrganizationProjects.ts",
    "content": "import { useShape } from '@/shared/integrations/electric/hooks';\nimport { PROJECTS_SHAPE } from 'shared/remote-types';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\n\nexport function useOrganizationProjects(organizationId: string | null) {\n  const { isSignedIn } = useAuth();\n\n  // Only subscribe to Electric when signed in AND have an org\n  const enabled = isSignedIn && !!organizationId;\n\n  const { data, isLoading, error } = useShape(\n    PROJECTS_SHAPE,\n    { organization_id: organizationId || '' },\n    { enabled }\n  );\n\n  return {\n    data,\n    isLoading,\n    isError: !!error,\n    error,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useOrganizationSelection.ts",
    "content": "import { useCallback, useEffect, useMemo } from 'react';\nimport type {\n  OrganizationWithRole,\n  ListOrganizationsResponse,\n} from 'shared/types';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\n\ninterface UseOrganizationSelectionOptions {\n  organizations: ListOrganizationsResponse | undefined;\n  onSelectionChange?: () => void;\n}\n\nexport function useOrganizationSelection(\n  options: UseOrganizationSelectionOptions\n) {\n  const { organizations, onSelectionChange } = options;\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId);\n\n  const orgList = useMemo(\n    () => organizations?.organizations ?? [],\n    [organizations]\n  );\n\n  // Default to first available organization if none selected or selection is invalid\n  useEffect(() => {\n    if (orgList.length === 0) return;\n\n    const hasValidSelection = selectedOrgId\n      ? orgList.some((org) => org.id === selectedOrgId)\n      : false;\n\n    if (!selectedOrgId || !hasValidSelection) {\n      // Prefer first non-personal org, fallback to first org if all are personal\n      const firstNonPersonal = orgList.find((org) => !org.is_personal);\n      const fallbackId = (firstNonPersonal ?? orgList[0]).id;\n      setSelectedOrgId(fallbackId);\n    }\n  }, [orgList, selectedOrgId, setSelectedOrgId]);\n\n  // Derive the selected organization object\n  const selectedOrg = useMemo<OrganizationWithRole | null>(() => {\n    if (!selectedOrgId || orgList.length === 0) return null;\n    return orgList.find((o) => o.id === selectedOrgId) ?? null;\n  }, [selectedOrgId, orgList]);\n\n  // Handle organization selection from dropdown\n  const handleOrgSelect = useCallback(\n    (id: string) => {\n      if (id === selectedOrgId) return;\n      setSelectedOrgId(id);\n      onSelectionChange?.();\n    },\n    [selectedOrgId, setSelectedOrgId, onSelectionChange]\n  );\n\n  return {\n    selectedOrgId: selectedOrgId ?? '',\n    selectedOrg,\n    handleOrgSelect,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/usePageTitle.ts",
    "content": "import { useEffect } from 'react';\n\nconst BASE_TITLE = 'Vibe Kanban';\n\n/**\n * Sets the document title based on the given parts.\n * Multiple callers can coexist — the most specific (deepest) component wins\n * because React runs child effects after parent effects.\n *\n * No cleanup is performed on unmount so that a parent-level caller\n * (e.g. the legacy ProjectProvider) provides a stable fallback without\n * competing with page-level callers.\n */\nexport function usePageTitle(...parts: (string | null | undefined)[]) {\n  const filtered = parts.filter(Boolean) as string[];\n  const title =\n    filtered.length > 0\n      ? `${filtered.join(' - ')} | ${BASE_TITLE}`\n      : BASE_TITLE;\n\n  useEffect(() => {\n    document.title = title;\n  }, [title]);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/usePrComments.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { PrCommentsResponse } from 'shared/types';\n\nexport const prCommentsKeys = {\n  all: ['prComments'] as const,\n  byAttempt: (workspaceId: string | undefined, repoId: string | undefined) =>\n    ['prComments', workspaceId, repoId] as const,\n};\n\ntype Options = {\n  enabled?: boolean;\n};\n\nexport function usePrComments(\n  workspaceId?: string,\n  repoId?: string,\n  opts?: Options\n) {\n  const enabled = (opts?.enabled ?? true) && !!workspaceId && !!repoId;\n\n  return useQuery<PrCommentsResponse>({\n    queryKey: prCommentsKeys.byAttempt(workspaceId, repoId),\n    queryFn: () => workspacesApi.getPrComments(workspaceId!, repoId!),\n    enabled,\n    staleTime: 30_000, // Cache for 30s - comments don't change frequently\n    retry: 2,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/usePresetOptions.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { BaseCodingAgent, ExecutorConfig } from 'shared/types';\nimport { agentsApi } from '@/shared/lib/api';\n\nexport const presetOptionsKeys = {\n  all: ['preset-options'] as const,\n  byProfile: (executor: BaseCodingAgent | null, variant: string | null) =>\n    ['preset-options', executor, variant] as const,\n};\n\nexport function usePresetOptions(\n  executor: BaseCodingAgent | null,\n  variant: string | null\n) {\n  return useQuery<ExecutorConfig | null>({\n    queryKey: presetOptionsKeys.byProfile(executor, variant),\n    queryFn: () =>\n      executor ? agentsApi.getPresetOptions({ executor, variant }) : null,\n    enabled: !!executor,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/usePreviewNavigation.ts",
    "content": "import { useCallback, useRef, useState } from 'react';\nimport type {\n  NavigationState,\n  PreviewDevToolsMessage,\n} from '@/shared/types/previewDevTools';\n\nexport interface UsePreviewNavigationReturn {\n  navigation: NavigationState | null;\n  isReady: boolean;\n  handleMessage: (message: PreviewDevToolsMessage) => void;\n  reset: () => void;\n}\n\nexport function usePreviewNavigation(): UsePreviewNavigationReturn {\n  const [navigation, setNavigation] = useState<NavigationState | null>(null);\n  const [isReady, setIsReady] = useState(false);\n  const activeDocIdRef = useRef<string | null>(null);\n  const lastSeqByDocRef = useRef<Record<string, number>>({});\n  const lastTimestampRef = useRef(0);\n\n  const handleMessage = useCallback((message: PreviewDevToolsMessage) => {\n    switch (message.type) {\n      case 'navigation': {\n        const docId = message.payload.docId;\n        if (docId) {\n          const activeDocId = activeDocIdRef.current;\n          if (activeDocId && activeDocId !== docId) {\n            return;\n          }\n          if (!activeDocId) {\n            activeDocIdRef.current = docId;\n          }\n\n          const seq = message.payload.seq;\n          if (typeof seq === 'number') {\n            const lastSeq = lastSeqByDocRef.current[docId] ?? 0;\n            if (seq <= lastSeq) {\n              return;\n            }\n            lastSeqByDocRef.current[docId] = seq;\n          }\n        }\n\n        if (message.payload.timestamp < lastTimestampRef.current) {\n          return;\n        }\n        lastTimestampRef.current = message.payload.timestamp;\n\n        setNavigation({\n          url: message.payload.url,\n          title: message.payload.title,\n          canGoBack: message.payload.canGoBack,\n          canGoForward: message.payload.canGoForward,\n        });\n        break;\n      }\n      case 'ready': {\n        const readyDocId = message.payload?.docId;\n        if (readyDocId) {\n          activeDocIdRef.current = readyDocId;\n          if (!(readyDocId in lastSeqByDocRef.current)) {\n            lastSeqByDocRef.current[readyDocId] = 0;\n          }\n        }\n        setIsReady(true);\n        break;\n      }\n      default:\n        break;\n    }\n  }, []);\n\n  const reset = useCallback(() => {\n    setNavigation(null);\n    setIsReady(false);\n    activeDocIdRef.current = null;\n    lastSeqByDocRef.current = {};\n    lastTimestampRef.current = 0;\n  }, []);\n\n  return { navigation, isReady, handleMessage, reset };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/usePreviewSettings.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useScratch } from '@/shared/hooks/useScratch';\nimport { useDebouncedCallback } from '@/shared/hooks/useDebouncedCallback';\nimport {\n  ScratchType,\n  type PreviewSettingsData,\n  type ScratchPayload,\n} from 'shared/types';\n\nexport type ScreenSize = 'desktop' | 'mobile' | 'responsive';\n\nexport interface ResponsiveDimensions {\n  width: number;\n  height: number;\n}\n\ninterface UsePreviewSettingsResult {\n  // URL override\n  overrideUrl: string | null;\n  hasOverride: boolean;\n  setOverrideUrl: (url: string) => void;\n  clearOverride: () => Promise<void>;\n\n  // Screen size\n  screenSize: ScreenSize;\n  responsiveDimensions: ResponsiveDimensions;\n  setScreenSize: (size: ScreenSize) => void;\n  setResponsiveDimensions: (dimensions: ResponsiveDimensions) => void;\n\n  isLoading: boolean;\n}\n\nconst DEFAULT_RESPONSIVE_DIMENSIONS: ResponsiveDimensions = {\n  width: 800,\n  height: 600,\n};\n\n/**\n * Hook to manage per-workspace preview settings (URL override and screen size).\n * Uses the scratch system for persistence.\n */\nexport function usePreviewSettings(\n  workspaceId: string | undefined\n): UsePreviewSettingsResult {\n  const enabled = !!workspaceId;\n\n  const {\n    scratch,\n    updateScratch,\n    deleteScratch,\n    isLoading: isScratchLoading,\n  } = useScratch(ScratchType.PREVIEW_SETTINGS, workspaceId ?? '', {\n    enabled,\n  });\n\n  // Extract settings from scratch data\n  const payload = scratch?.payload as ScratchPayload | undefined;\n  const scratchData: PreviewSettingsData | undefined =\n    payload?.type === 'PREVIEW_SETTINGS' ? payload.data : undefined;\n\n  const overrideUrl = scratchData?.url ?? null;\n  const hasOverride = overrideUrl !== null && overrideUrl.trim() !== '';\n\n  const screenSize: ScreenSize =\n    (scratchData?.screen_size as ScreenSize) ?? 'desktop';\n  const responsiveDimensions: ResponsiveDimensions = useMemo(\n    () => ({\n      width:\n        scratchData?.responsive_width ?? DEFAULT_RESPONSIVE_DIMENSIONS.width,\n      height:\n        scratchData?.responsive_height ?? DEFAULT_RESPONSIVE_DIMENSIONS.height,\n    }),\n    [scratchData?.responsive_width, scratchData?.responsive_height]\n  );\n\n  // Helper to save settings\n  const saveSettings = useCallback(\n    async (updates: Partial<PreviewSettingsData>) => {\n      if (!workspaceId) return;\n\n      try {\n        await updateScratch({\n          payload: {\n            type: 'PREVIEW_SETTINGS',\n            data: {\n              url: updates.url ?? overrideUrl ?? '',\n              screen_size: updates.screen_size ?? screenSize,\n              responsive_width:\n                updates.responsive_width ?? responsiveDimensions.width,\n              responsive_height:\n                updates.responsive_height ?? responsiveDimensions.height,\n            },\n          },\n        });\n      } catch (e) {\n        console.error('[usePreviewSettings] Failed to save:', e);\n      }\n    },\n    [\n      workspaceId,\n      updateScratch,\n      overrideUrl,\n      screenSize,\n      responsiveDimensions.width,\n      responsiveDimensions.height,\n    ]\n  );\n\n  // Debounced save for URL changes (frequent typing)\n  const { debounced: debouncedSaveUrl } = useDebouncedCallback(\n    async (url: string) => {\n      await saveSettings({ url });\n    },\n    300\n  );\n\n  // Debounced save for responsive dimensions (frequent dragging)\n  const { debounced: debouncedSaveDimensions } = useDebouncedCallback(\n    async (dimensions: ResponsiveDimensions) => {\n      await saveSettings({\n        responsive_width: dimensions.width,\n        responsive_height: dimensions.height,\n      });\n    },\n    300\n  );\n\n  const setOverrideUrl = useCallback(\n    (url: string) => {\n      debouncedSaveUrl(url);\n    },\n    [debouncedSaveUrl]\n  );\n\n  const setScreenSize = useCallback(\n    (size: ScreenSize) => {\n      saveSettings({ screen_size: size });\n    },\n    [saveSettings]\n  );\n\n  const setResponsiveDimensions = useCallback(\n    (dimensions: ResponsiveDimensions) => {\n      debouncedSaveDimensions(dimensions);\n    },\n    [debouncedSaveDimensions]\n  );\n\n  const clearOverride = useCallback(async () => {\n    try {\n      await deleteScratch();\n    } catch (e) {\n      // Ignore 404 errors when scratch doesn't exist\n      console.error('[usePreviewSettings] Failed to clear:', e);\n    }\n  }, [deleteScratch]);\n\n  return {\n    overrideUrl,\n    hasOverride,\n    setOverrideUrl,\n    clearOverride,\n    screenSize,\n    responsiveDimensions,\n    setScreenSize,\n    setResponsiveDimensions,\n    isLoading: isScratchLoading,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/usePreviewUrl.ts",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport { stripAnsi } from 'fancy-ansi';\n\nexport interface PreviewUrlInfo {\n  url: string;\n  port?: number;\n  scheme: 'http' | 'https';\n}\n\nconst urlPatterns = [\n  // Full URL pattern (e.g., http://localhost:3000, https://127.0.0.1:8080)\n  /(https?:\\/\\/(?:\\[[0-9a-f:]+\\]|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\d{1,3}(?:\\.\\d{1,3}){3})(?::\\d{2,5})?(?:\\/\\S*)?)/i,\n  // Host:port pattern (e.g., localhost:3000, 0.0.0.0:8080)\n  /((?:localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[[0-9a-f:]+\\]|(?:\\d{1,3}\\.){3}\\d{1,3})):(\\d{2,5})/gi,\n];\nconst LOG_SCAN_BUFFER_LIMIT = 16 * 1024;\n\nconst LOOPBACK_HOSTS = new Set([\n  'localhost',\n  '127.0.0.1',\n  '0.0.0.0',\n  '::',\n  '[::]',\n]);\n\nconst isIpv4Host = (host: string): boolean =>\n  /^\\d{1,3}(?:\\.\\d{1,3}){3}$/.test(host);\n\nconst normalizeDetectedHost = (host: string): string => {\n  const normalized = host.toLowerCase();\n  if (LOOPBACK_HOSTS.has(normalized)) {\n    return 'localhost';\n  }\n\n  // Dev servers often print network/private IP addresses in addition to Local.\n  // We keep preview stable by preferring localhost for these cases.\n  if (isIpv4Host(normalized)) {\n    return 'localhost';\n  }\n\n  return host;\n};\n\nconst getUrlParts = (\n  url: string\n): { hostname: string; port: string } | null => {\n  try {\n    const parsed = new URL(url);\n    return {\n      hostname: parsed.hostname,\n      port: parsed.port,\n    };\n  } catch {\n    return null;\n  }\n};\n\nconst isLocalPreviewUrl = (url: string): boolean => {\n  const parsed = getUrlParts(url);\n  if (!parsed) return false;\n  return normalizeDetectedHost(parsed.hostname) === 'localhost';\n};\n\nconst isBetterPreviewUrlCandidate = (\n  candidate: PreviewUrlInfo,\n  current: PreviewUrlInfo\n): boolean => {\n  if (candidate.url === current.url) {\n    return false;\n  }\n\n  const candidateIsLocal = isLocalPreviewUrl(candidate.url);\n  const currentIsLocal = isLocalPreviewUrl(current.url);\n  if (candidateIsLocal && !currentIsLocal) {\n    return true;\n  }\n  if (!candidateIsLocal && currentIsLocal) {\n    return false;\n  }\n\n  return false;\n};\n\nconst getVibeKanbanPort = (): string | null => {\n  if (typeof window !== 'undefined' && window.location.port) {\n    return window.location.port;\n  }\n  return null;\n};\n\nconst isStandaloneHostPortMatch = (\n  source: string,\n  startIndex: number,\n  matchedText: string\n): boolean => {\n  const before = startIndex > 0 ? source[startIndex - 1] : '';\n  const afterIndex = startIndex + matchedText.length;\n  const after = afterIndex < source.length ? source[afterIndex] : '';\n\n  // Ignore embedded matches such as \"4000.localhost:3009\" where the detected\n  // \"localhost:3009\" is just a suffix of a larger hostname.\n  if (before && /[A-Za-z0-9_.-]/.test(before)) {\n    return false;\n  }\n\n  // Reject if token keeps going with hostname-safe chars.\n  if (after && /[A-Za-z0-9_.-]/.test(after)) {\n    return false;\n  }\n\n  return true;\n};\n\nconst trimMatchedUrlCandidate = (raw: string): string => {\n  let candidate = raw.trim();\n\n  while (\n    candidate.length > 0 &&\n    ['\"', \"'\", '`', '<', '(', '[', '{'].includes(candidate[0])\n  ) {\n    candidate = candidate.slice(1).trimStart();\n  }\n\n  while (\n    candidate.length > 0 &&\n    ['\"', \"'\", '`', '>', ')', ']', '}', ',', ';'].includes(\n      candidate[candidate.length - 1]\n    )\n  ) {\n    candidate = candidate.slice(0, -1).trimEnd();\n  }\n\n  return candidate;\n};\n\nconst toOriginUrlInfo = (\n  parsed: URL,\n  scheme: 'http' | 'https'\n): PreviewUrlInfo => {\n  const originOnly = new URL(parsed.origin);\n  originOnly.pathname = '/';\n  originOnly.search = '';\n  originOnly.hash = '';\n  return {\n    url: originOnly.toString(),\n    port: parsed.port ? Number(parsed.port) : undefined,\n    scheme,\n  };\n};\n\nexport const detectPreviewUrl = (line: string): PreviewUrlInfo | null => {\n  const cleaned = stripAnsi(line);\n  // Some dev servers split terminal output into chunks, which can break\n  // ports as `:40\\n00`. Collapse whitespace inside the port before matching.\n  const normalized = cleaned.replace(\n    /:(\\d(?:[\\d\\s]{0,8}\\d))(?=\\/|\\s|$)/g,\n    (_match, rawPort) => `:${rawPort.replace(/\\s+/g, '')}`\n  );\n  const vibeKanbanPort = getVibeKanbanPort();\n\n  const fullUrlMatch = urlPatterns[0].exec(normalized);\n  if (fullUrlMatch) {\n    try {\n      const candidateUrl = trimMatchedUrlCandidate(fullUrlMatch[1]);\n      const parsed = new URL(candidateUrl);\n      const normalizedHost = normalizeDetectedHost(parsed.hostname);\n      const isLocalhost = normalizedHost === 'localhost';\n\n      if (isLocalhost && !parsed.port) {\n        // Fall through to host:port pattern detection\n      } else {\n        parsed.hostname = normalizedHost;\n\n        if (vibeKanbanPort && parsed.port === vibeKanbanPort) {\n          return null;\n        }\n\n        const scheme = parsed.protocol === 'https:' ? 'https' : 'http';\n        return toOriginUrlInfo(parsed, scheme);\n      }\n    } catch {\n      // Ignore invalid URLs and fall through to host:port detection\n    }\n  }\n\n  const hostPortPattern = new RegExp(urlPatterns[1]);\n  let hostPortMatch: RegExpExecArray | null;\n\n  while ((hostPortMatch = hostPortPattern.exec(normalized)) !== null) {\n    if (\n      !isStandaloneHostPortMatch(\n        normalized,\n        hostPortMatch.index,\n        hostPortMatch[0]\n      )\n    ) {\n      continue;\n    }\n\n    const host = normalizeDetectedHost(hostPortMatch[1]);\n    const port = Number(hostPortMatch[2]);\n\n    if (vibeKanbanPort && String(port) === vibeKanbanPort) {\n      continue;\n    }\n\n    const scheme = /https/i.test(normalized) ? 'https' : 'http';\n    const originOnly = new URL(`${scheme}://${host}:${port}`);\n    originOnly.pathname = '/';\n    originOnly.search = '';\n    originOnly.hash = '';\n    return {\n      url: originOnly.toString(),\n      port,\n      scheme: scheme as 'http' | 'https',\n    };\n  }\n\n  return null;\n};\n\nfunction detectPreviewUrlFromBuffer(\n  buffer: string,\n  blockedPort?: number\n): PreviewUrlInfo | null {\n  const lines = buffer.split(/\\r?\\n/);\n  let best: PreviewUrlInfo | null = null;\n\n  // Prefer the newest entries first so stale older matches don't block detection.\n  for (let i = lines.length - 1; i >= 0; i -= 1) {\n    const line = lines[i];\n    if (!line) continue;\n\n    const detected = detectPreviewUrl(line);\n    if (!detected || (blockedPort && detected.port === blockedPort)) {\n      continue;\n    }\n\n    if (!best || isBetterPreviewUrlCandidate(detected, best)) {\n      best = detected;\n    }\n  }\n  if (best) return best;\n\n  // Fallback for URLs split across chunk boundaries where line-by-line matching fails.\n  const fallback = detectPreviewUrl(buffer);\n  if (fallback && blockedPort && fallback.port === blockedPort) {\n    return null;\n  }\n  return fallback;\n}\n\nexport function usePreviewUrl(\n  logs: Array<{ content: string }> | undefined,\n  previewProxyPort?: number\n): PreviewUrlInfo | undefined {\n  const [urlInfo, setUrlInfo] = useState<PreviewUrlInfo | undefined>();\n  const lastIndexRef = useRef(0);\n  const logBufferRef = useRef('');\n\n  useEffect(() => {\n    if (!logs) {\n      setUrlInfo(undefined);\n      lastIndexRef.current = 0;\n      logBufferRef.current = '';\n      return;\n    }\n\n    // Reset if logs were cleared (new process started)\n    if (logs.length < lastIndexRef.current) {\n      lastIndexRef.current = 0;\n      setUrlInfo(undefined);\n      logBufferRef.current = '';\n    }\n\n    const hasBlockedUrl =\n      Boolean(previewProxyPort) && urlInfo?.port === previewProxyPort;\n    if (hasBlockedUrl) {\n      setUrlInfo(undefined);\n      lastIndexRef.current = 0;\n      logBufferRef.current = '';\n    }\n\n    // Scan new log entries for URL\n    let detectedUrl: PreviewUrlInfo | undefined;\n    const newEntries = logs.slice(lastIndexRef.current);\n    if (newEntries.length > 0) {\n      const chunk = newEntries.map((entry) => entry.content).join('');\n      const merged = `${logBufferRef.current}${chunk}`;\n      logBufferRef.current =\n        merged.length > LOG_SCAN_BUFFER_LIMIT\n          ? merged.slice(-LOG_SCAN_BUFFER_LIMIT)\n          : merged;\n      detectedUrl =\n        detectPreviewUrlFromBuffer(logBufferRef.current, previewProxyPort) ??\n        undefined;\n    }\n\n    if (detectedUrl) {\n      setUrlInfo((prev) => {\n        if (!prev) return detectedUrl;\n        return isBetterPreviewUrlCandidate(detectedUrl, prev)\n          ? detectedUrl\n          : prev;\n      });\n    }\n\n    lastIndexRef.current = logs.length;\n  }, [logs, urlInfo, previewProxyPort]);\n\n  return urlInfo;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useProfiles.ts",
    "content": "import { useMemo } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { profilesApi } from '@/shared/lib/api';\nimport type { JsonValue } from 'shared/types';\nimport { presetOptionsKeys } from '@/shared/hooks/usePresetOptions';\n\nexport type UseProfilesReturn = {\n  // data\n  profilesContent: string;\n  parsedProfiles: JsonValue | null;\n  profilesPath: string;\n\n  // status\n  isLoading: boolean;\n  isError: boolean;\n  error: unknown;\n  isSaving: boolean;\n\n  // actions\n  refetch: () => void;\n  save: (content: string) => Promise<void>;\n  saveParsed: (obj: unknown) => Promise<void>;\n};\n\nexport function useProfiles(): UseProfilesReturn {\n  const queryClient = useQueryClient();\n\n  const { data, isLoading, isError, error, refetch } = useQuery({\n    queryKey: ['profiles'],\n    queryFn: () => profilesApi.load(),\n    staleTime: 1000 * 60, // 1 minute cache\n  });\n\n  const { mutateAsync: saveMutation, isPending: isSaving } = useMutation({\n    mutationFn: (content: string) => profilesApi.save(content),\n    onSuccess: (_, content) => {\n      // Optimistically update cache with new content\n      queryClient.setQueryData<{ content: string; path: string }>(\n        ['profiles'],\n        (old) => (old ? { ...old, content } : old)\n      );\n      void queryClient.invalidateQueries({\n        queryKey: presetOptionsKeys.all,\n      });\n    },\n  });\n\n  const save = async (content: string): Promise<void> => {\n    await saveMutation(content);\n  };\n\n  const parsedProfiles = useMemo(() => {\n    if (!data?.content) return null;\n    try {\n      return JSON.parse(data.content);\n    } catch {\n      return null;\n    }\n  }, [data?.content]);\n\n  const saveParsed = async (obj: unknown) => {\n    await save(JSON.stringify(obj, null, 2));\n  };\n\n  return {\n    profilesContent: data?.content ?? '',\n    parsedProfiles,\n    profilesPath: data?.path ?? '',\n    isLoading,\n    isError,\n    error,\n    isSaving,\n    refetch,\n    save,\n    saveParsed,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useProjectContext.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { InsertResult, MutationResult } from '@/shared/lib/electric/types';\nimport type { SyncError } from '@/shared/lib/electric/types';\nimport type {\n  Issue,\n  ProjectStatus,\n  Tag,\n  IssueAssignee,\n  IssueFollower,\n  IssueTag,\n  IssueRelationship,\n  PullRequest,\n  Workspace,\n  CreateIssueRequest,\n  UpdateIssueRequest,\n  CreateProjectStatusRequest,\n  UpdateProjectStatusRequest,\n  CreateTagRequest,\n  UpdateTagRequest,\n  CreateIssueAssigneeRequest,\n  CreateIssueFollowerRequest,\n  CreateIssueTagRequest,\n  CreateIssueRelationshipRequest,\n} from 'shared/remote-types';\n\n/**\n * ProjectContext provides project-scoped data and mutations.\n *\n * Entities synced at project scope:\n * - Issues (data + mutations)\n * - ProjectStatuses (data + mutations)\n * - Tags (data + mutations)\n * - IssueAssignees (data + mutations)\n * - IssueFollowers (data + mutations)\n * - IssueTags (data + mutations)\n * - IssueRelationships (data + mutations)\n * - PullRequests (data only)\n * - Workspaces (data only)\n */\nexport interface ProjectContextValue {\n  projectId: string;\n\n  // Normalized data arrays\n  issues: Issue[];\n  statuses: ProjectStatus[];\n  tags: Tag[];\n  issueAssignees: IssueAssignee[];\n  issueFollowers: IssueFollower[];\n  issueTags: IssueTag[];\n  issueRelationships: IssueRelationship[];\n  pullRequests: PullRequest[];\n  workspaces: Workspace[];\n\n  // Loading/error state\n  isLoading: boolean;\n  error: SyncError | null;\n  retry: () => void;\n\n  // Issue mutations\n  insertIssue: (data: CreateIssueRequest) => InsertResult<Issue>;\n  updateIssue: (\n    id: string,\n    changes: Partial<UpdateIssueRequest>\n  ) => MutationResult;\n  removeIssue: (id: string) => MutationResult;\n\n  // Status mutations\n  insertStatus: (\n    data: CreateProjectStatusRequest\n  ) => InsertResult<ProjectStatus>;\n  updateStatus: (\n    id: string,\n    changes: Partial<UpdateProjectStatusRequest>\n  ) => MutationResult;\n  removeStatus: (id: string) => MutationResult;\n\n  // Tag mutations\n  insertTag: (data: CreateTagRequest) => InsertResult<Tag>;\n  updateTag: (id: string, changes: Partial<UpdateTagRequest>) => MutationResult;\n  removeTag: (id: string) => MutationResult;\n\n  // IssueAssignee mutations\n  insertIssueAssignee: (\n    data: CreateIssueAssigneeRequest\n  ) => InsertResult<IssueAssignee>;\n  removeIssueAssignee: (id: string) => MutationResult;\n\n  // IssueFollower mutations\n  insertIssueFollower: (\n    data: CreateIssueFollowerRequest\n  ) => InsertResult<IssueFollower>;\n  removeIssueFollower: (id: string) => MutationResult;\n\n  // IssueTag mutations\n  insertIssueTag: (data: CreateIssueTagRequest) => InsertResult<IssueTag>;\n  removeIssueTag: (id: string) => MutationResult;\n\n  // IssueRelationship mutations\n  insertIssueRelationship: (\n    data: CreateIssueRelationshipRequest\n  ) => InsertResult<IssueRelationship>;\n  removeIssueRelationship: (id: string) => MutationResult;\n\n  // Lookup helpers\n  getIssue: (issueId: string) => Issue | undefined;\n  getIssuesForStatus: (statusId: string) => Issue[];\n  getAssigneesForIssue: (issueId: string) => IssueAssignee[];\n  getFollowersForIssue: (issueId: string) => IssueFollower[];\n  getTagsForIssue: (issueId: string) => IssueTag[];\n  getTagObjectsForIssue: (issueId: string) => Tag[];\n  getRelationshipsForIssue: (issueId: string) => IssueRelationship[];\n  getStatus: (statusId: string) => ProjectStatus | undefined;\n  getTag: (tagId: string) => Tag | undefined;\n  getPullRequestsForIssue: (issueId: string) => PullRequest[];\n  getWorkspacesForIssue: (issueId: string) => Workspace[];\n\n  // Computed aggregations (Maps for O(1) lookup)\n  issuesById: Map<string, Issue>;\n  statusesById: Map<string, ProjectStatus>;\n  tagsById: Map<string, Tag>;\n}\n\nexport const ProjectContext = createHmrContext<ProjectContextValue | null>(\n  'RemoteProjectContext',\n  null\n);\n\nexport function useProjectContext(): ProjectContextValue {\n  const context = useContext(ProjectContext);\n  if (!context) {\n    throw new Error('useProjectContext must be used within a ProjectProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useProjectRepoDefaults.ts",
    "content": "import { scratchApi, ApiError } from '@/shared/lib/api';\nimport {\n  ScratchType,\n  type DraftWorkspaceRepo,\n  type ScratchPayload,\n} from 'shared/types';\n\nconst SCRATCH_TYPE = ScratchType.PROJECT_REPO_DEFAULTS;\n\n/**\n * Read project repo defaults from scratch storage.\n * Returns null if no defaults have been saved for this project.\n */\nexport async function getProjectRepoDefaults(\n  projectId: string\n): Promise<DraftWorkspaceRepo[] | null> {\n  try {\n    const scratch = await scratchApi.get(SCRATCH_TYPE, projectId);\n    const payload = scratch.payload as ScratchPayload;\n    if (payload?.type === 'PROJECT_REPO_DEFAULTS') {\n      return payload.data.repos;\n    }\n    return null;\n  } catch (error) {\n    // 404 means no defaults saved yet — not an error\n    if (error instanceof ApiError && error.status === 404) {\n      return null;\n    }\n    console.error('[useProjectRepoDefaults] Failed to read defaults:', error);\n    return null;\n  }\n}\n\n/**\n * Save project repo defaults to scratch storage (upsert).\n */\nexport async function saveProjectRepoDefaults(\n  projectId: string,\n  repos: DraftWorkspaceRepo[]\n): Promise<void> {\n  await scratchApi.update(SCRATCH_TYPE, projectId, {\n    payload: {\n      type: 'PROJECT_REPO_DEFAULTS',\n      data: { repos },\n    },\n  });\n}\n\n/**\n * Read project repo defaults and filter out repos that no longer exist.\n * Returns an empty array if no defaults are saved or all saved repos are stale.\n */\nexport async function getValidProjectRepoDefaults(\n  projectId: string,\n  availableRepoIds: Set<string>\n): Promise<DraftWorkspaceRepo[]> {\n  const defaults = await getProjectRepoDefaults(projectId);\n  if (!defaults) {\n    return [];\n  }\n  return defaults.filter((repo) => availableRepoIds.has(repo.repo_id));\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useProjectWorkspaceCreateDraft.ts",
    "content": "import { useCallback } from 'react';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useAppRuntime } from '@/shared/hooks/useAppRuntime';\nimport { useCurrentKanbanRouteState } from '@/shared/hooks/useCurrentKanbanRouteState';\nimport { useProjectContext } from '@/shared/hooks/useProjectContext';\nimport type { CreateModeInitialState } from '@/shared/types/createMode';\nimport { persistWorkspaceCreateDraft } from '@/shared/lib/workspaceCreateState';\n\nexport function useProjectWorkspaceCreateDraft() {\n  const { projectId } = useProjectContext();\n  const appNavigation = useAppNavigation();\n  const routeState = useCurrentKanbanRouteState();\n  const runtime = useAppRuntime();\n\n  const openWorkspaceCreateFromState = useCallback(\n    async (\n      initialState: CreateModeInitialState,\n      options?: { issueId?: string | null }\n    ): Promise<string | null> => {\n      if (!projectId) return null;\n\n      const draftId = await persistWorkspaceCreateDraft(\n        initialState,\n        crypto.randomUUID(),\n        runtime\n      );\n      if (!draftId) {\n        return null;\n      }\n\n      const issueId =\n        options?.issueId ??\n        initialState.linkedIssue?.issueId ??\n        routeState.issueId ??\n        null;\n      if (issueId) {\n        appNavigation.goToProjectIssueWorkspaceCreate(\n          projectId,\n          issueId,\n          draftId\n        );\n      } else {\n        appNavigation.goToProjectWorkspaceCreate(projectId, draftId);\n      }\n\n      return draftId;\n    },\n    [projectId, appNavigation, routeState.issueId, runtime]\n  );\n\n  return {\n    openWorkspaceCreateFromState,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/usePush.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { PushError, PushWorkspaceRequest } from 'shared/types';\n\nclass PushErrorWithData extends Error {\n  constructor(\n    message: string,\n    public errorData?: PushError\n  ) {\n    super(message);\n    this.name = 'PushErrorWithData';\n  }\n}\n\nexport function usePush(\n  workspaceId?: string,\n  onSuccess?: () => void,\n  onError?: (\n    err: unknown,\n    errorData?: PushError,\n    params?: PushWorkspaceRequest\n  ) => void\n) {\n  const queryClient = useQueryClient();\n\n  return useMutation<void, unknown, PushWorkspaceRequest>({\n    mutationFn: async (params: PushWorkspaceRequest) => {\n      if (!workspaceId) return;\n      const result = await workspacesApi.push(workspaceId, params);\n      if (!result.success) {\n        throw new PushErrorWithData(\n          result.message || 'Push failed',\n          result.error\n        );\n      }\n    },\n    onSuccess: () => {\n      // A push only affects remote status; invalidate the same branchStatus\n      queryClient.invalidateQueries({\n        queryKey: ['branchStatus', workspaceId],\n      });\n      onSuccess?.();\n    },\n    onError: (err, variables) => {\n      console.error('Failed to push:', err);\n      const errorData =\n        err instanceof PushErrorWithData ? err.errorData : undefined;\n      onError?.(err, errorData, variables);\n    },\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useRebase.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi, Result } from '@/shared/lib/api';\nimport type { RebaseWorkspaceRequest } from 'shared/types';\nimport type { GitOperationError } from 'shared/types';\nimport { repoBranchKeys } from '@/shared/hooks/useRepoBranches';\nimport { workspaceRepoKeys } from '@/shared/hooks/useWorkspaceRepo';\n\nexport function useRebase(\n  workspaceId: string | undefined,\n  repoId: string | undefined,\n  onSuccess?: () => void,\n  onError?: (err: Result<void, GitOperationError>) => void\n) {\n  const queryClient = useQueryClient();\n\n  type RebaseMutationArgs = {\n    repoId: string;\n    newBaseBranch?: string;\n    oldBaseBranch?: string;\n  };\n\n  return useMutation<void, Result<void, GitOperationError>, RebaseMutationArgs>(\n    {\n      mutationFn: (args) => {\n        if (!workspaceId) return Promise.resolve();\n        const { repoId, newBaseBranch, oldBaseBranch } = args ?? {};\n\n        const data: RebaseWorkspaceRequest = {\n          repo_id: repoId,\n          old_base_branch: oldBaseBranch ?? null,\n          new_base_branch: newBaseBranch ?? null,\n        };\n\n        return workspacesApi.rebase(workspaceId, data).then((res) => {\n          if (!res.success) {\n            // Propagate typed failure Result for caller to handle (no manual ApiError construction)\n            return Promise.reject(res);\n          }\n        });\n      },\n      onSuccess: () => {\n        // Refresh branch status immediately\n        queryClient.invalidateQueries({\n          queryKey: ['branchStatus', workspaceId],\n        });\n\n        // Invalidate workspaceWithSession query to refresh attempt.target_branch\n        queryClient.invalidateQueries({\n          queryKey: ['workspaceWithSession', workspaceId],\n        });\n\n        // Refresh repos to update target_branch in RepoCard\n        queryClient.invalidateQueries({\n          queryKey: workspaceRepoKeys.byWorkspace(workspaceId),\n        });\n\n        // Refresh branch list\n        if (repoId) {\n          queryClient.invalidateQueries({\n            queryKey: repoBranchKeys.byRepo(repoId),\n          });\n        }\n\n        onSuccess?.();\n      },\n      onError: (err: Result<void, GitOperationError>) => {\n        console.error('Failed to rebase:', err);\n        // Even on failure (likely conflicts), re-fetch branch status immediately to show rebase-in-progress\n        queryClient.invalidateQueries({\n          queryKey: ['branchStatus', workspaceId],\n        });\n        onError?.(err);\n      },\n    }\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useReleases.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { releasesApi, type GitHubRelease } from '@/shared/lib/api';\n\nexport function useReleases() {\n  return useQuery<GitHubRelease[]>({\n    queryKey: ['releases'],\n    queryFn: () => releasesApi.list(),\n    staleTime: 15 * 60 * 1000,\n    gcTime: 30 * 60 * 1000,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useRenameBranch.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport { workspaceRecordKeys } from '@/shared/hooks/useWorkspaceRecord';\nimport type { Workspace } from 'shared/types';\n\ninterface RenameBranchContext {\n  previousWorkspace: Workspace | undefined;\n}\n\nexport function useRenameBranch(\n  workspaceId?: string,\n  onSuccess?: (newBranchName: string) => void,\n  onError?: (err: unknown) => void\n) {\n  const queryClient = useQueryClient();\n\n  return useMutation<{ branch: string }, unknown, string, RenameBranchContext>({\n    mutationFn: async (newBranchName) => {\n      if (!workspaceId) throw new Error('Workspace id is not set');\n      return workspacesApi.renameBranch(workspaceId, newBranchName);\n    },\n    onMutate: async (newBranchName) => {\n      if (!workspaceId) return { previousWorkspace: undefined };\n\n      await queryClient.cancelQueries({\n        queryKey: workspaceRecordKeys.byId(workspaceId),\n      });\n\n      // Snapshot the previous value\n      const previousWorkspace = queryClient.getQueryData<Workspace>(\n        workspaceRecordKeys.byId(workspaceId)\n      );\n\n      // Optimistically update the cache\n      queryClient.setQueryData<Workspace>(\n        workspaceRecordKeys.byId(workspaceId),\n        (old) => {\n          if (!old) return old;\n          return { ...old, branch: newBranchName };\n        }\n      );\n\n      // Return context with the previous value\n      return { previousWorkspace };\n    },\n    onSuccess: (data) => {\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: ['workspaceWithSession', workspaceId],\n        });\n        queryClient.invalidateQueries({\n          queryKey: workspaceRecordKeys.byId(workspaceId),\n        });\n        queryClient.invalidateQueries({\n          queryKey: ['attemptBranch', workspaceId],\n        });\n        queryClient.invalidateQueries({\n          queryKey: ['branchStatus', workspaceId],\n        });\n        queryClient.invalidateQueries({ queryKey: ['taskWorkspaces'] });\n      }\n      onSuccess?.(data.branch);\n    },\n    onError: (err, _newBranchName, context) => {\n      console.error('Failed to rename branch:', err);\n      // Rollback to the previous value on error\n      if (workspaceId && context?.previousWorkspace) {\n        queryClient.setQueryData(\n          workspaceRecordKeys.byId(workspaceId),\n          context.previousWorkspace\n        );\n      }\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: ['branchStatus', workspaceId],\n        });\n      }\n      onError?.(err);\n    },\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useRepoBranchSelection.ts",
    "content": "import { useState, useMemo, useCallback } from 'react';\nimport { useQueries } from '@tanstack/react-query';\nimport { repoApi } from '@/shared/lib/api';\nimport { repoBranchKeys } from '@/shared/hooks/useRepoBranches';\nimport type { GitBranch, Repo } from 'shared/types';\n\nexport type RepoBranchConfig = {\n  repoId: string;\n  repoDisplayName: string;\n  targetBranch: string | null;\n  branches: GitBranch[];\n};\n\ntype UseRepoBranchSelectionOptions = {\n  repos: Repo[];\n  initialBranch?: string | null;\n  enabled?: boolean;\n};\n\ntype UseRepoBranchSelectionReturn = {\n  configs: RepoBranchConfig[];\n  isLoading: boolean;\n  setRepoBranch: (repoId: string, branch: string) => void;\n  getWorkspaceRepoInputs: () => Array<{\n    repo_id: string;\n    target_branch: string;\n  }>;\n  reset: () => void;\n};\n\nexport function useRepoBranchSelection({\n  repos,\n  initialBranch,\n  enabled = true,\n}: UseRepoBranchSelectionOptions): UseRepoBranchSelectionReturn {\n  const [userOverrides, setUserOverrides] = useState<\n    Record<string, string | null>\n  >({});\n\n  const queries = useQueries({\n    queries: repos.map((repo) => ({\n      queryKey: repoBranchKeys.byRepo(repo.id),\n      queryFn: () => repoApi.getBranches(repo.id),\n      enabled,\n      staleTime: 60_000,\n    })),\n  });\n\n  const isLoadingBranches = queries.some((q) => q.isLoading);\n\n  const configs = useMemo((): RepoBranchConfig[] => {\n    return repos.map((repo, i) => {\n      const branches = queries[i]?.data ?? [];\n\n      let targetBranch: string | null = userOverrides[repo.id] ?? null;\n\n      if (targetBranch === null) {\n        if (initialBranch && branches.some((b) => b.name === initialBranch)) {\n          targetBranch = initialBranch;\n        } else if (\n          repo.default_target_branch &&\n          branches.some((b) => b.name === repo.default_target_branch)\n        ) {\n          targetBranch = repo.default_target_branch;\n        } else {\n          const currentBranch = branches.find((b) => b.is_current);\n          targetBranch = currentBranch?.name ?? branches[0]?.name ?? null;\n        }\n      }\n\n      return {\n        repoId: repo.id,\n        repoDisplayName: repo.display_name,\n        targetBranch,\n        branches,\n      };\n    });\n  }, [repos, queries, userOverrides, initialBranch]);\n\n  const setRepoBranch = useCallback((repoId: string, branch: string) => {\n    setUserOverrides((prev) => ({\n      ...prev,\n      [repoId]: branch,\n    }));\n  }, []);\n\n  const reset = useCallback(() => {\n    setUserOverrides({});\n  }, []);\n\n  const getWorkspaceRepoInputs = useCallback(() => {\n    return configs\n      .filter((config) => config.targetBranch !== null)\n      .map((config) => ({\n        repo_id: config.repoId,\n        target_branch: config.targetBranch!,\n      }));\n  }, [configs]);\n\n  return {\n    configs,\n    isLoading: isLoadingBranches,\n    setRepoBranch,\n    getWorkspaceRepoInputs,\n    reset,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useRepoBranches.ts",
    "content": "import { useQuery, useQueries } from '@tanstack/react-query';\nimport { useMemo } from 'react';\nimport { repoApi } from '@/shared/lib/api';\nimport type { GitBranch } from 'shared/types';\n\nexport const repoBranchKeys = {\n  all: ['repoBranches'] as const,\n  byRepo: (repoId: string | undefined) => ['repoBranches', repoId] as const,\n};\n\ntype Options = {\n  enabled?: boolean;\n};\n\nexport function useRepoBranches(repoId?: string | null, opts?: Options) {\n  const enabled = (opts?.enabled ?? true) && !!repoId;\n\n  return useQuery<GitBranch[]>({\n    queryKey: repoBranchKeys.byRepo(repoId ?? undefined),\n    queryFn: () => repoApi.getBranches(repoId!),\n    enabled,\n    staleTime: 60_000,\n    refetchOnWindowFocus: true,\n  });\n}\n\ninterface UseMultiRepoBranchesResult {\n  branchesByRepo: Record<string, GitBranch[]>;\n  isLoading: boolean;\n  isError: boolean;\n}\n\nexport function useMultiRepoBranches(\n  repoIds: string[]\n): UseMultiRepoBranchesResult {\n  const queries = useQueries({\n    queries: repoIds.map((repoId) => ({\n      queryKey: repoBranchKeys.byRepo(repoId),\n      queryFn: () => repoApi.getBranches(repoId),\n      staleTime: 60_000,\n    })),\n  });\n\n  const branchesByRepo = useMemo(() => {\n    const result: Record<string, GitBranch[]> = {};\n    repoIds.forEach((repoId, idx) => {\n      if (queries[idx]?.data) {\n        result[repoId] = queries[idx].data;\n      }\n    });\n    return result;\n  }, [repoIds, queries]);\n\n  const isLoading = queries.some((q) => q.isLoading);\n  const isError = queries.some((q) => q.isError);\n\n  return { branchesByRepo, isLoading, isError };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useRetryProcess.ts",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { sessionsApi } from '@/shared/lib/api';\nimport {\n  RestoreLogsDialog,\n  type RestoreLogsDialogResult,\n} from '@/shared/dialogs/tasks/RestoreLogsDialog';\nimport type {\n  RepoBranchStatus,\n  ExecutionProcess,\n  BaseCodingAgent,\n} from 'shared/types';\n\nexport interface RetryProcessParams {\n  message: string;\n  executor: BaseCodingAgent;\n  variant: string | null;\n  executionProcessId: string;\n  branchStatus: RepoBranchStatus[] | undefined;\n  processes: ExecutionProcess[] | undefined;\n}\n\nclass RetryDialogCancelledError extends Error {\n  constructor() {\n    super('Retry dialog was cancelled');\n    this.name = 'RetryDialogCancelledError';\n  }\n}\n\nexport function useRetryProcess(\n  sessionId: string,\n  onSuccess?: () => void,\n  onError?: (err: unknown) => void\n) {\n  return useMutation({\n    mutationFn: async ({\n      message,\n      executor,\n      variant,\n      executionProcessId,\n      branchStatus,\n      processes,\n    }: RetryProcessParams) => {\n      // Ask user for confirmation - dialog fetches its own preflight data\n      let modalResult: RestoreLogsDialogResult | undefined;\n      try {\n        modalResult = await RestoreLogsDialog.show({\n          executionProcessId,\n          branchStatus,\n          processes,\n        });\n      } catch {\n        throw new RetryDialogCancelledError();\n      }\n      if (!modalResult || modalResult.action !== 'confirmed') {\n        throw new RetryDialogCancelledError();\n      }\n\n      // Send the retry request\n      await sessionsApi.followUp(sessionId, {\n        prompt: message,\n        executor_config: { executor, variant },\n        retry_process_id: executionProcessId,\n        force_when_dirty: modalResult.forceWhenDirty ?? false,\n        perform_git_reset: modalResult.performGitReset ?? true,\n      });\n    },\n    onSuccess: () => {\n      onSuccess?.();\n    },\n    onError: (err) => {\n      // Don't report cancellation as an error\n      if (err instanceof RetryDialogCancelledError) {\n        return;\n      }\n      console.error('Failed to send retry:', err);\n      onError?.(err);\n    },\n  });\n}\n\nexport { RetryDialogCancelledError };\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useRetryUi.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\n\nexport type RetryUiContextType = {\n  activeRetryProcessId: string | null;\n  setActiveRetryProcessId: (processId: string | null) => void;\n  processOrder: Record<string, number>;\n  isProcessGreyed: (processId?: string) => boolean;\n};\n\nexport const RetryUiContext = createHmrContext<RetryUiContextType | null>(\n  'RetryUiContext',\n  null\n);\n\nexport function useRetryUi() {\n  const ctx = useContext(RetryUiContext);\n  if (!ctx)\n    return {\n      activeRetryProcessId: null,\n      setActiveRetryProcessId: () => {},\n      processOrder: {},\n      isProcessGreyed: () => false,\n    } as RetryUiContextType;\n  return ctx;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useReview.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport { DiffSide } from '@/shared/types/diff';\n\nexport interface ReviewComment {\n  id: string;\n  filePath: string;\n  lineNumber: number;\n  side: DiffSide;\n  text: string;\n  codeLine?: string;\n}\n\nexport interface ReviewDraft {\n  filePath: string;\n  side: DiffSide;\n  lineNumber: number;\n  text: string;\n  codeLine?: string;\n}\n\ninterface ReviewContextType {\n  comments: ReviewComment[];\n  drafts: Record<string, ReviewDraft>;\n  addComment: (comment: Omit<ReviewComment, 'id'>) => void;\n  updateComment: (id: string, text: string) => void;\n  deleteComment: (id: string) => void;\n  clearComments: () => void;\n  setDraft: (key: string, draft: ReviewDraft | null) => void;\n  generateReviewMarkdown: () => string;\n}\n\nexport const ReviewContext = createHmrContext<ReviewContextType | null>(\n  'ReviewContext',\n  null\n);\n\nexport function useReview() {\n  const context = useContext(ReviewContext);\n  if (!context) {\n    throw new Error('useReview must be used within a ReviewProvider');\n  }\n  return context;\n}\n\nexport function useReviewOptional() {\n  return useContext(ReviewContext);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useScratch.ts",
    "content": "import { useCallback } from 'react';\nimport { useJsonPatchWsStream } from '@/shared/hooks/useJsonPatchWsStream';\nimport { useAppRuntime } from '@/shared/hooks/useAppRuntime';\nimport { useLocalStorageScratch } from '@/shared/hooks/useLocalStorageScratch';\nimport { scratchApi } from '@/shared/lib/api';\nimport { ScratchType, type Scratch, type UpdateScratch } from 'shared/types';\n\ntype ScratchState = {\n  scratch: Scratch | null;\n};\n\nexport interface UseScratchResult {\n  scratch: Scratch | null;\n  isLoading: boolean;\n  isConnected: boolean;\n  error: string | null;\n  updateScratch: (update: UpdateScratch) => Promise<void>;\n  deleteScratch: () => Promise<void>;\n}\n\ninterface UseScratchOptions {\n  /** Whether to enable the scratch connection. Defaults to true. */\n  enabled?: boolean;\n}\n\n/**\n * Runtime-aware scratch storage hook.\n *\n * - Local runtime: streams a single scratch item via WebSocket (JSON Patch)\n *   backed by the server-side SQLite scratch table.\n * - Remote runtime: persists scratch data in localStorage for the stable\n *   cloud domain (cloud.vibekanban.com).\n */\nexport const useScratch = (\n  scratchType: ScratchType,\n  id: string,\n  options?: UseScratchOptions\n): UseScratchResult => {\n  const runtime = useAppRuntime();\n  const isRemote = runtime === 'remote';\n\n  // --- localStorage path (remote-web) ---\n  const localResult = useLocalStorageScratch(scratchType, id, {\n    enabled: isRemote && (options?.enabled ?? true),\n  });\n\n  // --- WebSocket/API path (local-web) ---\n  const serverEnabled =\n    !isRemote && (options?.enabled ?? true) && id.length > 0;\n  const endpoint = serverEnabled\n    ? scratchApi.getStreamUrl(scratchType, id)\n    : undefined;\n\n  const initialData = useCallback((): ScratchState => ({ scratch: null }), []);\n\n  const { data, isConnected, isInitialized, error } =\n    useJsonPatchWsStream<ScratchState>(endpoint, serverEnabled, initialData);\n\n  // Treat deleted scratches as null\n  const rawScratch = data?.scratch as (Scratch & { deleted?: boolean }) | null;\n  const scratch = rawScratch?.deleted ? null : rawScratch;\n\n  const updateScratch = useCallback(\n    async (update: UpdateScratch) => {\n      await scratchApi.update(scratchType, id, update);\n    },\n    [scratchType, id]\n  );\n\n  const deleteScratch = useCallback(async () => {\n    await scratchApi.delete(scratchType, id);\n  }, [scratchType, id]);\n\n  const isLoading = !isInitialized && !error;\n\n  const serverResult: UseScratchResult = {\n    scratch,\n    isLoading,\n    isConnected,\n    error,\n    updateScratch,\n    deleteScratch,\n  };\n\n  return isRemote ? localResult : serverResult;\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useScriptPlaceholders.ts",
    "content": "import { useUserSystem } from '@/shared/hooks/useUserSystem';\nimport {\n  createScriptPlaceholderStrategy,\n  ScriptPlaceholderContext,\n  type ScriptPlaceholders,\n} from '@/shared/lib/scriptPlaceholders';\n\nexport function useScriptPlaceholders(): ScriptPlaceholders {\n  const { system } = useUserSystem();\n\n  if (system.environment) {\n    return new ScriptPlaceholderContext(\n      createScriptPlaceholderStrategy(system.environment.os_type)\n    ).getPlaceholders();\n  }\n\n  return {\n    setup: '#!/bin/bash\\nnpm install\\n# Add any setup commands here...',\n    dev: '#!/bin/bash\\nnpm run dev\\n# Add dev server start command here...',\n    cleanup:\n      '#!/bin/bash\\n# Add cleanup commands here...\\n# This runs after coding agent execution',\n    archive:\n      '#!/bin/bash\\n# Add archive commands here...\\n# This runs when the workspace is archived',\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useScrollSyncStateMachine.ts",
    "content": "import { useRef, useCallback, useState } from 'react';\n\n/**\n * State machine for managing bidirectional scroll sync between file tree and diff view.\n *\n * Uses explicit states instead of boolean flags to avoid conflicts between\n * programmatic scrolling and user-initiated scrolling:\n * - Making states explicit (no boolean flags)\n * - Having clear transition rules\n * - Using cooldown period after programmatic scroll\n * - Separating concerns (state machine doesn't do actual scrolling)\n */\n\nexport type SyncState =\n  | 'idle' // Normal operation, sync active\n  | 'programmatic-scroll' // File tree click triggered scroll\n  | 'user-scrolling' // User is actively scrolling\n  | 'sync-cooldown'; // Brief pause after programmatic scroll\n\nexport interface ScrollTarget {\n  path: string;\n  lineNumber?: number;\n  index: number;\n}\n\nexport interface ScrollSyncOptions {\n  /** Debounce delay for user scroll events (default: 150ms) */\n  debounceDelay?: number;\n  /** Cooldown delay after programmatic scroll (default: 200ms) */\n  cooldownDelay?: number;\n  /** Map from file path to virtuoso index */\n  pathToIndex: Map<string, number>;\n  /** Function to get file path from virtuoso index */\n  indexToPath: (index: number) => string | null;\n  /** Custom function to determine which file is at the top of the visible range */\n  getTopFilePath?: (range: {\n    startIndex: number;\n    endIndex: number;\n  }) => string | null;\n}\n\nexport interface ScrollSyncResult {\n  /** Current state of the sync state machine */\n  state: SyncState;\n  /** Currently visible file path (updated during idle state) */\n  fileInView: string | null;\n  /** Current scroll target (set during programmatic-scroll state) */\n  scrollTarget: ScrollTarget | null;\n  /**\n   * Trigger a programmatic scroll to a file.\n   * Sets state to 'programmatic-scroll' and returns the target index.\n   * Returns null if path not found in pathToIndex map.\n   */\n  scrollToFile: (path: string, lineNumber?: number) => number | null;\n  /**\n   * Call when user initiates a scroll (e.g., wheel event, touch).\n   * Transitions to 'user-scrolling' state if currently idle.\n   */\n  onUserScroll: () => void;\n  /**\n   * Call when virtuoso's rangeChanged fires.\n   * Updates fileInView only when in 'idle' or 'user-scrolling' state.\n   */\n  onRangeChanged: (range: { startIndex: number; endIndex: number }) => void;\n  /**\n   * Call when programmatic scroll animation completes.\n   * Transitions from 'programmatic-scroll' to 'sync-cooldown'.\n   */\n  onScrollComplete: () => void;\n}\n\nconst DEFAULT_DEBOUNCE_DELAY = 300;\nconst DEFAULT_COOLDOWN_DELAY = 200;\n\nexport function useScrollSyncStateMachine(\n  options: ScrollSyncOptions\n): ScrollSyncResult {\n  const {\n    debounceDelay = DEFAULT_DEBOUNCE_DELAY,\n    cooldownDelay = DEFAULT_COOLDOWN_DELAY,\n    pathToIndex,\n    indexToPath,\n    getTopFilePath,\n  } = options;\n\n  // Use refs for state to avoid stale closure issues in callbacks\n  const stateRef = useRef<SyncState>('idle');\n  const scrollTargetRef = useRef<ScrollTarget | null>(null);\n  const fileInViewRef = useRef<string | null>(null);\n\n  // Timer refs for cleanup\n  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const cooldownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // React state for triggering re-renders when consumers need updates\n  const [, forceUpdate] = useState(0);\n\n  const triggerUpdate = useCallback(() => {\n    forceUpdate((n) => n + 1);\n  }, []);\n\n  const clearTimers = useCallback(() => {\n    if (debounceTimerRef.current) {\n      clearTimeout(debounceTimerRef.current);\n      debounceTimerRef.current = null;\n    }\n    if (cooldownTimerRef.current) {\n      clearTimeout(cooldownTimerRef.current);\n      cooldownTimerRef.current = null;\n    }\n  }, []);\n\n  const setState = useCallback(\n    (newState: SyncState) => {\n      if (stateRef.current !== newState) {\n        stateRef.current = newState;\n        triggerUpdate();\n      }\n    },\n    [triggerUpdate]\n  );\n\n  const setFileInView = useCallback(\n    (path: string | null) => {\n      if (fileInViewRef.current !== path) {\n        fileInViewRef.current = path;\n        triggerUpdate();\n      }\n    },\n    [triggerUpdate]\n  );\n\n  /**\n   * Trigger a programmatic scroll to a file.\n   * Transition: idle → programmatic-scroll\n   */\n  const scrollToFile = useCallback(\n    (path: string, lineNumber?: number): number | null => {\n      const index = pathToIndex.get(path);\n      if (index === undefined) {\n        return null;\n      }\n\n      // Clear any pending timers\n      clearTimers();\n\n      // Set scroll target\n      scrollTargetRef.current = { path, lineNumber, index };\n\n      // Transition to programmatic-scroll state\n      setState('programmatic-scroll');\n\n      return index;\n    },\n    [pathToIndex, clearTimers, setState]\n  );\n\n  /**\n   * Handle user-initiated scroll.\n   * Transition: idle → user-scrolling\n   */\n  const onUserScroll = useCallback(() => {\n    const currentState = stateRef.current;\n\n    // Only transition from idle to user-scrolling\n    // Ignore during programmatic-scroll or sync-cooldown\n    if (currentState !== 'idle') {\n      return;\n    }\n\n    // Clear any pending debounce timer\n    if (debounceTimerRef.current) {\n      clearTimeout(debounceTimerRef.current);\n    }\n\n    setState('user-scrolling');\n\n    // Set up debounce timer to return to idle\n    debounceTimerRef.current = setTimeout(() => {\n      debounceTimerRef.current = null;\n      if (stateRef.current === 'user-scrolling') {\n        setState('idle');\n      }\n    }, debounceDelay);\n  }, [debounceDelay, setState]);\n\n  /**\n   * Handle virtuoso range changes.\n   * Updates fileInView only in idle or user-scrolling states.\n   */\n  const onRangeChanged = useCallback(\n    (range: { startIndex: number; endIndex: number }) => {\n      const currentState = stateRef.current;\n\n      // Only update fileInView during idle or user-scrolling\n      if (\n        currentState === 'programmatic-scroll' ||\n        currentState === 'sync-cooldown'\n      ) {\n        return;\n      }\n\n      // Use DOM measurement if available, otherwise fall back to index-based\n      const path = getTopFilePath\n        ? getTopFilePath(range)\n        : indexToPath(range.startIndex);\n      if (path !== null) {\n        setFileInView(path);\n      }\n\n      // If user is scrolling, reset the debounce timer\n      if (currentState === 'user-scrolling') {\n        if (debounceTimerRef.current) {\n          clearTimeout(debounceTimerRef.current);\n        }\n        debounceTimerRef.current = setTimeout(() => {\n          debounceTimerRef.current = null;\n          if (stateRef.current === 'user-scrolling') {\n            setState('idle');\n          }\n        }, debounceDelay);\n      }\n    },\n    [getTopFilePath, indexToPath, debounceDelay, setFileInView, setState]\n  );\n\n  /**\n   * Handle programmatic scroll completion.\n   * Transition: programmatic-scroll → sync-cooldown → idle\n   */\n  const onScrollComplete = useCallback(() => {\n    const currentState = stateRef.current;\n\n    // Only handle if we're in programmatic-scroll state\n    if (currentState !== 'programmatic-scroll') {\n      return;\n    }\n\n    // Clear scroll target\n    scrollTargetRef.current = null;\n\n    // Transition to cooldown\n    setState('sync-cooldown');\n\n    // Set up cooldown timer to return to idle\n    cooldownTimerRef.current = setTimeout(() => {\n      cooldownTimerRef.current = null;\n      if (stateRef.current === 'sync-cooldown') {\n        setState('idle');\n      }\n    }, cooldownDelay);\n  }, [cooldownDelay, setState]);\n\n  return {\n    state: stateRef.current,\n    fileInView: fileInViewRef.current,\n    scrollTarget: scrollTargetRef.current,\n    scrollToFile,\n    onUserScroll,\n    onRangeChanged,\n    onScrollComplete,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useSyncErrorContext.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { SyncError } from '@/shared/lib/electric/types';\n\nexport interface StreamError {\n  streamId: string;\n  tableName: string;\n  error: SyncError;\n  retry: () => void;\n}\n\nexport interface SyncErrorContextValue {\n  errors: StreamError[];\n  hasErrors: boolean;\n  registerError: (\n    streamId: string,\n    tableName: string,\n    error: SyncError,\n    retry: () => void\n  ) => void;\n  clearError: (streamId: string) => void;\n  retryAll: () => void;\n}\n\nexport const SyncErrorContext = createHmrContext<SyncErrorContextValue | null>(\n  'SyncErrorContext',\n  null\n);\n\nexport function useSyncErrorContext(): SyncErrorContextValue | null {\n  return useContext(SyncErrorContext);\n}\n\nexport function useSyncErrorContextRequired(): SyncErrorContextValue {\n  const context = useContext(SyncErrorContext);\n  if (!context) {\n    throw new Error(\n      'useSyncErrorContextRequired must be used within a SyncErrorProvider'\n    );\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useTaskWorkspaces.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { workspacesApi, sessionsApi } from '@/shared/lib/api';\nimport type { Workspace } from 'shared/types';\nimport type { WorkspaceWithSession } from '@/shared/types/attempt';\nimport { createWorkspaceWithSession } from '@/shared/types/attempt';\n\nexport const taskWorkspaceKeys = {\n  all: ['taskWorkspaces'] as const,\n  byTask: (taskId: string | undefined) => ['taskWorkspaces', taskId] as const,\n  byTaskWithSessions: (taskId: string | undefined) =>\n    ['taskWorkspacesWithSessions', taskId] as const,\n};\n\ntype Options = {\n  enabled?: boolean;\n  refetchInterval?: number | false;\n};\n\nexport function useTaskWorkspaces(taskId?: string, opts?: Options) {\n  const enabled = (opts?.enabled ?? true) && !!taskId;\n  const refetchInterval = opts?.refetchInterval ?? 5000;\n\n  return useQuery<Workspace[]>({\n    queryKey: taskWorkspaceKeys.byTask(taskId),\n    queryFn: () => workspacesApi.getAll(taskId!),\n    enabled,\n    refetchInterval,\n  });\n}\n\n/**\n * Hook for components that need session data for all workspaces in a task.\n * Fetches all workspaces and their sessions in parallel.\n */\nexport function useTaskWorkspacesWithSessions(taskId?: string, opts?: Options) {\n  const enabled = (opts?.enabled ?? true) && !!taskId;\n  const refetchInterval = opts?.refetchInterval ?? 5000;\n\n  return useQuery<WorkspaceWithSession[]>({\n    queryKey: taskWorkspaceKeys.byTaskWithSessions(taskId),\n    queryFn: async () => {\n      const workspaces = await workspacesApi.getAll(taskId!);\n      // Fetch sessions for all workspaces in parallel\n      const sessionsResults = await Promise.all(\n        workspaces.map((workspace) => sessionsApi.getByWorkspace(workspace.id))\n      );\n      return workspaces.map((workspace, i) => {\n        const session = sessionsResults[i][0];\n        return createWorkspaceWithSession(workspace, session);\n      });\n    },\n    enabled,\n    refetchInterval,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useTerminal.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { Terminal } from '@xterm/xterm';\nimport type { FitAddon } from '@xterm/addon-fit';\n\nexport interface TerminalInstance {\n  terminal: Terminal;\n  fitAddon: FitAddon;\n}\n\nexport interface TerminalTab {\n  id: string;\n  title: string;\n  workspaceId: string;\n  cwd: string;\n}\n\ninterface TerminalConnection {\n  ws: WebSocket;\n  send: (data: string) => void;\n  resize: (cols: number, rows: number) => void;\n}\n\nexport interface TerminalContextType {\n  getTabsForWorkspace: (workspaceId: string) => TerminalTab[];\n  getActiveTab: (workspaceId: string) => TerminalTab | null;\n  createTab: (workspaceId: string, cwd: string) => void;\n  closeTab: (workspaceId: string, tabId: string) => void;\n  setActiveTab: (workspaceId: string, tabId: string) => void;\n  updateTabTitle: (workspaceId: string, tabId: string, title: string) => void;\n  clearWorkspaceTabs: (workspaceId: string) => void;\n  registerTerminalInstance: (\n    tabId: string,\n    terminal: Terminal,\n    fitAddon: FitAddon\n  ) => void;\n  getTerminalInstance: (tabId: string) => TerminalInstance | null;\n  unregisterTerminalInstance: (tabId: string) => void;\n  createTerminalConnection: (\n    tabId: string,\n    endpoint: string,\n    onData: (data: string) => void,\n    onExit?: () => void\n  ) => {\n    send: (data: string) => void;\n    resize: (cols: number, rows: number) => void;\n  };\n  getTerminalConnection: (tabId: string) => TerminalConnection | null;\n}\n\nexport const TerminalContext = createHmrContext<TerminalContextType | null>(\n  'TerminalContext',\n  null\n);\n\nexport function useTerminal() {\n  const context = useContext(TerminalContext);\n  if (!context) {\n    throw new Error('useTerminal must be used within TerminalProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useTheme.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport { ThemeMode } from 'shared/types';\n\nexport type ThemeProviderState = {\n  theme: ThemeMode;\n  setTheme: (theme: ThemeMode) => void;\n};\n\nconst initialState: ThemeProviderState = {\n  theme: ThemeMode.SYSTEM,\n  setTheme: () => null,\n};\n\nexport const ThemeProviderContext = createHmrContext<ThemeProviderState>(\n  'ThemeProviderContext',\n  initialState\n);\n\nexport const useTheme = () => {\n  const context = useContext(ThemeProviderContext);\n\n  if (context === undefined)\n    throw new Error('useTheme must be used within a ThemeProvider');\n\n  return context;\n};\n\nexport function getResolvedTheme(theme: ThemeMode): 'light' | 'dark' {\n  if (theme === ThemeMode.SYSTEM) {\n    return window.matchMedia('(prefers-color-scheme: dark)').matches\n      ? 'dark'\n      : 'light';\n  }\n  return theme === ThemeMode.DARK ? 'dark' : 'light';\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useUiPreferencesScratch.ts",
    "content": "import { useCallback, useEffect, useRef } from 'react';\nimport { useScratch } from '@/shared/hooks/useScratch';\nimport { useDebouncedCallback } from '@/shared/hooks/useDebouncedCallback';\nimport {\n  ScratchType,\n  type UiPreferencesData,\n  type ScratchPayload,\n  type WorkspacePanelStateData,\n  type JsonValue,\n} from 'shared/types';\nimport {\n  useUiPreferencesStore,\n  DEFAULT_CREATE_DRAFT_WORKSPACE_BY_DEFAULT,\n  type RightMainPanelMode,\n  type ContextBarPosition,\n  type WorkspacePanelState,\n  type WorkspaceFilterState,\n  type WorkspaceSortState,\n  type WorkspacePrFilter,\n  type WorkspaceSortBy,\n  type WorkspaceSortOrder,\n} from '@/shared/stores/useUiPreferencesStore';\nimport type { RepoAction } from '@vibe/ui/components/RepoCard';\n\n// Stable UUID for global UI preferences (not tied to a workspace/user)\n// This is a deterministic UUID v5 generated from the namespace \"ui-preferences\"\n// Using a fixed UUID ensures all users/sessions share the same preferences record\nconst UI_PREFERENCES_ID = '00000000-0000-0000-0000-000000000001';\n\n/**\n * Converts store state to scratch data format (camelCase to snake_case)\n */\nfunction storeToScratchData(state: {\n  repoActions: Record<string, RepoAction>;\n  expanded: Record<string, boolean>;\n  contextBarPosition: ContextBarPosition;\n  paneSizes: Record<string, number | string>;\n  collapsedPaths: Record<string, string[]>;\n  fileSearchRepoId: string | null;\n  isLeftSidebarVisible: boolean;\n  isRightSidebarVisible: boolean;\n  isTerminalVisible: boolean;\n  workspacePanelStates: Record<string, WorkspacePanelState>;\n  workspaceFilters: WorkspaceFilterState;\n  workspaceSort: WorkspaceSortState;\n  selectedOrgId: string | null;\n  selectedProjectId: string | null;\n  createDraftWorkspaceByDefault: boolean;\n}): UiPreferencesData {\n  const workspacePanelStates: { [key: string]: WorkspacePanelStateData } = {};\n  for (const [key, value] of Object.entries(state.workspacePanelStates)) {\n    workspacePanelStates[key] = {\n      right_main_panel_mode: value.rightMainPanelMode,\n      is_left_main_panel_visible: value.isLeftMainPanelVisible,\n    };\n  }\n\n  return {\n    repo_actions: state.repoActions as { [key: string]: string },\n    expanded: state.expanded,\n    context_bar_position: state.contextBarPosition,\n    pane_sizes: state.paneSizes as { [key: string]: JsonValue },\n    collapsed_paths: state.collapsedPaths,\n    file_search_repo_id: state.fileSearchRepoId,\n    is_left_sidebar_visible: state.isLeftSidebarVisible,\n    is_right_sidebar_visible: state.isRightSidebarVisible,\n    is_terminal_visible: state.isTerminalVisible,\n    workspace_panel_states: workspacePanelStates,\n    workspace_filters: {\n      project_ids: state.workspaceFilters.projectIds,\n      pr_filter: state.workspaceFilters.prFilter,\n    },\n    workspace_sort: {\n      sort_by: state.workspaceSort.sortBy,\n      sort_order: state.workspaceSort.sortOrder,\n    },\n    selected_org_id: state.selectedOrgId,\n    selected_project_id: state.selectedProjectId,\n    create_draft_workspace_by_default: state.createDraftWorkspaceByDefault,\n  };\n}\n\n/**\n * Converts scratch data to store state format (snake_case to camelCase)\n */\nfunction scratchDataToStore(data: UiPreferencesData): {\n  repoActions: Record<string, RepoAction>;\n  expanded: Record<string, boolean>;\n  contextBarPosition: ContextBarPosition;\n  paneSizes: Record<string, number | string>;\n  collapsedPaths: Record<string, string[]>;\n  fileSearchRepoId: string | null;\n  isLeftSidebarVisible: boolean;\n  isRightSidebarVisible: boolean;\n  isTerminalVisible: boolean;\n  workspacePanelStates: Record<string, WorkspacePanelState>;\n  workspaceFilters: WorkspaceFilterState;\n  workspaceSort: WorkspaceSortState;\n  selectedOrgId: string | null;\n  selectedProjectId: string | null;\n  createDraftWorkspaceByDefault: boolean;\n} {\n  const workspacePanelStates: Record<string, WorkspacePanelState> = {};\n  if (data.workspace_panel_states) {\n    for (const [key, value] of Object.entries(data.workspace_panel_states)) {\n      if (value) {\n        workspacePanelStates[key] = {\n          rightMainPanelMode:\n            (value.right_main_panel_mode as RightMainPanelMode) ?? null,\n          isLeftMainPanelVisible: value.is_left_main_panel_visible ?? true,\n        };\n      }\n    }\n  }\n\n  // Backwards compatibility with older payloads that used\n  // file_search_repo_by_project (project_id -> repo_id).\n  const legacyFileSearchRepoByProject = (\n    data as UiPreferencesData & {\n      file_search_repo_by_project?: Record<string, string>;\n    }\n  ).file_search_repo_by_project;\n  const legacyFileSearchRepoId =\n    legacyFileSearchRepoByProject &&\n    Object.values(legacyFileSearchRepoByProject)[0]\n      ? Object.values(legacyFileSearchRepoByProject)[0]\n      : null;\n\n  return {\n    repoActions: (data.repo_actions ?? {}) as Record<string, RepoAction>,\n    expanded: (data.expanded ?? {}) as Record<string, boolean>,\n    contextBarPosition:\n      (data.context_bar_position as ContextBarPosition) ?? 'middle-right',\n    paneSizes: (data.pane_sizes ?? {}) as Record<string, number | string>,\n    collapsedPaths: (data.collapsed_paths ?? {}) as Record<string, string[]>,\n    fileSearchRepoId: data.file_search_repo_id ?? legacyFileSearchRepoId,\n    isLeftSidebarVisible: data.is_left_sidebar_visible ?? true,\n    isRightSidebarVisible: data.is_right_sidebar_visible ?? true,\n    isTerminalVisible: data.is_terminal_visible ?? true,\n    workspacePanelStates,\n    workspaceFilters: {\n      projectIds: data.workspace_filters?.project_ids ?? [],\n      prFilter:\n        (data.workspace_filters?.pr_filter as WorkspacePrFilter) ?? 'all',\n    },\n    workspaceSort: {\n      sortBy: (data.workspace_sort?.sort_by as WorkspaceSortBy) ?? 'updated_at',\n      sortOrder:\n        (data.workspace_sort?.sort_order as WorkspaceSortOrder) ?? 'desc',\n    },\n    selectedOrgId: data.selected_org_id ?? null,\n    selectedProjectId: data.selected_project_id ?? null,\n    createDraftWorkspaceByDefault:\n      data.create_draft_workspace_by_default ??\n      DEFAULT_CREATE_DRAFT_WORKSPACE_BY_DEFAULT,\n  };\n}\n\n/**\n * Hook that syncs UI preferences between Zustand store and server scratch storage.\n * Should be used once at the app root level.\n */\nexport function useUiPreferencesScratch() {\n  const { scratch, updateScratch, isLoading, isConnected } = useScratch(\n    ScratchType.UI_PREFERENCES,\n    UI_PREFERENCES_ID\n  );\n\n  // Track whether we've initialized from server\n  const hasInitializedRef = useRef(false);\n  // Track whether we're currently applying server data to prevent save loops\n  const isApplyingServerDataRef = useRef(false);\n\n  // Get current store state\n  const storeState = useUiPreferencesStore((state) => ({\n    repoActions: state.repoActions,\n    expanded: state.expanded,\n    contextBarPosition: state.contextBarPosition,\n    paneSizes: state.paneSizes,\n    collapsedPaths: state.collapsedPaths,\n    fileSearchRepoId: state.fileSearchRepoId,\n    isLeftSidebarVisible: state.isLeftSidebarVisible,\n    isRightSidebarVisible: state.isRightSidebarVisible,\n    isTerminalVisible: state.isTerminalVisible,\n    workspacePanelStates: state.workspacePanelStates,\n    workspaceFilters: state.workspaceFilters,\n    workspaceSort: state.workspaceSort,\n    selectedOrgId: state.selectedOrgId,\n    selectedProjectId: state.selectedProjectId,\n    createDraftWorkspaceByDefault: state.createDraftWorkspaceByDefault,\n  }));\n\n  // Extract scratch data\n  const payload = scratch?.payload as ScratchPayload | undefined;\n  const scratchData: UiPreferencesData | undefined =\n    payload?.type === 'UI_PREFERENCES' ? payload.data : undefined;\n\n  // Save to server function\n  const saveToServer = useCallback(async () => {\n    if (isApplyingServerDataRef.current || !hasInitializedRef.current) {\n      return;\n    }\n\n    const currentState = useUiPreferencesStore.getState();\n    const data = storeToScratchData({\n      repoActions: currentState.repoActions,\n      expanded: currentState.expanded,\n      contextBarPosition: currentState.contextBarPosition,\n      paneSizes: currentState.paneSizes,\n      collapsedPaths: currentState.collapsedPaths,\n      fileSearchRepoId: currentState.fileSearchRepoId,\n      isLeftSidebarVisible: currentState.isLeftSidebarVisible,\n      isRightSidebarVisible: currentState.isRightSidebarVisible,\n      isTerminalVisible: currentState.isTerminalVisible,\n      workspacePanelStates: currentState.workspacePanelStates,\n      workspaceFilters: currentState.workspaceFilters,\n      workspaceSort: currentState.workspaceSort,\n      selectedOrgId: currentState.selectedOrgId,\n      selectedProjectId: currentState.selectedProjectId,\n      createDraftWorkspaceByDefault: currentState.createDraftWorkspaceByDefault,\n    });\n\n    try {\n      await updateScratch({\n        payload: {\n          type: 'UI_PREFERENCES',\n          data,\n        },\n      });\n    } catch (e) {\n      console.error('[useUiPreferencesScratch] Failed to save:', e);\n    }\n  }, [updateScratch]);\n\n  const { debounced: debouncedSave } = useDebouncedCallback(saveToServer, 500);\n\n  // Initialize store from server data when first loaded\n  useEffect(() => {\n    if (hasInitializedRef.current || isLoading || !isConnected) {\n      return;\n    }\n\n    hasInitializedRef.current = true;\n\n    if (scratchData) {\n      // Server has data - apply it to store\n      isApplyingServerDataRef.current = true;\n      const serverState = scratchDataToStore(scratchData);\n\n      // Merge server state into the store\n      useUiPreferencesStore.setState({\n        repoActions: serverState.repoActions,\n        expanded: serverState.expanded,\n        contextBarPosition: serverState.contextBarPosition,\n        paneSizes: serverState.paneSizes,\n        collapsedPaths: serverState.collapsedPaths,\n        fileSearchRepoId: serverState.fileSearchRepoId,\n        isLeftSidebarVisible: serverState.isLeftSidebarVisible,\n        isRightSidebarVisible: serverState.isRightSidebarVisible,\n        isTerminalVisible: serverState.isTerminalVisible,\n        workspacePanelStates: serverState.workspacePanelStates,\n        workspaceFilters: serverState.workspaceFilters,\n        workspaceSort: serverState.workspaceSort,\n        selectedOrgId: serverState.selectedOrgId,\n        selectedProjectId: serverState.selectedProjectId,\n        createDraftWorkspaceByDefault:\n          serverState.createDraftWorkspaceByDefault,\n      });\n\n      // Allow a brief delay for state to settle\n      setTimeout(() => {\n        isApplyingServerDataRef.current = false;\n      }, 100);\n    }\n  }, [isLoading, isConnected, scratchData]);\n\n  // Subscribe to store changes and save to server\n  useEffect(() => {\n    const unsubscribe = useUiPreferencesStore.subscribe(() => {\n      if (!isApplyingServerDataRef.current && hasInitializedRef.current) {\n        debouncedSave();\n      }\n    });\n\n    return unsubscribe;\n  }, [debouncedSave]);\n\n  return {\n    isLoading,\n    isConnected,\n    // Expose for debugging\n    scratchData,\n    storeState,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useUserContext.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type { Workspace } from 'shared/remote-types';\nimport type { SyncError } from '@/shared/lib/electric/types';\n\n/**\n * UserContext provides user-scoped data.\n *\n * Shapes synced at user scope:\n * - Workspaces (data only, scoped by owner_user_id)\n */\nexport interface UserContextValue {\n  // Data\n  workspaces: Workspace[];\n\n  // Loading/error state\n  isLoading: boolean;\n  error: SyncError | null;\n  retry: () => void;\n\n  // Lookup helpers\n  getWorkspacesForIssue: (issueId: string) => Workspace[];\n}\n\nexport const UserContext = createHmrContext<UserContextValue | null>(\n  'UserContext',\n  null\n);\n\n/**\n * Hook to access user context.\n * Must be used within a UserProvider.\n */\nexport function useUserContext(): UserContextValue {\n  const context = useContext(UserContext);\n  if (!context) {\n    throw new Error('useUserContext must be used within a UserProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useUserOrganizations.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { organizationsApi } from '@/shared/lib/api';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport type { ListOrganizationsResponse } from 'shared/types';\nimport { organizationKeys } from '@/shared/hooks/organizationKeys';\n\n/**\n * Hook to fetch all organizations that the current user is a member of\n */\nexport function useUserOrganizations() {\n  const { isSignedIn } = useAuth();\n\n  return useQuery<ListOrganizationsResponse>({\n    queryKey: organizationKeys.userList(),\n    queryFn: () => organizationsApi.getUserOrganizations(),\n    enabled: isSignedIn,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useUserSystem.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type {\n  Config,\n  Environment,\n  BaseAgentCapability,\n  LoginStatus,\n} from 'shared/types';\nimport type { ExecutorProfile } from 'shared/types';\n\nexport interface UserSystemState {\n  appVersion: string | null;\n  config: Config | null;\n  environment: Environment | null;\n  profiles: Record<string, ExecutorProfile> | null;\n  capabilities: Record<string, BaseAgentCapability[]> | null;\n  analyticsUserId: string | null;\n  loginStatus: LoginStatus | null;\n}\n\nexport interface UserSystemContextType {\n  // Full system state\n  system: UserSystemState;\n\n  // Hot path - config helpers (most frequently used)\n  appVersion: string | null;\n  config: Config | null;\n  updateConfig: (updates: Partial<Config>) => void;\n  updateAndSaveConfig: (updates: Partial<Config>) => Promise<boolean>;\n  saveConfig: () => Promise<boolean>;\n\n  // System data access\n  environment: Environment | null;\n  profiles: Record<string, ExecutorProfile> | null;\n  capabilities: Record<string, BaseAgentCapability[]> | null;\n  analyticsUserId: string | null;\n  loginStatus: LoginStatus | null;\n  setEnvironment: (env: Environment | null) => void;\n  setProfiles: (profiles: Record<string, ExecutorProfile> | null) => void;\n  setCapabilities: (caps: Record<string, BaseAgentCapability[]> | null) => void;\n\n  // Reload system data\n  reloadSystem: () => Promise<void>;\n\n  // State\n  loading: boolean;\n}\n\nexport const UserSystemContext = createHmrContext<\n  UserSystemContextType | undefined\n>('UserSystemContext', undefined);\n\nexport function useUserSystem() {\n  const context = useContext(UserSystemContext);\n  if (context === undefined) {\n    throw new Error('useUserSystem must be used within a UserSystemProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useVariant.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\n\ntype Args = {\n  processVariant: string | null;\n  scratchVariant?: string | null;\n};\n\n/**\n * Hook to manage variant selection with priority:\n * 1. User dropdown selection (current session) - highest priority\n * 2. Scratch-persisted variant (from previous session)\n * 3. Last execution process variant (fallback)\n */\nexport function useVariant({ processVariant, scratchVariant }: Args) {\n  // Track if user has explicitly selected a variant this session\n  const hasUserSelectionRef = useRef(false);\n\n  // Compute initial value: scratch takes priority over process\n  const getInitialVariant = () =>\n    scratchVariant !== undefined ? scratchVariant : processVariant;\n\n  const [selectedVariant, setSelectedVariantState] = useState<string | null>(\n    getInitialVariant\n  );\n\n  // Sync state when inputs change (if user hasn't made a selection)\n  useEffect(() => {\n    if (hasUserSelectionRef.current) return;\n\n    const newVariant =\n      scratchVariant !== undefined ? scratchVariant : processVariant;\n    setSelectedVariantState(newVariant);\n  }, [scratchVariant, processVariant]);\n\n  // When user explicitly selects a variant, mark it and update state\n  const setSelectedVariant = useCallback((variant: string | null) => {\n    hasUserSelectionRef.current = true;\n    setSelectedVariantState(variant);\n  }, []);\n\n  return { selectedVariant, setSelectedVariant } as const;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspace.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { WorkspaceWithSession } from '@/shared/types/attempt';\n\nexport function useWorkspace(workspaceId?: string) {\n  return useQuery({\n    queryKey: ['workspace', workspaceId],\n    queryFn: () => workspacesApi.get(workspaceId!),\n    enabled: !!workspaceId,\n  });\n}\n\n/**\n * Hook for components that need executor field (e.g., for capability checks).\n * Fetches workspace with executor from latest session.\n */\nexport function useWorkspaceWithSession(workspaceId?: string) {\n  return useQuery<WorkspaceWithSession>({\n    queryKey: ['workspaceWithSession', workspaceId],\n    queryFn: () => workspacesApi.getWithSession(workspaceId!),\n    enabled: !!workspaceId,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceConflicts.ts",
    "content": "import { useCallback } from 'react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\n\nexport function useWorkspaceConflicts(workspaceId?: string, repoId?: string) {\n  const queryClient = useQueryClient();\n\n  const abortConflicts = useCallback(async () => {\n    if (!workspaceId || !repoId) return;\n    await workspacesApi.abortConflicts(workspaceId, { repo_id: repoId });\n    await queryClient.invalidateQueries({\n      queryKey: ['branchStatus', workspaceId],\n    });\n  }, [workspaceId, repoId, queryClient]);\n\n  return { abortConflicts } as const;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceContext.ts",
    "content": "import { useContext } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport type {\n  Session,\n  RepoWithTargetBranch,\n  UnifiedPrComment,\n  Diff,\n  DiffStats,\n  Workspace as ApiWorkspace,\n} from 'shared/types';\nimport type { SidebarWorkspace } from '@/shared/hooks/useWorkspaces';\nimport { DiffSide } from '@/shared/types/diff';\n\nexport interface NormalizedGitHubComment {\n  id: string;\n  author: string;\n  body: string;\n  createdAt: string;\n  url: string | null;\n  filePath: string;\n  lineNumber: number;\n  side: DiffSide;\n  diffHunk: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Diff / GitHub-comments context — changes frequently during streaming.\n// Separated so that conversation-shell components do not rerender on diff churn.\n// ---------------------------------------------------------------------------\n\nexport interface WorkspaceDiffContextValue {\n  /** Diffs for the current workspace */\n  diffs: Diff[];\n  /** Set of file paths in the diffs */\n  diffPaths: Set<string>;\n  /** Aggregate diff statistics */\n  diffStats: DiffStats;\n  /** GitHub PR Comments */\n  gitHubComments: UnifiedPrComment[];\n  isGitHubCommentsLoading: boolean;\n  showGitHubComments: boolean;\n  setShowGitHubComments: (show: boolean) => void;\n  getGitHubCommentsForFile: (filePath: string) => NormalizedGitHubComment[];\n  getGitHubCommentCountForFile: (filePath: string) => number;\n  getFilesWithGitHubComments: () => string[];\n  getFirstCommentLineForFile: (filePath: string) => number | null;\n}\n\nexport const WorkspaceDiffContext =\n  createHmrContext<WorkspaceDiffContextValue | null>(\n    'WorkspaceDiffContext',\n    null\n  );\n\nexport function useWorkspaceDiffContext(): WorkspaceDiffContextValue {\n  const context = useContext(WorkspaceDiffContext);\n  if (!context) {\n    throw new Error(\n      'useWorkspaceDiffContext must be used within a WorkspaceProvider'\n    );\n  }\n  return context;\n}\n\n// ---------------------------------------------------------------------------\n// Core workspace context — workspace, sessions, repos, navigation.\n// Changes infrequently; safe for conversation-shell subscriptions.\n// ---------------------------------------------------------------------------\n\nexport interface WorkspaceContextValue {\n  workspaceId: string | undefined;\n  /** Real workspace data from API */\n  workspace: ApiWorkspace | undefined;\n  /** Active workspaces for sidebar display */\n  activeWorkspaces: SidebarWorkspace[];\n  /** Archived workspaces for sidebar display */\n  archivedWorkspaces: SidebarWorkspace[];\n  isLoading: boolean;\n  isCreateMode: boolean;\n  selectWorkspace: (id: string) => void;\n  navigateToCreate: () => void;\n  /** Sessions for the current workspace */\n  sessions: Session[];\n  selectedSession: Session | undefined;\n  selectedSessionId: string | undefined;\n  selectSession: (sessionId: string) => void;\n  selectLatestSession: () => void;\n  isSessionsLoading: boolean;\n  /** Whether user is creating a new session */\n  isNewSessionMode: boolean;\n  /** Enter new session mode */\n  startNewSession: () => void;\n  /** Repos for the current workspace */\n  repos: RepoWithTargetBranch[];\n  isReposLoading: boolean;\n}\n\n// Exported for optional usage outside WorkspaceProvider (e.g., old UI)\nexport const WorkspaceContext = createHmrContext<WorkspaceContextValue | null>(\n  'WorkspaceContext',\n  null\n);\n\nexport function useWorkspaceContext(): WorkspaceContextValue {\n  const context = useContext(WorkspaceContext);\n  if (!context) {\n    throw new Error(\n      'useWorkspaceContext must be used within a WorkspaceProvider'\n    );\n  }\n  return context;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceCreateDefaults.ts",
    "content": "import { useMemo } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport type { ExecutorConfig, RepoWithTargetBranch } from 'shared/types';\nimport { workspacesApi } from '@/shared/lib/api';\nimport { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses';\nimport { getLatestConfigFromProcesses } from '@/shared/lib/executor';\n\ninterface UseWorkspaceCreateDefaultsOptions {\n  sourceWorkspaceId: string | null;\n  enabled: boolean;\n}\n\ninterface WorkspaceCreateDefaultsData {\n  repos: RepoWithTargetBranch[];\n  sourceSessionId: string | undefined;\n  sourceSessionExecutor: ExecutorConfig['executor'] | null;\n}\n\ninterface UseWorkspaceCreateDefaultsResult {\n  preferredRepos: RepoWithTargetBranch[];\n  preferredExecutorConfig: ExecutorConfig | null;\n  hasResolvedPreferredRepos: boolean;\n}\n\nexport function useWorkspaceCreateDefaults({\n  sourceWorkspaceId,\n  enabled,\n}: UseWorkspaceCreateDefaultsOptions): UseWorkspaceCreateDefaultsResult {\n  const queryEnabled = enabled && !!sourceWorkspaceId;\n\n  const { data, status } = useQuery<WorkspaceCreateDefaultsData>({\n    queryKey: ['workspaceCreateDefaults', sourceWorkspaceId],\n    enabled: queryEnabled,\n    staleTime: 0,\n    refetchOnMount: 'always',\n    queryFn: async () => {\n      const [repos, workspaceWithSession] = await Promise.all([\n        workspacesApi.getRepos(sourceWorkspaceId!),\n        workspacesApi.getWithSession(sourceWorkspaceId!),\n      ]);\n\n      const result = {\n        repos,\n        sourceSessionId: workspaceWithSession.session?.id ?? undefined,\n        sourceSessionExecutor:\n          (workspaceWithSession.session\n            ?.executor as ExecutorConfig['executor']) ?? null,\n      };\n      return result;\n    },\n  });\n\n  const { executionProcesses } = useExecutionProcesses(data?.sourceSessionId);\n\n  const preferredExecutorConfig = useMemo(() => {\n    const fromProcesses = getLatestConfigFromProcesses(executionProcesses);\n    if (fromProcesses) return fromProcesses;\n    if (data?.sourceSessionExecutor) {\n      return { executor: data.sourceSessionExecutor };\n    }\n    return null;\n  }, [executionProcesses, data?.sourceSessionExecutor]);\n\n  return {\n    preferredRepos: data?.repos ?? [],\n    preferredExecutorConfig,\n    hasResolvedPreferredRepos: !queryEnabled || status !== 'pending',\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceExecution.ts",
    "content": "import { useMemo, useCallback } from 'react';\nimport {\n  useMutation,\n  useMutationState,\n  useQueries,\n} from '@tanstack/react-query';\nimport { workspacesApi, executionProcessesApi } from '@/shared/lib/api';\nimport { useExecutionProcessesContext } from '@/shared/hooks/useExecutionProcessesContext';\nimport type { AttemptData } from '@/shared/lib/types';\nimport type { ExecutionProcess } from 'shared/types';\n\nexport function useWorkspaceExecution(workspaceId?: string) {\n  const stopMutationKey = useMemo(\n    () => ['stopWorkspaceExecution', workspaceId] as const,\n    [workspaceId]\n  );\n\n  const stopMutation = useMutation({\n    mutationKey: stopMutationKey,\n    mutationFn: async () => {\n      if (!workspaceId) return;\n      await workspacesApi.stop(workspaceId);\n    },\n  });\n\n  const isStopping =\n    useMutationState({\n      filters: {\n        mutationKey: stopMutationKey,\n        status: 'pending',\n      },\n    }).length > 0;\n\n  const {\n    executionProcessesVisible: executionProcesses,\n    isAttemptRunningVisible: isAttemptRunning,\n    isLoading: streamLoading,\n  } = useExecutionProcessesContext();\n\n  // Get setup script processes that need detailed info\n  const setupProcesses = useMemo(() => {\n    if (!executionProcesses.length) return [] as ExecutionProcess[];\n    return executionProcesses.filter((p) => p.run_reason === 'setupscript');\n  }, [executionProcesses]);\n\n  // Fetch details for setup processes\n  const processDetailQueries = useQueries({\n    queries: setupProcesses.map((process) => ({\n      queryKey: ['processDetails', process.id],\n      queryFn: () => executionProcessesApi.getDetails(process.id),\n      enabled: !!process.id,\n    })),\n  });\n\n  // Build attempt data combining processes and details\n  const attemptData: AttemptData = useMemo(() => {\n    if (!executionProcesses.length) {\n      return { processes: [], runningProcessDetails: {} };\n    }\n\n    // Build runningProcessDetails from the detail queries\n    const runningProcessDetails: Record<string, ExecutionProcess> = {};\n\n    setupProcesses.forEach((process, index) => {\n      const detailQuery = processDetailQueries[index];\n      if (detailQuery?.data) {\n        runningProcessDetails[process.id] = detailQuery.data;\n      }\n    });\n\n    return {\n      processes: executionProcesses,\n      runningProcessDetails,\n    };\n  }, [executionProcesses, setupProcesses, processDetailQueries]);\n\n  const stopExecution = useCallback(async () => {\n    if (!workspaceId || isStopping) return;\n\n    try {\n      await stopMutation.mutateAsync();\n    } catch (error) {\n      console.error('Failed to stop executions:', error);\n      throw error;\n    }\n  }, [workspaceId, isStopping, stopMutation]);\n\n  const isLoading =\n    streamLoading || processDetailQueries.some((q) => q.isLoading);\n  const isFetching =\n    streamLoading || processDetailQueries.some((q) => q.isFetching);\n\n  return {\n    // Data\n    processes: executionProcesses,\n    attemptData,\n    runningProcessDetails: attemptData.runningProcessDetails,\n\n    // Status\n    isAttemptRunning,\n    isLoading,\n    isFetching,\n\n    // Actions\n    stopExecution,\n    isStopping,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceRecord.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { Workspace } from 'shared/types';\n\nexport const workspaceRecordKeys = {\n  byId: (workspaceId: string | undefined) =>\n    ['workspaceRecord', workspaceId] as const,\n};\n\ntype Options = {\n  enabled?: boolean;\n};\n\nexport function useWorkspaceRecord(workspaceId?: string, opts?: Options) {\n  const enabled = (opts?.enabled ?? true) && !!workspaceId;\n\n  return useQuery<Workspace>({\n    queryKey: workspaceRecordKeys.byId(workspaceId),\n    queryFn: () => workspacesApi.get(workspaceId!),\n    enabled,\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceRepo.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useEffect, useMemo } from 'react';\nimport { workspacesApi } from '@/shared/lib/api';\nimport type { RepoWithTargetBranch } from 'shared/types';\n\ninterface UseWorkspaceRepoOptions {\n  enabled?: boolean;\n}\n\nexport const workspaceRepoKeys = {\n  byWorkspace: (workspaceId: string | undefined) =>\n    ['workspaceRepos', workspaceId] as const,\n  selection: (workspaceId: string | undefined) =>\n    ['workspaceRepoSelection', workspaceId] as const,\n};\n\nexport function useWorkspaceRepo(\n  workspaceId?: string,\n  options: UseWorkspaceRepoOptions = {}\n) {\n  const { enabled = true } = options;\n  const queryClient = useQueryClient();\n\n  const query = useQuery<RepoWithTargetBranch[]>({\n    queryKey: workspaceRepoKeys.byWorkspace(workspaceId),\n    queryFn: async () => {\n      const repos = await workspacesApi.getRepos(workspaceId!);\n      return repos;\n    },\n    enabled: enabled && !!workspaceId,\n  });\n\n  const repos = useMemo(() => query.data ?? [], [query.data]);\n\n  // Use React Query cache for shared state across all hook consumers\n  const { data: selectedRepoId = null } = useQuery<string | null>({\n    queryKey: workspaceRepoKeys.selection(workspaceId),\n    queryFn: () => null,\n    enabled: false,\n    staleTime: Infinity,\n  });\n\n  const setSelectedRepoId = useCallback(\n    (id: string | null) => {\n      queryClient.setQueryData(workspaceRepoKeys.selection(workspaceId), id);\n    },\n    [queryClient, workspaceId]\n  );\n\n  // Auto-select first repo when none selected\n  useEffect(() => {\n    if (repos.length > 0 && selectedRepoId === null) {\n      setSelectedRepoId(repos[0].id);\n    }\n  }, [repos, selectedRepoId, setSelectedRepoId]);\n\n  return {\n    repos,\n    selectedRepoId,\n    setSelectedRepoId,\n    isLoading: query.isLoading,\n    refetch: query.refetch,\n  } as const;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceSessions.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useState, useCallback, useEffect, useMemo, useRef } from 'react';\nimport { sessionsApi } from '@/shared/lib/api';\nimport type { Session } from 'shared/types';\n\ninterface UseWorkspaceSessionsOptions {\n  enabled?: boolean;\n}\n\n/** Discriminated union for session selection state */\nexport type SessionSelection =\n  | { mode: 'existing'; sessionId: string }\n  | { mode: 'new' };\n\ninterface UseWorkspaceSessionsResult {\n  sessions: Session[];\n  selectedSession: Session | undefined;\n  selectedSessionId: string | undefined;\n  selectSession: (sessionId: string) => void;\n  selectLatestSession: () => void;\n  isLoading: boolean;\n  /** Whether user is creating a new session */\n  isNewSessionMode: boolean;\n  /** Enter new session mode */\n  startNewSession: () => void;\n}\n\n/**\n * Hook for managing sessions within a workspace.\n * Fetches all sessions for a workspace and provides session switching capability.\n * Sessions are ordered by most recently used (latest non-dev server execution first).\n */\nexport function useWorkspaceSessions(\n  workspaceId: string | undefined,\n  options: UseWorkspaceSessionsOptions = {}\n): UseWorkspaceSessionsResult {\n  const { enabled = true } = options;\n  const [selection, setSelection] = useState<SessionSelection | undefined>(\n    undefined\n  );\n  const prevWorkspaceIdRef = useRef(workspaceId);\n\n  const { data: sessions = [], isLoading } = useQuery<Session[]>({\n    queryKey: ['workspaceSessions', workspaceId],\n    queryFn: () => sessionsApi.getByWorkspace(workspaceId!),\n    enabled: enabled && !!workspaceId,\n  });\n\n  // Combined effect: handle workspace changes and auto-select sessions\n  // This replaces two separate effects that had a race condition where the reset\n  // effect would fire after auto-select when sessions were cached, undoing the selection.\n  useEffect(() => {\n    const workspaceChanged = prevWorkspaceIdRef.current !== workspaceId;\n    prevWorkspaceIdRef.current = workspaceId;\n\n    if (sessions.length > 0) {\n      // Sessions are ordered by most recently used, so first is the most recently used\n      // Always select first session when sessions are available for this workspace\n      // Only preserve new session mode within the same workspace\n      setSelection((prev) => {\n        if (prev?.mode === 'new' && !workspaceChanged) return prev;\n        return { mode: 'existing', sessionId: sessions[0].id };\n      });\n    } else {\n      setSelection(undefined);\n    }\n  }, [workspaceId, sessions]);\n\n  const isNewSessionMode = selection?.mode === 'new' || sessions.length === 0;\n  const selectedSessionId =\n    selection?.mode === 'existing' ? selection.sessionId : undefined;\n\n  const selectedSession = useMemo(\n    () => sessions.find((s) => s.id === selectedSessionId),\n    [sessions, selectedSessionId]\n  );\n\n  const selectSession = useCallback((sessionId: string) => {\n    setSelection({ mode: 'existing', sessionId });\n  }, []);\n\n  const selectLatestSession = useCallback(() => {\n    if (sessions.length > 0) {\n      setSelection({ mode: 'existing', sessionId: sessions[0].id });\n    }\n  }, [sessions]);\n\n  const startNewSession = useCallback(() => {\n    setSelection({ mode: 'new' });\n  }, []);\n\n  return {\n    sessions,\n    selectedSession,\n    selectedSessionId,\n    selectSession,\n    selectLatestSession,\n    isLoading,\n    isNewSessionMode,\n    startNewSession,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaceSidebarPreviewController.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\n\nconst PREVIEW_CLOSE_DELAY_MS = 120;\n\nexport function useWorkspaceSidebarPreviewController({\n  enabled,\n  isAppBarHovered,\n}: {\n  enabled: boolean;\n  isAppBarHovered: boolean;\n}) {\n  const [isPreviewOpen, setIsPreviewOpen] = useState(false);\n  const closeTimeoutRef = useRef<number | null>(null);\n  const hoverStateRef = useRef({\n    isAppBarHovered,\n    isHandleHovered: false,\n    isPreviewHovered: false,\n  });\n\n  const clearScheduledClose = useCallback(() => {\n    if (closeTimeoutRef.current !== null) {\n      window.clearTimeout(closeTimeoutRef.current);\n      closeTimeoutRef.current = null;\n    }\n  }, []);\n\n  const scheduleCloseIfIdle = useCallback(() => {\n    if (!enabled) {\n      return;\n    }\n\n    const { isAppBarHovered, isHandleHovered, isPreviewHovered } =\n      hoverStateRef.current;\n    if (isAppBarHovered || isHandleHovered || isPreviewHovered) {\n      clearScheduledClose();\n      return;\n    }\n\n    clearScheduledClose();\n    closeTimeoutRef.current = window.setTimeout(() => {\n      closeTimeoutRef.current = null;\n      const {\n        isAppBarHovered: latestAppBarHover,\n        isHandleHovered: latestHandleHover,\n        isPreviewHovered: latestPreviewHover,\n      } = hoverStateRef.current;\n      if (latestAppBarHover || latestHandleHover || latestPreviewHover) {\n        return;\n      }\n      setIsPreviewOpen(false);\n    }, PREVIEW_CLOSE_DELAY_MS);\n  }, [clearScheduledClose, enabled]);\n\n  useEffect(() => {\n    hoverStateRef.current.isAppBarHovered = isAppBarHovered;\n\n    if (!enabled) {\n      clearScheduledClose();\n      setIsPreviewOpen(false);\n      return;\n    }\n\n    if (isAppBarHovered) {\n      clearScheduledClose();\n      setIsPreviewOpen(true);\n      return;\n    }\n\n    scheduleCloseIfIdle();\n  }, [clearScheduledClose, enabled, isAppBarHovered, scheduleCloseIfIdle]);\n\n  useEffect(() => {\n    hoverStateRef.current.isHandleHovered = false;\n    hoverStateRef.current.isPreviewHovered = false;\n    clearScheduledClose();\n\n    if (!enabled) {\n      setIsPreviewOpen(false);\n    }\n  }, [clearScheduledClose, enabled]);\n\n  useEffect(() => () => clearScheduledClose(), [clearScheduledClose]);\n\n  const handleHandleHoverStart = useCallback(() => {\n    if (!enabled) {\n      return;\n    }\n    // The handle unmounts as soon as the preview opens, so treat it as a\n    // transient trigger instead of persistent hover state.\n    hoverStateRef.current.isHandleHovered = false;\n    clearScheduledClose();\n    setIsPreviewOpen(true);\n    scheduleCloseIfIdle();\n  }, [clearScheduledClose, enabled, scheduleCloseIfIdle]);\n\n  const handleHandleHoverEnd = useCallback(() => {\n    hoverStateRef.current.isHandleHovered = false;\n    scheduleCloseIfIdle();\n  }, [scheduleCloseIfIdle]);\n\n  const handlePreviewHoverStart = useCallback(() => {\n    if (!enabled) {\n      return;\n    }\n    hoverStateRef.current.isPreviewHovered = true;\n    clearScheduledClose();\n    setIsPreviewOpen(true);\n  }, [clearScheduledClose, enabled]);\n\n  const handlePreviewHoverEnd = useCallback(() => {\n    hoverStateRef.current.isPreviewHovered = false;\n    scheduleCloseIfIdle();\n  }, [scheduleCloseIfIdle]);\n\n  return {\n    isPreviewOpen,\n    handleHandleHoverStart,\n    handleHandleHoverEnd,\n    handlePreviewHoverStart,\n    handlePreviewHoverEnd,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/useWorkspaces.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useQuery, keepPreviousData } from '@tanstack/react-query';\nimport { useJsonPatchWsStream } from '@/shared/hooks/useJsonPatchWsStream';\nimport { workspaceSummaryKeys } from '@/shared/hooks/workspaceSummaryKeys';\nimport { makeLocalApiRequest } from '@/shared/lib/localApiTransport';\nimport type {\n  WorkspaceWithStatus,\n  WorkspaceSummary,\n  WorkspaceSummaryResponse,\n  ApiResponse,\n} from 'shared/types';\n\n// UI-specific workspace type for sidebar display\nexport interface SidebarWorkspace {\n  id: string;\n  name: string;\n  branch: string;\n  createdAt: string;\n  updatedAt: string;\n  description: string;\n  filesChanged?: number;\n  linesAdded?: number;\n  linesRemoved?: number;\n  isRunning?: boolean;\n  isPinned?: boolean;\n  isArchived?: boolean;\n  hasPendingApproval?: boolean;\n  hasRunningDevServer?: boolean;\n  hasUnseenActivity?: boolean;\n  latestProcessCompletedAt?: string;\n  latestProcessStatus?: 'running' | 'completed' | 'failed' | 'killed';\n  prStatus?: 'open' | 'merged' | 'closed' | 'unknown';\n  prNumber?: number;\n  prUrl?: string;\n}\n\n// Keep the old export name for backwards compatibility\nexport type Workspace = SidebarWorkspace;\n\nexport interface UseWorkspacesResult {\n  workspaces: SidebarWorkspace[];\n  archivedWorkspaces: SidebarWorkspace[];\n  isLoading: boolean;\n  isConnected: boolean;\n  error: string | null;\n}\n\n// State shape from the WebSocket stream\ntype WorkspacesState = {\n  workspaces: Record<string, WorkspaceWithStatus>;\n};\n\n// Transform WorkspaceWithStatus to SidebarWorkspace, optionally merging summary data\nfunction toSidebarWorkspace(\n  ws: WorkspaceWithStatus,\n  summary?: WorkspaceSummary\n): SidebarWorkspace {\n  return {\n    id: ws.id,\n    name: ws.name ?? ws.branch, // Use name if available, fallback to branch\n    branch: ws.branch,\n    createdAt: ws.created_at,\n    updatedAt: ws.updated_at,\n    description: '',\n    // Use real stats from summary if available\n    filesChanged: summary?.files_changed ?? undefined,\n    linesAdded: summary?.lines_added ?? undefined,\n    linesRemoved: summary?.lines_removed ?? undefined,\n    // Real data from stream\n    isRunning: ws.is_running,\n    isPinned: ws.pinned,\n    isArchived: ws.archived,\n    // Additional data from summary\n    hasPendingApproval: summary?.has_pending_approval,\n    hasRunningDevServer: summary?.has_running_dev_server,\n    hasUnseenActivity: summary?.has_unseen_turns,\n    latestProcessCompletedAt: summary?.latest_process_completed_at ?? undefined,\n    latestProcessStatus: summary?.latest_process_status ?? undefined,\n    prStatus: summary?.pr_status ?? undefined,\n    prNumber:\n      summary?.pr_number != null ? Number(summary.pr_number) : undefined,\n    prUrl: summary?.pr_url ?? undefined,\n  };\n}\n\nexport const workspaceKeys = {\n  all: ['workspaces'] as const,\n};\n\n// workspaceSummaryKeys is imported from @/shared/hooks/workspaceSummaryKeys\n\n// Fetch workspace summaries from the API by archived status\nasync function fetchWorkspaceSummariesByArchived(\n  archived: boolean\n): Promise<Map<string, WorkspaceSummary>> {\n  try {\n    const response = await makeLocalApiRequest('/api/workspaces/summaries', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ archived }),\n    });\n\n    if (!response.ok) {\n      console.warn('Failed to fetch workspace summaries:', response.status);\n      return new Map();\n    }\n\n    const data: ApiResponse<WorkspaceSummaryResponse> = await response.json();\n    if (!data.success || !data.data?.summaries) {\n      return new Map();\n    }\n\n    const map = new Map<string, WorkspaceSummary>();\n    for (const summary of data.data.summaries) {\n      map.set(summary.workspace_id, summary);\n    }\n    return map;\n  } catch (err) {\n    console.warn('Error fetching workspace summaries:', err);\n    return new Map();\n  }\n}\n\nexport function useWorkspaces(): UseWorkspacesResult {\n  // Two separate WebSocket connections: one for active, one for archived\n  // No limit param - we fetch all and slice on frontend so backfill works when archiving\n  const activeEndpoint = '/api/workspaces/streams/ws?archived=false';\n  const archivedEndpoint = '/api/workspaces/streams/ws?archived=true';\n\n  const initialData = useCallback(\n    (): WorkspacesState => ({ workspaces: {} }),\n    []\n  );\n\n  const {\n    data: activeData,\n    isConnected: activeIsConnected,\n    isInitialized: activeIsInitialized,\n    error: activeError,\n  } = useJsonPatchWsStream<WorkspacesState>(activeEndpoint, true, initialData);\n\n  const {\n    data: archivedData,\n    isConnected: archivedIsConnected,\n    isInitialized: archivedIsInitialized,\n    error: archivedError,\n  } = useJsonPatchWsStream<WorkspacesState>(\n    archivedEndpoint,\n    true,\n    initialData\n  );\n\n  // Wait for both streams to be initialized before fetching summaries\n  // Fetch summaries for active workspaces\n  const { data: activeSummaries = new Map<string, WorkspaceSummary>() } =\n    useQuery({\n      queryKey: workspaceSummaryKeys.byArchived(false),\n      queryFn: () => fetchWorkspaceSummariesByArchived(false),\n      enabled: activeIsInitialized,\n      staleTime: 1000,\n      refetchInterval: 15000,\n      refetchOnWindowFocus: false,\n      refetchOnMount: 'always',\n      placeholderData: keepPreviousData,\n    });\n\n  // Fetch summaries for archived workspaces\n  const { data: archivedSummaries = new Map<string, WorkspaceSummary>() } =\n    useQuery({\n      queryKey: workspaceSummaryKeys.byArchived(true),\n      queryFn: () => fetchWorkspaceSummariesByArchived(true),\n      enabled: archivedIsInitialized,\n      staleTime: 1000,\n      refetchInterval: 15000,\n      refetchOnWindowFocus: false,\n      refetchOnMount: 'always',\n      placeholderData: keepPreviousData,\n    });\n\n  const workspaces = useMemo(() => {\n    if (!activeData?.workspaces) return [];\n    return Object.values(activeData.workspaces)\n      .sort((a, b) => {\n        // First sort by pinned (pinned first)\n        if (a.pinned !== b.pinned) {\n          return a.pinned ? -1 : 1;\n        }\n        // Then by created_at (newest first)\n        return (\n          new Date(b.created_at).getTime() - new Date(a.created_at).getTime()\n        );\n      })\n      .map((ws) => toSidebarWorkspace(ws, activeSummaries.get(ws.id)));\n  }, [activeData, activeSummaries]);\n\n  const archivedWorkspaces = useMemo(() => {\n    if (!archivedData?.workspaces) return [];\n    return Object.values(archivedData.workspaces)\n      .sort((a, b) => {\n        // First sort by pinned (pinned first)\n        if (a.pinned !== b.pinned) {\n          return a.pinned ? -1 : 1;\n        }\n        // Then by created_at (newest first)\n        return (\n          new Date(b.created_at).getTime() - new Date(a.created_at).getTime()\n        );\n      })\n      .map((ws) => toSidebarWorkspace(ws, archivedSummaries.get(ws.id)));\n  }, [archivedData, archivedSummaries]);\n\n  // isLoading is true when we haven't received initial data from either stream\n  const isLoading = !activeIsInitialized || !archivedIsInitialized;\n\n  // Combined connection status\n  const isConnected = activeIsConnected && archivedIsConnected;\n\n  // Combined error (show first error if any)\n  const error = activeError || archivedError;\n\n  return {\n    workspaces,\n    archivedWorkspaces,\n    isLoading,\n    isConnected,\n    error,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/hooks/workspaceSummaryKeys.ts",
    "content": "export const workspaceSummaryKeys = {\n  all: ['workspace-summaries'] as const,\n  byArchived: (archived: boolean) =>\n    ['workspace-summaries', archived ? 'archived' : 'active'] as const,\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/integrations/electric/hooks.ts",
    "content": "import { useState, useMemo, useCallback, useEffect, useRef } from 'react';\nimport { useLiveQuery } from '@tanstack/react-db';\nimport { createShapeCollection } from '@/shared/lib/electric/collections';\nimport { useSyncErrorContext } from '@/shared/hooks/useSyncErrorContext';\nimport type { MutationDefinition, ShapeDefinition } from 'shared/remote-types';\nimport type { SyncError } from '@/shared/lib/electric/types';\nimport type { MutationResult, InsertResult } from '@/shared/lib/electric/types';\n\n// Type helpers for extracting types from MutationDefinition\ntype MutationCreateType<M> =\n  M extends MutationDefinition<unknown, infer C, unknown> ? C : never;\ntype MutationUpdateType<M> =\n  M extends MutationDefinition<unknown, unknown, infer U> ? U : never;\n\n/**\n * Base result type returned by useShape (read-only).\n */\nexport interface UseShapeResult<TRow> {\n  /** The synced data array */\n  data: TRow[];\n  /** Whether the initial sync is still loading */\n  isLoading: boolean;\n  /** Sync error if one occurred */\n  error: SyncError | null;\n  /** Function to retry after an error */\n  retry: () => void;\n}\n\n/**\n * Extended result when mutation is provided — adds insert/update/remove.\n */\nexport interface UseShapeMutationResult<TRow, TCreate, TUpdate>\n  extends UseShapeResult<TRow> {\n  /** Insert a new row (optimistic), returns row and persistence promise */\n  insert: (data: TCreate) => InsertResult<TRow>;\n  /** Update a row by ID (optimistic), returns persistence promise */\n  update: (id: string, changes: Partial<TUpdate>) => MutationResult;\n  /** Update multiple rows in a single optimistic transaction */\n  updateMany: (\n    updates: Array<{ id: string; changes: Partial<TUpdate> }>\n  ) => MutationResult;\n  /** Delete a row by ID (optimistic), returns persistence promise */\n  remove: (id: string) => MutationResult;\n}\n\n/**\n * Options for the useShape hook.\n */\nexport interface UseShapeOptions<\n  M extends\n    | MutationDefinition<unknown, unknown, unknown>\n    | undefined = undefined,\n> {\n  /**\n   * Whether to enable the Electric sync subscription.\n   * When false, returns empty data and no-op mutation functions.\n   * @default true\n   */\n  enabled?: boolean;\n  /**\n   * Optional mutation definition. When provided, the hook returns\n   * insert/update/remove functions for optimistic mutations.\n   */\n  mutation?: M;\n}\n\n/**\n * Hook for subscribing to a shape's data via Electric sync,\n * with optional optimistic mutation support.\n *\n * @param shape - The shape definition from shared/remote-types.ts\n * @param params - URL parameters matching the shape's requirements\n * @param options - Optional configuration (enabled, mutation, etc.)\n *\n * @example\n * // Read-only:\n * const { data, isLoading } = useShape(PROJECT_PULL_REQUESTS_SHAPE, { project_id });\n *\n * // With mutations:\n * const { data, insert, update, remove } = useShape(\n *   PROJECT_ISSUES_SHAPE,\n *   { project_id },\n *   { mutation: ISSUE_MUTATION }\n * );\n */\nexport function useShape<\n  T extends Record<string, unknown>,\n  M extends\n    | MutationDefinition<unknown, unknown, unknown>\n    | undefined = undefined,\n>(\n  shape: ShapeDefinition<T>,\n  params: Record<string, string>,\n  options: UseShapeOptions<M> = {} as UseShapeOptions<M>\n): M extends MutationDefinition<unknown, unknown, unknown>\n  ? UseShapeMutationResult<T, MutationCreateType<M>, MutationUpdateType<M>>\n  : UseShapeResult<T> {\n  const { enabled = true, mutation } = options;\n\n  const [error, setError] = useState<SyncError | null>(null);\n  const [retryKey, setRetryKey] = useState(0);\n\n  const syncErrorContext = useSyncErrorContext();\n  const registerErrorFn = syncErrorContext?.registerError;\n  const clearErrorFn = syncErrorContext?.clearError;\n\n  const handleError = useCallback((err: SyncError) => setError(err), []);\n\n  const retry = useCallback(() => {\n    setError(null);\n    setRetryKey((k) => k + 1);\n  }, []);\n\n  const paramsKey = JSON.stringify(params);\n  const stableParams = useMemo(\n    () => JSON.parse(paramsKey) as Record<string, string>,\n    [paramsKey]\n  );\n\n  const streamId = useMemo(\n    () => `${shape.table}:${paramsKey}`,\n    [shape.table, paramsKey]\n  );\n\n  useEffect(() => {\n    if (error && registerErrorFn) {\n      registerErrorFn(streamId, shape.table, error, retry);\n    } else if (!error && clearErrorFn) {\n      clearErrorFn(streamId);\n    }\n\n    return () => {\n      clearErrorFn?.(streamId);\n    };\n  }, [error, streamId, shape.table, retry, registerErrorFn, clearErrorFn]);\n\n  const collection = useMemo(() => {\n    if (!enabled) return null;\n    const config = { onError: handleError };\n    void retryKey;\n    return createShapeCollection(shape, stableParams, config, mutation);\n  }, [enabled, shape, mutation, handleError, retryKey, stableParams]);\n\n  const { data, isLoading: queryLoading } = useLiveQuery(\n    (query) => (collection ? query.from({ item: collection }) : undefined),\n    [collection]\n  );\n\n  const items = useMemo(() => {\n    if (!enabled || !collection || !data || queryLoading) return [];\n    return data as unknown as T[];\n  }, [enabled, collection, data, queryLoading]);\n\n  const isLoading = enabled ? queryLoading : false;\n\n  // --- Mutation support (only used when mutation is provided) ---\n\n  const itemsRef = useRef<T[]>([]);\n  useEffect(() => {\n    itemsRef.current = items;\n  }, [items]);\n\n  type TransactionResult = { isPersisted: { promise: Promise<void> } };\n  type CollectionWithMutations = {\n    insert: (data: unknown) => TransactionResult;\n    update: {\n      (\n        id: string,\n        updater: (draft: Record<string, unknown>) => void\n      ): TransactionResult;\n      (\n        ids: string[],\n        updater: (drafts: Array<Record<string, unknown>>) => void\n      ): TransactionResult;\n    };\n    delete: (id: string) => TransactionResult;\n  };\n  const typedCollection =\n    collection as unknown as CollectionWithMutations | null;\n\n  const insert = useCallback(\n    (insertData: unknown): InsertResult<T> => {\n      const dataWithId = {\n        id: crypto.randomUUID(),\n        ...(insertData as Record<string, unknown>),\n      };\n      if (!typedCollection) {\n        return {\n          data: dataWithId as unknown as T,\n          persisted: Promise.resolve(dataWithId as unknown as T),\n        };\n      }\n      const tx = typedCollection.insert(dataWithId);\n      return {\n        data: dataWithId as unknown as T,\n        persisted: tx.isPersisted.promise.then(() => {\n          const synced = itemsRef.current.find(\n            (item) => (item as unknown as { id: string }).id === dataWithId.id\n          );\n          return (synced ?? dataWithId) as unknown as T;\n        }),\n      };\n    },\n    [typedCollection]\n  );\n\n  const update = useCallback(\n    (id: string, changes: unknown): MutationResult => {\n      if (!typedCollection) {\n        return { persisted: Promise.resolve() };\n      }\n      const tx = typedCollection.update(id, (draft: Record<string, unknown>) =>\n        Object.assign(draft, changes)\n      );\n      return { persisted: tx.isPersisted.promise };\n    },\n    [typedCollection]\n  );\n\n  const updateMany = useCallback(\n    (updates: Array<{ id: string; changes: unknown }>): MutationResult => {\n      if (!typedCollection || updates.length === 0) {\n        return { persisted: Promise.resolve() };\n      }\n\n      const ids = updates.map((update) => update.id);\n      const changesById = new Map(\n        updates.map((update) => [update.id, update.changes])\n      );\n\n      const tx = typedCollection.update(\n        ids,\n        (drafts: Array<Record<string, unknown>>) => {\n          for (const draft of drafts) {\n            const draftId = String(draft.id ?? '');\n            const changes = changesById.get(draftId);\n            if (changes) {\n              Object.assign(draft, changes);\n            }\n          }\n        }\n      );\n\n      return { persisted: tx.isPersisted.promise };\n    },\n    [typedCollection]\n  );\n\n  const remove = useCallback(\n    (id: string): MutationResult => {\n      if (!typedCollection) {\n        return { persisted: Promise.resolve() };\n      }\n      const tx = typedCollection.delete(id);\n      return { persisted: tx.isPersisted.promise };\n    },\n    [typedCollection]\n  );\n\n  const base: UseShapeResult<T> = {\n    data: items,\n    isLoading,\n    error,\n    retry,\n  };\n\n  if (mutation) {\n    return {\n      ...base,\n      insert,\n      update,\n      updateMany,\n      remove,\n    } as M extends MutationDefinition<unknown, unknown, unknown>\n      ? UseShapeMutationResult<T, MutationCreateType<M>, MutationUpdateType<M>>\n      : UseShapeResult<T>;\n  }\n\n  return base as M extends MutationDefinition<unknown, unknown, unknown>\n    ? UseShapeMutationResult<T, MutationCreateType<M>, MutationUpdateType<M>>\n    : UseShapeResult<T>;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/SequenceIndicator.tsx",
    "content": "import { useSequenceTracker } from './SequenceTracker';\nimport { cn } from '@/shared/lib/utils';\n\nexport function SequenceIndicator() {\n  const { buffer, isActive, isInvalid } = useSequenceTracker();\n\n  if (!isActive && !isInvalid) return null;\n\n  return (\n    <div\n      className={cn(\n        'fixed bottom-4 right-4 z-[10001]',\n        'animate-in fade-in-0 zoom-in-95 duration-150',\n        isInvalid && 'animate-shake'\n      )}\n      data-testid=\"sequence-indicator\"\n    >\n      <div\n        className={cn(\n          'flex items-center gap-1 rounded-sm border',\n          'backdrop-blur-sm px-base py-half shadow-lg',\n          isInvalid ? 'border-error bg-error/10' : 'border-border bg-panel/95'\n        )}\n      >\n        {buffer.map((key, index) => (\n          <kbd\n            key={index}\n            className={cn(\n              'inline-flex items-center justify-center',\n              'min-w-[24px] h-6 px-1.5',\n              'rounded-sm border bg-secondary',\n              'font-ibm-plex-mono text-sm',\n              isInvalid ? 'border-error text-error' : 'border-border text-high'\n            )}\n          >\n            {key.toUpperCase()}\n          </kbd>\n        ))}\n        {!isInvalid && <span className=\"text-low text-sm ml-1\">...</span>}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/SequenceTracker.tsx",
    "content": "import { useContext, useState, useEffect, useRef, ReactNode } from 'react';\nimport { createHmrContext } from '@/shared/lib/hmrContext';\nimport {\n  SEQUENCE_FIRST_KEYS,\n  sequentialBindings,\n} from '@/shared/keyboard/registry';\n\ninterface SequenceTrackerContextValue {\n  buffer: string[];\n  isActive: boolean;\n  isInvalid: boolean;\n}\n\nconst SequenceTrackerContext = createHmrContext<SequenceTrackerContextValue>(\n  'SequenceTrackerContext',\n  {\n    buffer: [],\n    isActive: false,\n    isInvalid: false,\n  }\n);\n\nexport const useSequenceTracker = () => useContext(SequenceTrackerContext);\n\nconst SEQUENCE_TIMEOUT_MS = 1500;\nconst INVALID_DISPLAY_MS = 400;\n\nconst VALID_SEQUENCES = new Set(\n  sequentialBindings.map((b) => b.keys.join(','))\n);\n\nfunction isValidPartialSequence(buffer: string[]): boolean {\n  if (buffer.length === 0) return false;\n  if (buffer.length === 1) {\n    return SEQUENCE_FIRST_KEYS.has(buffer[0]);\n  }\n  return VALID_SEQUENCES.has(buffer.join(','));\n}\n\ninterface SequenceTrackerProviderProps {\n  children: ReactNode;\n}\n\n/**\n * Visual feedback for sequential shortcuts (g>s, v>c).\n * Display-only - execution handled by react-hotkeys-hook.\n *\n * IMPORTANT: Uses refs alongside state because React setState is async.\n * Without synchronous ref updates, rapid keypresses read stale state.\n */\nexport function SequenceTrackerProvider({\n  children,\n}: SequenceTrackerProviderProps) {\n  const [buffer, setBuffer] = useState<string[]>([]);\n  const [isActive, setIsActive] = useState(false);\n  const [isInvalid, setIsInvalid] = useState(false);\n\n  const bufferRef = useRef<string[]>([]);\n  const isActiveRef = useRef(false);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const invalidTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  bufferRef.current = buffer;\n  isActiveRef.current = isActive;\n\n  useEffect(() => {\n    const clearBuffer = () => {\n      bufferRef.current = [];\n      isActiveRef.current = false;\n      setBuffer([]);\n      setIsActive(false);\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n        timeoutRef.current = null;\n      }\n    };\n\n    const showInvalid = (invalidBuffer: string[]) => {\n      bufferRef.current = invalidBuffer;\n      isActiveRef.current = false;\n      setBuffer(invalidBuffer);\n      setIsInvalid(true);\n      setIsActive(false);\n\n      if (invalidTimeoutRef.current) {\n        clearTimeout(invalidTimeoutRef.current);\n      }\n      invalidTimeoutRef.current = setTimeout(() => {\n        setIsInvalid(false);\n        setBuffer([]);\n        invalidTimeoutRef.current = null;\n      }, INVALID_DISPLAY_MS);\n    };\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      const target = event.target as HTMLElement;\n      if (\n        target.tagName === 'INPUT' ||\n        target.tagName === 'TEXTAREA' ||\n        target.isContentEditable\n      ) {\n        return;\n      }\n\n      if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) {\n        return;\n      }\n\n      const key = mapCodeToLogicalKey(event.code, event.key);\n      if (!key) return;\n\n      const currentIsActive = isActiveRef.current;\n      const currentBuffer = bufferRef.current;\n\n      if (!currentIsActive && !SEQUENCE_FIRST_KEYS.has(key)) {\n        return;\n      }\n\n      const newBuffer = currentIsActive ? [...currentBuffer, key] : [key];\n\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n      }\n\n      if (newBuffer.length === 1) {\n        bufferRef.current = newBuffer;\n        isActiveRef.current = true;\n        setBuffer(newBuffer);\n        setIsActive(true);\n        timeoutRef.current = setTimeout(clearBuffer, SEQUENCE_TIMEOUT_MS);\n      } else if (newBuffer.length === 2) {\n        isActiveRef.current = false;\n        if (isValidPartialSequence(newBuffer)) {\n          bufferRef.current = newBuffer;\n          setBuffer(newBuffer);\n          timeoutRef.current = setTimeout(clearBuffer, 200);\n        } else {\n          showInvalid(newBuffer);\n        }\n        setIsActive(false);\n      } else {\n        clearBuffer();\n      }\n    };\n\n    window.addEventListener('keydown', handleKeyDown);\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown);\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n      if (invalidTimeoutRef.current) clearTimeout(invalidTimeoutRef.current);\n    };\n  }, []);\n\n  return (\n    <SequenceTrackerContext.Provider value={{ buffer, isActive, isInvalid }}>\n      {children}\n    </SequenceTrackerContext.Provider>\n  );\n}\n\n/** Maps event.code to logical key for keyboard layout independence (KeyG -> 'g') */\nfunction mapCodeToLogicalKey(code: string, key: string): string | null {\n  if (code.startsWith('Key')) {\n    return code.slice(3).toLowerCase();\n  }\n  return key.toLowerCase();\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/hooks.ts",
    "content": "import { createSemanticHook } from '@/shared/keyboard/useSemanticKey';\nimport { Action } from '@/shared/keyboard/registry';\n\n/**\n * Semantic keyboard shortcut hooks\n *\n * These hooks provide a clean, semantic interface for common keyboard actions.\n * All key bindings are centrally managed in the registry.\n */\n\n/**\n * Exit/Close action - typically Esc key\n *\n * @example\n * // In a dialog\n * useKeyExit(() => closeDialog(), { scope: Scope.DIALOG });\n *\n * @example\n * // In kanban board\n * useKeyExit(() => navigateToProjects(), { scope: Scope.KANBAN });\n */\nexport const useKeyExit = createSemanticHook(Action.EXIT);\n\n/**\n * Create action - typically 'c' key\n *\n * @example\n * // Create new task\n * useKeyCreate(() => openTaskForm(), { scope: Scope.KANBAN });\n *\n * @example\n * // Create new project\n * useKeyCreate(() => openProjectForm(), { scope: Scope.PROJECTS });\n */\nexport const useKeyCreate = createSemanticHook(Action.CREATE);\n\n/**\n * Submit action - typically Enter key\n *\n * @example\n * // Submit form in dialog\n * useKeySubmit(() => submitForm(), { scope: Scope.DIALOG });\n */\nexport const useKeySubmit = createSemanticHook(Action.SUBMIT);\n\n/**\n * Focus search action - typically '/' key\n *\n * @example\n * useKeyFocusSearch(() => focusSearchInput(), { scope: Scope.KANBAN });\n */\nexport const useKeyFocusSearch = createSemanticHook(Action.FOCUS_SEARCH);\n\n/**\n * Navigation actions - arrow keys and vim keys (hjkl)\n */\nexport const useKeyNavUp = createSemanticHook(Action.NAV_UP);\nexport const useKeyNavDown = createSemanticHook(Action.NAV_DOWN);\nexport const useKeyNavLeft = createSemanticHook(Action.NAV_LEFT);\nexport const useKeyNavRight = createSemanticHook(Action.NAV_RIGHT);\n\n/**\n * Open details action - typically Enter key\n *\n * @example\n * useKeyOpenDetails(() => openTaskDetails(), { scope: Scope.KANBAN });\n */\nexport const useKeyOpenDetails = createSemanticHook(Action.OPEN_DETAILS);\n\n/**\n * Show help action - typically '?' key\n *\n * @example\n * useKeyShowHelp(() => openHelpDialog(), { scope: Scope.GLOBAL });\n */\nexport const useKeyShowHelp = createSemanticHook(Action.SHOW_HELP);\n\n/**\n * Delete task action - typically 'd' key\n *\n * @example\n * useKeyDeleteTask(() => handleDeleteTask(selectedTask), { scope: Scope.KANBAN });\n */\nexport const useKeyDeleteTask = createSemanticHook(Action.DELETE_TASK);\n\n/**\n * Approve pending approval action - typically Enter key\n *\n * @example\n * useKeyApproveRequest(() => approvePendingRequest(), { scope: Scope.APPROVALS });\n */\nexport const useKeyApproveRequest = createSemanticHook(Action.APPROVE_REQUEST);\n\n/**\n * Deny pending approval action - typically Cmd/Ctrl+Enter\n *\n * @example\n * useKeyDenyApproval(() => denyPendingRequest(), { scope: Scope.GLOBAL });\n */\nexport const useKeyDenyApproval = createSemanticHook(Action.DENY_APPROVAL);\n\n/**\n * Submit follow-up action - typically Cmd+Enter\n * Intelligently sends or queues based on current state (running vs idle)\n *\n * @example\n * useKeySubmitFollowUp(() => handleSubmit(), { scope: Scope.FOLLOW_UP_READY });\n */\nexport const useKeySubmitFollowUp = createSemanticHook(Action.SUBMIT_FOLLOW_UP);\n\n/**\n * Submit task action - typically Cmd+Enter\n * Primary submit action in task dialog (Create & Start or Update)\n *\n * @example\n * useKeySubmitTask(() => handleSubmit(), { scope: Scope.DIALOG, when: canSubmit });\n */\nexport const useKeySubmitTask = createSemanticHook(Action.SUBMIT_TASK);\n\n/**\n * Alternative task submit action - typically Cmd+Shift+Enter\n * Secondary submit action in task dialog (Create Task without starting)\n *\n * @example\n * useKeySubmitTaskAlt(() => handleCreateOnly(), { scope: Scope.DIALOG, when: canSubmit });\n */\nexport const useKeySubmitTaskAlt = createSemanticHook(Action.SUBMIT_TASK_ALT);\n\n/**\n * Submit comment action - typically Cmd+Enter\n * Submit review comment in diff view\n *\n * @example\n * useKeySubmitComment(() => handleSave(), { scope: Scope.EDIT_COMMENT, when: hasContent });\n */\nexport const useKeySubmitComment = createSemanticHook(Action.SUBMIT_COMMENT);\n\n/**\n * Cycle view backward action - typically Cmd+Shift+Enter\n * Cycle views backward in attempt area\n *\n * @example\n * useKeyCycleViewBackward(() => cycleBackward(), { scope: Scope.KANBAN });\n */\nexport const useKeyCycleViewBackward = createSemanticHook(\n  Action.CYCLE_VIEW_BACKWARD\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/index.ts",
    "content": "export * from './hooks';\nexport * from '@/shared/keyboard/registry';\nexport * from './SequenceTracker';\nexport * from './SequenceIndicator';\nexport * from './useWorkspaceShortcuts';\nexport * from './useIssueShortcuts';\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/registry.ts",
    "content": "export enum Scope {\n  GLOBAL = 'global',\n  DIALOG = 'dialog',\n  CONFIRMATION = 'confirmation',\n  KANBAN = 'kanban',\n  PROJECTS = 'projects',\n  SETTINGS = 'settings',\n  EDIT_COMMENT = 'edit-comment',\n  APPROVALS = 'approvals',\n  FOLLOW_UP = 'follow-up',\n  FOLLOW_UP_READY = 'follow-up-ready',\n  WORKSPACE = 'workspace',\n}\n\nexport enum Action {\n  EXIT = 'exit',\n  CREATE = 'create',\n  SUBMIT = 'submit',\n  FOCUS_SEARCH = 'focus_search',\n  NAV_UP = 'nav_up',\n  NAV_DOWN = 'nav_down',\n  NAV_LEFT = 'nav_left',\n  NAV_RIGHT = 'nav_right',\n  OPEN_DETAILS = 'open_details',\n  SHOW_HELP = 'show_help',\n  DELETE_TASK = 'delete_task',\n  APPROVE_REQUEST = 'approve_request',\n  DENY_APPROVAL = 'deny_approval',\n  SUBMIT_FOLLOW_UP = 'submit_follow_up',\n  SUBMIT_TASK = 'submit_task',\n  SUBMIT_TASK_ALT = 'submit_task_alt',\n  SUBMIT_COMMENT = 'submit_comment',\n  CYCLE_VIEW_BACKWARD = 'cycle_view_backward',\n}\n\nexport interface KeyBinding {\n  action: Action;\n  keys: string | string[];\n  scopes?: Scope[];\n  description: string;\n  group?: string;\n}\n\n/**\n * Sequential keyboard shortcut binding (e.g., \"g s\" for Go to Settings)\n */\nexport interface SequentialBinding {\n  id: string;\n  keys: string[];\n  scopes?: Scope[];\n  description: string;\n  group: string;\n  actionId: string;\n}\n\n/**\n * Valid first keys for sequential shortcuts.\n * These keys will be intercepted to start a sequence.\n */\nexport const SEQUENCE_FIRST_KEYS = new Set([\n  'g', // Go/Navigate\n  'w', // Workspace\n  'v', // View\n  'x', // eXecute (git)\n  'y', // Yank/Copy\n  't', // Toggle\n  'r', // Run\n  'i', // Issue\n]);\n\n/**\n * All sequential keyboard shortcuts organized by namespace\n */\nexport const sequentialBindings: SequentialBinding[] = [\n  // Navigation (G = Go)\n  {\n    id: 'seq-go-settings',\n    keys: ['g', 's'],\n    description: 'Go to Settings',\n    group: 'Navigation',\n    actionId: 'settings',\n  },\n  {\n    id: 'seq-go-new-workspace',\n    keys: ['g', 'n'],\n    description: 'Go to New Workspace',\n    group: 'Navigation',\n    actionId: 'new-workspace',\n  },\n\n  // Workspace (W)\n  {\n    id: 'seq-workspace-duplicate',\n    keys: ['w', 'd'],\n    description: 'Duplicate workspace',\n    group: 'Workspace',\n    actionId: 'duplicate-workspace',\n  },\n  {\n    id: 'seq-workspace-rename',\n    keys: ['w', 'r'],\n    description: 'Rename workspace',\n    group: 'Workspace',\n    actionId: 'rename-workspace',\n  },\n  {\n    id: 'seq-workspace-pin',\n    keys: ['w', 'p'],\n    description: 'Pin/Unpin workspace',\n    group: 'Workspace',\n    actionId: 'pin-workspace',\n  },\n  {\n    id: 'seq-workspace-archive',\n    keys: ['w', 'a'],\n    description: 'Archive workspace',\n    group: 'Workspace',\n    actionId: 'archive-workspace',\n  },\n  {\n    id: 'seq-workspace-delete',\n    keys: ['w', 'x'],\n    description: 'Delete workspace',\n    group: 'Workspace',\n    actionId: 'delete-workspace',\n  },\n\n  // View (V)\n  {\n    id: 'seq-view-changes',\n    keys: ['v', 'c'],\n    description: 'Toggle Changes panel',\n    group: 'View',\n    actionId: 'toggle-changes-mode',\n  },\n  {\n    id: 'seq-view-logs',\n    keys: ['v', 'l'],\n    description: 'Toggle Logs panel',\n    group: 'View',\n    actionId: 'toggle-logs-mode',\n  },\n  {\n    id: 'seq-view-preview',\n    keys: ['v', 'p'],\n    description: 'Toggle Preview panel',\n    group: 'View',\n    actionId: 'toggle-preview-mode',\n  },\n  {\n    id: 'seq-view-sidebar',\n    keys: ['v', 's'],\n    description: 'Toggle Left Sidebar',\n    group: 'View',\n    actionId: 'toggle-left-sidebar',\n  },\n  {\n    id: 'seq-view-chat',\n    keys: ['v', 'h'],\n    description: 'Toggle Chat panel',\n    group: 'View',\n    actionId: 'toggle-left-main-panel',\n  },\n\n  // Git (X = eXecute)\n  {\n    id: 'seq-git-pr',\n    keys: ['x', 'p'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Create Pull Request',\n    group: 'Git',\n    actionId: 'git-create-pr',\n  },\n  {\n    id: 'seq-git-merge',\n    keys: ['x', 'm'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Merge branch',\n    group: 'Git',\n    actionId: 'git-merge',\n  },\n  {\n    id: 'seq-git-rebase',\n    keys: ['x', 'r'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Rebase branch',\n    group: 'Git',\n    actionId: 'git-rebase',\n  },\n  {\n    id: 'seq-git-push',\n    keys: ['x', 'u'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Push changes',\n    group: 'Git',\n    actionId: 'git-push',\n  },\n\n  // Yank/Copy (Y)\n  {\n    id: 'seq-yank-path',\n    keys: ['y', 'p'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Copy path',\n    group: 'Yank',\n    actionId: 'copy-path',\n  },\n  {\n    id: 'seq-yank-logs',\n    keys: ['y', 'l'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Copy raw logs',\n    group: 'Yank',\n    actionId: 'copy-raw-logs',\n  },\n\n  // Toggle (T)\n  {\n    id: 'seq-toggle-dev-server',\n    keys: ['t', 'd'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Toggle dev server',\n    group: 'Toggle',\n    actionId: 'toggle-dev-server',\n  },\n  {\n    id: 'seq-toggle-wrap',\n    keys: ['t', 'w'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Toggle line wrapping',\n    group: 'Toggle',\n    actionId: 'toggle-wrap-lines',\n  },\n\n  // Run (R)\n  {\n    id: 'seq-run-setup',\n    keys: ['r', 's'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Run setup script',\n    group: 'Run',\n    actionId: 'run-setup-script',\n  },\n  {\n    id: 'seq-run-cleanup',\n    keys: ['r', 'c'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Run cleanup script',\n    group: 'Run',\n    actionId: 'run-cleanup-script',\n  },\n  {\n    id: 'seq-run-archive',\n    keys: ['r', 'a'],\n    scopes: [Scope.WORKSPACE],\n    description: 'Run archive script',\n    group: 'Run',\n    actionId: 'run-archive-script',\n  },\n\n  // Issue (I)\n  {\n    id: 'seq-issue-create',\n    keys: ['i', 'c'],\n    description: 'Create Issue',\n    group: 'Issue',\n    actionId: 'create-issue',\n  },\n  {\n    id: 'seq-issue-status',\n    keys: ['i', 's'],\n    description: 'Change Status',\n    group: 'Issue',\n    actionId: 'change-issue-status',\n  },\n  {\n    id: 'seq-issue-priority',\n    keys: ['i', 'p'],\n    description: 'Change Priority',\n    group: 'Issue',\n    actionId: 'change-issue-priority',\n  },\n  {\n    id: 'seq-issue-assignees',\n    keys: ['i', 'a'],\n    description: 'Change Assignees',\n    group: 'Issue',\n    actionId: 'change-assignees',\n  },\n  {\n    id: 'seq-issue-make-sub-issue',\n    keys: ['i', 'm'],\n    description: 'Make Sub-issue of',\n    group: 'Issue',\n    actionId: 'make-sub-issue-of',\n  },\n  {\n    id: 'seq-issue-add-sub-issue',\n    keys: ['i', 'b'],\n    description: 'Add Sub-issue',\n    group: 'Issue',\n    actionId: 'add-sub-issue',\n  },\n  {\n    id: 'seq-issue-remove-parent',\n    keys: ['i', 'u'],\n    description: 'Remove Parent',\n    group: 'Issue',\n    actionId: 'remove-parent-issue',\n  },\n  {\n    id: 'seq-issue-link-workspace',\n    keys: ['i', 'w'],\n    description: 'Link Workspace',\n    group: 'Issue',\n    actionId: 'link-workspace',\n  },\n  {\n    id: 'seq-issue-duplicate',\n    keys: ['i', 'd'],\n    description: 'Duplicate Issue',\n    group: 'Issue',\n    actionId: 'duplicate-issue',\n  },\n  {\n    id: 'seq-issue-delete',\n    keys: ['i', 'x'],\n    description: 'Delete Issue',\n    group: 'Issue',\n    actionId: 'delete-issue',\n  },\n];\n\nexport const keyBindings: KeyBinding[] = [\n  // Exit/Close actions\n  {\n    action: Action.EXIT,\n    keys: 'esc',\n    scopes: [Scope.CONFIRMATION],\n    description: 'Close confirmation dialog',\n    group: 'Dialog',\n  },\n  {\n    action: Action.EXIT,\n    keys: 'esc',\n    scopes: [Scope.DIALOG],\n    description: 'Close dialog or blur input',\n    group: 'Dialog',\n  },\n  {\n    action: Action.EXIT,\n    keys: 'esc',\n    scopes: [Scope.KANBAN],\n    description: 'Close panel or navigate to projects',\n    group: 'Navigation',\n  },\n  {\n    action: Action.EXIT,\n    keys: 'esc',\n    scopes: [Scope.EDIT_COMMENT],\n    description: 'Cancel comment',\n    group: 'Comments',\n  },\n  {\n    action: Action.EXIT,\n    keys: 'esc',\n    scopes: [Scope.SETTINGS],\n    description: 'Close settings',\n    group: 'Navigation',\n  },\n\n  // Creation actions\n  {\n    action: Action.CREATE,\n    keys: 'c',\n    scopes: [Scope.KANBAN],\n    description: 'Create new task',\n    group: 'Kanban',\n  },\n  {\n    action: Action.CREATE,\n    keys: 'c',\n    scopes: [Scope.PROJECTS],\n    description: 'Create new project',\n    group: 'Projects',\n  },\n\n  // Submit actions\n  {\n    action: Action.SUBMIT,\n    keys: 'enter',\n    scopes: [Scope.DIALOG],\n    description: 'Submit form or confirm action',\n    group: 'Dialog',\n  },\n\n  // Navigation actions\n  {\n    action: Action.FOCUS_SEARCH,\n    keys: 'slash',\n    scopes: [Scope.KANBAN],\n    description: 'Focus search',\n    group: 'Navigation',\n  },\n  {\n    action: Action.NAV_UP,\n    keys: 'k',\n    scopes: [Scope.KANBAN],\n    description: 'Move up within column',\n    group: 'Navigation',\n  },\n  {\n    action: Action.NAV_DOWN,\n    keys: 'j',\n    scopes: [Scope.KANBAN],\n    description: 'Move down within column',\n    group: 'Navigation',\n  },\n  {\n    action: Action.NAV_LEFT,\n    keys: 'h',\n    scopes: [Scope.KANBAN],\n    description: 'Move to previous column',\n    group: 'Navigation',\n  },\n  {\n    action: Action.NAV_RIGHT,\n    keys: 'l',\n    scopes: [Scope.KANBAN],\n    description: 'Move to next column',\n    group: 'Navigation',\n  },\n  {\n    action: Action.OPEN_DETAILS,\n    keys: ['meta+enter', 'ctrl+enter'],\n    scopes: [Scope.KANBAN],\n    description:\n      'Open details; when open, cycle views forward (attempt → preview → diffs)',\n    group: 'Navigation',\n  },\n  {\n    action: Action.CYCLE_VIEW_BACKWARD,\n    keys: ['meta+shift+enter', 'ctrl+shift+enter'],\n    scopes: [Scope.KANBAN],\n    description: 'Cycle views backward (diffs → preview → attempt)',\n    group: 'Navigation',\n  },\n\n  // Global actions\n  {\n    action: Action.SHOW_HELP,\n    keys: 'shift+slash',\n    scopes: [Scope.GLOBAL],\n    description: 'Show keyboard shortcuts help',\n    group: 'Global',\n  },\n\n  // Task actions\n  {\n    action: Action.DELETE_TASK,\n    keys: 'd',\n    scopes: [Scope.KANBAN],\n    description: 'Delete selected task',\n    group: 'Task Details',\n  },\n\n  // Approval actions\n  {\n    action: Action.APPROVE_REQUEST,\n    keys: 'enter',\n    scopes: [Scope.APPROVALS],\n    description: 'Approve pending approval request',\n    group: 'Approvals',\n  },\n  {\n    action: Action.DENY_APPROVAL,\n    keys: ['meta+enter', 'ctrl+enter'],\n    scopes: [Scope.APPROVALS],\n    description: 'Deny pending approval request',\n    group: 'Approvals',\n  },\n\n  // Follow-up actions\n  {\n    action: Action.SUBMIT_FOLLOW_UP,\n    keys: 'meta+enter',\n    scopes: [Scope.FOLLOW_UP_READY],\n    description: 'Send or queue follow-up (depending on state)',\n    group: 'Follow-up',\n  },\n  {\n    action: Action.SUBMIT_TASK,\n    keys: ['meta+enter', 'ctrl+enter'],\n    scopes: [Scope.DIALOG],\n    description: 'Submit task form (Create & Start or Update)',\n    group: 'Dialog',\n  },\n  {\n    action: Action.SUBMIT_TASK_ALT,\n    keys: ['meta+shift+enter', 'ctrl+shift+enter'],\n    scopes: [Scope.DIALOG],\n    description: 'Submit task form (Create Task)',\n    group: 'Dialog',\n  },\n  {\n    action: Action.SUBMIT_COMMENT,\n    keys: ['meta+enter', 'ctrl+enter'],\n    scopes: [Scope.EDIT_COMMENT],\n    description: 'Submit review comment',\n    group: 'Comments',\n  },\n];\n\n/**\n * Get keyboard bindings for a specific action and scope\n */\nexport function getKeysFor(action: Action, scope?: Scope): string[] {\n  const bindings = keyBindings\n    .filter(\n      (binding) =>\n        binding.action === action &&\n        (!scope || !binding.scopes || binding.scopes.includes(scope))\n    )\n    .flatMap((binding) =>\n      Array.isArray(binding.keys) ? binding.keys : [binding.keys]\n    );\n\n  return bindings;\n}\n\n/**\n * Get binding info for a specific action and scope\n */\nexport function getBindingFor(\n  action: Action,\n  scope?: Scope\n): KeyBinding | undefined {\n  return keyBindings.find(\n    (binding) =>\n      binding.action === action &&\n      (!scope || !binding.scopes || binding.scopes.includes(scope))\n  );\n}\n\n/**\n * Get sequential binding for a specific action ID\n */\nexport function getSequentialBindingFor(\n  actionId: string\n): SequentialBinding | undefined {\n  return sequentialBindings.find((binding) => binding.actionId === actionId);\n}\n\n/**\n * Format sequential keys for display (e.g., ['g', 's'] -> 'G S')\n */\nexport function formatSequentialKeys(keys: string[]): string {\n  return keys.map((k) => k.toUpperCase()).join(' ');\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/types.ts",
    "content": "export type FormTag = 'input' | 'textarea' | 'select';\nexport type EnableOnFormTags =\n  | boolean\n  | readonly (FormTag | Uppercase<FormTag>)[];\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/useIssueShortcuts.ts",
    "content": "import { useCallback, useRef, useEffect, useMemo } from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { Actions } from '@/shared/actions';\nimport {\n  type ActionDefinition,\n  ActionTargetType,\n} from '@/shared/types/actions';\nimport { Scope } from '@/shared/keyboard/registry';\nimport { isProjectDestination } from '@/shared/lib/routes/appNavigation';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\nimport { useCurrentKanbanRouteState } from '@/shared/hooks/useCurrentKanbanRouteState';\nimport { useIssueSelectionStore } from '@/shared/stores/useIssueSelectionStore';\n\nconst SEQUENCE_TIMEOUT_MS = 1500;\n\nconst OPTIONS = {\n  scopes: [Scope.KANBAN],\n  sequenceTimeout: SEQUENCE_TIMEOUT_MS,\n} as const;\n\nexport function useIssueShortcuts() {\n  const { executeAction } = useActions();\n  const { projectId, issueId } = useParams({ strict: false });\n  const destination = useCurrentAppDestination();\n  const { isCreateMode: isCreatingIssue } = useCurrentKanbanRouteState();\n\n  const isKanban = isProjectDestination(destination);\n\n  // Multi-selection support\n  const multiSelectedIssueIds = useIssueSelectionStore(\n    (s) => s.selectedIssueIds\n  );\n  const selectAll = useIssueSelectionStore((s) => s.selectAll);\n  const clearSelection = useIssueSelectionStore((s) => s.clearSelection);\n  const toggleIssue = useIssueSelectionStore((s) => s.toggleIssue);\n  const selectAdjacent = useIssueSelectionStore((s) => s.selectAdjacent);\n\n  const executeActionRef = useRef(executeAction);\n  const projectIdRef = useRef(projectId);\n  const issueIdRef = useRef(issueId);\n  const isKanbanRef = useRef(isKanban);\n  const isCreatingIssueRef = useRef(isCreatingIssue);\n  const multiSelectedIssueIdsRef = useRef(multiSelectedIssueIds);\n  const selectAllRef = useRef(selectAll);\n  const clearSelectionRef = useRef(clearSelection);\n  const toggleIssueRef = useRef(toggleIssue);\n  const selectAdjacentRef = useRef(selectAdjacent);\n\n  useEffect(() => {\n    executeActionRef.current = executeAction;\n    projectIdRef.current = projectId;\n    issueIdRef.current = issueId;\n    isKanbanRef.current = isKanban;\n    isCreatingIssueRef.current = isCreatingIssue;\n    multiSelectedIssueIdsRef.current = multiSelectedIssueIds;\n    selectAllRef.current = selectAll;\n    clearSelectionRef.current = clearSelection;\n    toggleIssueRef.current = toggleIssue;\n    selectAdjacentRef.current = selectAdjacent;\n  });\n\n  // Clean up sequence timer on unmount\n  useEffect(() => {\n    return () => clearTimeout(sequenceTimerRef.current);\n  }, []);\n\n  // Use multi-selected IDs when available, otherwise fall back to single issue\n  const issueIds = useMemo(() => {\n    if (multiSelectedIssueIds.size > 0) {\n      return [...multiSelectedIssueIds];\n    }\n    return issueId ? [issueId] : [];\n  }, [multiSelectedIssueIds, issueId]);\n  const issueIdsRef = useRef(issueIds);\n  useEffect(() => {\n    issueIdsRef.current = issueIds;\n  });\n\n  const executeIssueAction = useCallback(\n    (action: ActionDefinition, e?: KeyboardEvent) => {\n      if (!isKanbanRef.current) return;\n      // react-hotkeys-hook does not call preventDefault for sequence hotkeys,\n      // so we must do it manually to stop the second keystroke from being typed\n      // into any focused input (e.g. the title field after i>c opens create mode).\n      e?.preventDefault();\n\n      const currentProjectId = projectIdRef.current;\n      const currentIssueIds = issueIdsRef.current;\n\n      if (action.requiresTarget === ActionTargetType.ISSUE) {\n        if (!currentProjectId || currentIssueIds.length === 0) return;\n        executeActionRef.current(\n          action,\n          undefined,\n          currentProjectId,\n          currentIssueIds\n        );\n      } else if (action.requiresTarget === ActionTargetType.NONE) {\n        executeActionRef.current(action);\n      }\n    },\n    []\n  );\n\n  const enabled = isKanban;\n\n  // Track when a sequence prefix key (i) is pressed so standalone keys\n  // like `x` don't fire during a sequence like `i>x`.\n  const sequencePendingRef = useRef(false);\n  const sequenceTimerRef = useRef<ReturnType<typeof setTimeout>>();\n  useHotkeys(\n    'i',\n    () => {\n      sequencePendingRef.current = true;\n      clearTimeout(sequenceTimerRef.current);\n      sequenceTimerRef.current = setTimeout(() => {\n        sequencePendingRef.current = false;\n      }, SEQUENCE_TIMEOUT_MS);\n    },\n    { scopes: [Scope.KANBAN], enabled, keydown: true, keyup: false }\n  );\n\n  useHotkeys('i>c', (e) => executeIssueAction(Actions.CreateIssue, e), {\n    ...OPTIONS,\n    enabled,\n  });\n  useHotkeys(\n    'i>s',\n    (e) => {\n      if (isCreatingIssueRef.current) {\n        executeIssueAction(Actions.ChangeNewIssueStatus, e);\n      } else {\n        executeIssueAction(Actions.ChangeIssueStatus, e);\n      }\n    },\n    { ...OPTIONS, enabled }\n  );\n  useHotkeys(\n    'i>p',\n    (e) => {\n      if (isCreatingIssueRef.current) {\n        executeIssueAction(Actions.ChangeNewIssuePriority, e);\n      } else {\n        executeIssueAction(Actions.ChangePriority, e);\n      }\n    },\n    { ...OPTIONS, enabled }\n  );\n  useHotkeys(\n    'i>a',\n    (e) => {\n      if (isCreatingIssueRef.current) {\n        executeIssueAction(Actions.ChangeNewIssueAssignees, e);\n      } else {\n        executeIssueAction(Actions.ChangeAssignees, e);\n      }\n    },\n    { ...OPTIONS, enabled }\n  );\n  useHotkeys('i>m', (e) => executeIssueAction(Actions.MakeSubIssueOf, e), {\n    ...OPTIONS,\n    enabled,\n  });\n  useHotkeys('i>b', (e) => executeIssueAction(Actions.AddSubIssue, e), {\n    ...OPTIONS,\n    enabled,\n  });\n  useHotkeys('i>u', (e) => executeIssueAction(Actions.RemoveParentIssue, e), {\n    ...OPTIONS,\n    enabled,\n  });\n  useHotkeys('i>w', (e) => executeIssueAction(Actions.LinkWorkspace, e), {\n    ...OPTIONS,\n    enabled,\n  });\n  useHotkeys('i>d', (e) => executeIssueAction(Actions.DuplicateIssue, e), {\n    ...OPTIONS,\n    enabled,\n  });\n  useHotkeys('i>x', (e) => executeIssueAction(Actions.DeleteIssue, e), {\n    ...OPTIONS,\n    enabled,\n  });\n\n  // Select all visible issues\n  useHotkeys(\n    'mod+a',\n    (e) => {\n      if (!isKanbanRef.current) return;\n      e.preventDefault();\n      selectAllRef.current();\n    },\n    { scopes: [Scope.KANBAN], enabled }\n  );\n\n  // Clear selection on Escape\n  useHotkeys(\n    'escape',\n    (e) => {\n      if (!isKanbanRef.current) return;\n      if (multiSelectedIssueIdsRef.current.size > 0) {\n        e.preventDefault();\n        e.stopPropagation();\n        clearSelectionRef.current();\n      }\n    },\n    { scopes: [Scope.KANBAN], enabled }\n  );\n\n  // Toggle current issue selection with X\n  useHotkeys(\n    'x',\n    (e) => {\n      if (!isKanbanRef.current) return;\n      // Skip if part of a sequence (e.g. i>x for delete)\n      if (sequencePendingRef.current) return;\n      const currentIssueId = issueIdRef.current;\n      if (!currentIssueId) return;\n      e.preventDefault();\n      toggleIssueRef.current(currentIssueId);\n    },\n    { scopes: [Scope.KANBAN], enabled }\n  );\n\n  // Extend selection with Shift+J / Shift+ArrowDown (select next issue)\n  useHotkeys(\n    'shift+j, shift+down',\n    (e) => {\n      if (!isKanbanRef.current) return;\n      e.preventDefault();\n      selectAdjacentRef.current('down', issueIdRef.current);\n    },\n    { scopes: [Scope.KANBAN], enabled }\n  );\n\n  // Extend selection with Shift+K / Shift+ArrowUp (select previous issue)\n  useHotkeys(\n    'shift+k, shift+up',\n    (e) => {\n      if (!isKanbanRef.current) return;\n      e.preventDefault();\n      selectAdjacentRef.current('up', issueIdRef.current);\n    },\n    { scopes: [Scope.KANBAN], enabled }\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/useSemanticKey.ts",
    "content": "import { useMemo } from 'react';\nimport type { EnableOnFormTags } from '@/shared/keyboard/types';\nimport { Action, Scope, getKeysFor } from '@/shared/keyboard/registry';\nimport { useHotkeys } from 'react-hotkeys-hook';\n\nexport interface SemanticKeyOptions {\n  scope?: Scope;\n  enabled?: boolean | (() => boolean);\n  when?: boolean | (() => boolean); // Alias for enabled\n  enableOnContentEditable?: boolean;\n  enableOnFormTags?: EnableOnFormTags;\n  preventDefault?: boolean;\n}\n\ntype Handler = (e?: KeyboardEvent) => void;\n\n/**\n * Creates a semantic keyboard shortcut hook for a specific action\n */\nexport function createSemanticHook<A extends Action>(action: A) {\n  return function useSemanticKey(\n    handler: Handler,\n    options: SemanticKeyOptions = {}\n  ) {\n    const {\n      scope,\n      enabled = true,\n      when,\n      enableOnContentEditable,\n      enableOnFormTags,\n      preventDefault,\n    } = options;\n\n    // Use 'when' as alias for 'enabled' if provided\n    const isEnabled = when !== undefined ? when : enabled;\n\n    // Memoize to get stable array references and prevent unnecessary re-registrations\n    const keys = useMemo(() => getKeysFor(action, scope), [scope]);\n\n    useHotkeys(\n      keys,\n      (event) => {\n        // Skip if IME composition is in progress (e.g., Japanese, Chinese, Korean input)\n        // This prevents shortcuts from firing when user is converting text with Enter\n        if (event.isComposing) {\n          return;\n        }\n\n        if (isEnabled) {\n          handler(event);\n        }\n      },\n      {\n        enabled,\n        enableOnContentEditable,\n        enableOnFormTags,\n        preventDefault,\n        scopes: scope ? [scope] : ['*'],\n      },\n      [\n        keys,\n        scope,\n        enableOnContentEditable,\n        enableOnFormTags,\n        preventDefault,\n        handler,\n        isEnabled,\n      ]\n    );\n\n    if (keys.length === 0) {\n      console.warn(\n        `No key binding found for action ${action} in scope ${scope}`\n      );\n    }\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/keyboard/useWorkspaceShortcuts.ts",
    "content": "import { useCallback, useRef, useEffect } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { useActions } from '@/shared/hooks/useActions';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { Actions } from '@/shared/actions';\nimport {\n  type ActionDefinition,\n  ActionTargetType,\n} from '@/shared/types/actions';\nimport { Scope } from '@/shared/keyboard/registry';\n\nconst SEQUENCE_TIMEOUT_MS = 1500;\n\nconst OPTIONS = {\n  scopes: [Scope.WORKSPACE],\n  sequenceTimeout: SEQUENCE_TIMEOUT_MS,\n} as const;\n\nexport function useWorkspaceShortcuts() {\n  const { executeAction } = useActions();\n  const { workspaceId, repos } = useWorkspaceContext();\n\n  const workspaceIdRef = useRef(workspaceId);\n  const reposRef = useRef(repos);\n  const executeActionRef = useRef(executeAction);\n\n  useEffect(() => {\n    workspaceIdRef.current = workspaceId;\n    reposRef.current = repos;\n    executeActionRef.current = executeAction;\n  });\n\n  const execute = useCallback((action: ActionDefinition) => {\n    const currentWorkspaceId = workspaceIdRef.current;\n    const currentRepos = reposRef.current;\n    const currentExecuteAction = executeActionRef.current;\n    const firstRepoId = currentRepos?.[0]?.id;\n\n    switch (action.requiresTarget) {\n      case ActionTargetType.GIT:\n        currentExecuteAction(action, currentWorkspaceId, firstRepoId);\n        break;\n      case ActionTargetType.WORKSPACE:\n        currentExecuteAction(action, currentWorkspaceId);\n        break;\n      case ActionTargetType.NONE:\n      case ActionTargetType.ISSUE:\n        currentExecuteAction(action);\n        break;\n    }\n  }, []);\n\n  useHotkeys('g>s', () => execute(Actions.Settings), OPTIONS);\n  useHotkeys('g>n', () => execute(Actions.NewWorkspace), OPTIONS);\n\n  useHotkeys('w>d', () => execute(Actions.DuplicateWorkspace), OPTIONS);\n  useHotkeys('w>r', () => execute(Actions.RenameWorkspace), OPTIONS);\n  useHotkeys('w>p', () => execute(Actions.PinWorkspace), OPTIONS);\n  useHotkeys('w>a', () => execute(Actions.ArchiveWorkspace), OPTIONS);\n  useHotkeys('w>x', () => execute(Actions.DeleteWorkspace), OPTIONS);\n\n  useHotkeys('v>c', () => execute(Actions.ToggleChangesMode), OPTIONS);\n  useHotkeys('v>l', () => execute(Actions.ToggleLogsMode), OPTIONS);\n  useHotkeys('v>p', () => execute(Actions.TogglePreviewMode), OPTIONS);\n  useHotkeys('v>s', () => execute(Actions.ToggleLeftSidebar), OPTIONS);\n  useHotkeys('v>h', () => execute(Actions.ToggleLeftMainPanel), OPTIONS);\n\n  useHotkeys('x>p', () => execute(Actions.GitCreatePR), OPTIONS);\n  useHotkeys('x>m', () => execute(Actions.GitMerge), OPTIONS);\n  useHotkeys('x>r', () => execute(Actions.GitRebase), OPTIONS);\n  useHotkeys('x>u', () => execute(Actions.GitPush), OPTIONS);\n\n  useHotkeys('y>p', () => execute(Actions.CopyWorkspacePath), OPTIONS);\n  useHotkeys('y>l', () => execute(Actions.CopyRawLogs), OPTIONS);\n\n  useHotkeys('t>d', () => execute(Actions.ToggleDevServer), OPTIONS);\n  useHotkeys('t>w', () => execute(Actions.ToggleWrapLines), OPTIONS);\n\n  useHotkeys('r>s', () => execute(Actions.RunSetupScript), OPTIONS);\n  useHotkeys('r>c', () => execute(Actions.RunCleanupScript), OPTIONS);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/StyleOverride.tsx",
    "content": "import { useEffect } from 'react';\nimport { ThemeMode } from 'shared/types';\nimport { useTheme } from '@/shared/hooks/useTheme';\n\ninterface VibeStyleOverrideMessage {\n  type: 'VIBE_STYLE_OVERRIDE';\n  payload:\n    | {\n        kind: 'cssVars';\n        variables: Record<string, string>;\n      }\n    | {\n        kind: 'theme';\n        theme: ThemeMode;\n      };\n}\n\ninterface VibeIframeReadyMessage {\n  type: 'VIBE_IFRAME_READY';\n}\n\n// Component that adds postMessage listener for style overrides\nexport function AppWithStyleOverride({\n  children,\n  setTheme,\n}: {\n  children: React.ReactNode;\n  setTheme: (theme: ThemeMode) => void;\n}) {\n  useEffect(() => {\n    function handleStyleMessage(event: MessageEvent) {\n      if (event.data?.type !== 'VIBE_STYLE_OVERRIDE') return;\n\n      // Origin validation (only if VITE_PARENT_ORIGIN is configured)\n      const allowedOrigin = import.meta.env.VITE_PARENT_ORIGIN;\n      if (allowedOrigin && event.origin !== allowedOrigin) {\n        console.warn(\n          '[StyleOverride] Message from unauthorized origin:',\n          event.origin\n        );\n        return;\n      }\n\n      const message = event.data as VibeStyleOverrideMessage;\n\n      // CSS variable overrides (only --vibe-* prefixed variables)\n      if (\n        message.payload.kind === 'cssVars' &&\n        typeof message.payload.variables === 'object'\n      ) {\n        Object.entries(message.payload.variables).forEach(([name, value]) => {\n          if (typeof value === 'string') {\n            document.documentElement.style.setProperty(name, value);\n          }\n        });\n      } else if (message.payload.kind === 'theme') {\n        setTheme(message.payload.theme);\n      }\n    }\n\n    window.addEventListener('message', handleStyleMessage);\n    return () => window.removeEventListener('message', handleStyleMessage);\n  }, [setTheme]);\n\n  // Send ready message to parent when component mounts\n  useEffect(() => {\n    const allowedOrigin = import.meta.env.VITE_PARENT_ORIGIN;\n\n    // Only send if we're in an iframe and have a parent\n    if (window.parent && window.parent !== window) {\n      const readyMessage: VibeIframeReadyMessage = {\n        type: 'VIBE_IFRAME_READY',\n      };\n\n      // Send to specific origin if configured, otherwise send to any origin\n      const targetOrigin = allowedOrigin || '*';\n      window.parent.postMessage(readyMessage, targetOrigin);\n    }\n  }, []);\n\n  return <>{children}</>;\n}\n\nexport function useStyleOverrideThemeSetter() {\n  const { setTheme } = useTheme();\n  return setTheme;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/TruncatePath.tsx",
    "content": "import { cn } from '@/shared/lib/utils';\n\nexport function DisplayTruncatedPath({ path }: { path: string }) {\n  const isWindows = path.includes('\\\\');\n  const parts = isWindows ? path.split('\\\\') : path.split('/');\n\n  return (\n    <div className=\"h-[1lh] overflow-hidden\">\n      <div className=\"flex flex-row-reverse flex-wrap justify-end relative pl-2\">\n        <EllipsisComponent className=\"bottom-[1lh]\" />\n        <EllipsisComponent className=\"bottom-[2lh]\" />\n        <EllipsisComponent className=\"bottom-[3lh]\" />\n        <EllipsisComponent className=\"bottom-[4lh]\" />\n        <EllipsisComponent className=\"bottom-[5lh]\" />\n        <EllipsisComponent className=\"bottom-[6lh]\" />\n        <EllipsisComponent className=\"bottom-[7lh]\" />\n        <EllipsisComponent className=\"bottom-[8lh]\" />\n        <EllipsisComponent className=\"bottom-[9lh]\" />\n        <EllipsisComponent className=\"bottom-[10lh]\" />\n\n        {parts.reverse().map((part, index) => (\n          <span className=\"flex-none font-ibm-plex-mono \" key={index}>\n            {isWindows ? '\\\\' : '/'}\n            {part}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nconst EllipsisComponent = ({ className }: { className: string }) => {\n  return (\n    <div\n      className={cn('absolute -translate-x-full tracking-tighter', className)}\n    >\n      ...\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/aggregateEntries.ts",
    "content": "import type {\n  PatchTypeWithKey,\n  DisplayEntry,\n  AggregatedPatchGroup,\n  AggregatedDiffGroup,\n  AggregatedThinkingGroup,\n  ToolAggregationType,\n} from '@/shared/hooks/useConversationHistory/types';\n\n/**\n * Checks if a patch entry is a user_message entry.\n */\nfunction isUserMessage(entry: PatchTypeWithKey): boolean {\n  if (entry.type !== 'NORMALIZED_ENTRY') return false;\n  return entry.content.entry_type.type === 'user_message';\n}\n\n/**\n * Checks if a patch entry is a thinking entry.\n */\nfunction isThinkingEntry(entry: PatchTypeWithKey): boolean {\n  if (entry.type !== 'NORMALIZED_ENTRY') return false;\n  return entry.content.entry_type.type === 'thinking';\n}\n\n/**\n * Extracts the file path from a file_edit entry, or null if not a file_edit entry.\n */\nfunction getFileEditPath(entry: PatchTypeWithKey): string | null {\n  if (entry.type !== 'NORMALIZED_ENTRY') return null;\n\n  const entryType = entry.content.entry_type;\n  if (entryType.type !== 'tool_use') return null;\n\n  const { action_type } = entryType;\n  if (action_type.action === 'file_edit') {\n    return action_type.path;\n  }\n\n  return null;\n}\n\n/**\n * Determines if a patch entry can be aggregated and returns its aggregation type.\n * Handles file_read, search, web_fetch, and command_run (categorized by command type).\n */\nfunction getAggregationType(\n  entry: PatchTypeWithKey\n): ToolAggregationType | null {\n  if (entry.type !== 'NORMALIZED_ENTRY') return null;\n\n  const entryType = entry.content.entry_type;\n  if (entryType.type !== 'tool_use') return null;\n\n  const { action_type } = entryType;\n  if (action_type.action === 'file_read') return 'file_read';\n  if (action_type.action === 'search') return 'search';\n  if (action_type.action === 'web_fetch') return 'web_fetch';\n\n  if (action_type.action === 'command_run') {\n    const category = action_type.category;\n    if (\n      category === 'read' ||\n      category === 'search' ||\n      category === 'edit' ||\n      category === 'fetch'\n    ) {\n      return `command_run_${category}`;\n    }\n  }\n\n  return null;\n}\n\n/**\n * First pass: group consecutive thinking entries within each turn (between user messages)\n * for all turns except the last one.\n */\nfunction aggregateThinkingInPreviousTurns(\n  entries: PatchTypeWithKey[]\n): PatchTypeWithKey[] {\n  if (entries.length === 0) return [];\n\n  // Find all user message indices\n  const userMessageIndices: number[] = [];\n  entries.forEach((entry, index) => {\n    if (isUserMessage(entry)) {\n      userMessageIndices.push(index);\n    }\n  });\n\n  // If there's 0 or 1 user message, no \"previous\" turns exist\n  if (userMessageIndices.length <= 1) {\n    return entries;\n  }\n\n  // The last user message index marks the start of the \"current\" turn\n  const lastUserMessageIndex =\n    userMessageIndices[userMessageIndices.length - 1];\n\n  // Process entries, grouping thinking entries in previous turns\n  const result: PatchTypeWithKey[] = [];\n  let currentThinkingGroup: PatchTypeWithKey[] = [];\n\n  const flushThinkingGroup = () => {\n    if (currentThinkingGroup.length === 0) return;\n\n    if (currentThinkingGroup.length === 1) {\n      // Single thinking entry - create a group anyway for consistency in collapsed view\n      const entry = currentThinkingGroup[0];\n      const aggregatedGroup: AggregatedThinkingGroup = {\n        type: 'AGGREGATED_THINKING_GROUP',\n        entries: [...currentThinkingGroup],\n        patchKey: `agg-thinking:${entry.patchKey}`,\n        executionProcessId: entry.executionProcessId,\n      };\n      // Cast to PatchTypeWithKey to maintain the array type\n      result.push(aggregatedGroup as unknown as PatchTypeWithKey);\n    } else {\n      // Multiple entries - create an aggregated thinking group\n      const firstEntry = currentThinkingGroup[0];\n      const aggregatedGroup: AggregatedThinkingGroup = {\n        type: 'AGGREGATED_THINKING_GROUP',\n        entries: [...currentThinkingGroup],\n        patchKey: `agg-thinking:${firstEntry.patchKey}`,\n        executionProcessId: firstEntry.executionProcessId,\n      };\n      // Cast to PatchTypeWithKey to maintain the array type\n      result.push(aggregatedGroup as unknown as PatchTypeWithKey);\n    }\n\n    currentThinkingGroup = [];\n  };\n\n  for (let i = 0; i < entries.length; i++) {\n    const entry = entries[i];\n    const isInPreviousTurn = i < lastUserMessageIndex;\n\n    // Track turn boundaries\n    if (isUserMessage(entry)) {\n      // Flush any pending thinking group before the user message\n      flushThinkingGroup();\n      result.push(entry);\n      continue;\n    }\n\n    // Only aggregate thinking entries in previous turns\n    if (isInPreviousTurn && isThinkingEntry(entry)) {\n      currentThinkingGroup.push(entry);\n    } else {\n      // Flush any pending thinking group\n      flushThinkingGroup();\n      result.push(entry);\n    }\n  }\n\n  // Flush any remaining thinking group\n  flushThinkingGroup();\n\n  return result;\n}\n\n/**\n * Aggregates consecutive entries of the same aggregatable type (file_read, search, web_fetch)\n * into grouped entries for accordion-style display.\n *\n * Also aggregates consecutive file_edit entries for the same file path.\n * Also aggregates thinking entries in previous conversation turns.\n *\n * Rules:\n * - Only group entries of the same type that follow each other consecutively\n * - For file_edit entries, also group by file path\n * - Thinking entries in previous turns (before the last user message) are collapsed\n * - Preserve the original order of entries\n * - Single entries of an aggregatable type are NOT grouped (returned as-is)\n * - At least 2 consecutive entries of the same type are required to form a group\n */\nexport function aggregateConsecutiveEntries(\n  entries: PatchTypeWithKey[]\n): DisplayEntry[] {\n  if (entries.length === 0) return [];\n\n  // First pass: aggregate thinking entries in previous turns\n  const entriesWithThinkingAggregated =\n    aggregateThinkingInPreviousTurns(entries);\n\n  const result: DisplayEntry[] = [];\n\n  // State for tool aggregation (file_read, search, web_fetch, command_run_*)\n  let currentToolGroup: PatchTypeWithKey[] = [];\n  let currentAggregationType: ToolAggregationType | null = null;\n\n  // State for diff aggregation (file_edit by path)\n  let currentDiffGroup: PatchTypeWithKey[] = [];\n  let currentDiffPath: string | null = null;\n\n  const flushToolGroup = () => {\n    if (currentToolGroup.length === 0) return;\n\n    if (currentToolGroup.length === 1) {\n      // Single entry - don't aggregate, return as-is\n      result.push(currentToolGroup[0]);\n    } else {\n      // Multiple entries - create an aggregated group\n      const firstEntry = currentToolGroup[0];\n      const aggregatedGroup: AggregatedPatchGroup = {\n        type: 'AGGREGATED_GROUP',\n        aggregationType: currentAggregationType!,\n        entries: [...currentToolGroup],\n        patchKey: `agg:${firstEntry.patchKey}`,\n        executionProcessId: firstEntry.executionProcessId,\n      };\n      result.push(aggregatedGroup);\n    }\n\n    currentToolGroup = [];\n    currentAggregationType = null;\n  };\n\n  const flushDiffGroup = () => {\n    if (currentDiffGroup.length === 0) return;\n\n    if (currentDiffGroup.length === 1) {\n      // Single entry - don't aggregate, return as-is\n      result.push(currentDiffGroup[0]);\n    } else {\n      // Multiple entries for same file - create an aggregated diff group\n      const firstEntry = currentDiffGroup[0];\n      const aggregatedDiffGroup: AggregatedDiffGroup = {\n        type: 'AGGREGATED_DIFF_GROUP',\n        filePath: currentDiffPath!,\n        entries: [...currentDiffGroup],\n        patchKey: `agg-diff:${firstEntry.patchKey}`,\n        executionProcessId: firstEntry.executionProcessId,\n      };\n      result.push(aggregatedDiffGroup);\n    }\n\n    currentDiffGroup = [];\n    currentDiffPath = null;\n  };\n\n  for (const entry of entriesWithThinkingAggregated) {\n    // Check if this is already an aggregated thinking group (from first pass)\n    if (\n      (entry as unknown as AggregatedThinkingGroup).type ===\n      'AGGREGATED_THINKING_GROUP'\n    ) {\n      flushToolGroup();\n      flushDiffGroup();\n      result.push(entry as unknown as DisplayEntry);\n      continue;\n    }\n\n    const aggregationType = getAggregationType(entry);\n    const fileEditPath = getFileEditPath(entry);\n\n    // Handle file_edit entries\n    if (fileEditPath !== null) {\n      // Flush any pending tool group first\n      flushToolGroup();\n\n      if (currentDiffPath === null) {\n        // Start a new diff group\n        currentDiffPath = fileEditPath;\n        currentDiffGroup.push(entry);\n      } else if (fileEditPath === currentDiffPath) {\n        // Same file - add to current diff group\n        currentDiffGroup.push(entry);\n      } else {\n        // Different file - flush current diff group and start new one\n        flushDiffGroup();\n        currentDiffPath = fileEditPath;\n        currentDiffGroup.push(entry);\n      }\n    }\n    // Handle tool aggregation (file_read, search, web_fetch)\n    else if (aggregationType !== null) {\n      // Flush any pending diff group first\n      flushDiffGroup();\n\n      if (currentAggregationType === null) {\n        // Start a new tool group\n        currentAggregationType = aggregationType;\n        currentToolGroup.push(entry);\n      } else if (aggregationType === currentAggregationType) {\n        // Same type - add to current group\n        currentToolGroup.push(entry);\n      } else {\n        // Different aggregatable type - flush current group and start new one\n        flushToolGroup();\n        currentAggregationType = aggregationType;\n        currentToolGroup.push(entry);\n      }\n    }\n    // Non-aggregatable entry\n    else {\n      // Flush any pending groups and add this entry\n      flushToolGroup();\n      flushDiffGroup();\n      result.push(entry);\n    }\n  }\n\n  // Flush any remaining groups\n  flushToolGroup();\n  flushDiffGroup();\n\n  return result;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/api.ts",
    "content": "// Import all necessary types from shared types\n\nimport {\n  ApprovalStatus,\n  ApiResponse,\n  Config,\n  CreateFollowUpAttempt,\n  ResetProcessRequest,\n  EditorType,\n  CreatePrApiRequest,\n  CreateTag,\n  DirectoryListResponse,\n  DirectoryEntry,\n  ExecutionProcess,\n  ExecutionProcessRepoState,\n  GitBranch,\n  Repo,\n  RepoWithTargetBranch,\n  UpdateRepo,\n  SearchMode,\n  SearchResult,\n  Tag,\n  TagSearchParams,\n  UpdateTag,\n  UserSystemInfo,\n  McpServerQuery,\n  UpdateMcpServersBody,\n  GetMcpServerResponse,\n  AttachmentResponse,\n  GitOperationError,\n  ApprovalResponse,\n  RebaseWorkspaceRequest,\n  ChangeTargetBranchRequest,\n  ChangeTargetBranchResponse,\n  RenameBranchRequest,\n  RenameBranchResponse,\n  CheckEditorAvailabilityResponse,\n  AvailabilityInfo,\n  BaseCodingAgent,\n  ExecutorConfig,\n  DraftFollowUpData,\n  AgentPresetOptionsQuery,\n  RunAgentSetupRequest,\n  RunAgentSetupResponse,\n  GhCliSetupError,\n  RunScriptError,\n  StatusResponse,\n  ListOrganizationsResponse,\n  OrganizationMemberWithProfile,\n  ListMembersResponse,\n  CreateOrganizationRequest,\n  CreateOrganizationResponse,\n  CreateInvitationRequest,\n  CreateInvitationResponse,\n  RevokeInvitationRequest,\n  UpdateMemberRoleRequest,\n  UpdateMemberRoleResponse,\n  Invitation,\n  ListInvitationsResponse,\n  OpenEditorResponse,\n  OpenEditorRequest,\n  PrError,\n  Scratch,\n  ScratchType,\n  CreateScratch,\n  UpdateScratch,\n  PushError,\n  TokenResponse,\n  CurrentUserResponse,\n  QueueStatus,\n  PrCommentsResponse,\n  MergeWorkspaceRequest,\n  PushWorkspaceRequest,\n  RepoBranchStatus,\n  AbortConflictsRequest,\n  ContinueRebaseRequest,\n  Session,\n  Workspace,\n  StartReviewRequest,\n  ReviewError,\n  OpenPrInfo,\n  GitRemote,\n  ListPrsError,\n  AttachExistingPrRequest,\n  AttachPrResponse,\n  CreateWorkspaceFromPrBody,\n  CreateWorkspaceFromPrResponse,\n  CreateFromPrError,\n  MigrationRequest,\n  MigrationResponse,\n  Project,\n  CreateAndStartWorkspaceRequest,\n  CreateAndStartWorkspaceResponse,\n  RelayPairedClient,\n  ListRelayPairedClientsResponse,\n  RemoveRelayPairedClientResponse,\n} from 'shared/types';\nimport type { Project as RemoteProject } from 'shared/remote-types';\nimport type { WorkspaceWithSession } from '@/shared/types/attempt';\nimport { createWorkspaceWithSession } from '@/shared/types/attempt';\nimport { makeRequest as makeRemoteRequest } from '@/shared/lib/remoteApi';\nimport { makeLocalApiRequest } from '@/shared/lib/localApiTransport';\n\nexport class ApiError<E = unknown> extends Error {\n  public status?: number;\n  public error_data?: E;\n\n  constructor(\n    message: string,\n    public statusCode?: number,\n    public response?: Response,\n    error_data?: E\n  ) {\n    super(message);\n    this.name = 'ApiError';\n    this.status = statusCode;\n    this.error_data = error_data;\n  }\n}\n\nconst makeRequest = async (url: string, options: RequestInit = {}) => {\n  const headers = new Headers(options.headers ?? {});\n  if (!headers.has('Content-Type')) {\n    headers.set('Content-Type', 'application/json');\n  }\n\n  return makeLocalApiRequest(url, {\n    ...options,\n    headers,\n  });\n};\n\nexport type Ok<T> = { success: true; data: T };\nexport type Err<E> = { success: false; error: E | undefined; message?: string };\n\n// Result type for endpoints that need typed errors\nexport type Result<T, E> = Ok<T> | Err<E>;\n\ntype ListRemoteProjectsResponse = {\n  projects: RemoteProject[];\n};\n\nexport type OrganizationBillingStatus =\n  | 'free'\n  | 'active'\n  | 'past_due'\n  | 'cancelled'\n  | 'requires_subscription';\n\nexport interface OrganizationBillingStatusResponse {\n  status: OrganizationBillingStatus;\n  billing_enabled: boolean;\n  seat_info: {\n    current_members: number;\n    free_seats: number;\n    requires_subscription: boolean;\n    subscription: {\n      status: string;\n      current_period_end: string;\n      cancel_at_period_end: boolean;\n      quantity: number;\n      unit_amount: number;\n    } | null;\n  } | null;\n}\n\n// Special handler for Result-returning endpoints\nconst handleApiResponseAsResult = async <T, E>(\n  response: Response\n): Promise<Result<T, E>> => {\n  if (!response.ok) {\n    // HTTP error - no structured error data\n    let errorMessage = `Request failed with status ${response.status}`;\n\n    try {\n      const errorData = await response.json();\n      if (errorData.message) {\n        errorMessage = errorData.message;\n      }\n    } catch {\n      errorMessage = response.statusText || errorMessage;\n    }\n\n    return {\n      success: false,\n      error: undefined,\n      message: errorMessage,\n    };\n  }\n\n  const result: ApiResponse<T, E> = await response.json();\n\n  if (!result.success) {\n    return {\n      success: false,\n      error: result.error_data || undefined,\n      message: result.message || undefined,\n    };\n  }\n\n  return { success: true, data: result.data as T };\n};\n\nexport const handleApiResponse = async <T, E = T>(\n  response: Response\n): Promise<T> => {\n  if (!response.ok) {\n    let errorMessage = `Request failed with status ${response.status}`;\n\n    try {\n      const errorData = await response.json();\n      if (errorData.message) {\n        errorMessage = errorData.message;\n      }\n    } catch {\n      // Fallback to status text if JSON parsing fails\n      errorMessage = response.statusText || errorMessage;\n    }\n\n    console.error('[API Error]', {\n      message: errorMessage,\n      status: response.status,\n      response,\n      endpoint: response.url,\n      timestamp: new Date().toISOString(),\n    });\n    throw new ApiError<E>(errorMessage, response.status, response);\n  }\n\n  if (response.status === 204) {\n    return undefined as T;\n  }\n\n  const result: ApiResponse<T, E> = await response.json();\n\n  if (!result.success) {\n    // Check for error_data first (structured errors), then fall back to message\n    if (result.error_data) {\n      console.error('[API Error with data]', {\n        error_data: result.error_data,\n        message: result.message,\n        status: response.status,\n        response,\n        endpoint: response.url,\n        timestamp: new Date().toISOString(),\n      });\n      // Throw a properly typed error with the error data\n      throw new ApiError<E>(\n        result.message || 'API request failed',\n        response.status,\n        response,\n        result.error_data\n      );\n    }\n\n    console.error('[API Error]', {\n      message: result.message || 'API request failed',\n      status: response.status,\n      response,\n      endpoint: response.url,\n      timestamp: new Date().toISOString(),\n    });\n    throw new ApiError<E>(\n      result.message || 'API request failed',\n      response.status,\n      response\n    );\n  }\n\n  return result.data as T;\n};\n\n// Sessions API\nexport const sessionsApi = {\n  getByWorkspace: async (workspaceId: string): Promise<Session[]> => {\n    const response = await makeRequest(\n      `/api/sessions?workspace_id=${workspaceId}`\n    );\n    return handleApiResponse<Session[]>(response);\n  },\n\n  getById: async (sessionId: string): Promise<Session> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}`);\n    return handleApiResponse<Session>(response);\n  },\n\n  create: async (data: {\n    workspace_id: string;\n    executor?: string;\n    name?: string;\n  }): Promise<Session> => {\n    const response = await makeRequest('/api/sessions', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Session>(response);\n  },\n\n  followUp: async (\n    sessionId: string,\n    data: CreateFollowUpAttempt\n  ): Promise<ExecutionProcess> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}/follow-up`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<ExecutionProcess>(response);\n  },\n\n  startReview: async (\n    sessionId: string,\n    data: StartReviewRequest\n  ): Promise<ExecutionProcess> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}/review`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<ExecutionProcess, ReviewError>(response);\n  },\n\n  reset: async (\n    sessionId: string,\n    data: ResetProcessRequest\n  ): Promise<void> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}/reset`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  runSetupScript: async (\n    sessionId: string\n  ): Promise<Result<ExecutionProcess, RunScriptError>> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}/setup`, {\n      method: 'POST',\n    });\n    return handleApiResponseAsResult<ExecutionProcess, RunScriptError>(\n      response\n    );\n  },\n\n  update: async (\n    sessionId: string,\n    data: { name?: string }\n  ): Promise<Session> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Session>(response);\n  },\n};\n\n// Workspace APIs\nexport const workspacesApi = {\n  createAndStart: async (\n    data: CreateAndStartWorkspaceRequest\n  ): Promise<CreateAndStartWorkspaceResponse> => {\n    const response = await makeRequest(`/api/workspaces/start`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<CreateAndStartWorkspaceResponse>(response);\n  },\n\n  getAll: async (taskId: string): Promise<Workspace[]> => {\n    const response = await makeRequest(`/api/workspaces?task_id=${taskId}`);\n    return handleApiResponse<Workspace[]>(response);\n  },\n\n  /** Get all workspaces across all tasks (newest first) */\n  getAllWorkspaces: async (): Promise<Workspace[]> => {\n    const response = await makeRequest('/api/workspaces');\n    return handleApiResponse<Workspace[]>(response);\n  },\n\n  get: async (workspaceId: string): Promise<Workspace> => {\n    const response = await makeRequest(`/api/workspaces/${workspaceId}`);\n    return handleApiResponse<Workspace>(response);\n  },\n\n  update: async (\n    workspaceId: string,\n    data: { archived?: boolean; pinned?: boolean; name?: string }\n  ): Promise<Workspace> => {\n    const response = await makeRequest(`/api/workspaces/${workspaceId}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Workspace>(response);\n  },\n\n  /** Get workspace with latest session */\n  getWithSession: async (\n    workspaceId: string\n  ): Promise<WorkspaceWithSession> => {\n    const [workspace, sessions] = await Promise.all([\n      workspacesApi.get(workspaceId),\n      sessionsApi.getByWorkspace(workspaceId),\n    ]);\n    return createWorkspaceWithSession(workspace, sessions[0]);\n  },\n\n  stop: async (workspaceId: string): Promise<void> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/execution/stop`,\n      {\n        method: 'POST',\n      }\n    );\n    return handleApiResponse<void>(response);\n  },\n\n  delete: async (\n    workspaceId: string,\n    deleteBranches?: boolean\n  ): Promise<void> => {\n    const params = new URLSearchParams();\n    if (deleteBranches) {\n      params.set('delete_branches', 'true');\n    }\n    const queryString = params.toString();\n    const url = `/api/workspaces/${workspaceId}${queryString ? `?${queryString}` : ''}`;\n    const response = await makeRequest(url, {\n      method: 'DELETE',\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  linkToIssue: async (\n    workspaceId: string,\n    projectId: string,\n    issueId: string\n  ): Promise<void> => {\n    const response = await makeRequest(`/api/workspaces/${workspaceId}/links`, {\n      method: 'POST',\n      body: JSON.stringify({ project_id: projectId, issue_id: issueId }),\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  unlinkFromIssue: async (workspaceId: string): Promise<void> => {\n    const response = await makeRequest(`/api/workspaces/${workspaceId}/links`, {\n      method: 'DELETE',\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  runAgentSetup: async (\n    workspaceId: string,\n    data: RunAgentSetupRequest\n  ): Promise<RunAgentSetupResponse> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/integration/agent/setup`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponse<RunAgentSetupResponse>(response);\n  },\n\n  openEditor: async (\n    workspaceId: string,\n    data: OpenEditorRequest\n  ): Promise<OpenEditorResponse> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/integration/editor/open`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponse<OpenEditorResponse>(response);\n  },\n\n  getBranchStatus: async (workspaceId: string): Promise<RepoBranchStatus[]> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/status`\n    );\n    return handleApiResponse<RepoBranchStatus[]>(response);\n  },\n\n  getRepos: async (workspaceId: string): Promise<RepoWithTargetBranch[]> => {\n    const response = await makeRequest(`/api/workspaces/${workspaceId}/repos`);\n    return handleApiResponse<RepoWithTargetBranch[]>(response);\n  },\n\n  getFirstUserMessage: async (workspaceId: string): Promise<string | null> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/messages/first`\n    );\n    return handleApiResponse<string | null>(response);\n  },\n\n  merge: async (\n    workspaceId: string,\n    data: MergeWorkspaceRequest\n  ): Promise<void> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/merge`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponse<void>(response);\n  },\n\n  push: async (\n    workspaceId: string,\n    data: PushWorkspaceRequest\n  ): Promise<Result<void, PushError>> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/push`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponseAsResult<void, PushError>(response);\n  },\n\n  forcePush: async (\n    workspaceId: string,\n    data: PushWorkspaceRequest\n  ): Promise<Result<void, PushError>> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/push/force`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponseAsResult<void, PushError>(response);\n  },\n\n  rebase: async (\n    workspaceId: string,\n    data: RebaseWorkspaceRequest\n  ): Promise<Result<void, GitOperationError>> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/rebase`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponseAsResult<void, GitOperationError>(response);\n  },\n\n  change_target_branch: async (\n    workspaceId: string,\n    data: ChangeTargetBranchRequest\n  ): Promise<ChangeTargetBranchResponse> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/target-branch`,\n      {\n        method: 'PUT',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponse<ChangeTargetBranchResponse>(response);\n  },\n\n  renameBranch: async (\n    workspaceId: string,\n    newBranchName: string\n  ): Promise<RenameBranchResponse> => {\n    const payload: RenameBranchRequest = {\n      new_branch_name: newBranchName,\n    };\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/branch`,\n      {\n        method: 'PUT',\n        body: JSON.stringify(payload),\n      }\n    );\n    return handleApiResponse<RenameBranchResponse>(response);\n  },\n\n  abortConflicts: async (\n    workspaceId: string,\n    data: AbortConflictsRequest\n  ): Promise<void> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/conflicts/abort`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponse<void>(response);\n  },\n\n  continueRebase: async (\n    workspaceId: string,\n    data: ContinueRebaseRequest\n  ): Promise<void> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/git/rebase/continue`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponse<void>(response);\n  },\n\n  createPR: async (\n    workspaceId: string,\n    data: CreatePrApiRequest\n  ): Promise<Result<string, PrError>> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/pull-requests`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponseAsResult<string, PrError>(response);\n  },\n\n  /** Try to auto-attach a PR by matching the workspace branch */\n  attachPr: async (\n    workspaceId: string,\n    data: AttachExistingPrRequest\n  ): Promise<Result<AttachPrResponse, PrError>> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/pull-requests/attach`,\n      {\n        method: 'POST',\n        body: JSON.stringify(data),\n      }\n    );\n    return handleApiResponseAsResult<AttachPrResponse, PrError>(response);\n  },\n\n  startDevServer: async (workspaceId: string): Promise<ExecutionProcess[]> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/execution/dev-server/start`,\n      {\n        method: 'POST',\n      }\n    );\n    return handleApiResponse<ExecutionProcess[]>(response);\n  },\n\n  setupGhCli: async (workspaceId: string): Promise<ExecutionProcess> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/integration/github/cli/setup`,\n      {\n        method: 'POST',\n      }\n    );\n    return handleApiResponse<ExecutionProcess, GhCliSetupError>(response);\n  },\n\n  runSetupScript: async (\n    workspaceId: string\n  ): Promise<Result<ExecutionProcess, RunScriptError>> => {\n    const sessions = await sessionsApi.getByWorkspace(workspaceId);\n    const session =\n      sessions[0] ??\n      (await sessionsApi.create({\n        workspace_id: workspaceId,\n      }));\n\n    return sessionsApi.runSetupScript(session.id);\n  },\n\n  runCleanupScript: async (\n    workspaceId: string\n  ): Promise<Result<ExecutionProcess, RunScriptError>> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/execution/cleanup`,\n      {\n        method: 'POST',\n      }\n    );\n    return handleApiResponseAsResult<ExecutionProcess, RunScriptError>(\n      response\n    );\n  },\n\n  runArchiveScript: async (\n    workspaceId: string\n  ): Promise<Result<ExecutionProcess, RunScriptError>> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/execution/archive`,\n      {\n        method: 'POST',\n      }\n    );\n    return handleApiResponseAsResult<ExecutionProcess, RunScriptError>(\n      response\n    );\n  },\n\n  getPrComments: async (\n    workspaceId: string,\n    repoId: string\n  ): Promise<PrCommentsResponse> => {\n    const response = await makeRequest(\n      `/api/workspaces/${workspaceId}/pull-requests/comments?repo_id=${encodeURIComponent(repoId)}`\n    );\n    return handleApiResponse<PrCommentsResponse>(response);\n  },\n\n  /** Mark all coding agent turns for a workspace as seen */\n  markSeen: async (workspaceId: string): Promise<void> => {\n    const response = await makeRequest(`/api/workspaces/${workspaceId}/seen`, {\n      method: 'PUT',\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  /** Create a workspace directly from a pull request */\n  createFromPr: async (\n    data: CreateWorkspaceFromPrBody\n  ): Promise<Result<CreateWorkspaceFromPrResponse, CreateFromPrError>> => {\n    const response = await makeRequest('/api/workspaces/from-pr', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponseAsResult<\n      CreateWorkspaceFromPrResponse,\n      CreateFromPrError\n    >(response);\n  },\n};\n\n// Execution Process APIs\nexport const executionProcessesApi = {\n  getDetails: async (processId: string): Promise<ExecutionProcess> => {\n    const response = await makeRequest(`/api/execution-processes/${processId}`);\n    return handleApiResponse<ExecutionProcess>(response);\n  },\n\n  getRepoStates: async (\n    processId: string\n  ): Promise<ExecutionProcessRepoState[]> => {\n    const response = await makeRequest(\n      `/api/execution-processes/${processId}/repo-states`\n    );\n    return handleApiResponse<ExecutionProcessRepoState[]>(response);\n  },\n\n  stopExecutionProcess: async (processId: string): Promise<void> => {\n    const response = await makeRequest(\n      `/api/execution-processes/${processId}/stop`,\n      {\n        method: 'POST',\n      }\n    );\n    return handleApiResponse<void>(response);\n  },\n};\n\n// File System APIs\nexport const fileSystemApi = {\n  list: async (path?: string): Promise<DirectoryListResponse> => {\n    const queryParam = path ? `?path=${encodeURIComponent(path)}` : '';\n    const response = await makeRequest(\n      `/api/filesystem/directory${queryParam}`\n    );\n    return handleApiResponse<DirectoryListResponse>(response);\n  },\n\n  listGitRepos: async (path?: string): Promise<DirectoryEntry[]> => {\n    const queryParam = path ? `?path=${encodeURIComponent(path)}` : '';\n    const response = await makeRequest(\n      `/api/filesystem/git-repos${queryParam}`\n    );\n    return handleApiResponse<DirectoryEntry[]>(response);\n  },\n};\n\n// Repo APIs\nexport const repoApi = {\n  list: async (): Promise<Repo[]> => {\n    const response = await makeRequest('/api/repos');\n    return handleApiResponse<Repo[]>(response);\n  },\n\n  listRecent: async (): Promise<Repo[]> => {\n    const response = await makeRequest('/api/repos/recent');\n    return handleApiResponse<Repo[]>(response);\n  },\n\n  getById: async (repoId: string): Promise<Repo> => {\n    const response = await makeRequest(`/api/repos/${repoId}`);\n    return handleApiResponse<Repo>(response);\n  },\n\n  update: async (repoId: string, data: UpdateRepo): Promise<Repo> => {\n    const response = await makeRequest(`/api/repos/${repoId}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Repo>(response);\n  },\n\n  delete: async (repoId: string): Promise<void> => {\n    const response = await makeRequest(`/api/repos/${repoId}`, {\n      method: 'DELETE',\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  register: async (data: {\n    path: string;\n    display_name?: string;\n  }): Promise<Repo> => {\n    const response = await makeRequest('/api/repos', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Repo>(response);\n  },\n\n  getBranches: async (repoId: string): Promise<GitBranch[]> => {\n    const response = await makeRequest(`/api/repos/${repoId}/branches`);\n    return handleApiResponse<GitBranch[]>(response);\n  },\n\n  init: async (data: {\n    parent_path: string;\n    folder_name: string;\n  }): Promise<Repo> => {\n    const response = await makeRequest('/api/repos/init', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Repo>(response);\n  },\n\n  getBatch: async (ids: string[]): Promise<Repo[]> => {\n    const response = await makeRequest('/api/repos/batch', {\n      method: 'POST',\n      body: JSON.stringify({ ids }),\n    });\n    return handleApiResponse<Repo[]>(response);\n  },\n\n  openEditor: async (\n    repoId: string,\n    data: OpenEditorRequest\n  ): Promise<OpenEditorResponse> => {\n    const response = await makeRequest(`/api/repos/${repoId}/open-editor`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<OpenEditorResponse>(response);\n  },\n\n  searchFiles: async (\n    repoId: string,\n    query: string,\n    mode?: SearchMode,\n    options?: RequestInit\n  ): Promise<SearchResult[]> => {\n    const modeParam = mode ? `&mode=${encodeURIComponent(mode)}` : '';\n    const response = await makeRequest(\n      `/api/repos/${repoId}/search?q=${encodeURIComponent(query)}${modeParam}`,\n      options\n    );\n    return handleApiResponse<SearchResult[]>(response);\n  },\n\n  listOpenPrs: async (\n    repoId: string,\n    remoteName?: string\n  ): Promise<Result<OpenPrInfo[], ListPrsError>> => {\n    const params = remoteName\n      ? `?remote=${encodeURIComponent(remoteName)}`\n      : '';\n    const response = await makeRequest(`/api/repos/${repoId}/prs${params}`);\n    return handleApiResponseAsResult<OpenPrInfo[], ListPrsError>(response);\n  },\n\n  listRemotes: async (repoId: string): Promise<GitRemote[]> => {\n    const response = await makeRequest(`/api/repos/${repoId}/remotes`);\n    return handleApiResponse<GitRemote[]>(response);\n  },\n};\n\n// Config APIs (backwards compatible)\nexport const configApi = {\n  getConfig: async (): Promise<UserSystemInfo> => {\n    const response = await makeRequest('/api/info', { cache: 'no-store' });\n    return handleApiResponse<UserSystemInfo>(response);\n  },\n  saveConfig: async (config: Config): Promise<Config> => {\n    const response = await makeRequest('/api/config', {\n      method: 'PUT',\n      body: JSON.stringify(config),\n    });\n    return handleApiResponse<Config>(response);\n  },\n  checkEditorAvailability: async (\n    editorType: EditorType\n  ): Promise<CheckEditorAvailabilityResponse> => {\n    const response = await makeRequest(\n      `/api/editors/check-availability?editor_type=${encodeURIComponent(editorType)}`\n    );\n    return handleApiResponse<CheckEditorAvailabilityResponse>(response);\n  },\n  checkAgentAvailability: async (\n    agent: BaseCodingAgent\n  ): Promise<AvailabilityInfo> => {\n    const response = await makeRequest(\n      `/api/agents/check-availability?executor=${encodeURIComponent(agent)}`\n    );\n    return handleApiResponse<AvailabilityInfo>(response);\n  },\n};\n\n// Task Tags APIs (all tags are global)\nexport const tagsApi = {\n  list: async (params?: TagSearchParams): Promise<Tag[]> => {\n    const queryParam = params?.search\n      ? `?search=${encodeURIComponent(params.search)}`\n      : '';\n    const response = await makeRequest(`/api/tags${queryParam}`);\n    return handleApiResponse<Tag[]>(response);\n  },\n\n  create: async (data: CreateTag): Promise<Tag> => {\n    const response = await makeRequest('/api/tags', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Tag>(response);\n  },\n\n  update: async (tagId: string, data: UpdateTag): Promise<Tag> => {\n    const response = await makeRequest(`/api/tags/${tagId}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Tag>(response);\n  },\n\n  delete: async (tagId: string): Promise<void> => {\n    const response = await makeRequest(`/api/tags/${tagId}`, {\n      method: 'DELETE',\n    });\n    return handleApiResponse<void>(response);\n  },\n};\n\n// MCP Servers APIs\nexport const mcpServersApi = {\n  load: async (query: McpServerQuery): Promise<GetMcpServerResponse> => {\n    const params = new URLSearchParams(query);\n    const response = await makeRequest(`/api/mcp-config?${params.toString()}`);\n    return handleApiResponse<GetMcpServerResponse>(response);\n  },\n  save: async (\n    query: McpServerQuery,\n    data: UpdateMcpServersBody\n  ): Promise<void> => {\n    const params = new URLSearchParams(query);\n    // params.set('profile', profile);\n    const response = await makeRequest(`/api/mcp-config?${params.toString()}`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    if (!response.ok) {\n      const errorData = await response.json();\n      console.error('[API Error] Failed to save MCP servers', {\n        message: errorData.message,\n        status: response.status,\n        response,\n        timestamp: new Date().toISOString(),\n      });\n      throw new ApiError(\n        errorData.message || 'Failed to save MCP servers',\n        response.status,\n        response\n      );\n    }\n  },\n};\n\n// Profiles API\nexport const profilesApi = {\n  load: async (): Promise<{ content: string; path: string }> => {\n    const response = await makeRequest('/api/profiles');\n    return handleApiResponse<{ content: string; path: string }>(response);\n  },\n  save: async (content: string): Promise<string> => {\n    const response = await makeRequest('/api/profiles', {\n      method: 'PUT',\n      body: content,\n      headers: {\n        'Content-Type': 'application/json',\n      },\n    });\n    return handleApiResponse<string>(response);\n  },\n};\n\n// Workspace attachments API\nexport const attachmentsApi = {\n  upload: async (attachment: File): Promise<AttachmentResponse> => {\n    const formData = new FormData();\n    formData.append('image', attachment);\n\n    const response = await makeLocalApiRequest('/api/attachments/upload', {\n      method: 'POST',\n      body: formData,\n      credentials: 'include',\n    });\n\n    if (!response.ok) {\n      const errorText = await response.text();\n      throw new ApiError(\n        `Failed to upload attachment: ${errorText}`,\n        response.status,\n        response\n      );\n    }\n\n    return handleApiResponse<AttachmentResponse>(response);\n  },\n\n  uploadForTask: async (\n    taskId: string,\n    attachment: File\n  ): Promise<AttachmentResponse> => {\n    const formData = new FormData();\n    formData.append('image', attachment);\n\n    const response = await makeLocalApiRequest(\n      `/api/attachments/task/${taskId}/upload`,\n      {\n        method: 'POST',\n        body: formData,\n        credentials: 'include',\n      }\n    );\n\n    if (!response.ok) {\n      const errorText = await response.text();\n      throw new ApiError(\n        `Failed to upload attachment: ${errorText}`,\n        response.status,\n        response\n      );\n    }\n\n    return handleApiResponse<AttachmentResponse>(response);\n  },\n\n  uploadForAttempt: async (\n    workspaceId: string,\n    sessionId: string,\n    attachment: File\n  ): Promise<AttachmentResponse> => {\n    const formData = new FormData();\n    formData.append('image', attachment);\n\n    const response = await makeLocalApiRequest(\n      `/api/workspaces/${workspaceId}/attachments/upload?session_id=${sessionId}`,\n      {\n        method: 'POST',\n        body: formData,\n        credentials: 'include',\n      }\n    );\n\n    if (!response.ok) {\n      const errorText = await response.text();\n      throw new ApiError(\n        `Failed to upload attachment: ${errorText}`,\n        response.status,\n        response\n      );\n    }\n\n    return handleApiResponse<AttachmentResponse>(response);\n  },\n\n  delete: async (attachmentId: string): Promise<void> => {\n    const response = await makeRequest(`/api/attachments/${attachmentId}`, {\n      method: 'DELETE',\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  getTaskAttachments: async (taskId: string): Promise<AttachmentResponse[]> => {\n    const response = await makeRequest(`/api/attachments/task/${taskId}`);\n    return handleApiResponse<AttachmentResponse[]>(response);\n  },\n\n  getAttachmentUrl: (attachmentId: string): string => {\n    return `/api/attachments/${attachmentId}/file`;\n  },\n};\n\n// Approval API\nexport const approvalsApi = {\n  respond: async (\n    approvalId: string,\n    payload: ApprovalResponse,\n    signal?: AbortSignal\n  ): Promise<ApprovalStatus> => {\n    const res = await makeRequest(`/api/approvals/${approvalId}/respond`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(payload),\n      signal,\n    });\n\n    return handleApiResponse<ApprovalStatus>(res);\n  },\n};\n\n// OAuth API\nexport const oauthApi = {\n  handoffInit: async (\n    provider: string,\n    returnTo: string\n  ): Promise<{ handoff_id: string; authorize_url: string }> => {\n    const response = await makeRequest('/api/auth/handoff/init', {\n      method: 'POST',\n      body: JSON.stringify({ provider, return_to: returnTo }),\n    });\n    return handleApiResponse<{ handoff_id: string; authorize_url: string }>(\n      response\n    );\n  },\n\n  status: async (): Promise<StatusResponse> => {\n    const response = await makeRequest('/api/auth/status', {\n      cache: 'no-store',\n    });\n    return handleApiResponse<StatusResponse>(response);\n  },\n\n  logout: async (): Promise<void> => {\n    const response = await makeRequest('/api/auth/logout', {\n      method: 'POST',\n    });\n    if (!response.ok) {\n      throw new ApiError(\n        `Logout failed with status ${response.status}`,\n        response.status,\n        response\n      );\n    }\n  },\n\n  /** Returns the current access token for the remote server (auto-refreshes if needed) */\n  getToken: async (): Promise<TokenResponse | null> => {\n    const response = await makeRequest('/api/auth/token');\n    if (response.status === 401) {\n      throw new ApiError('Unauthorized', 401, response);\n    }\n    if (!response.ok) return null;\n    return handleApiResponse<TokenResponse>(response);\n  },\n\n  /** Returns the user ID of the currently authenticated user */\n  getCurrentUser: async (): Promise<CurrentUserResponse> => {\n    const response = await makeRequest('/api/auth/user');\n    return handleApiResponse<CurrentUserResponse>(response);\n  },\n};\n\n/**\n * @deprecated Use `tokenManager.getToken()` from\n * `@/shared/lib/auth/tokenManager` instead.\n * This function does not handle 401 responses or token refresh coordination.\n */\nexport async function getCachedToken(): Promise<string | null> {\n  const { tokenManager } = await import('@/shared/lib/auth/tokenManager');\n  return tokenManager.getToken();\n}\n\nconst handleRemoteResponse = async <T>(response: Response): Promise<T> => {\n  if (!response.ok) {\n    let errorMessage = `Request failed with status ${response.status}`;\n\n    try {\n      const body = (await response.json()) as {\n        error?: string;\n        message?: string;\n      };\n      errorMessage = body.error || body.message || errorMessage;\n    } catch {\n      errorMessage = response.statusText || errorMessage;\n    }\n\n    throw new ApiError(errorMessage, response.status, response);\n  }\n\n  if (response.status === 204) {\n    return undefined as T;\n  }\n\n  return response.json() as Promise<T>;\n};\n\n// Organizations API\nexport const organizationsApi = {\n  getMembers: async (\n    orgId: string\n  ): Promise<OrganizationMemberWithProfile[]> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/members`\n    );\n    const result = await handleRemoteResponse<ListMembersResponse>(response);\n    return result.members;\n  },\n\n  getUserOrganizations: async (): Promise<ListOrganizationsResponse> => {\n    const response = await makeRemoteRequest('/v1/organizations');\n    return handleRemoteResponse<ListOrganizationsResponse>(response);\n  },\n\n  createOrganization: async (\n    data: CreateOrganizationRequest\n  ): Promise<CreateOrganizationResponse> => {\n    const response = await makeRemoteRequest('/v1/organizations', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(data),\n    });\n    return handleRemoteResponse<CreateOrganizationResponse>(response);\n  },\n\n  createInvitation: async (\n    orgId: string,\n    data: CreateInvitationRequest\n  ): Promise<CreateInvitationResponse> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/invitations`,\n      {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(data),\n      }\n    );\n    return handleRemoteResponse<CreateInvitationResponse>(response);\n  },\n\n  removeMember: async (orgId: string, userId: string): Promise<void> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/members/${userId}`,\n      {\n        method: 'DELETE',\n      }\n    );\n    return handleRemoteResponse<void>(response);\n  },\n\n  updateMemberRole: async (\n    orgId: string,\n    userId: string,\n    data: UpdateMemberRoleRequest\n  ): Promise<UpdateMemberRoleResponse> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/members/${userId}/role`,\n      {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(data),\n      }\n    );\n    return handleRemoteResponse<UpdateMemberRoleResponse>(response);\n  },\n\n  listInvitations: async (orgId: string): Promise<Invitation[]> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/invitations`\n    );\n    const result =\n      await handleRemoteResponse<ListInvitationsResponse>(response);\n    return result.invitations;\n  },\n\n  revokeInvitation: async (\n    orgId: string,\n    invitationId: string\n  ): Promise<void> => {\n    const body: RevokeInvitationRequest = { invitation_id: invitationId };\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/invitations/revoke`,\n      {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(body),\n      }\n    );\n    return handleRemoteResponse<void>(response);\n  },\n\n  getBillingStatus: async (\n    orgId: string\n  ): Promise<OrganizationBillingStatusResponse> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/billing`\n    );\n    return handleRemoteResponse<OrganizationBillingStatusResponse>(response);\n  },\n\n  createCheckoutSession: async (\n    orgId: string,\n    successUrl: string,\n    cancelUrl: string\n  ): Promise<{ url: string }> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/billing/checkout`,\n      {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          success_url: successUrl,\n          cancel_url: cancelUrl,\n        }),\n      }\n    );\n    return handleRemoteResponse<{ url: string }>(response);\n  },\n\n  createPortalSession: async (\n    orgId: string,\n    returnUrl: string\n  ): Promise<{ url: string }> => {\n    const response = await makeRemoteRequest(\n      `/v1/organizations/${orgId}/billing/portal`,\n      {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          return_url: returnUrl,\n        }),\n      }\n    );\n    return handleRemoteResponse<{ url: string }>(response);\n  },\n\n  deleteOrganization: async (orgId: string): Promise<void> => {\n    const response = await makeRemoteRequest(`/v1/organizations/${orgId}`, {\n      method: 'DELETE',\n    });\n    return handleRemoteResponse<void>(response);\n  },\n};\n\nexport const remoteProjectsApi = {\n  listByOrganization: async (\n    organizationId: string\n  ): Promise<RemoteProject[]> => {\n    const response = await makeRequest(\n      `/api/remote/projects?organization_id=${encodeURIComponent(organizationId)}`\n    );\n    const result =\n      await handleApiResponse<ListRemoteProjectsResponse>(response);\n    return result.projects;\n  },\n};\n\n// Scratch API\nexport const scratchApi = {\n  create: async (\n    scratchType: ScratchType,\n    id: string,\n    data: CreateScratch\n  ): Promise<Scratch> => {\n    const response = await makeRequest(`/api/scratch/${scratchType}/${id}`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<Scratch>(response);\n  },\n\n  get: async (scratchType: ScratchType, id: string): Promise<Scratch> => {\n    const response = await makeRequest(`/api/scratch/${scratchType}/${id}`);\n    return handleApiResponse<Scratch>(response);\n  },\n\n  update: async (\n    scratchType: ScratchType,\n    id: string,\n    data: UpdateScratch\n  ): Promise<void> => {\n    const response = await makeRequest(`/api/scratch/${scratchType}/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  delete: async (scratchType: ScratchType, id: string): Promise<void> => {\n    const response = await makeRequest(`/api/scratch/${scratchType}/${id}`, {\n      method: 'DELETE',\n    });\n    return handleApiResponse<void>(response);\n  },\n\n  getStreamUrl: (scratchType: ScratchType, id: string): string =>\n    `/api/scratch/${scratchType}/${id}/stream/ws`,\n};\n\n// Agents API\nexport const agentsApi = {\n  getDiscoveredOptionsStreamUrl: (\n    agent: BaseCodingAgent,\n    opts?: { workspaceId?: string; sessionId?: string; repoId?: string }\n  ): string => {\n    const params = new URLSearchParams();\n    params.set('executor', agent);\n    if (opts?.workspaceId) params.set('workspace_id', opts.workspaceId);\n    if (opts?.sessionId) params.set('session_id', opts.sessionId);\n    if (opts?.repoId) params.set('repo_id', opts.repoId);\n\n    return `/api/agents/discovered-options/ws?${params.toString()}`;\n  },\n\n  getPresetOptions: async (\n    query: AgentPresetOptionsQuery\n  ): Promise<ExecutorConfig> => {\n    const params = new URLSearchParams();\n    params.set('executor', query.executor);\n    if (query.variant) params.set('variant', query.variant);\n    const response = await makeRequest(\n      `/api/agents/preset-options?${params.toString()}`\n    );\n    return handleApiResponse<ExecutorConfig>(response);\n  },\n};\n\n// Queue API for session follow-up messages\nexport const queueApi = {\n  /**\n   * Queue a follow-up message to be executed when current execution finishes\n   */\n  queue: async (\n    sessionId: string,\n    data: DraftFollowUpData\n  ): Promise<QueueStatus> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}/queue`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<QueueStatus>(response);\n  },\n\n  /**\n   * Cancel a queued follow-up message\n   */\n  cancel: async (sessionId: string): Promise<QueueStatus> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}/queue`, {\n      method: 'DELETE',\n    });\n    return handleApiResponse<QueueStatus>(response);\n  },\n\n  /**\n   * Get the current queue status for a session\n   */\n  getStatus: async (sessionId: string): Promise<QueueStatus> => {\n    const response = await makeRequest(`/api/sessions/${sessionId}/queue`);\n    return handleApiResponse<QueueStatus>(response);\n  },\n};\n\n// Migration API\nexport const migrationApi = {\n  listProjects: async (): Promise<Project[]> => {\n    const response = await makeRequest('/api/migration/projects');\n    return handleApiResponse<Project[]>(response);\n  },\n\n  start: async (data: MigrationRequest): Promise<MigrationResponse> => {\n    const response = await makeRequest('/api/migration/start', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    });\n    return handleApiResponse<MigrationResponse>(response);\n  },\n};\n\n// Relay API\nexport const relayApi = {\n  getEnrollmentCode: async (): Promise<{ enrollment_code: string }> => {\n    const response = await makeRequest('/api/relay-auth/enrollment-code', {\n      method: 'POST',\n    });\n    return handleApiResponse<{ enrollment_code: string }>(response);\n  },\n\n  listPairedClients: async (): Promise<RelayPairedClient[]> => {\n    const response = await makeRequest('/api/relay-auth/clients');\n    const body =\n      await handleApiResponse<ListRelayPairedClientsResponse>(response);\n    return body.clients;\n  },\n\n  removePairedClient: async (\n    clientId: string\n  ): Promise<RemoveRelayPairedClientResponse> => {\n    const response = await makeRequest(\n      `/api/relay-auth/clients/${encodeURIComponent(clientId)}`,\n      {\n        method: 'DELETE',\n      }\n    );\n    return handleApiResponse<RemoveRelayPairedClientResponse>(response);\n  },\n};\n\n// Releases API (GitHub releases proxy)\nexport interface GitHubRelease {\n  name: string;\n  tag_name: string;\n  published_at: string;\n  body: string;\n}\n\ninterface ReleasesResponse {\n  releases: GitHubRelease[];\n}\n\nexport const releasesApi = {\n  list: async (): Promise<GitHubRelease[]> => {\n    const response = await makeRequest('/api/releases');\n    const result = await handleApiResponse<ReleasesResponse>(response);\n    return result.releases;\n  },\n};\n\n// Search API (multi-repo file search)\nexport const searchApi = {\n  searchFiles: async (\n    repoIds: string[],\n    query: string,\n    mode?: SearchMode,\n    options?: RequestInit\n  ): Promise<SearchResult[]> => {\n    const repoIdsParam = repoIds.join(',');\n    const modeParam = mode ? `&mode=${encodeURIComponent(mode)}` : '';\n    const response = await makeRequest(\n      `/api/search?q=${encodeURIComponent(query)}&repo_ids=${encodeURIComponent(repoIdsParam)}${modeParam}`,\n      options\n    );\n    return handleApiResponse<SearchResult[]>(response);\n  },\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/attachmentUtils.ts",
    "content": "/** Downloads an attachment from a URL and triggers a browser save dialog. */\nexport async function downloadBlobUrl(\n  url: string,\n  filename: string\n): Promise<void> {\n  const response = await fetch(url, {\n    method: 'GET',\n    mode: 'cors',\n    credentials: 'omit',\n  });\n\n  if (!response.ok) {\n    throw new Error('Failed to download attachment');\n  }\n\n  const blob = await response.blob();\n  const objectUrl = URL.createObjectURL(blob);\n\n  try {\n    const anchor = document.createElement('a');\n    anchor.href = objectUrl;\n    anchor.download = filename;\n    document.body.appendChild(anchor);\n    anchor.click();\n    document.body.removeChild(anchor);\n  } finally {\n    URL.revokeObjectURL(objectUrl);\n  }\n}\n\nconst ATTACHMENT_MARKDOWN_PATTERN = /(!?)\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n\ninterface AttachmentMarkdownMatch {\n  prefix: string;\n  label: string;\n  src: string;\n  start: number;\n  end: number;\n}\n\nfunction findAttachmentMarkdownMatches(\n  content: string\n): AttachmentMarkdownMatch[] {\n  const matches: AttachmentMarkdownMatch[] = [];\n\n  for (const match of content.matchAll(ATTACHMENT_MARKDOWN_PATTERN)) {\n    const fullMatch = match[0];\n    const start = match.index;\n    if (start == null) {\n      continue;\n    }\n\n    matches.push({\n      prefix: match[1] ?? '',\n      label: match[2] ?? '',\n      src: match[3] ?? '',\n      start,\n      end: start + fullMatch.length,\n    });\n  }\n\n  return matches;\n}\n\nfunction normalizeAttachmentWhitespace(content: string): string {\n  return content\n    .replace(/[ \\t]+\\n/g, '\\n')\n    .replace(/\\n[ \\t]+/g, '\\n')\n    .replace(/\\n{3,}/g, '\\n\\n');\n}\n\nfunction removeAttachmentSlice(\n  content: string,\n  start: number,\n  end: number\n): string {\n  let before = content.slice(0, start);\n  let after = content.slice(end);\n\n  if (before.length === 0 && after.startsWith('\\n')) {\n    after = after.slice(1);\n  } else if (after.length === 0 && before.endsWith('\\n')) {\n    before = before.slice(0, -1);\n  } else if (before.endsWith('\\n') && after.startsWith('\\n')) {\n    after = after.slice(1);\n  } else if (before.endsWith(' ') && after.startsWith(' ')) {\n    after = after.slice(1);\n  }\n\n  return normalizeAttachmentWhitespace(before + after);\n}\n\n/** Extracts attachment IDs from `attachment://` references in markdown content. */\nexport function extractAttachmentIds(content: string): Set<string> {\n  const ids = new Set<string>();\n  const regex = /attachment:\\/\\/([a-f0-9-]+)/g;\n  let match;\n  while ((match = regex.exec(content)) !== null) {\n    ids.add(match[1]);\n  }\n  return ids;\n}\n\nexport function replaceAttachmentSource(\n  content: string,\n  previousSrc: string,\n  nextSrc: string\n): { content: string; replaced: boolean } {\n  const matches = findAttachmentMarkdownMatches(content).filter(\n    (match) => match.src === previousSrc\n  );\n\n  if (matches.length === 0) {\n    return { content, replaced: false };\n  }\n\n  let nextContent = content;\n\n  for (const match of matches.reverse()) {\n    const replacement = `${match.prefix}[${match.label}](${nextSrc})`;\n    nextContent =\n      nextContent.slice(0, match.start) +\n      replacement +\n      nextContent.slice(match.end);\n  }\n\n  return {\n    content: nextContent,\n    replaced: true,\n  };\n}\n\nexport function removeAttachmentMarkdownBySource(\n  content: string,\n  src: string\n): { content: string; removed: boolean } {\n  const matches = findAttachmentMarkdownMatches(content).filter(\n    (match) => match.src === src\n  );\n\n  if (matches.length === 0) {\n    return { content, removed: false };\n  }\n\n  let nextContent = content;\n  for (const match of matches.reverse()) {\n    nextContent = removeAttachmentSlice(nextContent, match.start, match.end);\n  }\n\n  return { content: nextContent, removed: true };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/auth/runtime.ts",
    "content": "type PauseableShape = { pause: () => void; resume: () => void };\n\ntype CurrentUser = { user_id: string };\n\nexport interface AuthRuntime {\n  getToken: () => Promise<string | null>;\n  triggerRefresh: () => Promise<string | null>;\n  registerShape: (shape: PauseableShape) => () => void;\n  getCurrentUser: () => Promise<CurrentUser>;\n}\n\nlet authRuntime: AuthRuntime | null = null;\n\nexport function configureAuthRuntime(runtime: AuthRuntime): void {\n  authRuntime = runtime;\n}\n\nexport function getAuthRuntime(): AuthRuntime {\n  if (!authRuntime) {\n    throw new Error('Auth runtime has not been configured');\n  }\n\n  return authRuntime;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/auth/tokenManager.ts",
    "content": "import { ApiError, oauthApi } from '@/shared/lib/api';\nimport { queryClient } from '@/shared/lib/queryClient';\nimport { shouldRefreshAccessToken } from 'shared/jwt';\n\nconst TOKEN_QUERY_KEY = ['auth', 'token'] as const;\nconst TOKEN_STALE_TIME = 125 * 1000;\n\ntype RefreshStateCallback = (isRefreshing: boolean) => void;\ntype PauseableShape = { pause: () => void; resume: () => void };\n\nfunction isUnauthorizedError(error: unknown): boolean {\n  return error instanceof ApiError && error.statusCode === 401;\n}\n\nclass TokenManager {\n  private isRefreshing = false;\n  private refreshPromise: Promise<string | null> | null = null;\n  private subscribers = new Set<RefreshStateCallback>();\n  private pauseableShapes = new Set<PauseableShape>();\n\n  /**\n   * Get a valid access token, refreshing if needed.\n   * Returns null immediately if the user is not logged in.\n   */\n  async getToken(): Promise<string | null> {\n    if (this.refreshPromise) {\n      return this.refreshPromise;\n    }\n\n    // Skip token fetch if user is not logged in — avoids unnecessary 401s\n    // from Electric shapes or other background requests after logout.\n    const cachedSystem = queryClient.getQueryData<{\n      login_status?: { status: string };\n    }>(['user-system']);\n    if (cachedSystem && cachedSystem.login_status?.status !== 'loggedin') {\n      return null;\n    }\n\n    const cachedData = queryClient.getQueryData<{\n      access_token?: string;\n    }>(TOKEN_QUERY_KEY);\n    const cachedToken = cachedData?.access_token;\n    if (!cachedToken || shouldRefreshAccessToken(cachedToken)) {\n      await queryClient.invalidateQueries({ queryKey: TOKEN_QUERY_KEY });\n    }\n\n    try {\n      const data = await queryClient.fetchQuery({\n        queryKey: TOKEN_QUERY_KEY,\n        queryFn: () => oauthApi.getToken(),\n        staleTime: TOKEN_STALE_TIME,\n      });\n      return data?.access_token ?? null;\n    } catch (error) {\n      if (isUnauthorizedError(error)) {\n        await this.handleUnauthorized();\n      }\n      return null;\n    }\n  }\n\n  /**\n   * Force a token refresh. Call this when you receive a 401 response.\n   * Coordinates multiple callers to prevent concurrent refresh attempts.\n   *\n   * Returns the new token (or null if refresh failed).\n   */\n  triggerRefresh(): Promise<string | null> {\n    // CRITICAL: Assign promise SYNCHRONOUSLY so concurrent 401 handlers share one refresh.\n    this.refreshPromise ??= this.doRefresh();\n    return this.refreshPromise;\n  }\n\n  /**\n   * Register an Electric shape for pause/resume during token refresh.\n   * When refresh starts, all shapes are paused to prevent 401 spam.\n   * When refresh completes, shapes are resumed.\n   *\n   * Returns an unsubscribe function.\n   */\n  registerShape(shape: PauseableShape): () => void {\n    this.pauseableShapes.add(shape);\n    // If currently refreshing, pause immediately\n    if (this.isRefreshing) {\n      shape.pause();\n    }\n    return () => this.pauseableShapes.delete(shape);\n  }\n\n  /**\n   * Get the current refreshing state synchronously.\n   */\n  getRefreshingState(): boolean {\n    return this.isRefreshing;\n  }\n\n  /**\n   * Subscribe to refresh state changes.\n   * Returns an unsubscribe function.\n   */\n  subscribe(callback: RefreshStateCallback): () => void {\n    this.subscribers.add(callback);\n    return () => this.subscribers.delete(callback);\n  }\n\n  private async doRefresh(): Promise<string | null> {\n    // Skip refresh if user is already logged out — avoids unnecessary 401s\n    // from Electric shapes or other background requests after logout.\n    const cachedSystem = queryClient.getQueryData<{\n      login_status?: { status: string };\n    }>(['user-system']);\n    if (cachedSystem && cachedSystem.login_status?.status !== 'loggedin') {\n      // Pause shapes so they stop making requests while logged out\n      this.pauseShapes();\n      return null;\n    }\n\n    this.setRefreshing(true);\n    this.pauseShapes();\n\n    try {\n      // Invalidate the cache to force a fresh fetch\n      await queryClient.invalidateQueries({ queryKey: TOKEN_QUERY_KEY });\n\n      // Fetch fresh token\n      const data = await queryClient.fetchQuery({\n        queryKey: TOKEN_QUERY_KEY,\n        queryFn: () => oauthApi.getToken(),\n        staleTime: TOKEN_STALE_TIME,\n      });\n\n      const token = data?.access_token ?? null;\n      if (token) {\n        this.resumeShapes();\n      }\n      return token;\n    } catch (error) {\n      if (isUnauthorizedError(error)) {\n        await this.handleUnauthorized();\n      }\n      return null;\n    } finally {\n      this.refreshPromise = null;\n      this.setRefreshing(false);\n    }\n  }\n\n  private async handleUnauthorized(): Promise<void> {\n    // Check if the user was previously logged in before we invalidate.\n    // If they're already logged out, 401s are expected — don't show the dialog.\n    const cachedSystem = queryClient.getQueryData<{\n      login_status?: { status: string };\n    }>(['user-system']);\n    const wasLoggedIn = cachedSystem?.login_status?.status === 'loggedin';\n\n    // Pause shapes — session is invalid, prevent further 401s\n    this.pauseShapes();\n\n    // Reload system state so the UI transitions to logged-out\n    await queryClient.invalidateQueries({ queryKey: ['user-system'] });\n\n    // Only show the login dialog if the user was previously logged in\n    // (i.e., their session expired unexpectedly). Don't prompt users who\n    // intentionally logged out or were never logged in.\n    if (wasLoggedIn) {\n      const { OAuthDialog } = await import(\n        '@/shared/dialogs/global/OAuthDialog'\n      );\n      void OAuthDialog.show({});\n    }\n  }\n\n  private setRefreshing(value: boolean): void {\n    this.isRefreshing = value;\n    this.subscribers.forEach((cb) => cb(value));\n  }\n\n  private pauseShapes(): void {\n    for (const shape of this.pauseableShapes) {\n      shape.pause();\n    }\n  }\n\n  private resumeShapes(): void {\n    for (const shape of this.pauseableShapes) {\n      shape.resume();\n    }\n  }\n}\n\n// Export singleton instance\nexport const tokenManager = new TokenManager();\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/clipboard.ts",
    "content": "/** Ask the extension to copy text to the OS clipboard (fallback path). */\nexport function parentClipboardWrite(text: string) {\n  try {\n    window.parent.postMessage(\n      { type: 'vscode-iframe-clipboard-copy', text },\n      '*'\n    );\n  } catch (_err) {\n    void 0;\n  }\n}\n\n/** Copy helper that prefers navigator.clipboard and falls back to the bridge. */\nexport async function writeClipboardViaBridge(text: string): Promise<boolean> {\n  try {\n    await navigator.clipboard.writeText(text);\n    return true;\n  } catch {\n    parentClipboardWrite(text);\n    return false;\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/colors.ts",
    "content": "// Predefined color palette for projects and tags (HSL format)\n// Modern, vibrant colors with good differentiation\nexport const PRESET_COLORS = [\n  '0 84% 60%', // Coral Red - vibrant, warm\n  '24 95% 53%', // Tangerine - energetic orange\n  '45 93% 58%', // Golden Yellow - bright, optimistic\n  '158 64% 52%', // Mint Green - fresh, modern\n  '200 98% 39%', // Ocean Blue - professional, calm\n  '271 81% 56%', // Vivid Purple - creative, modern\n  '330 81% 60%', // Hot Pink - bold, playful\n  '183 74% 44%', // Teal - sophisticated\n  '262 52% 47%', // Indigo - deep, elegant\n  '142 71% 45%', // Emerald - nature, growth\n  '17 88% 40%', // Rust - warm, earthy\n  '231 48% 48%', // Slate Blue - professional\n] as const;\n\nexport type PresetColor = (typeof PRESET_COLORS)[number];\n\n/**\n * Get a random color from the preset palette\n */\nexport function getRandomPresetColor(): string {\n  return PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)];\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/conflicts.ts",
    "content": "import type { ConflictOp } from 'shared/types';\n\nexport function displayConflictOpLabel(op?: ConflictOp | null): string {\n  switch (op) {\n    case 'merge':\n      return 'Merge';\n    case 'cherry_pick':\n      return 'Cherry-pick';\n    case 'revert':\n      return 'Revert';\n    case 'rebase':\n    default:\n      return 'Rebase';\n  }\n}\n\nfunction formatConflictHeader(\n  op: ConflictOp | null | undefined,\n  sourceBranch: string,\n  baseBranch?: string,\n  repoName?: string\n): string {\n  const repoContext = repoName ? ` in repository '${repoName}'` : '';\n  switch (op) {\n    case 'merge':\n      return `Merge conflicts while merging into '${sourceBranch}'${repoContext}.`;\n    case 'cherry_pick':\n      return `Cherry-pick conflicts on '${sourceBranch}'${repoContext}.`;\n    case 'revert':\n      return `Revert conflicts on '${sourceBranch}'${repoContext}.`;\n    case 'rebase':\n    default:\n      return `Rebase conflicts while rebasing '${sourceBranch}' onto '${baseBranch ?? 'base branch'}'${repoContext}.`;\n  }\n}\n\nexport function buildResolveConflictsInstructions(\n  sourceBranch: string | null,\n  baseBranch: string | undefined,\n  conflictedFiles: string[],\n  op?: ConflictOp | null,\n  repoName?: string\n): string {\n  const source = sourceBranch || 'current attempt branch';\n  const base = baseBranch ?? 'base branch';\n  const filesList = conflictedFiles.slice(0, 12);\n  const filesBlock = filesList.length\n    ? `\\n\\nFiles with conflicts:\\n${filesList.map((f) => `- ${f}`).join('\\n')}`\n    : '';\n\n  const opTitle = displayConflictOpLabel(op);\n  const header = formatConflictHeader(op, source, base, repoName);\n\n  return (\n    `${header}` +\n    filesBlock +\n    `\\n\\nPlease resolve each file carefully. When continuing, ensure the ${opTitle.toLowerCase()} does not hang (set \\`GIT_EDITOR=true\\` or use a non-interactive editor).`\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/date.ts",
    "content": "/**\n * Format a date string as \"Jan 5, 10:30 AM\".\n */\nexport function formatDateShortWithTime(dateString: string): string {\n  const date = new Date(dateString);\n  return date.toLocaleDateString(undefined, {\n    month: 'short',\n    day: 'numeric',\n    hour: '2-digit',\n    minute: '2-digit',\n  });\n}\n\n/**\n * Format a date string as a relative time (e.g., \"just now\", \"5m ago\", \"2h ago\", \"3d ago\").\n */\nexport function formatRelativeTime(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffSecs = Math.floor(diffMs / 1000);\n  const diffMins = Math.floor(diffSecs / 60);\n  const diffHours = Math.floor(diffMins / 60);\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffSecs < 60) return 'just now';\n  if (diffMins < 60) return `${diffMins}m ago`;\n  if (diffHours < 24) return `${diffHours}h ago`;\n  return `${diffDays}d ago`;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/devServerUtils.ts",
    "content": "import type { ExecutionProcess } from 'shared/types';\n\n/**\n * Extract the working directory from a dev server process's executor action.\n */\nexport function getDevServerWorkingDir(\n  process: ExecutionProcess\n): string | null {\n  const typ = process.executor_action?.typ;\n  if (typ && 'type' in typ && typ.type === 'ScriptRequest') {\n    return (typ as { working_dir: string | null }).working_dir;\n  }\n  return null;\n}\n\n/**\n * Deduplicate dev server processes by working directory, keeping the latest\n * process for each unique directory.\n */\nexport function deduplicateDevServersByWorkingDir(\n  processes: ExecutionProcess[]\n): ExecutionProcess[] {\n  const byWorkingDir = new Map<string, ExecutionProcess>();\n  for (const process of processes) {\n    const workingDir = getDevServerWorkingDir(process) ?? 'unknown';\n    const existing = byWorkingDir.get(workingDir);\n    if (\n      !existing ||\n      new Date(process.started_at) > new Date(existing.started_at)\n    ) {\n      byWorkingDir.set(workingDir, process);\n    }\n  }\n  return Array.from(byWorkingDir.values());\n}\n\n/**\n * Filter processes to only include dev servers.\n */\nexport function filterDevServerProcesses(\n  processes: ExecutionProcess[]\n): ExecutionProcess[] {\n  return processes.filter((process) => process.run_reason === 'devserver');\n}\n\n/**\n * Filter processes to only include running dev servers.\n */\nexport function filterRunningDevServers(\n  processes: ExecutionProcess[]\n): ExecutionProcess[] {\n  return processes.filter(\n    (process) =>\n      process.run_reason === 'devserver' && process.status === 'running'\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/diffDataAdapter.ts",
    "content": "import {\n  parseDiffFromFile,\n  type FileContents,\n  type FileDiffMetadata,\n  type ChangeTypes,\n  type DiffLineAnnotation,\n  type AnnotationSide,\n} from '@pierre/diffs';\nimport type { Diff, DiffChangeKind } from 'shared/types';\nimport type { ReviewComment } from '@/shared/hooks/useReview';\nimport type { NormalizedGitHubComment } from '@/shared/hooks/useWorkspaceContext';\nimport { DiffSide } from '@/shared/types/diff';\n\n/**\n * Discriminated union type for comment annotations.\n * Allows the diff viewer to distinguish between user review comments\n * and GitHub PR comments.\n */\nexport type CommentAnnotation =\n  | { type: 'review'; comment: ReviewComment }\n  | { type: 'github'; comment: NormalizedGitHubComment };\n\n/**\n * Maps vibe-kanban's DiffChangeKind to pierre/diffs ChangeTypes.\n *\n * Mapping:\n * - 'added' → 'new'\n * - 'deleted' → 'deleted'\n * - 'modified' → 'change'\n * - 'renamed' → 'rename-pure' or 'rename-changed' (based on content diff)\n * - 'copied' → 'change'\n * - 'permissionChange' → 'change'\n */\nfunction mapChangeKindToChangeType(\n  kind: DiffChangeKind,\n  oldContent: string | null,\n  newContent: string | null\n): ChangeTypes {\n  switch (kind) {\n    case 'added':\n      return 'new';\n    case 'deleted':\n      return 'deleted';\n    case 'modified':\n      return 'change';\n    case 'renamed':\n      // Check if content differs for renamed files\n      return oldContent === newContent ? 'rename-pure' : 'rename-changed';\n    case 'copied':\n      return 'change';\n    case 'permissionChange':\n      return 'change';\n    default:\n      return 'change';\n  }\n}\n\n/**\n * Maps DiffSide (0 = old, 1 = new) to pierre/diffs AnnotationSide.\n */\nfunction mapSideToAnnotationSide(side: DiffSide): AnnotationSide {\n  return side === DiffSide.Old ? 'deletions' : 'additions';\n}\n\n/**\n * Extracts the file path from a Diff, preferring newPath for most cases.\n */\nfunction getFilePath(diff: Diff): string {\n  return diff.newPath ?? diff.oldPath ?? 'unknown';\n}\n\n/**\n * Transforms a vibe-kanban Diff to pierre/diffs FileDiffMetadata.\n *\n * Uses parseDiffFromFile from @pierre/diffs to generate the diff metadata\n * from old and new file contents.\n *\n * @param diff - The vibe-kanban Diff object\n * @returns FileDiffMetadata for use with pierre/diffs components\n */\nexport function transformDiffToFileDiffMetadata(\n  diff: Diff,\n  options?: { ignoreWhitespace?: boolean }\n): FileDiffMetadata {\n  const filePath = getFilePath(diff);\n\n  // Handle contentOmitted case - create placeholder metadata\n  if (diff.contentOmitted) {\n    const changeType = mapChangeKindToChangeType(\n      diff.change,\n      diff.oldContent,\n      diff.newContent\n    );\n\n    return {\n      name: filePath,\n      prevName:\n        diff.oldPath !== diff.newPath ? (diff.oldPath ?? undefined) : undefined,\n      type: changeType,\n      hunks: [],\n      splitLineCount: 0,\n      unifiedLineCount: 0,\n    };\n  }\n\n  // Prepare file contents for parseDiffFromFile\n  const oldFile: FileContents = {\n    name: diff.oldPath ?? filePath,\n    contents: diff.oldContent ?? '',\n  };\n\n  const newFile: FileContents = {\n    name: filePath,\n    contents: diff.newContent ?? '',\n  };\n\n  // Use pierre/diffs parser to generate diff metadata\n  const metadata = parseDiffFromFile(\n    oldFile,\n    newFile,\n    options?.ignoreWhitespace ? { ignoreWhitespace: true } : undefined\n  );\n\n  // Override the type based on our DiffChangeKind mapping\n  // parseDiffFromFile may not correctly detect renames/copies\n  const changeType = mapChangeKindToChangeType(\n    diff.change,\n    diff.oldContent,\n    diff.newContent\n  );\n\n  return {\n    ...metadata,\n    type: changeType,\n    prevName:\n      diff.oldPath !== diff.newPath ? (diff.oldPath ?? undefined) : undefined,\n  };\n}\n\n/**\n * Creates a unique key for a comment based on file path, line number, and side.\n * Used for deduplication.\n */\nfunction createCommentKey(\n  filePath: string,\n  lineNumber: number,\n  side: DiffSide\n): string {\n  return `${filePath}:${lineNumber}:${side}`;\n}\n\n/**\n * Transforms review comments and GitHub comments into pierre/diffs annotations.\n *\n * Implements deduplication: if both a user review comment and a GitHub comment\n * exist on the same line/side, only the user review comment is included.\n *\n * @param comments - User review comments from ReviewProvider\n * @param githubComments - Normalized GitHub PR comments\n * @param filePath - The file path to filter comments for\n * @returns Array of DiffLineAnnotation with CommentAnnotation metadata\n */\nexport function transformCommentsToAnnotations(\n  comments: ReviewComment[],\n  githubComments: NormalizedGitHubComment[],\n  filePath: string\n): DiffLineAnnotation<CommentAnnotation>[] {\n  const annotations: DiffLineAnnotation<CommentAnnotation>[] = [];\n  const occupiedKeys = new Set<string>();\n\n  // First, add all user review comments (they take priority)\n  for (const comment of comments) {\n    if (comment.filePath !== filePath) continue;\n\n    const key = createCommentKey(\n      comment.filePath,\n      comment.lineNumber,\n      comment.side\n    );\n    occupiedKeys.add(key);\n\n    annotations.push({\n      side: mapSideToAnnotationSide(comment.side),\n      lineNumber: comment.lineNumber,\n      metadata: {\n        type: 'review',\n        comment,\n      },\n    });\n  }\n\n  // Then, add GitHub comments only if no user comment exists on that line/side\n  for (const ghComment of githubComments) {\n    // Handle path matching - GitHub paths may not have repo prefix\n    const pathMatches =\n      ghComment.filePath === filePath ||\n      filePath.endsWith('/' + ghComment.filePath);\n\n    if (!pathMatches) continue;\n\n    const key = createCommentKey(\n      ghComment.filePath,\n      ghComment.lineNumber,\n      ghComment.side\n    );\n\n    // Skip if user already has a comment on this line/side\n    if (occupiedKeys.has(key)) continue;\n\n    annotations.push({\n      side: mapSideToAnnotationSide(ghComment.side),\n      lineNumber: ghComment.lineNumber,\n      metadata: {\n        type: 'github',\n        comment: ghComment,\n      },\n    });\n  }\n\n  return annotations;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/diffHeightEstimate.ts",
    "content": "import type { Diff } from 'shared/types';\n\n// Constants for height calculation\nconst HEADER_HEIGHT = 48; // px - collapsed state\nconst LINE_HEIGHT = 20; // px - approximate line height in diff view\nconst PADDING = 16; // px - top/bottom padding\nconst SPACING = 8; // px - space between items (pb-base = 0.5rem)\n\n/**\n * Estimate the height of a diff item based on its content.\n * Used by virtuoso for better scroll position estimation.\n */\nexport function estimateDiffHeight(diff: Diff, isExpanded: boolean): number {\n  if (!isExpanded) {\n    return HEADER_HEIGHT + SPACING;\n  }\n\n  // For expanded diffs, estimate based on line count\n  const lineCount = (diff.additions ?? 0) + (diff.deletions ?? 0);\n  // Add some buffer for hunk headers, context lines, etc.\n  const estimatedLines = Math.max(lineCount * 1.2, 10);\n\n  return HEADER_HEIGHT + estimatedLines * LINE_HEIGHT + PADDING + SPACING;\n}\n\n/**\n * Calculate a reasonable default height for the virtuoso list.\n * Uses median of estimated heights for better accuracy.\n */\nexport function calculateDefaultHeight(diffs: Diff[]): number {\n  if (diffs.length === 0) return 200;\n\n  // Assume most diffs start expanded for modified files\n  const heights = diffs.map((diff) => estimateDiffHeight(diff, true));\n  heights.sort((a, b) => a - b);\n\n  // Return median\n  const mid = Math.floor(heights.length / 2);\n  return heights.length % 2 === 0\n    ? (heights[mid - 1] + heights[mid]) / 2\n    : heights[mid];\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/diffStatsParser.ts",
    "content": "/**\n * Parse unified diff to extract addition/deletion counts.\n * Does not depend on any diff library.\n */\nexport function parseDiffStats(unifiedDiff: string): {\n  additions: number;\n  deletions: number;\n} {\n  let additions = 0;\n  let deletions = 0;\n  const lines = unifiedDiff.split('\\n');\n  for (const line of lines) {\n    if (line.startsWith('+') && !line.startsWith('+++')) additions++;\n    else if (line.startsWith('-') && !line.startsWith('---')) deletions++;\n  }\n  return { additions, deletions };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/electric/collections.ts",
    "content": "import { electricCollectionOptions } from '@tanstack/electric-db-collection';\nimport { createCollection } from '@tanstack/react-db';\n\nimport { getAuthRuntime } from '@/shared/lib/auth/runtime';\nimport { getRemoteApiUrl, makeRequest } from '@/shared/lib/remoteApi';\nimport type { MutationDefinition, ShapeDefinition } from 'shared/remote-types';\nimport type { CollectionConfig, SyncError } from '@/shared/lib/electric/types';\n\ntype ElectricRow = Record<string, unknown> & { [key: string]: unknown };\n\ntype SourceMode = 'electric' | 'fallback';\n\ntype SourceRuntime = {\n  mode: SourceMode;\n  fallbackLocked: boolean;\n  refreshers: Set<() => Promise<void>>;\n  fallbackSwitchers: Set<() => void>;\n};\n\ntype MutationFnParams = {\n  transaction: {\n    mutations: Array<{\n      modified?: unknown;\n      original?: unknown;\n      key?: string;\n      changes?: unknown;\n    }>;\n  };\n};\n\ntype SyncParams = {\n  collection: {\n    isReady: () => boolean;\n    onFirstReady: (callback: () => void) => void;\n  };\n  begin: () => void;\n  write: (message: {\n    type: 'insert' | 'update' | 'delete';\n    value: ElectricRow;\n    metadata?: Record<string, unknown>;\n  }) => void;\n  commit: () => void;\n  markReady: () => void;\n  truncate: () => void;\n};\n\ntype LoadSubsetFn = (options: unknown) => true | Promise<void>;\ntype UnloadSubsetFn = (options: unknown) => void;\n\ntype SyncResult =\n  | void\n  | (() => void)\n  | {\n      cleanup?: () => void;\n      loadSubset?: LoadSubsetFn;\n      unloadSubset?: UnloadSubsetFn;\n    };\n\ntype NormalizedSyncResult = {\n  cleanup?: () => void;\n  loadSubset?: LoadSubsetFn;\n  unloadSubset?: UnloadSubsetFn;\n};\n\ntype SyncConfigLike = {\n  sync: (syncParams: SyncParams) => SyncResult;\n  getSyncMetadata?: () => Record<string, unknown>;\n  rowUpdateMode?: 'partial' | 'full';\n};\n\nconst DEFAULT_GC_TIME_MS = 5 * 60 * 1000;\nconst ELECTRIC_READY_TIMEOUT_MS = 3000;\nconst FALLBACK_REFRESH_INTERVAL_MS = 30 * 1000;\n\nconst collectionCache = new Map<string, ReturnType<typeof createCollection>>();\nconst sourceRuntimes = new Map<string, SourceRuntime>();\nconst fallbackSnapshotCache = new Map<string, ElectricRow[]>();\n\nclass ErrorHandler {\n  private lastErrorTime = 0;\n  private lastErrorMessage = '';\n  private consecutiveErrors = 0;\n  private readonly baseDebounceMs = 1000;\n  private readonly maxDebounceMs = 30000;\n\n  shouldReport(message: string): boolean {\n    const now = Date.now();\n    const debounceMs = Math.min(\n      this.baseDebounceMs * Math.pow(2, this.consecutiveErrors),\n      this.maxDebounceMs\n    );\n\n    if (\n      message === this.lastErrorMessage &&\n      now - this.lastErrorTime < debounceMs\n    ) {\n      return false;\n    }\n\n    this.lastErrorTime = now;\n    if (message === this.lastErrorMessage) {\n      this.consecutiveErrors += 1;\n    } else {\n      this.consecutiveErrors = 0;\n      this.lastErrorMessage = message;\n    }\n\n    return true;\n  }\n}\n\nfunction buildUrl(baseUrl: string, params: Record<string, string>): string {\n  let url = baseUrl;\n  for (const [key, value] of Object.entries(params)) {\n    url = url.replace(`{${key}}`, encodeURIComponent(value));\n  }\n  return url;\n}\n\nfunction buildFallbackRequestPath(\n  fallbackUrl: string,\n  params: Record<string, string>\n): string {\n  const path = buildUrl(fallbackUrl, params);\n  const query = new URLSearchParams();\n\n  for (const [key, value] of Object.entries(params)) {\n    if (!value) continue;\n    query.set(key, value);\n  }\n\n  const queryString = query.toString();\n  return queryString ? `${path}?${queryString}` : path;\n}\n\nfunction buildCollectionId(\n  table: string,\n  params: Record<string, string>,\n  hasMutations: boolean\n): string {\n  const sortedParams = Object.keys(params)\n    .sort()\n    .map((key) => params[key])\n    .join('-');\n\n  const base = sortedParams ? `${table}-${sortedParams}` : table;\n  return hasMutations ? `${base}-mut` : base;\n}\n\nfunction buildSourceKey(table: string, params: Record<string, string>): string {\n  const sortedEntries = Object.entries(params).sort(([a], [b]) =>\n    a.localeCompare(b)\n  );\n  if (sortedEntries.length === 0) {\n    return table;\n  }\n\n  const values = sortedEntries\n    .map(([key, value]) => `${key}=${value}`)\n    .join('&');\n  return `${table}?${values}`;\n}\n\nfunction getRowKey(item: Record<string, unknown>): string {\n  if ('id' in item && item.id) {\n    return String(item.id);\n  }\n\n  return Object.entries(item)\n    .filter(([key]) => key.endsWith('_id'))\n    .sort(([a], [b]) => a.localeCompare(b))\n    .map(([, value]) => String(value))\n    .join('-');\n}\n\nfunction normalizeSyncResult(result: SyncResult): NormalizedSyncResult {\n  if (!result) return {};\n  if (typeof result === 'function') {\n    return { cleanup: result };\n  }\n  return result;\n}\n\nfunction getOrCreateSourceRuntime(sourceKey: string): SourceRuntime {\n  const existing = sourceRuntimes.get(sourceKey);\n  if (existing) {\n    return existing;\n  }\n\n  const created: SourceRuntime = {\n    mode: 'electric',\n    fallbackLocked: false,\n    refreshers: new Set(),\n    fallbackSwitchers: new Set(),\n  };\n  sourceRuntimes.set(sourceKey, created);\n  return created;\n}\n\nfunction lockSourceToFallback(sourceKey: string): void {\n  const runtime = getOrCreateSourceRuntime(sourceKey);\n  if (runtime.fallbackLocked) return;\n\n  runtime.fallbackLocked = true;\n  runtime.mode = 'fallback';\n\n  const switchers = Array.from(runtime.fallbackSwitchers);\n  for (const switcher of switchers) {\n    switcher();\n  }\n}\n\nfunction registerFallbackSwitcher(\n  sourceKey: string,\n  switcher: () => void\n): () => void {\n  const runtime = getOrCreateSourceRuntime(sourceKey);\n  runtime.fallbackSwitchers.add(switcher);\n\n  if (runtime.fallbackLocked) {\n    switcher();\n  }\n\n  return () => {\n    runtime.fallbackSwitchers.delete(switcher);\n  };\n}\n\nfunction registerFallbackRefresher(\n  sourceKey: string,\n  refresher: () => Promise<void>\n): () => void {\n  const runtime = getOrCreateSourceRuntime(sourceKey);\n  runtime.refreshers.add(refresher);\n  return () => {\n    runtime.refreshers.delete(refresher);\n  };\n}\n\nfunction invalidateFallbackCache(sourceKey: string): void {\n  fallbackSnapshotCache.delete(sourceKey);\n}\n\nfunction refreshFallbackSource(sourceKey: string): void {\n  const runtime = getOrCreateSourceRuntime(sourceKey);\n  for (const refresher of runtime.refreshers) {\n    void refresher();\n  }\n}\n\nfunction isAbortError(error: unknown): boolean {\n  return error instanceof DOMException && error.name === 'AbortError';\n}\n\nfunction isPageVisible(): boolean {\n  return document.visibilityState === 'visible';\n}\n\nfunction isCancelledErrorMessage(message?: string): boolean {\n  if (!message) return false;\n  return /\\bcancell?ed\\b/i.test(message);\n}\n\nfunction isTransientElectricFailure(error: unknown): boolean {\n  if (isAbortError(error)) return true;\n  if (!isPageVisible()) return true;\n\n  const message = error instanceof Error ? error.message : String(error);\n  return isCancelledErrorMessage(message);\n}\n\nfunction isTransientElectricShapeError(error: {\n  name?: string;\n  message?: string;\n}): boolean {\n  if (error.name === 'AbortError') return true;\n  if (!isPageVisible()) return true;\n  return isCancelledErrorMessage(error.message);\n}\n\nfunction createErrorReporter(\n  config?: CollectionConfig\n): (error: SyncError) => void {\n  const handler = new ErrorHandler();\n\n  return (error: SyncError) => {\n    if (!handler.shouldReport(error.message)) return;\n\n    if (isPageVisible()) {\n      console.error('Shape sync error:', error);\n    }\n    config?.onError?.(error);\n  };\n}\n\nfunction createErrorHandlingFetch(args: {\n  onError: (error: SyncError) => void;\n  onElectricUnavailable: () => void;\n  isPaused: () => boolean;\n}) {\n  return async (\n    input: RequestInfo | URL,\n    init?: RequestInit\n  ): Promise<Response> => {\n    if (args.isPaused()) {\n      throw new DOMException(\n        'Shape request aborted: not authenticated',\n        'AbortError'\n      );\n    }\n\n    try {\n      return await fetch(input, init);\n    } catch (error) {\n      if (isTransientElectricFailure(error)) {\n        throw error;\n      }\n\n      const message = error instanceof Error ? error.message : 'Network error';\n      args.onError({ message });\n      args.onElectricUnavailable();\n      throw error;\n    }\n  };\n}\n\nfunction createElectricShapeOptions(args: {\n  shape: ShapeDefinition<unknown>;\n  params: Record<string, string>;\n  reportError: (error: SyncError) => void;\n  onElectricUnavailable: () => void;\n}) {\n  const authRuntime = getAuthRuntime();\n  let isPaused = false;\n\n  authRuntime.registerShape({\n    pause: () => {\n      isPaused = true;\n    },\n    resume: () => {\n      isPaused = false;\n    },\n  });\n\n  const url = buildUrl(args.shape.url, args.params);\n\n  return {\n    url: `${getRemoteApiUrl()}${url}`,\n    params: args.params,\n    headers: {\n      Authorization: async () => {\n        const token = await authRuntime.getToken();\n        if (!token) {\n          isPaused = true;\n          return '';\n        }\n        return `Bearer ${token}`;\n      },\n    },\n    parser: {\n      timestamptz: (value: string) => value,\n    },\n    fetchClient: createErrorHandlingFetch({\n      onError: args.reportError,\n      onElectricUnavailable: args.onElectricUnavailable,\n      isPaused: () => isPaused,\n    }),\n    onError: (error: { status?: number; message?: string; name?: string }) => {\n      if (isPaused) return;\n      if (isTransientElectricShapeError(error)) return;\n\n      const status = error.status;\n      const message = error.message || String(error);\n\n      if (status === 401) {\n        authRuntime.triggerRefresh().catch(() => {\n          args.reportError({ status, message });\n        });\n        return;\n      }\n\n      args.reportError({ status, message });\n\n      if (status === undefined || status >= 500) {\n        args.onElectricUnavailable();\n      }\n    },\n  };\n}\n\nfunction applySnapshot(syncParams: SyncParams, rows: ElectricRow[]): void {\n  syncParams.begin();\n  syncParams.truncate();\n\n  for (const row of rows) {\n    syncParams.write({\n      type: 'insert',\n      value: row,\n      metadata: {},\n    });\n  }\n\n  syncParams.commit();\n  syncParams.markReady();\n}\n\nfunction extractFallbackRows(\n  payload: unknown,\n  table: string\n): Array<ElectricRow> {\n  if (!payload || typeof payload !== 'object') {\n    throw new Error(`Fallback response for \"${table}\" is not an object`);\n  }\n\n  const rows = (payload as Record<string, unknown>)[table];\n  if (!Array.isArray(rows)) {\n    throw new Error(`Fallback response missing \"${table}\" array`);\n  }\n\n  return rows as Array<ElectricRow>;\n}\n\nasync function parseResponseError(\n  response: Response,\n  fallbackMessage: string\n): Promise<string> {\n  try {\n    const body = (await response.json()) as {\n      message?: string;\n      error?: string;\n    };\n    return body.message || body.error || fallbackMessage;\n  } catch {\n    return fallbackMessage;\n  }\n}\n\nfunction createFallbackSync(args: {\n  sourceKey: string;\n  shape: ShapeDefinition<unknown>;\n  params: Record<string, string>;\n  reportError: (error: SyncError) => void;\n}) {\n  return (syncParams: SyncParams): SyncResult => {\n    const runtime = getOrCreateSourceRuntime(args.sourceKey);\n    runtime.mode = 'fallback';\n    runtime.fallbackLocked = true;\n\n    let isCleanedUp = false;\n    let refreshPromise: Promise<void> | null = null;\n\n    const refreshNow = async () => {\n      if (refreshPromise) {\n        return refreshPromise;\n      }\n\n      refreshPromise = (async () => {\n        try {\n          const response = await makeRequest(\n            buildFallbackRequestPath(args.shape.fallbackUrl, args.params),\n            { method: 'GET', cache: 'no-store' }\n          );\n\n          if (!response.ok) {\n            const message = await parseResponseError(\n              response,\n              `Failed to fetch fallback ${args.shape.table}`\n            );\n            throw new Error(message);\n          }\n\n          const payload = (await response.json()) as unknown;\n          const rows = extractFallbackRows(payload, args.shape.table);\n          fallbackSnapshotCache.set(args.sourceKey, rows);\n\n          if (!isCleanedUp) {\n            applySnapshot(syncParams, rows);\n          }\n        } catch (error) {\n          if (isAbortError(error)) return;\n\n          const message =\n            error instanceof Error ? error.message : 'Fallback fetch failed';\n          args.reportError({ message });\n\n          if (!isCleanedUp && !syncParams.collection.isReady()) {\n            syncParams.markReady();\n          }\n        } finally {\n          refreshPromise = null;\n        }\n      })();\n\n      return refreshPromise;\n    };\n\n    const unregisterRefresher = registerFallbackRefresher(\n      args.sourceKey,\n      refreshNow\n    );\n\n    const cachedRows = fallbackSnapshotCache.get(args.sourceKey);\n    if (cachedRows) {\n      applySnapshot(syncParams, cachedRows);\n    }\n\n    void refreshNow();\n\n    const intervalId = globalThis.setInterval(() => {\n      void refreshNow();\n    }, FALLBACK_REFRESH_INTERVAL_MS);\n\n    return {\n      cleanup: () => {\n        isCleanedUp = true;\n        globalThis.clearInterval(intervalId);\n        unregisterRefresher();\n      },\n      loadSubset: () => true,\n    };\n  };\n}\n\nfunction createHybridSync(args: {\n  sourceKey: string;\n  shape: ShapeDefinition<unknown>;\n  params: Record<string, string>;\n  reportError: (error: SyncError) => void;\n  electricSync: SyncConfigLike['sync'];\n}) {\n  const fallbackSync = createFallbackSync({\n    sourceKey: args.sourceKey,\n    shape: args.shape,\n    params: args.params,\n    reportError: args.reportError,\n  });\n\n  return (syncParams: SyncParams): SyncResult => {\n    const runtime = getOrCreateSourceRuntime(args.sourceKey);\n    if (runtime.fallbackLocked) {\n      return fallbackSync(syncParams);\n    }\n\n    runtime.mode = 'electric';\n\n    let isCleanedUp = false;\n    let usingFallback = false;\n    let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;\n\n    let activeSync = normalizeSyncResult(args.electricSync(syncParams));\n\n    const switchToFallback = () => {\n      if (isCleanedUp || usingFallback) return;\n      usingFallback = true;\n\n      activeSync.cleanup?.();\n      activeSync = normalizeSyncResult(fallbackSync(syncParams));\n    };\n\n    const unregisterSwitcher = registerFallbackSwitcher(\n      args.sourceKey,\n      switchToFallback\n    );\n\n    const scheduleReadyTimeout = () => {\n      timeoutId = globalThis.setTimeout(() => {\n        if (isCleanedUp || usingFallback || syncParams.collection.isReady()) {\n          return;\n        }\n\n        if (!isPageVisible()) {\n          scheduleReadyTimeout();\n          return;\n        }\n\n        args.reportError({\n          message: `Electric sync timed out after ${ELECTRIC_READY_TIMEOUT_MS}ms, switching to fallback`,\n        });\n        lockSourceToFallback(args.sourceKey);\n      }, ELECTRIC_READY_TIMEOUT_MS);\n    };\n\n    scheduleReadyTimeout();\n\n    syncParams.collection.onFirstReady(() => {\n      if (!usingFallback) {\n        if (timeoutId) {\n          globalThis.clearTimeout(timeoutId);\n        }\n      }\n    });\n\n    return {\n      cleanup: () => {\n        isCleanedUp = true;\n        if (timeoutId) {\n          globalThis.clearTimeout(timeoutId);\n        }\n        unregisterSwitcher();\n        activeSync.cleanup?.();\n      },\n      loadSubset: (options: unknown) =>\n        activeSync.loadSubset ? activeSync.loadSubset(options) : true,\n      unloadSubset: (options: unknown) => {\n        activeSync.unloadSubset?.(options);\n      },\n    };\n  };\n}\n\nfunction isSourceFallbackLocked(sourceKey: string): boolean {\n  const runtime = getOrCreateSourceRuntime(sourceKey);\n  return runtime.fallbackLocked;\n}\n\nfunction maybeRefreshFallbackAfterMutation(sourceKey: string): void {\n  if (!isSourceFallbackLocked(sourceKey)) return;\n  invalidateFallbackCache(sourceKey);\n  refreshFallbackSource(sourceKey);\n}\n\nfunction buildMutationHandlers(\n  mutation: MutationDefinition<unknown, unknown, unknown>,\n  sourceKey: string\n) {\n  return {\n    onInsert: async ({\n      transaction,\n    }: MutationFnParams): Promise<{ txid: number[] } | void> => {\n      const txids = await Promise.all(\n        transaction.mutations.map(async (mutationItem) => {\n          const data = mutationItem.modified as Record<string, unknown>;\n          const response = await makeRequest(mutation.url, {\n            method: 'POST',\n            body: JSON.stringify(data),\n          });\n\n          if (!response.ok) {\n            const message = await parseResponseError(\n              response,\n              `Failed to create ${mutation.name}`\n            );\n            throw new Error(message);\n          }\n\n          const result = (await response.json()) as { txid: number };\n          return result.txid;\n        })\n      );\n\n      maybeRefreshFallbackAfterMutation(sourceKey);\n\n      if (isSourceFallbackLocked(sourceKey)) {\n        return;\n      }\n\n      return { txid: txids };\n    },\n\n    onUpdate: async ({\n      transaction,\n    }: MutationFnParams): Promise<{ txid: number[] } | void> => {\n      let txids: number[] = [];\n\n      if (transaction.mutations.length > 1) {\n        const updates = transaction.mutations.map((mutationItem) => {\n          if (!mutationItem.key) {\n            throw new Error(`Failed to update ${mutation.name}: missing key`);\n          }\n\n          return {\n            id: String(mutationItem.key),\n            ...(mutationItem.changes as Record<string, unknown>),\n          };\n        });\n\n        const response = await makeRequest(`${mutation.url}/bulk`, {\n          method: 'POST',\n          body: JSON.stringify({ updates }),\n        });\n\n        if (!response.ok) {\n          const message = await parseResponseError(\n            response,\n            `Failed to bulk update ${mutation.name}`\n          );\n          throw new Error(message);\n        }\n\n        const result = (await response.json()) as { txid: number };\n        txids = [result.txid];\n      } else {\n        const mutationItem = transaction.mutations[0];\n        if (!mutationItem?.key) {\n          throw new Error(`Failed to update ${mutation.name}: missing key`);\n        }\n\n        const response = await makeRequest(\n          `${mutation.url}/${mutationItem.key}`,\n          {\n            method: 'PATCH',\n            body: JSON.stringify(mutationItem.changes),\n          }\n        );\n\n        if (!response.ok) {\n          const message = await parseResponseError(\n            response,\n            `Failed to update ${mutation.name}`\n          );\n          throw new Error(message);\n        }\n\n        const result = (await response.json()) as { txid: number };\n        txids = [result.txid];\n      }\n\n      maybeRefreshFallbackAfterMutation(sourceKey);\n\n      if (isSourceFallbackLocked(sourceKey)) {\n        return;\n      }\n\n      return { txid: txids };\n    },\n\n    onDelete: async ({\n      transaction,\n    }: MutationFnParams): Promise<{ txid: number[] } | void> => {\n      const txids = await Promise.all(\n        transaction.mutations.map(async (mutationItem) => {\n          const response = await makeRequest(\n            `${mutation.url}/${mutationItem.key}`,\n            {\n              method: 'DELETE',\n            }\n          );\n\n          if (!response.ok) {\n            const message = await parseResponseError(\n              response,\n              `Failed to delete ${mutation.name}`\n            );\n            throw new Error(message);\n          }\n\n          const result = (await response.json()) as { txid: number };\n          return result.txid;\n        })\n      );\n\n      maybeRefreshFallbackAfterMutation(sourceKey);\n\n      if (isSourceFallbackLocked(sourceKey)) {\n        return;\n      }\n\n      return { txid: txids };\n    },\n  };\n}\n\nexport function createShapeCollection<TRow extends ElectricRow>(\n  shape: ShapeDefinition<TRow>,\n  params: Record<string, string>,\n  config?: CollectionConfig,\n  mutation?: MutationDefinition<unknown, unknown, unknown>\n) {\n  const hasMutations = Boolean(mutation);\n  const collectionId = buildCollectionId(shape.table, params, hasMutations);\n  const sourceKey = buildSourceKey(shape.table, params);\n\n  const cached = collectionCache.get(collectionId);\n  if (cached) {\n    return cached as typeof cached & { __rowType?: TRow };\n  }\n\n  const reportError = createErrorReporter(config);\n  const onElectricUnavailable = () => lockSourceToFallback(sourceKey);\n\n  const shapeOptions = createElectricShapeOptions({\n    shape,\n    params,\n    reportError,\n    onElectricUnavailable,\n  });\n\n  const mutationHandlers = mutation\n    ? buildMutationHandlers(mutation, sourceKey)\n    : {};\n\n  const electricOptions = electricCollectionOptions({\n    id: collectionId,\n    shapeOptions: shapeOptions as never,\n    getKey: (item: ElectricRow) => getRowKey(item),\n    gcTime: DEFAULT_GC_TIME_MS,\n    ...mutationHandlers,\n  } as never);\n\n  const electricSyncConfig = electricOptions.sync as unknown as SyncConfigLike;\n\n  const collectionOptions = {\n    ...electricOptions,\n    sync: {\n      ...electricSyncConfig,\n      sync: createHybridSync({\n        sourceKey,\n        shape,\n        params,\n        reportError,\n        electricSync: electricSyncConfig.sync,\n      }),\n    },\n  };\n\n  const collection = createCollection(\n    collectionOptions as never\n  ) as unknown as ReturnType<typeof createCollection> & { __rowType?: TRow };\n\n  collectionCache.set(collectionId, collection);\n  return collection;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/electric/types.ts",
    "content": "/**\n * Error type for Electric sync operations.\n * Wraps errors from Electric's onError callback (HTTP errors, network failures, etc.)\n */\nexport interface SyncError {\n  /** HTTP status code if available */\n  status?: number;\n  /** Error message */\n  message: string;\n}\n\n/**\n * Configuration options for creating Electric collections.\n */\nexport interface CollectionConfig {\n  /** Callback for sync errors */\n  onError?: (error: SyncError) => void;\n}\n\n/**\n * Result of an optimistic mutation operation.\n * Contains a promise that resolves when the backend confirms the change.\n */\nexport interface MutationResult {\n  /** Promise that resolves when the mutation is confirmed by the backend */\n  persisted: Promise<void>;\n}\n\n/**\n * Result of an insert operation, including the created row data.\n */\nexport interface InsertResult<TRow> {\n  /** The optimistically created row with generated ID */\n  data: TRow;\n  /** Promise that resolves with the synced row (including server-generated fields) when confirmed by backend */\n  persisted: Promise<TRow>;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/executor.ts",
    "content": "import type {\n  BaseCodingAgent,\n  ExecutorConfig,\n  ExecutorConfigs,\n  ExecutorProfile,\n  ExecutorAction,\n  ExecutorProfileId,\n  ExecutionProcess,\n} from 'shared/types';\n\nconst RESERVED_KEYS = new Set(['recently_used_models']);\n\nexport function getExecutorVariantKeys(\n  executorProfile: ExecutorProfile | Record<string, unknown> | null | undefined\n): string[] {\n  return Object.keys(executorProfile || {}).filter(\n    (key) => !RESERVED_KEYS.has(key)\n  );\n}\n\nfunction sortVariantKeys(variants: string[]): string[] {\n  return variants.sort((a, b) => {\n    if (a === 'DEFAULT') return -1;\n    if (b === 'DEFAULT') return 1;\n    return a.localeCompare(b);\n  });\n}\n\nexport function getSortedExecutorVariantKeys(\n  executorProfile: ExecutorProfile | Record<string, unknown> | null | undefined\n): string[] {\n  return sortVariantKeys(getExecutorVariantKeys(executorProfile));\n}\n\n/**\n * Compare two ExecutorProfileIds for equality.\n * Treats null/undefined variant as equivalent to \"DEFAULT\".\n */\nexport function areProfilesEqual(\n  a: ExecutorProfileId | null | undefined,\n  b: ExecutorProfileId | null | undefined\n): boolean {\n  if (!a || !b) return a === b;\n  if (a.executor !== b.executor) return false;\n  // Normalize variants: null/undefined -> 'DEFAULT'\n  const variantA = a.variant ?? 'DEFAULT';\n  const variantB = b.variant ?? 'DEFAULT';\n  return variantA === variantB;\n}\n\n/**\n * Get variant options for a given executor from profiles.\n * Returns variants sorted: DEFAULT first, then alphabetically.\n */\nexport function getVariantOptions(\n  executor: BaseCodingAgent | null | undefined,\n  profiles: ExecutorConfigs['executors'] | null | undefined\n): string[] {\n  if (!executor || !profiles) return [];\n  const executorProfile = profiles[executor];\n  if (!executorProfile) return [];\n\n  const variants = getExecutorVariantKeys(executorProfile);\n  return sortVariantKeys(variants);\n}\n\n/**\n * Extract full ExecutorConfig from an ExecutorAction chain.\n * Traverses the action chain to find the first coding agent request.\n */\nexport function executorConfigFromAction(\n  action: ExecutorAction | null\n): ExecutorConfig | null {\n  let curr: ExecutorAction | null = action;\n  while (curr) {\n    const typ = curr.typ;\n    switch (typ.type) {\n      case 'CodingAgentInitialRequest':\n      case 'CodingAgentFollowUpRequest':\n      case 'ReviewRequest':\n        return typ.executor_config;\n      case 'ScriptRequest':\n      default:\n        curr = curr.next_action;\n        continue;\n    }\n  }\n  return null;\n}\n\n/**\n * Get the full ExecutorConfig from the most recent execution process.\n * Searches from most recent to oldest.\n */\nexport function getLatestConfigFromProcesses(\n  processes: ExecutionProcess[] | undefined\n): ExecutorConfig | null {\n  if (!processes?.length) return null;\n  return (\n    processes\n      .slice()\n      .reverse()\n      .map((p) => executorConfigFromAction(p.executor_action ?? null))\n      .find((c) => c !== null) ?? null\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/extToLanguage.ts",
    "content": "/**\n * getHighlightLanguage(ext)\n * Returns the Highlight.js language id (or null if not mapped).\n *\n * @param {string} ext – File extension with or without the leading dot.\n * @example\n *   getHighlightLanguage('.py');   // \"python\"\n *   getHighlightLanguage('tsx');   // \"tsx\"\n */\nconst extToLang: Record<string, string> = {\n  // Web & scripting\n  js: 'javascript',\n  mjs: 'javascript',\n  cjs: 'javascript',\n  ts: 'typescript',\n  jsx: 'jsx',\n  tsx: 'tsx',\n  html: 'xml', // Highlight.js groups HTML/XML\n  htm: 'xml',\n  xml: 'xml',\n  css: 'css',\n  scss: 'scss',\n  less: 'less',\n  json: 'json',\n  md: 'markdown',\n  yml: 'yaml',\n  yaml: 'yaml',\n  sh: 'bash',\n  bash: 'bash',\n  zsh: 'bash',\n  ps1: 'powershell',\n  php: 'php',\n\n  // Classic compiled\n  c: 'c',\n  h: 'c',\n  cpp: 'cpp',\n  cc: 'cpp',\n  cxx: 'cpp',\n  hpp: 'cpp',\n  cs: 'csharp',\n  java: 'java',\n  kt: 'kotlin',\n  scala: 'scala',\n  go: 'go',\n  rs: 'rust',\n  swift: 'swift',\n  dart: 'dart',\n\n  // Others & fun stuff\n  py: 'python',\n  rb: 'ruby',\n  pl: 'perl',\n  lua: 'lua',\n  r: 'r',\n  sql: 'sql',\n  tex: 'latex',\n};\n\n/**\n * Normalises the extension and looks it up.\n */\nexport function getHighlightLanguage(ext: string): string | null {\n  ext = ext.toLowerCase();\n  return extToLang[ext];\n}\n\nexport function getHighLightLanguageFromPath(path: string): string | null {\n  const ext = path.split('.').pop();\n  return getHighlightLanguage(ext || '');\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/fileTreeUtils.ts",
    "content": "import type { Diff } from 'shared/types';\nimport type { TreeNode } from '@/shared/types/fileTree';\n\n/**\n * Transforms flat Diff[] into hierarchical TreeNode[]\n */\nexport function buildFileTree(diffs: Diff[]): TreeNode[] {\n  const rootMap = new Map<string, TreeNode>();\n\n  for (const diff of diffs) {\n    // Use newPath for most changes, oldPath for deletions\n    const filePath = diff.newPath ?? diff.oldPath;\n    if (!filePath) continue;\n\n    const parts = filePath.split('/');\n    let currentMap = rootMap;\n\n    for (let i = 0; i < parts.length; i++) {\n      const part = parts[i];\n      const isFile = i === parts.length - 1;\n      const currentPath = parts.slice(0, i + 1).join('/');\n\n      if (!currentMap.has(part)) {\n        const node: TreeNode = {\n          id: currentPath,\n          name: part,\n          path: currentPath,\n          type: isFile ? 'file' : 'folder',\n          children: isFile ? undefined : [],\n        };\n\n        if (isFile) {\n          node.diff = diff;\n          node.changeKind = diff.change;\n          node.additions = diff.additions;\n          node.deletions = diff.deletions;\n        }\n\n        currentMap.set(part, node);\n      }\n\n      const node = currentMap.get(part)!;\n\n      if (!isFile && node.children) {\n        // Build map for next level from existing children\n        const childMap = new Map<string, TreeNode>();\n        for (const child of node.children) {\n          childMap.set(child.name, child);\n        }\n\n        // Process remaining parts, then sync back to children array\n        if (i < parts.length - 1) {\n          const nextPart = parts[i + 1];\n          const nextPath = parts.slice(0, i + 2).join('/');\n          const nextIsFile = i + 1 === parts.length - 1;\n\n          if (!childMap.has(nextPart)) {\n            const nextNode: TreeNode = {\n              id: nextPath,\n              name: nextPart,\n              path: nextPath,\n              type: nextIsFile ? 'file' : 'folder',\n              children: nextIsFile ? undefined : [],\n            };\n\n            if (nextIsFile) {\n              nextNode.diff = diff;\n              nextNode.changeKind = diff.change;\n              nextNode.additions = diff.additions;\n              nextNode.deletions = diff.deletions;\n            }\n\n            childMap.set(nextPart, nextNode);\n            node.children.push(nextNode);\n          }\n\n          currentMap = childMap;\n        }\n      }\n    }\n  }\n\n  return sortTreeNodes(Array.from(rootMap.values()));\n}\n\n/**\n * Sort nodes: folders first, then alphabetically\n */\nfunction sortTreeNodes(nodes: TreeNode[]): TreeNode[] {\n  return nodes\n    .map((node) => ({\n      ...node,\n      children: node.children ? sortTreeNodes(node.children) : undefined,\n    }))\n    .sort((a, b) => {\n      // Folders before files\n      if (a.type !== b.type) {\n        return a.type === 'folder' ? -1 : 1;\n      }\n      // Alphabetical within same type\n      return a.name.localeCompare(b.name);\n    });\n}\n\n/**\n * Filter tree based on search query only\n */\nexport function filterFileTree(\n  nodes: TreeNode[],\n  searchQuery: string\n): TreeNode[] {\n  if (!searchQuery) {\n    return nodes;\n  }\n\n  const query = searchQuery.toLowerCase();\n\n  function filterNode(node: TreeNode): TreeNode | null {\n    // For folders, recursively filter children\n    if (node.type === 'folder' && node.children) {\n      const filteredChildren = node.children\n        .map(filterNode)\n        .filter((n): n is TreeNode => n !== null);\n\n      if (filteredChildren.length === 0) {\n        return null;\n      }\n\n      return { ...node, children: filteredChildren };\n    }\n\n    // For files, check search query\n    if (node.type === 'file') {\n      if (node.path.toLowerCase().includes(query)) {\n        return node;\n      }\n    }\n\n    return null;\n  }\n\n  return nodes.map(filterNode).filter((n): n is TreeNode => n !== null);\n}\n\n/**\n * Get all folder paths that should be expanded to show matching files\n */\nexport function getExpandedPathsForSearch(\n  nodes: TreeNode[],\n  searchQuery: string\n): Set<string> {\n  const paths = new Set<string>();\n  const query = searchQuery.toLowerCase();\n\n  function traverse(node: TreeNode, parentPaths: string[]) {\n    if (node.type === 'file' && node.path.toLowerCase().includes(query)) {\n      // Add all parent folder paths\n      parentPaths.forEach((p) => paths.add(p));\n    }\n\n    if (node.children) {\n      const currentPaths = [...parentPaths, node.path];\n      node.children.forEach((child) => traverse(child, currentPaths));\n    }\n  }\n\n  nodes.forEach((node) => traverse(node, []));\n  return paths;\n}\n\n/**\n * Get all folder paths in the tree\n */\nexport function getAllFolderPaths(nodes: TreeNode[]): string[] {\n  const paths: string[] = [];\n\n  function traverse(node: TreeNode) {\n    if (node.type === 'folder') {\n      paths.push(node.path);\n      node.children?.forEach(traverse);\n    }\n  }\n\n  nodes.forEach(traverse);\n  return paths;\n}\n\n/**\n * Sort diffs to match FileTree ordering: folders before files at each level,\n * then alphabetically within each group\n */\nexport function sortDiffs(diffs: Diff[]): Diff[] {\n  return [...diffs].sort((a, b) => {\n    const pathA = a.newPath || a.oldPath || '';\n    const pathB = b.newPath || b.oldPath || '';\n\n    const partsA = pathA.split('/');\n    const partsB = pathB.split('/');\n\n    const minLength = Math.min(partsA.length, partsB.length);\n\n    for (let i = 0; i < minLength; i++) {\n      const isLastA = i === partsA.length - 1;\n      const isLastB = i === partsB.length - 1;\n\n      // If one is a file (last segment) and other is a folder (not last), folder comes first\n      if (isLastA !== isLastB) {\n        return isLastA ? 1 : -1;\n      }\n\n      // Same type at this level, compare alphabetically\n      const cmp = partsA[i].localeCompare(partsB[i]);\n      if (cmp !== 0) return cmp;\n    }\n\n    // Shorter path (folder) comes before longer path (nested file)\n    return partsA.length - partsB.length;\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/fileTypeIcon.ts",
    "content": "import { createElement, FunctionComponentElement, SVGProps } from 'react';\nimport {\n  TypeScript,\n  JavaScript,\n  Python,\n  RustDark,\n  RustLight,\n  Go,\n  Java,\n  C,\n  CPlusPlus,\n  CSharp,\n  Swift,\n  Kotlin,\n  Dart,\n  Ruby,\n  PHP,\n  Lua,\n  R,\n  Scala,\n  Elixir,\n  HTML5,\n  CSS3,\n  Sass,\n  JSON,\n  Bash,\n  PowerShell,\n  React,\n  VueJs,\n  SvelteJS,\n  Angular,\n  Docker,\n  PostgreSQL,\n  GraphQL,\n} from 'developer-icons';\nimport { FileIcon, FileMd, FileCss } from '@phosphor-icons/react';\n\n// Match the DeveloperIconProps from developer-icons\ninterface DeveloperIconProps extends Partial<SVGProps<SVGElement>> {\n  size?: number;\n}\n\ntype DeveloperIcon = (\n  props: DeveloperIconProps\n) => FunctionComponentElement<DeveloperIconProps>;\n\ntype IconMapping = {\n  light: DeveloperIcon;\n  dark: DeveloperIcon;\n};\n\nfunction icon(component: DeveloperIcon): IconMapping {\n  return { light: component, dark: component };\n}\n\nfunction iconWithVariants(\n  lightIcon: DeveloperIcon,\n  darkIcon: DeveloperIcon\n): IconMapping {\n  return { light: lightIcon, dark: darkIcon };\n}\n\n// Wrapper for FileMd from phosphor\nconst FileMdWrapper: DeveloperIcon = ({ size, ...props }) => {\n  return createElement(FileMd, {\n    size,\n    ...(props as object),\n  }) as unknown as FunctionComponentElement<DeveloperIconProps>;\n};\n\n// Wrapper for FileCss from phosphor\nconst FileCssWrapper: DeveloperIcon = ({ size, ...props }) => {\n  return createElement(FileCss, {\n    size,\n    ...(props as object),\n  }) as unknown as FunctionComponentElement<DeveloperIconProps>;\n};\n\nconst extToIcon: Record<string, IconMapping> = {\n  // TypeScript/JavaScript\n  ts: icon(TypeScript),\n  tsx: icon(TypeScript),\n  js: icon(JavaScript),\n  mjs: icon(JavaScript),\n  cjs: icon(JavaScript),\n  jsx: icon(React),\n\n  // Web\n  html: icon(HTML5),\n  htm: icon(HTML5),\n  css: icon(FileCssWrapper),\n  scss: icon(Sass),\n  sass: icon(Sass),\n  less: icon(CSS3),\n\n  // Frameworks\n  vue: icon(VueJs),\n  svelte: icon(SvelteJS),\n\n  // Languages\n  py: icon(Python),\n  rs: iconWithVariants(RustDark, RustLight),\n  go: icon(Go),\n  java: icon(Java),\n  c: icon(C),\n  h: icon(C),\n  cpp: icon(CPlusPlus),\n  cc: icon(CPlusPlus),\n  cxx: icon(CPlusPlus),\n  hpp: icon(CPlusPlus),\n  cs: icon(CSharp),\n  swift: icon(Swift),\n  kt: icon(Kotlin),\n  dart: icon(Dart),\n  rb: icon(Ruby),\n  php: icon(PHP),\n  lua: icon(Lua),\n  r: icon(R),\n  scala: icon(Scala),\n  ex: icon(Elixir),\n  exs: icon(Elixir),\n\n  // Data/Config\n  json: icon(JSON),\n  md: icon(FileMdWrapper),\n  // No YAML icon in developer-icons, use JSON as fallback\n  yaml: icon(JSON),\n  yml: icon(JSON),\n\n  // Shell\n  sh: icon(Bash),\n  bash: icon(Bash),\n  zsh: icon(Bash),\n  ps1: icon(PowerShell),\n\n  // Databases\n  sql: icon(PostgreSQL),\n  psql: icon(PostgreSQL),\n\n  // Special files\n  graphql: icon(GraphQL),\n  gql: icon(GraphQL),\n};\n\n// Special filename mappings (for files without extensions)\nconst filenameToIcon: Record<string, IconMapping> = {\n  dockerfile: icon(Docker),\n  'docker-compose.yml': icon(Docker),\n  'docker-compose.yaml': icon(Docker),\n  '.angular.json': icon(Angular),\n};\n\n// Wrapper component to adapt phosphor FileIcon to same interface\nconst FileIconWrapper: DeveloperIcon = ({ size, ...props }) => {\n  return createElement(FileIcon, {\n    size,\n    ...(props as object),\n  }) as unknown as FunctionComponentElement<DeveloperIconProps>;\n};\n\nexport function getFileIcon(\n  filename: string,\n  theme: 'light' | 'dark'\n): DeveloperIcon {\n  const lowerFilename = filename.toLowerCase();\n\n  // Check special filenames first\n  const basename = lowerFilename.split('/').pop() || '';\n  const filenameMapping = filenameToIcon[basename];\n  if (filenameMapping) {\n    return filenameMapping[theme];\n  }\n\n  // Then check extension\n  const ext = basename.split('.').pop() || '';\n  const extMapping = extToIcon[ext];\n  if (extMapping) {\n    return extMapping[theme];\n  }\n\n  return FileIconWrapper;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/firstProjectDestination.ts",
    "content": "import { PROJECTS_SHAPE, type Project } from 'shared/remote-types';\nimport { type OrganizationWithRole } from 'shared/types';\nimport { organizationsApi } from '@/shared/lib/api';\nimport { createShapeCollection } from '@/shared/lib/electric/collections';\nimport { getFirstProjectByOrder } from '@/shared/lib/projectOrder';\nimport type { AppDestination } from '@/shared/lib/routes/appNavigation';\n\nconst FIRST_PROJECT_LOOKUP_TIMEOUT_MS = 3000;\n\nfunction getFirstOrganization(\n  organizations: OrganizationWithRole[]\n): OrganizationWithRole | null {\n  if (organizations.length === 0) {\n    return null;\n  }\n\n  return organizations[0];\n}\n\nasync function getProjectsInOrganization(\n  organizationId: string\n): Promise<Project[] | null> {\n  const collection = createShapeCollection(PROJECTS_SHAPE, {\n    organization_id: organizationId,\n  });\n\n  const getCollectionProjects = () =>\n    collection.toArray as unknown as Project[];\n\n  if (collection.isReady()) {\n    return getCollectionProjects();\n  }\n\n  return new Promise<Project[] | null>((resolve) => {\n    let settled = false;\n    let timeoutId: number | undefined;\n    let subscription: { unsubscribe: () => void } | undefined;\n\n    const settle = (projects: Project[] | null) => {\n      if (settled) return;\n      settled = true;\n\n      if (timeoutId !== undefined) {\n        window.clearTimeout(timeoutId);\n        timeoutId = undefined;\n      }\n      if (subscription) {\n        subscription.unsubscribe();\n        subscription = undefined;\n      }\n\n      resolve(projects);\n    };\n\n    const tryResolve = () => {\n      if (!collection.isReady()) {\n        return;\n      }\n\n      settle(getCollectionProjects());\n    };\n\n    subscription = collection.subscribeChanges(tryResolve, {\n      includeInitialState: true,\n    });\n\n    timeoutId = window.setTimeout(() => {\n      settle(null);\n    }, FIRST_PROJECT_LOOKUP_TIMEOUT_MS);\n\n    tryResolve();\n  });\n}\n\nexport async function getFirstProjectDestination(\n  setSelectedOrgId: (orgId: string | null) => void,\n  savedOrgId?: string | null,\n  savedProjectId?: string | null\n): Promise<AppDestination | null> {\n  try {\n    const organizationsResponse = await organizationsApi.getUserOrganizations();\n    const organizations = organizationsResponse.organizations ?? [];\n\n    // Prefer saved org if it still exists, otherwise fall back to first org\n    const savedOrg = savedOrgId\n      ? organizations.find((org) => org.id === savedOrgId)\n      : null;\n    const resolvedOrg = savedOrg ?? getFirstOrganization(organizations);\n\n    if (!resolvedOrg) {\n      return null;\n    }\n\n    setSelectedOrgId(resolvedOrg.id);\n\n    const projects = await getProjectsInOrganization(resolvedOrg.id);\n\n    // If we have a saved project in the same saved org, use it if still valid\n    if (savedProjectId && savedOrg && projects) {\n      if (projects.some((p) => p.id === savedProjectId)) {\n        return { kind: 'project', projectId: savedProjectId };\n      }\n    }\n\n    // Fall back to first project by sort order\n    const firstProject = projects ? getFirstProjectByOrder(projects) : null;\n    if (!firstProject) {\n      return null;\n    }\n\n    return { kind: 'project', projectId: firstProject.id };\n  } catch (error) {\n    console.error('Failed to resolve first project destination:', error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/hmrContext.ts",
    "content": "import { createContext, type Context } from 'react';\n\n/**\n * Creates a React context that preserves its identity across Vite HMR updates.\n *\n * During HMR, module re-execution creates a new context object via createContext(),\n * but already-mounted providers still hold the old one. Consumers then read from\n * the new context, find no matching provider, and get the default value (typically\n * null/undefined), which causes \"must be used within Provider\" errors.\n *\n * This helper stashes the context in import.meta.hot.data so the same object is\n * reused across HMR re-executions of the module.\n *\n * @param key - A unique string key to identify this context in HMR data\n * @param defaultValue - The default context value (same as createContext's argument)\n */\nexport function createHmrContext<T>(key: string, defaultValue: T): Context<T> {\n  const existing = import.meta.hot?.data?.[key] as Context<T> | undefined;\n  const ctx = existing ?? createContext<T>(defaultValue);\n  if (import.meta.hot) {\n    import.meta.hot.data[key] = ctx;\n  }\n  return ctx;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/id.ts",
    "content": "let seq = 0;\n\nexport function genId(): string {\n  seq = (seq + 1) & 0xffff;\n  return `${Date.now().toString(36)}-${seq.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/ideName.ts",
    "content": "import { EditorType } from 'shared/types';\nimport i18n from '@/i18n';\n\nexport function getIdeName(editorType: EditorType | undefined | null): string {\n  if (!editorType) return 'IDE';\n  switch (editorType) {\n    case EditorType.VS_CODE:\n      return 'VS Code';\n    case EditorType.VS_CODE_INSIDERS:\n      return 'VS Code Insiders';\n    case EditorType.CURSOR:\n      return 'Cursor';\n    case EditorType.WINDSURF:\n      return 'Windsurf';\n    case EditorType.INTELLI_J:\n      return 'IntelliJ IDEA';\n    case EditorType.ZED:\n      return 'Zed';\n    case EditorType.XCODE:\n      return 'Xcode';\n    case EditorType.CUSTOM:\n      return i18n.t('common:editorNames.custom');\n    case EditorType.GOOGLE_ANTIGRAVITY:\n      return 'Antigravity';\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/jsonPatch.ts",
    "content": "import { applyPatch, type Operation } from 'rfc6902';\n\nexport function applyUpsertPatch(target: object, ops: Operation[]): void {\n  ops.forEach((op) => {\n    const [error] = applyPatch(target, [op]);\n\n    if (op.op === 'replace' && error?.name === 'MissingError') {\n      applyPatch(target, [{ ...op, op: 'add' }]);\n    }\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/localApiTransport.ts",
    "content": "export interface LocalApiTransport {\n  request: (pathOrUrl: string, init?: RequestInit) => Promise<Response>;\n  openWebSocket: (pathOrUrl: string) => Promise<WebSocket> | WebSocket;\n}\n\nfunction isAbsoluteUrl(pathOrUrl: string): boolean {\n  return /^https?:\\/\\//i.test(pathOrUrl) || /^wss?:\\/\\//i.test(pathOrUrl);\n}\n\nfunction toAbsoluteWsUrl(pathOrUrl: string): string {\n  if (/^wss?:\\/\\//i.test(pathOrUrl)) {\n    return pathOrUrl;\n  }\n\n  if (/^https?:\\/\\//i.test(pathOrUrl)) {\n    return pathOrUrl.replace(/^http/i, 'ws');\n  }\n\n  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n  const normalizedPath = pathOrUrl.startsWith('/')\n    ? pathOrUrl\n    : `/${pathOrUrl}`;\n  return `${protocol}//${window.location.host}${normalizedPath}`;\n}\n\nconst defaultTransport: LocalApiTransport = {\n  request: (pathOrUrl, init = {}) => fetch(pathOrUrl, init),\n  openWebSocket: (pathOrUrl) => new WebSocket(toAbsoluteWsUrl(pathOrUrl)),\n};\n\nlet transport: LocalApiTransport = defaultTransport;\n\nexport function setLocalApiTransport(nextTransport: LocalApiTransport | null) {\n  transport = nextTransport ?? defaultTransport;\n}\n\nexport async function makeLocalApiRequest(\n  pathOrUrl: string,\n  init: RequestInit = {}\n): Promise<Response> {\n  return transport.request(pathOrUrl, init);\n}\n\nexport async function openLocalApiWebSocket(\n  pathOrUrl: string\n): Promise<WebSocket> {\n  return transport.openWebSocket(pathOrUrl);\n}\n\nexport function isLocalApiPath(pathOrUrl: string): boolean {\n  if (isAbsoluteUrl(pathOrUrl)) {\n    const url = new URL(pathOrUrl);\n    return url.pathname.startsWith('/api/');\n  }\n\n  const normalizedPath = pathOrUrl.startsWith('/')\n    ? pathOrUrl\n    : `/${pathOrUrl}`;\n  return normalizedPath.startsWith('/api/');\n}\n\nexport function toPathAndQuery(pathOrUrl: string): string {\n  if (isAbsoluteUrl(pathOrUrl)) {\n    const url = new URL(pathOrUrl);\n    return `${url.pathname}${url.search}`;\n  }\n\n  return pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/mcpStrategies.ts",
    "content": "import type { McpConfig, JsonValue } from 'shared/types';\n\ntype JsonObject = Record<string, JsonValue>;\n\nfunction isJsonObject(v: unknown): v is JsonObject {\n  return typeof v === 'object' && v !== null && !Array.isArray(v);\n}\n\nexport class McpConfigStrategyGeneral {\n  static createFullConfig(cfg: McpConfig): JsonObject {\n    const cloned: JsonValue = JSON.parse(JSON.stringify(cfg.template ?? {}));\n    const fullConfig: JsonObject = isJsonObject(cloned) ? cloned : {};\n    let current: JsonObject = fullConfig;\n\n    for (let i = 0; i < cfg.servers_path.length - 1; i++) {\n      const key = cfg.servers_path[i];\n      const next = isJsonObject(current[key])\n        ? (current[key] as JsonObject)\n        : undefined;\n      if (!next) current[key] = {};\n      current = current[key] as JsonObject;\n    }\n\n    if (cfg.servers_path.length > 0) {\n      const lastKey = cfg.servers_path[cfg.servers_path.length - 1];\n      current[lastKey] = cfg.servers;\n    }\n    return fullConfig;\n  }\n  static validateFullConfig(\n    mcp_config: McpConfig,\n    full_config: JsonValue\n  ): void {\n    let current: JsonValue = full_config;\n    for (const key of mcp_config.servers_path) {\n      if (!isJsonObject(current)) {\n        throw new Error(\n          `Expected object at path: ${mcp_config.servers_path.join('.')}`\n        );\n      }\n      current = current[key];\n      if (current === undefined) {\n        throw new Error(\n          `Missing required field at path: ${mcp_config.servers_path.join('.')}`\n        );\n      }\n    }\n    if (!isJsonObject(current)) {\n      throw new Error('Servers configuration must be an object');\n    }\n  }\n  static extractServersForApi(\n    mcp_config: McpConfig,\n    full_config: JsonValue\n  ): JsonObject {\n    let current: JsonValue = full_config;\n    for (const key of mcp_config.servers_path) {\n      if (!isJsonObject(current)) {\n        throw new Error(\n          `Expected object at path: ${mcp_config.servers_path.join('.')}`\n        );\n      }\n      current = current[key];\n      if (current === undefined) {\n        throw new Error(\n          `Missing required field at path: ${mcp_config.servers_path.join('.')}`\n        );\n      }\n    }\n    if (!isJsonObject(current)) {\n      throw new Error('Servers configuration must be an object');\n    }\n    return current;\n  }\n\n  static addPreconfiguredToConfig(\n    mcp_config: McpConfig,\n    existingConfig: JsonValue,\n    serverKey: string\n  ): JsonObject {\n    const preconfVal = mcp_config.preconfigured;\n    if (!isJsonObject(preconfVal) || !(serverKey in preconfVal)) {\n      throw new Error(`Unknown preconfigured server '${serverKey}'`);\n    }\n\n    const updatedVal: JsonValue = JSON.parse(\n      JSON.stringify(existingConfig ?? {})\n    );\n    const updated: JsonObject = isJsonObject(updatedVal) ? updatedVal : {};\n    let current: JsonObject = updated;\n\n    for (let i = 0; i < mcp_config.servers_path.length - 1; i++) {\n      const key = mcp_config.servers_path[i];\n      const next = isJsonObject(current[key])\n        ? (current[key] as JsonObject)\n        : undefined;\n      if (!next) current[key] = {};\n      current = current[key] as JsonObject;\n    }\n\n    if (mcp_config.servers_path.length === 0) {\n      current[serverKey] = preconfVal[serverKey];\n      return updated;\n    }\n\n    const lastKey = mcp_config.servers_path[mcp_config.servers_path.length - 1];\n    if (!isJsonObject(current[lastKey])) current[lastKey] = {};\n    (current[lastKey] as JsonObject)[serverKey] = preconfVal[serverKey];\n\n    return updated;\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/modals.ts",
    "content": "import type React from 'react';\nimport { hide, remove, show } from '@ebay/nice-modal-react';\nimport type { NiceModalHocProps } from '@ebay/nice-modal-react';\n\n// Use this instead of {} to avoid ban-types\nexport type NoProps = Record<string, never>;\n\n// Map P for component props: void -> NoProps; otherwise P\ntype ComponentProps<P> = [P] extends [void] ? NoProps : P;\n\n// Map P for .show() args: void -> []; otherwise [props: P]\ntype ShowArgs<P> = [P] extends [void] ? [] : [props: P];\n\n// Modalized component with static show/hide/remove methods\nexport type Modalized<P, R> = React.ComponentType<ComponentProps<P>> & {\n  __modalResult?: R;\n  show: (...args: ShowArgs<P>) => Promise<R>;\n  hide: () => void;\n  remove: () => void;\n};\n\nexport function defineModal<P, R>(\n  component: React.ComponentType<ComponentProps<P> & NiceModalHocProps>\n): Modalized<P, R> {\n  const c = component as unknown as Modalized<P, R>;\n  c.show = ((...args: ShowArgs<P>) =>\n    show(\n      component as React.FC<ComponentProps<P>>,\n      args[0] as ComponentProps<P>\n    ) as Promise<R>) as Modalized<P, R>['show'];\n  c.hide = () => {\n    void hide(component as React.FC<ComponentProps<P>>);\n  };\n  c.remove = () => remove(component as React.FC<ComponentProps<P>>);\n  return c;\n}\n\n// Common modal result types for standardization\nexport type ConfirmResult = 'confirmed' | 'canceled';\nexport type DeleteResult = 'deleted' | 'canceled';\nexport type SaveResult = 'saved' | 'canceled';\n\n// Error handling utility for modal operations\nexport function getErrorMessage(error: unknown): string {\n  if (error instanceof Error) {\n    return error.message;\n  }\n  if (typeof error === 'string') {\n    return error;\n  }\n  return 'An unknown error occurred';\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/modelSelector.ts",
    "content": "import type {\n  ModelInfo,\n  ModelSelectorConfig,\n  ReasoningOption,\n} from 'shared/types';\n\nfunction toPrettyCase(value: string): string {\n  return value\n    .split('_')\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n    .join(' ');\n}\n\nexport function getSelectedModel(\n  models: ModelInfo[],\n  selectedProviderId: string | null,\n  selectedModelId: string | null\n): ModelInfo | null {\n  if (!selectedModelId) return null;\n  const selectedId = selectedModelId.toLowerCase();\n  if (selectedProviderId) {\n    const providerId = selectedProviderId.toLowerCase();\n    return (\n      models.find(\n        (model) =>\n          model.id.toLowerCase() === selectedId &&\n          model.provider_id?.toLowerCase() === providerId\n      ) ?? null\n    );\n  }\n  return models.find((model) => model.id.toLowerCase() === selectedId) ?? null;\n}\n\nexport function getReasoningLabel(\n  options: ReasoningOption[],\n  selectedId: string | null\n): string | null {\n  if (!selectedId) return null;\n  return (\n    options.find((option) => option.id === selectedId)?.label ??\n    toPrettyCase(selectedId)\n  );\n}\n\nexport function escapeAttributeValue(value: string): string {\n  if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {\n    return CSS.escape(value);\n  }\n  return value.replace(/[\"\\\\]/g, '\\\\$&');\n}\n\nexport function parseModelId(\n  value?: string | null,\n  hasProviders?: boolean\n): {\n  providerId: string | null;\n  modelId: string | null;\n} {\n  if (!value) return { providerId: null, modelId: null };\n  if (!hasProviders) return { providerId: null, modelId: value };\n  const slashIdx = value.indexOf('/');\n  if (slashIdx === -1) return { providerId: null, modelId: value };\n  return {\n    providerId: value.substring(0, slashIdx),\n    modelId: value.substring(slashIdx + 1),\n  };\n}\n\nexport function appendPresetModel(\n  config: ModelSelectorConfig | null,\n  presetModel: string | null | undefined\n): ModelSelectorConfig | null {\n  if (!config || !presetModel) return config;\n  const hasProviders = config.providers.length > 0;\n  const { providerId, modelId } = parseModelId(presetModel, hasProviders);\n  if (!modelId) return config;\n\n  const exists = config.models.some(\n    (m) =>\n      m.id.toLowerCase() === modelId.toLowerCase() &&\n      (!providerId || m.provider_id?.toLowerCase() === providerId.toLowerCase())\n  );\n  if (exists) return config;\n\n  return {\n    ...config,\n    models: [\n      {\n        id: modelId,\n        name: modelId,\n        provider_id: providerId,\n        reasoning_options: [],\n      },\n      ...config.models,\n    ],\n  };\n}\n\nexport function resolveDefaultModelId(\n  models: ModelInfo[],\n  providerId: string | null,\n  defaultModel: string | null | undefined,\n  hasProviders?: boolean\n): string | null {\n  if (models.length === 0) return null;\n  const scoped = providerId\n    ? models.filter((model) => model.provider_id === providerId)\n    : models;\n  if (scoped.length === 0) return null;\n\n  const { providerId: defaultProvider, modelId: defaultId } = parseModelId(\n    defaultModel,\n    hasProviders\n  );\n  if (\n    defaultId &&\n    (!providerId || !defaultProvider || providerId === defaultProvider)\n  ) {\n    const match = scoped.find((model) => model.id === defaultId);\n    if (match) return match.id;\n  }\n\n  if (!defaultModel) return null;\n\n  return scoped[0]?.id ?? null;\n}\n\nexport function isModelAvailable(\n  config: ModelSelectorConfig,\n  providerId: string,\n  modelId: string\n): boolean {\n  const providerLower = providerId.toLowerCase();\n  const modelLower = modelId.toLowerCase();\n  return config.models.some(\n    (model) =>\n      model.id.toLowerCase() === modelLower &&\n      model.provider_id?.toLowerCase() === providerLower\n  );\n}\n\nexport function resolveDefaultReasoningId(\n  options: ReasoningOption[]\n): string | null {\n  return (\n    options.find((option) => option.is_default)?.id ?? options[0]?.id ?? null\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/notificationMessage.ts",
    "content": "import type { GroupedNotification } from '@/shared/lib/notifications';\nimport { getPayload } from '@/shared/lib/notifications';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\n\nexport type MessageSegment =\n  | { type: 'text'; value: string }\n  | { type: 'emphasis'; value: string }\n  | { type: 'issue'; value: string }\n  | { type: 'user'; userId: string };\n\nfunction text(value: string): MessageSegment {\n  return { type: 'text', value };\n}\n\nfunction emphasis(value: string): MessageSegment {\n  return { type: 'emphasis', value };\n}\n\nfunction issue(value: string): MessageSegment {\n  return { type: 'issue', value };\n}\n\nfunction user(userId: string): MessageSegment {\n  return { type: 'user', userId };\n}\n\nfunction getMemberLabel(member?: OrganizationMemberWithProfile): string | null {\n  if (!member) return null;\n\n  const fullName = [member.first_name, member.last_name]\n    .filter((value): value is string => Boolean(value && value.trim()))\n    .join(' ');\n\n  if (fullName) return fullName;\n  if (member.username?.trim()) return member.username;\n\n  return null;\n}\n\nfunction formatPriority(priority?: string | null): string | null {\n  if (!priority) return null;\n\n  return priority\n    .split('_')\n    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n    .join(' ');\n}\n\nfunction getActorSegments(group: GroupedNotification): MessageSegment[] {\n  const actorId = getPayload(group.latest).actor_user_id;\n  return actorId ? [user(actorId)] : [text('Someone')];\n}\n\nfunction getIssueSegments(group: GroupedNotification): MessageSegment[] {\n  const payload = getPayload(group.latest);\n  if (payload.issue_simple_id) {\n    return [issue(payload.issue_simple_id)];\n  }\n\n  return [emphasis(payload.issue_title ?? 'an issue')];\n}\n\nfunction formatCountLabel(\n  count: number,\n  singular: string,\n  plural = `${singular}s`\n): string {\n  return `${count} ${count === 1 ? singular : plural}`;\n}\n\nexport function getGroupedNotificationSegments(\n  group: GroupedNotification\n): MessageSegment[] {\n  const payload = getPayload(group.latest);\n  const actor = getActorSegments(group);\n  const issueSegments = getIssueSegments(group);\n\n  if (group.kind !== 'single' && group.notificationCount > 1) {\n    switch (group.kind) {\n      case 'issue_changes':\n        return [\n          ...actor,\n          text(' changed '),\n          emphasis(formatCountLabel(group.issueChangeCount, 'field')),\n          text(' on '),\n          ...issueSegments,\n        ];\n      case 'comments':\n        return [\n          ...actor,\n          text(' left '),\n          emphasis(formatCountLabel(group.notificationCount, 'comment')),\n          text(' on '),\n          ...issueSegments,\n        ];\n      case 'status_changes':\n        return [...actor, text(' changed status on '), ...issueSegments];\n      case 'reactions':\n        return [\n          ...actor,\n          text(' reacted '),\n          emphasis(formatCountLabel(group.notificationCount, 'time')),\n          text(' on '),\n          ...issueSegments,\n        ];\n      case 'issue_deleted':\n        return [\n          ...actor,\n          text(' deleted '),\n          emphasis(formatCountLabel(group.notificationCount, 'issue')),\n        ];\n    }\n  }\n\n  switch (group.latest.notification_type) {\n    case 'issue_title_changed': {\n      const newTitle = payload.new_title;\n      if (newTitle) {\n        return [\n          ...actor,\n          text(' changed the title of '),\n          ...issueSegments,\n          text(' to '),\n          emphasis(newTitle),\n        ];\n      }\n      return [...actor, text(' changed the title of '), ...issueSegments];\n    }\n    case 'issue_assignee_changed': {\n      const assigneeId = payload.assignee_user_id;\n      const assignee = assigneeId ? [user(assigneeId)] : [text('Someone')];\n      return [\n        ...assignee,\n        text(' was assigned to '),\n        ...issueSegments,\n        text(' by '),\n        ...actor,\n      ];\n    }\n    case 'issue_unassigned':\n      return [...actor, text(' unassigned you from '), ...issueSegments];\n    case 'issue_description_changed':\n      return [...actor, text(' changed the description on '), ...issueSegments];\n    case 'issue_priority_changed': {\n      const oldPriority = formatPriority(payload.old_priority);\n      const newPriority = formatPriority(payload.new_priority);\n\n      if (oldPriority && newPriority) {\n        return [\n          ...actor,\n          text(' changed the priority of '),\n          ...issueSegments,\n          text(' from '),\n          emphasis(oldPriority),\n          text(' to '),\n          emphasis(newPriority),\n        ];\n      }\n\n      if (newPriority) {\n        return [\n          ...actor,\n          text(' changed the priority of '),\n          ...issueSegments,\n          text(' to '),\n          emphasis(newPriority),\n        ];\n      }\n\n      return [...actor, text(' cleared the priority of '), ...issueSegments];\n    }\n    case 'issue_comment_added':\n      return [...actor, text(' commented on '), ...issueSegments];\n    case 'issue_comment_reaction': {\n      const emoji = payload.emoji;\n      if (emoji) {\n        return [\n          ...actor,\n          text(' reacted '),\n          emphasis(emoji),\n          text(' to your comment on '),\n          ...issueSegments,\n        ];\n      }\n      return [...actor, text(' reacted to your comment on '), ...issueSegments];\n    }\n    case 'issue_status_changed': {\n      const oldStatusName = payload.old_status_name;\n      const newStatusName = payload.new_status_name;\n\n      if (oldStatusName && newStatusName) {\n        return [\n          ...actor,\n          text(' changed status of '),\n          ...issueSegments,\n          text(' from '),\n          emphasis(oldStatusName),\n          text(' to '),\n          emphasis(newStatusName),\n        ];\n      }\n\n      return [...actor, text(' changed status of '), ...issueSegments];\n    }\n    case 'issue_deleted':\n      return [...actor, text(' deleted '), ...issueSegments];\n    default:\n      return [text('New notification')];\n  }\n}\n\nexport function getGroupedNotificationText(\n  group: GroupedNotification,\n  membersByUserId?: Map<string, OrganizationMemberWithProfile>\n): string {\n  return getGroupedNotificationSegments(group)\n    .map((segment) => {\n      if (segment.type === 'user') {\n        return (\n          getMemberLabel(membersByUserId?.get(segment.userId)) ?? 'Someone'\n        );\n      }\n\n      return segment.value;\n    })\n    .join('');\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/notifications.ts",
    "content": "import type {\n  Notification,\n  NotificationGroupKind,\n  NotificationPayload,\n  NotificationType,\n} from 'shared/remote-types';\n\nconst GROUP_WINDOW_MS = 5 * 60 * 1000;\n\nexport function getPayload(n: Notification): NotificationPayload {\n  return n.payload ?? {};\n}\n\nexport function getDeeplinkPath(n: Notification): string | null {\n  return getPayload(n).deeplink_path ?? null;\n}\n\ntype IssueChangeField =\n  | 'title'\n  | 'description'\n  | 'priority'\n  | 'assignee'\n  | 'unassigned';\n\ntype GroupableNotificationKind = Exclude<NotificationGroupKind, 'single'>;\n\nexport type GroupedNotification = {\n  id: string;\n  kind: NotificationGroupKind;\n  latest: Notification;\n  seen: boolean;\n  deeplinkPath: string | null;\n  notificationCount: number;\n  unseenNotificationIds: string[];\n  issueChangeCount: number;\n};\n\ntype NotificationGroupingMeta = {\n  groupKind: GroupableNotificationKind;\n  issueChangeField?: IssueChangeField;\n  scope: 'issue' | 'project';\n};\n\ntype GroupAccumulator = {\n  id: string;\n  kind: GroupableNotificationKind;\n  notifications: Notification[];\n  latest: Notification;\n  issueChangeFields: Set<IssueChangeField>;\n};\n\ntype ActiveGroup = {\n  index: number;\n  group: GroupAccumulator;\n};\n\nconst NOTIFICATION_GROUPING_META: Partial<\n  Record<NotificationType, NotificationGroupingMeta>\n> = {\n  issue_title_changed: {\n    groupKind: 'issue_changes',\n    issueChangeField: 'title',\n    scope: 'issue',\n  },\n  issue_description_changed: {\n    groupKind: 'issue_changes',\n    issueChangeField: 'description',\n    scope: 'issue',\n  },\n  issue_priority_changed: {\n    groupKind: 'issue_changes',\n    issueChangeField: 'priority',\n    scope: 'issue',\n  },\n  issue_status_changed: {\n    groupKind: 'status_changes',\n    scope: 'issue',\n  },\n  issue_assignee_changed: {\n    groupKind: 'issue_changes',\n    issueChangeField: 'assignee',\n    scope: 'issue',\n  },\n  issue_unassigned: {\n    groupKind: 'issue_changes',\n    issueChangeField: 'unassigned',\n    scope: 'issue',\n  },\n  issue_comment_added: {\n    groupKind: 'comments',\n    scope: 'issue',\n  },\n  issue_comment_reaction: {\n    groupKind: 'reactions',\n    scope: 'issue',\n  },\n  issue_deleted: {\n    groupKind: 'issue_deleted',\n    scope: 'project',\n  },\n};\n\nfunction getGroupingMeta(\n  notification: Notification\n): NotificationGroupingMeta | null {\n  return NOTIFICATION_GROUPING_META[notification.notification_type] ?? null;\n}\n\nfunction getGroupKey(\n  notification: Notification,\n  meta: NotificationGroupingMeta\n): string | null {\n  const payload = getPayload(notification);\n  const actorId = payload.actor_user_id;\n\n  if (!actorId) {\n    return null;\n  }\n\n  if (meta.scope === 'project') {\n    const projectPath = payload.deeplink_path;\n    if (!projectPath) {\n      return null;\n    }\n    return `${meta.groupKind}:${actorId}:${projectPath}`;\n  }\n\n  const issueId = payload.issue_id ?? notification.issue_id;\n  if (!issueId) {\n    return null;\n  }\n\n  return `${meta.groupKind}:${actorId}:${issueId}`;\n}\n\nfunction buildGroupedNotification(\n  id: string,\n  kind: NotificationGroupKind,\n  latest: Notification,\n  notifications: Notification[],\n  issueChangeCount: number\n): GroupedNotification {\n  const unseenNotificationIds = notifications\n    .filter((notification) => !notification.seen)\n    .map((notification) => notification.id);\n\n  return {\n    id,\n    kind,\n    latest,\n    seen: unseenNotificationIds.length === 0,\n    deeplinkPath: getDeeplinkPath(latest),\n    notificationCount: notifications.length,\n    unseenNotificationIds,\n    issueChangeCount,\n  };\n}\n\nfunction buildSingleGroupedNotification(\n  notification: Notification\n): GroupedNotification {\n  return buildGroupedNotification(\n    notification.id,\n    'single',\n    notification,\n    [notification],\n    0\n  );\n}\n\nfunction createAccumulator(\n  notification: Notification,\n  groupKey: string,\n  meta: NotificationGroupingMeta\n): GroupAccumulator {\n  const issueChangeFields = new Set<IssueChangeField>();\n  if (meta.issueChangeField) {\n    issueChangeFields.add(meta.issueChangeField);\n  }\n\n  return {\n    id: `${groupKey}:${notification.id}`,\n    kind: meta.groupKind,\n    notifications: [notification],\n    latest: notification,\n    issueChangeFields,\n  };\n}\n\nfunction getCreatedAtTimestamp(notification: Notification): number {\n  return new Date(notification.created_at).getTime();\n}\n\nfunction shouldStartNewGroup(\n  group: GroupAccumulator,\n  notification: Notification\n): boolean {\n  return (\n    getCreatedAtTimestamp(group.latest) - getCreatedAtTimestamp(notification) >\n    GROUP_WINDOW_MS\n  );\n}\n\nfunction finalizeGroup(group: GroupAccumulator): GroupedNotification {\n  return buildGroupedNotification(\n    group.id,\n    group.kind,\n    group.latest,\n    group.notifications,\n    group.kind === 'issue_changes'\n      ? Math.max(group.issueChangeFields.size, 1)\n      : 0\n  );\n}\n\nfunction addNotificationToGroup(\n  group: GroupAccumulator,\n  notification: Notification,\n  meta: NotificationGroupingMeta\n) {\n  group.notifications.push(notification);\n\n  if (meta.issueChangeField) {\n    group.issueChangeFields.add(meta.issueChangeField);\n  }\n}\n\nexport function groupNotifications(\n  notifications: Notification[]\n): GroupedNotification[] {\n  const sorted = [...notifications].sort(\n    (a, b) => getCreatedAtTimestamp(b) - getCreatedAtTimestamp(a)\n  );\n  const groups: GroupedNotification[] = [];\n  const groupsByKey = new Map<string, ActiveGroup>();\n\n  for (const notification of sorted) {\n    const meta = getGroupingMeta(notification);\n    if (!meta) {\n      groups.push(buildSingleGroupedNotification(notification));\n      continue;\n    }\n\n    const groupKey = getGroupKey(notification, meta);\n    if (!groupKey) {\n      groups.push(buildSingleGroupedNotification(notification));\n      continue;\n    }\n\n    const activeGroup = groupsByKey.get(groupKey);\n    if (!activeGroup || shouldStartNewGroup(activeGroup.group, notification)) {\n      const nextGroup = createAccumulator(notification, groupKey, meta);\n      const index = groups.length;\n      groups.push(finalizeGroup(nextGroup));\n      groupsByKey.set(groupKey, { index, group: nextGroup });\n      continue;\n    }\n\n    addNotificationToGroup(activeGroup.group, notification, meta);\n    groups[activeGroup.index] = finalizeGroup(activeGroup.group);\n  }\n\n  return groups;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/paths.ts",
    "content": "export const paths = {\n  projects: () => '/workspaces',\n  projectTasks: (projectId: string) => `/projects/${projectId}`,\n  task: (projectId: string, taskId: string) => {\n    void taskId;\n    return `/projects/${projectId}`;\n  },\n  attempt: (projectId: string, taskId: string, workspaceId: string) => {\n    void taskId;\n    void workspaceId;\n    return `/projects/${projectId}`;\n  },\n  attemptFull: (projectId: string, taskId: string, workspaceId: string) => {\n    void taskId;\n    void workspaceId;\n    return `/projects/${projectId}`;\n  },\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/platform.ts",
    "content": "export function isMac(): boolean {\n  // Modern API (Chrome, Edge) - not supported in Safari\n  const nav = navigator as Navigator & {\n    userAgentData?: { platform?: string };\n  };\n  if (nav.userAgentData?.platform) {\n    return nav.userAgentData.platform === 'macOS';\n  }\n  // Fallback for Safari and older browsers\n  return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);\n}\n\nexport function getModifierKey(): string {\n  return isMac() ? '⌘' : 'Ctrl';\n}\n\nexport function isTauriApp(): boolean {\n  return '__TAURI_INTERNALS__' in window;\n}\n\nexport function isTauriMac(): boolean {\n  return isTauriApp() && isMac();\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/previewBridge.ts",
    "content": "export interface ComponentSource {\n  fileName: string;\n  lineNumber: number;\n  columnNumber: number;\n}\n\nexport interface ComponentInfo {\n  name: string;\n  props: Record<string, unknown>;\n  source: ComponentSource;\n  pathToSource: string;\n}\n\nexport interface SelectedComponent extends ComponentInfo {\n  editor: string;\n  url: string;\n}\n\nexport interface ClickedElement {\n  tag?: string;\n  id?: string;\n  className?: string;\n  role?: string;\n  dataset?: Record<string, string>;\n}\n\nexport interface Coordinates {\n  x?: number;\n  y?: number;\n}\n\nexport interface OpenInEditorPayload {\n  selected: SelectedComponent;\n  components: ComponentInfo[];\n  trigger: 'alt-click' | 'context-menu';\n  coords?: Coordinates;\n  clickedElement?: ClickedElement;\n}\n\nexport interface ClickToComponentMessage {\n  source: 'click-to-component';\n  version: number;\n  type: 'ready' | 'open-in-editor' | 'enable-button';\n  payload?: OpenInEditorPayload;\n}\n\nexport interface ClickToComponentEnableMessage {\n  source: 'click-to-component';\n  version: 1;\n  type: 'enable-button';\n}\n\nexport interface EventHandlers {\n  onReady?: () => void;\n  onOpenInEditor?: (payload: OpenInEditorPayload) => void;\n  onUnknownMessage?: (message: unknown) => void;\n}\n\nexport class ClickToComponentListener {\n  private handlers: EventHandlers = {};\n  private messageListener: ((event: MessageEvent) => void) | null = null;\n\n  constructor(handlers: EventHandlers = {}) {\n    this.handlers = handlers;\n  }\n\n  /**\n   * Start listening for messages from click-to-component iframe\n   */\n  start(): void {\n    if (this.messageListener) {\n      this.stop(); // Clean up existing listener\n    }\n\n    this.messageListener = (event: MessageEvent) => {\n      const data = event.data as ClickToComponentMessage;\n\n      // Only handle messages from our click-to-component tool\n      if (!data || data.source !== 'click-to-component') {\n        return;\n      }\n\n      switch (data.type) {\n        case 'ready':\n          if (event.source) {\n            const enableMsg: ClickToComponentEnableMessage = {\n              source: 'click-to-component',\n              version: 1,\n              type: 'enable-button',\n            };\n            (event.source as Window).postMessage(enableMsg, '*');\n          }\n          this.handlers.onReady?.();\n          break;\n\n        case 'open-in-editor':\n          if (data.payload) {\n            this.handlers.onOpenInEditor?.(data.payload);\n          }\n          break;\n\n        default:\n          this.handlers.onUnknownMessage?.(data);\n      }\n    };\n\n    window.addEventListener('message', this.messageListener);\n  }\n\n  /**\n   * Stop listening for messages\n   */\n  stop(): void {\n    if (this.messageListener) {\n      window.removeEventListener('message', this.messageListener);\n      this.messageListener = null;\n    }\n  }\n\n  /**\n   * Update event handlers\n   */\n  setHandlers(handlers: EventHandlers): void {\n    this.handlers = { ...this.handlers, ...handlers };\n  }\n\n  /**\n   * Send a message to the iframe (if needed)\n   */\n  sendToIframe(iframe: HTMLIFrameElement, message: unknown): void {\n    if (iframe.contentWindow) {\n      iframe.contentWindow.postMessage(message, '*');\n    }\n  }\n}\n\n// Convenience function for quick setup\nexport function listenToClickToComponent(\n  handlers: EventHandlers\n): ClickToComponentListener {\n  const listener = new ClickToComponentListener(handlers);\n  listener.start();\n  return listener;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/previewDevToolsBridge.ts",
    "content": "import React from 'react';\nimport {\n  PreviewDevToolsMessage,\n  NavigationCommand,\n  PREVIEW_DEVTOOLS_SOURCE,\n  isPreviewDevToolsMessage,\n} from '@/shared/types/previewDevTools';\n\ntype MessageHandler = (message: PreviewDevToolsMessage) => void;\n\n/**\n * Bridge for communicating with the preview iframe's devtools\n * Handles postMessage communication for navigation commands and devtools events\n */\nexport class PreviewDevToolsBridge {\n  private messageHandler: MessageHandler;\n  private iframeRef: React.RefObject<HTMLIFrameElement | null>;\n  private messageListener: ((event: MessageEvent) => void) | null = null;\n\n  constructor(\n    messageHandler: MessageHandler,\n    iframeRef: React.RefObject<HTMLIFrameElement | null>\n  ) {\n    this.messageHandler = messageHandler;\n    this.iframeRef = iframeRef;\n  }\n\n  /**\n   * Start listening for messages from the preview iframe\n   */\n  start(): void {\n    if (this.messageListener) {\n      this.stop(); // Clean up existing listener\n    }\n\n    this.messageListener = (event: MessageEvent) => {\n      if (event.source !== this.iframeRef.current?.contentWindow) return;\n\n      const data = event.data;\n\n      // Only handle messages from our devtools\n      if (!isPreviewDevToolsMessage(data)) {\n        return;\n      }\n\n      this.messageHandler(data);\n    };\n\n    window.addEventListener('message', this.messageListener);\n  }\n\n  /**\n   * Stop listening for messages\n   */\n  stop(): void {\n    if (this.messageListener) {\n      window.removeEventListener('message', this.messageListener);\n      this.messageListener = null;\n    }\n  }\n\n  /**\n   * Send a navigation command to the iframe\n   */\n  private sendCommand(command: NavigationCommand): void {\n    const iframe = this.iframeRef.current;\n    if (iframe?.contentWindow) {\n      iframe.contentWindow.postMessage(command, '*');\n    }\n  }\n\n  /**\n   * Navigate back in the iframe's history\n   */\n  navigateBack(): void {\n    this.sendCommand({\n      source: PREVIEW_DEVTOOLS_SOURCE,\n      type: 'navigate',\n      payload: {\n        action: 'back',\n      },\n    });\n  }\n\n  /**\n   * Navigate forward in the iframe's history\n   */\n  navigateForward(): void {\n    this.sendCommand({\n      source: PREVIEW_DEVTOOLS_SOURCE,\n      type: 'navigate',\n      payload: {\n        action: 'forward',\n      },\n    });\n  }\n\n  /**\n   * Refresh the iframe\n   */\n  refresh(): void {\n    this.sendCommand({\n      source: PREVIEW_DEVTOOLS_SOURCE,\n      type: 'navigate',\n      payload: {\n        action: 'refresh',\n      },\n    });\n  }\n\n  /**\n   * Navigate to a specific URL in the iframe\n   */\n  navigateTo(url: string): void {\n    this.sendCommand({\n      source: PREVIEW_DEVTOOLS_SOURCE,\n      type: 'navigate',\n      payload: {\n        action: 'goto',\n        url,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/projectOrder.ts",
    "content": "import type { Project } from 'shared/remote-types';\n\nexport function compareProjectsByOrder(a: Project, b: Project): number {\n  const bySortOrder = a.sort_order - b.sort_order;\n  if (bySortOrder !== 0) {\n    return bySortOrder;\n  }\n\n  const byCreatedAt =\n    new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n  if (byCreatedAt !== 0) {\n    return byCreatedAt;\n  }\n\n  return a.id.localeCompare(b.id);\n}\n\nexport function sortProjectsByOrder(projects: Project[]): Project[] {\n  return [...projects].sort(compareProjectsByOrder);\n}\n\nexport function getFirstProjectByOrder(projects: Project[]): Project | null {\n  if (projects.length === 0) {\n    return null;\n  }\n\n  return sortProjectsByOrder(projects)[0];\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/promptMessage.ts",
    "content": "function isSlashCommandPrompt(prompt: string): boolean {\n  const trimmed = prompt.trimStart();\n  if (!trimmed.startsWith('/')) return false;\n\n  const match = /^\\/([^\\s/]+)(?:\\s|$)/.exec(trimmed);\n  if (!match) return false;\n\n  return true;\n}\n\nexport function buildAgentPrompt(\n  rawUserMessage: string,\n  contextParts: (string | null | undefined)[]\n) {\n  const trimmed = rawUserMessage.trim();\n  const isSlashCommand = !!trimmed && isSlashCommandPrompt(trimmed);\n\n  const parts = isSlashCommand\n    ? [trimmed]\n    : [...contextParts, rawUserMessage].filter(Boolean);\n\n  return {\n    prompt: parts.join('\\n\\n'),\n    isSlashCommand,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/queryClient.ts",
    "content": "import { QueryCache, QueryClient } from '@tanstack/react-query';\n\nexport const queryClient = new QueryClient({\n  queryCache: new QueryCache({\n    onError: (error, query) => {\n      console.error('[React Query Error]', {\n        queryKey: query.queryKey,\n        error: error,\n        message: error instanceof Error ? error.message : String(error),\n        stack: error instanceof Error ? error.stack : undefined,\n      });\n    },\n  }),\n  defaultOptions: {\n    queries: {\n      staleTime: 1000 * 60 * 5,\n      refetchOnWindowFocus: false,\n    },\n  },\n});\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/recentModels.ts",
    "content": "import type {\n  BaseCodingAgent,\n  ExecutorProfile,\n  ModelInfo,\n  ModelProvider,\n} from 'shared/types';\n\ntype ProfilesMap = Record<string, ExecutorProfile> | null;\n\nconst MAX_RECENT_MODELS = 20;\n\nexport function getModelKey(model: ModelInfo): string {\n  return model.provider_id ? `${model.provider_id}/${model.id}` : model.id;\n}\n\nexport function getRecentModelEntries(\n  profiles: ProfilesMap,\n  executor: BaseCodingAgent | null\n): string[] {\n  if (!profiles || !executor) return [];\n  const entries = profiles[executor]?.recently_used_models?.models ?? [];\n  return entries.map((e) => e.trim()).filter(Boolean);\n}\n\nexport function getRecentReasoningByModel(\n  profiles: ProfilesMap,\n  executor: BaseCodingAgent | null\n): Record<string, string> {\n  if (!profiles || !executor) return {};\n  const raw = profiles[executor]?.recently_used_models?.reasoning_by_model;\n  if (!raw) return {};\n  const out: Record<string, string> = {};\n  for (const [k, v] of Object.entries(raw)) {\n    if (v) out[k] = v;\n  }\n  return out;\n}\n\n/**\n * Touch a model in the LRU list: move it to the end (most recent).\n * If the model doesn't exist, append it. Caps list at MAX_RECENT_MODELS.\n */\nexport function touchRecentModel(\n  entries: string[],\n  model: ModelInfo\n): string[] {\n  const key = getModelKey(model);\n  const keyLower = key.toLowerCase();\n  const filtered = entries.filter((e) => e.toLowerCase() !== keyLower);\n  const updated = [...filtered, key];\n  if (updated.length > MAX_RECENT_MODELS) {\n    return updated.slice(updated.length - MAX_RECENT_MODELS);\n  }\n  return updated;\n}\n\nexport function updateRecentModelEntries(\n  profiles: Record<string, ExecutorProfile>,\n  executor: BaseCodingAgent,\n  entries: string[],\n  reasoningByModel?: Record<string, string>\n): Record<string, ExecutorProfile> {\n  const normalized = entries.map((e) => e.trim()).filter(Boolean);\n  const existing = profiles[executor]?.recently_used_models;\n  const mergedReasoning = reasoningByModel ?? existing?.reasoning_by_model;\n  const recentModels =\n    normalized.length > 0 ||\n    (mergedReasoning && Object.keys(mergedReasoning).length > 0)\n      ? {\n          ...(normalized.length > 0 ? { models: normalized } : {}),\n          ...(mergedReasoning && Object.keys(mergedReasoning).length > 0\n            ? { reasoning_by_model: mergedReasoning }\n            : {}),\n        }\n      : null;\n  const existingConfig = profiles[executor] ?? {};\n\n  return {\n    ...profiles,\n    [executor]: {\n      ...existingConfig,\n      recently_used_models: recentModels,\n    } as ExecutorProfile,\n  };\n}\n\nexport function setRecentReasoning(\n  profiles: Record<string, ExecutorProfile>,\n  executor: BaseCodingAgent,\n  model: ModelInfo,\n  reasoningId: string | null\n): Record<string, ExecutorProfile> {\n  const existing = getRecentReasoningByModel(profiles, executor);\n  const key = getModelKey(model);\n  const updated = { ...existing };\n  if (reasoningId) {\n    updated[key] = reasoningId;\n  } else {\n    delete updated[key];\n  }\n  const entries = getRecentModelEntries(profiles, executor);\n  return updateRecentModelEntries(profiles, executor, entries, updated);\n}\n\n/**\n * Get the index of a model in the LRU list (-1 if not found).\n * Higher index = more recently used.\n */\nexport function getRecentIndex(\n  recentEntries: string[],\n  model: ModelInfo\n): number {\n  const key = getModelKey(model).toLowerCase();\n  return recentEntries.findIndex((e) => e.toLowerCase() === key);\n}\n\n/**\n * Sort models by recency. Defaults to bottom-weighted ordering:\n * recent items last and non-recent reversed for bottom-up reading.\n */\nexport type RecentAlignment = 'top' | 'bottom';\n\nexport function sortByRecency(\n  models: ModelInfo[],\n  recentEntries: string[],\n  align: RecentAlignment = 'bottom'\n): ModelInfo[] {\n  if (recentEntries.length === 0) {\n    return align === 'bottom' ? [...models].reverse() : [...models];\n  }\n  const recentMap = new Map(recentEntries.map((e, i) => [e.toLowerCase(), i]));\n\n  // Separate into non-recent and recent groups\n  const nonRecent: ModelInfo[] = [];\n  const recent: { model: ModelInfo; idx: number }[] = [];\n\n  for (const model of models) {\n    const key = getModelKey(model).toLowerCase();\n    const idx = recentMap.get(key) ?? -1;\n    if (idx === -1) {\n      nonRecent.push(model);\n    } else {\n      recent.push({ model, idx });\n    }\n  }\n\n  // Non-recent: reversed original order (bottom-up reading)\n  if (align === 'bottom') {\n    nonRecent.reverse();\n  }\n\n  // Recent: sorted by recency (higher index = more recent)\n  recent.sort((a, b) => (align === 'bottom' ? a.idx - b.idx : b.idx - a.idx));\n\n  if (align === 'top') {\n    return [...recent.map((r) => r.model), ...nonRecent];\n  }\n  return [...nonRecent, ...recent.map((r) => r.model)];\n}\n\nexport function sortProvidersByRecency(\n  providers: ModelProvider[],\n  models: ModelInfo[],\n  recentEntries: string[]\n): ModelProvider[] {\n  const baseProviders = [...providers].reverse();\n  if (recentEntries.length === 0) return baseProviders;\n\n  const recencyByProvider = new Map<string, number>();\n  for (const model of models) {\n    if (!model.provider_id) continue;\n    const idx = getRecentIndex(recentEntries, model);\n    if (idx === -1) continue;\n    const current = recencyByProvider.get(model.provider_id) ?? -1;\n    if (idx > current) {\n      recencyByProvider.set(model.provider_id, idx);\n    }\n  }\n\n  const order = new Map(\n    baseProviders.map((provider, index) => [provider.id, index])\n  );\n\n  return [...baseProviders].sort((a, b) => {\n    const aRecent = recencyByProvider.get(a.id) ?? -1;\n    const bRecent = recencyByProvider.get(b.id) ?? -1;\n    if (aRecent === -1 && bRecent === -1) {\n      return (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0);\n    }\n    if (aRecent === -1) return -1;\n    if (bRecent === -1) return 1;\n    if (aRecent === bRecent) {\n      return (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0);\n    }\n    return aRecent - bRecent;\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/relayBackendApi.ts",
    "content": "import type { RelaySessionAuthCodeResponse } from 'shared/remote-types';\nimport type {\n  FinishSpake2EnrollmentRequest,\n  FinishSpake2EnrollmentResponse,\n  RefreshRelaySigningSessionResponse,\n  StartSpake2EnrollmentRequest,\n  StartSpake2EnrollmentResponse,\n} from 'shared/types';\nimport { getAuthRuntime } from '@/shared/lib/auth/runtime';\n\nexport interface RelaySigningSessionRefreshPayload {\n  client_id: string;\n  timestamp: number;\n  nonce: string;\n  signature_b64: string;\n}\n\nconst BUILD_TIME_API_BASE = import.meta.env.VITE_VK_SHARED_API_BASE || '';\nconst BUILD_TIME_RELAY_API_BASE = import.meta.env.VITE_RELAY_API_BASE_URL || '';\nconst USE_REMOTE_API_BASE_FALLBACK = !BUILD_TIME_RELAY_API_BASE;\n\nlet _relayApiBase: string = BUILD_TIME_RELAY_API_BASE || BUILD_TIME_API_BASE;\n\nexport function setRelayApiBase(base: string | null | undefined) {\n  if (base) {\n    _relayApiBase = base;\n  }\n}\n\nexport function getRelayApiUrl(): string {\n  return _relayApiBase;\n}\n\nexport function syncRelayApiBaseWithRemote(base: string | null | undefined) {\n  if (USE_REMOTE_API_BASE_FALLBACK) {\n    setRelayApiBase(base);\n  }\n}\n\nexport async function createRelaySessionAuthCode(\n  sessionId: string\n): Promise<RelaySessionAuthCodeResponse> {\n  const response = await makeAuthenticatedRequest(\n    getRelayApiUrl(),\n    `/v1/relay/sessions/${sessionId}/auth-code`,\n    { method: 'POST' }\n  );\n  if (!response.ok) {\n    throw await parseErrorResponse(\n      response,\n      'Failed to create relay session auth code'\n    );\n  }\n\n  return (await response.json()) as RelaySessionAuthCodeResponse;\n}\n\nexport async function establishRelaySessionBaseUrl(\n  relayUrl: string,\n  hostId: string,\n  code: string\n): Promise<string> {\n  const exchangeUrl = buildRelayExchangeUrl(relayUrl, hostId, code);\n  const exchangeResponse = await fetch(exchangeUrl, {\n    method: 'GET',\n    redirect: 'follow',\n  });\n\n  return parseRelaySessionBaseUrl(exchangeResponse.url, hostId);\n}\n\nexport async function startRelaySpake2Enrollment(\n  relaySessionBaseUrl: string,\n  payload: StartSpake2EnrollmentRequest\n): Promise<StartSpake2EnrollmentResponse> {\n  const response = await fetch(\n    `${relaySessionBaseUrl}/api/relay-auth/spake2/start`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(payload),\n    }\n  );\n\n  return parseLocalApiResponse(response, 'Failed to start pairing.');\n}\n\nexport async function finishRelaySpake2Enrollment(\n  relaySessionBaseUrl: string,\n  payload: FinishSpake2EnrollmentRequest\n): Promise<FinishSpake2EnrollmentResponse> {\n  const response = await fetch(\n    `${relaySessionBaseUrl}/api/relay-auth/spake2/finish`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(payload),\n    }\n  );\n\n  return parseLocalApiResponse(response, 'Failed to finish pairing.');\n}\n\nexport async function refreshRelaySigningSession(\n  relaySessionBaseUrl: string,\n  payload: RelaySigningSessionRefreshPayload\n): Promise<RefreshRelaySigningSessionResponse> {\n  const response = await fetch(\n    `${relaySessionBaseUrl}/api/relay-auth/signing-session/refresh`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(payload),\n    }\n  );\n\n  return parseLocalApiResponse(\n    response,\n    'Failed to refresh relay signing session.'\n  );\n}\n\nasync function makeAuthenticatedRequest(\n  baseUrl: string,\n  path: string,\n  options: RequestInit = {},\n  retryOn401 = true\n): Promise<Response> {\n  const authRuntime = getAuthRuntime();\n  const token = await authRuntime.getToken();\n  if (!token) {\n    throw new Error('Not authenticated');\n  }\n\n  const headers = new Headers(options.headers ?? {});\n  if (!headers.has('Content-Type')) {\n    headers.set('Content-Type', 'application/json');\n  }\n  headers.set('Authorization', `Bearer ${token}`);\n  headers.set('X-Client-Version', __APP_VERSION__);\n  headers.set('X-Client-Type', 'frontend');\n\n  const response = await fetch(`${baseUrl}${path}`, {\n    ...options,\n    headers,\n    credentials: 'include',\n  });\n\n  if (response.status === 401 && retryOn401) {\n    const newToken = await authRuntime.triggerRefresh();\n    if (newToken) {\n      headers.set('Authorization', `Bearer ${newToken}`);\n      return fetch(`${baseUrl}${path}`, {\n        ...options,\n        headers,\n        credentials: 'include',\n      });\n    }\n\n    throw new Error('Session expired. Please log in again.');\n  }\n\n  return response;\n}\n\nasync function parseErrorResponse(\n  response: Response,\n  fallbackMessage: string\n): Promise<Error> {\n  try {\n    const body = await response.json();\n    const message = body.error || body.message || fallbackMessage;\n    return new Error(`${message} (${response.status} ${response.statusText})`);\n  } catch {\n    return new Error(\n      `${fallbackMessage} (${response.status} ${response.statusText})`\n    );\n  }\n}\n\nfunction buildRelayExchangeUrl(\n  relayUrl: string,\n  hostId: string,\n  code: string\n): string {\n  const relayBase = relayUrl.replace(/\\/+$/, '');\n  return `${relayBase}/relay/h/${hostId}/exchange?code=${encodeURIComponent(code)}`;\n}\n\nfunction parseRelaySessionBaseUrl(finalUrl: string, hostId: string): string {\n  const parsed = new URL(finalUrl);\n  const hostPattern = escapeRegExp(hostId);\n  const match = parsed.pathname.match(\n    new RegExp(`^/relay/h/${hostPattern}/s/[^/]+`)\n  );\n  if (!match) {\n    throw new Error('Failed to establish relay browser session.');\n  }\n\n  return `${parsed.origin}${match[0]}`;\n}\n\nfunction escapeRegExp(value: string): string {\n  return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\ninterface LocalApiSuccess<T> {\n  success: true;\n  data: T;\n}\n\ninterface LocalApiFailure {\n  success: false;\n  message?: string;\n}\n\ntype LocalApiEnvelope<T> = LocalApiSuccess<T> | LocalApiFailure;\n\nasync function parseLocalApiResponse<T>(\n  response: Response,\n  fallbackMessage: string\n): Promise<T> {\n  if (!response.ok) {\n    throw new Error(await extractErrorMessage(response, fallbackMessage));\n  }\n\n  const body = (await response.json()) as LocalApiEnvelope<T>;\n  if (!body.success) {\n    throw new Error(body.message || fallbackMessage);\n  }\n\n  return body.data;\n}\n\nasync function extractErrorMessage(\n  response: Response,\n  fallbackMessage: string\n): Promise<string> {\n  try {\n    const body = await response.json();\n    if (body && typeof body.message === 'string') {\n      return body.message;\n    }\n    if (body && typeof body.error === 'string') {\n      return body.error;\n    }\n  } catch {\n    // Ignore parse failures and use fallback.\n  }\n\n  return `${fallbackMessage} (${response.status})`;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/relayClientIdentity.ts",
    "content": "export interface RelayClientIdentity {\n  clientId: string;\n  clientName: string;\n  clientBrowser: string;\n  clientOs: string;\n  clientDevice: string;\n}\n\nexport function createRelayClientIdentity(): RelayClientIdentity {\n  const userAgent = navigator.userAgent;\n  const clientBrowser = detectBrowser(userAgent);\n  const clientOs = detectOs(userAgent);\n  const clientDevice = detectDevice(userAgent);\n  const clientName = `${clientBrowser} on ${clientOs} (${toTitleCase(clientDevice)})`;\n\n  return {\n    clientId: crypto.randomUUID(),\n    clientName,\n    clientBrowser,\n    clientOs,\n    clientDevice,\n  };\n}\n\nfunction detectBrowser(userAgent: string): string {\n  if (/Edg\\//.test(userAgent)) return 'Edge';\n  if (/OPR\\//.test(userAgent)) return 'Opera';\n  if (/Firefox\\//.test(userAgent)) return 'Firefox';\n  if (/Chrome\\//.test(userAgent) || /CriOS\\//.test(userAgent)) return 'Chrome';\n  if (/Safari\\//.test(userAgent)) return 'Safari';\n  return 'Unknown Browser';\n}\n\nfunction detectOs(userAgent: string): string {\n  if (/Windows NT/.test(userAgent)) return 'Windows';\n  if (/iPhone|iPad|iPod/.test(userAgent)) return 'iOS';\n  if (/Macintosh|Mac OS X/.test(userAgent)) return 'macOS';\n  if (/Android/.test(userAgent)) return 'Android';\n  if (/Linux/.test(userAgent)) return 'Linux';\n  return 'Unknown OS';\n}\n\nfunction detectDevice(userAgent: string): string {\n  if (/iPad|Tablet|PlayBook|Silk/.test(userAgent)) return 'tablet';\n  if (/Mobi|Android|iPhone|iPod/.test(userAgent)) return 'mobile';\n  return 'desktop';\n}\n\nfunction toTitleCase(value: string): string {\n  if (!value) return value;\n  return `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}`;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/relayPairingStorage.ts",
    "content": "const DB_NAME = 'vk-relay-pairing';\nconst DB_VERSION = 1;\nconst PAIRED_HOSTS_STORE = 'paired_hosts';\n\ntype RelayPairingChangeType = 'saved' | 'removed';\n\nexport interface RelayPairingChange {\n  hostId: string;\n  type: RelayPairingChangeType;\n}\n\ntype RelayPairingChangeListener = (change: RelayPairingChange) => void;\n\nconst relayPairingChangeListeners = new Set<RelayPairingChangeListener>();\n\nexport interface PairedRelayHost {\n  host_id: string;\n  host_name: string;\n  client_id?: string;\n  client_name?: string;\n  signing_session_id?: string;\n  public_key_b64: string;\n  private_key_jwk: JsonWebKey;\n  server_public_key_b64: string;\n  paired_at: string;\n}\n\nexport function subscribeRelayPairingChanges(\n  listener: RelayPairingChangeListener\n): () => void {\n  relayPairingChangeListeners.add(listener);\n  return () => {\n    relayPairingChangeListeners.delete(listener);\n  };\n}\n\nfunction emitRelayPairingChange(change: RelayPairingChange): void {\n  for (const listener of relayPairingChangeListeners) {\n    try {\n      listener(change);\n    } catch (error) {\n      console.error('relay pairing change listener failed', error);\n    }\n  }\n}\n\nfunction openDb(): Promise<IDBDatabase> {\n  return new Promise((resolve, reject) => {\n    const request = indexedDB.open(DB_NAME, DB_VERSION);\n\n    request.onupgradeneeded = () => {\n      const db = request.result;\n      if (!db.objectStoreNames.contains(PAIRED_HOSTS_STORE)) {\n        db.createObjectStore(PAIRED_HOSTS_STORE, { keyPath: 'host_id' });\n      }\n    };\n\n    request.onsuccess = () => resolve(request.result);\n    request.onerror = () => reject(request.error);\n  });\n}\n\nexport async function listPairedRelayHosts(): Promise<PairedRelayHost[]> {\n  const db = await openDb();\n  return new Promise<PairedRelayHost[]>((resolve, reject) => {\n    const tx = db.transaction(PAIRED_HOSTS_STORE, 'readonly');\n    const store = tx.objectStore(PAIRED_HOSTS_STORE);\n    const request = store.getAll();\n\n    request.onsuccess = () => {\n      const pairedHosts = (request.result as PairedRelayHost[]) ?? [];\n      pairedHosts.sort((a, b) => b.paired_at.localeCompare(a.paired_at));\n      resolve(pairedHosts);\n    };\n    request.onerror = () => reject(request.error);\n    tx.onerror = () => reject(tx.error);\n    tx.onabort = () => reject(tx.error);\n    tx.oncomplete = () => {\n      db.close();\n    };\n  });\n}\n\nexport async function savePairedRelayHost(\n  host: PairedRelayHost\n): Promise<void> {\n  const db = await openDb();\n  await new Promise<void>((resolve, reject) => {\n    const tx = db.transaction(PAIRED_HOSTS_STORE, 'readwrite');\n    const store = tx.objectStore(PAIRED_HOSTS_STORE);\n    const request = store.put(host);\n\n    request.onerror = () => reject(request.error);\n    tx.onerror = () => reject(tx.error);\n    tx.onabort = () => reject(tx.error);\n    tx.oncomplete = () => {\n      db.close();\n      emitRelayPairingChange({ hostId: host.host_id, type: 'saved' });\n      resolve();\n    };\n  });\n}\n\nexport async function removePairedRelayHost(hostId: string): Promise<void> {\n  const db = await openDb();\n  await new Promise<void>((resolve, reject) => {\n    const tx = db.transaction(PAIRED_HOSTS_STORE, 'readwrite');\n    const store = tx.objectStore(PAIRED_HOSTS_STORE);\n    const request = store.delete(hostId);\n\n    request.onerror = () => reject(request.error);\n    tx.onerror = () => reject(tx.error);\n    tx.onabort = () => reject(tx.error);\n    tx.oncomplete = () => {\n      db.close();\n      emitRelayPairingChange({ hostId, type: 'removed' });\n      resolve();\n    };\n  });\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/relayPake.ts",
    "content": "import { ed25519 } from '@noble/curves/ed25519';\n\nconst ENCODER = new TextEncoder();\n\nconst SPAKE2_CLIENT_ID = ENCODER.encode('vibe-kanban-browser');\nconst SPAKE2_SERVER_ID = ENCODER.encode('vibe-kanban-server');\n\nconst KEY_CONFIRMATION_INFO = ENCODER.encode('key-confirmation');\nconst CLIENT_PROOF_CONTEXT = ENCODER.encode('vk-spake2-client-proof-v2');\nconst SERVER_PROOF_CONTEXT = ENCODER.encode('vk-spake2-server-proof-v2');\n\nconst SPAKE2_PASSWORD_INFO = ENCODER.encode('SPAKE2 pw');\nconst ENROLLMENT_CODE_LENGTH = 6;\n\n// Ed25519 subgroup order (same value used in curve25519-dalek).\nconst CURVE_ORDER =\n  7237005577332262213973186563042994240857116359379907606001950938285454250989n;\n\nconst SPAKE2_M = ed25519.ExtendedPoint.fromHex(\n  '15cfd18e385952982b6a8f8c7854963b58e34388c8e6dae891db756481a02312'\n);\nconst SPAKE2_N = ed25519.ExtendedPoint.fromHex(\n  'f04f2e7eb734b2a8f8b472eaf9c3c632576ac64aea650b496a8a20ff00e583c3'\n);\n\nexport interface Spake2EnrollmentClientState {\n  passwordBytes: Uint8Array;\n  passwordScalar: bigint;\n  xScalar: bigint;\n  clientMessageBytes: Uint8Array;\n}\n\nexport function normalizeEnrollmentCode(rawCode: string): string {\n  return rawCode\n    .trim()\n    .toUpperCase()\n    .replace(/[^A-Z0-9]/g, '');\n}\n\nexport async function startSpake2Enrollment(\n  rawEnrollmentCode: string\n): Promise<{ state: Spake2EnrollmentClientState; clientMessageB64: string }> {\n  const enrollmentCode = normalizeEnrollmentCode(rawEnrollmentCode);\n  if (enrollmentCode.length !== ENROLLMENT_CODE_LENGTH) {\n    throw new Error('Enrollment code must be 6 characters.');\n  }\n\n  const passwordBytes = ENCODER.encode(enrollmentCode);\n  const passwordScalar = await hashToSpake2Scalar(passwordBytes);\n  const xScalar = randomScalar();\n\n  const clientPoint = ed25519.ExtendedPoint.BASE.multiply(xScalar).add(\n    SPAKE2_M.multiply(passwordScalar)\n  );\n  const clientPointBytes = clientPoint.toRawBytes();\n\n  const clientMessage = new Uint8Array(1 + clientPointBytes.length);\n  clientMessage[0] = 0x41; // 'A'\n  clientMessage.set(clientPointBytes, 1);\n\n  return {\n    state: {\n      passwordBytes,\n      passwordScalar,\n      xScalar,\n      clientMessageBytes: clientPointBytes,\n    },\n    clientMessageB64: bytesToBase64(clientMessage),\n  };\n}\n\nexport async function finishSpake2Enrollment(\n  state: Spake2EnrollmentClientState,\n  serverMessageB64: string\n): Promise<Uint8Array> {\n  const serverMessage = base64ToBytes(serverMessageB64);\n  if (serverMessage.length !== 33) {\n    throw new Error('Server message has invalid length.');\n  }\n  if (serverMessage[0] !== 0x42) {\n    throw new Error('Server message has invalid side identifier.');\n  }\n\n  const serverPointBytes = serverMessage.slice(1);\n  const serverPoint = ed25519.ExtendedPoint.fromHex(serverPointBytes);\n  const negativePasswordScalar =\n    (CURVE_ORDER - state.passwordScalar) % CURVE_ORDER;\n\n  const keyPoint = serverPoint\n    .add(SPAKE2_N.multiply(negativePasswordScalar))\n    .multiply(state.xScalar);\n  const keyPointBytes = keyPoint.toRawBytes();\n\n  return hashAb(\n    state.passwordBytes,\n    SPAKE2_CLIENT_ID,\n    SPAKE2_SERVER_ID,\n    state.clientMessageBytes,\n    serverPointBytes,\n    keyPointBytes\n  );\n}\n\nexport async function generateRelaySigningKeyPair(): Promise<{\n  privateKeyJwk: JsonWebKey;\n  publicKeyBytes: Uint8Array;\n  publicKeyB64: string;\n}> {\n  const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, [\n    'sign',\n    'verify',\n  ])) as CryptoKeyPair;\n\n  const [privateKeyJwk, publicKeyRaw] = await Promise.all([\n    crypto.subtle.exportKey('jwk', keyPair.privateKey),\n    crypto.subtle.exportKey('raw', keyPair.publicKey),\n  ]);\n\n  const publicKeyBytes = new Uint8Array(publicKeyRaw);\n  return {\n    privateKeyJwk,\n    publicKeyBytes,\n    publicKeyB64: bytesToBase64(publicKeyBytes),\n  };\n}\n\nexport async function buildClientProofB64(\n  sharedKey: Uint8Array,\n  enrollmentId: string,\n  browserPublicKeyBytes: Uint8Array\n): Promise<string> {\n  const confirmationKey = await deriveConfirmationKey(sharedKey);\n  const enrollmentIdBytes = uuidToBytes(enrollmentId);\n  const payload = concatBytes(\n    CLIENT_PROOF_CONTEXT,\n    enrollmentIdBytes,\n    browserPublicKeyBytes\n  );\n  const proof = await hmacSha256(confirmationKey, payload);\n  return bytesToBase64(proof);\n}\n\nexport async function verifyServerProof(\n  sharedKey: Uint8Array,\n  enrollmentId: string,\n  browserPublicKeyBytes: Uint8Array,\n  serverPublicKeyB64: string,\n  serverProofB64: string\n): Promise<boolean> {\n  const confirmationKey = await deriveConfirmationKey(sharedKey);\n  const enrollmentIdBytes = uuidToBytes(enrollmentId);\n  const serverPublicKeyBytes = base64ToBytes(serverPublicKeyB64);\n\n  const payload = concatBytes(\n    SERVER_PROOF_CONTEXT,\n    enrollmentIdBytes,\n    browserPublicKeyBytes,\n    serverPublicKeyBytes\n  );\n  const expectedProof = await hmacSha256(confirmationKey, payload);\n  const actualProof = base64ToBytes(serverProofB64);\n\n  return constantTimeEqual(expectedProof, actualProof);\n}\n\nasync function hashAb(\n  passwordBytes: Uint8Array,\n  idA: Uint8Array,\n  idB: Uint8Array,\n  firstMessage: Uint8Array,\n  secondMessage: Uint8Array,\n  keyBytes: Uint8Array\n): Promise<Uint8Array> {\n  const transcript = new Uint8Array(6 * 32);\n\n  transcript.set(await sha256(passwordBytes), 0);\n  transcript.set(await sha256(idA), 32);\n  transcript.set(await sha256(idB), 64);\n  transcript.set(firstMessage, 96);\n  transcript.set(secondMessage, 128);\n  transcript.set(keyBytes, 160);\n\n  return sha256(transcript);\n}\n\nasync function hashToSpake2Scalar(passwordBytes: Uint8Array): Promise<bigint> {\n  const okm = await hkdfSha256(\n    passwordBytes,\n    new Uint8Array(0),\n    SPAKE2_PASSWORD_INFO,\n    48\n  );\n\n  const reducible = new Uint8Array(64);\n  for (let i = 0; i < okm.length; i += 1) {\n    reducible[okm.length - 1 - i] = okm[i];\n  }\n\n  return bytesToBigIntLE(reducible) % CURVE_ORDER;\n}\n\nfunction randomScalar(): bigint {\n  const randomBytes = new Uint8Array(64);\n  crypto.getRandomValues(randomBytes);\n  return bytesToBigIntLE(randomBytes) % CURVE_ORDER;\n}\n\nasync function deriveConfirmationKey(\n  sharedKey: Uint8Array\n): Promise<Uint8Array> {\n  return hkdfSha256(sharedKey, new Uint8Array(0), KEY_CONFIRMATION_INFO, 32);\n}\n\nasync function hkdfSha256(\n  ikm: Uint8Array,\n  salt: Uint8Array,\n  info: Uint8Array,\n  length: number\n): Promise<Uint8Array> {\n  const key = await crypto.subtle.importKey(\n    'raw',\n    toArrayBuffer(ikm),\n    'HKDF',\n    false,\n    ['deriveBits']\n  );\n  const derivedBits = await crypto.subtle.deriveBits(\n    {\n      name: 'HKDF',\n      hash: 'SHA-256',\n      salt: toArrayBuffer(salt),\n      info: toArrayBuffer(info),\n    },\n    key,\n    length * 8\n  );\n  return new Uint8Array(derivedBits);\n}\n\nasync function hmacSha256(\n  keyBytes: Uint8Array,\n  data: Uint8Array\n): Promise<Uint8Array> {\n  const key = await crypto.subtle.importKey(\n    'raw',\n    toArrayBuffer(keyBytes),\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  );\n  const signature = await crypto.subtle.sign('HMAC', key, toArrayBuffer(data));\n  return new Uint8Array(signature);\n}\n\nasync function sha256(data: Uint8Array): Promise<Uint8Array> {\n  const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(data));\n  return new Uint8Array(digest);\n}\n\nfunction toArrayBuffer(data: Uint8Array): ArrayBuffer {\n  return new Uint8Array(data).buffer;\n}\n\nfunction uuidToBytes(rawUuid: string): Uint8Array {\n  const hex = rawUuid.replace(/-/g, '');\n  if (hex.length !== 32) {\n    throw new Error('Invalid enrollment ID.');\n  }\n\n  const bytes = new Uint8Array(16);\n  for (let i = 0; i < 16; i += 1) {\n    const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n    if (Number.isNaN(value)) {\n      throw new Error('Invalid enrollment ID.');\n    }\n    bytes[i] = value;\n  }\n\n  return bytes;\n}\n\nfunction bytesToBigIntLE(bytes: Uint8Array): bigint {\n  let value = 0n;\n  for (let i = bytes.length - 1; i >= 0; i -= 1) {\n    value = (value << 8n) + BigInt(bytes[i]);\n  }\n  return value;\n}\n\nfunction concatBytes(...chunks: Uint8Array[]): Uint8Array {\n  const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n  const output = new Uint8Array(totalLength);\n  let offset = 0;\n  for (const chunk of chunks) {\n    output.set(chunk, offset);\n    offset += chunk.length;\n  }\n  return output;\n}\n\nfunction constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {\n  if (a.length !== b.length) {\n    return false;\n  }\n  let mismatch = 0;\n  for (let i = 0; i < a.length; i += 1) {\n    mismatch |= a[i] ^ b[i];\n  }\n  return mismatch === 0;\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n  const binary = String.fromCharCode(...bytes);\n  return btoa(binary);\n}\n\nfunction base64ToBytes(value: string): Uint8Array {\n  const binary = atob(value);\n  const bytes = new Uint8Array(binary.length);\n  for (let i = 0; i < binary.length; i += 1) {\n    bytes[i] = binary.charCodeAt(i);\n  }\n  return bytes;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/relaySigningSessionRefresh.ts",
    "content": "import type { RelaySigningSessionRefreshPayload } from '@/shared/lib/relayBackendApi';\n\nconst TEXT_ENCODER = new TextEncoder();\n\nexport function buildRelaySigningSessionRefreshMessage(\n  timestamp: number,\n  nonce: string,\n  clientId: string\n): string {\n  return `v1|refresh|${timestamp}|${nonce}|${clientId}`;\n}\n\nexport async function buildRelaySigningSessionRefreshPayload(\n  clientId: string,\n  privateKeyJwk: JsonWebKey\n): Promise<RelaySigningSessionRefreshPayload> {\n  const timestamp = Math.floor(Date.now() / 1000);\n  const nonce = crypto.randomUUID().replace(/-/g, '');\n  const message = buildRelaySigningSessionRefreshMessage(\n    timestamp,\n    nonce,\n    clientId\n  );\n  const key = await crypto.subtle.importKey(\n    'jwk',\n    privateKeyJwk,\n    { name: 'Ed25519' },\n    false,\n    ['sign']\n  );\n  const signature = await crypto.subtle.sign(\n    'Ed25519',\n    key,\n    toArrayBuffer(TEXT_ENCODER.encode(message))\n  );\n\n  return {\n    client_id: clientId,\n    timestamp,\n    nonce,\n    signature_b64: bytesToBase64(new Uint8Array(signature)),\n  };\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n  let binary = '';\n  for (const value of bytes) {\n    binary += String.fromCharCode(value);\n  }\n  return btoa(binary);\n}\n\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n  return bytes.buffer.slice(\n    bytes.byteOffset,\n    bytes.byteOffset + bytes.byteLength\n  ) as ArrayBuffer;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/remoteApi.ts",
    "content": "import type {\n  AttachmentUrlResponse,\n  AttachmentWithBlob,\n  CommitAttachmentsRequest,\n  CommitAttachmentsResponse,\n  CreateRelaySessionResponse,\n  ConfirmUploadRequest,\n  InitUploadRequest,\n  InitUploadResponse,\n  ListRelayHostsResponse,\n  RelayHost,\n  UpdateIssueRequest,\n  UpdateProjectRequest,\n  UpdateProjectStatusRequest,\n} from 'shared/remote-types';\nimport { getAuthRuntime } from '@/shared/lib/auth/runtime';\nimport { syncRelayApiBaseWithRemote } from '@/shared/lib/relayBackendApi';\n\nconst BUILD_TIME_API_BASE = import.meta.env.VITE_VK_SHARED_API_BASE || '';\n\n// Mutable module-level variable — overridden at runtime by ConfigProvider\n// when VK_SHARED_API_BASE is set (for self-hosting support)\nlet _remoteApiBase: string = BUILD_TIME_API_BASE;\n\n/**\n * Set the remote API base URL at runtime.\n * Called by ConfigProvider when /api/info returns a shared_api_base value.\n * No-op if base is null/undefined/empty (preserves build-time fallback).\n */\nexport function setRemoteApiBase(base: string | null | undefined) {\n  if (base) {\n    _remoteApiBase = base;\n    syncRelayApiBaseWithRemote(base);\n  }\n}\n\n/**\n * Get the current remote API base URL.\n * Returns the runtime value if set by ConfigProvider, otherwise the build-time default.\n */\nexport function getRemoteApiUrl(): string {\n  return _remoteApiBase;\n}\n\n// Backward-compatible export — consumers should migrate to getRemoteApiUrl()\nexport const REMOTE_API_URL = BUILD_TIME_API_BASE;\n\nexport const makeRequest = async (\n  path: string,\n  options: RequestInit = {},\n  retryOn401 = true\n): Promise<Response> => {\n  return makeAuthenticatedRequest(getRemoteApiUrl(), path, options, retryOn401);\n};\n\nasync function makeAuthenticatedRequest(\n  baseUrl: string,\n  path: string,\n  options: RequestInit = {},\n  retryOn401 = true\n): Promise<Response> {\n  const authRuntime = getAuthRuntime();\n  const token = await authRuntime.getToken();\n  if (!token) {\n    throw new Error('Not authenticated');\n  }\n\n  const headers = new Headers(options.headers ?? {});\n  if (!headers.has('Content-Type')) {\n    headers.set('Content-Type', 'application/json');\n  }\n  headers.set('Authorization', `Bearer ${token}`);\n  headers.set('X-Client-Version', __APP_VERSION__);\n  headers.set('X-Client-Type', 'frontend');\n\n  const response = await fetch(`${baseUrl}${path}`, {\n    ...options,\n    headers,\n    credentials: 'include',\n  });\n\n  // Handle 401 - token may have expired\n  if (response.status === 401 && retryOn401) {\n    const newToken = await authRuntime.triggerRefresh();\n    if (newToken) {\n      // Retry the request with the new token\n      headers.set('Authorization', `Bearer ${newToken}`);\n      return fetch(`${baseUrl}${path}`, {\n        ...options,\n        headers,\n        credentials: 'include',\n      });\n    }\n    // Refresh failed, throw an auth error\n    throw new Error('Session expired. Please log in again.');\n  }\n\n  return response;\n}\n\nexport interface BulkUpdateIssueItem {\n  id: string;\n  changes: Partial<UpdateIssueRequest>;\n}\n\nexport interface BulkUpdateProjectItem {\n  id: string;\n  changes: Partial<UpdateProjectRequest>;\n}\n\nexport async function bulkUpdateProjects(\n  updates: BulkUpdateProjectItem[]\n): Promise<void> {\n  const response = await makeRequest('/v1/projects/bulk', {\n    method: 'POST',\n    body: JSON.stringify({\n      updates: updates.map((u) => ({ id: u.id, ...u.changes })),\n    }),\n  });\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(error.message || 'Failed to bulk update projects');\n  }\n}\n\nexport async function bulkUpdateIssues(\n  updates: BulkUpdateIssueItem[]\n): Promise<void> {\n  const response = await makeRequest('/v1/issues/bulk', {\n    method: 'POST',\n    body: JSON.stringify({\n      updates: updates.map((u) => ({ id: u.id, ...u.changes })),\n    }),\n  });\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(error.message || 'Failed to bulk update issues');\n  }\n}\n\nexport interface BulkUpdateProjectStatusItem {\n  id: string;\n  changes: Partial<UpdateProjectStatusRequest>;\n}\n\nexport async function bulkUpdateProjectStatuses(\n  updates: BulkUpdateProjectStatusItem[]\n): Promise<void> {\n  const response = await makeRequest('/v1/project_statuses/bulk', {\n    method: 'POST',\n    body: JSON.stringify({\n      updates: updates.map((u) => ({ id: u.id, ...u.changes })),\n    }),\n  });\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(error.message || 'Failed to bulk update project statuses');\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Relay host API functions (served by remote backend)\n// ---------------------------------------------------------------------------\n\nexport async function listRelayHosts(): Promise<RelayHost[]> {\n  const response = await makeRequest('/v1/hosts', { method: 'GET' });\n  if (!response.ok) {\n    throw await parseErrorResponse(response, 'Failed to list relay hosts');\n  }\n\n  const body = (await response.json()) as ListRelayHostsResponse;\n  return body.hosts;\n}\n\nexport async function createRelaySession(\n  hostId: string\n): Promise<CreateRelaySessionResponse['session']> {\n  const response = await makeRequest(`/v1/hosts/${hostId}/sessions`, {\n    method: 'POST',\n  });\n  if (!response.ok) {\n    throw await parseErrorResponse(response, 'Failed to create relay session');\n  }\n\n  const body = (await response.json()) as CreateRelaySessionResponse;\n  return body.session;\n}\n\n// ---------------------------------------------------------------------------\n// SAS URL cache with TTL — SAS URLs expire after 5 minutes, cache for 4\n// ---------------------------------------------------------------------------\n\nconst SAS_URL_TTL_MS = 4 * 60 * 1000;\n\ninterface CachedSasUrl {\n  url: string;\n  expiresAt: number;\n}\n\nconst sasUrlCache = new Map<string, CachedSasUrl>();\n\n// ---------------------------------------------------------------------------\n// Utility: SHA-256 file hash\n// ---------------------------------------------------------------------------\n\nexport async function computeFileHash(file: File): Promise<string> {\n  const buffer = await file.arrayBuffer();\n  const hash = await crypto.subtle.digest('SHA-256', buffer);\n  return Array.from(new Uint8Array(hash))\n    .map((b) => b.toString(16).padStart(2, '0'))\n    .join('');\n}\n\n// ---------------------------------------------------------------------------\n// Utility: Upload to Azure Blob Storage with progress\n// ---------------------------------------------------------------------------\n\nexport function uploadToAzure(\n  uploadUrl: string,\n  file: File,\n  onProgress?: (pct: number) => void\n): Promise<void> {\n  return new Promise((resolve, reject) => {\n    const xhr = new XMLHttpRequest();\n    xhr.open('PUT', uploadUrl, true);\n    xhr.setRequestHeader('x-ms-blob-type', 'BlockBlob');\n    xhr.setRequestHeader('Content-Type', file.type);\n\n    if (onProgress) {\n      xhr.upload.addEventListener('progress', (e) => {\n        if (e.lengthComputable) {\n          onProgress(Math.round((e.loaded / e.total) * 100));\n        }\n      });\n    }\n\n    xhr.onload = () => {\n      if (xhr.status === 201) {\n        resolve();\n      } else {\n        reject(\n          new Error(\n            `Azure upload failed with status ${xhr.status}: ${xhr.statusText}`\n          )\n        );\n      }\n    };\n\n    xhr.onerror = () => {\n      reject(new Error('Azure upload failed: network error'));\n    };\n\n    xhr.send(file);\n  });\n}\n\n// ---------------------------------------------------------------------------\n// Utility: safe error response parsing (handles non-JSON error bodies)\n// ---------------------------------------------------------------------------\n\nasync function parseErrorResponse(\n  response: Response,\n  fallbackMessage: string\n): Promise<Error> {\n  try {\n    const body = await response.json();\n    const message = body.error || body.message || fallbackMessage;\n    return new Error(`${message} (${response.status} ${response.statusText})`);\n  } catch {\n    return new Error(\n      `${fallbackMessage} (${response.status} ${response.statusText})`\n    );\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Attachment API functions\n// ---------------------------------------------------------------------------\n\nexport async function initAttachmentUpload(\n  params: InitUploadRequest\n): Promise<InitUploadResponse> {\n  const response = await makeRequest('/v1/attachments/init', {\n    method: 'POST',\n    body: JSON.stringify(params),\n  });\n  if (!response.ok) {\n    throw await parseErrorResponse(\n      response,\n      'Failed to init attachment upload'\n    );\n  }\n  return response.json();\n}\n\nexport async function confirmAttachmentUpload(\n  params: ConfirmUploadRequest\n): Promise<AttachmentWithBlob> {\n  const response = await makeRequest('/v1/attachments/confirm', {\n    method: 'POST',\n    body: JSON.stringify(params),\n  });\n  if (!response.ok) {\n    throw await parseErrorResponse(\n      response,\n      'Failed to confirm attachment upload'\n    );\n  }\n  return response.json();\n}\n\nexport async function commitIssueAttachments(\n  issueId: string,\n  request: CommitAttachmentsRequest\n): Promise<CommitAttachmentsResponse> {\n  const response = await makeRequest(\n    `/v1/issues/${issueId}/attachments/commit`,\n    {\n      method: 'POST',\n      body: JSON.stringify(request),\n    }\n  );\n  if (!response.ok) {\n    throw await parseErrorResponse(\n      response,\n      'Failed to commit issue attachments'\n    );\n  }\n  return response.json();\n}\n\nexport async function commitCommentAttachments(\n  commentId: string,\n  request: CommitAttachmentsRequest\n): Promise<CommitAttachmentsResponse> {\n  const response = await makeRequest(\n    `/v1/comments/${commentId}/attachments/commit`,\n    {\n      method: 'POST',\n      body: JSON.stringify(request),\n    }\n  );\n  if (!response.ok) {\n    throw await parseErrorResponse(\n      response,\n      'Failed to commit comment attachments'\n    );\n  }\n  return response.json();\n}\n\nexport async function deleteAttachment(attachmentId: string): Promise<void> {\n  const response = await makeRequest(`/v1/attachments/${attachmentId}`, {\n    method: 'DELETE',\n  });\n  if (!response.ok) {\n    throw await parseErrorResponse(response, 'Failed to delete attachment');\n  }\n}\n\nexport async function fetchAttachmentSasUrl(\n  attachmentId: string,\n  type: 'file' | 'thumbnail'\n): Promise<string> {\n  const cacheKey = `${attachmentId}:${type}`;\n  const cached = sasUrlCache.get(cacheKey);\n  if (cached && Date.now() < cached.expiresAt) {\n    return cached.url;\n  }\n\n  const response = await makeRequest(`/v1/attachments/${attachmentId}/${type}`);\n  if (!response.ok) {\n    throw new Error(\n      `Failed to fetch attachment ${type}: ${response.statusText}`\n    );\n  }\n\n  const data: AttachmentUrlResponse = await response.json();\n  sasUrlCache.set(cacheKey, {\n    url: data.url,\n    expiresAt: Date.now() + SAS_URL_TTL_MS,\n  });\n  return data.url;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/resolveRelationships.ts",
    "content": "import type { IssueRelationship, Issue } from 'shared/remote-types';\n\nexport type RelationshipDisplayType =\n  | 'blocks'\n  | 'blocked_by'\n  | 'related'\n  | 'duplicate_of'\n  | 'duplicated_by';\n\nexport interface ResolvedRelationship {\n  relationshipId: string;\n  displayType: RelationshipDisplayType;\n  relatedIssueId: string;\n  relatedIssueDisplayId: string;\n}\n\nexport function resolveRelationshipsForIssue(\n  issueId: string,\n  relationships: IssueRelationship[],\n  issuesById: Map<string, Issue>\n): ResolvedRelationship[] {\n  return relationships\n    .map((r) => {\n      const isSource = r.issue_id === issueId;\n      const otherIssueId = isSource ? r.related_issue_id : r.issue_id;\n      const otherIssue = issuesById.get(otherIssueId);\n      if (!otherIssue) return null;\n\n      let displayType: RelationshipDisplayType;\n      if (r.relationship_type === 'blocking') {\n        displayType = isSource ? 'blocks' : 'blocked_by';\n      } else if (r.relationship_type === 'related') {\n        displayType = 'related';\n      } else {\n        displayType = isSource ? 'duplicate_of' : 'duplicated_by';\n      }\n\n      return {\n        relationshipId: r.id,\n        displayType,\n        relatedIssueId: otherIssueId,\n        relatedIssueDisplayId: otherIssue.simple_id,\n      };\n    })\n    .filter((r): r is ResolvedRelationship => r !== null);\n}\n\nexport function getRelationshipLabel(\n  displayType: RelationshipDisplayType\n): string {\n  switch (displayType) {\n    case 'blocks':\n      return 'blocks';\n    case 'blocked_by':\n      return 'blocked by';\n    case 'related':\n      return 'related';\n    case 'duplicate_of':\n      return 'dup of';\n    case 'duplicated_by':\n      return 'dup';\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/routes/appNavigation.ts",
    "content": "export type AppDestination =\n  | { kind: 'root' }\n  | { kind: 'onboarding' }\n  | { kind: 'onboarding-sign-in' }\n  | { kind: 'migrate' }\n  | { kind: 'workspaces'; hostId?: string }\n  | { kind: 'workspaces-create'; hostId?: string }\n  | { kind: 'workspace'; workspaceId: string; hostId?: string }\n  | { kind: 'workspace-vscode'; workspaceId: string; hostId?: string }\n  | { kind: 'project'; projectId: string }\n  | {\n      kind: 'project-issue';\n      projectId: string;\n      issueId: string;\n    }\n  | {\n      kind: 'project-issue-workspace';\n      projectId: string;\n      issueId: string;\n      workspaceId: string;\n      hostId?: string;\n    }\n  | {\n      kind: 'project-issue-workspace-create';\n      projectId: string;\n      issueId: string;\n      draftId: string;\n      hostId?: string;\n    }\n  | {\n      kind: 'project-workspace-create';\n      projectId: string;\n      draftId: string;\n      hostId?: string;\n    };\n\nexport type NavigationTransition = {\n  replace?: boolean;\n};\n\nexport interface AppNavigation {\n  resolveFromPath(path: string): AppDestination | null;\n  goToRoot(transition?: NavigationTransition): void;\n  goToOnboarding(transition?: NavigationTransition): void;\n  goToOnboardingSignIn(transition?: NavigationTransition): void;\n  goToMigrate(transition?: NavigationTransition): void;\n  goToWorkspaces(transition?: NavigationTransition): void;\n  goToWorkspacesCreate(transition?: NavigationTransition): void;\n  goToWorkspace(workspaceId: string, transition?: NavigationTransition): void;\n  goToWorkspaceVsCode(\n    workspaceId: string,\n    transition?: NavigationTransition\n  ): void;\n  goToProject(projectId: string, transition?: NavigationTransition): void;\n  goToProjectIssue(\n    projectId: string,\n    issueId: string,\n    transition?: NavigationTransition\n  ): void;\n  goToProjectIssueWorkspace(\n    projectId: string,\n    issueId: string,\n    workspaceId: string,\n    transition?: NavigationTransition\n  ): void;\n  goToProjectIssueWorkspaceCreate(\n    projectId: string,\n    issueId: string,\n    draftId: string,\n    transition?: NavigationTransition\n  ): void;\n  goToProjectWorkspaceCreate(\n    projectId: string,\n    draftId: string,\n    transition?: NavigationTransition\n  ): void;\n}\n\ntype ProjectDestinationKind =\n  | 'project'\n  | 'project-issue'\n  | 'project-issue-workspace'\n  | 'project-issue-workspace-create'\n  | 'project-workspace-create';\n\ntype WorkspaceDestinationKind =\n  | 'workspaces'\n  | 'workspaces-create'\n  | 'workspace'\n  | 'workspace-vscode';\n\nexport type ProjectDestination = Extract<\n  AppDestination,\n  { kind: ProjectDestinationKind }\n>;\n\nexport type WorkspaceDestination = Extract<\n  AppDestination,\n  { kind: WorkspaceDestinationKind }\n>;\n\nexport type KanbanSidebarMode =\n  | 'closed'\n  | 'issue'\n  | 'issue-workspace'\n  | 'workspace-create';\n\nexport interface KanbanRouteState {\n  hostId: string | null;\n  projectId: string | null;\n  issueId: string | null;\n  workspaceId: string | null;\n  draftId: string | null;\n  sidebarMode: KanbanSidebarMode | null;\n  isCreateMode: boolean;\n  isWorkspaceCreateMode: boolean;\n  hasInvalidWorkspaceCreateDraftId: boolean;\n  isPanelOpen: boolean;\n}\n\nexport function getDestinationHostId(\n  destination: AppDestination | null\n): string | null {\n  if (!destination || !('hostId' in destination)) {\n    return null;\n  }\n\n  return destination.hostId ?? null;\n}\n\nexport function isProjectDestination(\n  destination: AppDestination | null\n): destination is ProjectDestination {\n  if (!destination) {\n    return false;\n  }\n\n  switch (destination.kind) {\n    case 'project':\n    case 'project-issue':\n    case 'project-issue-workspace':\n    case 'project-issue-workspace-create':\n    case 'project-workspace-create':\n      return true;\n    default:\n      return false;\n  }\n}\n\nexport function isWorkspacesDestination(\n  destination: AppDestination | null\n): destination is WorkspaceDestination {\n  if (!destination) {\n    return false;\n  }\n\n  switch (destination.kind) {\n    case 'workspaces':\n    case 'workspaces-create':\n    case 'workspace':\n    case 'workspace-vscode':\n      return true;\n    default:\n      return false;\n  }\n}\n\nexport function getProjectDestination(\n  destination: AppDestination | null\n): ProjectDestination | null {\n  return isProjectDestination(destination) ? destination : null;\n}\n\nfunction isValidUuid(value: string): boolean {\n  return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(\n    value\n  );\n}\n\nexport function resolveKanbanRouteState(\n  destination: AppDestination | null\n): KanbanRouteState {\n  const projectDestination = getProjectDestination(destination);\n  const projectId = projectDestination?.projectId ?? null;\n  const hostId = getDestinationHostId(projectDestination);\n\n  const issueId = (() => {\n    if (!projectDestination) {\n      return null;\n    }\n\n    switch (projectDestination.kind) {\n      case 'project-issue':\n      case 'project-issue-workspace':\n      case 'project-issue-workspace-create':\n        return projectDestination.issueId;\n      default:\n        return null;\n    }\n  })();\n\n  const workspaceId =\n    projectDestination?.kind === 'project-issue-workspace'\n      ? projectDestination.workspaceId\n      : null;\n\n  const rawDraftId =\n    projectDestination?.kind === 'project-issue-workspace-create' ||\n    projectDestination?.kind === 'project-workspace-create'\n      ? projectDestination.draftId\n      : null;\n  const draftId = rawDraftId && isValidUuid(rawDraftId) ? rawDraftId : null;\n\n  const hasInvalidWorkspaceCreateDraftId =\n    (projectDestination?.kind === 'project-issue-workspace-create' ||\n      projectDestination?.kind === 'project-workspace-create') &&\n    rawDraftId !== null &&\n    !draftId;\n\n  const isWorkspaceCreateMode =\n    (projectDestination?.kind === 'project-issue-workspace-create' ||\n      projectDestination?.kind === 'project-workspace-create') &&\n    draftId !== null;\n\n  const sidebarMode = (() => {\n    if (!projectDestination) {\n      return null;\n    }\n\n    switch (projectDestination.kind) {\n      case 'project':\n        return 'closed';\n      case 'project-issue':\n        return 'issue';\n      case 'project-issue-workspace':\n        return 'issue-workspace';\n      case 'project-issue-workspace-create':\n      case 'project-workspace-create':\n        return 'workspace-create';\n    }\n  })();\n\n  return {\n    hostId,\n    projectId,\n    issueId,\n    workspaceId,\n    draftId,\n    sidebarMode,\n    // Issue-create mode is route-independent and derived from composer state.\n    isCreateMode: false,\n    isWorkspaceCreateMode,\n    hasInvalidWorkspaceCreateDraftId,\n    isPanelOpen: !!projectDestination && projectDestination.kind !== 'project',\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/scriptPlaceholders.ts",
    "content": "interface ScriptPlaceholders {\n  setup: string;\n  dev: string;\n  cleanup: string;\n  archive: string;\n}\n\ninterface ScriptPlaceholderStrategy {\n  getPlaceholders(): ScriptPlaceholders;\n}\n\nclass WindowsScriptPlaceholderStrategy implements ScriptPlaceholderStrategy {\n  getPlaceholders(): ScriptPlaceholders {\n    return {\n      setup: `@echo off\nnpm install\nREM Add any setup commands here...`,\n      dev: `@echo off\nnpm run dev\nREM Add dev server start command here...`,\n      cleanup: `@echo off\nREM Add cleanup commands here...\nREM This runs after coding agent execution - only if changes were made`,\n      archive: `@echo off\nREM Add archive commands here...\nREM This runs when the workspace is archived`,\n    };\n  }\n}\n\nclass UnixScriptPlaceholderStrategy implements ScriptPlaceholderStrategy {\n  getPlaceholders(): ScriptPlaceholders {\n    return {\n      setup: `npm install\n# Add any setup commands here...`,\n      dev: `npm run dev\n# Add dev server start command here...`,\n      cleanup: `# Add cleanup commands here...\n# This runs after coding agent execution - only if changes were made`,\n      archive: `# Add archive commands here...\n# This runs when the workspace is archived`,\n    };\n  }\n}\n\nclass ScriptPlaceholderContext {\n  private strategy: ScriptPlaceholderStrategy;\n\n  constructor(strategy: ScriptPlaceholderStrategy) {\n    this.strategy = strategy;\n  }\n\n  setStrategy(strategy: ScriptPlaceholderStrategy): void {\n    this.strategy = strategy;\n  }\n\n  getPlaceholders(): ScriptPlaceholders {\n    return this.strategy.getPlaceholders();\n  }\n}\n\nexport function createScriptPlaceholderStrategy(\n  osType: string\n): ScriptPlaceholderStrategy {\n  if (osType.toLowerCase().includes('windows')) {\n    return new WindowsScriptPlaceholderStrategy();\n  }\n  return new UnixScriptPlaceholderStrategy();\n}\n\nexport { ScriptPlaceholderContext, type ScriptPlaceholders };\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/searchTagsAndFiles.ts",
    "content": "import { searchApi, tagsApi } from '@/shared/lib/api';\nimport type { SearchResult, Tag } from 'shared/types';\n\ninterface FileSearchResult extends SearchResult {\n  name: string;\n}\n\nexport interface SearchResultItem {\n  type: 'tag' | 'file';\n  tag?: Tag;\n  file?: FileSearchResult;\n}\n\nexport interface SearchOptions {\n  repoIds?: string[];\n  projectId?: string;\n}\n\nexport async function searchTagsAndFiles(\n  query: string,\n  options?: SearchOptions\n): Promise<SearchResultItem[]> {\n  const results: SearchResultItem[] = [];\n\n  // Fetch all tags and filter client-side\n  const tags = await tagsApi.list();\n  const filteredTags = tags.filter((tag) =>\n    tag.tag_name.toLowerCase().includes(query.toLowerCase())\n  );\n  results.push(...filteredTags.map((tag) => ({ type: 'tag' as const, tag })));\n\n  // Fetch files - prefer repo-scoped if available\n  if (query.length > 0) {\n    let fileResults: SearchResult[] = [];\n    if (options?.repoIds && options.repoIds.length > 0) {\n      fileResults = await searchApi.searchFiles(options.repoIds, query);\n    }\n\n    if (fileResults.length > 0) {\n      const fileSearchResults: FileSearchResult[] = fileResults.map((item) => ({\n        ...item,\n        name: item.path.split('/').pop() || item.path,\n      }));\n      results.push(\n        ...fileSearchResults.map((file) => ({ type: 'file' as const, file }))\n      );\n    }\n  }\n\n  return results;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/streamJsonPatchEntries.ts",
    "content": "// streamJsonPatchEntries.ts - WebSocket JSON patch streaming utility\nimport { produce } from 'immer';\nimport type { Operation } from 'rfc6902';\nimport { applyUpsertPatch } from '@/shared/lib/jsonPatch';\nimport { openLocalApiWebSocket } from '@/shared/lib/localApiTransport';\n\ntype PatchContainer<E = unknown> = { entries: E[] };\n\nexport interface StreamOptions<E = unknown> {\n  initial?: PatchContainer<E>;\n  /** called after each successful patch application */\n  onEntries?: (entries: E[]) => void;\n  onConnect?: () => void;\n  onError?: (err: unknown) => void;\n  /** called once when a \"finished\" event is received */\n  onFinished?: (entries: E[]) => void;\n}\n\ninterface StreamController<E = unknown> {\n  /** Current entries array (immutable snapshot) */\n  getEntries(): E[];\n  /** Full { entries } snapshot */\n  getSnapshot(): PatchContainer<E>;\n  /** Best-effort connection state */\n  isConnected(): boolean;\n  /** Subscribe to updates; returns an unsubscribe function */\n  onChange(cb: (entries: E[]) => void): () => void;\n  /** Close the stream */\n  close(): void;\n}\n\n/**\n * Connect to a WebSocket endpoint that emits JSON messages containing:\n *   {\"JsonPatch\": [{\"op\": \"add\", \"path\": \"/entries/0\", \"value\": {...}}, ...]}\n *   {\"Finished\": \"\"}\n *\n * Maintains an in-memory { entries: [] } snapshot and returns a controller.\n *\n * Messages are batched per animation frame and applied using immer for\n * structural sharing, avoiding a full deep clone on every message.\n */\nexport function streamJsonPatchEntries<E = unknown>(\n  url: string,\n  opts: StreamOptions<E> = {}\n): StreamController<E> {\n  let connected = false;\n  let closed = false;\n  let ws: WebSocket | null = null;\n  let snapshot: PatchContainer<E> = structuredClone(\n    opts.initial ?? ({ entries: [] } as PatchContainer<E>)\n  );\n\n  const subscribers = new Set<(entries: E[]) => void>();\n  if (opts.onEntries) subscribers.add(opts.onEntries);\n\n  // --- rAF batching state ---\n  let pendingOps: Operation[] = [];\n  let rafId: number | null = null;\n\n  const notify = () => {\n    for (const cb of subscribers) {\n      try {\n        cb(snapshot.entries);\n      } catch {\n        /* swallow subscriber errors */\n      }\n    }\n  };\n\n  const flush = () => {\n    rafId = null;\n    if (pendingOps.length === 0) return;\n\n    const ops = dedupeOps(pendingOps);\n    pendingOps = [];\n\n    snapshot = produce(snapshot, (draft) => {\n      applyUpsertPatch(draft, ops);\n    });\n    notify();\n  };\n\n  const handleMessage = (event: MessageEvent) => {\n    try {\n      const msg = JSON.parse(event.data);\n\n      // Handle JsonPatch messages — accumulate ops for next rAF flush\n      if (msg.JsonPatch) {\n        const raw = msg.JsonPatch as Operation[];\n        pendingOps.push(...raw);\n        if (rafId === null) {\n          rafId = requestAnimationFrame(flush);\n        }\n      }\n\n      // Handle Finished messages — flush synchronously before closing\n      if (msg.finished !== undefined) {\n        if (rafId !== null) {\n          cancelAnimationFrame(rafId);\n        }\n        flush();\n        opts.onFinished?.(snapshot.entries);\n        ws?.close();\n      }\n    } catch (err) {\n      opts.onError?.(err);\n    }\n  };\n\n  void (async () => {\n    try {\n      const opened = await openLocalApiWebSocket(url);\n\n      if (closed) {\n        opened.close();\n        return;\n      }\n\n      ws = opened;\n      ws.addEventListener('open', () => {\n        connected = true;\n        opts.onConnect?.();\n      });\n\n      ws.addEventListener('message', handleMessage);\n\n      ws.addEventListener('error', (err) => {\n        connected = false;\n        opts.onError?.(err);\n      });\n\n      ws.addEventListener('close', () => {\n        connected = false;\n        if (rafId !== null) {\n          cancelAnimationFrame(rafId);\n          rafId = null;\n        }\n      });\n    } catch (error) {\n      if (!closed) {\n        opts.onError?.(error);\n      }\n    }\n  })();\n\n  return {\n    getEntries(): E[] {\n      return snapshot.entries;\n    },\n    getSnapshot(): PatchContainer<E> {\n      return snapshot;\n    },\n    isConnected(): boolean {\n      return connected;\n    },\n    onChange(cb: (entries: E[]) => void): () => void {\n      subscribers.add(cb);\n      // push current state immediately\n      cb(snapshot.entries);\n      return () => subscribers.delete(cb);\n    },\n    close(): void {\n      closed = true;\n      if (rafId !== null) {\n        cancelAnimationFrame(rafId);\n        rafId = null;\n      }\n      ws?.close();\n      subscribers.clear();\n      connected = false;\n    },\n  };\n}\n\n/**\n * Dedupe multiple ops that touch the same path within a batch.\n * Last write for a path wins, while preserving the overall left-to-right\n * order of the *kept* final operations.\n *\n * Example:\n *   add /entries/4, replace /entries/4  -> keep only the final replace\n */\nfunction dedupeOps(ops: Operation[]): Operation[] {\n  const lastIndexByPath = new Map<string, number>();\n  ops.forEach((op, i) => lastIndexByPath.set(op.path, i));\n\n  // Keep only the last op for each path, in ascending order of their final index\n  const keptIndices = [...lastIndexByPath.values()].sort((a, b) => a - b);\n  return keptIndices.map((i) => ops[i]!);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/string.ts",
    "content": "/**\n * Converts SCREAMING_SNAKE_CASE to \"Pretty Case\"\n * @param value - The string to convert\n * @returns Formatted string with proper capitalization\n */\nexport const toPrettyCase = (value: string): string => {\n  return value\n    .split('_')\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n    .join(' ');\n};\n\n/**\n * Generates a pretty project name from a file path\n * Converts directory names like \"my-awesome-project\" to \"My Awesome Project\"\n * @param path - The file path to extract name from\n * @returns Formatted project name\n */\nexport const generateProjectNameFromPath = (path: string): string => {\n  const dirName = path.split('/').filter(Boolean).pop() || '';\n  return dirName.replace(/[-_]/g, ' ').replace(/\\b\\w/g, (l) => l.toUpperCase());\n};\n\n/**\n * Removes a single trailing newline sequence from a string.\n * Handles CRLF/CR/LF endings while leaving other trailing whitespace intact.\n */\nexport const stripLineEnding = (value: string): string => {\n  return value.replace(/(?:\\r\\n|\\r|\\n)$/, '');\n};\n\n/**\n * Splits a string by newlines and returns an array of lines.\n * Handles CRLF, CR, and LF line endings.\n */\nexport const splitLines = (value: string): string[] => {\n  return value.split(/\\r\\n|\\r|\\n/);\n};\n\n/**\n * Splits a message into title (max 100 chars) and description.\n * - First line becomes the title (truncated at word boundary if > 100 chars)\n * - Overflow from first line + remaining lines become description\n */\nexport function splitMessageToTitleDescription(message: string): {\n  title: string;\n  description: string | null;\n} {\n  const trimmed = message.trim();\n  const lines = trimmed.split('\\n');\n  const firstLine = lines[0];\n  const restOfLines = lines.slice(1).join('\\n').trim();\n\n  if (firstLine.length <= 100) {\n    return {\n      title: firstLine,\n      description: restOfLines || null,\n    };\n  }\n\n  // Find word boundary in first 100 chars\n  const truncated = firstLine.substring(0, 100);\n  const lastSpace = truncated.lastIndexOf(' ');\n\n  if (lastSpace > 50) {\n    // Split at word boundary (if at least half the title is preserved)\n    const title = truncated.substring(0, lastSpace);\n    const overflow = firstLine.substring(lastSpace + 1);\n    return {\n      title,\n      description: restOfLines ? `${overflow}\\n\\n${restOfLines}` : overflow,\n    };\n  }\n\n  // Fall back to character split\n  const overflow = firstLine.substring(100);\n  return {\n    title: truncated,\n    description: restOfLines ? `${overflow}\\n\\n${restOfLines}` : overflow,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/terminalTheme.ts",
    "content": "import type { ITheme } from '@xterm/xterm';\n\n/**\n * Convert HSL CSS variable value (e.g., \"210 40% 98%\") to hex color.\n */\nfunction hslToHex(hslValue: string): string {\n  const trimmed = hslValue.trim();\n  if (!trimmed) return '#000000';\n\n  // Parse \"H S% L%\" format (space-separated, S and L have % suffix)\n  const parts = trimmed.split(/\\s+/);\n  if (parts.length < 3) return '#000000';\n\n  const h = parseFloat(parts[0]) / 360;\n  const s = parseFloat(parts[1]) / 100;\n  const l = parseFloat(parts[2]) / 100;\n\n  // HSL to RGB conversion\n  let r: number, g: number, b: number;\n\n  if (s === 0) {\n    r = g = b = l;\n  } else {\n    const hue2rgb = (p: number, q: number, t: number) => {\n      if (t < 0) t += 1;\n      if (t > 1) t -= 1;\n      if (t < 1 / 6) return p + (q - p) * 6 * t;\n      if (t < 1 / 2) return q;\n      if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n      return p;\n    };\n\n    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n    const p = 2 * l - q;\n    r = hue2rgb(p, q, h + 1 / 3);\n    g = hue2rgb(p, q, h);\n    b = hue2rgb(p, q, h - 1 / 3);\n  }\n\n  const toHex = (x: number) => {\n    const hex = Math.round(x * 255).toString(16);\n    return hex.length === 1 ? '0' + hex : hex;\n  };\n\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n}\n\n/**\n * Get the CSS variable value from the computed styles.\n */\nfunction getCssVariable(name: string): string {\n  return getComputedStyle(document.documentElement)\n    .getPropertyValue(name)\n    .trim();\n}\n\n/**\n * Build an xterm.js theme from CSS variables defined in index.css.\n * Uses --console-background and --console-foreground as the main colors,\n * and derives ANSI colors from a combination of theme-appropriate defaults.\n */\nexport function getTerminalTheme(): ITheme {\n  const background = getCssVariable('--bg-secondary');\n  const foreground = getCssVariable('--text-high');\n  const success = getCssVariable('--console-success');\n  const error = getCssVariable('--console-error');\n\n  // Detect if we're in dark mode by checking the class on html element\n  const isDark = document.documentElement.classList.contains('dark');\n\n  // Convert the main colors\n  const bgHex = hslToHex(background);\n  const fgHex = hslToHex(foreground);\n  const greenHex = hslToHex(success);\n  const redHex = hslToHex(error);\n\n  // Define ANSI palette based on light/dark mode\n  // These are carefully chosen to be readable on the respective backgrounds\n  if (isDark) {\n    return {\n      background: bgHex,\n      foreground: fgHex,\n      cursor: fgHex,\n      cursorAccent: bgHex,\n      selectionBackground: '#3d4966',\n      selectionForeground: fgHex,\n      black: '#1a1a1a',\n      red: redHex,\n      green: greenHex,\n      yellow: '#e0af68',\n      blue: '#7aa2f7',\n      magenta: '#bb9af7',\n      cyan: '#7dcfff',\n      white: '#c0caf5',\n      brightBlack: '#545c7e',\n      brightRed: redHex,\n      brightGreen: greenHex,\n      brightYellow: '#e0af68',\n      brightBlue: '#7aa2f7',\n      brightMagenta: '#bb9af7',\n      brightCyan: '#7dcfff',\n      brightWhite: fgHex,\n    };\n  } else {\n    // Light mode colors\n    return {\n      background: bgHex,\n      foreground: fgHex,\n      cursor: fgHex,\n      cursorAccent: bgHex,\n      selectionBackground: '#accef7',\n      selectionForeground: '#1a1a1a',\n      black: '#1a1a1a',\n      red: redHex,\n      green: greenHex,\n      yellow: '#946800',\n      blue: '#0550ae',\n      magenta: '#a626a4',\n      cyan: '#0e7490',\n      white: '#57606a',\n      brightBlack: '#4b5563',\n      brightRed: redHex,\n      brightGreen: greenHex,\n      brightYellow: '#7c5800',\n      brightBlue: '#0969da',\n      brightMagenta: '#8250df',\n      brightCyan: '#0891b2',\n      brightWhite: fgHex,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/theme.ts",
    "content": "import { ThemeMode } from 'shared/types';\n\n/**\n * Resolves the actual theme (light/dark) based on the theme mode setting.\n * Handles system theme detection properly.\n */\nexport function getActualTheme(\n  themeMode: ThemeMode | undefined\n): 'light' | 'dark' {\n  if (!themeMode || themeMode === ThemeMode.LIGHT) {\n    return 'light';\n  }\n\n  if (themeMode === ThemeMode.SYSTEM) {\n    // Check system preference\n    return window.matchMedia('(prefers-color-scheme: dark)').matches\n      ? 'dark'\n      : 'light';\n  }\n\n  // ThemeMode.DARK\n  return 'dark';\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/types.ts",
    "content": "import {\n  ExecutionProcess,\n  NormalizedEntry,\n  ExecutionProcessStatus,\n} from 'shared/types';\n\nexport type AttemptData = {\n  processes: ExecutionProcess[];\n  runningProcessDetails: Record<string, ExecutionProcess>;\n};\n\nexport interface ConversationEntryDisplayType {\n  entry: NormalizedEntry;\n  processId: string;\n  processPrompt?: string;\n  processStatus: ExecutionProcessStatus;\n  processIsRunning: boolean;\n  process: ExecutionProcess;\n  isFirstInProcess: boolean;\n  processIndex: number;\n  entryIndex: number;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\n// import { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n  // TODO: Re-enable twMerge after migration to tailwind v4\n  // Doesn't support de-duplicating custom classes, eg text-brand and text-base\n  // return twMerge(clsx(inputs));\n  return clsx(inputs);\n}\n\n/**\n * Play a sound file.  In the Tauri desktop app we use AudioContext (Web\n * Audio API) because `new Audio()` registers with macOS NowPlaying /\n * MediaRemote, triggering an \"access Apple Music\" TCC prompt.  In the\n * browser the standard HTMLAudioElement works fine.\n */\nexport async function playSound(url: string): Promise<void> {\n  if ('__TAURI_INTERNALS__' in window) {\n    const ctx = new AudioContext();\n    try {\n      const res = await fetch(url);\n      const buf = await res.arrayBuffer();\n      const audio = await ctx.decodeAudioData(buf);\n      const src = ctx.createBufferSource();\n      src.buffer = audio;\n      src.connect(ctx.destination);\n      src.start();\n      await new Promise<void>((resolve) => {\n        src.onended = () => resolve();\n      });\n    } finally {\n      await ctx.close();\n    }\n  } else {\n    const audio = new Audio(url);\n    await audio.play();\n  }\n}\n\nexport function formatFileSize(bytes: bigint | null | undefined): string {\n  if (!bytes) return '';\n  const num = Number(bytes);\n  if (num < 1024) return `${num} B`;\n  if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`;\n  return `${(num / (1024 * 1024)).toFixed(1)} MB`;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/virtuoso-modifiers.ts",
    "content": "import type { ScrollModifier } from '@virtuoso.dev/message-list';\n\nexport const INITIAL_TOP_ITEM = {\n  index: 'LAST' as const,\n  align: 'end' as const,\n};\n\nexport const InitialDataScrollModifier: ScrollModifier = {\n  type: 'item-location',\n  location: INITIAL_TOP_ITEM,\n  purgeItemSizes: true,\n};\n\nexport const ScrollToBottomModifier: ScrollModifier = {\n  type: 'item-location',\n  location: INITIAL_TOP_ITEM,\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/workspaceAttachments.ts",
    "content": "import type { AttachmentResponse } from 'shared/types';\nimport type { LocalAttachmentMetadata } from '@vibe/ui/components/WorkspaceContext';\n\nfunction escapeMarkdownLabel(value: string): string {\n  return value.replace(/[[\\]\\\\]/g, '\\\\$&');\n}\n\nexport function isImageMimeType(mimeType?: string | null): boolean {\n  return mimeType?.startsWith('image/') ?? false;\n}\n\nexport function buildAttachmentMarkdown(attachment: {\n  name: string;\n  src: string;\n  mimeType?: string | null;\n}): string {\n  const label = escapeMarkdownLabel(attachment.name);\n  if (isImageMimeType(attachment.mimeType)) {\n    return `![${label}](${attachment.src})`;\n  }\n  return `[${label}](${attachment.src})`;\n}\n\nexport function buildWorkspaceAttachmentMarkdown(attachment: {\n  original_name: string;\n  file_path: string;\n  mime_type?: string | null;\n}): string {\n  return buildAttachmentMarkdown({\n    name: attachment.original_name,\n    src: attachment.file_path,\n    mimeType: attachment.mime_type,\n  });\n}\n\nexport function toLocalAttachmentMetadata(\n  attachment: AttachmentResponse\n): LocalAttachmentMetadata {\n  return {\n    path: attachment.file_path,\n    proxy_url: `/api/attachments/${attachment.id}/file`,\n    file_name: attachment.original_name,\n    size_bytes: Number(attachment.size_bytes),\n    format: attachment.mime_type?.split('/')[1] ?? 'bin',\n    mime_type: attachment.mime_type ?? 'application/octet-stream',\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/workspaceCreateState.ts",
    "content": "import type { CreateModeInitialState } from '@/shared/types/createMode';\nimport type { DraftWorkspaceData } from 'shared/types';\nimport { ScratchType } from 'shared/types';\nimport type { AppRuntime } from '@/shared/hooks/useAppRuntime';\nimport { scratchApi } from '@/shared/lib/api';\nimport { localStorageScratchUpdate } from '@/shared/hooks/useLocalStorageScratch';\n\ninterface WorkspaceDefaultsLike {\n  preferredRepos?: CreateModeInitialState['preferredRepos'];\n  project_id?: string | null;\n}\n\ninterface LocalWorkspaceLike {\n  id: string;\n}\n\ninterface LinkedIssueSource {\n  id: string;\n  simple_id: string;\n  title: string;\n}\n\nexport const DEFAULT_WORKSPACE_CREATE_DRAFT_ID =\n  '00000000-0000-0000-0000-000000000001';\n\nexport function buildWorkspaceCreatePrompt(\n  title: string | null | undefined,\n  description: string | null | undefined\n): string | null {\n  const trimmedTitle = title?.trim();\n  if (!trimmedTitle) return null;\n\n  const trimmedDescription = description?.trim();\n  return trimmedDescription\n    ? `${trimmedTitle}\\n\\n${trimmedDescription}`\n    : trimmedTitle;\n}\n\nexport function buildLinkedIssueCreateState(\n  issue: LinkedIssueSource | null | undefined,\n  projectId: string\n): NonNullable<CreateModeInitialState['linkedIssue']> | null {\n  if (!issue) return null;\n  return {\n    issueId: issue.id,\n    simpleId: issue.simple_id,\n    title: issue.title,\n    remoteProjectId: projectId,\n  };\n}\n\nexport function buildWorkspaceCreateInitialState(args: {\n  prompt: string | null;\n  defaults?: WorkspaceDefaultsLike | null;\n  linkedIssue?: CreateModeInitialState['linkedIssue'];\n  executorConfig?: CreateModeInitialState['executorConfig'];\n}): CreateModeInitialState {\n  return {\n    initialPrompt: args.prompt,\n    preferredRepos: args.defaults?.preferredRepos ?? null,\n    project_id: args.defaults?.project_id ?? null,\n    linkedIssue: args.linkedIssue ?? null,\n    executorConfig: args.executorConfig ?? null,\n  };\n}\n\nexport function buildLocalWorkspaceIdSet(\n  activeWorkspaces: LocalWorkspaceLike[],\n  archivedWorkspaces: LocalWorkspaceLike[]\n): Set<string> {\n  return new Set([\n    ...activeWorkspaces.map((workspace) => workspace.id),\n    ...archivedWorkspaces.map((workspace) => workspace.id),\n  ]);\n}\n\nexport function toDraftWorkspaceData(\n  initialState: CreateModeInitialState\n): DraftWorkspaceData {\n  return {\n    message: initialState.initialPrompt ?? '',\n    repos:\n      initialState.preferredRepos?.map((repo) => ({\n        repo_id: repo.repo_id,\n        target_branch: repo.target_branch ?? '',\n      })) ?? [],\n    executor_config: initialState.executorConfig ?? null,\n    linked_issue: initialState.linkedIssue\n      ? {\n          issue_id: initialState.linkedIssue.issueId,\n          simple_id: initialState.linkedIssue.simpleId ?? '',\n          title: initialState.linkedIssue.title ?? '',\n          remote_project_id: initialState.linkedIssue.remoteProjectId,\n        }\n      : null,\n    attachments: [],\n  };\n}\n\nexport async function persistWorkspaceCreateDraft(\n  initialState: CreateModeInitialState,\n  draftId = DEFAULT_WORKSPACE_CREATE_DRAFT_ID,\n  runtime: AppRuntime = 'local'\n): Promise<string | null> {\n  const draftData = toDraftWorkspaceData(initialState);\n  const payload = {\n    type: 'DRAFT_WORKSPACE' as const,\n    data: draftData,\n  };\n\n  try {\n    if (runtime === 'remote') {\n      const didPersist = localStorageScratchUpdate(\n        ScratchType.DRAFT_WORKSPACE,\n        draftId,\n        {\n          payload,\n        }\n      );\n      if (!didPersist) {\n        throw new Error('Failed to persist create-workspace draft in storage');\n      }\n    } else {\n      await scratchApi.update(ScratchType.DRAFT_WORKSPACE, draftId, {\n        payload,\n      });\n    }\n    return draftId;\n  } catch (error) {\n    console.error('Failed to persist create-workspace draft:', error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/workspaceDefaults.ts",
    "content": "import { workspacesApi, repoApi } from '@/shared/lib/api';\nimport type { Workspace } from 'shared/remote-types';\nimport { getValidProjectRepoDefaults } from '@/shared/hooks/useProjectRepoDefaults';\n\nexport interface WorkspaceDefaults {\n  preferredRepos: Array<{ repo_id: string; target_branch: string | null }>;\n}\n\n/**\n * Fetches workspace creation defaults using a project-aware priority chain:\n * 1. Scratch project-repo defaults (if projectId provided and valid repos exist)\n * 2. Most recent workspace for the same project (if projectId provided)\n * 3. Globally most recent workspace\n * 4. null (no defaults)\n */\nexport async function getWorkspaceDefaults(\n  remoteWorkspaces: Workspace[],\n  localWorkspaceIds: Set<string>,\n  projectId?: string | null\n): Promise<WorkspaceDefaults | null> {\n  // Priority 1: Scratch project-repo defaults\n  if (projectId) {\n    try {\n      const allRepos = await repoApi.list();\n      const availableRepoIds = new Set(allRepos.map((r) => r.id));\n      const scratchDefaults = await getValidProjectRepoDefaults(\n        projectId,\n        availableRepoIds\n      );\n      if (scratchDefaults.length > 0) {\n        return {\n          preferredRepos: scratchDefaults.map((r) => ({\n            repo_id: r.repo_id,\n            target_branch: r.target_branch,\n          })),\n        };\n      }\n    } catch (err) {\n      console.warn('Failed to fetch project scratch defaults:', err);\n    }\n\n    // Priority 2: Most recent workspace for the same project\n    const projectRecent = remoteWorkspaces\n      .filter(\n        (w) =>\n          w.project_id === projectId &&\n          w.local_workspace_id !== null &&\n          localWorkspaceIds.has(w.local_workspace_id)\n      )\n      .sort(\n        (a, b) =>\n          new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()\n      )[0];\n\n    if (projectRecent?.local_workspace_id) {\n      try {\n        const [repos] = await Promise.all([\n          workspacesApi.getRepos(projectRecent.local_workspace_id),\n          workspacesApi.get(projectRecent.local_workspace_id),\n        ]);\n        return {\n          preferredRepos: repos.map((r) => ({\n            repo_id: r.id,\n            target_branch: r.target_branch,\n          })),\n        };\n      } catch (err) {\n        console.warn('Failed to fetch project workspace defaults:', err);\n      }\n    }\n  }\n\n  // Priority 3: Globally most recent workspace\n  const mostRecent = remoteWorkspaces\n    .filter(\n      (w) =>\n        w.local_workspace_id !== null &&\n        localWorkspaceIds.has(w.local_workspace_id)\n    )\n    .sort(\n      (a, b) =>\n        new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()\n    )[0];\n\n  if (!mostRecent?.local_workspace_id) {\n    return null;\n  }\n\n  try {\n    const [repos] = await Promise.all([\n      workspacesApi.getRepos(mostRecent.local_workspace_id),\n      workspacesApi.get(mostRecent.local_workspace_id),\n    ]);\n\n    return {\n      preferredRepos: repos.map((r) => ({\n        repo_id: r.id,\n        target_branch: r.target_branch,\n      })),\n    };\n  } catch (err) {\n    console.warn('Failed to fetch workspace defaults:', err);\n    return null;\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/lib/zoom.ts",
    "content": "const ZOOM_STORAGE_KEY = 'vk-zoom-level';\nconst DEFAULT_FONT_SIZE = 16;\nconst MIN_FONT_SIZE = 8;\nconst MAX_FONT_SIZE = 32;\nconst STEP = 1;\n\nfunction loadFontSize(): number {\n  try {\n    const stored = localStorage.getItem(ZOOM_STORAGE_KEY);\n    if (stored) {\n      const size = Number(stored);\n      if (size >= MIN_FONT_SIZE && size <= MAX_FONT_SIZE) return size;\n    }\n  } catch {\n    // localStorage may be unavailable\n  }\n  return DEFAULT_FONT_SIZE;\n}\n\nfunction saveFontSize(size: number): void {\n  try {\n    if (size === DEFAULT_FONT_SIZE) {\n      localStorage.removeItem(ZOOM_STORAGE_KEY);\n    } else {\n      localStorage.setItem(ZOOM_STORAGE_KEY, String(size));\n    }\n  } catch {\n    // localStorage may be unavailable\n  }\n}\n\nfunction applyFontSize(size: number): void {\n  document.documentElement.style.fontSize = `${size}px`;\n}\n\nlet currentFontSize = DEFAULT_FONT_SIZE;\n\nexport function zoomIn(): void {\n  currentFontSize = Math.min(currentFontSize + STEP, MAX_FONT_SIZE);\n  applyFontSize(currentFontSize);\n  saveFontSize(currentFontSize);\n}\n\nexport function zoomOut(): void {\n  currentFontSize = Math.max(currentFontSize - STEP, MIN_FONT_SIZE);\n  applyFontSize(currentFontSize);\n  saveFontSize(currentFontSize);\n}\n\nexport function zoomReset(): void {\n  currentFontSize = DEFAULT_FONT_SIZE;\n  applyFontSize(currentFontSize);\n  saveFontSize(currentFontSize);\n}\n\nexport function initZoom(): void {\n  currentFontSize = loadFontSize();\n  if (currentFontSize !== DEFAULT_FONT_SIZE) {\n    applyFontSize(currentFontSize);\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/ActionsProvider.tsx",
    "content": "import {\n  useContext,\n  useCallback,\n  useMemo,\n  useState,\n  type ReactNode,\n} from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport { useQueryClient } from '@tanstack/react-query';\nimport type { Workspace } from 'shared/types';\nimport { useOrganizationStore } from '@/shared/stores/useOrganizationStore';\nimport { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog';\nimport { getDestinationHostId } from '@/shared/lib/routes/appNavigation';\nimport {\n  buildKanbanIssueComposerKey,\n  openKanbanIssueComposer,\n  type ProjectIssueCreateOptions,\n} from '@/shared/stores/useKanbanIssueComposerStore';\nimport {\n  type ActionDefinition,\n  type ActionExecutorContext,\n  type ActionVisibilityContext,\n  type ProjectMutations,\n  ActionTargetType,\n  resolveLabel,\n  getActionLabel,\n} from '@/shared/types/actions';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport { UserContext } from '@/shared/hooks/useUserContext';\nimport { ProjectContext } from '@/shared/hooks/useProjectContext';\nimport { useDevServer } from '@/shared/hooks/useDevServer';\nimport { useLogsPanel } from '@/shared/hooks/useLogsPanel';\nimport { useLogStream } from '@/shared/hooks/useLogStream';\nimport { ActionsContext } from '@/shared/hooks/useActions';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useAppRuntime } from '@/shared/hooks/useAppRuntime';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\n\ninterface ActionsProviderProps {\n  children: ReactNode;\n}\n\nexport function ActionsProvider({ children }: ActionsProviderProps) {\n  const runtime = useAppRuntime();\n  const appNavigation = useAppNavigation();\n  const { projectId } = useParams({ strict: false });\n  const currentDestination = useCurrentAppDestination();\n  const hostId = useMemo(\n    () => getDestinationHostId(currentDestination),\n    [currentDestination]\n  );\n  const queryClient = useQueryClient();\n  // Get selected organization ID from store (for kanban context)\n  const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId);\n  // Get workspace context (ActionsProvider is nested inside WorkspaceProvider)\n  const { selectWorkspace, activeWorkspaces, workspaceId, workspace } =\n    useWorkspaceContext();\n  // Get remote workspaces (optional — not available on all routes)\n  const userCtx = useContext(UserContext);\n  const projectCtx = useContext(ProjectContext);\n  // Get dev server state\n  const { start, stop, runningDevServers } = useDevServer(workspaceId);\n\n  // Default status for issue creation based on current kanban tab\n  const [defaultCreateStatusId, setDefaultCreateStatusId] = useState<\n    string | undefined\n  >();\n\n  // Project mutations state (registered by components inside ProjectProvider)\n  const [projectMutations, setProjectMutations] =\n    useState<ProjectMutations | null>(null);\n\n  const registerProjectMutations = useCallback(\n    (mutations: ProjectMutations | null) => {\n      setProjectMutations(mutations);\n    },\n    []\n  );\n\n  // Navigate to create issue mode (URL-based navigation)\n  const navigateToCreateIssue = useCallback(\n    (options?: ProjectIssueCreateOptions) => {\n      if (!projectId) {\n        return;\n      }\n\n      openKanbanIssueComposer(\n        buildKanbanIssueComposerKey(hostId, projectId),\n        options\n      );\n    },\n    [projectId, hostId]\n  );\n\n  // Get logs panel state\n  const { logsPanelContent } = useLogsPanel();\n  const processId =\n    logsPanelContent?.type === 'process' ? logsPanelContent.processId : '';\n  const { logs: processLogs } = useLogStream(processId);\n\n  // Compute currentLogs based on content type\n  const currentLogs = useMemo(() => {\n    if (logsPanelContent?.type === 'tool') {\n      return logsPanelContent.content\n        .split('\\n')\n        .map((line) => ({ type: 'STDOUT' as const, content: line }));\n    }\n    if (logsPanelContent?.type === 'process') {\n      return processLogs;\n    }\n    return null;\n  }, [logsPanelContent, processLogs]);\n\n  // Open status selection dialog (uses dynamic import to avoid circular deps)\n  const openStatusSelection = useCallback(\n    async (projectId: string, issueIds: string[]) => {\n      const { ProjectSelectionDialog } = await import(\n        '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n      );\n      await ProjectSelectionDialog.show({\n        projectId,\n        selection: { type: 'status', issueIds },\n      });\n    },\n    []\n  );\n\n  // Open priority selection dialog (uses dynamic import to avoid circular deps)\n  const openPrioritySelection = useCallback(\n    async (projectId: string, issueIds: string[]) => {\n      const { ProjectSelectionDialog } = await import(\n        '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n      );\n      await ProjectSelectionDialog.show({\n        projectId,\n        selection: { type: 'priority', issueIds },\n      });\n    },\n    []\n  );\n\n  // Open assignee selection dialog (uses dynamic import to avoid circular deps)\n  const openAssigneeSelection = useCallback(\n    async (projectId: string, issueIds: string[], isCreateMode = false) => {\n      const { AssigneeSelectionDialog } = await import(\n        '@/shared/dialogs/kanban/AssigneeSelectionDialog'\n      );\n      await AssigneeSelectionDialog.show({ projectId, issueIds, isCreateMode });\n    },\n    []\n  );\n\n  // Open sub-issue selection dialog (uses dynamic import to avoid circular deps)\n  const openSubIssueSelection = useCallback(\n    async (\n      projectId: string,\n      parentIssueId: string,\n      mode: 'addChild' | 'setParent' = 'addChild'\n    ) => {\n      const { ProjectSelectionDialog } = await import(\n        '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n      );\n      return (await ProjectSelectionDialog.show({\n        projectId,\n        selection: { type: 'subIssue', parentIssueId, mode },\n      })) as { type: string } | undefined;\n    },\n    []\n  );\n\n  // Open workspace selection dialog (uses dynamic import to avoid circular deps)\n  const openWorkspaceSelection = useCallback(\n    async (projectId: string, issueId: string) => {\n      const { WorkspaceSelectionDialog } = await import(\n        '@/shared/dialogs/command-bar/WorkspaceSelectionDialog'\n      );\n      await WorkspaceSelectionDialog.show({ projectId, issueId });\n    },\n    []\n  );\n\n  // Open relationship selection dialog (uses dynamic import to avoid circular deps)\n  const openRelationshipSelection = useCallback(\n    async (\n      projectId: string,\n      issueId: string,\n      relationshipType: 'blocking' | 'related' | 'has_duplicate',\n      direction: 'forward' | 'reverse'\n    ) => {\n      const { ProjectSelectionDialog } = await import(\n        '@/shared/dialogs/command-bar/selections/ProjectSelectionDialog'\n      );\n      await ProjectSelectionDialog.show({\n        projectId,\n        selection: {\n          type: 'relationship',\n          issueId,\n          relationshipType,\n          direction,\n        },\n      });\n    },\n    []\n  );\n\n  // Build executor context from hooks\n  const executorContext = useMemo<ActionExecutorContext>(() => {\n    return {\n      runtime,\n      appNavigation,\n      queryClient,\n      selectWorkspace,\n      activeWorkspaces,\n      currentWorkspaceId: workspaceId ?? null,\n      containerRef: workspace?.container_ref ?? null,\n      runningDevServers,\n      startDevServer: start,\n      stopDevServer: stop,\n      currentLogs,\n      logsPanelContent,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      openSubIssueSelection,\n      openWorkspaceSelection,\n      openRelationshipSelection,\n      navigateToCreateIssue,\n      defaultCreateStatusId,\n      kanbanOrgId: selectedOrgId ?? undefined,\n      kanbanProjectId: projectId,\n      projectMutations: projectMutations ?? undefined,\n      remoteWorkspaces: (() => {\n        const userWs = userCtx?.workspaces ?? [];\n        const projectWs = projectCtx?.workspaces ?? [];\n        if (projectWs.length === 0) return userWs;\n        if (userWs.length === 0) return projectWs;\n        const seen = new Set(userWs.map((w) => w.id));\n        return [...userWs, ...projectWs.filter((w) => !seen.has(w.id))];\n      })(),\n    };\n  }, [\n    runtime,\n    queryClient,\n    selectWorkspace,\n    activeWorkspaces,\n    workspaceId,\n    workspace?.container_ref,\n    runningDevServers,\n    start,\n    stop,\n    currentLogs,\n    logsPanelContent,\n    openStatusSelection,\n    openPrioritySelection,\n    openAssigneeSelection,\n    openSubIssueSelection,\n    openWorkspaceSelection,\n    openRelationshipSelection,\n    navigateToCreateIssue,\n    defaultCreateStatusId,\n    selectedOrgId,\n    projectId,\n    projectMutations,\n    userCtx?.workspaces,\n    projectCtx?.workspaces,\n  ]);\n\n  // Main action executor with centralized target validation and error handling\n  const executeAction = useCallback(\n    async (\n      action: ActionDefinition,\n      workspaceId?: string,\n      repoIdOrProjectId?: string,\n      issueIds?: string[]\n    ): Promise<void> => {\n      try {\n        switch (action.requiresTarget) {\n          case ActionTargetType.NONE:\n            await action.execute(executorContext);\n            break;\n\n          case ActionTargetType.WORKSPACE:\n            if (!workspaceId) {\n              throw new Error(\n                `Action \"${action.id}\" requires a workspace target`\n              );\n            }\n            await action.execute(executorContext, workspaceId);\n            break;\n\n          case ActionTargetType.GIT:\n            if (!workspaceId || !repoIdOrProjectId) {\n              throw new Error(\n                `Action \"${action.id}\" requires both workspace and repository`\n              );\n            }\n            await action.execute(\n              executorContext,\n              workspaceId,\n              repoIdOrProjectId\n            );\n            break;\n\n          case ActionTargetType.ISSUE:\n            if (!repoIdOrProjectId || !issueIds || issueIds.length === 0) {\n              throw new Error(\n                `Action \"${action.id}\" requires project and issue selection`\n              );\n            }\n            await action.execute(executorContext, repoIdOrProjectId, issueIds);\n            break;\n        }\n      } catch (error) {\n        // Show error to user via alert dialog\n        ConfirmDialog.show({\n          title: 'Error',\n          message: error instanceof Error ? error.message : 'An error occurred',\n          confirmText: 'OK',\n          showCancelButton: false,\n          variant: 'destructive',\n        });\n      }\n    },\n    [executorContext]\n  );\n\n  // Get resolved label helper (supports dynamic labels via visibility context)\n  const getLabel = useCallback(\n    (\n      action: ActionDefinition,\n      workspace?: Workspace,\n      ctx?: ActionVisibilityContext\n    ) => {\n      if (ctx) {\n        return getActionLabel(action, ctx, workspace);\n      }\n      return resolveLabel(action, workspace);\n    },\n    []\n  );\n\n  const value = useMemo(\n    () => ({\n      executeAction,\n      getLabel,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      openSubIssueSelection,\n      openWorkspaceSelection,\n      openRelationshipSelection,\n      setDefaultCreateStatusId,\n      registerProjectMutations,\n      executorContext,\n    }),\n    [\n      executeAction,\n      getLabel,\n      openStatusSelection,\n      openPrioritySelection,\n      openAssigneeSelection,\n      openSubIssueSelection,\n      openWorkspaceSelection,\n      openRelationshipSelection,\n      registerProjectMutations,\n      executorContext,\n    ]\n  );\n\n  return (\n    <ActionsContext.Provider value={value}>{children}</ActionsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/ExecutionProcessesProvider.tsx",
    "content": "import React, { useMemo } from 'react';\nimport { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses';\nimport type { ExecutionProcess } from 'shared/types';\nimport {\n  ExecutionProcessesContext,\n  type ExecutionProcessesContextType,\n} from '@/shared/hooks/useExecutionProcessesContext';\n\nexport const ExecutionProcessesProvider: React.FC<{\n  sessionId?: string | undefined;\n  children: React.ReactNode;\n}> = ({ sessionId, children }) => {\n  const {\n    executionProcesses,\n    executionProcessesById,\n    isAttemptRunning,\n    isLoading,\n    isConnected,\n    error,\n  } = useExecutionProcesses(sessionId, { showSoftDeleted: true });\n\n  // Filter out dropped processes (server already filters by session)\n  const visible = useMemo(() => {\n    return executionProcesses.filter((p) => !p.dropped);\n  }, [executionProcesses]);\n\n  const executionProcessesByIdVisible = useMemo(() => {\n    const m: Record<string, ExecutionProcess> = {};\n    for (const p of visible) m[p.id] = p;\n    return m;\n  }, [visible]);\n\n  const isAttemptRunningVisible = useMemo(\n    () =>\n      visible.some(\n        (process) =>\n          (process.run_reason === 'codingagent' ||\n            process.run_reason === 'cleanupscript' ||\n            process.run_reason === 'archivescript') &&\n          process.status === 'running'\n      ),\n    [visible]\n  );\n\n  const value = useMemo<ExecutionProcessesContextType>(\n    () => ({\n      executionProcessesAll: executionProcesses,\n      executionProcessesByIdAll: executionProcessesById,\n      isAttemptRunningAll: isAttemptRunning,\n      executionProcessesVisible: visible,\n      executionProcessesByIdVisible,\n      isAttemptRunningVisible,\n      isLoading,\n      isConnected,\n      error,\n    }),\n    [\n      executionProcesses,\n      executionProcessesById,\n      isAttemptRunning,\n      visible,\n      executionProcessesByIdVisible,\n      isAttemptRunningVisible,\n      isLoading,\n      isConnected,\n      error,\n    ]\n  );\n\n  return (\n    <ExecutionProcessesContext.Provider value={value}>\n      {children}\n    </ExecutionProcessesContext.Provider>\n  );\n};\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/LogsPanelProvider.tsx",
    "content": "import {\n  useState,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  type ReactNode,\n} from 'react';\nimport type { LogsPanelContent } from '@/shared/types/actions';\nimport {\n  useWorkspacePanelState,\n  RIGHT_MAIN_PANEL_MODES,\n} from '@/shared/stores/useUiPreferencesStore';\nimport { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext';\nimport {\n  LogsPanelActionsContext,\n  LogsPanelContext,\n} from '@/shared/hooks/useLogsPanel';\n\ninterface LogsPanelProviderProps {\n  children: ReactNode;\n}\n\nexport function LogsPanelProvider({ children }: LogsPanelProviderProps) {\n  const { workspaceId, isCreateMode } = useWorkspaceContext();\n  const { rightMainPanelMode, setRightMainPanelMode } = useWorkspacePanelState(\n    isCreateMode ? undefined : workspaceId\n  );\n  const rightMainPanelModeRef = useRef(rightMainPanelMode);\n  rightMainPanelModeRef.current = rightMainPanelMode;\n  const [logsPanelContent, setLogsPanelContent] =\n    useState<LogsPanelContent | null>(null);\n  const [logSearchQuery, setLogSearchQuery] = useState('');\n  const [logMatchIndices, setLogMatchIndices] = useState<number[]>([]);\n  const [logCurrentMatchIdx, setLogCurrentMatchIdx] = useState(0);\n\n  const isTerminalExpanded = logsPanelContent?.type === 'terminal';\n\n  const logContentId =\n    logsPanelContent?.type === 'process'\n      ? logsPanelContent.processId\n      : logsPanelContent?.type === 'tool'\n        ? logsPanelContent.toolName\n        : null;\n\n  useEffect(() => {\n    setLogSearchQuery('');\n    setLogCurrentMatchIdx(0);\n  }, [logContentId]);\n\n  useEffect(() => {\n    setLogCurrentMatchIdx(0);\n  }, [logSearchQuery]);\n\n  // Collapse terminal when switching away from Logs panel mode\n  useEffect(() => {\n    if (\n      rightMainPanelMode !== RIGHT_MAIN_PANEL_MODES.LOGS &&\n      isTerminalExpanded\n    ) {\n      setLogsPanelContent(null);\n    }\n  }, [rightMainPanelMode, isTerminalExpanded]);\n\n  const handleLogPrevMatch = useCallback(() => {\n    if (logMatchIndices.length === 0) return;\n    setLogCurrentMatchIdx((prev) =>\n      prev > 0 ? prev - 1 : logMatchIndices.length - 1\n    );\n  }, [logMatchIndices.length]);\n\n  const handleLogNextMatch = useCallback(() => {\n    if (logMatchIndices.length === 0) return;\n    setLogCurrentMatchIdx((prev) =>\n      prev < logMatchIndices.length - 1 ? prev + 1 : 0\n    );\n  }, [logMatchIndices.length]);\n\n  const viewProcessInPanel = useCallback(\n    (processId: string) => {\n      if (rightMainPanelModeRef.current !== RIGHT_MAIN_PANEL_MODES.LOGS) {\n        setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS);\n      }\n      setLogsPanelContent({ type: 'process', processId });\n    },\n    [setRightMainPanelMode]\n  );\n\n  const viewToolContentInPanel = useCallback(\n    (toolName: string, content: string, command?: string) => {\n      if (rightMainPanelModeRef.current !== RIGHT_MAIN_PANEL_MODES.LOGS) {\n        setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS);\n      }\n      setLogsPanelContent({ type: 'tool', toolName, content, command });\n    },\n    [setRightMainPanelMode]\n  );\n\n  const expandTerminal = useCallback(() => {\n    if (rightMainPanelModeRef.current !== RIGHT_MAIN_PANEL_MODES.LOGS) {\n      setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS);\n    }\n    setLogsPanelContent({ type: 'terminal' });\n  }, [setRightMainPanelMode]);\n\n  const collapseTerminal = useCallback(() => {\n    setLogsPanelContent(null);\n  }, []);\n\n  const actionsValue = useMemo(\n    () => ({\n      viewProcessInPanel,\n      viewToolContentInPanel,\n      expandTerminal,\n      collapseTerminal,\n    }),\n    [\n      viewProcessInPanel,\n      viewToolContentInPanel,\n      expandTerminal,\n      collapseTerminal,\n    ]\n  );\n\n  const value = useMemo(\n    () => ({\n      logsPanelContent,\n      logSearchQuery,\n      logMatchIndices,\n      logCurrentMatchIdx,\n      setLogSearchQuery,\n      setLogMatchIndices,\n      handleLogPrevMatch,\n      handleLogNextMatch,\n      viewProcessInPanel,\n      viewToolContentInPanel,\n      expandTerminal,\n      collapseTerminal,\n      isTerminalExpanded,\n    }),\n    [\n      logsPanelContent,\n      logSearchQuery,\n      logMatchIndices,\n      logCurrentMatchIdx,\n      handleLogPrevMatch,\n      handleLogNextMatch,\n      viewProcessInPanel,\n      viewToolContentInPanel,\n      expandTerminal,\n      collapseTerminal,\n      isTerminalExpanded,\n    ]\n  );\n\n  return (\n    <LogsPanelActionsContext.Provider value={actionsValue}>\n      <LogsPanelContext.Provider value={value}>\n        {children}\n      </LogsPanelContext.Provider>\n    </LogsPanelActionsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/SyncErrorProvider.tsx",
    "content": "import {\n  useState,\n  useCallback,\n  useMemo,\n  useEffect,\n  type ReactNode,\n} from 'react';\nimport type { SyncError } from '@/shared/lib/electric/types';\nimport {\n  SyncErrorContext,\n  type StreamError,\n  type SyncErrorContextValue,\n} from '@/shared/hooks/useSyncErrorContext';\n\ninterface SyncErrorProviderProps {\n  children: ReactNode;\n}\n\nexport function SyncErrorProvider({ children }: SyncErrorProviderProps) {\n  const [errorsMap, setErrorsMap] = useState<Map<string, StreamError>>(\n    () => new Map()\n  );\n\n  const registerError = useCallback(\n    (\n      streamId: string,\n      tableName: string,\n      error: SyncError,\n      retry: () => void\n    ) => {\n      setErrorsMap((prev) => {\n        const next = new Map(prev);\n        next.set(streamId, { streamId, tableName, error, retry });\n        return next;\n      });\n    },\n    []\n  );\n\n  const clearError = useCallback((streamId: string) => {\n    setErrorsMap((prev) => {\n      if (!prev.has(streamId)) return prev;\n      const next = new Map(prev);\n      next.delete(streamId);\n      return next;\n    });\n  }, []);\n\n  const errors = useMemo(() => Array.from(errorsMap.values()), [errorsMap]);\n\n  const hasErrors = errors.length > 0;\n\n  const retryAll = useCallback(() => {\n    for (const streamError of errors) {\n      streamError.retry();\n    }\n  }, [errors]);\n\n  // Auto-retry all failed streams when tab becomes visible\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === 'visible' && errorsMap.size > 0) {\n        for (const streamError of errorsMap.values()) {\n          streamError.retry();\n        }\n      }\n    };\n\n    document.addEventListener('visibilitychange', handleVisibilityChange);\n    return () => {\n      document.removeEventListener('visibilitychange', handleVisibilityChange);\n    };\n  }, [errorsMap]);\n\n  const value = useMemo<SyncErrorContextValue>(\n    () => ({\n      errors,\n      hasErrors,\n      registerError,\n      clearError,\n      retryAll,\n    }),\n    [errors, hasErrors, registerError, clearError, retryAll]\n  );\n\n  return (\n    <SyncErrorContext.Provider value={value}>\n      {children}\n    </SyncErrorContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/TerminalProvider.tsx",
    "content": "import { useReducer, useMemo, useCallback, useRef, ReactNode } from 'react';\nimport type { Terminal } from '@xterm/xterm';\nimport type { FitAddon } from '@xterm/addon-fit';\nimport {\n  TerminalContext,\n  type TerminalTab,\n  type TerminalInstance,\n} from '@/shared/hooks/useTerminal';\nimport { openLocalApiWebSocket } from '@/shared/lib/localApiTransport';\n\ninterface TerminalConnection {\n  ws: WebSocket;\n  send: (data: string) => void;\n  resize: (cols: number, rows: number) => void;\n}\n\ninterface TerminalState {\n  tabsByWorkspace: Record<string, TerminalTab[]>;\n  activeTabByWorkspace: Record<string, string | null>;\n}\n\ntype TerminalAction =\n  | { type: 'CREATE_TAB'; workspaceId: string; cwd: string }\n  | { type: 'CLOSE_TAB'; workspaceId: string; tabId: string }\n  | { type: 'SET_ACTIVE_TAB'; workspaceId: string; tabId: string }\n  | {\n      type: 'UPDATE_TAB_TITLE';\n      workspaceId: string;\n      tabId: string;\n      title: string;\n    }\n  | { type: 'CLEAR_WORKSPACE_TABS'; workspaceId: string };\n\nfunction generateTabId(): string {\n  return `term-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n}\n\nfunction encodeBase64(str: string): string {\n  const bytes = new TextEncoder().encode(str);\n  const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join('');\n  return btoa(binString);\n}\n\nfunction decodeBase64(base64: string): string {\n  const binString = atob(base64);\n  const bytes = Uint8Array.from(binString, (c) => c.codePointAt(0)!);\n  return new TextDecoder().decode(bytes);\n}\n\nfunction terminalReducer(\n  state: TerminalState,\n  action: TerminalAction\n): TerminalState {\n  switch (action.type) {\n    case 'CREATE_TAB': {\n      const { workspaceId, cwd } = action;\n      const existingTabs = state.tabsByWorkspace[workspaceId] || [];\n      const newTab: TerminalTab = {\n        id: generateTabId(),\n        title: `Terminal ${existingTabs.length + 1}`,\n        workspaceId,\n        cwd,\n      };\n      return {\n        ...state,\n        tabsByWorkspace: {\n          ...state.tabsByWorkspace,\n          [workspaceId]: [...existingTabs, newTab],\n        },\n        activeTabByWorkspace: {\n          ...state.activeTabByWorkspace,\n          [workspaceId]: newTab.id,\n        },\n      };\n    }\n\n    case 'CLOSE_TAB': {\n      const { workspaceId, tabId } = action;\n      const tabs = state.tabsByWorkspace[workspaceId] || [];\n      const newTabs = tabs.filter((t) => t.id !== tabId);\n      const wasActive = state.activeTabByWorkspace[workspaceId] === tabId;\n      let newActiveTab = state.activeTabByWorkspace[workspaceId];\n\n      if (wasActive && newTabs.length > 0) {\n        const closedIndex = tabs.findIndex((t) => t.id === tabId);\n        const newIndex = Math.min(closedIndex, newTabs.length - 1);\n        newActiveTab = newTabs[newIndex]?.id ?? null;\n      } else if (newTabs.length === 0) {\n        newActiveTab = null;\n      }\n\n      return {\n        ...state,\n        tabsByWorkspace: {\n          ...state.tabsByWorkspace,\n          [workspaceId]: newTabs,\n        },\n        activeTabByWorkspace: {\n          ...state.activeTabByWorkspace,\n          [workspaceId]: newActiveTab,\n        },\n      };\n    }\n\n    case 'SET_ACTIVE_TAB': {\n      const { workspaceId, tabId } = action;\n      return {\n        ...state,\n        activeTabByWorkspace: {\n          ...state.activeTabByWorkspace,\n          [workspaceId]: tabId,\n        },\n      };\n    }\n\n    case 'UPDATE_TAB_TITLE': {\n      const { workspaceId, tabId, title } = action;\n      const tabs = state.tabsByWorkspace[workspaceId] || [];\n      return {\n        ...state,\n        tabsByWorkspace: {\n          ...state.tabsByWorkspace,\n          [workspaceId]: tabs.map((t) =>\n            t.id === tabId ? { ...t, title } : t\n          ),\n        },\n      };\n    }\n\n    case 'CLEAR_WORKSPACE_TABS': {\n      const { workspaceId } = action;\n      const restTabs = Object.fromEntries(\n        Object.entries(state.tabsByWorkspace).filter(\n          ([key]) => key !== workspaceId\n        )\n      );\n      const restActive = Object.fromEntries(\n        Object.entries(state.activeTabByWorkspace).filter(\n          ([key]) => key !== workspaceId\n        )\n      );\n      return {\n        tabsByWorkspace: restTabs,\n        activeTabByWorkspace: restActive,\n      };\n    }\n\n    default:\n      return state;\n  }\n}\n\ninterface TerminalProviderProps {\n  children: ReactNode;\n}\n\nexport function TerminalProvider({ children }: TerminalProviderProps) {\n  const [state, dispatch] = useReducer(terminalReducer, {\n    tabsByWorkspace: {},\n    activeTabByWorkspace: {},\n  });\n\n  // Store terminal instances in a ref to persist across re-renders\n  const terminalInstancesRef = useRef<Map<string, TerminalInstance>>(new Map());\n\n  // Store WebSocket connections in a ref to persist across component remounts\n  const terminalConnectionsRef = useRef<Map<string, TerminalConnection>>(\n    new Map()\n  );\n\n  // Store callback refs for each connection to prevent stale closures\n  const connectionCallbacksRef = useRef<\n    Map<string, { onData: (data: string) => void; onExit?: () => void }>\n  >(new Map());\n\n  // Store reconnection state for each connection\n  const reconnectStateRef = useRef<\n    Map<\n      string,\n      {\n        endpoint: string;\n        retryCount: number;\n        retryTimer: ReturnType<typeof setTimeout> | null;\n        intentionallyClosed: boolean;\n      }\n    >\n  >(new Map());\n\n  const getTabsForWorkspace = useCallback(\n    (workspaceId: string): TerminalTab[] => {\n      return state.tabsByWorkspace[workspaceId] || [];\n    },\n    [state.tabsByWorkspace]\n  );\n\n  const getActiveTab = useCallback(\n    (workspaceId: string): TerminalTab | null => {\n      const activeId = state.activeTabByWorkspace[workspaceId];\n      if (!activeId) return null;\n      const tabs = state.tabsByWorkspace[workspaceId] || [];\n      return tabs.find((t) => t.id === activeId) || null;\n    },\n    [state.tabsByWorkspace, state.activeTabByWorkspace]\n  );\n\n  const createTab = useCallback((workspaceId: string, cwd: string) => {\n    dispatch({ type: 'CREATE_TAB', workspaceId, cwd });\n  }, []);\n\n  const closeTerminalConnection = useCallback((tabId: string) => {\n    // Mark as intentionally closed to prevent reconnection\n    const reconnectState = reconnectStateRef.current.get(tabId);\n    if (reconnectState) {\n      reconnectState.intentionallyClosed = true;\n      if (reconnectState.retryTimer) {\n        clearTimeout(reconnectState.retryTimer);\n      }\n      reconnectStateRef.current.delete(tabId);\n    }\n\n    const conn = terminalConnectionsRef.current.get(tabId);\n    if (conn) {\n      conn.ws.close();\n      terminalConnectionsRef.current.delete(tabId);\n    }\n    connectionCallbacksRef.current.delete(tabId);\n  }, []);\n\n  const closeTab = useCallback(\n    (workspaceId: string, tabId: string) => {\n      // Dispose the terminal instance when closing the tab\n      const instance = terminalInstancesRef.current.get(tabId);\n      if (instance) {\n        instance.terminal.dispose();\n        terminalInstancesRef.current.delete(tabId);\n      }\n      // Close the WebSocket connection\n      closeTerminalConnection(tabId);\n      dispatch({ type: 'CLOSE_TAB', workspaceId, tabId });\n    },\n    [closeTerminalConnection]\n  );\n\n  const setActiveTab = useCallback((workspaceId: string, tabId: string) => {\n    dispatch({ type: 'SET_ACTIVE_TAB', workspaceId, tabId });\n  }, []);\n\n  const updateTabTitle = useCallback(\n    (workspaceId: string, tabId: string, title: string) => {\n      dispatch({ type: 'UPDATE_TAB_TITLE', workspaceId, tabId, title });\n    },\n    []\n  );\n\n  const clearWorkspaceTabs = useCallback(\n    (workspaceId: string) => {\n      // Dispose all terminal instances for this workspace\n      const tabs = state.tabsByWorkspace[workspaceId] || [];\n      tabs.forEach((tab) => {\n        const instance = terminalInstancesRef.current.get(tab.id);\n        if (instance) {\n          instance.terminal.dispose();\n          terminalInstancesRef.current.delete(tab.id);\n        }\n        // Close WebSocket connections\n        closeTerminalConnection(tab.id);\n      });\n      dispatch({ type: 'CLEAR_WORKSPACE_TABS', workspaceId });\n    },\n    [state.tabsByWorkspace, closeTerminalConnection]\n  );\n\n  const registerTerminalInstance = useCallback(\n    (tabId: string, terminal: Terminal, fitAddon: FitAddon) => {\n      terminalInstancesRef.current.set(tabId, { terminal, fitAddon });\n    },\n    []\n  );\n\n  const getTerminalInstance = useCallback(\n    (tabId: string): TerminalInstance | null => {\n      return terminalInstancesRef.current.get(tabId) || null;\n    },\n    []\n  );\n\n  const unregisterTerminalInstance = useCallback((tabId: string) => {\n    terminalInstancesRef.current.delete(tabId);\n  }, []);\n\n  const createTerminalConnection = useCallback(\n    (\n      tabId: string,\n      endpoint: string,\n      onData: (data: string) => void,\n      onExit?: () => void\n    ) => {\n      // Close existing connection if any\n      const existing = terminalConnectionsRef.current.get(tabId);\n      if (existing) {\n        existing.ws.close();\n      }\n\n      // Store callbacks in ref so they can be updated without recreating connection\n      connectionCallbacksRef.current.set(tabId, { onData, onExit });\n\n      // Initialize or reset reconnection state\n      const existingReconnectState = reconnectStateRef.current.get(tabId);\n      if (existingReconnectState?.retryTimer) {\n        clearTimeout(existingReconnectState.retryTimer);\n      }\n      reconnectStateRef.current.set(tabId, {\n        endpoint,\n        retryCount: 0,\n        retryTimer: null,\n        intentionallyClosed: false,\n      });\n\n      const scheduleReconnect = () => {\n        const state = reconnectStateRef.current.get(tabId);\n        if (!state || state.intentionallyClosed) {\n          return;\n        }\n\n        const maxRetries = 6;\n        if (state.retryCount >= maxRetries) {\n          return;\n        }\n\n        const delay = Math.min(8000, 500 * Math.pow(2, state.retryCount));\n        state.retryCount += 1;\n        state.retryTimer = setTimeout(() => {\n          state.retryTimer = null;\n          connectWebSocket();\n        }, delay);\n      };\n\n      const connectWebSocket = () => {\n        const reconnectState = reconnectStateRef.current.get(tabId);\n        if (!reconnectState || reconnectState.intentionallyClosed) {\n          return;\n        }\n\n        void (async () => {\n          try {\n            const ws = await openLocalApiWebSocket(endpoint);\n            const state = reconnectStateRef.current.get(tabId);\n            if (!state || state.intentionallyClosed) {\n              ws.close();\n              return;\n            }\n\n            ws.onopen = () => {\n              // Reset retry count on successful connection\n              const latestState = reconnectStateRef.current.get(tabId);\n              if (latestState) {\n                latestState.retryCount = 0;\n              }\n            };\n\n            ws.onmessage = (event) => {\n              try {\n                const msg = JSON.parse(event.data);\n                const callbacks = connectionCallbacksRef.current.get(tabId);\n                if (msg.type === 'output' && msg.data && callbacks) {\n                  callbacks.onData(decodeBase64(msg.data));\n                } else if (msg.type === 'exit' && callbacks) {\n                  callbacks.onExit?.();\n                }\n              } catch {\n                // Ignore parse errors\n              }\n            };\n\n            ws.onerror = () => {\n              // Error will be followed by onclose, so we handle reconnection there\n            };\n\n            ws.onclose = (event) => {\n              const latestState = reconnectStateRef.current.get(tabId);\n              if (!latestState || latestState.intentionallyClosed) {\n                return;\n              }\n\n              // Don't reconnect on clean close (code 1000) or if shell exited\n              if (event.code === 1000 && event.wasClean) {\n                return;\n              }\n\n              scheduleReconnect();\n            };\n\n            const send = (data: string) => {\n              if (ws.readyState === WebSocket.OPEN) {\n                ws.send(\n                  JSON.stringify({ type: 'input', data: encodeBase64(data) })\n                );\n              }\n            };\n\n            const resize = (cols: number, rows: number) => {\n              if (ws.readyState === WebSocket.OPEN) {\n                ws.send(JSON.stringify({ type: 'resize', cols, rows }));\n              }\n            };\n\n            const connection: TerminalConnection = { ws, send, resize };\n            terminalConnectionsRef.current.set(tabId, connection);\n          } catch {\n            scheduleReconnect();\n          }\n        })();\n      };\n\n      connectWebSocket();\n\n      // Return functions that use the current connection\n      const send = (data: string) => {\n        const conn = terminalConnectionsRef.current.get(tabId);\n        conn?.send(data);\n      };\n\n      const resize = (cols: number, rows: number) => {\n        const conn = terminalConnectionsRef.current.get(tabId);\n        conn?.resize(cols, rows);\n      };\n\n      return { send, resize };\n    },\n    []\n  );\n\n  const getTerminalConnection = useCallback(\n    (tabId: string): TerminalConnection | null => {\n      return terminalConnectionsRef.current.get(tabId) || null;\n    },\n    []\n  );\n\n  const value = useMemo(\n    () => ({\n      getTabsForWorkspace,\n      getActiveTab,\n      createTab,\n      closeTab,\n      setActiveTab,\n      updateTabTitle,\n      clearWorkspaceTabs,\n      registerTerminalInstance,\n      getTerminalInstance,\n      unregisterTerminalInstance,\n      createTerminalConnection,\n      getTerminalConnection,\n    }),\n    [\n      getTabsForWorkspace,\n      getActiveTab,\n      createTab,\n      closeTab,\n      setActiveTab,\n      updateTabTitle,\n      clearWorkspaceTabs,\n      registerTerminalInstance,\n      getTerminalInstance,\n      unregisterTerminalInstance,\n      createTerminalConnection,\n      getTerminalConnection,\n    ]\n  );\n\n  return (\n    <TerminalContext.Provider value={value}>\n      {children}\n    </TerminalContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/WorkspaceProvider.tsx",
    "content": "import { ReactNode, useMemo, useCallback, useEffect } from 'react';\nimport { useParams } from '@tanstack/react-router';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useWorkspaces } from '@/shared/hooks/useWorkspaces';\nimport { workspaceSummaryKeys } from '@/shared/hooks/workspaceSummaryKeys';\nimport { useWorkspaceRecord } from '@/shared/hooks/useWorkspaceRecord';\nimport { useWorkspaceRepo } from '@/shared/hooks/useWorkspaceRepo';\nimport { useWorkspaceSessions } from '@/shared/hooks/useWorkspaceSessions';\nimport { useGitHubComments } from '@/shared/hooks/useGitHubComments';\nimport { useDiffStream } from '@/shared/hooks/useDiffStream';\nimport { workspacesApi } from '@/shared/lib/api';\nimport { useDiffViewStore } from '@/shared/stores/useDiffViewStore';\nimport type { DiffStats } from 'shared/types';\nimport { useAppNavigation } from '@/shared/hooks/useAppNavigation';\nimport { useCurrentAppDestination } from '@/shared/hooks/useCurrentAppDestination';\n\nimport {\n  WorkspaceContext,\n  WorkspaceDiffContext,\n} from '@/shared/hooks/useWorkspaceContext';\n\ninterface WorkspaceProviderProps {\n  children: ReactNode;\n}\n\nexport function WorkspaceProvider({ children }: WorkspaceProviderProps) {\n  const { workspaceId } = useParams({ strict: false });\n  const appNavigation = useAppNavigation();\n  const currentDestination = useCurrentAppDestination();\n  const queryClient = useQueryClient();\n\n  const isCreateMode = currentDestination?.kind === 'workspaces-create';\n\n  const {\n    workspaces: activeWorkspaces,\n    archivedWorkspaces,\n    isLoading: isLoadingList,\n  } = useWorkspaces();\n\n  const { data: workspace, isLoading: isLoadingWorkspace } = useWorkspaceRecord(\n    workspaceId,\n    { enabled: !!workspaceId && !isCreateMode }\n  );\n\n  const {\n    sessions,\n    selectedSession,\n    selectedSessionId,\n    selectSession,\n    selectLatestSession,\n    isLoading: isSessionsLoading,\n    isNewSessionMode,\n    startNewSession,\n  } = useWorkspaceSessions(workspaceId, { enabled: !isCreateMode });\n\n  const { repos, isLoading: isReposLoading } = useWorkspaceRepo(workspaceId, {\n    enabled: !isCreateMode,\n  });\n\n  // TODO: Support multiple repos - currently only fetches comments from the primary repo.\n  const primaryRepoId = repos[0]?.id;\n\n  const currentWorkspaceSummary = activeWorkspaces.find(\n    (w) => w.id === workspaceId\n  );\n  const hasPrAttached = !!currentWorkspaceSummary?.prStatus;\n\n  const {\n    gitHubComments,\n    isGitHubCommentsLoading,\n    showGitHubComments,\n    setShowGitHubComments,\n    getGitHubCommentsForFile,\n    getGitHubCommentCountForFile,\n    getFilesWithGitHubComments,\n    getFirstCommentLineForFile,\n  } = useGitHubComments({\n    workspaceId,\n    repoId: primaryRepoId,\n    enabled: !isCreateMode && hasPrAttached,\n  });\n\n  const { diffs } = useDiffStream(workspaceId ?? null, !isCreateMode);\n\n  const diffPaths = useMemo(\n    () =>\n      new Set(diffs.map((d) => d.newPath || d.oldPath || '').filter(Boolean)),\n    [diffs]\n  );\n\n  useEffect(() => {\n    useDiffViewStore.getState().setDiffPaths(Array.from(diffPaths));\n    return () => useDiffViewStore.getState().setDiffPaths([]);\n  }, [diffPaths]);\n\n  const diffStats: DiffStats = useMemo(\n    () => ({\n      files_changed: diffs.length,\n      lines_added: diffs.reduce((sum, d) => sum + (d.additions ?? 0), 0),\n      lines_removed: diffs.reduce((sum, d) => sum + (d.deletions ?? 0), 0),\n    }),\n    [diffs]\n  );\n\n  const isLoading = isLoadingList || isLoadingWorkspace;\n\n  useEffect(() => {\n    if (!workspaceId || isCreateMode) return;\n\n    workspacesApi\n      .markSeen(workspaceId)\n      .then(() => {\n        queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });\n      })\n      .catch((error) => {\n        console.warn('Failed to mark workspace as seen:', error);\n      });\n  }, [workspaceId, isCreateMode, queryClient]);\n\n  const selectWorkspace = useCallback(\n    (id: string) => {\n      appNavigation.goToWorkspace(id);\n    },\n    [appNavigation]\n  );\n\n  const navigateToCreate = useMemo(\n    () => () => {\n      appNavigation.goToWorkspacesCreate();\n    },\n    [appNavigation]\n  );\n\n  const coreValue = useMemo(\n    () => ({\n      workspaceId,\n      workspace,\n      activeWorkspaces,\n      archivedWorkspaces,\n      isLoading,\n      isCreateMode,\n      selectWorkspace,\n      navigateToCreate,\n      sessions,\n      selectedSession,\n      selectedSessionId,\n      selectSession,\n      selectLatestSession,\n      isSessionsLoading,\n      isNewSessionMode,\n      startNewSession,\n      repos,\n      isReposLoading,\n    }),\n    [\n      workspaceId,\n      workspace,\n      activeWorkspaces,\n      archivedWorkspaces,\n      isLoading,\n      isCreateMode,\n      selectWorkspace,\n      navigateToCreate,\n      sessions,\n      selectedSession,\n      selectedSessionId,\n      selectSession,\n      selectLatestSession,\n      isSessionsLoading,\n      isNewSessionMode,\n      startNewSession,\n      repos,\n      isReposLoading,\n    ]\n  );\n\n  const diffValue = useMemo(\n    () => ({\n      diffs,\n      diffPaths,\n      diffStats,\n      gitHubComments,\n      isGitHubCommentsLoading,\n      showGitHubComments,\n      setShowGitHubComments,\n      getGitHubCommentsForFile,\n      getGitHubCommentCountForFile,\n      getFilesWithGitHubComments,\n      getFirstCommentLineForFile,\n    }),\n    [\n      diffs,\n      diffPaths,\n      diffStats,\n      gitHubComments,\n      isGitHubCommentsLoading,\n      showGitHubComments,\n      setShowGitHubComments,\n      getGitHubCommentsForFile,\n      getGitHubCommentCountForFile,\n      getFilesWithGitHubComments,\n      getFirstCommentLineForFile,\n    ]\n  );\n\n  return (\n    <WorkspaceContext.Provider value={coreValue}>\n      <WorkspaceDiffContext.Provider value={diffValue}>\n        {children}\n      </WorkspaceDiffContext.Provider>\n    </WorkspaceContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/auth/LocalAuthProvider.tsx",
    "content": "import { useMemo, type ReactNode } from 'react';\nimport {\n  AuthContext,\n  type AuthContextValue,\n} from '@/shared/hooks/auth/useAuth';\nimport { useUserSystem } from '@/shared/hooks/useUserSystem';\n\ninterface LocalAuthProviderProps {\n  children: ReactNode;\n}\n\nexport function LocalAuthProvider({ children }: LocalAuthProviderProps) {\n  const { loginStatus } = useUserSystem();\n\n  const value = useMemo<AuthContextValue>(\n    () => ({\n      isSignedIn: loginStatus?.status === 'loggedin',\n      isLoaded: loginStatus !== null,\n      userId:\n        loginStatus?.status === 'loggedin' ? loginStatus.profile.user_id : null,\n    }),\n    [loginStatus]\n  );\n\n  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/remote/OrgProvider.tsx",
    "content": "import { useMemo, useCallback, type ReactNode } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport {\n  PROJECTS_SHAPE,\n  PROJECT_MUTATION,\n  type Project,\n} from 'shared/remote-types';\nimport type { OrganizationMemberWithProfile } from 'shared/types';\nimport { organizationsApi } from '@/shared/lib/api';\nimport { organizationKeys } from '@/shared/hooks/organizationKeys';\nimport { OrgContext, type OrgContextValue } from '@/shared/hooks/useOrgContext';\n\ninterface OrgProviderProps {\n  organizationId: string;\n  children: ReactNode;\n}\n\nexport function OrgProvider({ organizationId, children }: OrgProviderProps) {\n  const params = useMemo(\n    () => ({ organization_id: organizationId }),\n    [organizationId]\n  );\n  const enabled = Boolean(organizationId);\n\n  // Shape subscriptions (Electric sync)\n  const projectsResult = useShape(PROJECTS_SHAPE, params, {\n    enabled,\n    mutation: PROJECT_MUTATION,\n  });\n\n  // Members data from API\n  const membersQuery = useQuery({\n    queryKey: organizationKeys.members(organizationId),\n    queryFn: () => organizationsApi.getMembers(organizationId),\n    enabled: Boolean(organizationId),\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  // Combined loading state\n  const isLoading = projectsResult.isLoading || membersQuery.isLoading;\n\n  // First error found\n  const error = projectsResult.error || null;\n\n  // Combined retry\n  const retry = useCallback(() => {\n    projectsResult.retry();\n    membersQuery.refetch();\n  }, [projectsResult, membersQuery]);\n\n  // Computed Maps for O(1) lookup\n  const projectsById = useMemo(() => {\n    const map = new Map<string, Project>();\n    for (const project of projectsResult.data) {\n      map.set(project.id, project);\n    }\n    return map;\n  }, [projectsResult.data]);\n\n  const membersWithProfilesById = useMemo(() => {\n    const map = new Map<string, OrganizationMemberWithProfile>();\n    for (const member of membersQuery.data ?? []) {\n      map.set(member.user_id, member);\n    }\n    return map;\n  }, [membersQuery.data]);\n\n  // Lookup helpers\n  const getProject = useCallback(\n    (projectId: string) => projectsById.get(projectId),\n    [projectsById]\n  );\n\n  const value = useMemo<OrgContextValue>(\n    () => ({\n      organizationId,\n\n      // Data\n      projects: projectsResult.data,\n\n      // Loading/error\n      isLoading,\n      error,\n      retry,\n\n      // Project mutations\n      insertProject: projectsResult.insert,\n      updateProject: projectsResult.update,\n      removeProject: projectsResult.remove,\n\n      // Lookup helpers\n      getProject,\n\n      // Computed aggregations\n      projectsById,\n      membersWithProfilesById,\n    }),\n    [\n      organizationId,\n      projectsResult,\n      isLoading,\n      error,\n      retry,\n      getProject,\n      projectsById,\n      membersWithProfilesById,\n    ]\n  );\n\n  return <OrgContext.Provider value={value}>{children}</OrgContext.Provider>;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/remote/ProjectProvider.tsx",
    "content": "import { useMemo, useCallback, type ReactNode } from 'react';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport {\n  PROJECT_ISSUES_SHAPE,\n  PROJECT_PROJECT_STATUSES_SHAPE,\n  PROJECT_TAGS_SHAPE,\n  PROJECT_ISSUE_ASSIGNEES_SHAPE,\n  PROJECT_ISSUE_FOLLOWERS_SHAPE,\n  PROJECT_ISSUE_TAGS_SHAPE,\n  PROJECT_ISSUE_RELATIONSHIPS_SHAPE,\n  PROJECT_PULL_REQUESTS_SHAPE,\n  PROJECT_WORKSPACES_SHAPE,\n  ISSUE_MUTATION,\n  PROJECT_STATUS_MUTATION,\n  TAG_MUTATION,\n  ISSUE_ASSIGNEE_MUTATION,\n  ISSUE_FOLLOWER_MUTATION,\n  ISSUE_TAG_MUTATION,\n  ISSUE_RELATIONSHIP_MUTATION,\n  type Issue,\n  type ProjectStatus,\n  type Tag,\n} from 'shared/remote-types';\nimport {\n  ProjectContext,\n  type ProjectContextValue,\n} from '@/shared/hooks/useProjectContext';\n\ninterface ProjectProviderProps {\n  projectId: string;\n  children: ReactNode;\n}\n\nexport function ProjectProvider({ projectId, children }: ProjectProviderProps) {\n  const params = useMemo(() => ({ project_id: projectId }), [projectId]);\n  const enabled = Boolean(projectId);\n\n  // Shape subscriptions (with mutations where needed)\n  const issuesResult = useShape(PROJECT_ISSUES_SHAPE, params, {\n    enabled,\n    mutation: ISSUE_MUTATION,\n  });\n  const statusesResult = useShape(PROJECT_PROJECT_STATUSES_SHAPE, params, {\n    enabled,\n    mutation: PROJECT_STATUS_MUTATION,\n  });\n  const tagsResult = useShape(PROJECT_TAGS_SHAPE, params, {\n    enabled,\n    mutation: TAG_MUTATION,\n  });\n  const issueAssigneesResult = useShape(PROJECT_ISSUE_ASSIGNEES_SHAPE, params, {\n    enabled,\n    mutation: ISSUE_ASSIGNEE_MUTATION,\n  });\n  const issueFollowersResult = useShape(PROJECT_ISSUE_FOLLOWERS_SHAPE, params, {\n    enabled,\n    mutation: ISSUE_FOLLOWER_MUTATION,\n  });\n  const issueTagsResult = useShape(PROJECT_ISSUE_TAGS_SHAPE, params, {\n    enabled,\n    mutation: ISSUE_TAG_MUTATION,\n  });\n  const issueRelationshipsResult = useShape(\n    PROJECT_ISSUE_RELATIONSHIPS_SHAPE,\n    params,\n    { enabled, mutation: ISSUE_RELATIONSHIP_MUTATION }\n  );\n  const pullRequestsResult = useShape(PROJECT_PULL_REQUESTS_SHAPE, params, {\n    enabled,\n  });\n  const workspacesResult = useShape(PROJECT_WORKSPACES_SHAPE, params, {\n    enabled,\n  });\n\n  // Board readiness depends on core kanban data only.\n  // Other project-scoped shapes hydrate opportunistically after render.\n  const isLoading = issuesResult.isLoading || statusesResult.isLoading;\n\n  // First error found\n  const error =\n    issuesResult.error ||\n    statusesResult.error ||\n    tagsResult.error ||\n    issueAssigneesResult.error ||\n    issueFollowersResult.error ||\n    issueTagsResult.error ||\n    issueRelationshipsResult.error ||\n    pullRequestsResult.error ||\n    workspacesResult.error ||\n    null;\n\n  // Combined retry\n  const retry = useCallback(() => {\n    issuesResult.retry();\n    statusesResult.retry();\n    tagsResult.retry();\n    issueAssigneesResult.retry();\n    issueFollowersResult.retry();\n    issueTagsResult.retry();\n    issueRelationshipsResult.retry();\n    pullRequestsResult.retry();\n    workspacesResult.retry();\n  }, [\n    issuesResult,\n    statusesResult,\n    tagsResult,\n    issueAssigneesResult,\n    issueFollowersResult,\n    issueTagsResult,\n    issueRelationshipsResult,\n    pullRequestsResult,\n    workspacesResult,\n  ]);\n\n  // Computed Maps for O(1) lookup\n  const issuesById = useMemo(() => {\n    const map = new Map<string, Issue>();\n    for (const issue of issuesResult.data) {\n      map.set(issue.id, issue);\n    }\n    return map;\n  }, [issuesResult.data]);\n\n  const statusesById = useMemo(() => {\n    const map = new Map<string, ProjectStatus>();\n    for (const status of statusesResult.data) {\n      map.set(status.id, status);\n    }\n    return map;\n  }, [statusesResult.data]);\n\n  const tagsById = useMemo(() => {\n    const map = new Map<string, Tag>();\n    for (const tag of tagsResult.data) {\n      map.set(tag.id, tag);\n    }\n    return map;\n  }, [tagsResult.data]);\n\n  // Lookup helpers\n  const getIssue = useCallback(\n    (issueId: string) => issuesById.get(issueId),\n    [issuesById]\n  );\n\n  const getIssuesForStatus = useCallback(\n    (statusId: string) =>\n      issuesResult.data.filter((i) => i.status_id === statusId),\n    [issuesResult.data]\n  );\n\n  const getAssigneesForIssue = useCallback(\n    (issueId: string) =>\n      issueAssigneesResult.data.filter((a) => a.issue_id === issueId),\n    [issueAssigneesResult.data]\n  );\n\n  const getFollowersForIssue = useCallback(\n    (issueId: string) =>\n      issueFollowersResult.data.filter((f) => f.issue_id === issueId),\n    [issueFollowersResult.data]\n  );\n\n  const getTagsForIssue = useCallback(\n    (issueId: string) =>\n      issueTagsResult.data.filter((t) => t.issue_id === issueId),\n    [issueTagsResult.data]\n  );\n\n  const getTagObjectsForIssue = useCallback(\n    (issueId: string) => {\n      const issueTags = issueTagsResult.data.filter(\n        (t) => t.issue_id === issueId\n      );\n      return issueTags\n        .map((it) => tagsById.get(it.tag_id))\n        .filter((t): t is Tag => t !== undefined);\n    },\n    [issueTagsResult.data, tagsById]\n  );\n\n  const getRelationshipsForIssue = useCallback(\n    (issueId: string) =>\n      issueRelationshipsResult.data.filter(\n        (r) => r.issue_id === issueId || r.related_issue_id === issueId\n      ),\n    [issueRelationshipsResult.data]\n  );\n\n  const getStatus = useCallback(\n    (statusId: string) => statusesById.get(statusId),\n    [statusesById]\n  );\n\n  const getTag = useCallback(\n    (tagId: string) => tagsById.get(tagId),\n    [tagsById]\n  );\n\n  const getPullRequestsForIssue = useCallback(\n    (issueId: string) =>\n      pullRequestsResult.data.filter((pr) => pr.issue_id === issueId),\n    [pullRequestsResult.data]\n  );\n\n  const getWorkspacesForIssue = useCallback(\n    (issueId: string) =>\n      workspacesResult.data.filter((w) => w.issue_id === issueId),\n    [workspacesResult.data]\n  );\n\n  const value = useMemo<ProjectContextValue>(\n    () => ({\n      projectId,\n\n      // Data\n      issues: issuesResult.data,\n      statuses: statusesResult.data,\n      tags: tagsResult.data,\n      issueAssignees: issueAssigneesResult.data,\n      issueFollowers: issueFollowersResult.data,\n      issueTags: issueTagsResult.data,\n      issueRelationships: issueRelationshipsResult.data,\n      pullRequests: pullRequestsResult.data,\n      workspaces: workspacesResult.data,\n\n      // Loading/error\n      isLoading,\n      error,\n      retry,\n\n      // Issue mutations\n      insertIssue: issuesResult.insert,\n      updateIssue: issuesResult.update,\n      removeIssue: issuesResult.remove,\n\n      // Status mutations\n      insertStatus: statusesResult.insert,\n      updateStatus: statusesResult.update,\n      removeStatus: statusesResult.remove,\n\n      // Tag mutations\n      insertTag: tagsResult.insert,\n      updateTag: tagsResult.update,\n      removeTag: tagsResult.remove,\n\n      // IssueAssignee mutations\n      insertIssueAssignee: issueAssigneesResult.insert,\n      removeIssueAssignee: issueAssigneesResult.remove,\n\n      // IssueFollower mutations\n      insertIssueFollower: issueFollowersResult.insert,\n      removeIssueFollower: issueFollowersResult.remove,\n\n      // IssueTag mutations\n      insertIssueTag: issueTagsResult.insert,\n      removeIssueTag: issueTagsResult.remove,\n\n      // IssueRelationship mutations\n      insertIssueRelationship: issueRelationshipsResult.insert,\n      removeIssueRelationship: issueRelationshipsResult.remove,\n\n      // Lookup helpers\n      getIssue,\n      getIssuesForStatus,\n      getAssigneesForIssue,\n      getFollowersForIssue,\n      getTagsForIssue,\n      getTagObjectsForIssue,\n      getRelationshipsForIssue,\n      getStatus,\n      getTag,\n      getPullRequestsForIssue,\n      getWorkspacesForIssue,\n\n      // Computed aggregations\n      issuesById,\n      statusesById,\n      tagsById,\n    }),\n    [\n      projectId,\n      issuesResult,\n      statusesResult,\n      tagsResult,\n      issueAssigneesResult,\n      issueFollowersResult,\n      issueTagsResult,\n      issueRelationshipsResult,\n      pullRequestsResult,\n      workspacesResult,\n      isLoading,\n      error,\n      retry,\n      getIssue,\n      getIssuesForStatus,\n      getAssigneesForIssue,\n      getFollowersForIssue,\n      getTagsForIssue,\n      getTagObjectsForIssue,\n      getRelationshipsForIssue,\n      getStatus,\n      getTag,\n      getPullRequestsForIssue,\n      getWorkspacesForIssue,\n      issuesById,\n      statusesById,\n      tagsById,\n    ]\n  );\n\n  return (\n    <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/providers/remote/UserProvider.tsx",
    "content": "import { useMemo, useCallback, type ReactNode } from 'react';\nimport { useShape } from '@/shared/integrations/electric/hooks';\nimport { USER_WORKSPACES_SHAPE } from 'shared/remote-types';\nimport { useAuth } from '@/shared/hooks/auth/useAuth';\nimport {\n  UserContext,\n  type UserContextValue,\n} from '@/shared/hooks/useUserContext';\n\ninterface UserProviderProps {\n  children: ReactNode;\n}\n\nexport function UserProvider({ children }: UserProviderProps) {\n  const { isSignedIn } = useAuth();\n\n  // No params needed - backend gets user from auth context\n  const params = useMemo(() => ({}), []);\n  const enabled = isSignedIn;\n\n  // Shape subscriptions\n  const workspacesResult = useShape(USER_WORKSPACES_SHAPE, params, { enabled });\n\n  // Lookup helpers\n  const getWorkspacesForIssue = useCallback(\n    (issueId: string) => {\n      return workspacesResult.data.filter((w) => w.issue_id === issueId);\n    },\n    [workspacesResult.data]\n  );\n\n  const value = useMemo<UserContextValue>(\n    () => ({\n      // Data\n      workspaces: workspacesResult.data,\n\n      // Loading/error\n      isLoading: workspacesResult.isLoading,\n      error: workspacesResult.error,\n      retry: workspacesResult.retry,\n\n      // Lookup helpers\n      getWorkspacesForIssue,\n    }),\n    [workspacesResult, getWorkspacesForIssue]\n  );\n\n  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/stores/useAppUpdateStore.ts",
    "content": "import { create } from 'zustand';\n\ntype State = {\n  /** Non-null when a new version has been downloaded and is ready to install. */\n  updateVersion: string | null;\n  /** Callback to restart the app. Set by the platform-specific hook. */\n  restart: (() => void) | null;\n  setUpdate: (version: string, restart: () => void) => void;\n};\n\nexport const useAppUpdateStore = create<State>()((set) => ({\n  updateVersion: null,\n  restart: null,\n  setUpdate: (version, restart) => set({ updateVersion: version, restart }),\n}));\n"
  },
  {
    "path": "packages/web-core/src/shared/stores/useDiffViewStore.ts",
    "content": "import { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\nexport type DiffViewMode = 'unified' | 'split';\n\ntype State = {\n  mode: DiffViewMode;\n  setMode: (mode: DiffViewMode) => void;\n  toggle: () => void;\n  ignoreWhitespace: boolean;\n  setIgnoreWhitespace: (value: boolean) => void;\n  wrapText: boolean;\n  setWrapText: (value: boolean) => void;\n  // Current diff paths for expand/collapse all functionality\n  diffPaths: string[];\n  setDiffPaths: (paths: string[]) => void;\n};\n\nexport const useDiffViewStore = create<State>()(\n  persist(\n    (set) => ({\n      mode: 'unified',\n      setMode: (mode) => set({ mode }),\n      toggle: () =>\n        set((s) => ({ mode: s.mode === 'unified' ? 'split' : 'unified' })),\n      ignoreWhitespace: true,\n      setIgnoreWhitespace: (value) => set({ ignoreWhitespace: value }),\n      wrapText: false,\n      setWrapText: (value) => set({ wrapText: value }),\n      diffPaths: [],\n      setDiffPaths: (paths) => set({ diffPaths: paths }),\n    }),\n    {\n      name: 'diff-view-preferences',\n      // Don't persist diffPaths as it's transient state\n      partialize: (state) => ({\n        mode: state.mode,\n        ignoreWhitespace: state.ignoreWhitespace,\n        wrapText: state.wrapText,\n      }),\n    }\n  )\n);\n\nexport const useDiffViewMode = () => useDiffViewStore((s) => s.mode);\nexport const useIgnoreWhitespaceDiff = () =>\n  useDiffViewStore((s) => s.ignoreWhitespace);\nexport const useWrapTextDiff = () => useDiffViewStore((s) => s.wrapText);\n"
  },
  {
    "path": "packages/web-core/src/shared/stores/useExpandableStore.ts",
    "content": "import { create } from 'zustand';\n\ntype State = {\n  expanded: Record<string, boolean>;\n  setKey: (key: string, value: boolean) => void;\n  toggleKey: (key: string, fallback?: boolean) => void;\n  clear: () => void;\n};\n\nconst useExpandableStore = create<State>((set) => ({\n  expanded: {},\n  setKey: (key, value) =>\n    set((s) =>\n      s.expanded[key] === value\n        ? s\n        : { expanded: { ...s.expanded, [key]: value } }\n    ),\n  toggleKey: (key, fallback = false) =>\n    set((s) => {\n      const next = !(s.expanded[key] ?? fallback);\n      return { expanded: { ...s.expanded, [key]: next } };\n    }),\n  clear: () => set({ expanded: {} }),\n}));\n\nexport function useExpandable(\n  key: string,\n  defaultValue = false\n): [boolean, (next?: boolean) => void] {\n  const expandedValue = useExpandableStore((s) => s.expanded[key]);\n  const setKey = useExpandableStore((s) => s.setKey);\n  const toggleKey = useExpandableStore((s) => s.toggleKey);\n\n  const set = (next?: boolean) => {\n    if (typeof next === 'boolean') setKey(key, next);\n    else toggleKey(key, defaultValue);\n  };\n\n  return [expandedValue ?? defaultValue, set];\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/stores/useIssueSelectionStore.ts",
    "content": "import { create } from 'zustand';\n\ninterface IssueSelectionState {\n  /** Set of currently selected issue IDs */\n  selectedIssueIds: Set<string>;\n  /** Anchor issue for Shift+Click range selection */\n  anchorIssueId: string | null;\n  /** Cursor position for keyboard-driven selection (Shift+J/K) */\n  cursorIssueId: string | null;\n  /** Flat ordered list of all visible issue IDs (set by the kanban container) */\n  orderedIssueIds: string[];\n\n  toggleIssue: (issueId: string) => void;\n  selectRange: (targetIssueId: string) => void;\n  /** Extend selection by one issue in the given direction (for Shift+J/K) */\n  selectAdjacent: (\n    direction: 'up' | 'down',\n    fallbackIssueId?: string | null\n  ) => void;\n  selectAll: () => void;\n  clearSelection: () => void;\n  /** Set anchor for range selection without selecting the issue */\n  setAnchor: (issueId: string) => void;\n  setOrderedIssueIds: (ids: string[]) => void;\n}\n\nexport const useIssueSelectionStore = create<IssueSelectionState>(\n  (set, get) => ({\n    selectedIssueIds: new Set<string>(),\n    anchorIssueId: null,\n    cursorIssueId: null,\n    orderedIssueIds: [],\n\n    toggleIssue: (issueId: string) => {\n      const { selectedIssueIds, anchorIssueId } = get();\n      const next = new Set(selectedIssueIds);\n      const isDeselecting = next.has(issueId);\n      if (isDeselecting) {\n        next.delete(issueId);\n      } else {\n        // When starting multi-select from an opened issue, include the\n        // anchor (the opened issue) so both end up selected.\n        if (next.size === 0 && anchorIssueId && anchorIssueId !== issueId) {\n          next.add(anchorIssueId);\n        }\n        next.add(issueId);\n      }\n      // Only move anchor/cursor when selecting, not when deselecting\n      set({\n        selectedIssueIds: next,\n        ...(isDeselecting\n          ? {}\n          : { anchorIssueId: issueId, cursorIssueId: issueId }),\n      });\n    },\n\n    selectRange: (targetIssueId: string) => {\n      const { anchorIssueId, orderedIssueIds } = get();\n      if (!anchorIssueId) {\n        // No anchor — just select the target\n        set({\n          selectedIssueIds: new Set([targetIssueId]),\n          anchorIssueId: targetIssueId,\n          cursorIssueId: targetIssueId,\n        });\n        return;\n      }\n\n      const anchorIndex = orderedIssueIds.indexOf(anchorIssueId);\n      const targetIndex = orderedIssueIds.indexOf(targetIssueId);\n\n      if (anchorIndex === -1 || targetIndex === -1) {\n        // Fallback if IDs not in the ordered list\n        set({\n          selectedIssueIds: new Set([targetIssueId]),\n          anchorIssueId: targetIssueId,\n          cursorIssueId: targetIssueId,\n        });\n        return;\n      }\n\n      const start = Math.min(anchorIndex, targetIndex);\n      const end = Math.max(anchorIndex, targetIndex);\n      const rangeIds = orderedIssueIds.slice(start, end + 1);\n\n      // Replace selection with the new range (standard platform behavior)\n      set({\n        selectedIssueIds: new Set(rangeIds),\n        cursorIssueId: targetIssueId,\n      });\n    },\n\n    selectAdjacent: (\n      direction: 'up' | 'down',\n      fallbackIssueId?: string | null\n    ) => {\n      const {\n        anchorIssueId,\n        cursorIssueId,\n        orderedIssueIds,\n        selectedIssueIds,\n      } = get();\n      if (orderedIssueIds.length === 0) return;\n\n      // Determine starting point: cursor > anchor > fallback (open issue) > first\n      const startId = cursorIssueId ?? anchorIssueId ?? fallbackIssueId ?? null;\n      const startIndex = startId ? orderedIssueIds.indexOf(startId) : -1;\n\n      if (startIndex === -1 && selectedIssueIds.size === 0) {\n        // No starting point — select the first or last issue to begin\n        const id =\n          direction === 'down'\n            ? orderedIssueIds[0]\n            : orderedIssueIds[orderedIssueIds.length - 1];\n        set({\n          selectedIssueIds: new Set([id]),\n          anchorIssueId: id,\n          cursorIssueId: id,\n        });\n        return;\n      }\n\n      const effectiveIndex = startIndex === -1 ? 0 : startIndex;\n      const nextIndex =\n        direction === 'down' ? effectiveIndex + 1 : effectiveIndex - 1;\n\n      // Clamp to bounds\n      if (nextIndex < 0 || nextIndex >= orderedIssueIds.length) return;\n\n      const nextId = orderedIssueIds[nextIndex];\n\n      // Set anchor if none exists\n      const effectiveAnchor = anchorIssueId ?? orderedIssueIds[effectiveIndex];\n\n      // Build range from anchor to new cursor\n      const anchorIndex = orderedIssueIds.indexOf(effectiveAnchor);\n      const rangeStart = Math.min(anchorIndex, nextIndex);\n      const rangeEnd = Math.max(anchorIndex, nextIndex);\n      const rangeIds = orderedIssueIds.slice(rangeStart, rangeEnd + 1);\n\n      set({\n        selectedIssueIds: new Set(rangeIds),\n        anchorIssueId: effectiveAnchor,\n        cursorIssueId: nextId,\n      });\n    },\n\n    selectAll: () => {\n      const { orderedIssueIds } = get();\n      set({ selectedIssueIds: new Set(orderedIssueIds) });\n    },\n\n    clearSelection: () => {\n      set({\n        selectedIssueIds: new Set<string>(),\n        anchorIssueId: null,\n        cursorIssueId: null,\n      });\n    },\n\n    setAnchor: (issueId: string) => {\n      set({ anchorIssueId: issueId, cursorIssueId: issueId });\n    },\n\n    setOrderedIssueIds: (ids: string[]) => {\n      set({ orderedIssueIds: ids });\n    },\n  })\n);\n"
  },
  {
    "path": "packages/web-core/src/shared/stores/useKanbanIssueComposerStore.ts",
    "content": "import { useCallback } from 'react';\nimport { create } from 'zustand';\nimport type { IssuePriority } from 'shared/remote-types';\n\nexport interface ProjectIssueCreateOptions {\n  statusId?: string;\n  priority?: IssuePriority;\n  assigneeIds?: string[];\n  parentIssueId?: string;\n}\n\nexport interface KanbanIssueComposerDraft {\n  title: string;\n  description: string | null;\n  statusId?: string;\n  priority?: IssuePriority | null;\n  assigneeIds?: string[];\n  tagIds?: string[];\n  createDraftWorkspace?: boolean;\n  parentIssueId?: string;\n}\n\nexport interface KanbanIssueComposerEntry {\n  initial: KanbanIssueComposerDraft;\n  draft: KanbanIssueComposerDraft;\n}\n\ninterface KanbanIssueComposerState {\n  byKey: Record<string, KanbanIssueComposerEntry | undefined>;\n  openComposer: (\n    key: string,\n    options?: ProjectIssueCreateOptions | null\n  ) => void;\n  patchComposer: (\n    key: string,\n    patch: Partial<KanbanIssueComposerDraft>\n  ) => void;\n  resetComposer: (key: string) => void;\n  closeComposer: (key: string) => void;\n}\n\nconst LOCAL_HOST_SCOPE = 'local';\n\nfunction normalizeComposerDraft(\n  draft: Partial<KanbanIssueComposerDraft>\n): KanbanIssueComposerDraft {\n  return {\n    title: draft.title ?? '',\n    description: draft.description ?? null,\n    ...(draft.statusId ? { statusId: draft.statusId } : {}),\n    ...(draft.priority !== undefined ? { priority: draft.priority } : {}),\n    ...(draft.assigneeIds !== undefined\n      ? { assigneeIds: [...draft.assigneeIds] }\n      : {}),\n    ...(draft.tagIds !== undefined ? { tagIds: [...draft.tagIds] } : {}),\n    ...(draft.createDraftWorkspace !== undefined\n      ? { createDraftWorkspace: draft.createDraftWorkspace }\n      : {}),\n    ...(draft.parentIssueId ? { parentIssueId: draft.parentIssueId } : {}),\n  };\n}\n\nexport function buildKanbanIssueComposerKey(\n  hostId: string | null,\n  projectId: string\n): string {\n  const hostScope = hostId ?? LOCAL_HOST_SCOPE;\n  return `${hostScope}:${projectId}`;\n}\n\nfunction toInitialComposerDraft(\n  options?: ProjectIssueCreateOptions | null\n): KanbanIssueComposerDraft {\n  return normalizeComposerDraft({\n    statusId: options?.statusId,\n    priority: options?.priority,\n    assigneeIds: options?.assigneeIds,\n    parentIssueId: options?.parentIssueId,\n    tagIds: [],\n    createDraftWorkspace: false,\n  });\n}\n\nexport const useKanbanIssueComposerStore = create<KanbanIssueComposerState>()(\n  (set) => ({\n    byKey: {},\n    openComposer: (key, options) =>\n      set((state) => {\n        const initial = toInitialComposerDraft(options);\n        return {\n          byKey: {\n            ...state.byKey,\n            [key]: {\n              initial,\n              draft: initial,\n            },\n          },\n        };\n      }),\n    patchComposer: (key, patch) =>\n      set((state) => {\n        const current = state.byKey[key];\n        if (!current) {\n          return state;\n        }\n\n        return {\n          byKey: {\n            ...state.byKey,\n            [key]: {\n              ...current,\n              draft: normalizeComposerDraft({\n                ...current.draft,\n                ...patch,\n              }),\n            },\n          },\n        };\n      }),\n    resetComposer: (key) =>\n      set((state) => {\n        const current = state.byKey[key];\n        if (!current) {\n          return state;\n        }\n\n        return {\n          byKey: {\n            ...state.byKey,\n            [key]: {\n              ...current,\n              draft: current.initial,\n            },\n          },\n        };\n      }),\n    closeComposer: (key) =>\n      set((state) => {\n        if (!(key in state.byKey)) {\n          return state;\n        }\n\n        const { [key]: _removed, ...rest } = state.byKey;\n        return { byKey: rest };\n      }),\n  })\n);\n\nexport function useKanbanIssueComposer(\n  composerKey: string | null\n): KanbanIssueComposerEntry | null {\n  return useKanbanIssueComposerStore(\n    useCallback(\n      (state) => (composerKey ? (state.byKey[composerKey] ?? null) : null),\n      [composerKey]\n    )\n  );\n}\n\nexport function openKanbanIssueComposer(\n  composerKey: string,\n  options?: ProjectIssueCreateOptions | null\n): void {\n  useKanbanIssueComposerStore.getState().openComposer(composerKey, options);\n}\n\nexport function patchKanbanIssueComposer(\n  composerKey: string,\n  patch: Partial<KanbanIssueComposerDraft>\n): void {\n  useKanbanIssueComposerStore.getState().patchComposer(composerKey, patch);\n}\n\nexport function resetKanbanIssueComposer(composerKey: string): void {\n  useKanbanIssueComposerStore.getState().resetComposer(composerKey);\n}\n\nexport function closeKanbanIssueComposer(composerKey: string): void {\n  useKanbanIssueComposerStore.getState().closeComposer(composerKey);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/stores/useOrganizationStore.ts",
    "content": "import { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\nimport { useUiPreferencesStore } from './useUiPreferencesStore';\n\ntype State = {\n  selectedOrgId: string | null;\n  setSelectedOrgId: (orgId: string | null) => void;\n  clearSelectedOrgId: () => void;\n};\n\nexport const useOrganizationStore = create<State>()(\n  persist(\n    (set) => ({\n      selectedOrgId: null,\n      setSelectedOrgId: (orgId) => set({ selectedOrgId: orgId }),\n      clearSelectedOrgId: () => set({ selectedOrgId: null }),\n    }),\n    {\n      name: 'organization-selection',\n      partialize: (state) => ({ selectedOrgId: state.selectedOrgId }),\n    }\n  )\n);\n\n// Sync org store changes into the UI preferences store for server persistence\nuseOrganizationStore.subscribe((state) => {\n  useUiPreferencesStore.getState().setSelectedOrgId(state.selectedOrgId);\n});\n\nexport const useSelectedOrgId = () =>\n  useOrganizationStore((s) => s.selectedOrgId);\nexport const useSetSelectedOrgId = () =>\n  useOrganizationStore((s) => s.setSelectedOrgId);\n"
  },
  {
    "path": "packages/web-core/src/shared/stores/useUiPreferencesStore.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { create } from 'zustand';\nimport type { RepoAction } from '@vibe/ui/components/RepoCard';\nimport type { IssuePriority } from 'shared/remote-types';\n\nexport const RIGHT_MAIN_PANEL_MODES = {\n  CHANGES: 'changes',\n  LOGS: 'logs',\n  PREVIEW: 'preview',\n} as const;\n\nexport type RightMainPanelMode =\n  (typeof RIGHT_MAIN_PANEL_MODES)[keyof typeof RIGHT_MAIN_PANEL_MODES];\n\nexport type LayoutMode = 'workspaces' | 'kanban' | 'migrate';\n\nexport type MobileTab =\n  | 'workspaces'\n  | 'chat'\n  | 'changes'\n  | 'logs'\n  | 'preview'\n  | 'git';\n\nexport type MobileFontScale = 'default' | 'small' | 'smaller';\nexport const DEFAULT_CREATE_DRAFT_WORKSPACE_BY_DEFAULT = false;\n\nconst MOBILE_FONT_SCALE_KEY = 'vk-mobile-font-scale';\n\nconst loadMobileFontScale = (): MobileFontScale => {\n  try {\n    const stored = localStorage.getItem(MOBILE_FONT_SCALE_KEY);\n    if (stored === 'small' || stored === 'smaller') return stored;\n  } catch {\n    // localStorage may be unavailable\n  }\n  return 'default';\n};\n\nexport type KanbanViewMode = 'kanban' | 'list';\n\nexport type ContextBarPosition =\n  | 'top-left'\n  | 'top-right'\n  | 'middle-left'\n  | 'middle-right'\n  | 'bottom-left'\n  | 'bottom-right';\n\n// Workspace-specific panel state\nexport type WorkspacePanelState = {\n  rightMainPanelMode: RightMainPanelMode | null;\n  isLeftMainPanelVisible: boolean;\n};\n\nconst DEFAULT_WORKSPACE_PANEL_STATE: WorkspacePanelState = {\n  rightMainPanelMode: null,\n  isLeftMainPanelVisible: true,\n};\n\n// Kanban filter state\nexport type KanbanSortField =\n  | 'sort_order'\n  | 'priority'\n  | 'created_at'\n  | 'updated_at'\n  | 'title';\n\nexport type KanbanFilterState = {\n  searchQuery: string;\n  priorities: IssuePriority[];\n  assigneeIds: string[]; // 'unassigned' or '__self__' or user IDs\n  tagIds: string[];\n  sortField: KanbanSortField;\n  sortDirection: 'asc' | 'desc';\n};\n\nexport const DEFAULT_KANBAN_FILTER_STATE: KanbanFilterState = {\n  searchQuery: '',\n  priorities: [],\n  assigneeIds: [],\n  tagIds: [],\n  sortField: 'sort_order',\n  sortDirection: 'asc',\n};\n\nexport const KANBAN_ASSIGNEE_FILTER_VALUES = {\n  UNASSIGNED: 'unassigned',\n  SELF: '__self__',\n} as const;\n\nexport const KANBAN_PROJECT_VIEW_IDS = {\n  TEAM: 'team',\n  PERSONAL: 'personal',\n} as const;\n\nexport const DEFAULT_KANBAN_PROJECT_VIEW_ID = KANBAN_PROJECT_VIEW_IDS.TEAM;\nexport const DEFAULT_KANBAN_SHOW_WORKSPACES = true;\n\nexport const getDefaultShowSubIssuesForView = (viewId: string): boolean =>\n  viewId === KANBAN_PROJECT_VIEW_IDS.PERSONAL;\n\nexport type KanbanProjectView = {\n  id: string;\n  name: string;\n  filters: KanbanFilterState;\n  showSubIssues: boolean;\n  showWorkspaces: boolean;\n};\n\nexport type KanbanProjectViewSelection = {\n  activeViewId: string;\n};\n\nexport type KanbanProjectViewPreferences = {\n  filters: KanbanFilterState;\n  showSubIssues: boolean;\n  showWorkspaces: boolean;\n};\n\nexport type ResolvedKanbanProjectState = {\n  activeViewId: string;\n  filters: KanbanFilterState;\n  showSubIssues: boolean;\n  showWorkspaces: boolean;\n};\n\nconst cloneKanbanFilters = (filters: KanbanFilterState): KanbanFilterState => ({\n  searchQuery: filters.searchQuery,\n  priorities: [...filters.priorities],\n  assigneeIds: [...filters.assigneeIds],\n  tagIds: [...filters.tagIds],\n  sortField: filters.sortField,\n  sortDirection: filters.sortDirection,\n});\n\nconst isKanbanProjectViewId = (\n  viewId: string\n): viewId is (typeof KANBAN_PROJECT_VIEW_IDS)[keyof typeof KANBAN_PROJECT_VIEW_IDS] =>\n  viewId === KANBAN_PROJECT_VIEW_IDS.TEAM ||\n  viewId === KANBAN_PROJECT_VIEW_IDS.PERSONAL;\n\nconst getKanbanDefaultView = (viewId: string): KanbanProjectView => {\n  if (viewId === KANBAN_PROJECT_VIEW_IDS.PERSONAL) {\n    return {\n      id: KANBAN_PROJECT_VIEW_IDS.PERSONAL,\n      name: 'Personal',\n      filters: {\n        ...cloneKanbanFilters(DEFAULT_KANBAN_FILTER_STATE),\n        assigneeIds: [KANBAN_ASSIGNEE_FILTER_VALUES.SELF],\n        sortField: 'priority',\n        sortDirection: 'asc',\n      },\n      showSubIssues: getDefaultShowSubIssuesForView(\n        KANBAN_PROJECT_VIEW_IDS.PERSONAL\n      ),\n      showWorkspaces: DEFAULT_KANBAN_SHOW_WORKSPACES,\n    };\n  }\n\n  return {\n    id: KANBAN_PROJECT_VIEW_IDS.TEAM,\n    name: 'Team',\n    filters: cloneKanbanFilters(DEFAULT_KANBAN_FILTER_STATE),\n    showSubIssues: getDefaultShowSubIssuesForView(KANBAN_PROJECT_VIEW_IDS.TEAM),\n    showWorkspaces: DEFAULT_KANBAN_SHOW_WORKSPACES,\n  };\n};\n\nconst createDefaultKanbanProjectViewPreferences = (\n  viewId: string\n): KanbanProjectViewPreferences => {\n  const view = getKanbanDefaultView(viewId);\n  return {\n    filters: cloneKanbanFilters(view.filters),\n    showSubIssues: view.showSubIssues,\n    showWorkspaces: view.showWorkspaces,\n  };\n};\n\nexport const resolveKanbanProjectState = (\n  projectSelection: KanbanProjectViewSelection | undefined\n): ResolvedKanbanProjectState => {\n  const requestedViewId = projectSelection?.activeViewId;\n  const activeViewId = isKanbanProjectViewId(requestedViewId ?? '')\n    ? (requestedViewId ?? DEFAULT_KANBAN_PROJECT_VIEW_ID)\n    : DEFAULT_KANBAN_PROJECT_VIEW_ID;\n  const activeView = getKanbanDefaultView(activeViewId);\n\n  return {\n    activeViewId,\n    filters: cloneKanbanFilters(activeView.filters),\n    showSubIssues: activeView.showSubIssues,\n    showWorkspaces: activeView.showWorkspaces,\n  };\n};\n\n// Workspace sidebar filter state\nexport type WorkspacePrFilter = 'all' | 'has_pr' | 'no_pr';\nexport type WorkspaceSortBy = 'updated_at' | 'created_at';\nexport type WorkspaceSortOrder = 'asc' | 'desc';\n\nexport type WorkspaceFilterState = {\n  projectIds: string[]; // remote project IDs\n  prFilter: WorkspacePrFilter;\n};\n\nexport type WorkspaceSortState = {\n  sortBy: WorkspaceSortBy;\n  sortOrder: WorkspaceSortOrder;\n};\n\nconst DEFAULT_WORKSPACE_FILTER_STATE: WorkspaceFilterState = {\n  projectIds: [],\n  prFilter: 'all',\n};\n\nconst DEFAULT_WORKSPACE_SORT_STATE: WorkspaceSortState = {\n  sortBy: 'updated_at',\n  sortOrder: 'desc',\n};\n\n// Centralized persist keys for type safety\nexport const PERSIST_KEYS = {\n  // Sidebar sections\n  workspacesSidebarArchived: 'workspaces-sidebar-archived',\n  // v2 key forces accordion default to true for all users\n  workspacesSidebarAccordionLayout: 'workspaces-sidebar-accordion-layout-v2',\n  workspacesSidebarRaisedHand: 'workspaces-sidebar-raised-hand',\n  workspacesSidebarNotRunning: 'workspaces-sidebar-not-running',\n  workspacesSidebarRunning: 'workspaces-sidebar-running',\n  // Right panel sections\n  gitAdvancedSettings: 'git-advanced-settings',\n  gitPanelRepositories: 'git-panel-repositories',\n  gitPanelProject: 'git-panel-project',\n  gitPanelAddRepositories: 'git-panel-add-repositories',\n  rightPanelprocesses: 'right-panel-processes',\n  rightPanelPreview: 'right-panel-preview',\n  // Process panel sections\n  processesSection: 'processes-section',\n  // Changes panel sections\n  changesSection: 'changes-section',\n  // Preview panel sections\n  devServerSection: 'dev-server-section',\n  // Terminal panel section\n  terminalSection: 'terminal-section',\n  // Notes panel section\n  notesSection: 'notes-section',\n  // GitHub comments toggle\n  showGitHubComments: 'show-github-comments',\n  // Panel sizes\n  rightMainPanel: 'right-main-panel',\n  kanbanLeftPanel: 'kanban-left-panel',\n  // Kanban issue panel sections\n  kanbanIssueSubIssues: 'kanban-issue-sub-issues',\n  kanbanIssueRelationships: 'kanban-issue-relationships',\n  kanbanIssueAttachments: 'kanban-issue-attachments',\n  // Dynamic keys (use helper functions)\n  repoCard: (repoId: string) => `repo-card-${repoId}` as const,\n} as const;\n\n// Check if screen is wide enough to keep sidebar visible\nconst isWideScreen = () => window.innerWidth > 2048;\n\nexport type PersistKey =\n  | typeof PERSIST_KEYS.workspacesSidebarArchived\n  | typeof PERSIST_KEYS.workspacesSidebarAccordionLayout\n  | typeof PERSIST_KEYS.workspacesSidebarRaisedHand\n  | typeof PERSIST_KEYS.workspacesSidebarNotRunning\n  | typeof PERSIST_KEYS.workspacesSidebarRunning\n  | typeof PERSIST_KEYS.gitAdvancedSettings\n  | typeof PERSIST_KEYS.gitPanelRepositories\n  | typeof PERSIST_KEYS.gitPanelProject\n  | typeof PERSIST_KEYS.gitPanelAddRepositories\n  | typeof PERSIST_KEYS.processesSection\n  | typeof PERSIST_KEYS.changesSection\n  | typeof PERSIST_KEYS.devServerSection\n  | typeof PERSIST_KEYS.terminalSection\n  | typeof PERSIST_KEYS.notesSection\n  | typeof PERSIST_KEYS.showGitHubComments\n  | typeof PERSIST_KEYS.rightMainPanel\n  | typeof PERSIST_KEYS.rightPanelprocesses\n  | typeof PERSIST_KEYS.rightPanelPreview\n  | typeof PERSIST_KEYS.kanbanLeftPanel\n  | typeof PERSIST_KEYS.kanbanIssueSubIssues\n  | typeof PERSIST_KEYS.kanbanIssueRelationships\n  | typeof PERSIST_KEYS.kanbanIssueAttachments\n  | `repo-card-${string}`\n  | `diff:${string}`\n  | `edit:${string}`\n  | `plan:${string}`\n  | `tool:${string}`\n  | `todo:${string}`\n  | `subagent:${string}`\n  | `user:${string}`\n  | `system:${string}`\n  | `error:${string}`\n  | `entry:${string}`\n  | `list-section-${string}`;\n\ntype State = {\n  // UI preferences\n  repoActions: Record<string, RepoAction>;\n  expanded: Record<string, boolean>;\n  contextBarPosition: ContextBarPosition;\n  paneSizes: Record<string, number | string>;\n  collapsedPaths: Record<string, string[]>;\n  fileSearchRepoId: string | null;\n\n  // Global layout state (applies across all workspaces)\n  layoutMode: LayoutMode;\n  isLeftSidebarVisible: boolean;\n  isRightSidebarVisible: boolean;\n  isTerminalVisible: boolean;\n  previewRefreshKey: number;\n  // Note: Kanban issue panel state (selectedKanbanIssueId, createMode, etc.)\n  // is derived from URL via app navigation route state\n\n  // Workspace-specific panel state\n  workspacePanelStates: Record<string, WorkspacePanelState>;\n\n  // Selected built-in kanban view per project\n  kanbanProjectViewSelections: Record<string, KanbanProjectViewSelection>;\n\n  // In-memory kanban runtime preferences per project and view\n  kanbanProjectViewPreferences: Record<\n    string,\n    Record<string, KanbanProjectViewPreferences>\n  >;\n\n  // Workspace sidebar filter state\n  workspaceFilters: WorkspaceFilterState;\n  workspaceSort: WorkspaceSortState;\n\n  // Kanban view mode state\n  kanbanViewMode: KanbanViewMode;\n  listViewStatusFilter: string | null;\n\n  // Mobile tab state\n  mobileActiveTab: MobileTab;\n\n  // Mobile font scale\n  mobileFontScale: MobileFontScale;\n\n  // Last selected organization and project (persisted via scratch store)\n  selectedOrgId: string | null;\n  selectedProjectId: string | null;\n  createDraftWorkspaceByDefault: boolean;\n\n  // UI preferences actions\n  setRepoAction: (repoId: string, action: RepoAction) => void;\n  setExpanded: (key: string, value: boolean) => void;\n  toggleExpanded: (key: string, defaultValue?: boolean) => void;\n  setExpandedAll: (keys: string[], value: boolean) => void;\n  setContextBarPosition: (position: ContextBarPosition) => void;\n  setPaneSize: (key: string, size: number | string) => void;\n  setCollapsedPaths: (key: string, paths: string[]) => void;\n  setFileSearchRepo: (repoId: string | null) => void;\n\n  // Layout actions\n  setLayoutMode: (mode: LayoutMode) => void;\n  toggleLayoutMode: () => void;\n  toggleLeftSidebar: () => void;\n  toggleLeftMainPanel: (workspaceId?: string) => void;\n  toggleRightSidebar: () => void;\n  toggleTerminal: () => void;\n  setTerminalVisible: (value: boolean) => void;\n  // Note: Kanban panel actions (openKanbanIssuePanel, closeKanbanIssuePanel, etc.)\n  // are handled by app navigation\n  toggleRightMainPanelMode: (\n    mode: RightMainPanelMode,\n    workspaceId?: string\n  ) => void;\n  setRightMainPanelMode: (\n    mode: RightMainPanelMode | null,\n    workspaceId?: string\n  ) => void;\n  setLeftSidebarVisible: (value: boolean) => void;\n  setLeftMainPanelVisible: (value: boolean, workspaceId?: string) => void;\n  triggerPreviewRefresh: () => void;\n\n  // Workspace-specific panel state actions\n  getWorkspacePanelState: (workspaceId: string) => WorkspacePanelState;\n  setWorkspacePanelState: (\n    workspaceId: string,\n    state: Partial<WorkspacePanelState>\n  ) => void;\n\n  // Kanban view selection actions\n  setKanbanProjectView: (projectId: string, viewId: string) => void;\n  setKanbanProjectViewFilters: (\n    projectId: string,\n    viewId: string,\n    filters: KanbanFilterState\n  ) => void;\n  setKanbanProjectViewShowSubIssues: (\n    projectId: string,\n    viewId: string,\n    show: boolean\n  ) => void;\n  setKanbanProjectViewShowWorkspaces: (\n    projectId: string,\n    viewId: string,\n    show: boolean\n  ) => void;\n  clearKanbanProjectViewPreferences: (\n    projectId: string,\n    viewId: string\n  ) => void;\n\n  // Workspace sidebar filter actions\n  setWorkspaceProjectFilter: (projectIds: string[]) => void;\n  setWorkspacePrFilter: (prFilter: WorkspacePrFilter) => void;\n  clearWorkspaceFilters: () => void;\n  setWorkspaceSortBy: (sortBy: WorkspaceSortBy) => void;\n  setWorkspaceSortOrder: (sortOrder: WorkspaceSortOrder) => void;\n\n  // Kanban view mode actions\n  setKanbanViewMode: (mode: KanbanViewMode) => void;\n  setListViewStatusFilter: (statusId: string | null) => void;\n\n  // Mobile tab actions\n  setMobileActiveTab: (tab: MobileTab) => void;\n\n  // Mobile font scale actions\n  setMobileFontScale: (scale: MobileFontScale) => void;\n\n  // Last selected organization and project actions\n  setSelectedOrgId: (orgId: string | null) => void;\n  clearSelectedOrgId: () => void;\n  setSelectedProjectId: (projectId: string | null) => void;\n  setCreateDraftWorkspaceByDefault: (value: boolean) => void;\n};\n\nexport const useUiPreferencesStore = create<State>()((set, get) => ({\n  // UI preferences state\n  repoActions: {},\n  expanded: {},\n  contextBarPosition: 'middle-right',\n  paneSizes: {},\n  collapsedPaths: {},\n  fileSearchRepoId: null,\n\n  // Global layout state\n  layoutMode: 'workspaces' as LayoutMode,\n  isLeftSidebarVisible: true,\n  isRightSidebarVisible: true,\n  isTerminalVisible: true,\n  previewRefreshKey: 0,\n\n  // Workspace-specific panel state\n  workspacePanelStates: {},\n\n  // Kanban per-project view selection\n  kanbanProjectViewSelections: {},\n  kanbanProjectViewPreferences: {},\n\n  // Workspace sidebar filter state\n  workspaceFilters: DEFAULT_WORKSPACE_FILTER_STATE,\n  workspaceSort: DEFAULT_WORKSPACE_SORT_STATE,\n\n  // Kanban view mode state\n  kanbanViewMode: 'kanban' as KanbanViewMode,\n  listViewStatusFilter: null,\n\n  // Mobile tab state\n  mobileActiveTab: 'chat' as MobileTab,\n\n  // Mobile font scale\n  mobileFontScale: loadMobileFontScale(),\n\n  // Last selected organization and project\n  selectedOrgId: null,\n  selectedProjectId: null,\n  createDraftWorkspaceByDefault: DEFAULT_CREATE_DRAFT_WORKSPACE_BY_DEFAULT,\n\n  // UI preferences actions\n  setRepoAction: (repoId, action) =>\n    set((s) => ({ repoActions: { ...s.repoActions, [repoId]: action } })),\n  setExpanded: (key, value) =>\n    set((s) => ({ expanded: { ...s.expanded, [key]: value } })),\n  toggleExpanded: (key, defaultValue = true) =>\n    set((s) => ({\n      expanded: {\n        ...s.expanded,\n        [key]: !(s.expanded[key] ?? defaultValue),\n      },\n    })),\n  setExpandedAll: (keys, value) =>\n    set((s) => ({\n      expanded: {\n        ...s.expanded,\n        ...Object.fromEntries(keys.map((k) => [k, value])),\n      },\n    })),\n  setContextBarPosition: (position) => set({ contextBarPosition: position }),\n  setPaneSize: (key, size) =>\n    set((s) => ({ paneSizes: { ...s.paneSizes, [key]: size } })),\n  setCollapsedPaths: (key, paths) =>\n    set((s) => ({ collapsedPaths: { ...s.collapsedPaths, [key]: paths } })),\n  setFileSearchRepo: (repoId) => set({ fileSearchRepoId: repoId }),\n\n  // Layout actions\n  setLayoutMode: (mode) => set({ layoutMode: mode }),\n  toggleLayoutMode: () =>\n    set((s) => ({\n      layoutMode: s.layoutMode === 'workspaces' ? 'kanban' : 'workspaces',\n    })),\n  toggleLeftSidebar: () =>\n    set((s) => ({ isLeftSidebarVisible: !s.isLeftSidebarVisible })),\n\n  toggleLeftMainPanel: (workspaceId) => {\n    if (!workspaceId) return;\n    const state = get();\n    const wsState =\n      state.workspacePanelStates[workspaceId] ?? DEFAULT_WORKSPACE_PANEL_STATE;\n    if (wsState.isLeftMainPanelVisible && wsState.rightMainPanelMode === null)\n      return;\n    set({\n      workspacePanelStates: {\n        ...state.workspacePanelStates,\n        [workspaceId]: {\n          ...wsState,\n          isLeftMainPanelVisible: !wsState.isLeftMainPanelVisible,\n        },\n      },\n    });\n  },\n\n  toggleRightSidebar: () =>\n    set((s) => ({ isRightSidebarVisible: !s.isRightSidebarVisible })),\n\n  toggleTerminal: () =>\n    set((s) => ({ isTerminalVisible: !s.isTerminalVisible })),\n\n  setTerminalVisible: (value) => set({ isTerminalVisible: value }),\n\n  toggleRightMainPanelMode: (mode, workspaceId) => {\n    if (!workspaceId) return;\n    const state = get();\n    const wsState =\n      state.workspacePanelStates[workspaceId] ?? DEFAULT_WORKSPACE_PANEL_STATE;\n    const isCurrentlyActive = wsState.rightMainPanelMode === mode;\n    const isMobile = window.matchMedia('(max-width: 767px)').matches;\n\n    set({\n      workspacePanelStates: {\n        ...state.workspacePanelStates,\n        [workspaceId]: {\n          ...wsState,\n          rightMainPanelMode: isCurrentlyActive ? null : mode,\n        },\n      },\n      isLeftSidebarVisible: isCurrentlyActive\n        ? true\n        : isWideScreen()\n          ? state.isLeftSidebarVisible\n          : false,\n      ...(isMobile &&\n        !isCurrentlyActive && { mobileActiveTab: mode as MobileTab }),\n    });\n  },\n\n  setRightMainPanelMode: (mode, workspaceId) => {\n    if (!workspaceId) return;\n    const state = get();\n    const wsState =\n      state.workspacePanelStates[workspaceId] ?? DEFAULT_WORKSPACE_PANEL_STATE;\n    const isMobile = window.matchMedia('(max-width: 767px)').matches;\n    set({\n      workspacePanelStates: {\n        ...state.workspacePanelStates,\n        [workspaceId]: {\n          ...wsState,\n          rightMainPanelMode: mode,\n        },\n      },\n      ...(mode !== null && {\n        isLeftSidebarVisible: isWideScreen()\n          ? state.isLeftSidebarVisible\n          : false,\n      }),\n      ...(isMobile && mode !== null && { mobileActiveTab: mode as MobileTab }),\n    });\n  },\n\n  setLeftSidebarVisible: (value) => set({ isLeftSidebarVisible: value }),\n\n  setLeftMainPanelVisible: (value, workspaceId) => {\n    if (!workspaceId) return;\n    const state = get();\n    const wsState =\n      state.workspacePanelStates[workspaceId] ?? DEFAULT_WORKSPACE_PANEL_STATE;\n    set({\n      workspacePanelStates: {\n        ...state.workspacePanelStates,\n        [workspaceId]: {\n          ...wsState,\n          isLeftMainPanelVisible: value,\n        },\n      },\n    });\n  },\n\n  triggerPreviewRefresh: () =>\n    set((s) => ({ previewRefreshKey: s.previewRefreshKey + 1 })),\n\n  // Workspace-specific panel state actions\n  getWorkspacePanelState: (workspaceId) => {\n    const state = get();\n    return (\n      state.workspacePanelStates[workspaceId] ?? DEFAULT_WORKSPACE_PANEL_STATE\n    );\n  },\n\n  setWorkspacePanelState: (workspaceId, panelState) => {\n    const state = get();\n    const currentWsState =\n      state.workspacePanelStates[workspaceId] ?? DEFAULT_WORKSPACE_PANEL_STATE;\n    set({\n      workspacePanelStates: {\n        ...state.workspacePanelStates,\n        [workspaceId]: {\n          ...currentWsState,\n          ...panelState,\n        },\n      },\n    });\n  },\n\n  // Kanban view selection actions\n  setKanbanProjectView: (projectId, viewId) => {\n    if (!isKanbanProjectViewId(viewId)) {\n      return;\n    }\n\n    set((s) => ({\n      kanbanProjectViewSelections: {\n        ...s.kanbanProjectViewSelections,\n        [projectId]: { activeViewId: viewId },\n      },\n    }));\n  },\n\n  setKanbanProjectViewFilters: (projectId, viewId, filters) => {\n    if (!isKanbanProjectViewId(viewId)) {\n      return;\n    }\n\n    set((s) => {\n      const projectPreferences =\n        s.kanbanProjectViewPreferences[projectId] ?? {};\n      const existingPreferences =\n        projectPreferences[viewId] ??\n        createDefaultKanbanProjectViewPreferences(viewId);\n\n      return {\n        kanbanProjectViewPreferences: {\n          ...s.kanbanProjectViewPreferences,\n          [projectId]: {\n            ...projectPreferences,\n            [viewId]: {\n              ...existingPreferences,\n              filters: cloneKanbanFilters(filters),\n            },\n          },\n        },\n      };\n    });\n  },\n\n  setKanbanProjectViewShowSubIssues: (projectId, viewId, show) => {\n    if (!isKanbanProjectViewId(viewId)) {\n      return;\n    }\n\n    set((s) => {\n      const projectPreferences =\n        s.kanbanProjectViewPreferences[projectId] ?? {};\n      const existingPreferences =\n        projectPreferences[viewId] ??\n        createDefaultKanbanProjectViewPreferences(viewId);\n\n      return {\n        kanbanProjectViewPreferences: {\n          ...s.kanbanProjectViewPreferences,\n          [projectId]: {\n            ...projectPreferences,\n            [viewId]: {\n              ...existingPreferences,\n              showSubIssues: show,\n            },\n          },\n        },\n      };\n    });\n  },\n\n  setKanbanProjectViewShowWorkspaces: (projectId, viewId, show) => {\n    if (!isKanbanProjectViewId(viewId)) {\n      return;\n    }\n\n    set((s) => {\n      const projectPreferences =\n        s.kanbanProjectViewPreferences[projectId] ?? {};\n      const existingPreferences =\n        projectPreferences[viewId] ??\n        createDefaultKanbanProjectViewPreferences(viewId);\n\n      return {\n        kanbanProjectViewPreferences: {\n          ...s.kanbanProjectViewPreferences,\n          [projectId]: {\n            ...projectPreferences,\n            [viewId]: {\n              ...existingPreferences,\n              showWorkspaces: show,\n            },\n          },\n        },\n      };\n    });\n  },\n\n  clearKanbanProjectViewPreferences: (projectId, viewId) => {\n    if (!isKanbanProjectViewId(viewId)) {\n      return;\n    }\n\n    set((s) => {\n      const projectPreferences = s.kanbanProjectViewPreferences[projectId];\n      if (!projectPreferences || !projectPreferences[viewId]) {\n        return {};\n      }\n\n      const nextProjectPreferences = { ...projectPreferences };\n      delete nextProjectPreferences[viewId];\n\n      const nextAllPreferences = { ...s.kanbanProjectViewPreferences };\n      if (Object.keys(nextProjectPreferences).length === 0) {\n        delete nextAllPreferences[projectId];\n      } else {\n        nextAllPreferences[projectId] = nextProjectPreferences;\n      }\n\n      return {\n        kanbanProjectViewPreferences: nextAllPreferences,\n      };\n    });\n  },\n\n  // Workspace sidebar filter actions\n  setWorkspaceProjectFilter: (projectIds) =>\n    set((s) => ({\n      workspaceFilters: { ...s.workspaceFilters, projectIds },\n    })),\n\n  setWorkspacePrFilter: (prFilter) =>\n    set((s) => ({\n      workspaceFilters: { ...s.workspaceFilters, prFilter },\n    })),\n\n  clearWorkspaceFilters: () =>\n    set({ workspaceFilters: DEFAULT_WORKSPACE_FILTER_STATE }),\n\n  setWorkspaceSortBy: (sortBy) =>\n    set((s) => ({\n      workspaceSort: { ...s.workspaceSort, sortBy },\n    })),\n\n  setWorkspaceSortOrder: (sortOrder) =>\n    set((s) => ({\n      workspaceSort: { ...s.workspaceSort, sortOrder },\n    })),\n\n  // Kanban view mode actions\n  setKanbanViewMode: (mode) => set({ kanbanViewMode: mode }),\n\n  setListViewStatusFilter: (statusId) =>\n    set({ listViewStatusFilter: statusId }),\n\n  // Mobile tab actions\n  setMobileActiveTab: (tab) => set({ mobileActiveTab: tab }),\n\n  // Mobile font scale actions\n  setMobileFontScale: (scale) => {\n    try {\n      if (scale === 'default') {\n        localStorage.removeItem(MOBILE_FONT_SCALE_KEY);\n      } else {\n        localStorage.setItem(MOBILE_FONT_SCALE_KEY, scale);\n      }\n    } catch {\n      // localStorage may be unavailable\n    }\n    set({ mobileFontScale: scale });\n  },\n\n  // Last selected organization and project actions\n  setSelectedOrgId: (orgId) => set({ selectedOrgId: orgId }),\n  clearSelectedOrgId: () => set({ selectedOrgId: null }),\n  setSelectedProjectId: (projectId) => set({ selectedProjectId: projectId }),\n  setCreateDraftWorkspaceByDefault: (value) =>\n    set({ createDraftWorkspaceByDefault: value }),\n}));\n\n// Hook for repo action preference\nexport function useRepoAction(\n  repoId: string,\n  defaultAction: RepoAction = 'pull-request'\n): [RepoAction, (action: RepoAction) => void] {\n  const action = useUiPreferencesStore(\n    (s) => s.repoActions[repoId] ?? defaultAction\n  );\n  const setAction = useUiPreferencesStore((s) => s.setRepoAction);\n  return [action, (a) => setAction(repoId, a)];\n}\n\n// Hook for persisted expanded state\nexport function usePersistedExpanded(\n  key: PersistKey,\n  defaultValue = true\n): [boolean, (value?: boolean) => void] {\n  const expanded = useUiPreferencesStore(\n    (s) => s.expanded[key] ?? defaultValue\n  );\n  const setExpanded = useUiPreferencesStore((s) => s.setExpanded);\n  const toggleExpanded = useUiPreferencesStore((s) => s.toggleExpanded);\n\n  const set = (value?: boolean) => {\n    if (typeof value === 'boolean') setExpanded(key, value);\n    else toggleExpanded(key, defaultValue);\n  };\n\n  return [expanded, set];\n}\n\n// Hook for context bar position\nexport function useContextBarPosition(): [\n  ContextBarPosition,\n  (position: ContextBarPosition) => void,\n] {\n  const position = useUiPreferencesStore((s) => s.contextBarPosition);\n  const setPosition = useUiPreferencesStore((s) => s.setContextBarPosition);\n  return [position, setPosition];\n}\n\n// Hook for pane size preference\nexport function usePaneSize(\n  key: PersistKey,\n  defaultSize: number | string\n): [number | string, (size: number | string) => void] {\n  const size = useUiPreferencesStore((s) => s.paneSizes[key] ?? defaultSize);\n  const setSize = useUiPreferencesStore((s) => s.setPaneSize);\n  return [size, (s) => setSize(key, s)];\n}\n\n// Hook for bulk expanded state operations\nexport function useExpandedAll() {\n  const expanded = useUiPreferencesStore((s) => s.expanded);\n  const setExpanded = useUiPreferencesStore((s) => s.setExpanded);\n  const setExpandedAll = useUiPreferencesStore((s) => s.setExpandedAll);\n  return { expanded, setExpanded, setExpandedAll };\n}\n\n// Hook for persisted file tree collapsed paths (per workspace)\nexport function usePersistedCollapsedPaths(\n  workspaceId: string | undefined\n): [Set<string>, (paths: Set<string>) => void] {\n  const key = workspaceId ? `file-tree:${workspaceId}` : '';\n  const paths = useUiPreferencesStore((s) => s.collapsedPaths[key] ?? []);\n  const setPaths = useUiPreferencesStore((s) => s.setCollapsedPaths);\n\n  const pathSet = useMemo(() => new Set(paths), [paths]);\n  const setPathSet = useCallback(\n    (newPaths: Set<string>) => {\n      if (key) setPaths(key, [...newPaths]);\n    },\n    [key, setPaths]\n  );\n\n  return [pathSet, setPathSet];\n}\n\n// Hook for mobile active tab\nexport function useMobileActiveTab() {\n  const tab = useUiPreferencesStore((s) => s.mobileActiveTab);\n  const set = useUiPreferencesStore((s) => s.setMobileActiveTab);\n  return [tab, set] as const;\n}\n\n// Hook for mobile font scale\nexport function useMobileFontScale() {\n  const scale = useUiPreferencesStore((s) => s.mobileFontScale);\n  const set = useUiPreferencesStore((s) => s.setMobileFontScale);\n  return [scale, set] as const;\n}\n\n// Hook for workspace-specific panel state\nexport function useWorkspacePanelState(workspaceId: string | undefined) {\n  // Get workspace-specific state (falls back to defaults when no workspaceId)\n  const workspacePanelStates = useUiPreferencesStore(\n    (s) => s.workspacePanelStates\n  );\n  const wsState = workspaceId\n    ? (workspacePanelStates[workspaceId] ?? DEFAULT_WORKSPACE_PANEL_STATE)\n    : DEFAULT_WORKSPACE_PANEL_STATE;\n\n  // Global state (sidebars are global)\n  const isLeftSidebarVisible = useUiPreferencesStore(\n    (s) => s.isLeftSidebarVisible\n  );\n  const isRightSidebarVisible = useUiPreferencesStore(\n    (s) => s.isRightSidebarVisible\n  );\n  const isTerminalVisible = useUiPreferencesStore((s) => s.isTerminalVisible);\n\n  // Actions from store\n  const toggleRightMainPanelMode = useUiPreferencesStore(\n    (s) => s.toggleRightMainPanelMode\n  );\n  const setRightMainPanelMode = useUiPreferencesStore(\n    (s) => s.setRightMainPanelMode\n  );\n  const setLeftMainPanelVisible = useUiPreferencesStore(\n    (s) => s.setLeftMainPanelVisible\n  );\n  const setLeftSidebarVisible = useUiPreferencesStore(\n    (s) => s.setLeftSidebarVisible\n  );\n\n  // Memoized callbacks that include workspaceId\n  const toggleRightMainPanelModeForWorkspace = useCallback(\n    (mode: RightMainPanelMode) => toggleRightMainPanelMode(mode, workspaceId),\n    [toggleRightMainPanelMode, workspaceId]\n  );\n\n  const setRightMainPanelModeForWorkspace = useCallback(\n    (mode: RightMainPanelMode | null) =>\n      setRightMainPanelMode(mode, workspaceId),\n    [setRightMainPanelMode, workspaceId]\n  );\n\n  const setLeftMainPanelVisibleForWorkspace = useCallback(\n    (value: boolean) => setLeftMainPanelVisible(value, workspaceId),\n    [setLeftMainPanelVisible, workspaceId]\n  );\n\n  return {\n    // Workspace-specific state\n    rightMainPanelMode: wsState.rightMainPanelMode,\n    isLeftMainPanelVisible: wsState.isLeftMainPanelVisible,\n\n    // Global state (sidebars and terminal)\n    isLeftSidebarVisible,\n    isRightSidebarVisible,\n    isTerminalVisible,\n\n    // Workspace-specific actions\n    toggleRightMainPanelMode: toggleRightMainPanelModeForWorkspace,\n    setRightMainPanelMode: setRightMainPanelModeForWorkspace,\n    setLeftMainPanelVisible: setLeftMainPanelVisibleForWorkspace,\n\n    // Global actions\n    setLeftSidebarVisible,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/actions.ts",
    "content": "import type { Icon } from '@phosphor-icons/react';\nimport type { QueryClient } from '@tanstack/react-query';\nimport type {\n  EditorType,\n  ExecutionProcess,\n  Workspace,\n  PatchType,\n} from 'shared/types';\nimport type { Workspace as RemoteWorkspace } from 'shared/remote-types';\nimport type { DiffViewMode } from '@/shared/stores/useDiffViewStore';\nimport type { LayoutMode } from '@/shared/stores/useUiPreferencesStore';\nimport { RIGHT_MAIN_PANEL_MODES } from '@/shared/stores/useUiPreferencesStore';\nimport type { AppNavigation } from '@/shared/lib/routes/appNavigation';\nimport type { ProjectIssueCreateOptions } from '@/shared/stores/useKanbanIssueComposerStore';\nimport type { AppRuntime } from '@/shared/hooks/useAppRuntime';\n\n// Portable type aliases (avoid importing from component containers)\nexport type LogEntry = Extract<\n  PatchType,\n  { type: 'STDOUT' } | { type: 'STDERR' }\n>;\n\nexport type LogsPanelContent =\n  | { type: 'process'; processId: string }\n  | {\n      type: 'tool';\n      toolName: string;\n      content: string;\n      command: string | undefined;\n    }\n  | { type: 'terminal' };\n\n// Special icon types for ContextBar\nexport type SpecialIconType = 'ide-icon' | 'copy-icon';\nexport type ActionIcon = Icon | SpecialIconType;\n\n// Dev server state type for visibility context\nexport type DevServerState = 'stopped' | 'starting' | 'running' | 'stopping';\n\n// Project mutations interface (registered by ProjectProvider consumers)\nexport interface ProjectMutations {\n  removeIssue: (id: string) => void;\n  duplicateIssue: (issueId: string) => void;\n  getIssue: (issueId: string) => { simple_id: string } | undefined;\n  getAssigneesForIssue: (issueId: string) => { user_id: string }[];\n}\n\n// Workspace type for sidebar (minimal subset needed for workspace selection)\ninterface SidebarWorkspace {\n  id: string;\n  isRunning?: boolean;\n}\n\n// Context provided to action executors (from React hooks)\nexport interface ActionExecutorContext {\n  runtime: AppRuntime;\n  appNavigation: AppNavigation;\n  queryClient: QueryClient;\n  selectWorkspace: (workspaceId: string) => void;\n  activeWorkspaces: SidebarWorkspace[];\n  currentWorkspaceId: string | null;\n  containerRef: string | null;\n  runningDevServers: ExecutionProcess[];\n  startDevServer: () => void;\n  stopDevServer: () => void;\n  // Logs panel state\n  currentLogs: LogEntry[] | null;\n  logsPanelContent: LogsPanelContent | null;\n  // Command bar navigation\n  openStatusSelection: (projectId: string, issueIds: string[]) => Promise<void>;\n  openPrioritySelection: (\n    projectId: string,\n    issueIds: string[]\n  ) => Promise<void>;\n  openAssigneeSelection: (\n    projectId: string,\n    issueIds: string[],\n    isCreateMode?: boolean\n  ) => Promise<void>;\n  openSubIssueSelection: (\n    projectId: string,\n    issueId: string,\n    mode?: 'addChild' | 'setParent'\n  ) => Promise<{ type: string } | undefined>;\n  openWorkspaceSelection: (projectId: string, issueId: string) => Promise<void>;\n  openRelationshipSelection: (\n    projectId: string,\n    issueId: string,\n    relationshipType: 'blocking' | 'related' | 'has_duplicate',\n    direction: 'forward' | 'reverse'\n  ) => Promise<void>;\n  // Kanban navigation (URL-based)\n  navigateToCreateIssue: (options?: ProjectIssueCreateOptions) => void;\n  // Default status for issue creation based on current kanban tab\n  defaultCreateStatusId?: string;\n  // Current kanban context (for project settings action)\n  kanbanOrgId?: string;\n  kanbanProjectId?: string;\n  // Project mutations (registered when inside ProjectProvider)\n  projectMutations?: ProjectMutations;\n  // Remote workspaces (from Electric sync via UserContext)\n  remoteWorkspaces: RemoteWorkspace[];\n}\n\n// Context for evaluating action visibility and state conditions\nexport interface ActionVisibilityContext {\n  // Layout state\n  layoutMode: LayoutMode;\n  rightMainPanelMode:\n    | (typeof RIGHT_MAIN_PANEL_MODES)[keyof typeof RIGHT_MAIN_PANEL_MODES]\n    | null;\n  isLeftSidebarVisible: boolean;\n  isLeftMainPanelVisible: boolean;\n  isRightSidebarVisible: boolean;\n  isCreateMode: boolean;\n\n  // Workspace state\n  hasWorkspace: boolean;\n  workspaceArchived: boolean;\n\n  // Diff state\n  hasDiffs: boolean;\n  diffViewMode: DiffViewMode;\n  isAllDiffsExpanded: boolean;\n\n  // Dev server state\n  editorType: EditorType | null;\n  devServerState: DevServerState;\n  runningDevServers: ExecutionProcess[];\n\n  // Git panel state\n  hasGitRepos: boolean;\n  hasMultipleRepos: boolean;\n  hasOpenPR: boolean;\n  hasUnpushedCommits: boolean;\n\n  // Execution state\n  isAttemptRunning: boolean;\n\n  // Logs panel state\n  logsPanelContent: LogsPanelContent | null;\n\n  // Kanban state\n  hasSelectedKanbanIssue: boolean;\n  hasSelectedKanbanIssueParent: boolean;\n  isCreatingIssue: boolean;\n\n  // Auth state\n  isSignedIn: boolean;\n}\n\n// Enum discriminant for action target types\nexport enum ActionTargetType {\n  NONE = 'none',\n  WORKSPACE = 'workspace',\n  GIT = 'git',\n  ISSUE = 'issue',\n}\n\n// Base properties shared by all actions\ninterface ActionBase {\n  id: string;\n  label: string | ((workspace?: Workspace) => string);\n  icon: ActionIcon;\n  shortcut?: string;\n  variant?: 'default' | 'destructive';\n  keywords?: string[];\n  isVisible?: (ctx: ActionVisibilityContext) => boolean;\n  isActive?: (ctx: ActionVisibilityContext) => boolean;\n  isEnabled?: (ctx: ActionVisibilityContext) => boolean;\n  getIcon?: (ctx: ActionVisibilityContext) => ActionIcon;\n  getTooltip?: (ctx: ActionVisibilityContext) => string;\n  getLabel?: (ctx: ActionVisibilityContext) => string;\n}\n\n// Global action (no target needed)\nexport interface GlobalActionDefinition extends ActionBase {\n  requiresTarget: ActionTargetType.NONE;\n  execute: (ctx: ActionExecutorContext) => Promise<void> | void;\n}\n\n// Workspace action (target required - validated by ActionsContext)\nexport interface WorkspaceActionDefinition extends ActionBase {\n  requiresTarget: ActionTargetType.WORKSPACE;\n  execute: (\n    ctx: ActionExecutorContext,\n    workspaceId: string\n  ) => Promise<void> | void;\n}\n\n// Git action (requires workspace + repoId)\nexport interface GitActionDefinition extends ActionBase {\n  requiresTarget: ActionTargetType.GIT;\n  execute: (\n    ctx: ActionExecutorContext,\n    workspaceId: string,\n    repoId: string\n  ) => Promise<void> | void;\n}\n\n// Issue action (requires projectId + issueIds)\nexport interface IssueActionDefinition extends ActionBase {\n  requiresTarget: ActionTargetType.ISSUE;\n  execute: (\n    ctx: ActionExecutorContext,\n    projectId: string,\n    issueIds: string[]\n  ) => Promise<void> | void;\n}\n\n// Discriminated union\nexport type ActionDefinition =\n  | GlobalActionDefinition\n  | WorkspaceActionDefinition\n  | GitActionDefinition\n  | IssueActionDefinition;\n\n// Divider markers\nexport const NavbarDivider = { type: 'divider' } as const;\nexport type NavbarItem = ActionDefinition | typeof NavbarDivider;\nexport const ContextBarDivider = { type: 'divider' } as const;\nexport type ContextBarItem = ActionDefinition | typeof ContextBarDivider;\n\n// Helper to resolve dynamic label\nexport function resolveLabel(\n  action: ActionDefinition,\n  workspace?: Workspace\n): string {\n  return typeof action.label === 'function'\n    ? action.label(workspace)\n    : action.label;\n}\n\n// Helper to check if an icon is a special type\nexport function isSpecialIcon(icon: ActionIcon): icon is SpecialIconType {\n  return icon === 'ide-icon' || icon === 'copy-icon';\n}\n\n// Pure action helper functions\nexport function isActionVisible(\n  action: ActionDefinition,\n  ctx: ActionVisibilityContext\n): boolean {\n  return action.isVisible ? action.isVisible(ctx) : true;\n}\n\nexport function isActionActive(\n  action: ActionDefinition,\n  ctx: ActionVisibilityContext\n): boolean {\n  return action.isActive ? action.isActive(ctx) : false;\n}\n\nexport function isActionEnabled(\n  action: ActionDefinition,\n  ctx: ActionVisibilityContext\n): boolean {\n  return action.isEnabled ? action.isEnabled(ctx) : true;\n}\n\nexport function getActionIcon(\n  action: ActionDefinition,\n  ctx: ActionVisibilityContext\n): ActionIcon {\n  return action.getIcon ? action.getIcon(ctx) : action.icon;\n}\n\nexport function getActionTooltip(\n  action: ActionDefinition,\n  ctx: ActionVisibilityContext\n): string {\n  return action.getTooltip ? action.getTooltip(ctx) : resolveLabel(action);\n}\n\nexport function getActionLabel(\n  action: ActionDefinition,\n  ctx: ActionVisibilityContext,\n  workspace?: Workspace\n): string {\n  return action.getLabel\n    ? action.getLabel(ctx)\n    : resolveLabel(action, workspace);\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/attempt.ts",
    "content": "import type { Workspace, Session } from 'shared/types';\n\n/**\n * WorkspaceWithSession includes the latest Session for the workspace.\n * Provides access to session.id, session.executor, etc.\n */\nexport type WorkspaceWithSession = Workspace & {\n  session: Session | undefined;\n};\n\n/**\n * Create a WorkspaceWithSession from a Workspace and Session.\n */\nexport function createWorkspaceWithSession(\n  workspace: Workspace,\n  session: Session | undefined\n): WorkspaceWithSession {\n  return {\n    ...workspace,\n    session,\n  };\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/commandBar.ts",
    "content": "import type { Icon } from '@phosphor-icons/react';\nimport type { Issue } from 'shared/remote-types';\nimport type { ActionDefinition, ActionVisibilityContext } from './actions';\nimport type {\n  RepoItem,\n  StatusItem,\n  PriorityItem,\n  BranchItem,\n} from '@/shared/types/selectionItems';\n\n// Define page IDs first to avoid circular reference\nexport type PageId =\n  | 'root'\n  | 'workspaceActions'\n  | 'diffOptions'\n  | 'viewOptions'\n  | 'repoActions' // Page for repo-specific actions (opened from repo card or CMD+K)\n  | 'issueActions'; // Page for issue-specific actions (kanban mode)\n\n// Items that can appear inside a group\nexport type CommandBarGroupItem =\n  | { type: 'action'; action: ActionDefinition }\n  | { type: 'page'; pageId: PageId; label: string; icon: Icon }\n  | { type: 'childPages'; id: PageId };\n\n// Group container with label and nested items\nexport interface CommandBarGroup {\n  type: 'group';\n  label: string;\n  items: CommandBarGroupItem[];\n}\n\n// Top-level items in a page are groups\nexport type CommandBarItem = CommandBarGroup;\n\n// Resolved types (after childPages expansion)\nexport type ResolvedGroupItem =\n  | { type: 'action'; action: ActionDefinition }\n  | { type: 'page'; pageId: PageId; label: string; icon: Icon }\n  | { type: 'repo'; repo: RepoItem }\n  | { type: 'status'; status: StatusItem }\n  | { type: 'priority'; priority: PriorityItem }\n  | { type: 'issue'; issue: Issue }\n  | { type: 'createSubIssue' }\n  | { type: 'branch'; branch: BranchItem };\n\nexport interface ResolvedGroup {\n  label: string;\n  items: ResolvedGroupItem[];\n}\n\nexport interface CommandBarPage {\n  id: string;\n  title?: string; // Optional heading shown in command bar\n  items: CommandBarItem[];\n  // Optional: parent page for back button navigation\n  parent?: PageId;\n  // Optional visibility condition - if omitted, page is always visible\n  isVisible?: (ctx: ActionVisibilityContext) => boolean;\n}\n\nexport type StaticPageId = PageId;\n"
  },
  {
    "path": "packages/web-core/src/shared/types/createMode.ts",
    "content": "import type { ExecutorConfig } from 'shared/types';\n\nexport interface LinkedIssue {\n  issueId: string;\n  simpleId?: string;\n  title?: string;\n  remoteProjectId: string;\n}\n\nexport interface CreateModeInitialState {\n  initialPrompt?: string | null;\n  preferredRepos?: Array<{\n    repo_id: string;\n    target_branch: string | null;\n  }> | null;\n  project_id?: string | null;\n  linkedIssue?: LinkedIssue | null;\n  executorConfig?: ExecutorConfig | null;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/diff.ts",
    "content": "/**\n * Represents which side of a diff (old/deletions or new/additions).\n * This matches the SplitSide enum from @git-diff-view/react (Old=0, New=1)\n * but allows us to decouple from that library.\n */\nexport enum DiffSide {\n  Old = 0,\n  New = 1,\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/fileTree.ts",
    "content": "import type { Diff, DiffChangeKind } from 'shared/types';\n\nexport type TreeNodeType = 'file' | 'folder';\n\nexport interface TreeNode {\n  id: string;\n  name: string;\n  path: string;\n  type: TreeNodeType;\n  children?: TreeNode[];\n  diff?: Diff;\n  changeKind?: DiffChangeKind;\n  additions?: number | null;\n  deletions?: number | null;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/logs.ts",
    "content": "import type { NormalizedEntry, ExecutorAction } from 'shared/types';\n\nexport interface UnifiedLogEntry {\n  id: string;\n  ts: number; // epoch-ms timestamp for sorting and react-window key\n  processId: string;\n  processName: string;\n  channel: 'raw' | 'stdout' | 'stderr' | 'normalized' | 'process_start';\n  payload: string | NormalizedEntry | ProcessStartPayload;\n}\n\nexport interface ProcessStartPayload {\n  processId: string;\n  runReason: string;\n  startedAt: string;\n  status: string;\n  action?: ExecutorAction;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/modal-args.d.ts",
    "content": "import { TaskWithAttemptStatus, Workspace } from 'shared/types';\n\n// Extend nice-modal-react to provide type safety for modal arguments\ndeclare module '@ebay/nice-modal-react' {\n  interface ModalArgs {\n    'create-pr': {\n      attempt: Workspace;\n      task: TaskWithAttemptStatus;\n      projectId: string;\n    };\n  }\n}\n\nexport {};\n"
  },
  {
    "path": "packages/web-core/src/shared/types/modals.ts",
    "content": "import type { ConfirmDialogProps } from '@/shared/dialogs/shared/ConfirmDialog';\nimport type { EditorSelectionDialogProps } from '@/shared/dialogs/command-bar/EditorSelectionDialog';\n\n// Type definitions for nice-modal-react modal arguments\n// Note: 'create-pr' is declared in modal-args.d.ts\ndeclare module '@ebay/nice-modal-react' {\n  interface ModalArgs {\n    // Generic modals\n    confirm: ConfirmDialogProps;\n\n    // App flow modals\n    'release-notes': void;\n\n    'editor-selection': EditorSelectionDialogProps;\n  }\n}\n\nexport {};\n"
  },
  {
    "path": "packages/web-core/src/shared/types/previewDevTools.ts",
    "content": "// Message source identifier\nexport const PREVIEW_DEVTOOLS_SOURCE = 'vibe-devtools' as const;\nexport type PreviewDevToolsSource = typeof PREVIEW_DEVTOOLS_SOURCE;\n\n// === Entry Types (for state management) ===\n\nexport interface NavigationState {\n  url: string;\n  title?: string;\n  canGoBack: boolean;\n  canGoForward: boolean;\n}\n\n// === Message Types (from iframe to parent) ===\n\nexport interface NavigationMessage {\n  source: PreviewDevToolsSource;\n  type: 'navigation';\n  payload: NavigationState & {\n    timestamp: number;\n    docId?: string;\n    seq?: number;\n  };\n}\n\nexport interface ReadyMessage {\n  source: PreviewDevToolsSource;\n  type: 'ready';\n  payload?: {\n    docId?: string;\n  };\n}\n\n// === Command Types (from parent to iframe) ===\n\nexport interface NavigationCommand {\n  source: PreviewDevToolsSource;\n  type: 'navigate';\n  payload: {\n    action: 'back' | 'forward' | 'refresh' | 'goto';\n    url?: string; // for 'goto' action\n  };\n}\n\n// === Union Types ===\n\nexport type PreviewDevToolsMessage = NavigationMessage | ReadyMessage;\n\nexport type PreviewDevToolsCommand = NavigationCommand;\n\n// === Type Guards ===\n\nexport function isPreviewDevToolsMessage(\n  data: unknown\n): data is PreviewDevToolsMessage {\n  return (\n    typeof data === 'object' &&\n    data !== null &&\n    'source' in data &&\n    (data as { source: unknown }).source === PREVIEW_DEVTOOLS_SOURCE\n  );\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/selectionItems.ts",
    "content": "import type { IssuePriority } from 'shared/remote-types';\n\nexport interface RepoItem {\n  id: string;\n  display_name: string;\n}\n\nexport interface StatusItem {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface PriorityItem {\n  id: IssuePriority | null;\n  name: string;\n}\n\nexport interface BranchItem {\n  name: string;\n  isCurrent: boolean;\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/tabs.ts",
    "content": "export type TabType = 'logs' | 'diffs' | 'processes' | 'preview';\n"
  },
  {
    "path": "packages/web-core/src/shared/types/tanstack-history.d.ts",
    "content": "import '@tanstack/history';\n\ndeclare module '@tanstack/history' {\n  interface HistoryState {\n    [key: string]: unknown;\n  }\n}\n"
  },
  {
    "path": "packages/web-core/src/shared/types/virtual-executor-schemas.d.ts",
    "content": "declare module 'virtual:executor-schemas' {\n  import type { RJSFSchema } from '@rjsf/utils';\n  import type { BaseCodingAgent } from '@/shared/types';\n\n  const schemas: Record<BaseCodingAgent, RJSFSchema>;\n  export { schemas };\n  export default schemas;\n}\n"
  },
  {
    "path": "packages/web-core/src/styles/diff-style-overrides.css",
    "content": "@import '../app/styles/diff-style-overrides.css';\n"
  },
  {
    "path": "packages/web-core/src/styles/edit-diff-overrides.css",
    "content": "@import '../app/styles/edit-diff-overrides.css';\n"
  },
  {
    "path": "packages/web-core/src/styles/new/index.css",
    "content": "@import '../../app/styles/new/index.css';\n"
  },
  {
    "path": "packages/web-core/src/test/fixtures/normalized_entries.json",
    "content": "[\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"entry_type\": {\n        \"type\": \"user_message\"\n      },\n      \"content\": \"Remove the logo\\n\\nfrontend/src/components/layout/Navbar.tsx\",\n      \"timestamp\": null\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:user\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"error_message\",\n        \"error_type\": {\n          \"type\": \"other\"\n        }\n      },\n      \"content\": \"npm warn Unknown env config \\\"_jsr-registry\\\". This will stop working in the next major version of npm.\\nnpm warn Unknown env config \\\"verify-deps-before-run\\\". This will stop working in the next major version of npm.\\n\",\n      \"metadata\": null\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:0\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"system_message\"\n      },\n      \"content\": \"System initialized with model: claude-sonnet-4-5-20250929\",\n      \"metadata\": null\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:1\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"assistant_message\"\n      },\n      \"content\": \"I'll help you remove the logo from the Navbar component. Let me first read the file to see the current implementation.\",\n      \"metadata\": {\n        \"type\": \"text\",\n        \"text\": \"I'll help you remove the logo from the Navbar component. Let me first read the file to see the current implementation.\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:2\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"tool_use\",\n        \"tool_name\": \"Read\",\n        \"action_type\": {\n          \"action\": \"file_read\",\n          \"path\": \"frontend/src/components/layout/Navbar.tsx\"\n        },\n        \"status\": {\n          \"status\": \"created\"\n        }\n      },\n      \"content\": \"frontend/src/components/layout/Navbar.tsx\",\n      \"metadata\": {\n        \"type\": \"tool_use\",\n        \"id\": \"toolu_01C4eRJgKfiHg5YNz74DnqkZ\",\n        \"name\": \"Read\",\n        \"input\": {\n          \"file_path\": \"/private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\"\n        },\n        \"tool_call_id\": \"toolu_01C4eRJgKfiHg5YNz74DnqkZ\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:3\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"assistant_message\"\n      },\n      \"content\": \"Now I'll remove the logo from the Navbar. I can see the logo is rendered on lines 141-143. I'll also need to remove the import of the Logo component from line 23.\",\n      \"metadata\": {\n        \"type\": \"text\",\n        \"text\": \"Now I'll remove the logo from the Navbar. I can see the logo is rendered on lines 141-143. I'll also need to remove the import of the Logo component from line 23.\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:4\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"tool_use\",\n        \"tool_name\": \"Edit\",\n        \"action_type\": {\n          \"action\": \"file_edit\",\n          \"path\": \"frontend/src/components/layout/Navbar.tsx\",\n          \"changes\": [\n            {\n              \"action\": \"edit\",\n              \"unified_diff\": \"--- a//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n+++ b//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n@@ -1,2 +1 @@\\n-import { Logo } from '@/components/Logo';\\n import { SearchBar } from '@/components/SearchBar';\\n\",\n              \"has_line_numbers\": false\n            }\n          ]\n        },\n        \"status\": {\n          \"status\": \"created\"\n        }\n      },\n      \"content\": \"frontend/src/components/layout/Navbar.tsx\",\n      \"metadata\": {\n        \"type\": \"tool_use\",\n        \"id\": \"toolu_01ECjA5SgaonrPsSnd4bkfxB\",\n        \"name\": \"Edit\",\n        \"input\": {\n          \"file_path\": \"/private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\",\n          \"old_string\": \"import { Logo } from '@/components/Logo';\\nimport { SearchBar } from '@/components/SearchBar';\",\n          \"new_string\": \"import { SearchBar } from '@/components/SearchBar';\"\n        },\n        \"tool_call_id\": \"toolu_01ECjA5SgaonrPsSnd4bkfxB\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:5\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"tool_use\",\n        \"tool_name\": \"Edit\",\n        \"action_type\": {\n          \"action\": \"file_edit\",\n          \"path\": \"frontend/src/components/layout/Navbar.tsx\",\n          \"changes\": [\n            {\n              \"action\": \"edit\",\n              \"unified_diff\": \"--- a//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n+++ b//private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\\n@@ -1,6 +1,3 @@\\n           <div className=\\\"flex-1 flex items-center\\\">\\n-            <Link to=\\\"/projects\\\">\\n-              <Logo />\\n-            </Link>\\n             <a\\n               href=\\\"https://discord.gg/AC4nwVtJM3\\\"\\n\",\n              \"has_line_numbers\": false\n            }\n          ]\n        },\n        \"status\": {\n          \"status\": \"created\"\n        }\n      },\n      \"content\": \"frontend/src/components/layout/Navbar.tsx\",\n      \"metadata\": {\n        \"type\": \"tool_use\",\n        \"id\": \"toolu_01QNp5prW7wYa6SmfQQaTkrR\",\n        \"name\": \"Edit\",\n        \"input\": {\n          \"file_path\": \"/private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/33af-remove-the-logo/frontend/src/components/layout/Navbar.tsx\",\n          \"old_string\": \"          <div className=\\\"flex-1 flex items-center\\\">\\n            <Link to=\\\"/projects\\\">\\n              <Logo />\\n            </Link>\\n            <a\\n              href=\\\"https://discord.gg/AC4nwVtJM3\\\"\",\n          \"new_string\": \"          <div className=\\\"flex-1 flex items-center\\\">\\n            <a\\n              href=\\\"https://discord.gg/AC4nwVtJM3\\\"\"\n        },\n        \"tool_call_id\": \"toolu_01QNp5prW7wYa6SmfQQaTkrR\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:6\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"timestamp\": null,\n      \"entry_type\": {\n        \"type\": \"assistant_message\"\n      },\n      \"content\": \"Perfect! I've successfully removed the logo from the Navbar component. The changes I made:\\n\\n1. **Removed the Logo import** (frontend/src/components/layout/Navbar.tsx:23)\\n2. **Removed the Logo component and its Link wrapper** (frontend/src/components/layout/Navbar.tsx:140-143)\\n\\nThe logo has been removed while keeping all other navigation functionality intact, including the Discord link, search bar, and menu buttons.\",\n      \"metadata\": {\n        \"type\": \"text\",\n        \"text\": \"Perfect! I've successfully removed the logo from the Navbar component. The changes I made:\\n\\n1. **Removed the Logo import** (frontend/src/components/layout/Navbar.tsx:23)\\n2. **Removed the Logo component and its Link wrapper** (frontend/src/components/layout/Navbar.tsx:140-143)\\n\\nThe logo has been removed while keeping all other navigation functionality intact, including the Discord link, search bar, and menu buttons.\"\n      }\n    },\n    \"patchKey\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d:7\",\n    \"executionProcessId\": \"b2a8c33e-7b14-4903-81e6-4082d300ee0d\"\n  },\n  {\n    \"type\": \"NORMALIZED_ENTRY\",\n    \"content\": {\n      \"entry_type\": {\n        \"type\": \"next_action\",\n        \"failed\": false,\n        \"execution_processes\": 1,\n        \"needs_setup\": false,\n        \"setup_help_text\": null\n      },\n      \"content\": \"\",\n      \"timestamp\": null\n    },\n    \"patchKey\": \"next_action\",\n    \"executionProcessId\": \"\"\n  }\n]\n"
  },
  {
    "path": "packages/web-core/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_VK_SHARED_API_BASE?: string;\n  readonly VITE_RELAY_API_BASE_URL?: string;\n}\n\ndeclare const __APP_VERSION__: string;\n"
  },
  {
    "path": "packages/web-core/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"shared/*\": [\"../../shared/*\"]\n    }\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n\nonlyBuiltDependencies:\n  - '@sentry/cli'\n  - core-js\n  - esbuild\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"nightly-2025-12-04\"\ncomponents = [\n    \"rustfmt\",\n    \"rustc\",\n    \"rust-analyzer\",\n    \"rust-src\",\n    \"rust-std\",\n    \"cargo\",\n]\nprofile = \"default\""
  },
  {
    "path": "rustfmt.toml",
    "content": "edition = \"2024\"\nreorder_imports = true\ngroup_imports = \"StdExternalCrate\"\nimports_granularity = \"Crate\""
  },
  {
    "path": "scripts/build-bippy-bundle.mjs",
    "content": "/**\n * Build a vendored bippy IIFE bundle for injection into proxied HTML pages.\n *\n * Produces: crates/server/src/preview_proxy/bippy_bundle.js\n * Global:   window.VKBippy\n *\n * Usage: node scripts/build-bippy-bundle.mjs\n */\n\nimport { build } from 'esbuild';\nimport { writeFileSync, unlinkSync, mkdirSync } from 'fs';\nimport { join, dirname } from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst ROOT = join(__dirname, '..');\nconst OUT_FILE = join(\n  ROOT,\n  'crates/server/src/preview_proxy/bippy_bundle.js',\n);\n\n// Temporary entrypoint that imports bippy + bippy/source, installs the hook,\n// and re-exports everything we need.\nconst ENTRY_CODE = `\nimport {\n  getFiberFromHostInstance,\n  getDisplayName,\n  isCompositeFiber,\n  traverseFiber,\n  isInstrumentationActive,\n  safelyInstallRDTHook,\n} from 'bippy';\n\nimport {\n  getOwnerStack,\n  normalizeFileName,\n  isSourceFile,\n} from 'bippy/source';\n\n// Install React DevTools hook immediately — must run before React initializes.\nsafelyInstallRDTHook();\n\nexport {\n  getFiberFromHostInstance,\n  getDisplayName,\n  isCompositeFiber,\n  traverseFiber,\n  isInstrumentationActive,\n  getOwnerStack,\n  normalizeFileName,\n  isSourceFile,\n};\n`;\n\nconst tmpEntry = join(ROOT, '_bippy_entry.tmp.mjs');\n\ntry {\n  writeFileSync(tmpEntry, ENTRY_CODE, 'utf-8');\n\n  const result = await build({\n    entryPoints: [tmpEntry],\n    bundle: true,\n    format: 'iife',\n    globalName: 'VKBippy',\n    platform: 'browser',\n    target: ['es2020'],\n    minify: true,\n    outfile: OUT_FILE,\n    // Inline everything — no external dependencies\n    external: [],\n    logLevel: 'info',\n    metafile: true,\n  });\n\n  // Report size\n  const outBytes =\n    result.metafile.outputs[Object.keys(result.metafile.outputs)[0]].bytes;\n  const kb = (outBytes / 1024).toFixed(1);\n\n  if (outBytes > 50 * 1024) {\n    console.error(`\\n❌ Bundle too large: ${kb} KB (limit: 50 KB)`);\n    process.exit(1);\n  }\n\n  console.log(`\\n✅ bippy bundle built: ${OUT_FILE} (${kb} KB)`);\n} finally {\n  try {\n    unlinkSync(tmpEntry);\n  } catch {\n    // ignore cleanup errors\n  }\n}\n"
  },
  {
    "path": "scripts/build-tauri-msi.js",
    "content": "#!/usr/bin/env node\n/**\n * Build MSI installer for Windows using wixl (from msitools).\n *\n * Processes the WiX template (crates/tauri-app/msi-template.wxs),\n * replaces template variables with actual values, and invokes wixl\n * to produce an MSI file.\n *\n * Usage:\n *   node scripts/build-tauri-msi.js --target <target> [--version <version>]\n *\n * Example:\n *   node scripts/build-tauri-msi.js --target x86_64-pc-windows-msvc\n *   node scripts/build-tauri-msi.js --target aarch64-pc-windows-msvc --version 0.1.27\n */\n\nconst fs = require('fs');\nconst path = require('path');\nconst { execSync } = require('child_process');\n\n// Parse CLI args\nconst args = process.argv.slice(2);\nfunction getArg(name) {\n  const idx = args.indexOf(`--${name}`);\n  return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;\n}\n\nconst target = getArg('target');\nif (!target) {\n  console.error('Usage: node scripts/build-tauri-msi.js --target <target> [--version <version>]');\n  process.exit(1);\n}\n\n// Paths\nconst projectRoot = path.resolve(__dirname, '..');\nconst tauriAppDir = path.join(projectRoot, 'crates', 'tauri-app');\nconst templatePath = path.join(tauriAppDir, 'msi-template.wxs');\nconst confPath = path.join(tauriAppDir, 'tauri.conf.json');\nconst iconPath = path.join(tauriAppDir, 'icons', 'icon.ico');\n\n\n// Read tauri.conf.json for product metadata\nconst conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));\nconst productName = conf.productName || 'Vibe Kanban';\nconst confVersion = conf.version || '0.0.0';\n\n// Version from CLI or config\nlet version = getArg('version') || confVersion;\n// MSI requires exactly 3-part version (major.minor.patch), strip prerelease\nversion = version.replace(/-.*$/, '');\nconst parts = version.split('.');\nwhile (parts.length < 3) parts.push('0');\nversion = parts.slice(0, 3).join('.');\n\n// Manufacturer from identifier (reverse domain → organization)\nconst manufacturer = 'Bloop';\n\n// Stable upgrade code (UUID v5-style, derived from identifier — must never change)\nconst upgradeCode = 'E8C15B4D-5F9A-4B3E-8C1A-7D2F6E9A3B8C';\n\n// Path component GUID (stable per product)\nconst pathComponentGuid = 'A7B3D1E9-6F2C-4A8B-9E5D-1C3F7B2A8D6E';\n\n// Architecture mapping\n// Note: wixl only supports x86/x64/ia64. For arm64 targets we use x64 MSI\n// format which is standard — the arm64 binary is embedded inside an x64 MSI\n// package. Windows on ARM runs x64 MSIs natively.\nconst archMap = {\n  'x86_64-pc-windows-msvc': 'x64',\n  'aarch64-pc-windows-msvc': 'x64',\n};\nconst wixArch = archMap[target];\nif (!wixArch) {\n  console.error(`Unsupported target: ${target}`);\n  process.exit(1);\n}\n\n// Binary path\nconst binaryName = 'vibe-kanban-tauri.exe';\nconst mainBinaryPath = path.join(projectRoot, 'target', target, 'release', binaryName);\n\nif (!fs.existsSync(mainBinaryPath)) {\n  console.error(`Binary not found: ${mainBinaryPath}`);\n  console.error('Build the Tauri app first with: cargo tauri build --runner cargo-xwin --target ' + target + ' --ci');\n  process.exit(1);\n}\n\nif (!fs.existsSync(iconPath)) {\n  console.error(`Icon not found: ${iconPath}`);\n  process.exit(1);\n}\n\n// Read and process template\nconsole.log(`Processing WiX template for ${target} (${wixArch})...`);\nlet template = fs.readFileSync(templatePath, 'utf8');\n\nconst replacements = {\n  '{{product_name}}': productName,\n  '{{version}}': version,\n  '{{manufacturer}}': manufacturer,\n  '{{upgrade_code}}': upgradeCode,\n  '{{path_component_guid}}': pathComponentGuid,\n  '{{icon_path}}': iconPath,\n  '{{main_binary_path}}': mainBinaryPath,\n};\n\nfor (const [placeholder, value] of Object.entries(replacements)) {\n  template = template.split(placeholder).join(value);\n}\n\n// Write processed template to temp file\nconst bundleDir = path.join(projectRoot, 'target', target, 'release', 'bundle', 'msi');\nfs.mkdirSync(bundleDir, { recursive: true });\n\nconst processedWxs = path.join(bundleDir, 'processed.wxs');\n\n// Platform-appropriate filename (use target arch, not MSI package arch)\nconst archSuffix = target.startsWith('aarch64') ? 'aarch64' : 'x86_64';\nconst msiOutput = path.join(bundleDir, `${productName.replace(/\\s+/g, '-')}-${version}-${archSuffix}.msi`);\n\nfs.writeFileSync(processedWxs, template);\n\n\nconsole.log(`  Product: ${productName}`);\nconsole.log(`  Version: ${version}`);\nconsole.log(`  Arch:    ${wixArch}`);\nconsole.log(`  Binary:  ${mainBinaryPath}`);\nconsole.log(`  Output:  ${msiOutput}`);\n\n// Run wixl\nconst cmd = `wixl -v --arch ${wixArch} --ext ui \"${processedWxs}\" -o \"${msiOutput}\"`;\nconsole.log(`\\nRunning: ${cmd}`);\n\ntry {\n  execSync(cmd, { stdio: 'inherit', cwd: projectRoot });\n  console.log(`\\nMSI built successfully: ${msiOutput}`);\n\n  const stats = fs.statSync(msiOutput);\n  const sizeMB = (stats.size / 1024 / 1024).toFixed(1);\n  console.log(`Size: ${sizeMB} MB`);\n} catch (err) {\n  console.error('\\nwixl failed. Ensure msitools is installed (apt-get install msitools).');\n  process.exit(1);\n}\n"
  },
  {
    "path": "scripts/check-i18n.sh",
    "content": "#!/usr/bin/env bash\n# i18n regression check script\n# Compares i18next/no-literal-string violations between PR and main branch\n# Initial implementation: This script will show high violation counts until enforcement is enabled\nset -eo pipefail\n\nRULE=\"i18next/no-literal-string\"\n\n# Function that outputs violation count to stdout\nlint_count() {\n  local dir=$1\n  local tmp\n  tmp=$(mktemp)\n  \n  trap 'rm -f \"$tmp\"' RETURN\n  \n  (\n    set -eo pipefail\n    cd \"$dir/packages/local-web\"\n    # Lint current directory using ESLint from PR workspace\n    LINT_I18N=true npx --prefix \"$REPO_ROOT/packages/local-web\" eslint . \\\n      --ext ts,tsx \\\n      --format json \\\n      --output-file \"$tmp\" \\\n      --no-error-on-unmatched-pattern \\\n      > /dev/null 2>&1 || true  # Don't fail on violations\n  )\n  \n  # Parse the clean JSON file\n  jq --arg RULE \"$RULE\" \\\n     '[.[].messages[] | select(.ruleId == $RULE)] | length' \"$tmp\" \\\n     2>/dev/null || echo \"0\"\n}\n\nget_json_keys() {\n  local file=$1\n  if [ ! -f \"$file\" ]; then\n    return 2\n  fi\n  jq -r '\n    paths(scalars) as $p\n    | select(getpath($p) | type == \"string\")\n    | $p | join(\".\")\n  ' \"$file\" 2>/dev/null | LC_ALL=C sort -u\n}\n\ncheck_duplicate_keys() {\n  local file=$1\n  if [ ! -f \"$file\" ]; then\n    return 2\n  fi\n\n  # Strategy: Use jq's --stream flag to detect duplicate keys\n  # jq --stream processes JSON before parsing (preserves duplicates)\n  # jq tostream processes JSON after parsing (duplicates already collapsed)\n  # If the outputs differ, duplicate keys exist\n  if ! diff -q <(jq --stream . \"$file\" 2>/dev/null) <(jq tostream \"$file\" 2>/dev/null) > /dev/null 2>&1; then\n    # Duplicates found\n    echo \"duplicate keys detected\"\n    return 1\n  fi\n  return 0\n}\n\ncheck_duplicate_json_keys() {\n  local locales_dir=\"$REPO_ROOT/packages/web-core/src/i18n/locales\"\n  local exit_code=0\n\n  if [ ! -d \"$locales_dir\" ]; then\n    echo \"❌ Locales directory not found: $locales_dir\"\n    return 1\n  fi\n\n  # Check all JSON files in all locale directories\n  while IFS= read -r file; do\n    local rel_path=\"${file#$locales_dir/}\"\n    local duplicates\n\n    if duplicates=$(check_duplicate_keys \"$file\"); then\n      : # No duplicates found\n    else\n      echo \"❌ [$rel_path] Duplicate keys found:\"\n      printf '   - %s\\n' $duplicates\n      echo \"   JSON silently overwrites duplicate keys - only the last occurrence is used!\"\n      exit_code=1\n    fi\n  done < <(find \"$locales_dir\" -type f -name \"*.json\" 2>/dev/null)\n\n  return \"$exit_code\"\n}\n\ncheck_key_consistency() {\n  local locales_dir=\"$REPO_ROOT/packages/web-core/src/i18n/locales\"\n  local exit_code=0\n  local fail_on_extra=\"${I18N_FAIL_ON_EXTRA:-0}\"\n  local verbose=\"${I18N_VERBOSE:-0}\"\n\n  if [ ! -d \"$locales_dir/en\" ]; then\n    echo \"❌ Missing source locale directory: $locales_dir/en\"\n    return 1\n  fi\n\n  # Compute namespaces from en\n  local namespaces=()\n  while IFS= read -r ns; do\n    namespaces+=(\"$ns\")\n  done < <(find \"$locales_dir/en\" -maxdepth 1 -type f -name \"*.json\" -exec basename {} .json \\; 2>/dev/null | LC_ALL=C sort)\n  \n  # Compute languages from locales\n  local languages=()\n  while IFS= read -r lang; do\n    languages+=(\"$lang\")\n  done < <(find \"$locales_dir\" -maxdepth 1 -mindepth 1 -type d -exec basename {} \\; 2>/dev/null | LC_ALL=C sort)\n\n  # Ensure en exists\n  if ! printf '%s\\n' \"${languages[@]}\" | grep -qx \"en\"; then\n    echo \"❌ Source language 'en' not found in $locales_dir\"\n    return 1\n  fi\n\n  for ns in \"${namespaces[@]}\"; do\n    local ref_file=\"$locales_dir/en/$ns.json\"\n    if ! ref_keys=$(get_json_keys \"$ref_file\"); then\n      echo \"❌ Invalid or unreadable JSON: $ref_file\"\n      exit_code=1\n      continue\n    fi\n\n    for lang in \"${languages[@]}\"; do\n      [ \"$lang\" = \"en\" ] && continue\n      local tgt_file=\"$locales_dir/$lang/$ns.json\"\n\n      local tgt_keys\n      local missing\n      local extra\n      \n      if ! tgt_keys=$(get_json_keys \"$tgt_file\"); then\n        echo \"❌ [$lang/$ns] Missing or invalid JSON: $tgt_file\"\n        echo \"   All keys from en/$ns are considered missing.\"\n        missing=\"$ref_keys\"\n        extra=\"\"\n        exit_code=1\n      else\n        # Compute set differences\n        missing=$(comm -23 <(printf \"%s\\n\" \"$ref_keys\") <(printf \"%s\\n\" \"$tgt_keys\"))\n        extra=$(comm -13 <(printf \"%s\\n\" \"$ref_keys\") <(printf \"%s\\n\" \"$tgt_keys\"))\n      fi\n\n      if [ -n \"$missing\" ]; then\n        echo \"❌ [$lang/$ns] Missing keys:\"\n        if [ \"$verbose\" = \"1\" ]; then\n          printf '   - %s\\n' $missing\n        else\n          printf '   - %s\\n' $(echo \"$missing\" | head -n 50)\n          local total_missing\n          total_missing=$(printf \"%s\\n\" \"$missing\" | wc -l | tr -d ' ')\n          if [ \"$total_missing\" -gt 50 ]; then\n            echo \"   ... and $((total_missing - 50)) more. Set I18N_VERBOSE=1 to print all.\"\n          fi\n        fi\n        exit_code=1\n      fi\n\n      if [ -n \"$extra\" ]; then\n        if [ \"$fail_on_extra\" = \"1\" ]; then\n          echo \"❌ [$lang/$ns] Extra keys (not in en):\"\n          [ \"$verbose\" = \"1\" ] && printf '   - %s\\n' $extra || printf '   - %s\\n' $(echo \"$extra\" | head -n 50)\n          exit_code=1\n        else\n          echo \"⚠️  [$lang/$ns] Extra keys (not in en):\"\n          [ \"$verbose\" = \"1\" ] && printf '   - %s\\n' $extra || printf '   - %s\\n' $(echo \"$extra\" | head -n 50)\n        fi\n      fi\n    done\n  done\n\n  return \"$exit_code\"\n}\n\necho \"▶️  Counting literal strings in PR branch...\"\nREPO_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\nPR_COUNT=$(lint_count \"$REPO_ROOT\")\n\nBASE_REF=\"${GITHUB_BASE_REF:-main}\"\necho \"▶️  Fetching $BASE_REF for baseline (shallow clone)...\"\nREMOTE_URL=$(git -C \"$REPO_ROOT\" remote get-url origin)\nBASE_DIR=\"$(mktemp -d)\"\ncleanup_base() { rm -rf \"$BASE_DIR\"; }\ntrap cleanup_base EXIT\n\nif git clone --depth=1 --branch \"$BASE_REF\" --single-branch \"$REMOTE_URL\" \"$BASE_DIR\" >/dev/null 2>&1; then\n  BASE_COUNT=$(lint_count \"$BASE_DIR\")\nelse\n  echo \"⚠️  Could not clone $BASE_REF; defaulting baseline to 0.\"\n  BASE_COUNT=0\nfi\n\necho \"\"\necho \"📊 I18n Violation Summary:\"\necho \"   Base branch ($BASE_REF): $BASE_COUNT violations\"\necho \"   PR branch: $PR_COUNT violations\"\necho \"\"\n\nEXIT_STATUS=0\n\nif (( PR_COUNT > BASE_COUNT )); then\n  echo \"❌ PR introduces $((PR_COUNT - BASE_COUNT)) new hard-coded strings.\"\n  echo \"\"\n  echo \"💡 To fix, replace hardcoded strings with translation calls:\"\n  echo \"   Before: <Button>Save</Button>\"\n  echo \"   After:  <Button>{t('buttons.save')}</Button>\"\n  echo \"\"\n  echo \"Files with new violations:\"\n  (cd \"$REPO_ROOT/packages/local-web\" && LINT_I18N=true npx eslint . --ext ts,tsx --rule \"$RULE:error\" -f codeframe 2>/dev/null || true)\n  EXIT_STATUS=1\nelif (( PR_COUNT < BASE_COUNT )); then\n  echo \"🎉 Great job! PR removes $((BASE_COUNT - PR_COUNT)) hard-coded strings.\"\n  echo \"   This helps improve i18n coverage!\"\nelse\n  echo \"✅ No new literal strings introduced.\"\nfi\n\necho \"\"\necho \"▶️  Checking for duplicate JSON keys...\"\nif ! check_duplicate_json_keys; then\n  EXIT_STATUS=1\nelse\n  echo \"✅ No duplicate keys found in JSON files.\"\nfi\n\necho \"\"\necho \"▶️  Checking translation key consistency...\"\nif ! check_key_consistency; then\n  EXIT_STATUS=1\nelse\n  echo \"✅ Translation keys are consistent across locales.\"\nfi\n\nexit \"$EXIT_STATUS\"\n"
  },
  {
    "path": "scripts/check-legacy-frontend-paths.sh",
    "content": "#!/usr/bin/env bash\n# Blocks net-new files in legacy frontend paths during the structure migration.\nset -euo pipefail\n\nREPO_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\nALLOWLIST_FILE=\"$REPO_ROOT/scripts/legacy-frontend-paths-allowlist.txt\"\n\nLEGACY_PATHS=(\n  \"packages/local-web/src/components/ui-new\"\n  \"packages/local-web/src/components/dialogs\"\n)\n\nNAVIGATION_FILES=(\n  \"packages/web-core/src/shared/lib/routes/appNavigation.ts\"\n  \"packages/web-core/src/shared/hooks/useAppNavigation.ts\"\n  \"packages/local-web/src/app/navigation/AppNavigation.ts\"\n  \"packages/remote-web/src/app/navigation/AppNavigation.ts\"\n)\n\necho \"▶️  Checking for net-new files in legacy frontend paths...\"\n\nif [ ! -f \"$ALLOWLIST_FILE\" ]; then\n  echo \"❌ Missing allowlist: $ALLOWLIST_FILE\"\n  exit 1\nfi\n\ncurrent_files=\"$(\n  git -C \"$REPO_ROOT\" ls-files \"${LEGACY_PATHS[@]}\" | LC_ALL=C sort\n)\"\nallowed_files=\"$(\n  { grep -v '^\\s*#' \"$ALLOWLIST_FILE\" || true; } |\n    sed '/^\\s*$/d' |\n    LC_ALL=C sort\n)\"\n\nnew_files=\"$(\n  comm -13 <(printf '%s\\n' \"$allowed_files\") <(printf '%s\\n' \"$current_files\")\n)\"\n\nif [ -n \"$new_files\" ]; then\n  echo \"❌ New files found in frozen legacy paths:\"\n  printf '  - %s\\n' $new_files\n  echo \"\"\n  echo \"Add files to non-legacy paths (app/pages/widgets/features/entities/shared/integrations) instead.\"\n  exit 1\nfi\n\nremoved_files=\"$(\n  comm -23 <(printf '%s\\n' \"$allowed_files\") <(printf '%s\\n' \"$current_files\")\n)\"\nif [ -n \"$removed_files\" ]; then\n  echo \"ℹ️  Some allowlisted legacy files were removed. You can prune stale entries in:\"\n  echo \"   scripts/legacy-frontend-paths-allowlist.txt\"\nfi\n\necho \"✅ No net-new files in legacy frontend paths.\"\n\necho \"▶️  Checking navigation modules for explicit any...\"\n\nany_hits=\"$(\n  grep \\\n    -nE \\\n    '(as[[:space:]]+any([^[:alnum:]_]|$)|:[[:space:]]*any([^[:alnum:]_]|$)|<any>)' \\\n    \"${NAVIGATION_FILES[@]}\" || true\n)\"\n\nif [ -n \"$any_hits\" ]; then\n  echo \"❌ Explicit any found in navigation modules:\"\n  printf '%s\\n' \"$any_hits\"\n  exit 1\nfi\n\necho \"✅ No explicit any in navigation modules.\"\n\necho \"▶️  Checking web-core for navigate({ to: '.' ... }) usage...\"\n\ndot_navigation_hits=\"$(\n  find \"$REPO_ROOT/packages/web-core/src\" \\\n    -type f \\( -name '*.ts' -o -name '*.tsx' \\) \\\n    -print0 |\n    xargs -0 perl -0ne '\n      my $content = $_;\n      while ($content =~ /navigate\\s*\\(\\s*\\{[\\s\\S]*?\\bto\\s*:\\s*[\"\\x27]\\.[\"\\x27][\\s\\S]*?\\}\\s*\\)/g) {\n        my $line = 1 + (substr($content, 0, $-[0]) =~ tr/\\n//);\n        print \"$ARGV:$line\\n\";\n      }\n    ' || true\n)\"\n\nif [ -n \"$dot_navigation_hits\" ]; then\n  echo \"❌ Found navigate({ to: '.' ... }) usage in web-core:\"\n  printf '%s\\n' \"$dot_navigation_hits\"\n  echo \"\"\n  echo \"Use AppNavigation destination methods instead of route-local '.' normalization.\"\n  exit 1\nfi\n\necho \"✅ No navigate({ to: '.' ... }) usage in web-core.\"\n\necho \"▶️  Checking web-core for direct appNavigation.navigate(...) usage...\"\n\napp_navigation_navigate_hits=\"$(\n  find \"$REPO_ROOT/packages/web-core/src\" \\\n    -type f \\( -name '*.ts' -o -name '*.tsx' \\) \\\n    -print0 |\n    xargs -0 grep -nE 'appNavigation[[:space:]]*\\.[[:space:]]*navigate[[:space:]]*\\(' || true\n)\"\n\nif [ -n \"$app_navigation_navigate_hits\" ]; then\n  echo \"❌ Found direct appNavigation.navigate(...) usage in web-core:\"\n  printf '%s\\n' \"$app_navigation_navigate_hits\"\n  echo \"\"\n  echo \"Use goTo* methods or goToAppDestination(...) instead.\"\n  exit 1\nfi\n\necho \"✅ No direct appNavigation.navigate(...) usage in web-core.\"\n\necho \"▶️  Checking web-core for legacy pathResolution imports...\"\n\npath_resolution_import_hits=\"$(\n  find \"$REPO_ROOT/packages/web-core/src\" \\\n    -type f \\( -name '*.ts' -o -name '*.tsx' \\) \\\n    -print0 |\n    xargs -0 grep -nE '@/shared/lib/routes/pathResolution' || true\n)\"\n\nif [ -n \"$path_resolution_import_hits\" ]; then\n  echo \"❌ Found legacy pathResolution imports in web-core:\"\n  printf '%s\\n' \"$path_resolution_import_hits\"\n  echo \"\"\n  echo \"Use appNavigation.resolveFromPath(...) and AppDestination helpers instead.\"\n  exit 1\nfi\n\necho \"✅ No legacy pathResolution imports in web-core.\"\n"
  },
  {
    "path": "scripts/check-unused-i18n-keys.mjs",
    "content": "#!/usr/bin/env node\n/**\n * Checks for unused i18n translation keys.\n *\n * Scans the English locale JSON files and verifies that every leaf key is\n * referenced somewhere in the TypeScript/React source code.  Keys that are\n * only reachable via dynamic patterns (template-literal prefixes, i18next\n * pluralisation suffixes, or shortcut action lookups) are accepted as well.\n *\n * Usage:\n *   node scripts/check-unused-i18n-keys.mjs          # error on unused keys\n *   node scripts/check-unused-i18n-keys.mjs --list    # just print, exit 0\n */\nimport fs from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\nimport { fileURLToPath } from 'url';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\nconst ROOT = path.resolve(__dirname, '..');\n\nconst localesDir = path.join(\n  ROOT,\n  'packages/web-core/src/i18n/locales/en',\n);\nconst namespaces = ['common', 'settings', 'projects', 'tasks', 'organization'];\nconst srcDirs = [\n  'packages/web-core/src',\n  'packages/local-web/src',\n  'packages/remote-web/src',\n  'packages/ui/src',\n].map((d) => path.join(ROOT, d));\n\nconst listOnly = process.argv.includes('--list');\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction flattenKeys(obj, prefix = '') {\n  let keys = [];\n  for (const [key, value] of Object.entries(obj)) {\n    const fullKey = prefix ? `${prefix}.${key}` : key;\n    if (typeof value === 'object' && value !== null) {\n      keys = keys.concat(flattenKeys(value, fullKey));\n    } else {\n      keys.push(fullKey);\n    }\n  }\n  return keys;\n}\n\n/** Collect the concatenated contents of every .ts/.tsx file under `dirs`. */\nfunction loadSourceContent(dirs) {\n  let content = '';\n  for (const dir of dirs) {\n    try {\n      const files = execSync(\n        `find \"${dir}\" \\\\( -name \"*.tsx\" -o -name \"*.ts\" \\\\) ! -name \"*.d.ts\"`,\n        { encoding: 'utf8' },\n      )\n        .trim()\n        .split('\\n')\n        .filter(Boolean);\n      for (const file of files) {\n        content += fs.readFileSync(file, 'utf8') + '\\n';\n      }\n    } catch {\n      // directory might not exist\n    }\n  }\n  return content;\n}\n\nfunction isStaticKeyUsed(key, ns, src) {\n  if (\n    src.includes(`'${key}'`) ||\n    src.includes(`\"${key}\"`) ||\n    src.includes(`\\`${key}\\``)\n  )\n    return true;\n\n  const nsKey = `${ns}:${key}`;\n  return src.includes(`'${nsKey}'`) || src.includes(`\"${nsKey}\"`);\n}\n\nfunction isDynamicallyUsed(key, ns, src) {\n  const parts = key.split('.');\n\n  // Check if any parent prefix is used in a dynamic pattern.\n  for (let i = 1; i < parts.length; i++) {\n    const prefix = parts.slice(0, i).join('.');\n    const patterns = [\n      `\\`${prefix}.`,\n      `'${prefix}.' +`,\n      `\"${prefix}.\" +`,\n      `'${prefix}.' + `,\n      `\"${prefix}.\" + `,\n      `${ns}:${prefix}.`,\n    ];\n    if (patterns.some((p) => src.includes(p))) return true;\n  }\n\n  // i18next pluralisation suffixes (_one, _other, _zero, _few, _many, _two)\n  const pluralSuffixes = ['_one', '_other', '_zero', '_few', '_many', '_two'];\n  for (const suffix of pluralSuffixes) {\n    if (key.endsWith(suffix)) {\n      const baseKey = key.slice(0, -suffix.length);\n      if (\n        src.includes(`'${baseKey}'`) ||\n        src.includes(`\"${baseKey}\"`) ||\n        src.includes(`\\`${baseKey}\\``) ||\n        src.includes(`'${baseKey}',`) ||\n        src.includes(`\"${baseKey}\",`)\n      )\n        return true;\n    }\n  }\n\n  // Shortcut action keys are looked up by their action name.\n  if (key.startsWith('shortcuts.actions.')) {\n    const actionName = key.replace('shortcuts.actions.', '');\n    if (src.includes(`'${actionName}'`) || src.includes(`\"${actionName}\"`))\n      return true;\n  }\n\n  return false;\n}\n\n// ---------------------------------------------------------------------------\n// Main\n// ---------------------------------------------------------------------------\n\nconst src = loadSourceContent(srcDirs);\nlet totalUnused = 0;\nconst unusedByNs = {};\n\nfor (const ns of namespaces) {\n  const filePath = path.join(localesDir, `${ns}.json`);\n  if (!fs.existsSync(filePath)) continue;\n\n  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n  const keys = flattenKeys(data);\n  const unused = keys.filter(\n    (k) => !isStaticKeyUsed(k, ns, src) && !isDynamicallyUsed(k, ns, src),\n  );\n\n  if (unused.length > 0) {\n    unusedByNs[ns] = unused;\n    totalUnused += unused.length;\n  }\n}\n\nif (totalUnused === 0) {\n  console.log('✅ No unused i18n keys found.');\n  process.exit(0);\n}\n\nconsole.log(`❌ Found ${totalUnused} unused i18n key(s):\\n`);\nfor (const [ns, keys] of Object.entries(unusedByNs)) {\n  console.log(`  ${ns} (${keys.length}):`);\n  for (const k of keys) {\n    console.log(`    - ${k}`);\n  }\n  console.log();\n}\nconsole.log(\n  'Remove unused keys from packages/web-core/src/i18n/locales/*/  files.',\n);\n\nprocess.exit(listOnly ? 0 : 1);\n"
  },
  {
    "path": "scripts/clang",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nexec \"$(dirname \"$0\")/ring-cc-wrapper.sh\" \"$@\"\n"
  },
  {
    "path": "scripts/dialog-import-rewrite-map.tsv",
    "content": "@/features/command-bar/ui/dialogs/AssigneeSelectionDialog\t@/dialogs/kanban/AssigneeSelectionDialog\n@/features/command-bar/ui/dialogs/PrCommentsDialog\t@/dialogs/tasks/PrCommentsDialog\n@/features/command-bar/ui/dialogs/ResolveConflictsDialog\t@/dialogs/tasks/ResolveConflictsDialog\n@/features/command-bar/ui/dialogs/RestoreLogsDialog\t@/dialogs/tasks/RestoreLogsDialog\n@/features/command-bar/ui/dialogs/commandBar/\t@/dialogs/command-bar/commandBar/\n@/features/command-bar/ui/dialogs/selections/\t@/dialogs/command-bar/selections/\n@/features/command-bar/ui/dialogs/\t@/dialogs/command-bar/\n@/features/settings/ui/dialogs/CreateOrganizationDialog\t@/dialogs/org/CreateOrganizationDialog\n@/features/settings/ui/dialogs/CreateRemoteProjectDialog\t@/dialogs/org/CreateRemoteProjectDialog\n@/features/settings/ui/dialogs/DeleteRemoteProjectDialog\t@/dialogs/org/DeleteRemoteProjectDialog\n@/features/settings/ui/dialogs/InviteMemberDialog\t@/dialogs/org/InviteMemberDialog\n@/features/settings/ui/dialogs/GhCliSetupDialog\t@/dialogs/auth/GhCliSetupDialog\n@/features/settings/ui/dialogs/OAuthDialog\t@/dialogs/global/OAuthDialog\n@/features/settings/ui/dialogs/ReleaseNotesDialog\t@/dialogs/global/ReleaseNotesDialog\n@/features/settings/ui/dialogs/settings/\t@/dialogs/settings/settings/\n@/features/settings/ui/dialogs/CreateConfigurationDialog\t@/dialogs/settings/CreateConfigurationDialog\n@/features/settings/ui/dialogs/DeleteConfigurationDialog\t@/dialogs/settings/DeleteConfigurationDialog\n@/features/settings/ui/dialogs/SettingsDialog\t@/dialogs/settings/SettingsDialog\n@/features/settings/ui/dialogs/\t@/dialogs/settings/\n@/features/kanban/ui/dialogs/KanbanFiltersDialog\t@/dialogs/kanban/KanbanFiltersDialog\n@/shared/ui/dialogs/ScriptFixerDialog\t@/dialogs/scripts/ScriptFixerDialog\n@/shared/ui/dialogs/ImagePreviewDialog\t@/dialogs/wysiwyg/ImagePreviewDialog\n@/shared/ui/dialogs/ConfirmDialog\t@/dialogs/shared/ConfirmDialog\n@/shared/ui/dialogs/FolderPickerDialog\t@/dialogs/shared/FolderPickerDialog\n@/shared/ui/dialogs/KeyboardShortcutsDialog\t@/dialogs/shared/KeyboardShortcutsDialog\n@/shared/ui/dialogs/LoginRequiredPrompt\t@/dialogs/shared/LoginRequiredPrompt\n@/shared/ui/dialogs/TagEditDialog\t@/dialogs/shared/TagEditDialog\n@/shared/ui/dialogs/WorkspacesGuideDialog\t@/dialogs/shared/WorkspacesGuideDialog\n@/components/ui-new/dialogs/AssigneeSelectionDialog\t@/dialogs/kanban/AssigneeSelectionDialog\n@/components/ui-new/dialogs/ResolveConflictsDialog\t@/dialogs/tasks/ResolveConflictsDialog\n@/components/ui-new/dialogs/SettingsDialog\t@/dialogs/settings/SettingsDialog\n@/components/ui-new/dialogs/CommandBarDialog\t@/dialogs/command-bar/CommandBarDialog\n@/components/ui-new/dialogs/SelectionDialog\t@/dialogs/command-bar/SelectionDialog\n@/components/ui-new/dialogs/WorkspaceSelectionDialog\t@/dialogs/command-bar/WorkspaceSelectionDialog\n@/components/ui-new/dialogs/RebaseDialog\t@/dialogs/command-bar/RebaseDialog\n@/components/ui-new/dialogs/KanbanFiltersDialog\t@/dialogs/kanban/KanbanFiltersDialog\n@/components/ui-new/dialogs/KeyboardShortcutsDialog\t@/dialogs/shared/KeyboardShortcutsDialog\n@/components/ui-new/dialogs/WorkspacesGuideDialog\t@/dialogs/shared/WorkspacesGuideDialog\n@/components/ui-new/dialogs/commandBar/\t@/dialogs/command-bar/commandBar/\n@/components/ui-new/dialogs/selections/\t@/dialogs/command-bar/selections/\n@/components/ui-new/dialogs/settings/rjsf/\t@/dialogs/settings/settings/rjsf/\n@/components/ui-new/dialogs/settings/\t@/dialogs/settings/settings/\n@/components/dialogs/global/OAuthDialog\t@/dialogs/global/OAuthDialog\n@/components/dialogs/global/ReleaseNotesDialog\t@/dialogs/global/ReleaseNotesDialog\n@/components/dialogs/auth/GhCliSetupDialog\t@/dialogs/auth/GhCliSetupDialog\n@/components/dialogs/org/CreateOrganizationDialog\t@/dialogs/org/CreateOrganizationDialog\n@/components/dialogs/org/InviteMemberDialog\t@/dialogs/org/InviteMemberDialog\n@/components/dialogs/org/CreateRemoteProjectDialog\t@/dialogs/org/CreateRemoteProjectDialog\n@/components/dialogs/org/DeleteRemoteProjectDialog\t@/dialogs/org/DeleteRemoteProjectDialog\n@/components/dialogs/settings/CreateConfigurationDialog\t@/dialogs/settings/CreateConfigurationDialog\n@/components/dialogs/settings/DeleteConfigurationDialog\t@/dialogs/settings/DeleteConfigurationDialog\n@/components/dialogs/git/ForcePushDialog\t@/dialogs/command-bar/ForcePushDialog\n@/components/dialogs/tasks/ChangeTargetBranchDialog\t@/dialogs/command-bar/ChangeTargetBranchDialog\n@/components/dialogs/tasks/CreatePRDialog\t@/dialogs/command-bar/CreatePRDialog\n@/components/dialogs/tasks/EditBranchNameDialog\t@/dialogs/command-bar/EditBranchNameDialog\n@/components/dialogs/tasks/EditorSelectionDialog\t@/dialogs/command-bar/EditorSelectionDialog\n@/components/dialogs/tasks/GitActionsDialog\t@/dialogs/command-bar/GitActionsDialog\n@/components/dialogs/tasks/StartReviewDialog\t@/dialogs/command-bar/StartReviewDialog\n@/components/dialogs/tasks/ViewProcessesDialog\t@/dialogs/command-bar/ViewProcessesDialog\n@/components/dialogs/tasks/RebaseDialog\t@/dialogs/command-bar/BranchRebaseDialog\n@/components/dialogs/tasks/PrCommentsDialog\t@/dialogs/tasks/PrCommentsDialog\n@/components/dialogs/tasks/RestoreLogsDialog\t@/dialogs/tasks/RestoreLogsDialog\n@/components/dialogs/tasks/TagEditDialog\t@/dialogs/shared/TagEditDialog\n@/components/dialogs/CreateWorkspaceFromPrDialog\t@/dialogs/command-bar/CreateWorkspaceFromPrDialog\n@/components/dialogs/shared/ConfirmDialog\t@/dialogs/shared/ConfirmDialog\n@/components/dialogs/shared/FolderPickerDialog\t@/dialogs/shared/FolderPickerDialog\n@/components/dialogs/shared/LoginRequiredPrompt\t@/dialogs/shared/LoginRequiredPrompt\n@/components/dialogs/scripts/ScriptFixerDialog\t@/dialogs/scripts/ScriptFixerDialog\n@/components/dialogs/wysiwyg/ImagePreviewDialog\t@/dialogs/wysiwyg/ImagePreviewDialog\n@/components/dialogs\t@/dialogs\n"
  },
  {
    "path": "scripts/generate-desktop-manifest.js",
    "content": "#!/usr/bin/env node\n//\n// Generates a desktop-manifest.json for the NPX CLI auto-install flow.\n// This manifest tells the CLI where to download the Tauri desktop app\n// bundle for each platform, along with SHA256 checksums for verification.\n//\n// Usage:\n//   node scripts/generate-desktop-manifest.js \\\n//     --version 0.2.0 \\\n//     --artifacts-dir ./tauri-artifacts \\\n//     --output desktop-manifest.json\n//\n// The artifacts-dir should contain Tauri bundle outputs per platform:\n//   tauri-artifacts/\n//     darwin-aarch64/  -> vibe-kanban.app.tar.gz\n//     darwin-x86_64/   -> vibe-kanban.app.tar.gz\n//     linux-x86_64/    -> vibe-kanban.AppImage.tar.gz\n//     linux-aarch64/   -> vibe-kanban.AppImage.tar.gz\n//     windows-x86_64/  -> vibe-kanban-setup.exe (NSIS)\n//\n\nconst fs = require('fs');\nconst path = require('path');\nconst crypto = require('crypto');\n\nfunction parseArgs() {\n  const args = process.argv.slice(2);\n  const parsed = {};\n  for (let i = 0; i < args.length; i += 2) {\n    const key = args[i].replace(/^--/, '');\n    parsed[key] = args[i + 1];\n  }\n  return parsed;\n}\n\n// Find the main bundle artifact for a platform (skip .sig and installer-only files)\nfunction findBundleArtifact(dir) {\n  if (!fs.existsSync(dir)) return null;\n\n  const files = fs.readdirSync(dir);\n\n  // Look for updater artifacts in priority order\n  // macOS: .app.tar.gz, Linux: .AppImage.tar.gz, Windows: *-setup.exe\n  const tarGz = files.find(\n    (f) =>\n      (f.endsWith('.app.tar.gz') || f.endsWith('.AppImage.tar.gz')) &&\n      !f.endsWith('.sig')\n  );\n  if (tarGz) {\n    const type = tarGz.endsWith('.app.tar.gz')\n      ? 'app-tar-gz'\n      : 'appimage-tar-gz';\n    return { file: tarGz, type };\n  }\n\n  // Windows NSIS installer\n  const nsis = files.find(\n    (f) => f.endsWith('-setup.exe') && !f.endsWith('.sig')\n  );\n  if (nsis) {\n    return { file: nsis, type: 'nsis-exe' };\n  }\n\n  return null;\n}\n\nconst args = parseArgs();\nconst version = args.version;\nconst artifactsDir = args['artifacts-dir'];\nconst output = args.output || 'desktop-manifest.json';\n\nif (!version || !artifactsDir) {\n  console.error('Required: --version, --artifacts-dir');\n  process.exit(1);\n}\n\nconst platformDirs = [\n  'darwin-aarch64',\n  'darwin-x86_64',\n  'linux-x86_64',\n  'linux-aarch64',\n  'windows-x86_64',\n  'windows-aarch64',\n];\n\nconst platforms = {};\n\nfor (const platform of platformDirs) {\n  const dir = path.join(artifactsDir, platform);\n  const artifact = findBundleArtifact(dir);\n  if (artifact) {\n    const filePath = path.join(dir, artifact.file);\n    const data = fs.readFileSync(filePath);\n    platforms[platform] = {\n      file: artifact.file,\n      sha256: crypto.createHash('sha256').update(data).digest('hex'),\n      size: data.length,\n      type: artifact.type,\n    };\n    console.log(\n      `Found ${artifact.type} for ${platform}: ${artifact.file} (${(data.length / 1024 / 1024).toFixed(1)}MB)`\n    );\n  } else {\n    console.log(`No bundle artifact found for ${platform} in ${dir}`);\n  }\n}\n\nconst manifest = { version, platforms };\n\nfs.writeFileSync(output, JSON.stringify(manifest, null, 2) + '\\n');\nconsole.log(\n  `\\nWritten ${output} with ${Object.keys(platforms).length} platform(s)`\n);\n"
  },
  {
    "path": "scripts/generate-tauri-update-json.js",
    "content": "#!/usr/bin/env node\n//\n// Generates a Tauri v2 updater `latest.json` from build artifacts.\n//\n// Usage:\n//   node scripts/generate-tauri-update-json.js \\\n//     --version 0.2.0 \\\n//     --notes \"Bug fixes\" \\\n//     --artifacts-dir ./tauri-artifacts \\\n//     --download-base \"https://github.com/BloopAI/vibe-kanban/releases/download/v0.2.0\" \\\n//     --output latest.json\n//\n// The artifacts-dir should contain Tauri bundle outputs with .sig files:\n//   tauri-artifacts/\n//     darwin-aarch64/  -> vibe-kanban.app.tar.gz, vibe-kanban.app.tar.gz.sig\n//     darwin-x86_64/   -> vibe-kanban.app.tar.gz, vibe-kanban.app.tar.gz.sig\n//     linux-x86_64/    -> vibe-kanban.AppImage.tar.gz, vibe-kanban.AppImage.tar.gz.sig\n//     windows-x86_64/  -> vibe-kanban-setup.exe, vibe-kanban-setup.exe.sig (NSIS)\n//\n\nconst fs = require('fs');\nconst path = require('path');\n\nfunction parseArgs() {\n  const args = process.argv.slice(2);\n  const parsed = {};\n  for (let i = 0; i < args.length; i += 2) {\n    const key = args[i].replace(/^--/, '');\n    parsed[key] = args[i + 1];\n  }\n  return parsed;\n}\n\nfunction findArtifact(dir) {\n  if (!fs.existsSync(dir)) return null;\n\n  const files = fs.readdirSync(dir);\n  // Look for .sig files to find the updater artifacts\n  const sigFiles = files.filter(f => f.endsWith('.sig'));\n\n  if (sigFiles.length === 0) return null;\n\n  // Prefer .tar.gz (macOS/Linux) over .exe (Windows)\n  // Tauri generates: .app.tar.gz + .sig on macOS, .AppImage.tar.gz + .sig on Linux, .exe + .sig on Windows\n  const sigFile = sigFiles[0];\n  const artifactFile = sigFile.replace(/\\.sig$/, '');\n\n  if (!files.includes(artifactFile)) {\n    console.error(`Warning: Found ${sigFile} but missing ${artifactFile} in ${dir}`);\n    return null;\n  }\n\n  return {\n    file: artifactFile,\n    signature: fs.readFileSync(path.join(dir, sigFile), 'utf-8').trim(),\n  };\n}\n\nconst args = parseArgs();\nconst version = args.version;\nconst notes = args.notes || '';\nconst artifactsDir = args['artifacts-dir'];\nconst downloadBase = args['download-base'];\nconst output = args.output || 'latest.json';\n\nif (!version || !artifactsDir || !downloadBase) {\n  console.error('Required: --version, --artifacts-dir, --download-base');\n  process.exit(1);\n}\n\n// Map of Tauri platform keys to artifact subdirectories\nconst platformMap = {\n  'darwin-aarch64': 'darwin-aarch64',\n  'darwin-x86_64': 'darwin-x86_64',\n  'linux-x86_64': 'linux-x86_64',\n  'linux-aarch64': 'linux-aarch64',\n  'windows-x86_64': 'windows-x86_64',\n  'windows-aarch64': 'windows-aarch64',\n};\n\nconst platforms = {};\n\nfor (const [platformKey, subdir] of Object.entries(platformMap)) {\n  const dir = path.join(artifactsDir, subdir);\n  const artifact = findArtifact(dir);\n  if (artifact) {\n    platforms[platformKey] = {\n      url: `${downloadBase}/${subdir}/${artifact.file}`,\n      signature: artifact.signature,\n    };\n    console.log(`Found artifact for ${platformKey}: ${artifact.file}`);\n  } else {\n    console.log(`No artifact found for ${platformKey} in ${dir}`);\n  }\n}\n\nconst manifest = {\n  version,\n  notes,\n  pub_date: new Date().toISOString(),\n  platforms,\n};\n\nfs.writeFileSync(output, JSON.stringify(manifest, null, 2) + '\\n');\nconsole.log(`\\nWritten ${output} with ${Object.keys(platforms).length} platform(s)`);\n"
  },
  {
    "path": "scripts/legacy-frontend-paths-allowlist.txt",
    "content": "# Allowed legacy frontend files during structure migration\n# Any newly added file under these paths should fail CI until moved to the new structure.\n# Paths covered:\n# - packages/local-web/src/components/ui-new\n# - packages/local-web/src/components/dialogs\n#\n# Intentionally empty: both legacy directories have been removed.\n"
  },
  {
    "path": "scripts/migrate-remote-web-structure.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport process from \"node:process\";\n\nconst APPLY = process.argv.includes(\"--apply\");\nconst VERBOSE = process.argv.includes(\"--verbose\");\n\nfunction findRepoRoot(startDir) {\n  let current = path.resolve(startDir);\n  while (true) {\n    const remotePath = path.join(current, \"packages\", \"remote-web\");\n    const scriptsPath = path.join(current, \"scripts\");\n    if (fs.existsSync(remotePath) && fs.existsSync(scriptsPath)) {\n      return current;\n    }\n    const parent = path.dirname(current);\n    if (parent === current) {\n      throw new Error(\n        `could not locate repository root from starting path: ${startDir}`,\n      );\n    }\n    current = parent;\n  }\n}\n\nconst repoRoot = findRepoRoot(process.cwd());\nconst remoteRoot = path.join(repoRoot, \"packages/remote-web\");\nconst remoteSrcRoot = path.join(remoteRoot, \"src\");\nconst indexHtmlPath = path.join(remoteRoot, \"index.html\");\n\nconst movePlan = [\n  [\"main.tsx\", \"app/entry/Bootstrap.tsx\"],\n  [\"AppRouter.tsx\", \"app/entry/App.tsx\"],\n  [\"Router.tsx\", \"app/router/index.ts\"],\n  [\"index.css\", \"app/styles/index.css\"],\n  [\"hooks/useSystemTheme.ts\", \"shared/hooks/useSystemTheme.ts\"],\n  [\"lib/api.ts\", \"shared/lib/api.ts\"],\n  [\"lib/auth.ts\", \"shared/lib/auth.ts\"],\n  [\"lib/pkce.ts\", \"shared/lib/pkce.ts\"],\n  [\"lib/tokenManager.ts\", \"shared/lib/auth/tokenManager.ts\"],\n];\n\nconst importRewritePlan = [\n  [\"./Router\", \"@/app/router\"],\n  [\"./AppRouter\", \"@/app/entry/App\"],\n  [\"./index.css\", \"@/app/styles/index.css\"],\n  [\"./routeTree.gen\", \"@/routeTree.gen\"],\n  [\"../hooks/useSystemTheme\", \"@/shared/hooks/useSystemTheme\"],\n  [\"../lib/api\", \"@/shared/lib/api\"],\n  [\"../lib/auth\", \"@/shared/lib/auth\"],\n  [\"../lib/pkce\", \"@/shared/lib/pkce\"],\n  [\"./tokenManager\", \"@/shared/lib/auth/tokenManager\"],\n  [\"./auth\", \"@/shared/lib/auth\"],\n  [\"./api\", \"@/shared/lib/api\"],\n  [\n    \"../../web-core/src/app/styles/new/index.css\",\n    \"../../../../web-core/src/app/styles/new/index.css\",\n  ],\n];\n\nfunction log(message) {\n  console.log(message);\n}\n\nfunction verbose(message) {\n  if (VERBOSE) {\n    console.log(message);\n  }\n}\n\nfunction ensureDirFor(filePath) {\n  fs.mkdirSync(path.dirname(filePath), { recursive: true });\n}\n\nfunction moveFile(fromRel, toRel) {\n  const fromAbs = path.join(remoteSrcRoot, fromRel);\n  const toAbs = path.join(remoteSrcRoot, toRel);\n\n  const fromExists = fs.existsSync(fromAbs);\n  const toExists = fs.existsSync(toAbs);\n\n  if (!fromExists && toExists) {\n    verbose(`skip move (already moved): ${fromRel} -> ${toRel}`);\n    return;\n  }\n  if (!fromExists && !toExists) {\n    throw new Error(`missing source and destination: ${fromRel} -> ${toRel}`);\n  }\n  if (fromExists && toExists) {\n    throw new Error(`destination already exists: ${fromRel} -> ${toRel}`);\n  }\n\n  ensureDirFor(toAbs);\n  if (APPLY) {\n    fs.renameSync(fromAbs, toAbs);\n    log(`moved: ${fromRel} -> ${toRel}`);\n    return;\n  }\n  log(`would move: ${fromRel} -> ${toRel}`);\n}\n\nfunction rewriteText(text, rewritePairs) {\n  let next = text;\n  for (const [from, to] of rewritePairs) {\n    next = next.replaceAll(`'${from}'`, `'${to}'`);\n    next = next.replaceAll(`\"${from}\"`, `\"${to}\"`);\n  }\n  return next;\n}\n\nfunction walkCodeFiles(dir, out = []) {\n  if (!fs.existsSync(dir)) {\n    return out;\n  }\n  const entries = fs.readdirSync(dir, { withFileTypes: true });\n  for (const entry of entries) {\n    const fullPath = path.join(dir, entry.name);\n    if (entry.isDirectory()) {\n      walkCodeFiles(fullPath, out);\n      continue;\n    }\n    if (/\\.d\\.ts$/.test(entry.name) || /\\.(ts|tsx|css)$/.test(entry.name)) {\n      out.push(fullPath);\n    }\n  }\n  return out;\n}\n\nfunction rewriteFile(filePath, rewritePairs) {\n  const current = fs.readFileSync(filePath, \"utf8\");\n  const next = rewriteText(current, rewritePairs);\n  if (next === current) {\n    return false;\n  }\n  if (APPLY) {\n    fs.writeFileSync(filePath, next);\n  }\n  return true;\n}\n\nfunction pruneEmptyDir(dirPath) {\n  if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {\n    return;\n  }\n\n  const entries = fs.readdirSync(dirPath);\n  for (const entry of entries) {\n    const fullPath = path.join(dirPath, entry);\n    if (fs.statSync(fullPath).isDirectory()) {\n      pruneEmptyDir(fullPath);\n    }\n  }\n\n  if (fs.readdirSync(dirPath).length === 0) {\n    if (APPLY) {\n      fs.rmdirSync(dirPath);\n    }\n    log(\n      `${APPLY ? \"removed\" : \"would remove\"} empty dir: ${path.relative(remoteSrcRoot, dirPath)}`,\n    );\n  }\n}\n\nfunction run() {\n  if (!fs.existsSync(remoteSrcRoot)) {\n    throw new Error(`missing remote-web src root: ${remoteSrcRoot}`);\n  }\n\n  log(\n    APPLY\n      ? \"Applying remote-web structure migration...\"\n      : \"Dry-run remote-web structure migration...\",\n  );\n\n  for (const [fromRel, toRel] of movePlan) {\n    moveFile(fromRel, toRel);\n  }\n\n  const codeFiles = walkCodeFiles(remoteSrcRoot);\n  let rewrittenCount = 0;\n\n  for (const filePath of codeFiles) {\n    const changed = rewriteFile(filePath, importRewritePlan);\n    if (changed) {\n      rewrittenCount += 1;\n      const rel = path.relative(remoteRoot, filePath);\n      log(`${APPLY ? \"rewrote\" : \"would rewrite\"} imports: ${rel}`);\n    }\n  }\n\n  if (fs.existsSync(indexHtmlPath)) {\n    const changed = rewriteFile(indexHtmlPath, [\n      [\"/src/main.tsx\", \"/src/app/entry/Bootstrap.tsx\"],\n    ]);\n    if (changed) {\n      log(`${APPLY ? \"rewrote\" : \"would rewrite\"} entry in: index.html`);\n    }\n  }\n\n  pruneEmptyDir(path.join(remoteSrcRoot, \"hooks\"));\n  pruneEmptyDir(path.join(remoteSrcRoot, \"lib\"));\n\n  log(\n    `Done. ${APPLY ? \"Updated\" : \"Would update\"} ${rewrittenCount} source files.`,\n  );\n}\n\ntry {\n  run();\n} catch (error) {\n  console.error(error instanceof Error ? error.message : String(error));\n  process.exit(1);\n}\n"
  },
  {
    "path": "scripts/prepare-db.js",
    "content": "#!/usr/bin/env node\n\nconst { execSync } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\n\nconst checkMode = process.argv.includes('--check');\n\nconsole.log(checkMode ? 'Checking SQLx prepared queries...' : 'Preparing database for SQLx...');\n\n// Change to backend directory\nconst backendDir = path.join(__dirname, '..', 'crates/db');\nprocess.chdir(backendDir);\n\n// Create temporary database file\nconst dbFile = path.join(backendDir, 'prepare_db.sqlite');\nfs.writeFileSync(dbFile, '');\n\ntry {\n  // Get absolute path (cross-platform)\n  const dbPath = path.resolve(dbFile);\n  const databaseUrl = `sqlite:${dbPath}`;\n\n  console.log(`Using database: ${databaseUrl}`);\n\n  // Run migrations\n  console.log('Running migrations...');\n  execSync('cargo sqlx migrate run', {\n    stdio: 'inherit',\n    env: { ...process.env, DATABASE_URL: databaseUrl }\n  });\n\n  // Prepare queries\n  const sqlxCommand = checkMode ? 'cargo sqlx prepare --check' : 'cargo sqlx prepare';\n  console.log(checkMode ? 'Checking prepared queries...' : 'Preparing queries...');\n  execSync(sqlxCommand, {\n    stdio: 'inherit',\n    env: { ...process.env, DATABASE_URL: databaseUrl }\n  });\n\n  console.log(checkMode ? 'SQLx check complete!' : 'Database preparation complete!');\n\n} finally {\n  // Clean up temporary file\n  if (fs.existsSync(dbFile)) {\n    fs.unlinkSync(dbFile);\n  }\n}"
  },
  {
    "path": "scripts/refactor-web-shims.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport process from 'node:process';\nimport { createRequire } from 'node:module';\n\nconst APPLY = process.argv.includes('--apply');\nconst VERBOSE = process.argv.includes('--verbose');\n\nconst repoRoot = process.cwd();\nconst webRoot = path.join(repoRoot, 'packages/local-web');\nconst srcRoot = path.join(webRoot, 'src');\nconst tsconfigPath = path.join(webRoot, 'tsconfig.json');\nconst requireFromWeb = createRequire(path.join(webRoot, 'package.json'));\nconst ts = requireFromWeb('typescript');\n\nconst CODE_EXT_RE = /\\.(?:[cm]?ts|[cm]?tsx|[cm]?js|[cm]?jsx)$/;\nconst SHIM_EXPORT_RE =\n  /^\\s*export(?:\\s+type)?\\s+.+\\s+from\\s+['\"][^'\"]+['\"]\\s*;?\\s*$/;\n\nif (!fs.existsSync(tsconfigPath)) {\n  console.error(`Missing tsconfig: ${tsconfigPath}`);\n  process.exit(1);\n}\n\nfunction norm(p) {\n  return path.resolve(p).replace(/\\\\/g, '/');\n}\n\nfunction walk(dir, out = []) {\n  const entries = fs.readdirSync(dir, { withFileTypes: true });\n  for (const entry of entries) {\n    const full = path.join(dir, entry.name);\n    if (entry.isDirectory()) {\n      if (\n        entry.name === 'node_modules' ||\n        entry.name === '.git' ||\n        entry.name === 'dist' ||\n        entry.name === 'build' ||\n        entry.name === 'coverage'\n      ) {\n        continue;\n      }\n      walk(full, out);\n      continue;\n    }\n    if (CODE_EXT_RE.test(entry.name)) {\n      out.push(full);\n    }\n  }\n  return out;\n}\n\nfunction walkHtml(dir, out = []) {\n  const entries = fs.readdirSync(dir, { withFileTypes: true });\n  for (const entry of entries) {\n    const full = path.join(dir, entry.name);\n    if (entry.isDirectory()) {\n      if (\n        entry.name === 'node_modules' ||\n        entry.name === '.git' ||\n        entry.name === 'dist' ||\n        entry.name === 'build' ||\n        entry.name === 'coverage'\n      ) {\n        continue;\n      }\n      walkHtml(full, out);\n      continue;\n    }\n    if (entry.name.endsWith('.html')) {\n      out.push(full);\n    }\n  }\n  return out;\n}\n\nfunction stripExt(relPath) {\n  return relPath.replace(/\\.(?:[cm]?ts|[cm]?tsx|[cm]?js|[cm]?jsx)$/, '');\n}\n\nfunction parseShimTargetSpecifier(fileText) {\n  const lines = fileText\n    .split(/\\r?\\n/)\n    .map((line) => line.trim())\n    .filter((line) => line !== '');\n  if (lines.length < 1 || lines.length > 2) {\n    return null;\n  }\n  const specs = [];\n  for (const line of lines) {\n    if (!SHIM_EXPORT_RE.test(line)) {\n      return null;\n    }\n    const m = line.match(/from\\s+['\"]([^'\"]+)['\"]/);\n    if (!m) {\n      return null;\n    }\n    specs.push(m[1]);\n  }\n  const uniq = [...new Set(specs)];\n  if (uniq.length !== 1) {\n    return null;\n  }\n  return uniq[0];\n}\n\nfunction getScriptKind(filePath) {\n  if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX;\n  if (filePath.endsWith('.ts')) return ts.ScriptKind.TS;\n  if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX;\n  if (filePath.endsWith('.js')) return ts.ScriptKind.JS;\n  if (filePath.endsWith('.mts')) return ts.ScriptKind.TS;\n  if (filePath.endsWith('.cts')) return ts.ScriptKind.TS;\n  if (filePath.endsWith('.mjs')) return ts.ScriptKind.JS;\n  if (filePath.endsWith('.cjs')) return ts.ScriptKind.JS;\n  return ts.ScriptKind.Unknown;\n}\n\nconst readResult = ts.readConfigFile(tsconfigPath, ts.sys.readFile);\nif (readResult.error) {\n  const msg = ts.formatDiagnosticsWithColorAndContext(\n    [readResult.error],\n    {\n      getCanonicalFileName: (f) => f,\n      getCurrentDirectory: () => process.cwd(),\n      getNewLine: () => '\\n',\n    },\n  );\n  console.error(msg);\n  process.exit(1);\n}\n\nconst parsedConfig = ts.parseJsonConfigFileContent(\n  readResult.config,\n  ts.sys,\n  webRoot,\n);\nconst compilerOptions = parsedConfig.options;\n\nfunction resolveModule(containingFile, specifier) {\n  const resolved = ts.resolveModuleName(\n    specifier,\n    containingFile,\n    compilerOptions,\n    ts.sys,\n  ).resolvedModule;\n  if (!resolved) {\n    return null;\n  }\n  return norm(resolved.resolvedFileName);\n}\n\nfunction toAliasSpecifier(absPath) {\n  const srcPrefix = `${norm(srcRoot)}/`;\n  if (!absPath.startsWith(srcPrefix)) {\n    return null;\n  }\n  let rel = absPath.slice(srcPrefix.length);\n  rel = stripExt(rel);\n  if (rel.endsWith('/index')) {\n    rel = rel.slice(0, -'/index'.length);\n  }\n  return `@/${rel}`;\n}\n\nfunction collectShimReferences(codeFiles, shimInfoByPath) {\n  const refs = [];\n  for (const file of codeFiles) {\n    const fileKey = norm(file);\n    if (shimInfoByPath.has(fileKey)) {\n      continue;\n    }\n    const text = fs.readFileSync(file, 'utf8');\n    const source = ts.createSourceFile(\n      file,\n      text,\n      ts.ScriptTarget.Latest,\n      true,\n      getScriptKind(file),\n    );\n    function maybeRecord(lit) {\n      const spec = lit.text;\n      const resolved = resolveModule(file, spec);\n      if (!resolved || !shimInfoByPath.has(resolved)) {\n        return;\n      }\n      refs.push({\n        file: path.relative(repoRoot, file),\n        spec,\n        shim: path.relative(repoRoot, shimInfoByPath.get(resolved).shimAbs),\n      });\n    }\n    function visit(node) {\n      if (\n        ts.isImportDeclaration(node) &&\n        node.moduleSpecifier &&\n        ts.isStringLiteral(node.moduleSpecifier)\n      ) {\n        maybeRecord(node.moduleSpecifier);\n      } else if (\n        ts.isExportDeclaration(node) &&\n        node.moduleSpecifier &&\n        ts.isStringLiteral(node.moduleSpecifier)\n      ) {\n        maybeRecord(node.moduleSpecifier);\n      } else if (\n        ts.isCallExpression(node) &&\n        node.expression.kind === ts.SyntaxKind.ImportKeyword &&\n        node.arguments.length > 0 &&\n        ts.isStringLiteral(node.arguments[0])\n      ) {\n        maybeRecord(node.arguments[0]);\n      }\n      ts.forEachChild(node, visit);\n    }\n    visit(source);\n  }\n  return refs;\n}\n\nconst srcCodeFiles = walk(srcRoot);\nconst shimInfoByPath = new Map();\nconst unresolvedShims = [];\n\nfor (const file of srcCodeFiles) {\n  const text = fs.readFileSync(file, 'utf8');\n  const targetSpec = parseShimTargetSpecifier(text);\n  if (!targetSpec) {\n    continue;\n  }\n  const shimAbs = norm(file);\n  const targetAbs = resolveModule(file, targetSpec);\n  if (!targetAbs) {\n    unresolvedShims.push({\n      shim: path.relative(repoRoot, file),\n      targetSpec,\n    });\n    continue;\n  }\n  shimInfoByPath.set(shimAbs, {\n    shimAbs,\n    targetSpec,\n    targetAbs,\n    finalTargetAbs: null,\n    finalAliasSpec: null,\n  });\n}\n\nif (shimInfoByPath.size === 0) {\n  console.log('No shims found.');\n  process.exit(0);\n}\n\nif (unresolvedShims.length > 0) {\n  console.error('Failed to resolve some shim destinations:');\n  for (const item of unresolvedShims) {\n    console.error(`- ${item.shim} -> ${item.targetSpec}`);\n  }\n  process.exit(1);\n}\n\nfunction resolveFinalTarget(shimAbs) {\n  const seen = new Set([shimAbs]);\n  let cursor = shimInfoByPath.get(shimAbs).targetAbs;\n  while (shimInfoByPath.has(cursor)) {\n    if (seen.has(cursor)) {\n      const cycle = [...seen, cursor]\n        .map((p) => path.relative(repoRoot, p))\n        .join(' -> ');\n      throw new Error(`Shim cycle detected: ${cycle}`);\n    }\n    seen.add(cursor);\n    cursor = shimInfoByPath.get(cursor).targetAbs;\n  }\n  return cursor;\n}\n\nfor (const [shimAbs, shimInfo] of shimInfoByPath.entries()) {\n  const finalTargetAbs = resolveFinalTarget(shimAbs);\n  const finalAliasSpec = toAliasSpecifier(finalTargetAbs);\n  if (!finalAliasSpec) {\n    console.error(\n      `Final target for shim is outside src: ${path.relative(repoRoot, shimAbs)} -> ${path.relative(repoRoot, finalTargetAbs)}`,\n    );\n    process.exit(1);\n  }\n  shimInfo.finalTargetAbs = finalTargetAbs;\n  shimInfo.finalAliasSpec = finalAliasSpec;\n}\n\nconst webCodeFiles = walk(webRoot);\nlet rewrittenFiles = 0;\nlet rewrittenSpecifiers = 0;\nconst rewrittenFileDetails = [];\n\nfor (const file of webCodeFiles) {\n  const fileKey = norm(file);\n  if (shimInfoByPath.has(fileKey)) {\n    continue;\n  }\n  const text = fs.readFileSync(file, 'utf8');\n  const source = ts.createSourceFile(\n    file,\n    text,\n    ts.ScriptTarget.Latest,\n    true,\n    getScriptKind(file),\n  );\n  const replacements = [];\n  function maybeRewrite(lit) {\n    const spec = lit.text;\n    const resolved = resolveModule(file, spec);\n    if (!resolved) {\n      return;\n    }\n    const shimInfo = shimInfoByPath.get(resolved);\n    if (!shimInfo) {\n      return;\n    }\n    if (spec === shimInfo.finalAliasSpec) {\n      return;\n    }\n    replacements.push({\n      start: lit.getStart(source) + 1,\n      end: lit.getEnd() - 1,\n      oldText: spec,\n      newText: shimInfo.finalAliasSpec,\n    });\n  }\n  function visit(node) {\n    if (\n      ts.isImportDeclaration(node) &&\n      node.moduleSpecifier &&\n      ts.isStringLiteral(node.moduleSpecifier)\n    ) {\n      maybeRewrite(node.moduleSpecifier);\n    } else if (\n      ts.isExportDeclaration(node) &&\n      node.moduleSpecifier &&\n      ts.isStringLiteral(node.moduleSpecifier)\n    ) {\n      maybeRewrite(node.moduleSpecifier);\n    } else if (\n      ts.isCallExpression(node) &&\n      node.expression.kind === ts.SyntaxKind.ImportKeyword &&\n      node.arguments.length > 0 &&\n      ts.isStringLiteral(node.arguments[0])\n    ) {\n      maybeRewrite(node.arguments[0]);\n    }\n    ts.forEachChild(node, visit);\n  }\n  visit(source);\n\n  if (replacements.length === 0) {\n    continue;\n  }\n\n  replacements.sort((a, b) => b.start - a.start);\n  let nextText = text;\n  for (const rep of replacements) {\n    nextText =\n      nextText.slice(0, rep.start) + rep.newText + nextText.slice(rep.end);\n  }\n\n  rewrittenFiles += 1;\n  rewrittenSpecifiers += replacements.length;\n  rewrittenFileDetails.push({\n    file: path.relative(repoRoot, file),\n    replacements: replacements.length,\n  });\n\n  if (APPLY) {\n    fs.writeFileSync(file, nextText);\n  }\n}\n\nconst htmlFiles = walkHtml(webRoot);\nlet rewrittenHtmlFiles = 0;\nlet rewrittenHtmlReplacements = 0;\n\nfor (const file of htmlFiles) {\n  const original = fs.readFileSync(file, 'utf8');\n  let updated = original;\n\n  const pathPairs = [];\n  for (const shimInfo of shimInfoByPath.values()) {\n    const shimRel = path.relative(srcRoot, shimInfo.shimAbs).replace(/\\\\/g, '/');\n    const targetRel = path\n      .relative(srcRoot, shimInfo.finalTargetAbs)\n      .replace(/\\\\/g, '/');\n    pathPairs.push({\n      oldPath: `/src/${shimRel}`,\n      newPath: `/src/${targetRel}`,\n    });\n    pathPairs.push({\n      oldPath: `/src/${stripExt(shimRel)}`,\n      newPath: `/src/${stripExt(targetRel)}`,\n    });\n  }\n\n  pathPairs.sort((a, b) => b.oldPath.length - a.oldPath.length);\n  let localReplacements = 0;\n  for (const { oldPath, newPath } of pathPairs) {\n    if (oldPath === newPath || !updated.includes(oldPath)) {\n      continue;\n    }\n    const count = updated.split(oldPath).length - 1;\n    if (count > 0) {\n      updated = updated.split(oldPath).join(newPath);\n      localReplacements += count;\n    }\n  }\n\n  if (localReplacements > 0) {\n    rewrittenHtmlFiles += 1;\n    rewrittenHtmlReplacements += localReplacements;\n    if (APPLY) {\n      fs.writeFileSync(file, updated);\n    }\n  }\n}\n\nconst remainingRefs = collectShimReferences(webCodeFiles, shimInfoByPath);\n\nconsole.log(`Mode: ${APPLY ? 'apply' : 'dry-run'}`);\nconsole.log(`Shims found: ${shimInfoByPath.size}`);\nconsole.log(`Code files updated: ${rewrittenFiles}`);\nconsole.log(`Code specifiers rewritten: ${rewrittenSpecifiers}`);\nconsole.log(`HTML files updated: ${rewrittenHtmlFiles}`);\nconsole.log(`HTML path rewrites: ${rewrittenHtmlReplacements}`);\nconsole.log(`Remaining shim refs in code: ${remainingRefs.length}`);\n\nif (VERBOSE && rewrittenFileDetails.length > 0) {\n  console.log('\\nRewritten files:');\n  for (const detail of rewrittenFileDetails.sort((a, b) =>\n    a.file.localeCompare(b.file),\n  )) {\n    console.log(`- ${detail.file} (${detail.replacements})`);\n  }\n}\n\nif (APPLY && remainingRefs.length > 0) {\n  console.error('\\nCannot delete shims because references still remain:');\n  for (const ref of remainingRefs.slice(0, 100)) {\n    console.error(`- ${ref.file}: '${ref.spec}' -> ${ref.shim}`);\n  }\n  if (remainingRefs.length > 100) {\n    console.error(`... and ${remainingRefs.length - 100} more`);\n  }\n  process.exit(1);\n}\n\nif (APPLY) {\n  let deleted = 0;\n  for (const shimAbs of shimInfoByPath.keys()) {\n    if (fs.existsSync(shimAbs)) {\n      fs.unlinkSync(shimAbs);\n      deleted += 1;\n    }\n  }\n  console.log(`Shims deleted: ${deleted}`);\n} else {\n  if (remainingRefs.length > 0) {\n    console.log(\n      'Note: remaining refs are expected in dry-run because files are not written.',\n    );\n  }\n  console.log('Dry-run complete. Re-run with --apply to write changes.');\n}\n"
  },
  {
    "path": "scripts/relay-test-client/README.md",
    "content": "# Relay Test Client\n\nStandalone browser client for testing relay path routing without modifying the\nRemote frontend UX.\n\n## Run\n\nFrom repo root:\n\n```bash\npython3 -m http.server 8787 --directory scripts/relay-test-client\n```\n\nOpen:\n\n`http://127.0.0.1:8787/index.html`\n\n## Auth\n\n- `Remote API Base` (example: `https://localhost:3001`)\n- `Relay API Base` (example: `https://relay.localhost:3001`)\n- Use **Sign In (GitHub)** or **Sign In (Google)** to authenticate directly in\n  the standalone client.\n- Enter the host pairing code shown in the local backend logs after relay\n  startup (`Relay PAKE enrollment code ready`).\n- Tokens are stored in browser localStorage:\n  - `relay_test_access_token`\n  - `relay_test_refresh_token`\n- Manual token override is still supported in the token textarea.\n\nThis client intentionally does **not** auto-refresh tokens. If a token expires,\nsign in again.\n\n## What It Tests\n\n1. `POST {remote_api}/v1/hosts/{host_id}/sessions`\n2. `POST {relay_api}/v1/relay/sessions/{session_id}/auth-code`\n3. `GET {relay_url}/relay/h/{host_id}/exchange?code=...` (follow redirect)\n4. `POST {relay_session_prefix}/api/relay-auth/spake2/start`\n5. `POST {relay_session_prefix}/api/relay-auth/spake2/finish`\n6. Signed `GET {relay_session_prefix}/api/task-attempts`\n\nThe output includes the derived relay session prefix and full local backend\nresponse payload.\n"
  },
  {
    "path": "scripts/relay-test-client/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>Relay Test Client</title>\n    <style>\n      :root {\n        color-scheme: light;\n      }\n      body {\n        font-family:\n          ui-sans-serif,\n          system-ui,\n          -apple-system,\n          Segoe UI,\n          Roboto,\n          Helvetica,\n          Arial,\n          sans-serif;\n        margin: 0;\n        background: #f5f7fb;\n        color: #111827;\n      }\n      main {\n        max-width: 980px;\n        margin: 24px auto;\n        padding: 0 16px 24px;\n      }\n      h1 {\n        margin: 0 0 10px;\n        font-size: 24px;\n      }\n      p {\n        margin: 0 0 12px;\n        color: #374151;\n      }\n      .card {\n        background: #ffffff;\n        border: 1px solid #dbe2ea;\n        border-radius: 10px;\n        padding: 14px;\n        margin-top: 12px;\n      }\n      .grid {\n        display: grid;\n        gap: 10px;\n      }\n      .grid-2 {\n        grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\n      }\n      label {\n        font-size: 13px;\n        font-weight: 600;\n      }\n      input,\n      textarea,\n      select,\n      button {\n        font: inherit;\n      }\n      input,\n      textarea,\n      select {\n        width: 100%;\n        box-sizing: border-box;\n        border: 1px solid #c7d1dc;\n        border-radius: 8px;\n        padding: 8px 10px;\n        background: #fff;\n      }\n      textarea {\n        min-height: 86px;\n        resize: vertical;\n      }\n      .row {\n        display: flex;\n        gap: 10px;\n        flex-wrap: wrap;\n      }\n      button {\n        border: none;\n        border-radius: 8px;\n        padding: 9px 14px;\n        cursor: pointer;\n        background: #111827;\n        color: #fff;\n        font-weight: 600;\n      }\n      button.secondary {\n        background: #e5e7eb;\n        color: #111827;\n      }\n      button:disabled {\n        cursor: default;\n        opacity: 0.6;\n      }\n      .muted {\n        font-size: 12px;\n        color: #6b7280;\n      }\n      pre {\n        background: #0b1220;\n        color: #cde0ff;\n        border-radius: 8px;\n        padding: 10px;\n        overflow: auto;\n        margin: 0;\n        font-size: 12px;\n      }\n      .ok {\n        color: #166534;\n      }\n      .err {\n        color: #b91c1c;\n      }\n    </style>\n  </head>\n  <body>\n    <main>\n      <h1>Relay Test Client</h1>\n      <p>\n        Standalone test client for relay path routing and proxied local backend\n        API calls.\n      </p>\n\n      <div class=\"card grid grid-2\">\n        <div class=\"grid\">\n          <label for=\"remoteApiBase\">Remote API Base</label>\n          <input\n            id=\"remoteApiBase\"\n            value=\"https://localhost:3001\"\n            autocomplete=\"off\"\n          />\n        </div>\n        <div class=\"grid\">\n          <label for=\"relayApiBase\">Relay API Base</label>\n          <input\n            id=\"relayApiBase\"\n            value=\"https://relay.localhost:3001\"\n            autocomplete=\"off\"\n          />\n        </div>\n        <div class=\"row\" style=\"grid-column: 1 / -1\">\n          <button id=\"loginGithubBtn\">Sign In (GitHub)</button>\n          <button id=\"loginGoogleBtn\">Sign In (Google)</button>\n          <button id=\"clearTokensBtn\" class=\"secondary\">Clear Tokens</button>\n        </div>\n        <div class=\"grid\" style=\"grid-column: 1 / -1\">\n          <label for=\"accessToken\">Bearer Access Token</label>\n          <textarea\n            id=\"accessToken\"\n            placeholder=\"Auto-filled after OAuth; manual override allowed\"\n          ></textarea>\n          <div class=\"muted\">\n            Stored in localStorage under\n            <code>relay_test_access_token</code>. No auto-refresh in this\n            client.\n          </div>\n        </div>\n      </div>\n\n      <div class=\"card grid grid-2\">\n        <div class=\"grid\">\n          <label for=\"hostSelect\">Relay Host</label>\n          <select id=\"hostSelect\"></select>\n        </div>\n        <div class=\"grid\">\n          <label for=\"hostIdOverride\">Host ID Override (optional)</label>\n          <input id=\"hostIdOverride\" placeholder=\"UUID\" autocomplete=\"off\" />\n        </div>\n        <div class=\"grid\" style=\"grid-column: 1 / -1\">\n          <label for=\"enrollmentCode\">Host Pairing Code</label>\n          <input\n            id=\"enrollmentCode\"\n            placeholder=\"Code shown in local backend relay logs\"\n            autocomplete=\"off\"\n          />\n        </div>\n        <div class=\"row\">\n          <button id=\"loadHostsBtn\" class=\"secondary\">Load Hosts</button>\n          <button id=\"runTestBtn\">Run Test</button>\n        </div>\n      </div>\n\n      <div class=\"card grid\">\n        <div>\n          <strong>Result</strong>\n          <span id=\"statusText\" class=\"muted\"></span>\n        </div>\n        <pre id=\"resultOutput\">{}</pre>\n      </div>\n\n      <div class=\"card grid\">\n        <div><strong>Log</strong></div>\n        <pre id=\"logOutput\">Ready.</pre>\n      </div>\n    </main>\n\n    <script type=\"module\">\n      import { ed25519 } from \"https://cdn.jsdelivr.net/npm/@noble/curves@1.9.7/ed25519/+esm\";\n\n      const remoteApiBaseEl = document.getElementById(\"remoteApiBase\");\n      const relayApiBaseEl = document.getElementById(\"relayApiBase\");\n      const accessTokenEl = document.getElementById(\"accessToken\");\n      const hostSelectEl = document.getElementById(\"hostSelect\");\n      const hostIdOverrideEl = document.getElementById(\"hostIdOverride\");\n      const enrollmentCodeEl = document.getElementById(\"enrollmentCode\");\n      const loadHostsBtnEl = document.getElementById(\"loadHostsBtn\");\n      const runTestBtnEl = document.getElementById(\"runTestBtn\");\n      const loginGithubBtnEl = document.getElementById(\"loginGithubBtn\");\n      const loginGoogleBtnEl = document.getElementById(\"loginGoogleBtn\");\n      const clearTokensBtnEl = document.getElementById(\"clearTokensBtn\");\n      const resultOutputEl = document.getElementById(\"resultOutput\");\n      const logOutputEl = document.getElementById(\"logOutput\");\n      const statusTextEl = document.getElementById(\"statusText\");\n\n      const ACCESS_TOKEN_KEY = \"relay_test_access_token\";\n      const REFRESH_TOKEN_KEY = \"relay_test_refresh_token\";\n      const OAUTH_VERIFIER_KEY = \"relay_test_oauth_verifier\";\n      const encoder = new TextEncoder();\n      const decoder = new TextDecoder();\n      const ED25519_ORDER = ed25519.CURVE.n;\n      const CLIENT_ID = encoder.encode(\"vibe-kanban-browser\");\n      const SERVER_ID = encoder.encode(\"vibe-kanban-server\");\n      const CLIENT_PROOF_CONTEXT = encoder.encode(\"vk-spake2-client-proof-v2\");\n      const SERVER_PROOF_CONTEXT = encoder.encode(\"vk-spake2-server-proof-v2\");\n      const KEY_CONFIRMATION_INFO = encoder.encode(\"key-confirmation\");\n      const SPAKE2_M = ed25519.ExtendedPoint.fromHex(\n        \"15cfd18e385952982b6a8f8c7854963b58e34388c8e6dae891db756481a02312\",\n      );\n      const SPAKE2_N = ed25519.ExtendedPoint.fromHex(\n        \"f04f2e7eb734b2a8f8b472eaf9c3c632576ac64aea650b496a8a20ff00e583c3\",\n      );\n\n      function trimSlash(url) {\n        return String(url || \"\").trim().replace(/\\/+$/, \"\");\n      }\n\n      function getAccessToken() {\n        const override = accessTokenEl.value.trim();\n        if (override) return override;\n        return localStorage.getItem(ACCESS_TOKEN_KEY) || \"\";\n      }\n\n      function setTokens(accessToken, refreshToken) {\n        localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);\n        localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);\n        accessTokenEl.value = accessToken;\n      }\n\n      function clearTokens() {\n        localStorage.removeItem(ACCESS_TOKEN_KEY);\n        localStorage.removeItem(REFRESH_TOKEN_KEY);\n        accessTokenEl.value = \"\";\n      }\n\n      function authHeaders() {\n        const token = getAccessToken();\n        if (!token) {\n          throw new Error(\"Missing bearer access token.\");\n        }\n        return {\n          Authorization: `Bearer ${token}`,\n          \"Content-Type\": \"application/json\",\n        };\n      }\n\n      function setBusy(busy) {\n        loadHostsBtnEl.disabled = busy;\n        runTestBtnEl.disabled = busy;\n        loginGithubBtnEl.disabled = busy;\n        loginGoogleBtnEl.disabled = busy;\n      }\n\n      function log(line) {\n        const ts = new Date().toISOString();\n        logOutputEl.textContent += `\\n[${ts}] ${line}`;\n        logOutputEl.scrollTop = logOutputEl.scrollHeight;\n      }\n\n      function setResultStatus(message, kind) {\n        statusTextEl.className = kind || \"muted\";\n        statusTextEl.textContent = ` ${message}`;\n      }\n\n      function renderJson(value) {\n        resultOutputEl.textContent = JSON.stringify(value, null, 2);\n      }\n\n      function selectedHostId() {\n        const override = hostIdOverrideEl.value.trim();\n        if (override) return override;\n        return hostSelectEl.value;\n      }\n\n      function relaySessionPrefixFromUrl(url) {\n        try {\n          const parsed = new URL(url);\n          const match = parsed.pathname.match(/^\\/relay\\/h\\/[^/]+\\/s\\/[^/]+/);\n          return match ? `${parsed.origin}${match[0]}` : null;\n        } catch {\n          return null;\n        }\n      }\n\n      function extractWorkspaces(payload) {\n        if (Array.isArray(payload)) {\n          return payload;\n        }\n        if (payload && Array.isArray(payload.data)) {\n          return payload.data;\n        }\n        return [];\n      }\n\n      function bytesToBase64(bytes) {\n        return btoa(String.fromCharCode(...bytes));\n      }\n\n      function base64ToBytes(input) {\n        const binary = atob(input);\n        const bytes = new Uint8Array(binary.length);\n        for (let i = 0; i < binary.length; i++) {\n          bytes[i] = binary.charCodeAt(i);\n        }\n        return bytes;\n      }\n\n      function concatBytes(...parts) {\n        const totalLength = parts.reduce((sum, bytes) => sum + bytes.length, 0);\n        const output = new Uint8Array(totalLength);\n        let offset = 0;\n        for (const bytes of parts) {\n          output.set(bytes, offset);\n          offset += bytes.length;\n        }\n        return output;\n      }\n\n      function equalBytes(a, b) {\n        if (a.length !== b.length) {\n          return false;\n        }\n        let diff = 0;\n        for (let i = 0; i < a.length; i += 1) {\n          diff |= a[i] ^ b[i];\n        }\n        return diff === 0;\n      }\n\n      function bytesToBigIntLe(bytes) {\n        let value = 0n;\n        for (let i = bytes.length - 1; i >= 0; i -= 1) {\n          value = (value << 8n) + BigInt(bytes[i]);\n        }\n        return value;\n      }\n\n      function modOrder(value) {\n        const result = value % ED25519_ORDER;\n        return result >= 0n ? result : result + ED25519_ORDER;\n      }\n\n      function scalarNeg(scalar) {\n        return scalar === 0n ? 0n : ED25519_ORDER - scalar;\n      }\n\n      function randomScalar() {\n        while (true) {\n          const randomBytes = crypto.getRandomValues(new Uint8Array(64));\n          const scalar = modOrder(bytesToBigIntLe(randomBytes));\n          if (scalar !== 0n) {\n            return scalar;\n          }\n        }\n      }\n\n      function parseUuidBytes(uuid) {\n        const hex = uuid.replace(/-/g, \"\").toLowerCase();\n        if (!/^[0-9a-f]{32}$/.test(hex)) {\n          throw new Error(`Invalid UUID: ${uuid}`);\n        }\n        const bytes = new Uint8Array(16);\n        for (let i = 0; i < 16; i += 1) {\n          bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n        }\n        return bytes;\n      }\n\n      async function sha256Bytes(data) {\n        const digest = await crypto.subtle.digest(\"SHA-256\", data);\n        return new Uint8Array(digest);\n      }\n\n      async function sha256Base64(data) {\n        const digest = await sha256Bytes(data);\n        return bytesToBase64(digest);\n      }\n\n      async function hkdfSha256(ikm, info, outputLengthBytes) {\n        const key = await crypto.subtle.importKey(\"raw\", ikm, \"HKDF\", false, [\n          \"deriveBits\",\n        ]);\n        const bits = await crypto.subtle.deriveBits(\n          {\n            name: \"HKDF\",\n            hash: \"SHA-256\",\n            salt: new Uint8Array(),\n            info,\n          },\n          key,\n          outputLengthBytes * 8,\n        );\n        return new Uint8Array(bits);\n      }\n\n      async function hmacSha256(keyBytes, data) {\n        const key = await crypto.subtle.importKey(\n          \"raw\",\n          keyBytes,\n          { name: \"HMAC\", hash: \"SHA-256\" },\n          false,\n          [\"sign\"],\n        );\n        const signature = await crypto.subtle.sign(\"HMAC\", key, data);\n        return new Uint8Array(signature);\n      }\n\n      async function hashToScalar(passwordBytes) {\n        const okm = await hkdfSha256(passwordBytes, encoder.encode(\"SPAKE2 pw\"), 48);\n        const wideScalarBytes = new Uint8Array(64);\n        for (let i = 0; i < 48; i += 1) {\n          wideScalarBytes[47 - i] = okm[i];\n        }\n        return modOrder(bytesToBigIntLe(wideScalarBytes));\n      }\n\n      async function hashAb(passwordBytes, idA, idB, firstMessage, secondMessage, keyBytes) {\n        const transcript = new Uint8Array(192);\n        transcript.set(await sha256Bytes(passwordBytes), 0);\n        transcript.set(await sha256Bytes(idA), 32);\n        transcript.set(await sha256Bytes(idB), 64);\n        transcript.set(firstMessage, 96);\n        transcript.set(secondMessage, 128);\n        transcript.set(keyBytes, 160);\n        return sha256Bytes(transcript);\n      }\n\n      async function deriveConfirmationKey(sharedKey) {\n        return hkdfSha256(sharedKey, KEY_CONFIRMATION_INFO, 32);\n      }\n\n      async function buildClientProof(sharedKey, enrollmentId, browserPkBytes) {\n        const confirmationKey = await deriveConfirmationKey(sharedKey);\n        const proofInput = concatBytes(\n          CLIENT_PROOF_CONTEXT,\n          parseUuidBytes(enrollmentId),\n          browserPkBytes,\n        );\n        return hmacSha256(confirmationKey, proofInput);\n      }\n\n      async function buildExpectedServerProof(sharedKey, enrollmentId, browserPkBytes, serverPkBytes) {\n        const confirmationKey = await deriveConfirmationKey(sharedKey);\n        const proofInput = concatBytes(\n          SERVER_PROOF_CONTEXT,\n          parseUuidBytes(enrollmentId),\n          browserPkBytes,\n          serverPkBytes,\n        );\n        return hmacSha256(confirmationKey, proofInput);\n      }\n\n      async function ed25519Sign(privateKey, message) {\n        const signature = await crypto.subtle.sign(\"Ed25519\", privateKey, message);\n        return new Uint8Array(signature);\n      }\n\n      async function ed25519Verify(publicKey, signature, message) {\n        return crypto.subtle.verify(\"Ed25519\", publicKey, signature, message);\n      }\n\n      function randomNonce() {\n        const bytes = new Uint8Array(16);\n        crypto.getRandomValues(bytes);\n        return base64UrlEncode(bytes);\n      }\n\n      async function createSigningKey() {\n        const generated = await crypto.subtle.generateKey(\n          { name: \"Ed25519\" },\n          true,\n          [\"sign\", \"verify\"],\n        );\n        const publicRaw = new Uint8Array(\n          await crypto.subtle.exportKey(\"raw\", generated.publicKey),\n        );\n        return {\n          privateKey: generated.privateKey,\n          publicKey: generated.publicKey,\n          publicKeyBytes: publicRaw,\n          publicKeyB64: bytesToBase64(publicRaw),\n        };\n      }\n\n      async function postJson(url, payload, headers = null) {\n        const response = await fetch(url, {\n          method: \"POST\",\n          headers: headers || { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify(payload),\n        });\n        const text = await response.text();\n        let json = null;\n        if (text) {\n          try {\n            json = JSON.parse(text);\n          } catch {\n            json = null;\n          }\n        }\n        return { response, text, json };\n      }\n\n      function unwrapApiData(result, stepName) {\n        if (!result.response.ok) {\n          throw new Error(`${stepName} failed (${result.response.status}): ${result.text}`);\n        }\n        if (!result.json || result.json.success !== true || !(\"data\" in result.json)) {\n          throw new Error(`${stepName} returned an unexpected response body: ${result.text}`);\n        }\n        return result.json.data;\n      }\n\n      function bytesToHex(bytes) {\n        let out = \"\";\n        for (let i = 0; i < bytes.length; i++) {\n          out += bytes[i].toString(16).padStart(2, \"0\");\n        }\n        return out;\n      }\n\n      function base64UrlEncode(bytes) {\n        const base64 = btoa(String.fromCharCode(...bytes));\n        return base64.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=/g, \"\");\n      }\n\n      function generateVerifier() {\n        const bytes = new Uint8Array(32);\n        crypto.getRandomValues(bytes);\n        return base64UrlEncode(bytes);\n      }\n\n      async function sha256Hex(text) {\n        const data = new TextEncoder().encode(text);\n        const digest = await crypto.subtle.digest(\"SHA-256\", data);\n        return bytesToHex(new Uint8Array(digest));\n      }\n\n      function clearOAuthCallbackQuery() {\n        const url = new URL(window.location.href);\n        url.searchParams.delete(\"handoff_id\");\n        url.searchParams.delete(\"app_code\");\n        url.searchParams.delete(\"error\");\n        window.history.replaceState({}, \"\", `${url.pathname}${url.search}${url.hash}`);\n      }\n\n      async function startOAuth(provider) {\n        setBusy(true);\n        try {\n          const remoteApiBase = trimSlash(remoteApiBaseEl.value);\n          const verifier = generateVerifier();\n          const challenge = await sha256Hex(verifier);\n          sessionStorage.setItem(OAUTH_VERIFIER_KEY, verifier);\n\n          const returnTo = `${window.location.origin}${window.location.pathname}`;\n          const initRes = await fetch(`${remoteApiBase}/v1/oauth/web/init`, {\n            method: \"POST\",\n            headers: { \"Content-Type\": \"application/json\" },\n            body: JSON.stringify({\n              provider,\n              return_to: returnTo,\n              app_challenge: challenge,\n            }),\n          });\n          if (!initRes.ok) {\n            throw new Error(`OAuth init failed (${initRes.status})`);\n          }\n          const initPayload = await initRes.json();\n          const authorizeUrl = initPayload?.authorize_url;\n          if (!authorizeUrl) {\n            throw new Error(\"Missing authorize_url from OAuth init response.\");\n          }\n\n          log(`OAuth init succeeded for ${provider}; redirecting to provider`);\n          window.location.assign(authorizeUrl);\n        } catch (error) {\n          setResultStatus(\n            error instanceof Error ? error.message : \"OAuth init failed\",\n            \"err\",\n          );\n          log(`OAuth init failed: ${String(error)}`);\n          setBusy(false);\n        }\n      }\n\n      async function completeOAuthIfNeeded() {\n        const params = new URLSearchParams(window.location.search);\n        const handoffId = params.get(\"handoff_id\");\n        const appCode = params.get(\"app_code\");\n        const oauthError = params.get(\"error\");\n        if (!handoffId && !appCode && !oauthError) {\n          return;\n        }\n\n        if (oauthError) {\n          setResultStatus(`OAuth error: ${oauthError}`, \"err\");\n          log(`OAuth callback error: ${oauthError}`);\n          clearOAuthCallbackQuery();\n          return;\n        }\n\n        if (!handoffId || !appCode) {\n          setResultStatus(\"OAuth callback missing required params\", \"err\");\n          clearOAuthCallbackQuery();\n          return;\n        }\n\n        const verifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY);\n        if (!verifier) {\n          setResultStatus(\"Missing OAuth verifier in sessionStorage\", \"err\");\n          clearOAuthCallbackQuery();\n          return;\n        }\n\n        setBusy(true);\n        try {\n          const remoteApiBase = trimSlash(remoteApiBaseEl.value);\n          const redeemRes = await fetch(`${remoteApiBase}/v1/oauth/web/redeem`, {\n            method: \"POST\",\n            headers: { \"Content-Type\": \"application/json\" },\n            body: JSON.stringify({\n              handoff_id: handoffId,\n              app_code: appCode,\n              app_verifier: verifier,\n            }),\n          });\n          if (!redeemRes.ok) {\n            throw new Error(`OAuth redeem failed (${redeemRes.status})`);\n          }\n          const redeemPayload = await redeemRes.json();\n          const accessToken = redeemPayload?.access_token;\n          const refreshToken = redeemPayload?.refresh_token;\n          if (!accessToken || !refreshToken) {\n            throw new Error(\"OAuth redeem response missing tokens.\");\n          }\n\n          setTokens(accessToken, refreshToken);\n          sessionStorage.removeItem(OAUTH_VERIFIER_KEY);\n          clearOAuthCallbackQuery();\n          setResultStatus(\"OAuth login succeeded.\", \"ok\");\n          log(\"OAuth login succeeded; token auto-filled\");\n        } catch (error) {\n          setResultStatus(\n            error instanceof Error ? error.message : \"OAuth redeem failed\",\n            \"err\",\n          );\n          log(`OAuth redeem failed: ${String(error)}`);\n          clearOAuthCallbackQuery();\n        } finally {\n          setBusy(false);\n        }\n      }\n\n      async function loadHosts() {\n        setBusy(true);\n        try {\n          const remoteApiBase = trimSlash(remoteApiBaseEl.value);\n          const res = await fetch(`${remoteApiBase}/v1/hosts`, {\n            method: \"GET\",\n            headers: authHeaders(),\n          });\n          if (!res.ok) {\n            throw new Error(`Host list failed (${res.status})`);\n          }\n          const payload = await res.json();\n          const hosts = Array.isArray(payload.hosts) ? payload.hosts : [];\n\n          hostSelectEl.innerHTML = \"\";\n          for (const host of hosts) {\n            const option = document.createElement(\"option\");\n            option.value = host.id;\n            const status = host.status || \"unknown\";\n            option.textContent = `${host.name || host.id} (${status})`;\n            hostSelectEl.appendChild(option);\n          }\n\n          if (!hosts.length) {\n            const option = document.createElement(\"option\");\n            option.value = \"\";\n            option.textContent = \"No hosts found\";\n            hostSelectEl.appendChild(option);\n          }\n\n          setResultStatus(`Loaded ${hosts.length} host(s).`, \"ok\");\n          renderJson(payload);\n          log(`Loaded ${hosts.length} host(s) from ${remoteApiBase}/v1/hosts`);\n        } catch (error) {\n          setResultStatus(\n            error instanceof Error ? error.message : \"Load hosts failed\",\n            \"err\",\n          );\n          log(`Load hosts failed: ${String(error)}`);\n        } finally {\n          setBusy(false);\n        }\n      }\n\n      async function runPakeEnrollment(sessionPrefix, enrollmentCode) {\n        const normalizedCode = String(enrollmentCode || \"\").trim().toUpperCase();\n        if (!normalizedCode) {\n          throw new Error(\"Missing host pairing code.\");\n        }\n\n        const signingKey = await createSigningKey();\n\n        const x = randomScalar();\n        const passwordBytes = encoder.encode(normalizedCode);\n        const pw = await hashToScalar(passwordBytes);\n        const clientPoint = ed25519.ExtendedPoint.BASE\n          .multiply(x)\n          .add(SPAKE2_M.multiply(pw));\n        const clientMessage = concatBytes(Uint8Array.of(0x41), clientPoint.toRawBytes());\n\n        const startResult = await postJson(`${sessionPrefix}/api/relay-auth/spake2/start`, {\n          enrollment_code: normalizedCode,\n          client_message_b64: bytesToBase64(clientMessage),\n        });\n        const startData = unwrapApiData(startResult, \"SPAKE2 start\");\n        const enrollmentId = startData.enrollment_id;\n        const serverMessageBytes = base64ToBytes(startData.server_message_b64);\n        if (serverMessageBytes.length !== 33 || serverMessageBytes[0] !== 0x42) {\n          throw new Error(\"Invalid SPAKE2 server message format.\");\n        }\n\n        const serverPoint = ed25519.ExtendedPoint.fromHex(serverMessageBytes.slice(1));\n        const unblinding = SPAKE2_N.multiply(scalarNeg(pw));\n        const keyPoint = serverPoint.add(unblinding).multiply(x);\n        const sharedKey = await hashAb(\n          passwordBytes,\n          CLIENT_ID,\n          SERVER_ID,\n          clientMessage.slice(1),\n          serverMessageBytes.slice(1),\n          keyPoint.toRawBytes(),\n        );\n\n        const clientProof = await buildClientProof(\n          sharedKey,\n          enrollmentId,\n          signingKey.publicKeyBytes,\n        );\n        const finishResult = await postJson(`${sessionPrefix}/api/relay-auth/spake2/finish`, {\n          enrollment_id: enrollmentId,\n          public_key_b64: signingKey.publicKeyB64,\n          client_proof_b64: bytesToBase64(clientProof),\n        });\n        const finishData = unwrapApiData(finishResult, \"SPAKE2 finish\");\n\n        const serverPublicKeyBytes = base64ToBytes(finishData.server_public_key_b64);\n        const serverPublicKey = await crypto.subtle.importKey(\n          \"raw\",\n          serverPublicKeyBytes,\n          { name: \"Ed25519\" },\n          true,\n          [\"verify\"],\n        );\n\n        const expectedServerProof = await buildExpectedServerProof(\n          sharedKey,\n          enrollmentId,\n          signingKey.publicKeyBytes,\n          serverPublicKeyBytes,\n        );\n        const providedServerProof = base64ToBytes(finishData.server_proof_b64);\n        if (!equalBytes(expectedServerProof, providedServerProof)) {\n          throw new Error(\"SPAKE2 server proof mismatch.\");\n        }\n\n        return {\n          signingSessionId: finishData.signing_session_id,\n          browserPrivateKey: signingKey.privateKey,\n          serverPublicKey,\n        };\n      }\n\n      async function signedGetJson(\n        sessionPrefix,\n        path,\n        signingSessionId,\n        browserPrivateKey,\n        serverPublicKey,\n      ) {\n        const timestamp = Math.floor(Date.now() / 1000);\n        const nonce = randomNonce();\n        const bodyHash = await sha256Base64(new Uint8Array(0));\n        const message =\n          `v1|${timestamp}|GET|${path}|${signingSessionId}|${nonce}|${bodyHash}`;\n        const signatureBytes = await ed25519Sign(browserPrivateKey, encoder.encode(message));\n        const requestSignatureB64 = bytesToBase64(signatureBytes);\n\n        const response = await fetch(`${sessionPrefix}${path}`, {\n          method: \"GET\",\n          headers: {\n            \"x-vk-sig-session\": signingSessionId,\n            \"x-vk-sig-ts\": String(timestamp),\n            \"x-vk-sig-nonce\": nonce,\n            \"x-vk-sig-signature\": requestSignatureB64,\n          },\n        });\n        const responseBytes = new Uint8Array(await response.arrayBuffer());\n        const text = decoder.decode(responseBytes);\n        await verifySignedRelayResponse(\n          response,\n          responseBytes,\n          path,\n          signingSessionId,\n          nonce,\n          serverPublicKey,\n        );\n        let json = null;\n        if (text) {\n          try {\n            json = JSON.parse(text);\n          } catch {\n            json = null;\n          }\n        }\n        return { response, text, json };\n      }\n\n      async function verifySignedRelayResponse(\n        response,\n        responseBytes,\n        path,\n        signingSessionId,\n        requestNonce,\n        serverPublicKey,\n      ) {\n        const responseTs = response.headers.get(\"x-vk-resp-ts\");\n        const responseNonce = response.headers.get(\"x-vk-resp-nonce\");\n        const responseSignature = response.headers.get(\"x-vk-resp-signature\");\n\n        if (!responseTs || !responseNonce || !responseSignature) {\n          throw new Error(\"Missing signed relay response headers.\");\n        }\n\n        const bodyHash = await sha256Base64(responseBytes);\n        const message =\n          `v1|${responseTs}|${response.status}|${path}|${signingSessionId}|` +\n          `${requestNonce}|${responseNonce}|${bodyHash}`;\n        const signatureBytes = base64ToBytes(responseSignature);\n        const valid = await ed25519Verify(\n          serverPublicKey,\n          signatureBytes,\n          encoder.encode(message),\n        );\n        if (!valid) {\n          throw new Error(\"Relay response signature mismatch.\");\n        }\n      }\n\n      async function runTest() {\n        setBusy(true);\n        try {\n          const remoteApiBase = trimSlash(remoteApiBaseEl.value);\n          const relayApiBase = trimSlash(relayApiBaseEl.value);\n          const hostId = selectedHostId();\n          if (!hostId) {\n            throw new Error(\"Select a host or provide a host ID override.\");\n          }\n\n          log(`Starting test for host ${hostId}`);\n\n          const sessionRes = await fetch(\n            `${remoteApiBase}/v1/hosts/${hostId}/sessions`,\n            {\n              method: \"POST\",\n              headers: authHeaders(),\n              body: \"{}\",\n            },\n          );\n          if (!sessionRes.ok) {\n            throw new Error(`Create relay session failed (${sessionRes.status})`);\n          }\n          const sessionPayload = await sessionRes.json();\n          const sessionId = sessionPayload?.session?.id;\n          if (!sessionId) {\n            throw new Error(\"Missing session ID in create session response.\");\n          }\n          log(`Created relay session ${sessionId}`);\n\n          const authCodeRes = await fetch(\n            `${relayApiBase}/v1/relay/sessions/${sessionId}/auth-code`,\n            {\n              method: \"POST\",\n              headers: authHeaders(),\n              body: \"{}\",\n            },\n          );\n          if (!authCodeRes.ok) {\n            throw new Error(\n              `Create relay auth code failed (${authCodeRes.status})`,\n            );\n          }\n          const authCodePayload = await authCodeRes.json();\n          const code = authCodePayload?.code;\n          if (!code) {\n            throw new Error(\"Missing code in auth-code response.\");\n          }\n          log(\"Created auth code\");\n\n          const exchangeUrl = new URL(relayApiBase);\n          exchangeUrl.pathname = `/relay/h/${hostId}/exchange`;\n          exchangeUrl.searchParams.set(\"code\", code);\n\n          const exchangeRes = await fetch(exchangeUrl.toString(), {\n            method: \"GET\",\n            redirect: \"follow\",\n          });\n\n          const sessionPrefix = relaySessionPrefixFromUrl(exchangeRes.url);\n          if (!sessionPrefix) {\n            throw new Error(\n              `Exchange failed (${exchangeRes.status}): could not derive relay browser session path.`,\n            );\n          }\n          if (!exchangeRes.ok) {\n            log(\n              `Exchange landing returned ${exchangeRes.status}; continuing with derived session path`,\n            );\n          }\n          log(`Browser session path: ${sessionPrefix}`);\n          log(\"Starting SPAKE2 enrollment\");\n          const enrollmentCode = enrollmentCodeEl.value.trim();\n          const signing = await runPakeEnrollment(sessionPrefix, enrollmentCode);\n          log(`SPAKE2 enrollment succeeded (signing session ${signing.signingSessionId})`);\n\n          const workspacesResult = await signedGetJson(\n            sessionPrefix,\n            \"/api/task-attempts\",\n            signing.signingSessionId,\n            signing.browserPrivateKey,\n            signing.serverPublicKey,\n          );\n          if (!workspacesResult.response.ok) {\n            throw new Error(\n              `Fetch local workspaces failed (${workspacesResult.response.status}): ${workspacesResult.text}`,\n            );\n          }\n          const workspacesPayload = workspacesResult.json;\n          const workspaces = extractWorkspaces(workspacesPayload);\n\n          setResultStatus(\"Relay test succeeded.\", \"ok\");\n          renderJson({\n            host_id: hostId,\n            session_id: sessionId,\n            signing_session_id: signing.signingSessionId,\n            relay_exchange_url: exchangeUrl.toString(),\n            relay_session_prefix: sessionPrefix,\n            workspace_count: workspaces.length,\n            workspaces,\n            local_workspaces_response: workspacesPayload,\n          });\n          log(\n            `Local /api/task-attempts fetch succeeded (${workspaces.length} workspace(s))`,\n          );\n        } catch (error) {\n          setResultStatus(\n            error instanceof Error ? error.message : \"Relay test failed\",\n            \"err\",\n          );\n          log(`Relay test failed: ${String(error)}`);\n        } finally {\n          setBusy(false);\n        }\n      }\n\n      loadHostsBtnEl.addEventListener(\"click\", () => {\n        void loadHosts();\n      });\n      runTestBtnEl.addEventListener(\"click\", () => {\n        void runTest();\n      });\n      loginGithubBtnEl.addEventListener(\"click\", () => {\n        void startOAuth(\"github\");\n      });\n      loginGoogleBtnEl.addEventListener(\"click\", () => {\n        void startOAuth(\"google\");\n      });\n      clearTokensBtnEl.addEventListener(\"click\", () => {\n        clearTokens();\n        setResultStatus(\"Tokens cleared.\", \"muted\");\n        log(\"Cleared local auth tokens\");\n      });\n      accessTokenEl.addEventListener(\"change\", () => {\n        const token = accessTokenEl.value.trim();\n        if (token) {\n          localStorage.setItem(ACCESS_TOKEN_KEY, token);\n        } else {\n          localStorage.removeItem(ACCESS_TOKEN_KEY);\n        }\n      });\n\n      // Persist URL fields across page reloads (e.g. OAuth redirects)\n      const REMOTE_API_KEY = \"relay_test_remote_api\";\n      const RELAY_API_KEY = \"relay_test_relay_api\";\n\n      function persistUrlFields() {\n        localStorage.setItem(REMOTE_API_KEY, remoteApiBaseEl.value);\n        localStorage.setItem(RELAY_API_KEY, relayApiBaseEl.value);\n      }\n\n      const savedRemote = localStorage.getItem(REMOTE_API_KEY);\n      const savedRelay = localStorage.getItem(RELAY_API_KEY);\n      if (savedRemote) remoteApiBaseEl.value = savedRemote;\n      if (savedRelay) relayApiBaseEl.value = savedRelay;\n\n      remoteApiBaseEl.addEventListener(\"change\", persistUrlFields);\n      relayApiBaseEl.addEventListener(\"change\", persistUrlFields);\n\n      const existingToken = localStorage.getItem(ACCESS_TOKEN_KEY);\n      if (existingToken) {\n        accessTokenEl.value = existingToken;\n      }\n      void completeOAuthIfNeeded();\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "scripts/ring-cc-wrapper.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Uses clang for ring (clang-cl rejects ring's build) and clang-cl elsewhere.\n# See https://github.com/briansmith/ring/issues/2117\n\nring_cc=\"${RING_CC:-clang}\"\ndefault_cc=\"${DEFAULT_CC:-clang-cl}\"\n\nif [[ \"${CARGO_PKG_NAME:-}\" != \"ring\" && \"${CARGO_MANIFEST_DIR:-}\" != *\"/ring-\"* ]]; then\n  exec \"$default_cc\" \"$@\"\nfi\n\nargs=()\nwhile (( $# )); do\n  arg=\"$1\"\n  shift\n  case \"$arg\" in\n    /imsvc) [[ $# -gt 0 ]] && { args+=(-isystem \"$1\"); shift; } || args+=(\"$arg\") ;;\n    /imsvc*) args+=(-isystem \"${arg#/imsvc}\") ;;\n    /I) [[ $# -gt 0 ]] && { args+=(-I \"$1\"); shift; } || args+=(\"$arg\") ;;\n    /I*) args+=(-I \"${arg#/I}\") ;;\n    *) args+=(\"$arg\") ;;\n  esac\ndone\n\nexec \"$ring_cc\" \"${args[@]}\"\n"
  },
  {
    "path": "scripts/setup-dev-environment.js",
    "content": "#!/usr/bin/env node\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst net = require(\"net\");\n\nconst PORTS_FILE = path.join(__dirname, \"..\", \".dev-ports.json\");\nconst DEV_ASSETS_SEED = path.join(__dirname, \"..\", \"dev_assets_seed\");\nconst DEV_ASSETS = path.join(__dirname, \"..\", \"dev_assets\");\n\n/**\n * Check if a port is available\n */\nfunction isPortAvailable(port) {\n  return new Promise((resolve) => {\n    const sock = net.createConnection({ port, host: \"localhost\" });\n    sock.on(\"connect\", () => {\n      sock.destroy();\n      resolve(false);\n    });\n    sock.on(\"error\", () => resolve(true));\n  });\n}\n\n/**\n * Find a free port starting from a given port\n */\nasync function findFreePort(startPort = 3000) {\n  let port = startPort;\n  while (!(await isPortAvailable(port))) {\n    port++;\n    if (port > 65535) {\n      throw new Error(\"No available ports found\");\n    }\n  }\n  return port;\n}\n\n/**\n * Load existing ports from file\n */\nfunction loadPorts() {\n  try {\n    if (fs.existsSync(PORTS_FILE)) {\n      const data = fs.readFileSync(PORTS_FILE, \"utf8\");\n      return JSON.parse(data);\n    }\n  } catch (error) {\n    console.warn(\"Failed to load existing ports:\", error.message);\n  }\n  return null;\n}\n\n/**\n * Save ports to file\n */\nfunction savePorts(ports) {\n  try {\n    fs.writeFileSync(PORTS_FILE, JSON.stringify(ports, null, 2));\n  } catch (error) {\n    console.error(\"Failed to save ports:\", error.message);\n    throw error;\n  }\n}\n\n/**\n * Verify that saved ports are still available\n */\nasync function verifyPorts(ports) {\n  const frontendAvailable = await isPortAvailable(ports.frontend);\n  const backendAvailable = await isPortAvailable(ports.backend);\n  const previewProxyAvailable = await isPortAvailable(ports.preview_proxy);\n\n  if (process.argv[2] === \"get\" && (!frontendAvailable || !backendAvailable || !previewProxyAvailable)) {\n    console.log(\n      `Port availability check failed: frontend:${ports.frontend}=${frontendAvailable}, backend:${ports.backend}=${backendAvailable}, preview_proxy:${ports.preview_proxy}=${previewProxyAvailable}`\n    );\n  }\n\n  return frontendAvailable && backendAvailable && previewProxyAvailable;\n}\n\n/**\n * Allocate ports for development\n */\nasync function allocatePorts() {\n  // If PORT env is set, use it for frontend and PORT+1 for backend\n  if (process.env.PORT) {\n    const frontendPort = parseInt(process.env.PORT, 10);\n    const backendPort = frontendPort + 1;\n    const previewProxyPort = backendPort + 1;\n\n    const ports = {\n      frontend: frontendPort,\n      backend: backendPort,\n      preview_proxy: previewProxyPort,\n      timestamp: new Date().toISOString(),\n    };\n\n    if (process.argv[2] === \"get\") {\n      console.log(\"Using PORT environment variable:\");\n      console.log(`Frontend: ${ports.frontend}`);\n      console.log(`Backend: ${ports.backend}`);\n      console.log(`Preview Proxy: ${ports.preview_proxy}`);\n    }\n\n    return ports;\n  }\n\n  // Try to load existing ports first\n  const existingPorts = loadPorts();\n\n  if (existingPorts) {\n    // Verify existing ports are still available\n    if (await verifyPorts(existingPorts)) {\n      if (process.argv[2] === \"get\") {\n        console.log(\"Reusing existing dev ports:\");\n        console.log(`Frontend: ${existingPorts.frontend}`);\n        console.log(`Backend: ${existingPorts.backend}`);\n        console.log(`Preview Proxy: ${existingPorts.preview_proxy}`);\n      }\n      return existingPorts;\n    } else {\n      if (process.argv[2] === \"get\") {\n        console.log(\n          \"Existing ports are no longer available, finding new ones...\"\n        );\n      }\n    }\n  }\n\n  // Find new free ports\n  const frontendPort = await findFreePort(3000);\n  const backendPort = await findFreePort(frontendPort + 1);\n  const previewProxyPort = await findFreePort(backendPort + 1);\n\n  const ports = {\n    frontend: frontendPort,\n    backend: backendPort,\n    preview_proxy: previewProxyPort,\n    timestamp: new Date().toISOString(),\n  };\n\n  savePorts(ports);\n\n  if (process.argv[2] === \"get\") {\n    console.log(\"Allocated new dev ports:\");\n    console.log(`Frontend: ${ports.frontend}`);\n    console.log(`Backend: ${ports.backend}`);\n    console.log(`Preview Proxy: ${ports.preview_proxy}`);\n  }\n\n  return ports;\n}\n\n/**\n * Get ports (allocate if needed)\n */\nasync function getPorts() {\n  const ports = await allocatePorts();\n  copyDevAssets();\n  return ports;\n}\n\n/**\n * Copy dev_assets_seed to dev_assets\n */\nfunction copyDevAssets() {\n  try {\n    if (!fs.existsSync(DEV_ASSETS)) {\n      // Copy dev_assets_seed to dev_assets\n      fs.cpSync(DEV_ASSETS_SEED, DEV_ASSETS, { recursive: true });\n\n      if (process.argv[2] === \"get\") {\n        console.log(\"Copied dev_assets_seed to dev_assets\");\n      }\n    }\n  } catch (error) {\n    console.error(\"Failed to copy dev assets:\", error.message);\n  }\n}\n\n/**\n * Clear saved ports\n */\nfunction clearPorts() {\n  try {\n    if (fs.existsSync(PORTS_FILE)) {\n      fs.unlinkSync(PORTS_FILE);\n      console.log(\"Cleared saved dev ports\");\n    } else {\n      console.log(\"No saved ports to clear\");\n    }\n  } catch (error) {\n    console.error(\"Failed to clear ports:\", error.message);\n  }\n}\n\n// CLI interface\nif (require.main === module) {\n  const command = process.argv[2];\n\n  switch (command) {\n    case \"get\":\n      getPorts()\n        .then((ports) => {\n          console.log(JSON.stringify(ports));\n        })\n        .catch(console.error);\n      break;\n\n    case \"clear\":\n      clearPorts();\n      break;\n\n    case \"frontend\":\n      getPorts()\n        .then((ports) => {\n          console.log(JSON.stringify(ports.frontend, null, 2));\n        })\n        .catch(console.error);\n      break;\n\n    case \"backend\":\n      getPorts()\n        .then((ports) => {\n          console.log(JSON.stringify(ports.backend, null, 2));\n        })\n        .catch(console.error);\n      break;\n\n    case \"preview_proxy\":\n      getPorts()\n        .then((ports) => {\n          console.log(JSON.stringify(ports.preview_proxy, null, 2));\n        })\n        .catch(console.error);\n      break;\n\n    default:\n      console.log(\"Usage:\");\n      console.log(\n        \"  node setup-dev-environment.js get           - Setup dev environment (ports + assets)\"\n      );\n      console.log(\n        \"  node setup-dev-environment.js frontend      - Get frontend port only\"\n      );\n      console.log(\n        \"  node setup-dev-environment.js backend       - Get backend port only\"\n      );\n      console.log(\n        \"  node setup-dev-environment.js preview_proxy - Get preview proxy port only\"\n      );\n      console.log(\n        \"  node setup-dev-environment.js clear         - Clear saved ports\"\n      );\n      break;\n  }\n}\n\nmodule.exports = { getPorts, clearPorts, findFreePort };\n"
  },
  {
    "path": "shared/jwt.ts",
    "content": "import { jwtDecode } from 'jwt-decode';\n\ntype AccessTokenClaims = {\n  exp: number;\n  aud: string;\n};\n\nconst TOKEN_REFRESH_LEEWAY_MS = 20_000;\nconst ACCESS_TOKEN_AUD = 'access';\n\nconst getTokenExpiryMs = (token: string): number | null => {\n  try {\n    const { exp, aud } = jwtDecode<AccessTokenClaims>(token);\n    if (aud !== ACCESS_TOKEN_AUD) return null;\n    if (!Number.isFinite(exp)) return null;\n    return exp * 1000;\n  } catch {\n    return null;\n  }\n};\n\nexport const shouldRefreshAccessToken = (token: string): boolean => {\n  const expiresAt = getTokenExpiryMs(token);\n  if (expiresAt === null) return true;\n  return expiresAt - Date.now() <= TOKEN_REFRESH_LEEWAY_MS;\n};\n"
  },
  {
    "path": "shared/remote-types.ts",
    "content": "// This file was auto-generated by generate_types in the remote crate.\n// Do not edit manually.\n\n// Electric row types\nexport type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;\n\nexport type Project = { id: string, organization_id: string, name: string, color: string, sort_order: number, created_at: string, updated_at: string, };\n\nexport type Notification = { id: string, organization_id: string, user_id: string, notification_type: NotificationType, payload: NotificationPayload, issue_id: string | null, comment_id: string | null, seen: boolean, dismissed_at: string | null, created_at: string, };\n\nexport type NotificationGroupKind = \"single\" | \"issue_changes\" | \"status_changes\" | \"comments\" | \"reactions\" | \"issue_deleted\";\n\nexport type NotificationPayload = { deeplink_path?: string | null, issue_id?: string | null, issue_simple_id?: string | null, issue_title?: string | null, actor_user_id?: string | null, comment_preview?: string | null, old_status_id?: string | null, new_status_id?: string | null, old_status_name?: string | null, new_status_name?: string | null, new_title?: string | null, old_priority?: IssuePriority | null, new_priority?: IssuePriority | null, assignee_user_id?: string | null, emoji?: string | null, };\n\nexport type NotificationType = \"issue_comment_added\" | \"issue_status_changed\" | \"issue_assignee_changed\" | \"issue_priority_changed\" | \"issue_unassigned\" | \"issue_comment_reaction\" | \"issue_deleted\" | \"issue_title_changed\" | \"issue_description_changed\";\n\nexport type Workspace = { id: string, project_id: string, owner_user_id: string, issue_id: string | null, local_workspace_id: string | null, name: string | null, archived: boolean, files_changed: number | null, lines_added: number | null, lines_removed: number | null, created_at: string, updated_at: string, };\n\nexport type ProjectStatus = { id: string, project_id: string, name: string, color: string, sort_order: number, hidden: boolean, created_at: string, };\n\nexport type Tag = { id: string, project_id: string, name: string, color: string, };\n\nexport type Issue = { id: string, project_id: string, issue_number: number, simple_id: string, status_id: string, title: string, description: string | null, priority: IssuePriority | null, start_date: string | null, target_date: string | null, completed_at: string | null, sort_order: number, parent_issue_id: string | null, parent_issue_sort_order: number | null, extension_metadata: JsonValue, creator_user_id: string | null, created_at: string, updated_at: string, };\n\nexport type IssueAssignee = { id: string, issue_id: string, user_id: string, assigned_at: string, };\n\nexport type Blob = { id: string, project_id: string, blob_path: string, thumbnail_blob_path: string | null, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, width: number | null, height: number | null, created_at: string, updated_at: string, };\n\nexport type Attachment = { id: string, blob_id: string, issue_id: string | null, comment_id: string | null, created_at: string, expires_at: string | null, };\n\nexport type AttachmentWithBlob = { id: string, blob_id: string, issue_id: string | null, comment_id: string | null, created_at: string, expires_at: string | null, blob_path: string, thumbnail_blob_path: string | null, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, width: number | null, height: number | null, };\n\nexport type IssueFollower = { id: string, issue_id: string, user_id: string, };\n\nexport type IssueTag = { id: string, issue_id: string, tag_id: string, };\n\nexport type IssueRelationship = { id: string, issue_id: string, related_issue_id: string, relationship_type: IssueRelationshipType, created_at: string, };\n\nexport type IssueRelationshipType = \"blocking\" | \"related\" | \"has_duplicate\";\n\nexport type IssueComment = { id: string, issue_id: string, author_id: string | null, parent_id: string | null, message: string, created_at: string, updated_at: string, };\n\nexport type IssueCommentReaction = { id: string, comment_id: string, user_id: string, emoji: string, created_at: string, };\n\nexport type IssuePriority = \"urgent\" | \"high\" | \"medium\" | \"low\";\n\nexport type IssueSortField = \"sort_order\" | \"priority\" | \"created_at\" | \"updated_at\" | \"title\";\n\nexport type ListIssuesQuery = { project_id: string, };\n\nexport type SearchIssuesRequest = { project_id: string, status_id?: string, status_ids?: Array<string>, priority?: IssuePriority, parent_issue_id?: string, search?: string, simple_id?: string, assignee_user_id?: string, tag_id?: string, tag_ids?: Array<string>, sort_field?: IssueSortField, sort_direction?: SortDirection, limit?: number, offset?: number, };\n\nexport type ListIssuesResponse = { issues: Array<Issue>, total_count: number, limit: number, offset: number, };\n\nexport type PullRequestStatus = \"open\" | \"merged\" | \"closed\";\n\nexport type PullRequest = { id: string, url: string, number: number, status: PullRequestStatus, merged_at: string | null, merge_commit_sha: string | null, target_branch_name: string, issue_id: string, workspace_id: string | null, created_at: string, updated_at: string, };\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport type UserData = { user_id: string, first_name: string | null, last_name: string | null, username: string | null, };\n\nexport type User = { id: string, email: string, first_name: string | null, last_name: string | null, username: string | null, created_at: string, updated_at: string, };\n\nexport type RelayHost = { id: string, owner_user_id: string, name: string, status: string, last_seen_at: string | null, agent_version: string | null, created_at: string, updated_at: string, access_role: string, };\n\nexport type ListRelayHostsResponse = { hosts: Array<RelayHost>, };\n\nexport type RelaySession = { id: string, host_id: string, request_user_id: string, state: string, created_at: string, expires_at: string, claimed_at: string | null, ended_at: string | null, };\n\nexport type CreateRelaySessionResponse = { session: RelaySession, };\n\nexport type RelaySessionAuthCodeResponse = { session_id: string, code: string, };\n\nexport enum MemberRole { ADMIN = \"ADMIN\", MEMBER = \"MEMBER\" }\n\nexport type OrganizationMember = { organization_id: string, user_id: string, role: MemberRole, joined_at: string, last_seen_at: string | null, };\n\nexport type CreateProjectRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, organization_id: string, name: string, color: string, };\n\nexport type UpdateProjectRequest = { name: string | null, color: string | null, sort_order: number | null, };\n\nexport type UpdateNotificationRequest = { seen: boolean | null, };\n\nexport type CreateTagRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, project_id: string, name: string, color: string, };\n\nexport type UpdateTagRequest = { name: string | null, color: string | null, };\n\nexport type CreateProjectStatusRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, project_id: string, name: string, color: string, sort_order: number, hidden: boolean, };\n\nexport type UpdateProjectStatusRequest = { name: string | null, color: string | null, sort_order: number | null, hidden: boolean | null, };\n\nexport type CreateIssueRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, project_id: string, status_id: string, title: string, description: string | null, priority: IssuePriority | null, start_date: string | null, target_date: string | null, completed_at: string | null, sort_order: number, parent_issue_id: string | null, parent_issue_sort_order: number | null, extension_metadata: JsonValue, };\n\nexport type UpdateIssueRequest = { status_id?: string | null, title?: string | null, description?: string | null | null, priority?: IssuePriority | null | null, start_date?: string | null | null, target_date?: string | null | null, completed_at?: string | null | null, sort_order?: number | null, parent_issue_id?: string | null | null, parent_issue_sort_order?: number | null | null, extension_metadata?: JsonValue | null, };\n\nexport type CreateIssueAssigneeRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, issue_id: string, user_id: string, };\n\nexport type CreateIssueFollowerRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, issue_id: string, user_id: string, };\n\nexport type CreateIssueTagRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, issue_id: string, tag_id: string, };\n\nexport type CreateIssueRelationshipRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, issue_id: string, related_issue_id: string, relationship_type: IssueRelationshipType, };\n\nexport type CreateIssueCommentRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, issue_id: string, message: string, parent_id: string | null, };\n\nexport type UpdateIssueCommentRequest = { message: string | null, parent_id: string | null | null, };\n\nexport type CreateIssueCommentReactionRequest = { \n/**\n * Optional client-generated ID. If not provided, server generates one.\n * Using client-generated IDs enables stable optimistic updates.\n */\nid?: string, comment_id: string, emoji: string, };\n\nexport type UpdateIssueCommentReactionRequest = { emoji: string | null, };\n\nexport type InitUploadRequest = { project_id: string, filename: string, size_bytes: number, hash: string, };\n\nexport type InitUploadResponse = { upload_url: string, upload_id: string, expires_at: string, skip_upload: boolean, existing_blob_id: string | null, };\n\nexport type ConfirmUploadRequest = { project_id: string, upload_id: string, filename: string, content_type?: string, size_bytes: number, hash: string, issue_id?: string, comment_id?: string, };\n\nexport type CommitAttachmentsRequest = { attachment_ids: Array<string>, };\n\nexport type CommitAttachmentsResponse = { attachments: Array<AttachmentWithBlob>, };\n\nexport type AttachmentUrlResponse = { url: string, };\n\n// Shape definition interface\nexport interface ShapeDefinition<T> {\n  readonly table: string;\n  readonly params: readonly string[];\n  readonly url: string;\n  readonly fallbackUrl: string;\n  readonly _type: T;  // Phantom field for type inference (not present at runtime)\n}\n\n// Helper to create type-safe shape definitions\nfunction defineShape<T>(\n  table: string,\n  params: readonly string[],\n  url: string,\n  fallbackUrl: string\n): ShapeDefinition<T> {\n  return { table, params, url, fallbackUrl } as ShapeDefinition<T>;\n}\n\n// Individual shape definitions with embedded types\nexport const PROJECTS_SHAPE = defineShape<Project>(\n  'projects',\n  ['organization_id'] as const,\n  '/v1/shape/projects',\n  '/v1/fallback/projects'\n);\n\nexport const NOTIFICATIONS_SHAPE = defineShape<Notification>(\n  'notifications',\n  ['user_id'] as const,\n  '/v1/shape/notifications',\n  '/v1/fallback/notifications'\n);\n\nexport const ORGANIZATION_MEMBERS_SHAPE = defineShape<OrganizationMember>(\n  'organization_member_metadata',\n  ['organization_id'] as const,\n  '/v1/shape/organization_members',\n  '/v1/fallback/organization_members'\n);\n\nexport const USERS_SHAPE = defineShape<User>(\n  'users',\n  ['organization_id'] as const,\n  '/v1/shape/users',\n  '/v1/fallback/users'\n);\n\nexport const PROJECT_TAGS_SHAPE = defineShape<Tag>(\n  'tags',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/tags',\n  '/v1/fallback/tags'\n);\n\nexport const PROJECT_PROJECT_STATUSES_SHAPE = defineShape<ProjectStatus>(\n  'project_statuses',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/project_statuses',\n  '/v1/fallback/project_statuses'\n);\n\nexport const PROJECT_ISSUES_SHAPE = defineShape<Issue>(\n  'issues',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/issues',\n  '/v1/fallback/issues'\n);\n\nexport const USER_WORKSPACES_SHAPE = defineShape<Workspace>(\n  'workspaces',\n  ['owner_user_id'] as const,\n  '/v1/shape/user/workspaces',\n  '/v1/fallback/user_workspaces'\n);\n\nexport const PROJECT_WORKSPACES_SHAPE = defineShape<Workspace>(\n  'workspaces',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/workspaces',\n  '/v1/fallback/project_workspaces'\n);\n\nexport const PROJECT_ISSUE_ASSIGNEES_SHAPE = defineShape<IssueAssignee>(\n  'issue_assignees',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/issue_assignees',\n  '/v1/fallback/issue_assignees'\n);\n\nexport const PROJECT_ISSUE_FOLLOWERS_SHAPE = defineShape<IssueFollower>(\n  'issue_followers',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/issue_followers',\n  '/v1/fallback/issue_followers'\n);\n\nexport const PROJECT_ISSUE_TAGS_SHAPE = defineShape<IssueTag>(\n  'issue_tags',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/issue_tags',\n  '/v1/fallback/issue_tags'\n);\n\nexport const PROJECT_ISSUE_RELATIONSHIPS_SHAPE = defineShape<IssueRelationship>(\n  'issue_relationships',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/issue_relationships',\n  '/v1/fallback/issue_relationships'\n);\n\nexport const PROJECT_PULL_REQUESTS_SHAPE = defineShape<PullRequest>(\n  'pull_requests',\n  ['project_id'] as const,\n  '/v1/shape/project/{project_id}/pull_requests',\n  '/v1/fallback/pull_requests'\n);\n\nexport const ISSUE_COMMENTS_SHAPE = defineShape<IssueComment>(\n  'issue_comments',\n  ['issue_id'] as const,\n  '/v1/shape/issue/{issue_id}/comments',\n  '/v1/fallback/issue_comments'\n);\n\nexport const ISSUE_REACTIONS_SHAPE = defineShape<IssueCommentReaction>(\n  'issue_comment_reactions',\n  ['issue_id'] as const,\n  '/v1/shape/issue/{issue_id}/reactions',\n  '/v1/fallback/issue_comment_reactions'\n);\n\n// =============================================================================\n// Mutation Definitions\n// =============================================================================\n\n// Mutation definition interface\nexport interface MutationDefinition<TRow, TCreate = unknown, TUpdate = unknown> {\n  readonly name: string;\n  readonly url: string;\n  readonly _rowType: TRow;  // Phantom field for type inference (not present at runtime)\n  readonly _createType: TCreate;  // Phantom field for type inference (not present at runtime)\n  readonly _updateType: TUpdate;  // Phantom field for type inference (not present at runtime)\n}\n\n// Helper to create type-safe mutation definitions\nfunction defineMutation<TRow, TCreate, TUpdate>(\n  name: string,\n  url: string\n): MutationDefinition<TRow, TCreate, TUpdate> {\n  return { name, url } as MutationDefinition<TRow, TCreate, TUpdate>;\n}\n\n// Individual mutation definitions\nexport const PROJECT_MUTATION = defineMutation<Project, CreateProjectRequest, UpdateProjectRequest>(\n  'Project',\n  '/v1/projects'\n);\n\nexport const NOTIFICATION_MUTATION = defineMutation<Notification, unknown, UpdateNotificationRequest>(\n  'Notification',\n  '/v1/notifications'\n);\n\nexport const TAG_MUTATION = defineMutation<Tag, CreateTagRequest, UpdateTagRequest>(\n  'Tag',\n  '/v1/tags'\n);\n\nexport const PROJECT_STATUS_MUTATION = defineMutation<ProjectStatus, CreateProjectStatusRequest, UpdateProjectStatusRequest>(\n  'ProjectStatus',\n  '/v1/project_statuses'\n);\n\nexport const ISSUE_MUTATION = defineMutation<Issue, CreateIssueRequest, UpdateIssueRequest>(\n  'Issue',\n  '/v1/issues'\n);\n\nexport const ISSUE_ASSIGNEE_MUTATION = defineMutation<IssueAssignee, CreateIssueAssigneeRequest, unknown>(\n  'IssueAssignee',\n  '/v1/issue_assignees'\n);\n\nexport const ISSUE_FOLLOWER_MUTATION = defineMutation<IssueFollower, CreateIssueFollowerRequest, unknown>(\n  'IssueFollower',\n  '/v1/issue_followers'\n);\n\nexport const ISSUE_TAG_MUTATION = defineMutation<IssueTag, CreateIssueTagRequest, unknown>(\n  'IssueTag',\n  '/v1/issue_tags'\n);\n\nexport const ISSUE_RELATIONSHIP_MUTATION = defineMutation<IssueRelationship, CreateIssueRelationshipRequest, unknown>(\n  'IssueRelationship',\n  '/v1/issue_relationships'\n);\n\nexport const ISSUE_COMMENT_MUTATION = defineMutation<IssueComment, CreateIssueCommentRequest, UpdateIssueCommentRequest>(\n  'IssueComment',\n  '/v1/issue_comments'\n);\n\nexport const ISSUE_COMMENT_REACTION_MUTATION = defineMutation<IssueCommentReaction, CreateIssueCommentReactionRequest, UpdateIssueCommentReactionRequest>(\n  'IssueCommentReaction',\n  '/v1/issue_comment_reactions'\n);\n\n// Type helpers to extract types from a mutation definition\nexport type MutationRowType<M extends MutationDefinition<unknown>> = M extends MutationDefinition<infer R> ? R : never;\nexport type MutationCreateType<M extends MutationDefinition<unknown, unknown>> = M extends MutationDefinition<unknown, infer C> ? C : never;\nexport type MutationUpdateType<M extends MutationDefinition<unknown, unknown, unknown>> = M extends MutationDefinition<unknown, unknown, infer U> ? U : never;\n"
  },
  {
    "path": "shared/schemas/amp.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"dangerously_allow_all\": {\n      \"title\": \"Dangerously Allow All\",\n      \"description\": \"Allow all commands to be executed, even if they are not safe.\",\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/claude_code.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"claude_code_router\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"plan\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"approvals\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"model\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"effort\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"enum\": [\n        \"low\",\n        \"medium\",\n        \"high\",\n        \"max\",\n        null\n      ]\n    },\n    \"agent\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"dangerously_skip_permissions\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"disable_api_key\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/codex.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"sandbox\": {\n      \"description\": \"Sandbox policy modes for Codex\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"enum\": [\n        \"auto\",\n        \"read-only\",\n        \"workspace-write\",\n        \"danger-full-access\",\n        null\n      ]\n    },\n    \"ask_for_approval\": {\n      \"description\": \"Determines when the user is consulted to approve Codex actions.\\n\\n- `UnlessTrusted`: Read-only commands are auto-approved. Everything else will\\n  ask the user to approve.\\n- `OnFailure`: All commands run in a restricted sandbox initially. If a\\n  command fails, the user is asked to approve execution without the sandbox.\\n- `OnRequest`: The model decides when to ask the user for approval.\\n- `Never`: Commands never ask for approval. Commands that fail in the\\n  restricted sandbox are not retried.\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"enum\": [\n        \"unless-trusted\",\n        \"on-failure\",\n        \"on-request\",\n        \"never\",\n        null\n      ]\n    },\n    \"oss\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"model\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"model_reasoning_effort\": {\n      \"description\": \"Reasoning effort for the underlying model\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"enum\": [\n        \"low\",\n        \"medium\",\n        \"high\",\n        \"xhigh\",\n        null\n      ]\n    },\n    \"model_reasoning_summary\": {\n      \"description\": \"Model reasoning summary style\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"enum\": [\n        \"auto\",\n        \"concise\",\n        \"detailed\",\n        \"none\",\n        null\n      ]\n    },\n    \"model_reasoning_summary_format\": {\n      \"description\": \"Format for model reasoning summaries\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"enum\": [\n        \"none\",\n        \"experimental\",\n        null\n      ]\n    },\n    \"profile\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"base_instructions\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"include_apply_patch_tool\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"model_provider\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"compact_prompt\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"developer_instructions\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"plan\": {\n      \"type\": \"boolean\",\n      \"default\": false\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/copilot.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"model\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"allow_all_tools\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"allow_tool\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"deny_tool\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"add_dir\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"disable_mcp_server\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/cursor_agent.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"force\": {\n      \"description\": \"Force allow commands unless explicitly denied\",\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"model\": {\n      \"description\": \"auto, opus-4.6, sonnet-4.6, gpt-5.4, gpt-5.4-fast, gpt-5.3-codex, gpt-5.3-codex-fast, gpt-5.3-codex-spark-preview, gpt-5.2, gpt-5.2-codex, gpt-5.2-codex-fast, gpt-5.1, gpt-5.1-codex-max, gpt-5.1-codex-mini, grok, kimi-k2.5, gemini-3.1-pro, gemini-3-pro, gemini-3-flash, opus-4.5, sonnet-4.5, composer-1.5, composer-1\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"reasoning\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/droid.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"autonomy\": {\n      \"title\": \"Autonomy Level\",\n      \"description\": \"Permission level for file and system operations\",\n      \"type\": \"string\",\n      \"enum\": [\n        \"normal\",\n        \"low\",\n        \"medium\",\n        \"high\",\n        \"skip-permissions-unsafe\"\n      ],\n      \"default\": \"skip-permissions-unsafe\"\n    },\n    \"model\": {\n      \"title\": \"Model\",\n      \"description\": \"Model to use (e.g., gpt-5-codex, claude-sonnet-4-5-20250929, gpt-5-2025-08-07, claude-opus-4-1-20250805, claude-haiku-4-5-20251001, glm-4.6)\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"reasoning_effort\": {\n      \"title\": \"Reasoning Effort\",\n      \"description\": \"Reasoning effort level: none, dynamic, off, low, medium, high\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"enum\": [\n        \"none\",\n        \"dynamic\",\n        \"off\",\n        \"low\",\n        \"medium\",\n        \"high\",\n        null\n      ]\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"description\": \"Droid executor configuration\",\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/gemini.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"model\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"yolo\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/opencode.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"model\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"variant\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"agent\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"auto_approve\": {\n      \"description\": \"Auto-approve agent actions\",\n      \"type\": \"boolean\",\n      \"default\": true\n    },\n    \"auto_compact\": {\n      \"description\": \"Enable auto-compaction when the context length approaches the model's context window limit\",\n      \"type\": \"boolean\",\n      \"default\": true\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/schemas/qwen_code.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"properties\": {\n    \"append_prompt\": {\n      \"title\": \"Append Prompt\",\n      \"description\": \"Extra text appended to the prompt\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"format\": \"textarea\",\n      \"default\": null\n    },\n    \"model\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"agent\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"yolo\": {\n      \"type\": [\n        \"boolean\",\n        \"null\"\n      ]\n    },\n    \"base_command_override\": {\n      \"title\": \"Base Command Override\",\n      \"description\": \"Override the base command with a custom command\",\n      \"type\": [\n        \"string\",\n        \"null\"\n      ]\n    },\n    \"additional_params\": {\n      \"title\": \"Additional Parameters\",\n      \"description\": \"Additional parameters to append to the base command\",\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Environment variables to set when running the executor\",\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "shared/types.ts",
    "content": "// This file was generated by `crates/core/src/bin/generate_types.rs`.\n\n// Do not edit this file manually.\n\n// If you are an AI, and you absolutely have to edit this file, please confirm with the user first.\n\nexport type Repo = { id: string, path: string, name: string, display_name: string, setup_script: string | null, cleanup_script: string | null, archive_script: string | null, copy_files: string | null, parallel_setup_script: boolean, dev_server_script: string | null, default_target_branch: string | null, default_working_dir: string | null, created_at: Date, updated_at: Date, };\n\nexport type Project = { id: string, name: string, default_agent_working_dir: string | null, remote_project_id: string | null, created_at: Date, updated_at: Date, };\n\nexport type UpdateRepo = { display_name?: string | null, setup_script?: string | null, cleanup_script?: string | null, archive_script?: string | null, copy_files?: string | null, parallel_setup_script?: boolean | null, dev_server_script?: string | null, default_target_branch?: string | null, default_working_dir?: string | null, };\n\nexport type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, \n/**\n * Ranking score based on git history (higher = more recently/frequently edited)\n */\nscore: bigint, };\n\nexport type SearchMatchType = \"FileName\" | \"DirectoryName\" | \"FullPath\";\n\nexport type WorkspaceRepo = { id: string, workspace_id: string, repo_id: string, target_branch: string, created_at: Date, updated_at: Date, };\n\nexport type CreateWorkspaceRepo = { repo_id: string, target_branch: string, };\n\nexport type RepoWithTargetBranch = { target_branch: string, id: string, path: string, name: string, display_name: string, setup_script: string | null, cleanup_script: string | null, archive_script: string | null, copy_files: string | null, parallel_setup_script: boolean, dev_server_script: string | null, default_target_branch: string | null, default_working_dir: string | null, created_at: Date, updated_at: Date, };\n\nexport type Tag = { id: string, tag_name: string, content: string, created_at: string, updated_at: string, };\n\nexport type CreateTag = { tag_name: string, content: string, };\n\nexport type UpdateTag = { tag_name: string | null, content: string | null, };\n\nexport type DraftFollowUpData = { message: string, executor_config: ExecutorConfig, };\n\nexport type DraftWorkspaceData = { message: string, repos: Array<DraftWorkspaceRepo>, executor_config: ExecutorConfig | null, linked_issue: DraftWorkspaceLinkedIssue | null, attachments: Array<DraftWorkspaceAttachment>, };\n\nexport type DraftWorkspaceAttachment = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, };\n\nexport type DraftWorkspaceLinkedIssue = { issue_id: string, simple_id: string, title: string, remote_project_id: string, };\n\nexport type DraftWorkspaceRepo = { repo_id: string, target_branch: string, };\n\nexport type DraftIssueData = { title: string, description: string | null, status_id: string, \n/**\n * Stored as the string value of IssuePriority (e.g. \"urgent\", \"high\", \"medium\", \"low\")\n */\npriority: string | null, assignee_ids: Array<string>, tag_ids: Array<string>, create_draft_workspace: boolean, \n/**\n * The project this draft belongs to\n */\nproject_id: string, \n/**\n * Parent issue ID if creating a sub-issue\n */\nparent_issue_id: string | null, };\n\nexport type PreviewSettingsData = { url: string, screen_size: string | null, responsive_width: number | null, responsive_height: number | null, };\n\nexport type WorkspaceNotesData = { content: string, };\n\nexport type WorkspacePanelStateData = { right_main_panel_mode: string | null, is_left_main_panel_visible: boolean, };\n\nexport type WorkspacePrFilterData = \"all\" | \"has_pr\" | \"no_pr\";\n\nexport type WorkspaceSortByData = \"updated_at\" | \"created_at\";\n\nexport type WorkspaceSortOrderData = \"asc\" | \"desc\";\n\nexport type WorkspaceFilterStateData = { project_ids: Array<string>, pr_filter: WorkspacePrFilterData, };\n\nexport type WorkspaceSortStateData = { sort_by: WorkspaceSortByData, sort_order: WorkspaceSortOrderData, };\n\nexport type UiPreferencesData = { \n/**\n * Preferred repo actions per repo\n */\nrepo_actions: { [key in string]?: string }, \n/**\n * Expanded/collapsed state for UI sections\n */\nexpanded: { [key in string]?: boolean }, \n/**\n * Context bar position\n */\ncontext_bar_position: string | null, \n/**\n * Pane sizes\n */\npane_sizes: { [key in string]?: JsonValue }, \n/**\n * Collapsed paths per workspace in file tree\n */\ncollapsed_paths: { [key in string]?: Array<string> }, \n/**\n * Preferred file-search repo\n */\nfile_search_repo_id: string | null, \n/**\n * Global left sidebar visibility\n */\nis_left_sidebar_visible: boolean | null, \n/**\n * Global right sidebar visibility\n */\nis_right_sidebar_visible: boolean | null, \n/**\n * Global terminal visibility\n */\nis_terminal_visible: boolean | null, \n/**\n * Workspace-specific panel states\n */\nworkspace_panel_states: { [key in string]?: WorkspacePanelStateData }, \n/**\n * Workspace sidebar filter preferences\n */\nworkspace_filters: WorkspaceFilterStateData, \n/**\n * Workspace sidebar sort preferences\n */\nworkspace_sort: WorkspaceSortStateData, \n/**\n * Last selected organization ID\n */\nselected_org_id: string | null, \n/**\n * Last selected project ID\n */\nselected_project_id: string | null, \n/**\n * Default setting for creating a draft workspace from new issues\n */\ncreate_draft_workspace_by_default: boolean | null, };\n\nexport type ProjectRepoDefaultsData = { repos: Array<DraftWorkspaceRepo>, };\n\nexport type ScratchPayload = { \"type\": \"DRAFT_TASK\", \"data\": string } | { \"type\": \"DRAFT_FOLLOW_UP\", \"data\": DraftFollowUpData } | { \"type\": \"DRAFT_WORKSPACE\", \"data\": DraftWorkspaceData } | { \"type\": \"DRAFT_ISSUE\", \"data\": DraftIssueData } | { \"type\": \"PREVIEW_SETTINGS\", \"data\": PreviewSettingsData } | { \"type\": \"WORKSPACE_NOTES\", \"data\": WorkspaceNotesData } | { \"type\": \"UI_PREFERENCES\", \"data\": UiPreferencesData } | { \"type\": \"PROJECT_REPO_DEFAULTS\", \"data\": ProjectRepoDefaultsData };\n\nexport enum ScratchType { DRAFT_TASK = \"DRAFT_TASK\", DRAFT_FOLLOW_UP = \"DRAFT_FOLLOW_UP\", DRAFT_WORKSPACE = \"DRAFT_WORKSPACE\", DRAFT_ISSUE = \"DRAFT_ISSUE\", PREVIEW_SETTINGS = \"PREVIEW_SETTINGS\", WORKSPACE_NOTES = \"WORKSPACE_NOTES\", UI_PREFERENCES = \"UI_PREFERENCES\", PROJECT_REPO_DEFAULTS = \"PROJECT_REPO_DEFAULTS\" }\n\nexport type Scratch = { id: string, payload: ScratchPayload, created_at: string, updated_at: string, };\n\nexport type CreateScratch = { payload: ScratchPayload, };\n\nexport type UpdateScratch = { payload: ScratchPayload, };\n\nexport type Workspace = { id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, };\n\nexport type WorkspaceWithStatus = { is_running: boolean, is_errored: boolean, id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, };\n\nexport type Session = { id: string, workspace_id: string, name: string | null, executor: string | null, agent_working_dir: string | null, created_at: string, updated_at: string, };\n\nexport type ExecutionProcess = { id: string, session_id: string, run_reason: ExecutionProcessRunReason, executor_action: ExecutorAction, status: ExecutionProcessStatus, exit_code: bigint | null, \n/**\n * dropped: true if this process is excluded from the current\n * history view (due to restore/trimming). Hidden from logs/timeline;\n * still listed in the Processes tab.\n */\ndropped: boolean, started_at: string, completed_at: string | null, created_at: string, updated_at: string, };\n\nexport enum ExecutionProcessStatus { running = \"running\", completed = \"completed\", failed = \"failed\", killed = \"killed\" }\n\nexport type ExecutionProcessRunReason = \"setupscript\" | \"cleanupscript\" | \"archivescript\" | \"codingagent\" | \"devserver\";\n\nexport type ExecutionProcessRepoState = { id: string, execution_process_id: string, repo_id: string, before_head_commit: string | null, after_head_commit: string | null, merge_commit: string | null, created_at: Date, updated_at: Date, };\n\nexport type Merge = { \"type\": \"direct\" } & DirectMerge | { \"type\": \"pr\" } & PrMerge;\n\nexport type DirectMerge = { id: string, workspace_id: string, repo_id: string, merge_commit: string, target_branch_name: string, created_at: string, };\n\nexport type PrMerge = { id: string, workspace_id: string, repo_id: string, created_at: string, target_branch_name: string, pr_info: PullRequestInfo, };\n\nexport type MergeStatus = \"open\" | \"merged\" | \"closed\" | \"unknown\";\n\nexport type PullRequestInfo = { number: bigint, url: string, status: MergeStatus, merged_at: string | null, merge_commit_sha: string | null, };\n\nexport type ApprovalInfo = { approval_id: string, tool_name: string, execution_process_id: string, is_question: boolean, created_at: string, timeout_at: string, };\n\nexport type ApprovalStatus = { \"status\": \"pending\" } | { \"status\": \"approved\" } | { \"status\": \"denied\", reason?: string, } | { \"status\": \"timed_out\" };\n\nexport type QuestionAnswer = { question: string, answer: Array<string>, };\n\nexport type QuestionStatus = { \"status\": \"answered\", answers: Array<QuestionAnswer>, } | { \"status\": \"timed_out\" };\n\nexport type ApprovalOutcome = { \"status\": \"approved\" } | { \"status\": \"denied\", reason?: string, } | { \"status\": \"answered\", answers: Array<QuestionAnswer>, } | { \"status\": \"timed_out\" };\n\nexport type ApprovalResponse = { execution_process_id: string, status: ApprovalOutcome, };\n\nexport type Diff = { change: DiffChangeKind, oldPath: string | null, newPath: string | null, oldContent: string | null, newContent: string | null, \n/**\n * True when file contents are intentionally omitted (e.g., too large)\n */\ncontentOmitted: boolean, \n/**\n * Optional precomputed stats for omitted content\n */\nadditions: number | null, deletions: number | null, repoId: string | null, };\n\nexport type DiffChangeKind = \"added\" | \"deleted\" | \"modified\" | \"renamed\" | \"copied\" | \"permissionChange\";\n\nexport type ApiResponse<T, E = T> = { success: boolean, data: T | null, error_data: E | null, message: string | null, };\n\nexport type LoginStatus = { \"status\": \"loggedout\" } | { \"status\": \"loggedin\", profile: ProfileResponse, };\n\nexport type ProfileResponse = { user_id: string, username: string | null, email: string, providers: Array<ProviderProfile>, };\n\nexport type ProviderProfile = { provider: string, username: string | null, display_name: string | null, email: string | null, avatar_url: string | null, };\n\nexport type StatusResponse = { logged_in: boolean, profile: ProfileResponse | null, degraded: boolean | null, };\n\nexport enum MemberRole { ADMIN = \"ADMIN\", MEMBER = \"MEMBER\" }\n\nexport enum InvitationStatus { PENDING = \"PENDING\", ACCEPTED = \"ACCEPTED\", DECLINED = \"DECLINED\", EXPIRED = \"EXPIRED\" }\n\nexport type Organization = { id: string, name: string, slug: string, is_personal: boolean, issue_prefix: string, created_at: string, updated_at: string, };\n\nexport type OrganizationWithRole = { id: string, name: string, slug: string, is_personal: boolean, issue_prefix: string, created_at: string, updated_at: string, user_role: MemberRole, };\n\nexport type ListOrganizationsResponse = { organizations: Array<OrganizationWithRole>, };\n\nexport type GetOrganizationResponse = { organization: Organization, user_role: string, };\n\nexport type CreateOrganizationRequest = { name: string, slug: string, };\n\nexport type CreateOrganizationResponse = { organization: OrganizationWithRole, };\n\nexport type UpdateOrganizationRequest = { name: string, };\n\nexport type Invitation = { id: string, organization_id: string, invited_by_user_id: string | null, email: string, role: MemberRole, status: InvitationStatus, token: string, created_at: string, expires_at: string, };\n\nexport type CreateInvitationRequest = { email: string, role: MemberRole, };\n\nexport type CreateInvitationResponse = { invitation: Invitation, };\n\nexport type ListInvitationsResponse = { invitations: Array<Invitation>, };\n\nexport type GetInvitationResponse = { id: string, organization_slug: string, role: MemberRole, expires_at: string, };\n\nexport type AcceptInvitationResponse = { organization_id: string, organization_slug: string, role: MemberRole, };\n\nexport type RevokeInvitationRequest = { invitation_id: string, };\n\nexport type OrganizationMemberInfo = { user_id: string, role: MemberRole, joined_at: string, };\n\nexport type OrganizationMemberWithProfile = { user_id: string, role: MemberRole, joined_at: string, first_name: string | null, last_name: string | null, username: string | null, email: string | null, avatar_url: string | null, };\n\nexport type ListMembersResponse = { members: Array<OrganizationMemberWithProfile>, };\n\nexport type UpdateMemberRoleRequest = { role: MemberRole, };\n\nexport type UpdateMemberRoleResponse = { user_id: string, role: MemberRole, };\n\nexport type MigrationRequest = { organization_id: string, \n/**\n * List of local project IDs to migrate.\n */\nproject_ids: Array<string>, };\n\nexport type MigrationResponse = { report: MigrationReport, };\n\nexport type MigrationReport = { projects: EntityReport, tasks: EntityReport, pr_merges: EntityReport, workspaces: EntityReport, warnings: Array<string>, };\n\nexport type EntityReport = { total: number, migrated: number, failed: number, skipped: number, errors: Array<EntityError>, };\n\nexport type EntityError = { local_id: string, error: string, };\n\nexport type RegisterRepoRequest = { path: string, display_name: string | null, };\n\nexport type InitRepoRequest = { parent_path: string, folder_name: string, };\n\nexport type TagSearchParams = { search: string | null, };\n\nexport type TokenResponse = { access_token: string, expires_at: string | null, };\n\nexport type UserSystemInfo = { version: string, config: Config, analytics_user_id: string, login_status: LoginStatus, environment: Environment, \n/**\n * Capabilities supported per executor (e.g., { \"CLAUDE_CODE\": [\"SESSION_FORK\"] })\n */\ncapabilities: { [key in string]?: Array<BaseAgentCapability> }, shared_api_base: string | null, preview_proxy_port: number | null, executors: { [key in BaseCodingAgent]?: ExecutorProfile }, };\n\nexport type Environment = { os_type: string, os_version: string, os_architecture: string, bitness: string, };\n\nexport type McpServerQuery = { executor: BaseCodingAgent, };\n\nexport type UpdateMcpServersBody = { servers: { [key in string]?: JsonValue }, };\n\nexport type GetMcpServerResponse = { mcp_config: McpConfig, config_path: string, };\n\nexport type CheckEditorAvailabilityQuery = { editor_type: EditorType, };\n\nexport type CheckEditorAvailabilityResponse = { available: boolean, };\n\nexport type CheckAgentAvailabilityQuery = { executor: BaseCodingAgent, };\n\nexport type AgentPresetOptionsQuery = { executor: BaseCodingAgent, variant: string | null, };\n\nexport type CurrentUserResponse = { user_id: string, };\n\nexport type StartSpake2EnrollmentRequest = { enrollment_code: string, client_message_b64: string, };\n\nexport type FinishSpake2EnrollmentRequest = { enrollment_id: string, client_id: string, client_name: string, client_browser: string, client_os: string, client_device: string, public_key_b64: string, client_proof_b64: string, };\n\nexport type StartSpake2EnrollmentResponse = { enrollment_id: string, server_message_b64: string, };\n\nexport type FinishSpake2EnrollmentResponse = { signing_session_id: string, server_public_key_b64: string, server_proof_b64: string, };\n\nexport type RelayPairedClient = { client_id: string, client_name: string, client_browser: string, client_os: string, client_device: string, };\n\nexport type ListRelayPairedClientsResponse = { clients: Array<RelayPairedClient>, };\n\nexport type RemoveRelayPairedClientResponse = { removed: boolean, };\n\nexport type RefreshRelaySigningSessionRequest = { client_id: string, timestamp: bigint, nonce: string, signature_b64: string, };\n\nexport type RefreshRelaySigningSessionResponse = { signing_session_id: string, };\n\nexport type CreateFollowUpAttempt = { prompt: string, executor_config: ExecutorConfig, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, };\n\nexport type ResetProcessRequest = { process_id: string, force_when_dirty: boolean | null, perform_git_reset: boolean | null, };\n\nexport type ChangeTargetBranchRequest = { repo_id: string, new_target_branch: string, };\n\nexport type ChangeTargetBranchResponse = { repo_id: string, new_target_branch: string, status: [number, number], };\n\nexport type AddWorkspaceRepoRequest = { repo_id: string, target_branch: string, };\n\nexport type AddWorkspaceRepoResponse = { workspace: Workspace, repo: RepoWithTargetBranch, };\n\nexport type MergeWorkspaceRequest = { repo_id: string, };\n\nexport type PushWorkspaceRequest = { repo_id: string, };\n\nexport type RenameBranchRequest = { new_branch_name: string, };\n\nexport type RenameBranchResponse = { branch: string, };\n\nexport type StartReviewRequest = { executor_config: ExecutorConfig, additional_prompt: string | null, use_all_workspace_commits: boolean, };\n\nexport type ReviewError = { \"type\": \"process_already_running\" };\n\nexport type OpenEditorRequest = { editor_type: string | null, file_path: string | null, };\n\nexport type OpenEditorResponse = { url: string | null, };\n\nexport type LinkedIssueInfo = { remote_project_id: string, issue_id: string, };\n\nexport type CreatePrApiRequest = { title: string, body: string | null, target_branch: string | null, draft: boolean | null, repo_id: string, auto_generate_description: boolean, };\n\nexport type AttachmentResponse = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, };\n\nexport type AttachmentMetadata = { exists: boolean, file_name: string | null, path: string | null, size_bytes: bigint | null, format: string | null, proxy_url: string | null, };\n\nexport type WorkspaceRepoInput = { repo_id: string, target_branch: string, };\n\nexport type RunAgentSetupRequest = { executor_profile_id: ExecutorProfileId, };\n\nexport type RunAgentSetupResponse = Record<string, never>;\n\nexport type GhCliSetupError = \"BREW_MISSING\" | \"SETUP_HELPER_NOT_SUPPORTED\" | { \"OTHER\": { message: string, } };\n\nexport type RebaseWorkspaceRequest = { repo_id: string, old_base_branch: string | null, new_base_branch: string | null, };\n\nexport type ContinueRebaseRequest = { repo_id: string, };\n\nexport type AbortConflictsRequest = { repo_id: string, };\n\nexport type GitOperationError = { \"type\": \"merge_conflicts\", message: string, op: ConflictOp, conflicted_files: Array<string>, target_branch: string, } | { \"type\": \"rebase_in_progress\" };\n\nexport type PushError = { \"type\": \"force_push_required\" };\n\nexport type PrError = { \"type\": \"cli_not_installed\", provider: ProviderKind, } | { \"type\": \"cli_not_logged_in\", provider: ProviderKind, } | { \"type\": \"git_cli_not_logged_in\" } | { \"type\": \"git_cli_not_installed\" } | { \"type\": \"target_branch_not_found\", branch: string, } | { \"type\": \"unsupported_provider\" };\n\nexport type RunScriptError = { \"type\": \"no_script_configured\" } | { \"type\": \"process_already_running\" };\n\nexport type AssociateWorkspaceAttachmentsRequest = { attachment_ids: Array<string>, };\n\nexport type ImportIssueAttachmentsRequest = { issue_id: string, };\n\nexport type ImportIssueAttachmentsResponse = { attachment_ids: Array<string>, };\n\nexport type AttachPrResponse = { pr_attached: boolean, pr_url: string | null, pr_number: bigint | null, pr_status: MergeStatus | null, };\n\nexport type AttachExistingPrRequest = { repo_id: string, };\n\nexport type PrCommentsResponse = { comments: Array<UnifiedPrComment>, };\n\nexport type GetPrCommentsError = { \"type\": \"no_pr_attached\" } | { \"type\": \"cli_not_installed\", provider: ProviderKind, } | { \"type\": \"cli_not_logged_in\", provider: ProviderKind, };\n\nexport type GetPrCommentsQuery = { repo_id: string, };\n\nexport type CreateAndStartWorkspaceRequest = { name: string | null, repos: Array<WorkspaceRepoInput>, linked_issue: LinkedIssueInfo | null, executor_config: ExecutorConfig, prompt: string, attachment_ids: Array<string> | null, };\n\nexport type CreateAndStartWorkspaceResponse = { workspace: Workspace, execution_process: ExecutionProcess, };\n\nexport type UnifiedPrComment = { \"comment_type\": \"general\", id: string, author: string, author_association: string | null, body: string, created_at: string, url: string | null, } | { \"comment_type\": \"review\", id: bigint, author: string, author_association: string | null, body: string, created_at: string, url: string | null, path: string, line: bigint | null, side: string | null, diff_hunk: string | null, };\n\nexport type ProviderKind = \"git_hub\" | \"azure_dev_ops\" | \"unknown\";\n\nexport type OpenPrInfo = { number: bigint, url: string, title: string, head_branch: string, base_branch: string, };\n\nexport type GitRemote = { name: string, url: string, };\n\nexport type ListPrsError = { \"type\": \"cli_not_installed\", provider: ProviderKind, } | { \"type\": \"auth_failed\", message: string, } | { \"type\": \"unsupported_provider\" };\n\nexport type CreateWorkspaceFromPrBody = { repo_id: string, pr_number: bigint, pr_title: string, pr_url: string, head_branch: string, base_branch: string, run_setup: boolean, remote_name: string | null, };\n\nexport type CreateWorkspaceFromPrResponse = { workspace: Workspace, };\n\nexport type CreateFromPrError = { \"type\": \"pr_not_found\" } | { \"type\": \"branch_fetch_failed\", message: string, } | { \"type\": \"cli_not_installed\", provider: ProviderKind, } | { \"type\": \"auth_failed\", message: string, } | { \"type\": \"unsupported_provider\" };\n\nexport type RepoBranchStatus = { repo_id: string, repo_name: string, commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array<Merge>, is_rebase_in_progress: boolean, conflict_op: ConflictOp | null, conflicted_files: Array<string>, is_target_remote: boolean, };\n\nexport type UpdateWorkspace = { archived: boolean | null, pinned: boolean | null, name: string | null, };\n\nexport type UpdateSession = { name: string | null, };\n\nexport type WorkspaceSummaryRequest = { archived: boolean, };\n\nexport type WorkspaceSummary = { workspace_id: string, \n/**\n * Session ID of the latest execution process\n */\nlatest_session_id: string | null, \n/**\n * Is a tool approval currently pending?\n */\nhas_pending_approval: boolean, \n/**\n * Number of files with changes\n */\nfiles_changed: number | null, \n/**\n * Total lines added across all files\n */\nlines_added: number | null, \n/**\n * Total lines removed across all files\n */\nlines_removed: number | null, \n/**\n * When the latest execution process completed\n */\nlatest_process_completed_at?: string, \n/**\n * Status of the latest execution process\n */\nlatest_process_status: ExecutionProcessStatus | null, \n/**\n * Is a dev server currently running?\n */\nhas_running_dev_server: boolean, \n/**\n * Does this workspace have unseen coding agent turns?\n */\nhas_unseen_turns: boolean, \n/**\n * PR status for this workspace (if any PR exists)\n */\npr_status: MergeStatus | null, \n/**\n * PR number for this workspace (if any PR exists)\n */\npr_number: bigint | null, \n/**\n * PR URL for this workspace (if any PR exists)\n */\npr_url: string | null, };\n\nexport type WorkspaceSummaryResponse = { summaries: Array<WorkspaceSummary>, };\n\nexport type DiffStats = { files_changed: number, lines_added: number, lines_removed: number, };\n\nexport type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, last_modified: bigint | null, };\n\nexport type DirectoryListResponse = { entries: Array<DirectoryEntry>, current_path: string, };\n\nexport type SearchMode = \"taskform\" | \"settings\";\n\nexport type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, remote_onboarding_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, pr_auto_description_enabled: boolean, pr_auto_description_prompt: string | null, commit_reminder_enabled: boolean, commit_reminder_prompt: string | null, send_message_shortcut: SendMessageShortcut, relay_enabled: boolean, relay_host_name: string | null, };\n\nexport type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, };\n\nexport enum ThemeMode { LIGHT = \"LIGHT\", DARK = \"DARK\", SYSTEM = \"SYSTEM\" }\n\nexport type EditorConfig = { editor_type: EditorType, custom_command: string | null, remote_ssh_host: string | null, remote_ssh_user: string | null, auto_install_extension: boolean, };\n\nexport enum EditorType { VS_CODE = \"VS_CODE\", VS_CODE_INSIDERS = \"VS_CODE_INSIDERS\", CURSOR = \"CURSOR\", WINDSURF = \"WINDSURF\", INTELLI_J = \"INTELLI_J\", ZED = \"ZED\", XCODE = \"XCODE\", GOOGLE_ANTIGRAVITY = \"GOOGLE_ANTIGRAVITY\", CUSTOM = \"CUSTOM\" }\n\nexport type EditorOpenError = { \"type\": \"executable_not_found\", executable: string, editor_type: EditorType, } | { \"type\": \"invalid_command\", details: string, editor_type: EditorType, } | { \"type\": \"launch_failed\", executable: string, details: string, editor_type: EditorType, };\n\nexport type GitHubConfig = { pat: string | null, oauth_token: string | null, username: string | null, primary_email: string | null, default_pr_base: string | null, };\n\nexport enum SoundFile { ABSTRACT_SOUND1 = \"ABSTRACT_SOUND1\", ABSTRACT_SOUND2 = \"ABSTRACT_SOUND2\", ABSTRACT_SOUND3 = \"ABSTRACT_SOUND3\", ABSTRACT_SOUND4 = \"ABSTRACT_SOUND4\", COW_MOOING = \"COW_MOOING\", FAHHHHH = \"FAHHHHH\", PHONE_VIBRATION = \"PHONE_VIBRATION\", ROOSTER = \"ROOSTER\" }\n\nexport type UiLanguage = \"BROWSER\" | \"EN\" | \"FR\" | \"JA\" | \"ES\" | \"KO\" | \"ZH_HANS\" | \"ZH_HANT\";\n\nexport type ShowcaseState = { seen_features: Array<string>, };\n\nexport type SendMessageShortcut = \"ModifierEnter\" | \"Enter\";\n\nexport type GitBranch = { name: string, is_current: boolean, is_remote: boolean, last_commit_date: Date, };\n\nexport type QueuedMessage = { \n/**\n * The session this message is queued for\n */\nsession_id: string, \n/**\n * The follow-up data (message + variant)\n */\ndata: DraftFollowUpData, \n/**\n * Timestamp when the message was queued\n */\nqueued_at: string, };\n\nexport type QueueStatus = { \"status\": \"empty\" } | { \"status\": \"queued\", message: QueuedMessage, };\n\nexport type ConflictOp = \"rebase\" | \"merge\" | \"cherry_pick\" | \"revert\";\n\nexport type ExecutorAction = { typ: ExecutorActionType, next_action: ExecutorAction | null, };\n\nexport type McpConfig = { servers: { [key in string]?: JsonValue }, servers_path: Array<string>, template: JsonValue, preconfigured: JsonValue, is_toml_config: boolean, };\n\nexport type ExecutorActionType = { \"type\": \"CodingAgentInitialRequest\" } & CodingAgentInitialRequest | { \"type\": \"CodingAgentFollowUpRequest\" } & CodingAgentFollowUpRequest | { \"type\": \"ScriptRequest\" } & ScriptRequest | { \"type\": \"ReviewRequest\" } & ReviewRequest;\n\nexport type ExecutorConfig = { \n/**\n * The executor type (e.g., CLAUDE_CODE, AMP)\n */\nexecutor: BaseCodingAgent, \n/**\n * Optional variant/preset name (e.g., \"PLAN\", \"ROUTER\")\n */\nvariant?: string | null, \n/**\n * Model override (e.g., \"anthropic/claude-sonnet-4-20250514\")\n */\nmodel_id?: string | null, \n/**\n * Agent mode override\n */\nagent_id?: string | null, \n/**\n * Reasoning effort override (e.g., \"high\", \"medium\")\n */\nreasoning_id?: string | null, \n/**\n * Permission policy override\n */\npermission_policy?: PermissionPolicy | null, };\n\nexport type ScriptContext = \"SetupScript\" | \"CleanupScript\" | \"ArchiveScript\" | \"DevServer\" | \"ToolInstallScript\";\n\nexport type ScriptRequest = { script: string, language: ScriptRequestLanguage, context: ScriptContext, \n/**\n * Optional relative path to execute the script in (relative to container_ref).\n * If None, uses the container_ref directory directly.\n */\nworking_dir: string | null, };\n\nexport type ScriptRequestLanguage = \"Bash\";\n\nexport enum BaseCodingAgent { CLAUDE_CODE = \"CLAUDE_CODE\", AMP = \"AMP\", GEMINI = \"GEMINI\", CODEX = \"CODEX\", OPENCODE = \"OPENCODE\", CURSOR_AGENT = \"CURSOR_AGENT\", QWEN_CODE = \"QWEN_CODE\", COPILOT = \"COPILOT\", DROID = \"DROID\" }\n\nexport type CodingAgent = { \"CLAUDE_CODE\": ClaudeCode } | { \"AMP\": Amp } | { \"GEMINI\": Gemini } | { \"CODEX\": Codex } | { \"OPENCODE\": Opencode } | { \"CURSOR_AGENT\": CursorAgent } | { \"QWEN_CODE\": QwenCode } | { \"COPILOT\": Copilot } | { \"DROID\": Droid };\n\nexport type SlashCommandDescription = { \n/**\n * Command name without the leading slash, e.g. `help` for `/help`.\n */\nname: string, description?: string | null, };\n\nexport type AvailabilityInfo = { \"type\": \"LOGIN_DETECTED\", last_auth_timestamp: bigint, } | { \"type\": \"INSTALLATION_FOUND\" } | { \"type\": \"NOT_FOUND\" };\n\nexport type CommandBuilder = { \n/**\n * Base executable command (e.g., \"npx -y @anthropic-ai/claude-code@latest\")\n */\nbase: string, \n/**\n * Optional parameters to append to the base command\n */\nparams: Array<string> | null, };\n\nexport type ExecutorProfileId = { \n/**\n * The executor type (e.g., \"CLAUDE_CODE\", \"AMP\")\n */\nexecutor: BaseCodingAgent, \n/**\n * Optional variant name (e.g., \"PLAN\", \"ROUTER\")\n */\nvariant: string | null, };\n\nexport type ExecutorRecentModels = { \n/**\n * Ordered list of recently used model keys (most recent last).\n */\nmodels?: Array<string>, \n/**\n * Last-used reasoning effort per model\n */\nreasoning_by_model?: { [key in string]?: string }, };\n\nexport type ExecutorProfile = { recently_used_models?: ExecutorRecentModels | null, } & ({ [key in string]?: { \"CLAUDE_CODE\": ClaudeCode } | { \"AMP\": Amp } | { \"GEMINI\": Gemini } | { \"CODEX\": Codex } | { \"OPENCODE\": Opencode } | { \"CURSOR_AGENT\": CursorAgent } | { \"QWEN_CODE\": QwenCode } | { \"COPILOT\": Copilot } | { \"DROID\": Droid } });\n\nexport type ExecutorConfigs = { executors: { [key in BaseCodingAgent]?: ExecutorProfile }, };\n\nexport enum BaseAgentCapability { SESSION_FORK = \"SESSION_FORK\", SETUP_HELPER = \"SETUP_HELPER\", CONTEXT_USAGE = \"CONTEXT_USAGE\" }\n\nexport type ClaudeEffort = \"low\" | \"medium\" | \"high\" | \"max\";\n\nexport type ClaudeCode = { append_prompt: AppendPrompt, claude_code_router?: boolean | null, plan?: boolean | null, approvals?: boolean | null, model?: string | null, effort?: ClaudeEffort | null, agent?: string | null, dangerously_skip_permissions?: boolean | null, disable_api_key?: boolean | null, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type Gemini = { append_prompt: AppendPrompt, model?: string | null, yolo?: boolean | null, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type Amp = { append_prompt: AppendPrompt, dangerously_allow_all?: boolean | null, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type Codex = { append_prompt: AppendPrompt, sandbox?: SandboxMode | null, ask_for_approval?: AskForApproval | null, oss?: boolean | null, model?: string | null, model_reasoning_effort?: ReasoningEffort | null, model_reasoning_summary?: ReasoningSummary | null, model_reasoning_summary_format?: ReasoningSummaryFormat | null, profile?: string | null, base_instructions?: string | null, include_apply_patch_tool?: boolean | null, model_provider?: string | null, compact_prompt?: string | null, developer_instructions?: string | null, plan: boolean, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type SandboxMode = \"auto\" | \"read-only\" | \"workspace-write\" | \"danger-full-access\";\n\nexport type AskForApproval = \"unless-trusted\" | \"on-failure\" | \"on-request\" | \"never\";\n\nexport type ReasoningEffort = \"low\" | \"medium\" | \"high\" | \"xhigh\";\n\nexport type ReasoningSummary = \"auto\" | \"concise\" | \"detailed\" | \"none\";\n\nexport type ReasoningSummaryFormat = \"none\" | \"experimental\";\n\nexport type CursorAgent = { append_prompt: AppendPrompt, force?: boolean | null, model?: string | null, reasoning?: string | null, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type Copilot = { append_prompt: AppendPrompt, model?: string | null, allow_all_tools?: boolean | null, allow_tool?: string | null, deny_tool?: string | null, add_dir?: Array<string> | null, disable_mcp_server?: Array<string> | null, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type Opencode = { append_prompt: AppendPrompt, model?: string | null, variant?: string | null, agent?: string | null, \n/**\n * Auto-approve agent actions\n */\nauto_approve: boolean, \n/**\n * Enable auto-compaction when the context length approaches the model's context window limit\n */\nauto_compact: boolean, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type QwenCode = { append_prompt: AppendPrompt, model?: string | null, agent?: string | null, yolo?: boolean | null, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type Droid = { append_prompt: AppendPrompt, autonomy: Autonomy, model?: string | null, reasoning_effort?: DroidReasoningEffort | null, base_command_override?: string | null, additional_params?: Array<string> | null, env?: { [key in string]?: string } | null, };\n\nexport type Autonomy = \"normal\" | \"low\" | \"medium\" | \"high\" | \"skip-permissions-unsafe\";\n\nexport type DroidReasoningEffort = \"none\" | \"dynamic\" | \"off\" | \"low\" | \"medium\" | \"high\";\n\nexport type AppendPrompt = string | null;\n\nexport type CodingAgentInitialRequest = { prompt: string, \n/**\n * Unified executor identity + overrides\n */\nexecutor_config: ExecutorConfig, \n/**\n * Optional relative path to execute the agent in (relative to container_ref).\n * If None, uses the container_ref directory directly.\n */\nworking_dir: string | null, };\n\nexport type CodingAgentFollowUpRequest = { prompt: string, session_id: string, reset_to_message_id: string | null, \n/**\n * Unified executor identity + overrides\n */\nexecutor_config: ExecutorConfig, \n/**\n * Optional relative path to execute the agent in (relative to container_ref).\n * If None, uses the container_ref directory directly.\n */\nworking_dir: string | null, };\n\nexport type ReviewRequest = { \n/**\n * Unified executor identity + overrides\n */\nexecutor_config: ExecutorConfig, context: Array<RepoReviewContext> | null, prompt: string, \n/**\n * Optional session ID to resume an existing session\n */\nsession_id: string | null, \n/**\n * Optional relative path to execute the agent in (relative to container_ref).\n */\nworking_dir: string | null, };\n\nexport type RepoReviewContext = { repo_id: string, repo_name: string, base_commit: string, };\n\nexport type CommandExitStatus = { \"type\": \"exit_code\", code: number, } | { \"type\": \"success\", success: boolean, };\n\nexport type CommandRunResult = { exit_status: CommandExitStatus | null, output: string | null, };\n\nexport type CommandCategory = \"read\" | \"search\" | \"edit\" | \"fetch\" | \"other\";\n\nexport type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };\n\nexport type NormalizedEntryType = { \"type\": \"user_message\" } | { \"type\": \"user_feedback\", denied_tool: string, } | { \"type\": \"assistant_message\" } | { \"type\": \"tool_use\", tool_name: string, action_type: ActionType, status: ToolStatus, } | { \"type\": \"system_message\" } | { \"type\": \"error_message\", error_type: NormalizedEntryError, } | { \"type\": \"thinking\" } | { \"type\": \"loading\" } | { \"type\": \"next_action\", failed: boolean, execution_processes: number, needs_setup: boolean, } | { \"type\": \"token_usage_info\" } & TokenUsageInfo | { \"type\": \"user_answered_questions\", answers: Array<AnsweredQuestion>, };\n\nexport type TokenUsageInfo = { total_tokens: number, model_context_window: number, };\n\nexport type FileChange = { \"action\": \"write\", content: string, } | { \"action\": \"delete\" } | { \"action\": \"rename\", new_path: string, } | { \"action\": \"edit\", \n/**\n * Unified diff containing file header and hunks.\n */\nunified_diff: string, \n/**\n * Whether line number in the hunks are reliable.\n */\nhas_line_numbers: boolean, };\n\nexport type ActionType = { \"action\": \"file_read\", path: string, } | { \"action\": \"file_edit\", path: string, changes: Array<FileChange>, } | { \"action\": \"command_run\", command: string, result: CommandRunResult | null, category: CommandCategory, } | { \"action\": \"search\", query: string, } | { \"action\": \"web_fetch\", url: string, } | { \"action\": \"tool\", tool_name: string, arguments: JsonValue | null, result: ToolResult | null, } | { \"action\": \"task_create\", description: string, subagent_type: string | null, result: ToolResult | null, } | { \"action\": \"plan_presentation\", plan: string, } | { \"action\": \"todo_management\", todos: Array<TodoItem>, operation: string, } | { \"action\": \"ask_user_question\", questions: Array<AskUserQuestionItem>, } | { \"action\": \"other\", description: string, };\n\nexport type AnsweredQuestion = { question: string, answer: Array<string>, };\n\nexport type AskUserQuestionItem = { question: string, header: string, options: Array<AskUserQuestionOption>, multiSelect: boolean, };\n\nexport type AskUserQuestionOption = { label: string, description: string, };\n\nexport type TodoItem = { content: string, status: string, priority: string | null, };\n\nexport type NormalizedEntryError = { \"type\": \"setup_required\" } | { \"type\": \"other\" };\n\nexport type ToolResult = { type: ToolResultValueType, \n/**\n * For Markdown, this will be a JSON string; for JSON, a structured value\n */\nvalue: JsonValue, };\n\nexport type ToolResultValueType = { \"type\": \"markdown\" } | { \"type\": \"json\" };\n\nexport type ToolStatus = { \"status\": \"created\" } | { \"status\": \"success\" } | { \"status\": \"failed\" } | { \"status\": \"denied\", reason: string | null, } | { \"status\": \"pending_approval\", approval_id: string, } | { \"status\": \"timed_out\" };\n\nexport type PatchType = { \"type\": \"NORMALIZED_ENTRY\", \"content\": NormalizedEntry } | { \"type\": \"STDOUT\", \"content\": string } | { \"type\": \"STDERR\", \"content\": string } | { \"type\": \"DIFF\", \"content\": Diff };\n\nexport type ModelInfo = { \n/**\n * Model identifier\n */\nid: string, \n/**\n * Display name\n */\nname: string, \n/**\n * Provider this model belongs to\n */\nprovider_id?: string | null, \n/**\n * Configurable reasoning options if supported\n */\nreasoning_options: Array<ReasoningOption>, };\n\nexport type ReasoningOption = { id: string, label: string, is_default: boolean, };\n\nexport type ModelProvider = { \n/**\n * Provider identifier\n */\nid: string, \n/**\n * Display name\n */\nname: string, };\n\nexport type AgentInfo = { id: string, label: string, description?: string | null, is_default: boolean, };\n\nexport enum PermissionPolicy { AUTO = \"AUTO\", SUPERVISED = \"SUPERVISED\", PLAN = \"PLAN\" }\n\nexport type ModelSelectorConfig = { \n/**\n * Available providers\n */\nproviders: Array<ModelProvider>, \n/**\n * Available models\n */\nmodels: Array<ModelInfo>, \n/**\n * Global default model (format: provider_id/model_id)\n */\ndefault_model?: string | null, \n/**\n * Available agents\n */\nagents: Array<AgentInfo>, \n/**\n * Supported permission policies\n */\npermissions: Array<PermissionPolicy>, };\n\nexport type ExecutorDiscoveredOptions = { model_selector: ModelSelectorConfig, slash_commands: Array<SlashCommandDescription>, loading_models: boolean, loading_agents: boolean, loading_slash_commands: boolean, error: string | null, };\n\nexport type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;\n\nexport const DEFAULT_PR_DESCRIPTION_PROMPT = \"Update the PR that was just created with a better title and description.\\nThe PR number is #{pr_number} and the URL is {pr_url}.\\n\\nAnalyze the changes in this branch and write:\\n1. A concise, descriptive title that summarizes the changes, postfixed with \\\"(Vibe Kanban)\\\"\\n2. A detailed description that explains:\\n   - What changes were made\\n   - Why they were made (based on the task context)\\n   - Any important implementation details\\n   - At the end, include a note: \\\"This PR was written using [Vibe Kanban](https://vibekanban.com)\\\"\\n\\nUse the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps).\";\n\nexport const DEFAULT_COMMIT_REMINDER_PROMPT = \"There are uncommitted changes. Please stage and commit them now with a descriptive commit message.\";"
  }
]